注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

我发现很多程序员都不会打日志。。

大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊! 前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,...
继续阅读 »

大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊!


前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,更有甚者,竟然表示:直接用 System.out.println() 打印一下吧。。。



要知道,日志是我们系统出现错误时,最快速有效的定位工具,没有日志给出的错误信息,遇到报错你就会一脸懵逼;而且日志还可以用来记录业务信息,比如记录用户执行的每个操作,不仅可以用于分析改进系统,同时在遇到非法操作时,也能很快找到凶手。


因此,对于程序员来说,日志记录是重要的基本功。但很多同学并没有系统学习过日志操作、缺乏经验,所以我写下这篇文章,分享自己在开发项目中记录日志的方法和最佳实践,希望对大家有帮助~


一、日志记录的方法


日志框架选型


有很多 Java 的日志框架和工具库,可以帮我们用一行代码快速完成日志记录。


在学习日志记录之前,很多同学应该是通过 System.out.println 输出信息来调试程序的,简单方便。


但是,System.out.println 存在很严重的问题!



首先,System.out.println 是一个同步方法,每次调用都会导致 I/O 操作,比较耗时,频繁使用甚至会严重影响应用程序的性能,所以不建议在生产环境使用。此外,它只能输出简单的信息到标准控制台,无法灵活设置日志级别、格式、输出位置等。


所以我们一般会选择专业的 Java 日志框架或工具库,比如经典的 Apache Log4j 和它的升级版 Log4j 2,还有 Spring Boot 默认集成的 Logback 库。不仅可以帮我们用一行代码更快地完成日志记录,还能灵活调整格式、设置日志级别、将日志写入到文件中、压缩日志等。


可能还有同学听说过 SLF4J(Simple Logging Facade for Java),看英文名就知道了,这玩意并不是一个具体的日志实现,而是为各种日志框架提供简单统一接口的日志门面(抽象层)。


啥是门面?


举个例子,现在我们要记录日志了,先联系到前台接待人员 SLF4J,它说必须要让我们选择日志的级别(debug / info / warn / error),然后要提供日志的内容。确认之后,SLF4J 自己不干活,屁颠屁颠儿地去找具体的日志实现框架,比如 Logback,然后由 Logback 进行日志写入。



这样做有什么好处呢?无论我们选择哪套日志框架、或者后期要切换日志框架,调用的方法始终是相同的,不用再去更改日志调用代码,比如将 log.info 改为 log.printInfo。


既然 SLF4J 只是玩抽象,那么 Log4j、Log4j 2 和 Logback 应该选择哪一个呢?



值得一提的是,SLF4J、Log4j 和 Logback 竟然都是同一个作者(俄罗斯程序员 Ceki Gülcü)。



首先,Log4j 已经停止维护,直接排除。Log4j 2 和 Logback 基本都能满足功能需求,那么就看性能、稳定性和易用性。



  • 从性能来说,Log4j 2 和 Logback 虽然都支持异步日志,但是 Log4j 基于 LMAX Disruptor 高性能异步处理库实现,性能更高。

  • 从稳定性来说,虽然这些日志库都被曝出过漏洞,但 Log4j 2 的漏洞更为致命,姑且算是 Logback 得一分。

  • 从易用性来说,二者差不多,但 Logback 是 SLF4J 的原生实现、Log4j2 需要额外使用 SLF4J 绑定器实现。


再加上 Spring Boot 默认集成了 Logback,如果没有特殊的性能需求,我会更推荐初学者选择 Logback,都不用引入额外的库了~


使用日志框架


日志框架的使用非常简单,一般需要先获取到 Logger 日志对象,然后调用 logger.xxx(比如 logger.info)就能输出日志了。


最传统的方法就是通过 LoggerFactory 手动获取 Logger,示例代码如下:


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyService {
   private static final Logger logger = LoggerFactory.getLogger(MyService.class);

   public void doSomething() {
       logger.info("执行了一些操作");
  }
}

上述代码中,我们通过调用日志工厂并传入当前类,创建了一个 logger。但由于每个类的类名都不同,我们又经常复制这行代码到不同的类中,就很容易忘记修改类名。


所以我们可以使用 this.getClass 动态获取当前类的实例,来创建 Logger 对象:


public class MyService {
   private final Logger logger = LoggerFactory.getLogger(this.getClass());

   public void doSomething() {
       logger.info("执行了一些操作");
  }
}

给每个类都复制一遍这行代码,就能愉快地打日志了。


但我觉得这样做还是有点麻烦,我连复制粘贴都懒得做,怎么办?


还有更简单的方式,使用 Lombok 工具库提供的 @Slf4j 注解,可以自动为当前类生成一个名为 log 的 SLF4J Logger 对象,简化了 Logger 的定义过程。示例代码如下:


import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MyService {
   public void doSomething() {
       log.info("执行了一些操作");
  }
}

这也是我比较推荐的方式,效率杠杠的。



此外,你可以通过修改日志配置文件(比如 logback.xmllogback-spring.xml)来设置日志输出的格式、级别、输出路径等。日志配置文件比较复杂,不建议大家去记忆语法,随用随查即可。



二、日志记录的最佳实践


学习完日志记录的方法后,再分享一些我个人记录日志的经验。内容较多,大家可以先了解一下,实际开发中按需运用。


1、合理选择日志级别


日志级别的作用是标识日志的重要程度,常见的级别有:



  • TRACE:最细粒度的信息,通常只在开发过程中使用,用于跟踪程序的执行路径。

  • DEBUG:调试信息,记录程序运行时的内部状态和变量值。

  • INFO:一般信息,记录系统的关键运行状态和业务流程。

  • WARN:警告信息,表示可能存在潜在问题,但系统仍可继续运行。

  • ERROR:错误信息,表示出现了影响系统功能的问题,需要及时处理。

  • FATAL:致命错误,表示系统可能无法继续运行,需要立即关注。


其中,用的最多的当属 DEBUG、INFO、WARN 和 ERROR 了。


建议在开发环境使用低级别日志(比如 DEBUG),以获取详细的信息;生产环境使用高级别日志(比如 INFO 或 WARN),减少日志量,降低性能开销的同时,防止重要信息被无用日志淹没。


注意一点,日志级别未必是一成不变的,假如有一天你的程序出错了,但是看日志找不到任何有效信息,可能就需要降低下日志输出级别了。


2、正确记录日志信息


当要输出的日志内容中存在变量时,建议使用参数化日志,也就是在日志信息中使用占位符(比如 {}),由日志框架在运行时替换为实际参数值。


比如输出一行用户登录日志:


// 不推荐
logger.debug("用户ID:" + userId + " 登录成功。");

// 推荐
logger.debug("用户ID:{} 登录成功。", userId);

这样做不仅让日志清晰易读;而且在日志级别低于当前记录级别时,不会执行字符串拼接,从而避免了字符串拼接带来的性能开销、以及潜在的 NullPointerException 问题。所以建议在所有日志记录中,使用参数化的方式替代字符串拼接。


此外,在输出异常信息时,建议同时记录上下文信息、以及完整的异常堆栈信息,便于排查问题:


try {
   // 业务逻辑
catch (Exception e) {
logger.error("处理用户ID:{} 时发生异常:", userId, e);
}

3、控制日志输出量


过多的日志不仅会占用更多的磁盘空间,还会增加系统的 I/O 负担,影响系统性能。


因此,除了根据环境设置合适的日志级别外,还要尽量避免在循环中输出日志。


可以添加条件来控制,比如在批量处理时,每处理 1000 条数据时才记录一次:


if (index % 1000 == 0) {
   logger.info("已处理 {} 条记录", index);
}

或者在循环中利用 StringBuilder 进行字符串拼接,循环结束后统一输出:


StringBuilder logBuilder = new StringBuilder("处理结果:");
for (Item item : items) {
   try {
       processItem(item);
       logBuilder.append(String.format("成功[ID=%s], ", item.getId()));
  } catch (Exception e) {
       logBuilder.append(String.format("失败[ID=%s, 原因=%s], ", item.getId(), e.getMessage()));
  }
}
logger.info(logBuilder.toString());

如果参数的计算开销较大,且当前日志级别不需要输出,应该在记录前进行级别检查,从而避免多余的参数计算:


if (logger.isDebugEnabled()) {
   logger.debug("复杂对象信息:{}"expensiveToComputeObject());
}

此外,还可以通过更改日志配置文件整体过滤掉特定级别的日志,来防止日志刷屏:


<!-- Logback 示例 -->
<appender name="LIMITED" class="ch.qos.logback.classic.AsyncAppender">
<!-- 只允许 INFO 级别及以上的日志通过 -->
   <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
       <level>INFO</level>
   </filter>
   <!-- 配置其他属性 -->
</appender>

4、把控时机和内容


很多开发者(尤其是线上经验不丰富的开发者)并没有养成记录日志的习惯,觉得记录日志不重要,等到出了问题无法排查的时候才追悔莫及。


一般情况下,需要在系统的关键流程和重要业务节点记录日志,比如用户登录、订单处理、支付等都是关键业务,建议多记录日志。


对于重要的方法,建议在入口和出口记录重要的参数和返回值,便于快速还原现场、复现问题。


对于调用链较长的操作,确保在每个环节都有日志,以便追踪到问题所在的环节。


如果你不想区分上面这些情况,我的建议是尽量在前期多记录一些日志,后面再慢慢移除掉不需要的日志。比如可以利用 AOP 切面编程在每个业务方法执行前输出执行信息:


@Aspect
@Component
public class LoggingAspect {

   @Before("execution(* com.example.service..*(..))")
   public void logBeforeMethod(JoinPoint joinPoint) {
       Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
       logger.info("方法 {} 开始执行", joinPoint.getSignature().getName());
  }
}

利用 AOP,还可以自动打印每个 Controller 接口的请求参数和返回值,这样就不会错过任何一次调用信息了。


不过这样做也有一个很重要的点,注意不要在日志中记录了敏感信息,比如用户密码。万一你的日志不小心泄露出去,就相当于泄露了大量用户的信息。



5、日志管理


随着日志文件的持续增长,会导致磁盘空间耗尽,影响系统正常运行,所以我们需要一些策略来对日志进行管理。


首先是设置日志的滚动策略,可以根据文件大小或日期,自动对日志文件进行切分。比如按文件大小滚动:


<!-- 按大小滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeBasedRollingPolicy">
   <maxFileSize>10MB</maxFileSize>
</rollingPolicy>

如果日志文件大小达到 10MB,Logback 会将当前日志文件重命名为 app.log.1 或其他命名模式(具体由文件名模式决定),然后创建新的 app.log 文件继续写入日志。


还有按照时间日期滚动:


<!-- 按时间滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
   <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>

上述配置表示每天创建一个新的日志文件,%d{yyyy-MM-dd} 表示按照日期命名日志文件,例如 app-2024-11-21.log


还可以通过 maxHistory 属性,限制保留的历史日志文件数量或天数:


<maxHistory>30</maxHistory>

这样一来,我们就可以按照天数查看指定的日志,单个日志文件也不会很大,提高了日志检索效率。


对于用户较多的企业级项目,日志的增长是飞快的,因此建议开启日志压缩功能,节省磁盘空间。


<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
   <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>

上述配置表示:每天生成一个新的日志文件,旧的日志文件会被压缩存储。


除了配置日志切分和压缩外,我们还需要定期审查日志,查看日志的有效性和空间占用情况,从日志中发现系统的问题、清理无用的日志信息等。


如果你想偷懒,也可以写个自动化清理脚本,定期清理过期的日志文件,释放磁盘空间。比如:


# 每月清理一次超过 90 天的日志文件
find /var/log/myapp/ -type f -mtime +90 -exec rm {} ;

6、统一日志格式


统一的日志格式有助于日志的解析、搜索和分析,特别是在分布式系统中。


我举个例子大家就能感受到这么做的重要性了。


统一的日志格式:


2024-11-21 14:30:15.123 [main] INFO com.example.service.UserService - 用户ID:12345 登录成功
2024-11-21 14:30:16.789 [main] ERROR com.example.service.UserService - 用户ID:12345 登录失败,原因:密码错误
2024-11-21 14:30:17.456 [main] DEBUG com.example.dao.UserDao - 执行SQL:[SELECT * FROM users WHERE id=12345]
2024-11-21 14:30:18.654 [main] WARN com.example.config.AppConfig - 配置项 `timeout` 使用默认值:3000ms
2024-11-21 14:30:19.001 [main] INFO com.example.Main - 应用启动成功,耗时:2.34秒

这段日志整齐清晰,支持按照时间、线程、级别、类名和内容搜索。


不统一的日志格式:


2024/11/21 14:30 登录成功 用户ID: 12345
2024-11-21 14:30:16 错误 用户12345登录失败!密码不对
DEBUG 执行SQL SELECT * FROM users WHERE id=12345
Timeout = default
应用启动成功

emm,看到这种日志我直接原地爆炸!



建议每个项目都要明确约定和配置一套日志输出规范,确保日志中包含时间戳、日志级别、线程、类名、方法名、消息等关键信息。


<!-- 控制台日志输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
   <encoder>
       <!-- 日志格式 -->
       <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
   </encoder>
</appender>

也可以直接使用标准化格式,比如 JSON,确保所有日志遵循相同的结构,便于后续对日志进行分析处理:


<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
   <!-- 配置 JSON 编码器 -->
</encoder>

此外,你还可以通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如用户 ID、请求 ID 等,方便追踪。在 Java 代码中,可以为 MDC 变量设置值:


MDC.put("requestId""666");
MDC.put("userId""yupi");
logger.info("用户请求处理完成");
MDC.clear();

对应的日志配置如下:


<!-- 文件日志配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
   <encoder>
       <!-- 包含 MDC 信息 -->
       <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
   </encoder>
</appender>

这样,每个请求、每个用户的操作一目了然。


7、使用异步日志


对于追求性能的操作,可以使用异步日志,将日志的写入操作放在单独的线程中,减少对主线程的阻塞,从而提升系统性能。


除了自己开线程去执行 log 操作之外,还可以直接修改配置来开启 Logback 的异步日志功能:


<!-- 异步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
   <queueSize>500</queueSize> <!-- 队列大小 -->
   <discardingThreshold>0</discardingThreshold> <!-- 丢弃阈值,0 表示不丢弃 -->
   <neverBlock>true</neverBlock> <!-- 队列满时是否阻塞主线程,true 表示不阻塞 -->
   <appender-ref ref="CONSOLE" /> <!-- 生效的日志目标 -->
   <appender-ref ref="FILE" />
</appender>

上述配置的关键是配置缓冲队列,要设置合适的队列大小和丢弃策略,防止日志积压或丢失。


8、集成日志收集系统


在比较成熟的公司中,我们可能会使用更专业的日志管理和分析系统,比如 ELK(Elasticsearch、Logstash、Kibana)。不仅不用每次都登录到服务器上查看日志文件,还可以更灵活地搜索日志。


但是搭建和运维 ELK 的成本还是比较大的,对于小团队,我的建议是不要急着搞这一套。




OK,就分享到这里,洋洋洒洒 4000 多字,希望这篇文章能帮助大家意识到日志记录的重要性,并养成良好的日志记录习惯。学会的话给鱼皮点个赞吧~


日志不是写给机器看的,是写给未来的你和你的队友看的!


更多


💻 编程学习交流:编程导航

📃 简历快速制作:老鱼简历

✏️ 面试刷题神器:面试鸭


作者:程序员鱼皮
来源:juejin.cn/post/7439785794917072896
收起阅读 »

一种简单粗暴的大屏自适应方案,原理及案例

web
现状 现在最流行的大屏自适应手法: scale缩放 为了解决2d/3d的点击交互问题,通常设计成了2个层容器。图表层和2d/3d层。图表层负责缩放,2d/3d层保持100%显示,避免缩放引起的交互事件event问题。 下图是一个1920*1080的大屏示意...
继续阅读 »

现状



现在最流行的大屏自适应手法: scale缩放

为了解决2d/3d的点击交互问题,通常设计成了2个层容器。图表层和2d/3d层。图表层负责缩放,2d/3d层保持100%显示,避免缩放引起的交互事件event问题。



下图是一个1920*1080的大屏示意图


image.png


使用常规的缩放方法,让大屏在窗口内最大化显示。大屏在不同的窗口中出现了空白区域,并没有充满整个屏幕。


image.png
image.png


新的方法 


在缩放的基础上,对指定的要素进行贴边处理。我们希望上下吸附到窗口最上面和最下面。左右图表吸附到窗口的最左边和最右边。


这里面需要简单的计算,其中a是图表层 scale属性


var halftop = (window.innerHeight- (1080*a.scaleY))/2/a.scaleY;
var halfleft = (window.innerWidth- (1920*a.scaleX))/2/a.scaleX;

对指定id的容器,在resize事件中设置上下左右浮动。如下图


image.png


image.png


image.png


实战项目效果



注,下面图片中的数据指标、城市名、姓名、图像均为虚拟数据。



在实际应用中,一般1920*1080设计稿已宽屏为主,如果是竖屏大屏(下图6),需要设计竖屏UI。


211.png


2024-12-04_134349.jpg


2024-12-04_123059.jpg


2024-12-04_120938.jpg


2024-12-04_120604.jpg


2024-12-04_123119.jpg


你也可以下载该项目demo, 对窗口进行缩放查看效果 pan.baidu.com/s/1hE_C9x9i…


作者:波泼
来源:juejin.cn/post/7444378390843768843
收起阅读 »

还在等后端接口?自己写得了

web
前言 前端:芜湖~静态页面写完,起飞 前端:接口能不能搞快点 后端:没空 前端:emmmmmm 迭代结束..... 老板:前端你怎么回事?搞这么慢 前端: A:跳起来打老板 B:跳起来打后端 C:不干了 D:自己全干 E:继续挨骂 CABABABABABB...
继续阅读 »

前言



前端:芜湖~静态页面写完,起飞image.png


前端:接口能不能搞快点


后端:没空 image.png


前端:emmmmmmimage.png


迭代结束.....


老板:前端你怎么回事?搞这么慢


前端:image.png


A:跳起来打老板

B:跳起来打后端

C:不干了

D:自己全干

E:继续挨骂

CABABABABABBABABABABBABD
image.png


当然是选择Mock.js(骗你的,我自己也不用)
Snipaste_2024-11-26_16-52-11.png



Mock.js 的使用教程


一、什么是 Mock.js?


Mock.js 是一个用于生成随机数据的 JavaScript 库,它可以帮助开发者快速模拟后台接口返回的数据,常用于前端开发中的接口调试和数据展示。通过使用 Mock.js,前端开发者无需依赖后端接口就可以模拟真实的接口数据,提升开发效率。


Mock.js 支持的数据类型非常丰富,包括字符串、数字、日期、图片等,并且可以对数据进行自定义设置,模拟出不同的场景。


二、安装 Mock.js


Mock.js 是一个轻量级的库,可以通过 npmyarn 安装:


# 使用 npm 安装
npm install mockjs --save

# 使用 yarn 安装
yarn add mockjs

如果你没有使用包管理工具,也可以直接在 HTML 页面中通过 <script> 标签引入 Mock.js:


<script src="https://cdn.jsdelivr.net/npm/mockjs@1.1.0/dist/mock.min.js"></script>

三、Mock.js 的基本使用


Mock.js 提供了一个全局的 Mock 对象,使用 Mock 对象,你可以轻松地创建模拟数据。


1. 使用 Mock.mock() 方法


Mock.mock() 是 Mock.js 的核心方法,用于创建模拟数据。它接受一个模板作为参数,根据这个模板生成相应的模拟数据。


示例:生成简单的随机数据

const Mock = require('mockjs');

// 模拟一个简单的用户数据对象
const userData = Mock.mock({
'name': '@name', // 随机生成姓名
'age|18-60': 25, // 随机生成 18-60 之间的年龄
'email': '@email', // 随机生成邮箱地址
});

console.log(userData);

在这个例子中,@name@email 等是 Mock.js 内置的随机数据生成规则,'age|18-60': 25 是一种范围随机生成规则,它会生成 18 到 60 之间的随机数。


模拟输出:

{
"name": "张三",
"age": 34,
"email": "example@example.com"
}

2. 模拟数组数据


Mock.js 还可以生成数组数据,支持定义数组长度以及每个元素的生成规则。


const Mock = require('mockjs');

// 模拟一个包含多个用户的数组
const userList = Mock.mock({
'users|3-5': [{ // 随机生成 3 到 5 个用户对象
'name': '@name',
'age|20-30': 25,
'email': '@email'
}]
});

console.log(userList);

模拟输出:

{
"users": [
{ "name": "李四", "age": 22, "email": "user1@example.com" },
{ "name": "王五", "age": 28, "email": "user2@example.com" },
{ "name": "赵六", "age": 25, "email": "user3@example.com" }
]
}

3. 使用自定义规则生成数据


Mock.js 还支持自定义规则,你可以定义数据生成的规则,或者通过函数来生成特定的数据。


const Mock = require('mockjs');

// 使用自定义函数生成随机数据
const customData = Mock.mock({
'customField': () => {
return Math.random().toString(36).substr(2, 8); // 返回一个随机的 8 位字符串
}
});

console.log(customData);

模拟输出:

{
"customField": "rkf7hbw8"
}

四、常用的 Mock.js 模板规则


Mock.js 提供了丰富的数据生成规则,下面列出一些常用的规则。


1. 字符串相关规则



  • @name:生成一个随机的中文名字。

  • @cname:生成一个随机的中文全名。

  • @word(min, max):生成一个随机的单词,minmax 控制长度。

  • @sentence(min, max):生成一个随机的句子,minmax 控制单词数量。

  • @email:生成一个随机的邮箱地址。

  • @url:生成一个随机的 URL 地址。


2. 数字相关规则



  • @integer(min, max):生成一个随机整数,minmax 控制范围。

  • @float(min, max, dmin, dmax):生成一个随机浮点数,minmax 控制范围,dmindmax 控制小数点位数。

  • @boolean:生成一个随机布尔值。

  • @date(format):生成一个随机日期,format 为日期格式,默认是 yyyy-MM-dd

  • @time(format):生成一个随机时间。


3. 其他类型



  • @image(size, background, foreground):生成一张图片,size 控制图片大小,background 控制背景色,foreground 控制前景色。

  • @guid:生成一个 GUID。

  • @id:生成一个随机的身-份-证号。

  • @province@city@county:生成随机的省、市、区名称。


五、Mock.js 用于模拟接口数据


Mock.js 常用于前端开发中模拟接口数据,帮助前端开发人员在没有后端接口的情况下进行开发和调试。可以通过 Mock.mock() 来拦截 HTTP 请求,并返回模拟的数据。


示例:模拟一个接口请求


假设我们有一个接口需要返回用户数据,我们可以使用 Mock.js 来模拟这个接口。


const Mock = require('mockjs');

// 模拟接口请求
Mock.mock('/api/users', 'get', {
'users|5-10': [{ // 随机生成 5 到 10 个用户数据
'id|+1': 1, // id 从 1 开始递增
'name': '@name',
'email': '@email',
'age|18-60': 25,
}]
});

console.log('接口已模拟,发送请求查看结果');

在上面的代码中,Mock.mock() 拦截了对 /api/users 的 GET 请求,并返回一个包含随机用户数据的对象。当前端代码请求 /api/users 时,Mock.js 会自动返回模拟的数据。


六、Mock.js 高级用法


1. 延迟模拟


有时你可能希望模拟网络延迟,Mock.js 支持使用 timeout 配置来延迟接口响应。


Mock.mock('/api/data', 'get', {
'message': '成功获取数据'
}).timeout = 2000; // 设置延迟时间为 2000ms (2秒)

2. 使用正则表达式生成数据


Mock.js 还支持通过正则表达式来生成数据。例如,生成一个特定格式的电话号码。


const phoneData = Mock.mock({
'phone': /^1[3-9]\d{9}$/ // 正则表达式生成一个中国大陆手机号
});

console.log(phoneData);

3. 动态修改数据


Mock.js 还允许你在数据生成后对其进行动态修改,可以通过调用 Mock.Random 对象来获取随机数据,并进一步自定义。


const random = Mock.Random;
const customData = {
name: random.name(),
email: random.email(),
phone: random.phone(),
};

console.log(customData);

七、总结


Mock.js 是一个强大的工具,可以帮助你快速生成模拟数据,尤其适用于前后端分离的开发模式,前端开发人员可以独立于后端接口进行开发和调试。Mock.js 提供了灵活的数据生成规则,支持随机数、日期、图片等多种类型,并且能够模拟 HTTP 接口请求,极大地提高了开发效率。


掌握 Mock.js 的基本用法,可以帮助你在开发过程中更加高效,减少对后端开发的依赖,提升整个项目的开发速度。


各位彦祖亦菲再见ヾ( ̄▽ ̄)ByeBye


image.png


作者:不爱说话郭德纲
来源:juejin.cn/post/7442515129173262351
收起阅读 »

一封写给离职小伙伴的信

前言 亲爱的小伙伴,当你看到这封信的时候,相信你大概率也是离职浪潮中的一员了,不管是被裁还是主动离职,相信在接下来的日子里,求职路上必定不会一帆风顺,势必要经历一番波折与挑战。 也许你才刚开始,此刻意气风发信心满满;也许你正在经历,此时彻底怀疑自我,将要放弃底...
继续阅读 »

前言


亲爱的小伙伴,当你看到这封信的时候,相信你大概率也是离职浪潮中的一员了,不管是被裁还是主动离职,相信在接下来的日子里,求职路上必定不会一帆风顺,势必要经历一番波折与挑战。


也许你才刚开始,此刻意气风发信心满满;也许你正在经历,此时彻底怀疑自我,将要放弃底线;也许你已经历过了,此时已是遍体鳞伤体无完肤,彻底摆烂……


不管屏幕前的你是哪一种,但请记住,这是每个人入世时社会老师要给我们上的第一节课。


在这里,我先分享一段平凡普通又心酸的求职历程,也希望通过这篇文章能给你一些启发和帮助,也衷心地希望你能够重拾信心,一路披荆斩棘!




关于我


我是双非一本毕业,计算机专业,目前毕业已有九年了,一直在远离家乡的北漂之地工作。由于母校没啥名气且名字中带有的小众地域性,非本省的人很少知道它,所以在外省找工作经常会被问你是不是本科毕业(尴尬😅),学历这一块姑且算是杂牌吧,起码在筛选简历时不具备任何优势。


其次,简历也没有太多的亮眼经历,没有大厂背景,基本都是中小厂的工作经历,普通到不能再普通了。


其三,关于年龄这一块,本人目前很接近35岁的“退休”年龄,属于被重点劝退互联网的年纪,触碰到了互联网年龄红线。


最后,这还不算最糟糕,还有更糟糕的面试门槛。


什么是糟糕的面试门槛?接着往下看。


由于去年家里出事了,辞职回了老家,之后也没着急出来找工作,期间闲来无事,顺便考了个公(原本辞职也不是为了考公),结果可想而知,也没考上,所以也没当回事,该玩玩,该旅游就旅游,彻底放飞自我~(没有房贷车贷毫无顾忌)


等我再次出来找工作的时候,已经离上一份工作的间隔有一年多了(准确的说是一年零两个月),也就是说我已经 Gap 一年多了,我还丝毫没意识到这将成为我求职路上的一个障碍。




面试的准备


7月初,我正式开始准备面试。首先更新了简历,回顾之前的项目并总结,梳理项目架构、流程图、负责的模块以及技术难点。同时看了看八股文复习基础知识,刷刷leetcode,时间过得很快,大概三周后开始投简历。




面试的门槛


Gap一年多的经历,让我在求职中非常被动,一线大厂全都因为这段 Gap 经历被 HR 前期的电话沟通中直接否掉,连一面的面试机会都没有。


只有一家公司例外,那就是字节,也是我面试的第一家公司。首先不得不表扬一下这家公司的用人态度,只是我准备不足仓促应付,以为刷了算法题就没啥大问题,结果人家来了一个手撕题(非算法),当场把我给整懵了,结果可想而知……其实本质上还是综合能力不够,但起码人家给了你面试的机会。


不管最终结果如何,单凭这种不问出身来者不拒,招人包容的胸襟与态度就值得点赞👍


因此,后面我能接到面试的机会只有中小厂,一间大厂都没有,这也许就是 Gap 太久要付出的代价之一吧。




面试的过程


求职的过程比较曲折,毕竟离开岗位已经一年多了,很多零零碎碎的知识要整理起来也不是一蹴而就的,因此求职期间,一边面试,一边不断总结经验,把之前做过的东西以及面试的空白知识慢慢梳理出来,并整理成博客。通过系统化的梳理与表达,自己的思路也开始有了更清晰的脉络和方向。


经过三个多月的面试,期间共投了三十多家公司的简历,排除一线大厂以及被其他公司pass掉的简历,其实真正接到面试机会的一共只有二十家左右。


期间有一段是没有面试的空白期,那段时间真的怀疑自己,很彷徨,是不是真的该投外包和od,这里没有贬低的意思,只是目前自己还没有养老的打算,同时也有自己的职业规划和方向,外包暂时还不在考虑范围之内。


从刚开始投的高薪大中厂,到后来的中小厂,虽然姿态一直在放低,但终归守住了自己的底线——那就是行业方向和薪资待遇。




面试中的奇葩


面试过程中也会遇到各种各样的面试官,结合自己曾经也做过面试官的经验,一些常规的套路基本是熟悉的,幸运的是遇到的绝大多数面试官都非常的nice,当然也遇到个别的奇葩。


比如这次遇到了一个思路清奇的二面面试官,一面聊得还挺好的,本以为二面面缘应该也不会差,没想到上来他就开始板着脸,似乎人人都欠他八百万似的,之所以如此,直到后来我才知道,原来他在怀疑我简历造假。等我介绍完项目,没问技术实现细节,而是开始扣字眼,这个项目公司内部的名字叫什么?为什么简历中没提这个项目的内部名字?xxx公司有海外方向吗?我全程耐心地解释,一度怀疑他才是我前司的员工,而我不是。


最后问了我那个项目的域名地址,那个项目我也只跟进了一期,没有太深的记忆,当时没有找出来,后来就草草结束了。


这里也怪我,由于离开前司一年多,项目域名早变了,而自己在整理项目时没有及时跟进,才导致如此尴尬局面,后来我才知道那个项目的域名早已换成了官网域名,把它整合进海外官网了。


其实我也非常理解他的这种行为,换成是我,遇到这种情况,我也会对面试者产生怀疑。然而从逆向思维的角度分析,像我这种非大厂背景,学历看着又像渣本的简历,怎么造,简历都不会好到哪里去吧,何况还有背调,我又何必费那个心思。想想都觉得有点滑稽~


这里也给自己总结两点经验:



  • 凡是可能涉及到的点,都要一一回顾,有备无患,但说实话经历过那么多年的面试,不管是我面别人还是别人面我,问项目域名的我还是头一次遇到。

  • 怀疑一旦开始,罪名就已经成立。不管你后面如何辩白,结局其实早已注定,还不如趁早结束。即使你有幸通过面试,将来在一位不信任你的领导下干活,也是一件非常心累的事。


面试的结果


经过将近20家公司的轮番摩擦,终于在10月底的时候,陆陆续续有了口头offer,又经过银行流水、背调、体检,最终拿到了3家公司的正式offer,两家小公司和一家独角兽。


那家独角兽公司我很早就知道,其实也一直是我想进的一家公司,因此毫无悬念,我最终选择了那家中厂独角兽,总包降了一点点,但总算还是属于平跳,这个结果在当下的环境,对我来说已经很难得了。


面试经验分享


这里不提供什么面经,因为每面的问题几乎都不一样,几乎没有碰到任何相同的一个问题(自我介绍、项目介绍和HR面除外)。


面了这么多公司,印象中只有一道题是重复的,所以即使给出各家公司的面经,真正轮到你面时,出的题也会因人而异。面经并不是圣经,只是可以作为一个难度级别的参考罢了,所以面经意义其实并不大。


这里我想分享更多的是个人职业的规划与成长。


其实面试的过程本身就是一个提升自我认知的过程。


面对如此困境,我想分享一下我是如何破局的。


作为一个普通、大龄、又gap一年多的普通程序猿,我首先做的便是潜下心来扪心自问,在这么激烈的竞争环境中,与其他人相比,我的优势在哪?





  • 首先是职业稳定性,我虽然没有大厂背景,但还算稳定,总共经历3家公司,只有中间一家是因疫情原因而不满一年,八年时间里没有频繁跳槽还算稳定。

  • 其次是职业方向,个人之前从事的行业一直与电商领域相关,前几段工作经历一直是从事海外电商方向。因此,在海外电商这个领域中我有天然的行业经验优势,从最终拿到的3家offer公司的结果来看,也反向证明了这一点。

  • 其三,投简历时我只会挑选特定的领域方向,我不会进行海投,更不会盲投,因为那没有什么意义,因为人家没看上你的,即便投了也不会有什么结果,最多只会礼貌地给你回访,并不会真正进入面试流程。


    这是我在脉脉上得出的结论,因为对你发起的沟通是系统推荐,是系统发起的并不是他们本人,因此他们并不一定对你感兴趣(当然你若是那种天之骄子又有大厂背书,就请忽略我们这种平凡人的经历)。


    因此,投简历时我一般只在boss上,并且是被动投简历,也就是别人先发起的简历请求。底层逻辑是因为只有人家对你感兴趣,你才会有更大的可能获得面试机会


  • 其四,珍惜每一次面试的机会,看清楚JD里的要求和公司从事的方向,对JD中描述的职位要求和职责,有针对性地准备面试,面试时遇到自己知识盲区要诚实表示不会,不要试图与面试官产生争论,因为即便你是对的,也会对你产生不利。坦然接受任何结果,放松心态。

  • 其五,放低姿态,降低期望,期望越高,失望越高。由于我已经离职并 Gap 太久,没有任何骑驴找马的依仗,谈薪资时显得非常被动,这便是 Gap 太久的代价之二。


    之前也面过几个到终面的公司,谈薪时过高而被Pass掉,我的诉求也很简单,就是期望能平薪而跳,也没指望能涨薪,也愿意接受适度降薪。


    期间,有遇到过很爽快的,也有遇到拼命压你薪的,面了很多小公司都有这样一个相同的经历,到谈薪阶段,他们会故意冷淡你不跟你谈,而是经过多轮对比候选人,看看哪个性价比更高。其实也非常理解,毕竟当前的环境下,哪个老板不希望用低薪招到一个综合能力更强的人呢?


    而有的会比较爽快,他们急着大量招人,流程会很快,也不会过分压薪,碰到这种公司那么恭喜你,你中奖了,这种基本在经济行情好(公司盈利大好)的时候才会出现。


    放低姿态不意味着放弃原则反而要有更清晰的底线,它的底层逻辑是降低期望值,期望值是心理学上一个很巧妙东西,期望锚点值越低,才有更大的可能获得惊喜。


  • 最后,主动沉淀和总结经验,对面试中经常问到的同一类问题进行总结和思考,进而纳入自己的脑海中,逐渐形成自己的知识体系。


    比如,你为什么会从上一家公司离职?问这个问题的背后动机是什么?HR为什么会压你薪资?这些问题的背后原理一定要思考清楚,只有理解底层逻辑,才能应对自如。


    当然,搞清楚这些问题的背后逻辑不是为了让你去说谎,面试时可以结合自身的实际,美化用词,但不要试图说谎。





总结


以上就是一个平凡普通又Gap一年多的打工人自我总结与分享,也希望它能给你带来一些启发和帮助,哪怕只有一点点,那也是我莫大的荣幸!


眼下的就业形势确实不容乐观,招聘越来越挑剔,要找到心仪的工作实属不易,但请不要因此而对生活失去信心,要相信好好总结与反思,总有一个位置属于你。


愿屏幕前的你能渡过难关、顺利上岸!加油!


一封来自远方陌生人的信


作者:九幽归墟
来源:juejin.cn/post/7444773769242116111
收起阅读 »

springboot多种生产打包方式简介

生产上发布 Spring Boot 项目时,流程颇为繁琐且低效。但凡代码有一丁点改动,就得把整个项目重新打包部署,耗时费力不说,生成的 JAR 包还特别臃肿,体积庞大。每次更新项目,光是上传这大文件就得花费不少时间,严重影响工作节奏。为解决这一痛点,我打算把依...
继续阅读 »

生产上发布 Spring Boot 项目时,流程颇为繁琐且低效。但凡代码有一丁点改动,就得把整个项目重新打包部署,耗时费力不说,生成的 JAR 包还特别臃肿,体积庞大。每次更新项目,光是上传这大文件就得花费不少时间,严重影响工作节奏。为解决这一痛点,我打算把依赖库以及配置文件(lib 文件夹下的那些 jar 包,还有config下的applacation.yml等文件)从项目主体里剥离出来,后续部署时,只需发布核心代码就行,这样既能加快部署速度,又能减轻文件传输负担,让项目更新变得轻松便捷


方法一 插件spring-boot-maven-plugin


1. 项目应用的配置文件排除 统一打包到config目录下


利用springboot中resource插件来排除配置,并统一打包到config目录下


<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<!--指定目标路径为config-->
<targetPath>${project.build.directory}/config</targetPath>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>

</resources>

2. 把我们写代码打包可执行jar,并排除依赖jar包


<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->

<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>

<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>

</plugin>

3 配置依赖的jar包 统一打包lib目录


<!--此插件用于将依赖包抽出-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>


打包后目录结构,如下图所示


image.png
执行命令 java -jar -Dloader.path=./lib -jar xxx.jar


注意 springboot启动时候会优先读取config目录下配置文件 所以这里不用指定-Dspring.config.location=XX.yml文件


image.png
注意 例如日志文件配置以及mybits等配置文件 可以配成绝对路径 如下所示:


image.png


方法二 使用maven-jar-plugin插件实现


1 使用插件maven-resources-plugin处理配置文件打包到config目录


<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<encoding>UTF-8</encoding>
</configuration>

<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<!--配置文件打包成config目录下 -->
<outputDirectory>${project.build.directory}/twin-web/config</outputDirectory>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>

</plugin>

2. 使用maven-jar-plugin 打包可执行jar 并排除依赖


<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<outputDirectory>
<!--输入打包可执行的jar到twin-web\libs\下-->
${project.build.directory}/twin-web/
</outputDirectory>
<archive>
<addMavenDescriptor>false</addMavenDescriptor>
<manifest>
<addClasspath>true</addClasspath>
<!-- 增加执行启动jar的依赖jar包目录前缀-->
<classpathPrefix>./libs/</classpathPrefix>
<!-- 指定启动类-->
<mainClass>com.keqing.twinweb.TwinWebApplication</mainClass>
</manifest>
<manifestEntries>
<!-- 增加配置文件的classpath-->
<Class-Path>./config/</Class-Path>
</manifestEntries>
</archive>
<!-- 排除配置文件-->
<excludes>
<exclude>*.yml</exclude>
<exclude>mapper/**</exclude>
<exclude>*.xml</exclude>
</excludes>
</configuration>

</plugin>

3 使用maven-dependency-plugin 打包libs目录下


<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/twin-web/libs</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>

</plugin>

使用package打包后的目录


image.png
查看自己打包后jar目录,注意这种打包方式弊端,按照一定约定格式规范固定了,一旦依赖jar包(包括配置文件目录等)发生变化就必须重新打包
image.png


启动程序java -jar xxx.jar


image.png


方式三 使用maven-assembly-plugin打包


maven-assembly-plugin 是 Maven 中的一个插件,它允许用户将项目的输出以及依赖、模块、站点文档和其他文件打包成一个可发布的格式,例如 zip、tar.gz、jar 等。以下是使用 maven-assembly-plugin 的一些优势:



  1. 自定义打包格式maven-assembly-plugin 允许你通过定义描述符文件(descriptor)来完全自定义打包的内容和格式。你可以选择包含或排除特定的文件和目录。

  2. 一键打包:通过一个简单的 Maven 命令,你可以创建一个包含所有必需依赖的单一归档文件,这使得分发和部署变得非常简单。

  3. 多环境支持:可以为不同的环境(开发、测试、生产)创建不同的打包配置,使得环境迁移更加容易。

  4. 依赖管理:插件会自动处理项目依赖,将它们打包到最终的归档文件中,无需手动管理。

  5. 模块化项目支持:对于多模块项目,maven-assembly-plugin 可以将所有模块的输出合并到一个归档文件中。

  6. 预配置的描述符:插件提供了一些预定义的描述符,如 binjar-with-dependencies 等,可以直接使用,无需自定义。

  7. 灵活性:你可以通过修改描述符文件来调整打包行为,以适应不同的需求。

  8. 集成性maven-assembly-plugin 与 Maven 生态系统紧密集成,可以与其他 Maven 插件协同工作。

  9. 文档和社区支持:由于 maven-assembly-plugin 是 Maven 的一部分,因此有广泛的文档和社区支持。


1. 项目应用的配置文件排除


<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>

</resources>

2. 配置spring-boot-maven-plugin



<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->

<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>

<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>

</plugin>

3 引入springboot里约定maven-assembly-plugin


<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<!-- 打包文件名字不包含 assembly.xml 中 id -->
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<!--项目所在目录配置文件的 assembly.xml文件 -->
<descriptor>assembly.xml</descriptor>
</descriptors>
</configuration>

<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>

</plugin>

配置assembly.xml文件


<assembly>
<!-- 打包文件名的标识符,用来做后缀-->
<id>make-assembly</id>
<!-- 打包的类型,如果有N个,将会打N个类型的包 -->
<formats>
<format>tar.gz</format>
<format>zip</format>
</formats>

<!-- 压缩包下是否生成和项目名相同的根目录 -->
<includeBaseDirectory>true</includeBaseDirectory>
<!-- 用来设置一组文件在打包时的属性。-->
<fileSets>
<!-- 0755->即用户具有读/写/执行权限,组用户和其它用户具有读写权限;-->
<!-- 0644->即用户具有读写权限,组用户和其它用户具有只读权限;-->
<!-- 将src/bin目录下的jar启动脚本输出到打包后的目录中 -->
<fileSet>
<!--lineEnding选项可用于控制给定的行结束文件 -->
<lineEnding>unix</lineEnding>
<directory>${basedir}/bin</directory>
<outputDirectory>${file.separator}</outputDirectory>
<fileMode>0755</fileMode>
<includes>
<include>**.sh</include>
<include>**.bat</include>
</includes>
</fileSet>
<!-- 把项目的配置文件,打包进压缩文件的config目录 -->
<fileSet>
<directory>${basedir}/src/main/resources</directory>
<outputDirectory>config</outputDirectory>
<fileMode>0644</fileMode>
<includes>
<include>*.properties</include>
<include>*.yml</include>
<include>*.xml</include>
<include>mapper/*.xml</include>
</includes>
</fileSet>
<!-- 把项目自己编译出来的jar文件,打包进zip文件的根目录 -->
<fileSet>
<directory>${project.build.directory}</directory>
<outputDirectory>${file.separator}</outputDirectory>
<includes>
<include>*.jar</include>
</includes>
</fileSet>
</fileSets>

<!-- 依赖包的拷贝-->
<dependencySets>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>provided</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>system</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>runtime</scope>
</dependencySet>
</dependencySets>

</assembly>

打包后目录


image.png


解压zip目录查看


image.png


使用命令启动项目java -jar -Dloader.path=./lib -jar xxx.jar


image.png


作者:Mason_Ying
来源:juejin.cn/post/7442154802832916530
收起阅读 »

Spring Boot + liteflow竟然这么好用!

liteflow可以帮助我们快速编排复杂的业务规则,并实现动态的规则更新。在liteflow中,主要有以下几种组件:普通组件:集成NodeComponent,用于执行具体的业务逻辑;选择组件:通过业务逻辑选择不同的执行路径;条件组件:基于条件返回结果,决定下一...
继续阅读 »

liteflow可以帮助我们快速编排复杂的业务规则,并实现动态的规则更新。

liteflow中,主要有以下几种组件:

  • 普通组件:集成NodeComponent,用于执行具体的业务逻辑;
  • 选择组件:通过业务逻辑选择不同的执行路径;
  • 条件组件:基于条件返回结果,决定下一步的业务流程。

我们通过代码示例来了解每种组件的用法。

java

// 普通组件示例
@LiteflowComponent("commonNode")
public class CommonNode extends NodeComponent {
@Override
public void process() throws Exception {
// 业务逻辑
System.out.println("Executing commonNode logic");
}
}

// 选择组件示例
@LiteflowComponent("choiceNode")
public class ChoiceNode extends NodeSwitchComponent {
@Override
public String processSwitch() throws Exception {
// 根据条件返回不同的节点ID
return "nextNodeId";
}
}

// 条件组件示例
@LiteflowComponent("conditionNode")
public class ConditionNode extends NodeIfComponent {
@Override
public boolean processIf() throws Exception {
// 判断条件
return true;
}
}

EL规则文件

liteflow中,规则文件可以采用XML格式编写,下面是一个简单的规则文件示例。

图片

如何使用EL规则文件

  1. 创建规则文件:将上述规则文件保存为flow.xml,放在项目的resources目录下;
  2. 配置liteflow:在Spring Boot项目中添加liteflow的配置,指定规则文件的位置;
yaml

liteflow:
rule-source: "classpath:flow.xml"
node-retry: 3
thread-executor:
core-pool-size: 10
max-pool-size: 20
keep-alive-time: 60
  1. 编写业务逻辑组件:按照规则文件中的定义,编写相应的组件逻辑。

数据上下文

liteflow中,数据上下文非常重要,它用于参数传递业务逻辑的执行。

我们可以通过以下代码示例了解数据上下文的用法

图片

配置详解

在使用liteflow时,我们需要对一些参数进行配置,如规则文件地址、节点重试、线程池参数等。

以下是一个配置示例。

yaml

liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间

实践案例

接下来,我们来看一个完整的业务实践案例。

在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。

这时候,我们可以利用liteflow进行规则编排,处理这些并行任务。

1. 引入依赖

首先,在pom.xml文件中添加liteflow的依赖:

xml

<dependency>
<groupId>com.yomahubgroupId>
<artifactId>liteflow-spring-boot-starterartifactId>
<version>2.6.5version>
dependency>

2. 增加配置

application.yml文件中添加liteflow的配置:

yaml

spring:
application:
name: liteflow-demo

liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间

3. 编写规则文件

resources目录下创建flow.xml文件,编写规则文件内容:

xml

<flow>
<parallel>
<node id="pointNode"/>
<node id="messageNode"/>
parallel>
flow>

4. 编写业务逻辑组件

按照规则文件中的定义,编写相应的业务逻辑组件:

java

@LiteflowComponent("pointNode")
public class PointNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发放积分逻辑
System.out.println("Issuing points for the order");
}
}

@LiteflowComponent("messageNode")
public class MessageNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发送消息逻辑
System.out.println("Sending message for the order");
}
}

5. 流程触发

当订单完成后,我们需要触发liteflow的流程来执行积分发放和消息发送的逻辑。

我们可以在订单完成的服务方法中添加如下代码:

java

@Service
public class OrderService {

@Autowired
private FlowExecutor flowExecutor;

public void completeOrder(Order order) {
// 完成订单的其他逻辑
System.out.println("Order completed: " + order.getId());

// 执行liteflow流程
flowExecutor.execute2Resp("flow", order);
}
}

图片

性能统计

图片

图片

总结

图片



作者:程序员蜗牛
来源:juejin.cn/post/7394790673612718092
收起阅读 »

一位转行Java上岸4年到技术专家的经验分享

自我介绍: 大家好, 我是你们的朋友, 晓龙。今天我想分享一下我自己艰难的Java转行经历。 为什么没有学历 , 因为大学上了一学期, 当时因为一些原因, 就辍学了。年少无知 , 发现到社会上找工作, 没有学历就没有敲门砖,最低要求也是大专。在2018年报考了...
继续阅读 »

自我介绍:


大家好, 我是你们的朋友, 晓龙。今天我想分享一下我自己艰难的Java转行经历。


为什么没有学历 , 因为大学上了一学期, 当时因为一些原因, 就辍学了。年少无知 , 发现到社会上找工作, 没有学历就没有敲门砖,最低要求也是大专。在2018年报考了大专的网络教育。同时, 自己也开始了金融学本科的自考。2018年还是比较的迷茫, 也不知道能靠什么挣钱。2019年, 开始在黑马进行Java的培训, 因为大概有4年没有学习了,加之因为0基础, 当时去的时候连Java能干什么也不知道, 所以学起来还是比较的吃力,尤其我记得当时对方法的定义, 是真的理解不了。


开局buffer就叠满了,运气一直比较的好。


工作经历:


2019年9月左右, 在武汉经过几次面似乎,找到了第一家公司, 规模比较的小, 0-20人, 当然对于当时的我来说, 能找到一份工作(上岸),也是非常开心的, 但是很遗憾的是, 这家公司我去只坚持1个月, 公司就倒闭了。


但是也没有急着找工作了,花了20天, 花了20天把自考的科目复习,在11月份继续在武汉找工作,经过面试,入职了一家比较小的公司, 后端就2人,都是培训出来的。当时我的想法很明确, 花时间补知识, 在培训班的6个月, 时间还是比较的紧的,很多东西只是知道, 都没有亲自动手实践过。公司好在不是很忙, 有一定的时间学习。当时上了2个多月,就回家过年,后面因为yq , 就没有去武汉了,这家公司也就上了3个月的班, 一直在家呆到2020年年7月,期间把慕课网的架构课程好好学习了。7月开始在重庆找工作 ,经过了一周多的努力, 找到了一家更加不规范的公司, 后端2个, 前端一个, 产品一个,其他就没有了,还在薪资给的还可以, 这个期间继续学习, 真正的代码都写得比较的少。很遗憾, 公司只坚持了6个月, 2021年1月, 公司又倒闭了。


我清晰的记得当时准备面试的那一周,是压力最大的一周。在经历了前面几家公司后, 我也知道, 如果还去一家不稳定的公司, 结果还是这样, 几个月就倒闭了, 而且整个公司没有研发体系, 自己就永远不能真正的上岸(野路子到正规军)。虽然前面1年多代码没有写多少, 但是还是有一定的知识积累。


自己也没有放弃, 我当时在BOSS上看准了一家公司, 然后就开始好好的准备, 不仅仅是准备技术方面的知识, 还去了解公司的背景, 当时觉得这家公司规模比较的大。当时面试完后, 顺利的拿到了offer , 期间我和面试官(也是我的领导)说, 我非常期待能来公司上班,即使不要钱,但是没办法, 我自己得生活。直到现在, 我还是非常的感谢这位领导, 也会在微信中和他交流近况。




是的 ,从某种意义上讲, 现在才是真正的上岸!


e3d060f3a7fbe92678d66225a7c841c.png


我在这家公司感受到什么是真正的做开发。公司有正规的研发流程, 完善的研发体系, 每一个同事都身怀绝技。在这家公司, 我给自己的定位是2年成长为高级开发工程师,期间也暴露出我的一些问题,比如代码逻辑写得不清楚(之前代码写得太少了),设计做得不好等。


我是渴望成长的, 所以我针对自己具体的问题, 在2年的时间里, 充分利用自己的时间做了这些工作:


第一阶段: 在试用期的前三个月,虽然完成了业务功能开发,但是意识到自己的代码量还是远远不够,在以最快的速度完成业务功能开发后,投入leetcode的算法练习,每一道题就是一个功能的缩影,在完成300+的练习后,业务代码就得心应手了。


第二阶段: 当时培训机构的架构课对我来说,是无法转化为我的能力的,我学习它无疑是浪费我的时间,所以我更多的选择了自己去寻找资料和书籍来学习,主要是针对这几个方面,操作系统,计算机网络,netty,JVM,并发编程,框架源码,此过程历经1年。


第三阶段: 系统设计,在经历前两个阶段后, 是时候提高我的系统设计,架构能力,这个东西学不来, 靠悟,也是最难受的地方!每一次的业务功能开发前,我都会阅读软件设计方面的资料(每阅读一次,都有不一样的收获)再对业务功能进行设计,非常的耗时,但也非常的值得,经历了一年,对模块和系统的设计都有了自己的理解。


第四阶段: 和产品的深度沟通能力,无论是在需求评审,还是自己负责的模块,系统,都要和产品深度的沟通,这个阶段经历了半年,完成了这4个阶段后,自己的能力得到了极大的提高。跳槽成功




50d3627eb62fe1ee94a94520b55feca.png


image.png


05de66c2fa3c081391f4154bb9701d8.png


大家会说, 现在的环境不好, 不好找工作。从我自己的经历来看, 转行培训过来的同学, 开局不管怎么样, 不管遇到多大的困难,不要放弃自己, 请坚持学习, 好好的积累3年,完善自己的各项能力, 那个时候才是真正的上岸,自己也不愁找不到工作,拿到自己满意的薪资。


目前也在写重写自己的系统框架(脚手架)fd-frameworkfd-framework, 今天为了解决springmvc对请求时间进行统一格式处理 , Long精度丢失问题处理问题, 仔细花了大概5小时阅读里面的源码,期间的实现方式改了4版, 每一次的深入,都是有新的实现想法, 真的感觉很开心,哈哈, 预祝各位转行的同学上岸。


作者:玉龙小怪兽
来源:juejin.cn/post/7323408577709080610
收起阅读 »

为什么 Java 大佬都不推荐使用 keySet() 遍历HashMap?

在Java编程中,HashMap 是一种非常常见的数据结构。我们经常需要对其中的键值对进行遍历。通常有多种方法可以遍历 HashMap,其中一种方法是使用 keySet() 方法。 然而,很多Java大佬并不推荐这种方法。为什么呢? 已收录于,我的技术网站:d...
继续阅读 »

在Java编程中,HashMap 是一种非常常见的数据结构。我们经常需要对其中的键值对进行遍历。通常有多种方法可以遍历 HashMap,其中一种方法是使用 keySet() 方法。


然而,很多Java大佬并不推荐这种方法。为什么呢?


已收录于,我的技术网站:ddkk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,等等什么都有,欢迎收藏和转发。


keySet() 方法的工作原理


首先,让我们来看一下 keySet() 方法是如何工作的。keySet() 方法返回 HashMap 中所有键的集合 (Set<K>)。然后我们可以使用这些键来获取相应的值。


代码示例如下:


// 创建一个HashMap并填充数据
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);

// 使用keySet()方法遍历HashMap
for (String key : map.keySet()) {
// 通过键获取相应的值
Integer value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}

这个代码看起来没什么问题,但在性能和效率上存在一些隐患。


keySet() 方法的缺点


1、 多次哈希查找:如上面的代码所示,使用 keySet() 方法遍历时,需要通过键去调用 map.get(key) 方法来获取值。这意味着每次获取值时,都需要进行一次哈希查找操作。如果 HashMap 很大,这种方法的效率就会明显降低。


2、 额外的内存消耗keySet() 方法会生成一个包含所有键的集合。虽然这个集合是基于 HashMap 的键的视图,但仍然需要额外的内存开销来维护这个集合的结构。如果 HashMap 很大,这个内存开销也会变得显著。


3、 代码可读性和维护性:使用 keySet() 方法的代码可能会让人误解,因为它没有直接表现出键值对的关系。在大型项目中,代码的可读性和维护性尤为重要。


更好的选择:entrySet() 方法


相比之下,使用 entrySet() 方法遍历 HashMap 是一种更好的选择。entrySet() 方法返回的是 HashMap 中所有键值对的集合 (Set<Map.Entry<K, V>>)。通过遍历这个集合,我们可以直接获取每个键值对,从而避免了多次哈希查找和额外的内存消耗。


下面是使用 entrySet() 方法的示例代码:


// 创建一个HashMap并填充数据
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);

// 使用entrySet()方法遍历HashMap
for (Map.Entry<String, Integer> entry : map.entrySet()) {
// 直接获取键和值
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}

entrySet() 方法的优势


1、 避免多次哈希查找:在遍历过程中,我们可以直接从 Map.Entry 对象中获取键和值,而不需要再次进行哈希查找,提高了效率。


2、 减少内存消耗entrySet() 方法返回的是 HashMap 内部的一个视图,不需要额外的内存来存储键的集合。


3、 提高代码可读性entrySet() 方法更直观地表现了键值对的关系,使代码更加易读和易维护。


性能比较


我们来更深入地解析性能比较,特别是 keySet()entrySet() 方法在遍历 HashMap 时的性能差异。


主要性能问题


1、 多次哈希查找: 使用 keySet() 方法遍历 HashMap 时,需要通过键调用 map.get(key) 方法获取值。这意味着每次获取值时都需要进行一次哈希查找操作。哈希查找虽然时间复杂度为 O(1),但在大量数据下,频繁的哈希查找会累积较高的时间开销。


2、 额外的内存消耗keySet() 方法返回的是一个包含所有键的集合。虽然这个集合是基于 HashMap 的键的视图,但仍然需要额外的内存来维护这个集合的结构。


更高效的选择:entrySet() 方法


相比之下,entrySet() 方法返回的是 HashMap 中所有键值对的集合 (Set<Map.Entry<K, V>>)。通过遍历这个集合,我们可以直接获取每个键值对,避免了多次哈希查找和额外的内存消耗。


性能比较示例


让我们通过一个具体的性能比较示例来详细说明:


import java.util.HashMap;
import java.util.Map;

public class HashMapTraversalComparison {
public static void main(String[] args) {
// 创建一个大的HashMap
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
map.put("key" + i, i);
}

// 测试keySet()方法的性能
long startTime = System.nanoTime(); // 记录开始时间
for (String key : map.keySet()) {
Integer value = map.get(key); // 通过键获取值
}
long endTime = System.nanoTime(); // 记录结束时间
System.out.println("keySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");

// 测试entrySet()方法的性能
startTime = System.nanoTime(); // 记录开始时间
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey(); // 直接获取键
Integer value = entry.getValue(); // 直接获取值
}
endTime = System.nanoTime(); // 记录结束时间
System.out.println("entrySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
}
}

深度解析性能比较示例


1、 创建一个大的 HashMap



Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
map.put("key" + i, i);
}


  • 创建一个包含100万个键值对的 HashMap

  • "key" + ii

  • 这个 HashMap 足够大,可以明显展示两种遍历方法的性能差异。


2、 测试 keySet() 方法的性能


long startTime = System.nanoTime(); // 记录开始时间
for (String key : map.keySet()) {
Integer value = map.get(key); // 通过键获取值
}
long endTime = System.nanoTime(); // 记录结束时间
System.out.println("keySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");


  • 使用 keySet() 方法获取所有键,并遍历这些键。

  • 在每次迭代中,通过 map.get(key) 方法获取值。

  • 记录开始时间和结束时间,计算遍历所需的总时间。


3、 测试 entrySet() 方法的性能


startTime = System.nanoTime(); // 记录开始时间
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey(); // 直接获取键
Integer value = entry.getValue(); // 直接获取值
}
endTime = System.nanoTime(); // 记录结束时间
System.out.println("entrySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");


  • 使用 entrySet() 方法获取所有键值对,并遍历这些键值对。

  • 在每次迭代中,直接从 Map.Entry 对象中获取键和值。

  • 记录开始时间和结束时间,计算遍历所需的总时间。


性能结果分析


假设上述代码的运行结果如下:


keySet() 方法遍历时间: 1200000000 纳秒
entrySet() 方法遍历时间: 800000000 纳秒

可以看出,使用 entrySet() 方法的遍历时间明显短于 keySet() 方法。这主要是因为:


1、 避免了多次哈希查找: 使用 keySet() 方法时,每次获取值都需要进行一次哈希查找。而使用 entrySet() 方法时,键和值直接从 Map.Entry 对象中获取,无需再次查找。


2、 减少了内存消耗: 使用 keySet() 方法时,额外生成了一个包含所有键的集合。而使用 entrySet() 方法时,返回的是 HashMap 内部的一个视图,无需额外的内存开销。


小结一下


通过性能比较示例,我们可以清楚地看到 entrySet() 方法在遍历 HashMap 时的效率优势。使用 entrySet() 方法不仅能避免多次哈希查找,提高遍历效率,还能减少内存消耗。


综上所述,在遍历 HashMap 时,entrySet() 方法是更优的选择。


几种高效的替代方案


除了 entrySet() 方法外,还有其他几种高效的替代方案,可以用于遍历 HashMap


以下是几种常见的高效替代方案及其优缺点分析:


1. 使用 entrySet() 方法


我们已经讨论过,entrySet() 方法是遍历 HashMap 时的一个高效选择。它直接返回键值对的集合,避免了多次哈希查找,减少了内存开销。


import java.util.HashMap;
import java.util.Map;

public class EntrySetTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);

for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
}
}

2. 使用 forEach 方法


从 Java 8 开始,Map 接口提供了 forEach 方法,可以直接对每个键值对进行操作。这种方式利用了 lambda 表达式,代码更简洁,可读性强。


import java.util.HashMap;
import java.util.Map;

public class ForEachTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);

map.forEach((key, value) -> {
System.out.println("Key: " + key + ", Value: " + value);
});
}
}

3. 使用 iterator 方法


另一种遍历 HashMap 的方法是使用迭代器 (Iterator)。这种方法适用于需要在遍历过程中对集合进行修改的情况,比如删除某些元素。


import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

public class IteratorTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);

Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
}
}

4. 使用 Streams API


Java 8 引入了 Streams API,可以结合 stream() 方法和 forEach 方法来遍历 HashMap。这种方法可以对集合进行更复杂的操作,比如过滤、映射等。


import java.util.HashMap;
import java.util.Map;

public class StreamTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);

map.entrySet().stream().forEach(entry -> {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
});
}
}

优缺点分析


entrySet() 方法



  • 优点:避免多次哈希查找,减少内存消耗,代码简单明了。

  • 缺点:没有特定缺点,在大多数情况下是最佳选择。


forEach 方法



  • 优点:代码简洁,可读性强,充分利用 lambda 表达式。

  • 缺点:仅适用于 Java 8 及以上版本。


iterator 方法



  • 优点:适用于需要在遍历过程中修改集合的情况,如删除元素。

  • 缺点:代码稍显繁琐,不如 entrySet()forEach 方法直观。


Streams API 方法



  • 优点:支持复杂操作,如过滤、映射等,代码简洁。

  • 缺点:仅适用于 Java 8 及以上版本,性能在某些情况下可能不如 entrySet()forEach


结论


在遍历 HashMap 时,entrySet() 方法是一个高效且广泛推荐的选择。对于更现代的代码风格,forEach 方法和 Streams API 提供了简洁且强大的遍历方式。如果需要在遍历过程中修改集合,可以使用 iterator 方法。根据具体需求选择合适的遍历方法,可以显著提高代码的效率和可读性。


已收录于,我的技术网站:ddkk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,等等什么都有,欢迎收藏和转发。


作者:架构师专栏
来源:juejin.cn/post/7393663398406799372
收起阅读 »

threejs 仿抖音漂移停车特效

web
最近刷到了抖音的漂移停车2的视频,感觉还蛮有趣的乍一看,实现这个漂移停车的效果需要一些东西:一辆一直往前开的小车和一个停车点,这里就做成一个小车库吧漂移停车逻辑。这个小游戏是通过往左往右触屏滑动来刹车,附带了转向和车库的碰撞处理停车后的计分逻辑之前的文章实现了...
继续阅读 »

最近刷到了抖音的漂移停车2的视频,感觉还蛮有趣的

录屏2024-01-29 22.45.28.gif

乍一看,实现这个漂移停车的效果需要一些东西:

  • 一辆一直往前开的小车和一个停车点,这里就做成一个小车库吧
  • 漂移停车逻辑。这个小游戏是通过往左往右触屏滑动来刹车,附带了转向
  • 和车库的碰撞处理
  • 停车后的计分逻辑

之前的文章实现了基于threejs的3d场景和一辆麻雀虽小五脏俱全的小车,咱们拿来接着用一下

行车物理模拟

其实之前自己实现的自车行驶超级简单,加减速、转弯都做的比较粗糙,这里引入物理库 cannon-es(cannon.js 的增强版)来帮忙做这块逻辑。物理库的作用其实就是模拟一些真实的物理效果,比如行车、物理碰撞、重力等。具体api文档 戳这里,不过只有英文文档

npm install cannon-es

先初始化一个物理世界,其实和 threejs 场景的初始化有点像,之后也是需要将物理世界的物体和 threejs 的物体一一对应地关联起来,比如这里的地面、小车和车库,这样后面物理库做计算后,再将作用后的物体的位置信息赋值到 threejs 对应物体的属性上,最后通过循环渲染(animate)就能模拟行车场景了

import * as CANNON from "cannon-es";
// ...
const world = new CANNON.World();
// 物理世界预处理,这个可以快速排除明显不发生碰撞的物体对,提高模拟效率
world.broadphase = new CANNON.SAPBroadphase(world);
// 物理世界的重力向量
world.gravity.set(0, -9.8, 0);
// 刚体之间接触面的默认摩擦系数
world.defaultContactMaterial.friction = 0;

小车对象

cannon-es 的 RaycastVehicle 类可以辅助我们管理物理世界的小车对象,它提供了很多蛮好用的api,不仅可以帮助我们更好地管理车轮,而且能很好地根据地形运动

物理世界物体的基本要素有形状(常见的有Box长方体/Plane平面/Sphere球体)、材质 Material 和刚体 Body,类比 threejs 中的几何体、材质和 Mesh。创建刚体后别忘了将它添加到物理世界里,和 threejs 将物体添加到 scene 场景里类似

 // 创建小车底盘形状,这里就是一个长方体
const chassisShape = new CANNON.Box(new CANNON.Vec3(1, 0.3, 2));
// 创建质量为150kg的小车刚体。物理世界的质量单位是kg
const chassisBody = new CANNON.Body({ mass: 150 });
// 关联刚体和形状
chassisBody.addShape(chassisShape);
// 设定刚体位置
chassisBody.position.set(0, 0.4, 0);
// 基于小车底盘创建小车对象
const vehicle = new CANNON.RaycastVehicle({
chassisBody,
// 定义车辆的方向轴(0:x轴,1:y轴,2:z轴),让它符合右手坐标系
// 车辆右侧
indexRightAxis: 0,
// 车辆上方
indexUpAxis: 1,
// 车辆前进方向
indexForwardAxis: 2,
});
// 将小车添加到物理世界里,类比 threejs 的 scene.add()
vehicle.addToWorld(world);

四个车轮

接下来定义下车轮对象,用到了 Cylinder 这种圆柱体的形状,然后要注意做好旋转值 Quaternion 的调整。这部分会稍微复杂些,可以耐心看下注释:

 // 车轮配置,详情配置参考 https://pmndrs.github.io/cannon-es/docs/classes/RaycastVehicle.html#addWheel
const options = {
radius: 0.4, // 轮子半径
directionLocal: new CANNON.Vec3(0, -1, 0), // 轮子方向向量,指轮子从中心点出发的旋转方向
suspensionStiffness: 45,
suspensionRestLength: 0.4,
frictionSlip: 5, // 滑动摩擦系数
dampingRelaxation: 2.3,
dampingCompression: 4.5,
maxSuspensionForce: 200000,
rollInfluence: 0.01,
axleLocal: new CANNON.Vec3(-1, 0, 0),
chassisConnectionPointLocal: new CANNON.Vec3(1, 1, 0),
maxSuspensionTravel: 0.25,
customSlidingRotationalSpeed: -30,
useCustomSlidingRotationalSpeed: true,
};
const axlewidth = 0.7;
// 设置第一个车轮的连接点位置
options.chassisConnectionPointLocal.set(axlewidth, 0, -1);
// 按指定配置给小车添加第一个车轮,其他车轮类似
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, -1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(axlewidth, 0, 1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, 1);
vehicle.addWheel(options);
// 四个车轮
const wheelBodies: CANNON.Body[] = [];
const wheelVisuals: THREE.Mesh[] = [];
vehicle.wheelInfos.forEach(function (wheel) {
const shape = new CANNON.Cylinder(
wheel.radius,
wheel.radius,
wheel.radius / 2,
20
);
const body = new CANNON.Body({ mass: 1, material: wheelMaterial });
// 刚体可以是动态(DYNAMIC)、静态(STATIC)或运动学(KINEMATIC)
body.type = CANNON.Body.KINEMATIC;
// 0表示这个刚体将与所有其他未设置特定过滤组的刚体进行碰撞检测
body.collisionFilterGr0up = 0;
// 使用setFromEuler方法将欧拉角转换为四元数,欧拉角的值为-Math.PI / 2(即-90度或-π/2弧度)
const quaternion = new CANNON.Quaternion().setFromEuler(
-Math.PI / 2,
0,
0
);
body.addShape(shape, new CANNON.Vec3(), quaternion);
wheelBodies.push(body);
// 创建3d世界的车轮对象
const geometry = new THREE.CylinderGeometry(
wheel.radius,
wheel.radius,
0.4,
32
);
const material = new THREE.MeshPhongMaterial({
color: 0xd0901d,
emissive: 0xaa0000,
flatShading: true,
side: THREE.DoubleSide,
});
const cylinder = new THREE.Mesh(geometry, material);
cylinder.geometry.rotateZ(Math.PI / 2);
wheelVisuals.push(cylinder);
scene.add(cylinder);
});

这一步很关键,需要在每次物理模拟计算结束后 (postStep事件的回调函数) 更新车轮的位置和转角

// ...
world.addEventListener("postStep", function () {
for (let i = 0; i < vehicle.wheelInfos.length; i++) {
vehicle.updateWheelTransform(i);
const t = vehicle.wheelInfos[i].worldTransform;
// 更新物理世界车轮对象的属性
wheelBodies[i].position.copy(t.position);
wheelBodies[i].quaternion.copy(t.quaternion);
// 更新3d世界车轮对象的属性
wheelVisuals[i].position.copy(t.position);
wheelVisuals[i].quaternion.copy(t.quaternion);
}
});

车辆行驶和转向

监听键盘事件,按下上下方向键给一个前后的引擎动力,按下左右方向键给车轮一个转角值

// 引擎动力值
const engineForce = 3000;
// 转角值
const maxSteerVal = 0.7;
// 刹车作用力
const brakeForce = 20;
// ...
// 刹车
function brakeVehicle() {
// 四个车轮全部加刹车作用力
vehicle.setBrake(brakeForce, 0);
vehicle.setBrake(brakeForce, 1);
vehicle.setBrake(brakeForce, 2);
vehicle.setBrake(brakeForce, 3);
}
function handleNavigate(e: any) {
if (e.type != "keydown" && e.type != "keyup") {
return;
}
const isKeyup = e.type === "keyup";
switch (e.key) {
case "ArrowUp":
// 给第2/3个车轮加引擎动力
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 3);
break;
case "ArrowDown":
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 3);
break;
case "ArrowLeft":
// 设置车轮转角
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 3);
break;
}
brakeVehicle();
}
window.addEventListener("keydown", handleNavigate);
window.addEventListener("keyup", handleNavigate);

然后在每一帧里重新计算物体的物理值,并赋值给 3d 世界的小车属性,就可以实现行车效果

function updatePhysics() {
world.step(1 / 60);
egoCar.position.copy(chassisBody.position);
egoCar.quaternion.copy(chassisBody.quaternion);
}
// ...
const animate = () => {
stats.begin();
// ...
updatePhysics();
// ...
stats.end();
requestAnimationFrame(animate);
};
animate();

地面优化

地面看起来太光滑,显得有点假,咱们先给地面加上有磨砂质感的纹理贴图,同时隐藏掉辅助网格

// ...
// 加载纹理贴图
textureLoader.load("/gta/floor.jpg", (texture) => {
const planeMaterial = new THREE.MeshLambertMaterial({
// 将贴图对象赋值给材质
map: texture,
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 地面接受阴影
plane.receiveShadow = true;
plane.rotation.x = Math.PI / 2;
scene.add(plane);
});

加载完贴图,生成3d场景的地面对象后,别忘了创建地面刚体并关联。这里还要定义地面刚体的物理材质,类比 threejs 的材质,会影响不同刚体之间摩擦和反弹的效果

// ...
// 定义地板的物理材质
const groundMaterial = new CANNON.Material("groundMaterial");
// 定义车轮的物理材质,其实之前代码用过了,可以留意下
const wheelMaterial = new CANNON.Material("wheelMaterial");
// 定义车轮和地板之间接触面的物理关联,在这里定义摩擦反弹等系数
const wheelGroundContactMaterial = new CANNON.ContactMaterial(
wheelMaterial,
groundMaterial,
{
// 摩擦系数
friction: 0.5,
// 反弹系数,0表示没有反弹
restitution: 0,
}
);
world.addContactMaterial(wheelGroundContactMaterial);
// ...
textureLoader.load("/gta/floor.jpg", (texture) => {
// ...
// 地面刚体
const q = plane.quaternion;
const planeBody = new CANNON.Body({
// 0说明物体是静止的,发生物理碰撞时不会相互移动
mass: 0,
// 应用接触面材质
material: groundMaterial,
shape: new CANNON.Plane(),
// 和3d场景的旋转值保持一致。在Cannon.js中,刚体的旋转可以通过四元数来表示,而不是传统的欧拉角或轴角表示法
quaternion: new CANNON.Quaternion(-q._x, q._y, q._z, q._w),
});
world.addBody(planeBody);
});

这回开起来可顺畅许多了,场景和自车旋转也变得更自然一些,感谢开源 ~

搭建车库

咱就搭个棚,一个背景墙、两个侧边墙、加一个屋顶和地板,其实都是些立方体,拼装成网格对象 Mesh 后,按照一定的位置和旋转拼在一起组成小车库,参考代码:

createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
const sider1 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider1.rotation.y = Math.PI / 2;
sider1.position.set(-1.5, 0.1, -50);
this.scene.add(sider1);
const sider2 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider2.rotation.y = Math.PI / 2;
sider2.position.set(1.5, 0.1, -50);
this.scene.add(sider2);
// 创建屋顶
const roof = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({
color: 0xcccccc,
// 注意:这个值不为true的话,设置opacity是没用的
transparent: true,
opacity: 0.8,
})
);
roof.rotation.x = Math.PI / 2;
roof.position.set(0, 2, -50);
this.scene.add(roof);
// 创建地板
const floor = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({ color: 0x666666 })
);
floor.rotation.x = Math.PI / 2;
floor.position.set(0, 0.1, -50);
this.scene.add(floor);
}

好了,一个稍微有点模样的小车库就大功告成

创建车库刚体

先加个背景墙的物理刚体

createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
// ...
// physic
const houseShape = new CANNON.Box(new CANNON.Vec3(1.5, 4, 0.1));
const houseBody = new CANNON.Body({ mass: 0 });
houseBody.addShape(houseShape);
houseBody.position.set(0, 0, -53);
this.world.addBody(houseBody);
}
// ...

其他的墙体类似的处理,屋顶先不管吧,小车应该也够不着。来,先撞一下试试

漂移停车

其实达到一定速度,通过方向键就能做一个甩尾漂移倒车入库

  1. 提供一个弹射的初始动力
// ...
animate();
setTimeout(() => {
// 给后轮上点动力
vehicle.applyEngineForce(2000, 2);
vehicle.applyEngineForce(2000, 3);
}, 100);
  1. 电脑端根据方向键触发漂移,这里注意要消除后轮的动力
// ...
case "ArrowLeft":
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
// ...
  1. 移动端根据触屏方向触发。需要注意此时要把相机控制器关掉,避免和触屏操作冲突。计算触发方向的逻辑参考
 // 计算划过的角度
function getAngle(angx: number, angy: number) {
return (Math.atan2(angy, angx) * 180) / Math.PI;
}
// 计算触屏方向
function getDirection(
startx: number,
starty: number,
endx: number,
endy: number
): ESlideDirection {
const angx = endx - startx;
const angy = endy - starty;
let result = ESlideDirection.;
if (Math.abs(angx) < 2 && Math.abs(angy) < 2) {
return result;
}
const angle = getAngle(angx, angy);
if (angle >= -135 && angle <= -45) {
result = ESlideDirection.Top;
} else if (angle > 45 && angle < 135) {
result = ESlideDirection.Bottom;
} else if (
(angle >= 135 && angle <= 180) ||
(angle >= -180 && angle < -135)
) {
result = ESlideDirection.Left;
} else if (angle >= -45 && angle <= 45) {
result = ESlideDirection.Right;
}
return result;
}
let startx = 0;
let starty = 0;
document.addEventListener("touchstart", (e) => {
startx = e.touches[0].pageX;
starty = e.touches[0].pageY;
});
document.addEventListener("touchend", function (e) {
const endx = e.changedTouches[0].pageX;
const endy = e.changedTouches[0].pageY;
const direction = getDirection(startx, starty, endx, endy);
// 根据方向做转向和刹车的处理,和上面电脑侧左右键的逻辑一致就行了
// ...
})

计算分数

根据小车和车库角度偏差和中心点偏差来综合得分,这里就不细究了,浅浅定个规则:

  • 不入库或没倒车:0分
  • 其他情况:50分 + 角度分(20比例) + 中心分(30比例)

车停住后,先算出分数,再加个数字递增的效果,用 setInterval 实现就好了。不过这里要注意用回调函数的方式更新 state 值,避免闭包引起值不更新的问题

计分组件实现代码参考:

export const Overlay = observer(() => {
const [score, setScore] = useState(0);
useEffect(() => {
if (vehicleStore.score) {
// 计分动画
const timer = setInterval(() => {
// 回调方式更新state
setScore((score) => {
if (score + 1 === vehicleStore.score) {
clearInterval(timer);
}
return score + 1;
});
}, 10);
}
}, [vehicleStore.score]);

if (!vehicleStore.isStop) {
return null;
}

return (
<div className={styles["container"]}>
<div className={styles["score-box"]}>
<div className={styles["score-desc"]}>得分div>
<div>{score}div>
div>
div>
);
});

那么问题来了,怎么监听它停下了?可以加一个速度的阈值 velocityThreshold,如果小车刚体的速度低于这个阈值就判定小车停下了。然后通过 mobx 状态库建立一个 vehicleStore,主要是维护 isStop(是否停止) 和 score(分数) 这两个变量,变化后自动通知计分组件更新,这部分逻辑可以参考源码实现 ~

// ...
const velocityThreshold = 0.01;
function updatePhysics() {
world.step(1 / 60);
// ...
// 检查刚体的速度,小于阈值视为停止
if (
chassisBody.velocity.length() < velocityThreshold &&
// 停车标识
!vehicleStore.isStop
) {
console.log("小车已经停止");
vehicleStore.stop();
// 触发计分逻辑,自行参考源码
// ...
vehicleStore.setScore(score);
}
}
// ...

传送门


作者:_lucas
来源:juejin.cn/post/7331070678693380122
收起阅读 »

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



作者:bytebeats
来源:juejin.cn/post/7342861726000791603
收起阅读 »

Java 8 魔法:利用 Function 接口告别冗余代码,打造高效断言神器

前言 在 Java 开发的征途中,我们时常与重复代码不期而遇。这些重复代码不仅让项目显得笨重,更增加了维护成本。幸运的是,Java 8 带来了函数式编程的春风,以 Function 接口为代表的一系列新特性,为我们提供了破除这一难题的利剑。本文将以一个实际应用...
继续阅读 »

前言


Java 开发的征途中,我们时常与重复代码不期而遇。这些重复代码不仅让项目显得笨重,更增加了维护成本。幸运的是,Java 8 带来了函数式编程的春风,以 Function 接口为代表的一系列新特性,为我们提供了破除这一难题的利剑。本文将以一个实际应用场景为例,即使用 Java 8 的函数式编程特性来重构数据有效性断言逻辑,展示如何通过 SFunction(基于 Java 8Lambda 表达式封装)减少代码重复,从而提升代码的优雅性和可维护性。


背景故事:数据校验的烦恼


想象一下,在一个复杂的业务系统中,我们可能需要频繁地验证数据库中某个字段值是否有效,是否符合预期值。传统的做法可能充斥着大量相似的查询逻辑,每次都需要手动构建查询条件、执行查询并处理结果,这样的代码既冗长又难以维护。


例如以下两个验证用户 ID 和部门 ID 是否有效的方法,虽然简单,但每次需要校验不同实体或不同条件时,就需要复制粘贴并做相应修改,导致代码库中充满了大量雷同的校验逻辑,给维护带来了困扰。


// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}

// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}

Java 8 的魔法棒:函数式接口


Java 8 引入了函数式接口的概念,其中 Function<T, R> 是最基础的代表,它接受一个类型 T 的输入,返回类型 R 的结果。而在 MyBatis Plus 等框架中常用的 SFunction 是对 Lambda 表达式的进一步封装,使得我们可以更加灵活地操作实体类的属性。


实战演练:重构断言方法


下面的 ensureColumnValueValid 方法正是利用了函数式接口的魅力,实现了对任意实体类指定列值的有效性断言:


/**
* 确认数据库字段值有效(通用)
*
* @param <V> 待验证值的类型
* @param valueToCheck 待验证的值
* @param columnExtractor 实体类属性提取函数
* @param queryExecutor 单条数据查询执行器
* @param errorMessage 异常提示信息模板
*/

public static <T, R, V> void ensureColumnValueValid(V valueToCheck, SFunction<T, R> columnExtractor, SFunction<LambdaQueryWrapper<T>, T> queryExecutor, String errorMessage) {
if (valueToCheck == null) return;

LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(columnExtractor);
wrapper.eq(columnExtractor, valueToCheck);
wrapper.last("LIMIT 1");

T entity = queryExecutor.apply(wrapper);
R columnValue = columnExtractor.apply(entity);
if (entity == null || columnValue == null)
throw new DataValidationException(String.format(errorMessage, valueToCheck));
}

这个方法接受一个待验证的值、一个实体类属性提取函数、一个单行数据查询执行器和一个异常信息模板作为参数。通过这四个参数,不仅能够进行针对特定属性的有效性检查,而且还能生成具有一致性的异常信息。


对比分析


使用 Function 改造前


// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}

// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}

使用 Function 改造后


public void assignTaskToUser(AddOrderDTO dto) {
ensureColumnValueValid(dto.getUserId(), User::getId, userDao::getOne, "用户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getCustomerId(), Customer::getId, customerDao::getOne, "客户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getSupplieId(), Supplie::getId, supplierDao::getOne, "供应商ID无效");

// 现在可以确信客户存在
Customer cus = customerDao.findById(dto.getCustomerId());

// 创建订单的逻辑...
}

对比上述两段代码,我们发现后者不仅大幅减少了代码量,而且通过函数式编程,表达出更为清晰的逻辑意图,可读性和可维护性都有所提高。


优点



  1. 减少重复代码: 通过 ensureColumnValueValid 方法,所有涉及数据库字段值有效性检查的地方都可以复用相同的逻辑,将变化的部分作为参数传递,大大减少了因特定校验逻辑而产生的代码量。

  2. 增强代码复用: 抽象化的校验方法适用于多种场景,无论是用户ID、订单号还是其他任何实体属性的校验,一套逻辑即可应对。

  3. 提升可读性和维护性: 通过清晰的函数签名和 Lambda 表达式,代码意图一目了然,降低了后续维护的成本。

  4. 灵活性和扩展性: 当校验规则发生变化时,只需要调整 ensureColumnValueValid 方法或其内部实现,所有调用该方法的地方都会自动受益,提高了系统的灵活性和扩展性。


举一反三:拓展校验逻辑的边界


通过上述的实践,我们见识到了函数式编程在简化数据校验逻辑方面的威力。但这只是冰山一角,我们可以根据不同的业务场景,继续扩展和完善校验逻辑,实现更多样化的校验需求。以下两个示例展示了如何在原有基础上进一步深化,实现更复杂的数据比较和验证功能。


断言指定列值等于预期值


首先,考虑一个场景:除了验证数据的存在性,我们还需确认查询到的某列值是否与预期值相符。这在验证用户角色、状态变更等场景中尤为常见。为此,我们设计了 validateColumnValueMatchesExpected 方法:


/**
* 验证查询结果中指定列的值是否与预期值匹配
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValue 期望的列值
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值与预期值不匹配时抛出异常
*/

public static <T, R, C> void validateColumnValueMatchesExpected(
SFunction<T, R> targetColumn, R expectedValue,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage)
{

// 创建查询包装器,选择目标列并设置查询条件
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);

// 执行查询方法
T one = queryMethod.apply(wrapper);
// 如果查询结果为空,则直接返回,视为验证通过(或忽略)
if (one == null) return;

// 获取查询结果中目标列的实际值
R actualValue = targetColumn.apply(one);

// 比较实际值与预期值是否匹配,这里假设notMatch是一个自定义方法用于比较不匹配情况
boolean doesNotMatch = notMatch(actualValue, expectedValue);
if (doesNotMatch) {
// 若不匹配,则根据错误信息模板抛出异常
throw new RuntimeException(String.format(errorMessage, expectedValue, actualValue));
}
}

// 假设的辅助方法,用于比较值是否不匹配,根据实际需要实现
private static <R> boolean notMatch(R actual, R expected) {
// 示例简单实现为不相等判断,实际情况可能更复杂
return !Objects.equals(actual, expected);
}


这个方法允许我们指定一个查询目标列(targetColumn)、预期值(expectedValue)、查询条件列(conditionColumn)及其对应的条件值(conditionValue),并提供一个查询方法(queryMethod)来执行查询。如果查询到的列值与预期不符,则抛出异常,错误信息通过 errorMessage 参数定制。


应用场景:
例如在一个权限管理系统中,当需要更新用户角色时,系统需要确保当前用户的角色在更新前是 “普通用户”,才能将其升级为 “管理员”。此场景下,可以使用 validateColumnValueMatchesExpected 方法来验证用户当前的角色是否确实为“普通用户”。


// 当用户角色不是 “普通用户” 时抛异常
validateColumnValueMatchesExpected(User::getRoleType, "普通用户", User::getId, userId, userMapper::getOne, "用户角色不是普通用户,无法升级为管理员!");

断言指定值位于期望值列表内


进一步,某些情况下我们需要验证查询结果中的某一列值是否属于一个预设的值集合。例如,验证用户角色是否合法。为此,我们创建了 validateColumnValueMatchesExpectedList 方法:


/**
* 验证查询结果中指定列的值是否位于预期值列表内
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValueList 期望值的列表
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值不在预期值列表内时抛出异常
*/

public static <T, R, C> void validateColumnValueInExpectedList(
SFunction<T, R> targetColumn, List<R> expectedValueList,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage)
{

LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);

T one = queryMethod.apply(wrapper);
if (one == null) return;

R actualValue = targetColumn.apply(one);
if (actualValue == null) throw new RuntimeException("列查询结果为空");

if (!expectedValueList.contains(actualValue)) {
throw new RuntimeException(errorMessage);
}
}

这个方法接受一个目标列(targetColumn)、一个预期值列表(expectedValueList)、查询条件列(conditionColumn)及其条件值(conditionValue),同样需要一个查询方法(queryMethod)。如果查询到的列值不在预期值列表中,则触发异常。


应用场景: 在一个电商平台的订单处理流程中,系统需要验证订单状态是否处于可取消的状态列表里(如 “待支付”、“待发货”)才允许用户取消订单。此时,validateColumnValueInExpectedList 方法能有效确保操作的合法性。


// 假设 OrderStatusEnum 枚举了所有可能的订单状态,cancelableStatuses 包含可取消的状态
List<String> cancelableStatuses = Arrays.asList(OrderStatusEnum.WAITING_PAYMENT.getValue(), OrderStatusEnum.WAITING_DELIVERY.getValue());

// 验证订单状态是否在可取消状态列表内
validateColumnValueInExpectedList(Order::getStatus, cancelableStatuses, Order::getOrderId, orderId, orderMapper::selectOne, "订单当前状态不允许取消!");

通过这两个扩展方法,我们不仅巩固了函数式编程在减少代码重复、提升代码灵活性方面的优势,还进一步证明了通过抽象和泛型设计,可以轻松应对各种复杂的业务校验需求,使代码更加贴近业务逻辑,易于理解和维护。


核心优势



  1. 代码复用:通过泛型和函数式接口,该方法能够适应任何实体类和属性的校验需求,大大减少了重复的查询逻辑代码。

  2. 清晰表达意图:方法签名直观表达了校验逻辑的目的,提高了代码的可读性和可维护性。

  3. 灵活性:使用者只需提供几个简单的 Lambda 表达式,即可完成复杂的查询逻辑配置,无需关心底层实现细节。

  4. 易于维护与扩展:



    1. 当需要增加新的实体验证时,仅需调用 ensureColumnValueValid 并传入相应的参数,无需编写新的验证逻辑,降低了维护成本。

    2. 修改验证规则时,只需调整 ensureColumnValueValid 内部实现,所有调用处自动遵循新规则,便于统一管理。

    3. 异常处理集中于 ensureColumnValueValid 方法内部,统一了异常抛出行为,避免了在多个地方处理相同的逻辑错误,减少了潜在的错误源。




函数式编程的力量


通过这个实例,我们见证了函数式编程在简化代码、提高抽象层次上的强大能力。在 Java 8 及之后的版本中,拥抱函数式编程思想,不仅能够使我们的代码更加简洁、灵活,还能在一定程度上促进代码的正确性和可测试性。因此,无论是日常开发还是系统设计,都值得我们深入探索和应用这一现代编程范式,让代码如魔法般优雅而高效。


作者:最光阴2023
来源:juejin.cn/post/7384256110280572980
收起阅读 »

火山引擎多款大模型产品能力升级,开发者使用门槛再降低

12 月 19 日,火山引擎 Force 原动力大会开发者论坛在上海世博中心举行。上午主论坛中亮相的火山方舟、扣子、豆包MarsCode 等产品,展示了从高代码到低代码,火山引擎如何在大模型时代通过大模型产品和能力,助力开发者高效创新。作为火山引擎的大模型服务...
继续阅读 »

12 月 19 日,火山引擎 Force 原动力大会开发者论坛在上海世博中心举行。上午主论坛中亮相的火山方舟、扣子、豆包MarsCode 等产品,展示了从高代码到低代码,火山引擎如何在大模型时代通过大模型产品和能力,助力开发者高效创新。

作为火山引擎的大模型服务平台,火山方舟于 2023 年发布,在助力企业高效安全地应用大模型以外,也是开发者的友好帮手。本次会上,火山引擎智能算法负责人吴迪发布了方舟应用实验室——一套高代码 SDK 和企业级示例模板,可为客户和开发者提供完整的场景化解决方案和完整的源代码,支持业务深度自定义拓展,提高代码使用体验。吴迪表示,“我们将着眼于那些高难度,高价值的问题,用开源的方式把应该怎么做告诉大家,也鼓励开发者基于我们的代码去随意自定义和发挥,构造各自的产品”。通过方舟实验室,大模型使用门槛将进一步降低。

除了高代码智能体,火山方舟还推出了 API 接口,开发者通过这些接口即可高效调用大模型。本次亮相的火山 AI 搜推引擎则通过多模态大模型与信息检索技术的融合,支持自然语言理解,多轮多模态交互,使用户能够便捷地获取全域丰富信息,为用户提供一站式搜索推荐服务。

图片1.png

火山引擎智能算法负责人 吴迪

针对数据研发治理场景,火山引擎大数据研发治理套件 DataLeap 在本次会上公布了全新能力——开放平台。据介绍,DataLeap 开放平台提供完整的 IDE 界面扩展和流水线扩展体系以及多种开放能力,让开发者只需根据开发手册,就能低成本、快速完成扩展程序的开发。结合先进的 AI 能力,DataLeap 将智能化融入开发、运维、资产查询等数据研发流程中,并在开放平台中以插件形式开放,为开发者提供开发助手、运维助手等工具,进一步降低开发门槛,提升运维效率。

图片2.png

火山引擎数智平台产品总监 张辉

本次会上展示的大模型应用工具也同样带来了不少亮点,让 AI 应用开发可以更简单。

扣子是新一代应用开发平台,在今年 5 月份的 Force 大会上首次亮相。时隔半年多,扣子来到 1.5,完成了 3 个方面的重要升级。

首先,扣子 1.5 提供全新的应用开发环境,支持 Chatbot 之外更丰富的 AI 应用范式,包括小程序、API、WebSDK 等多种形态。结合扣子推出的无限画布,用扣子已经可以低代码、可视化搭建出完整的 AI 应用。

其次,不仅仅是大语言模型,扣子接入的多模态能力也全面升级。其中在语音交互能力方面,借助火山引擎 RTC 超低延时、智能打断、抗弱网的优势,能够实现更流畅、更自然、更可靠的语音通话功能;在硬件应用方面,通过和火山引擎边缘智能的协同,借助边缘智能理解边缘设备能力,能让大模型更好地连接物理世界。而 12 月 18 日新发布的豆包·视觉理解模型、豆包文生图 2.1 和豆包音乐模型,在扣子也都能通过插件的方式第一时间体验到。

在本次升级中,扣子同步发布了模板功能,旨在帮助所有用户更快地理解扣子各项能力的最佳实践。扣子产品经理姚慧在现场向大家展示了机智云 AI 养鱼项目、猫王妙播 AI 音响、和府捞面顾客点评分析智能体等多个利用扣子实现的 AI 场景案例,鼓励开发者可以快速投入到所希望开发的应用方向中去。

图片3.png

扣子产品经理 姚慧

姚慧表示,扣子已成为 AI 时代开发者首选的应用开发平台。目前扣子有超过100万的活跃开发者,发布超过200万个智能体。扣子 1.5 通过更强的产品能力进一步降低了开发者的使用门槛,让 Al 离应用再近一步。

在大模型时代,开发者还急需更高效的开发工具来提升编程效率。此次大会上,火山引擎协同豆包MarsCode 为开发者提供了智能 AI IDE,在代码补全、Bug 修复、代码问答等各编程阶段为开发者提供协助支持。在关注代码生成 AI 化的同时,豆包MarsCode 也关注开发工具本身的 AI 化,通过交互方式的转变和多种大模型能力的接入,为开发者提供更好的用户体验。

图片4.png

豆包MarsCode 产品经理 王海建

“大模型时代,开发者的定义已经被极大地拓宽,开发者画像变得更加丰富。”正如火山引擎总裁谭待在大会开场时的致辞所言,火山引擎将通过提供更易用的 AI 应用开发平台和工具普惠开发者,与大家一起共创无限可能。(作者:李双)

收起阅读 »

如果你没有必须要离职的原因,我建议你在忍忍

自述 本人成都,由于一些原因我在八月离职了,因为我终于脱离了那个压抑的环境,我没有自己想象中的那么开心,我离职的那天,甚至后面很长的一段时间;离职后的我回了一趟家,刚好在最热的那几天,在家躺了几天,然后又出去逛了逛,玩了差不多一个月吧!我觉得心情逐渐恢复了;然...
继续阅读 »

自述


本人成都,由于一些原因我在八月离职了,因为我终于脱离了那个压抑的环境,我没有自己想象中的那么开心,我离职的那天,甚至后面很长的一段时间;离职后的我回了一趟家,刚好在最热的那几天,在家躺了几天,然后又出去逛了逛,玩了差不多一个月吧!我觉得心情逐渐恢复了;然后开始慢慢的投递简历。


前期


刚投递简历那会,基本上每天都是耍耍哒哒的投递;有面试就去面试,没有面试就在家刷抖音也不看看面试题,可能我找工作的状态还在几年前或者还没从上家公司的状态中走出来,也有可能我目前有一点存款不是特别焦虑,所以也没认真的找。


就这样刷刷哒哒的又过了半月,然后有许多朋友跟我说他们被裁员了,问他们的打算是怎么样的:有的人休息了两三天就开始了找工作当中,而有的人就玩几个月再说。


休息两三天就开始找工作的人基本上都是有家庭有小孩的,反之基本上都是单身。


在跟他们聊天的过程中发现,有些人半年没找到工作了,也有一些人一年都没有找到工作了。可能是年级大了、也可能是工资不想要的太低吧!但是工作机会确实比原来少很多。


在听了大家的话以后,我觉得我差不多也该认真找工作了,我开始逐渐投递简历。


疯狂投递简历


我在9月的下旬开始了简历的修改以及各大招聘App的下载,拉钩、智联、boos以及一下小程序的招聘软件(记不住名字了,因为没啥效果);在我疯狂的投递了几天以后我迎来了第一家面试,是一个线上面试;刚一来就给了我迎头一棒,或许我只忙着修改简历和投递简历去了,没有去背面试题吧(网上说现在都问场景题,所以没准备);


具体的问题我记不全了,但是我记得这么一个问题,面试官问:“深克隆有哪些方法”,我回答的是递归,然后他说还有吗?我直接呆住说不知道了。然后我们就结束了面试,最后他跟我说了这么一句话:“现在的市场行情跟原来没法比,现在的中级基本上要原来的高级的水平,现在的初级也就是原来的中级的水平,所以问的问题会比原来难很多,你可以在学习一下,看你的简历是很不错的;至少简历是这样的。”


当这个面试结束以后我想了想发现是这样的,不知是我还没有接受或者说还没有进入一个面试的状态,还是因为我不想上班的原因,导致我连一些基本的八股文都不清楚,所以我决定开始学习。


给准备离职或者已经离职的朋友们一个忠告:“做任何事情都需提前准备,至少在找工作上是这样的。”


学习


我去看了招聘网站的技术要求(想了解下企业需要的一些技术),不看不知道一看吓一跳,真的奇葩层出不穷,大概给大家概述一下:



  • 开发三班倒:分为早中晚班

  • 要你会vue+react+php+java等技术(工资8-12)

  • 要你会基本的绘画(UI)以及会后端的一些工作,目前这些都需要你一个人完成

  • 要你会vue+react+fluter;了解electron以及3d等

  • 还有就是你的项目跟我们的项目不一致的。


我看到这些稀奇古怪的玩意有点失望,最终我选择了fabricjs进行学习,最开始的时候就是在canvas上画了几个矩形,感觉挺不错的;然后我就想这不是马上快要国庆了吗?我就想用fabric做一个制作头像的这么一个工具插件,在经过两天的开发成功将其制作了出来,并且发布到了网站上(插件tools),发布第一天就有使用的小伙伴给我提一些宝贵的建议了,然后又开始了调整,现在功能也越来越多;


fabricjs在国内的资料很少,基本上就那么几篇文章,没有办法的我就跑去扒拉他们的源码看,然后拷贝需要的代码在修修改改(毕竟比较菜只能这样....);然后在学习fabric的时候也会去学习一些基本知识,比如:js内置方法、手写防抖节流、eventloop、闭包(一些原理逻辑)、深拷贝、内存回收机制等等。


在学习的过程中很难受,感觉每天都是煎熬;每次都想在床上躺着,但是想想还是放弃了,毕竟没有谁会喜欢一个懒惰的人...


在战面试(HR像是刷KPI)


在有所准备的情况下再去面试时就得心应手了,基本上没有太多的胆怯,基本上问啥都知道一些,然后就在面试的时候随机应变即可,10月我基本上接到的面试邀请大概有10多家,然后有几家感觉工资低了就没去面试,去面试了的应该有7/8家的样子,最终只要一家录取。


说说其中一家吧(很像刷KPI的一家):这是一家做ai相关的公司,公司很大,看资料显示时一家中外合资的企业,进去以后先开始了一轮笔试题(3/4页纸),我大概做了50分钟的样子;我基本上8层都答对了(因为他的笔试题很多我都知道嘛,然后有一些还写了几个解决方案的),笔试完了以后,叫我去机试;机试写接口;而且还是在规定的一个网站写(就给我一个网站,然后说写一个接口返回正确结果就行;那个网站我都不会用);我在哪儿磨磨蹭蹭了10多分钟以后,根据node写了一个接口给了hr;然后HR说你这个在我们网站上不能运行。我站起来就走了...


其实我走的原因还有一个,就是他们另一个HR对带我进来的这个HR说:你都没有协调好研发是否有时间,就到处招面试...


是否离职


如果你在你现在这公司还能呆下去的情况下,我建议你还是先呆呆看吧!目前这个市场行情很差,你看到我有10来个面试,但是你知道嘛?我沟通了多少:



  • boos沟通了差不多800-900家公司,邀请我投递简历的只有100家左右。邀请我面试的只有8/9家。

  • 智联招聘我投递了400-600家,邀请我面试的只有1家。

  • 拉钩这个不说了基本上没有招聘的公司(反反复复就那几家);投递了一个月后有一家叫我去面试的,面试了差不多50来分钟;交谈的很开心,他说周一周二给我回复,结果没有回复,我发消息问;也没有回复;看招聘信息发现(邀约面试800+)


我离职情非得已,愿诸君与我不同;如若您已离职,愿您早日找到属于自己的路,不一定是打工的路;若你在职,请在坚持坚持;在坚持的同时去做一些对未来有用的事情,比如:副业、耍个男女朋友、拓展一下圈子等等。


后续的规划


在经历了这次离职以后,我觉得我的人生应该进行好好的规划了;不能为原有的事物所影响,不能为过去所迷茫;未来还很长,望诸君互勉;


未来的计划大致分为几个方向:



  • 拓展自己的圈子(早日脱单)

  • 学习开发鸿蒙(我已经在做了,目前开发的app在审核),发布几款工具类app(也算是为国内唯一的系统贡献一些微弱的力量吧!)

  • 持续更新我在utools上的绘图插件

  • 学习投资理财(最近一月炒股:目前赚了4000多了)

  • 持续更新公众号(前端雾恋)、掘金等网站技术文章


结尾


我们的生活终将回归正轨,所有的昨天也将是历史,不必遗憾昨天,吸取教训继续前进。再见了...


作者:雾恋
来源:juejin.cn/post/7435289649273569334
收起阅读 »

入职第一天,看了公司代码,牛马沉默了

web
入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。打开代码发现问题不断读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置 一边获取WEB-INF下的配置文件,一...
继续阅读 »

入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。

4f7ca8c685324356868f65dd8862f101~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg

打开代码发现问题不断

  1. 读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置

image.png

image.png

image.png 一边获取WEB-INF下的配置文件,一边用外部配置文件进行覆盖,有人可能会问既然覆盖,那可以全在外部配置啊,问的好,如果全用外部配置,咱们代码获取属性有的加上了项目前缀(上面的两个put),有的没加,这样配置文件就显得很乱不可取,所以形成了分开配置的局面,如果接受混乱,就写在外部配置;不能全写在内部配置,因为

prop_c.setProperty(key, value);

value获取外部配置为空的时候会抛出异常;properties底层集合用的是hashTable

public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
}
  1. 很多参数写死在代码里,如果有改动,工作量会变得异常庞大,举例权限方面伪代码
role.haveRole("ADMIN_USE")
  1. 日志打印居然sout和log混合双打

image.png

image.png

先不说双打的事,对于上图这个,应该输出包括堆栈信息,不然定位问题很麻烦,有人可能会说e.getMessage()最好,可是生产问题看多了发现还是打堆栈好;还有如果不是定向返回信息,仅仅是记录日志,完全没必要catch多个异常,一个Exception足够了,不知道原作者这么写的意思是啥;还是就是打印日志要用logger,用sout打印在控制台,那我日志文件干啥;

4.提交的代码没有技术经理把关,下发生产包是个人就可以发导致生产环境代码和本地代码或者数据库数据出现不一致的现象,数据库数据的同步是生产最容易忘记执行的一个事情;比如我的这家公司上传文件模板变化了,但是没同步,导致出问题时开发环境复现问题真是麻烦;

5.随意更改生产数据库,出不出问题全靠开发的职业素养;

6.Maven依赖的问题,Maven引pom,而pom里面却是另一个pom文件,没有生成的jar供引入,是的,我们可以在dependency里加上

<type>pom

来解决这个问题,但是公司内的,而且实际也是引入这个pom里面的jar的,我实在不知道这么做的用意是什么,有谁知道;求教 a972880380654b389246a3179add2cca~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg

以上这些都是我最近一家公司出现的问题,除了默默接受还能怎么办;

那有什么优点呢:

  1. 不用太怎么写文档
  2. 束缚很小
  3. 学到了js的全局调用怎么写的(下一篇我来写,顺便巩固一下)

解决之道

怎么解决这些问题呢,首先对于现有的新项目或升级的项目来说,spring的application.xml/yml 完全可以写我们的配置,开发环境没必要整外部文件,如果是生产环境我们可以在脚本或启动命令添加 nohup java -Dfile.encoding=UTF-8 -Dspring.config.location=server/src/main/config/application.properties -jar xxx.jar & 来告诉jar包引哪里的配置文件;也可以加上动态配置,都很棒的,

其次就是规范代码,养成良好的规范,跟着节奏,不要另辟蹊径;老老实实的,如果原项目上迭代,不要动源代码,追加即可,没有时间去重构的;

我也曾是个快乐的童鞋,也有过崇高的理想,直到我面前堆了一座座山,脚下多了一道道坑,我。。。。。。!


作者:小红帽的大灰狼
来源:juejin.cn/post/7371986999164928010
收起阅读 »

金价大跳水?写一个金价监控脚本

web
说在前面 😶‍🌫️国庆过后,金价就大跳水,一直往下跌,看样子暂时是停不下来了,女朋友之前也入手了一点黄金,因此对黄金价格的变化比较关心,为了让她不用整天盯着实时金价,所以就搞了一个金价监控工具,超出设置的阈值就会发送邮件提醒✉。 一、金价信息获取方案 金...
继续阅读 »

说在前面



😶‍🌫️国庆过后,金价就大跳水,一直往下跌,看样子暂时是停不下来了,女朋友之前也入手了一点黄金,因此对黄金价格的变化比较关心,为了让她不用整天盯着实时金价,所以就搞了一个金价监控工具,超出设置的阈值就会发送邮件提醒✉。



c7acc1daf5b30174b34bdd85cd34d25.jpg


一、金价信息获取方案


金价实时信息有两种方案可以获取到:


1、网页信息爬取


我们可以先找到一些官方的金价信息网站,然后直接利用爬虫直接爬取,比如:quote.cngold.org/gjs/jjs.htm…



2、通过接口获取


例如nowapi中就有黄金数据信息接口,我们可以直接通过接口来获取:



二、提醒阈值设置


1、创建数据库



2、监控页面编写


简单编写一个页面用于添加和调整提醒内容。



三、修改配置信息


1、邮箱配置


这里我使用的qq邮箱作为发件账号,需要开启邮箱授权,获取授权码。


{
host: "smtp.qq.com", // 主机
secureConnection: true, // 使用 SSL
port: 465, // SMTP 端口
auth: {
user: "jyeontu@qq.com", // 自己用于发送邮件的账号
pass: "jyeontu", // 授权码(这个是假的,改成自己账号对应即可,获取方法: QQ邮箱-->设置-->账户-->POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务-->IMAP/SMTP开启 复制授权码)
}
}


  • (1)打开pc端qq邮箱,点击设置,再点击帐户




  • (2)往下拉 可开启POP3/SMTP服务 根据提示即可获取qq邮箱授权码




  • (3)将获取到的授权码复制到配置信息里即可



2、数据库配置


填写数据库对应的配置信息。


{
host: "localhost",
user: "root", //数据库账号
password: "jyeontu", //数据库密码
database: "test", //数据库名称
}

3、nowapi配置


免费开通后将AppKeySign替换成自己的就可以了。



{
AppKey: AppKey,
Sign: "Sign",
}

四、脚本功能编写


1、获取金价信息


我直接使用nowapi的免费试用套餐,配额是10 次/小时



const { nowapiConfig } = require("./config.js");
async function getGoldPrice() {
const result = await axios.get(
`https://sapi.k780.com/?app=finance.gold_price&goldid=1053&appkey=${nowapiConfig.AppKey}&sign=${nowapiConfig.Sign}&format=json`
);
return result.data.result.dtList["1053"];
}

获取到的数据如下:



2、获取消息提醒阈值


(1)连接数据库


使用填写好的数据库配置信息连接数据库


const mysql = require("mysql");
const { dbConfig } = require("./config.js");

const connection = mysql.createConnection(dbConfig);

function connectDatabase() {
return new Promise((resolve) => {
connection.connect((error) => {
if (error) throw error;
console.log("成功连接数据库!");
resolve("成功连接数据库!");
});
});
}

(2)查询数据


function mysqlQuery(sqlStr) {
return new Promise((resolve) => {
connection.query(sqlStr, (error, results) => {
if (error) throw error;
resolve(results);
});
});
}

async function getMessage() {
const sqlStr =
"select * from t_message where isShow = 1 and isActive = 1 and type = '金价监控';";
const res = await mysqlQuery(sqlStr);
return { ...res[0] };
}

获取到的数据如下:



3、发送提醒邮件


(1)创建邮件传输对象


使用填写好的邮箱配置信息,创建邮件传输对象


const nodemailer = require("nodemailer");
const { mail } = require("./config.js");

const smtpTransport = nodemailer.createTransport(mail);
const sendMail = (options) => {
return new Promise((resolve) => {
const mailOptions = {
from: mail.auth.user,
...options,
};
// 发送邮件
smtpTransport.sendMail(mailOptions, function (error, response) {
if (error) {
console.error("发送邮件失败:", error);
} else {
console.log("邮件发送成功");
}
smtpTransport.close(); // 发送完成关闭连接池
resolve(true);
});
});
};
module.exports = sendMail;

(2)阈值判断


判断获取到的金价信息是否超出阈值范围来决定是否发送邮件提醒


async function mail(messageInfo, goldInfo) {
let { minVal = -Infinity, maxVal = Infinity } = messageInfo;
let { buy_price } = goldInfo;
minVal = parseFloat(minVal);
maxVal = parseFloat(maxVal);
buy_price = parseFloat(buy_price);
if (minVal < buy_price && maxVal > buy_price) {
return;
}
const mailOptions = {
to: messageInfo.mail.replaceAll("、", ","), // 接收人列表,多人用','隔开
subject: "金价监控",
text: `当前金价为${buy_price.toFixed(2)}`,
};
await sendMail(mailOptions);
}

五、定时执行脚本


可以使用corn编写一个定时任务来定时执行脚本即可。



  • * * * * * *分别对应:秒、分钟、小时、日、月、星期。

  • 每个字段可以是具体的值、范围、通配符(*表示每一个)或一些特殊的表达式。


例如:


0 0 * * *:每天午夜 0 点执行。
0 30 9 * * 1-5:周一到周五上午 9:30 执行。

你可以根据自己的需求设置合适的 cron 表达式来定时执行特定的任务。


六、效果展示


如果金价不在我们设置的阈值内时,我们就会收到邮件告知当前金价:





七、脚本使用


1、源码下载


git clone https://gitee.com/zheng_yongtao/node-scripting-tool.git


  • 源码已经上传到gitee仓库


gitee.com/zheng_yongt…



  • 具体目录如下:



2、依赖下载


npm install

3、配置数据填写



这里的配置信息需要修改为你自己的信息,数据库、gitee仓库、nowapi配置。


4、脚本运行


node index.js

更多脚本


该脚本仓库里还有很多有趣的脚本工具,有兴趣的也可以看看其他的:gitee.com/zheng_yongt…




🌟觉得有帮助的可以点个star~


🖊有什么问题或错误可以指出,欢迎pr~


📬有什么想要实现的工具或想法可以联系我~




公众号


关注公众号『前端也能这么有趣』,获取更多有趣内容。


说在后面



🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。



作者:JYeontu
来源:juejin.cn/post/7437006854122815497
收起阅读 »

程序员设计不出精美的 UI 界面?让 V0 来帮你

web
大家好,我是双越,也是 wangEditor 作者。 今年我致力于开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用。 本文分享一下前端实用的 AI 工具 v0.dev 以及我在 划...
继续阅读 »

大家好,我是双越,也是 wangEditor 作者。



今年我致力于开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用。



本文分享一下前端实用的 AI 工具 v0.dev 以及我在 划水AI 中的实际应用经验,非常推荐这款工具。


不同 AI 工具写代码


ChatGPT 不好直接写代码


去年 ChatGPT 发布,但它一直是一个聊天工具,直接让它来写代码,用一问一答的形式,体验其实并不是非常友好。


可以让它来生成一些单一的代码或工具,例如 生成一个 nodejs 发送 Email 的函数 。然后我们把生成的代码复制粘贴过来,自己调整一下。


它可以作为一个导师或助理,指导你如何写代码,但它没法直接帮你写,尤其是在一个项目环境中。


image.png


PS. 这里只是说 ChatGPT 这种问答方式不适合直接写代码,但 ChatGPT 背后的 LLM 却未后面各种 AI 写代码工具提供了支持。


Cursor 非专业程序员


Cursor 其实去年我就试用过,它算是 AI 工具 + VSCode ,付费试用。没办法,AI 接口服务现在都是收费的。


前段时间 Cursor 突然在社区中很火爆,国内外都有看过它的宣传资料,我记得看过一个国外的 8 岁小女孩,用 Cursor 写 AI 聊天工具的视频,非常有意思,我全程看完了。


image.png


Cursor 可能会更加针对于非专业编程人员,去做一些简单的 demo ,主要体验编程的逻辑和过程,不用关心其中的 bug 。


例如,对于公司的 PM UI 人员,或者创业公司的老板。它真的可以产生价值,所以它也可以收费。


Copilot 针对专业程序员


我们是专业程序员,我更加推荐 Copilot ,直接在 vscode 安装插件即可。


我一直在使用 Copilot ,而且我现在都感觉自己有点依赖它了,每次写代码的时候都会停顿下来等待它帮我生成。


在一些比较明确的问题上,它的生成是非常精准的,可以大大节省人力,提高效率。


image.png


如果你遇到 Copilot 收费的问题,可以试试 Amazon CodeWhisper ,同样的功能,目前是免费的,未来不知道是否收费。


UI 很重要!!!


对于一个前端人员,有 UI 设计稿让他去还原开发这并不难,但你让他从 0 设计一个精美的 UI 页面,这有点困难。别说精美,能做到 UI 的基本美观就已经很不容易了。


举个例子,这是我偶遇一个笔记软件,这个 UI 真的是一言难尽:左上角无端的空白,左侧不对齐,icon 间距过大,字号不统一,tab 间距过小 …… 这种比较随性的 UI 设计,让人看了就没有任何试用的欲望。


image.png


可以在对比看一下 划水AI 的 UI 界面,看颜色、字号、艰巨、icon 等这些基础的 UI ,会否更加舒适一些?专业一些?


image.png


PS. 无意攻击谁(所以打了马赛克),只是做一个对比,强调 UI 的重要性。


V0 专业生成 UI 代码


V0 也是专业写代码的,不过它更加专注于一个方向 —— 生成 UI 代码 ,能做到基本的美观、舒适、甚至专业。


给一个指令 a home page like notion.com 生成了右侧的 UI 界面,我觉得已经非常不错了。要让我自己设计,我可设计不出来。


image.png


这一点对于很多人来说都是极具价值的,例如中小公司、创业公司的前端人员,他们负责开发 UI 但是没有专业的 UI 设计师,或者说他们开发的是一些 toB 的产品,也不需要招聘一个专职的 UI 设计师。


你可以直接拷贝 React 代码,也可以使用 npx 命令一键将代码转移到你自己的项目中。


image.png


它甚至还会考虑到响应式布局和黑白主题,这一点很惊艳


image.png


再让 V0 生成一个登录页,看看能做到啥效果。在首页输入指令 A login form like Github login page


image.png


等待 1-2 分钟,生成了如下效果,我个人还是挺满意的。如果让我自己写,我还得去翻阅一些 UI 组件库文档,看 form 表单怎么写,怎么对齐,宽度多少合适 …… 光写 UI 也得搞半天。


image.png


划水AI 中“我的首页” 就是 V0 生成的,虽然这个页面很简洁,但是我个人对 UI 要求很高,没有工具帮助,我无法短时间做到满意。


image.png


最后


任何行业和领域,看它是否成熟、是否能发展壮大,一个很重要的特点就是:是否有庞大的细分领域。例如现代医学、现代制造业、计算机领域…… 专业细分及其周密,大家各司其职,整个领域才能欣欣向荣。


AI 领域也是一样,AI 编程将是一个细分领域,再往下还有更多细分领域,像针对 UI 的、针对数据库的、针对云服务的,未来会有更多这方面的发展。


作者:前端双越老师
来源:juejin.cn/post/7438647233219903542
收起阅读 »

只写后台管理的前端要怎么提升自己

本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。 写优雅的代码 一道面试题 大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单...
继续阅读 »

本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。


写优雅的代码


一道面试题


大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。


原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb,而我要展示成 KBMB 等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):


function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;

while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}

return `${kb.toFixed(2)} ${units[unitIndex]}`;
}

而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:


function formatSizeUnits(kb) {
var result = '';

if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}

return result;
}

虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。


如何提升代码质量


想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。


还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。


还是上面的问题,看看 GPT 给的答案


// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。

/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/

function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);

// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}

// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);

// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}

// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB

还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)


我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。


学会封装


一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?


你说,没时间,没必要,复制粘贴反而更快。


那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。


而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。


关注业务


对于前端业务重要吗?


相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。


但是就我找工作的经验,业务非常重要!


如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。


一场面试


还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。



  • 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”

  • 我:“好嘞!”


等到面试的时候:



  • 前端ld:“你知道xxx吗?(业务名词)”

  • 我:“我……”

  • 前端ld:“那xxxx呢?(业务名词)”

  • 我:“不……”

  • 前端ld:“那xxxxx呢??(业务名词)”

  • 我:“造……”


然后我就挂了………………


如何了解业务



  1. 每次接需求的时候,都要了解需求背景,并主动去理解


    我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么 cluster controller topic broker partition…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。


  2. 每次做完一个需求,都需要了解结果


    有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?


  3. 理解需求,并主动去优化


    产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?


    产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。


    其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。



关注源码


说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。


除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。


那说什么,后台管理就这些啊?!


如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?


可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点


至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?



讲一下 Axios 源码中,拦截器是怎么实现的?


Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。


在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含 fulfilledrejected 函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。


以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:


class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}

use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}

eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}

forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}

在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过 forEach 方法将拦截器中的 fulfilledrejected 函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。


axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的 .then.catch 执行之前,插入自定义的逻辑。


请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。



前端基建


当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。


技术选型


技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?


对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……


image.png

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)


Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。


React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。


总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。


开发规范


这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlintstylelintprettiercommitlint 等。


前端监控


干了这么多年前端,前端监控我是……一点没做过。


image.png

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。


对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。


对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerrorwindow.addEventListener('unhandledrejection', ...) 去分别捕获同步和异步错误,然后通过错误信息和 sourceMap 来定位到源码。


对于性能监控,我们可以通过 window.performancePerformanceObserver 等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。


最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon 还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。


CI/CD


持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。


场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。


这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline 、 Stage 和 Job 分别是什么,怎么配置,如何在不同环境配置不同工作流等。


了解技术动态


这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。


比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。


还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……


虽然不可能学完每一项新技术,但是可以多去了解下。


总结


写了这么多,可能有人会问,如果能回到过去,你会怎么做。


啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。


image.png

作者:我不吃饼干
来源:juejin.cn/post/7360528073631318027
收起阅读 »

用 vue 给女主播写了个工具,榜一大哥爱上了她,她爱上了我

web
用 vue 写了个直播助手,榜一大哥爱上了她,她爱上了我 这是一个什么样的程序?这是一个使用 sys-shim/vue3/vite 开发的一个 windows 程序。用于向网站注入自己的代码以实现一些自动化功能。 sys-shim 是什么?它是一个我开发的个人...
继续阅读 »

用 vue 写了个直播助手,榜一大哥爱上了她,她爱上了我


这是一个什么样的程序?这是一个使用 sys-shim/vue3/vite 开发的一个 windows 程序。用于向网站注入自己的代码以实现一些自动化功能。


sys-shim 是什么?它是一个我开发的个人工具,力求前端人员无需了解其他语言的情况下快速制作轻量的 windows 程序,详情请移步 electron 和 tauri 都不想用,那就自己写个想用的吧


为什么要开发这样的程序


虽然已经过去了很久,但那天的场景还是历历在目。


那天是在周五晚上 23 点过,大楼的中央空调都关了,我搓了搓手,看着还未完成的工作,想了想再不回去公车就没了,到家的话饭店也关门了。


然后按了一下显示器的电源按钮,让电脑继续工作着,准备回家吃饭后远程继续工作。


在大楼电梯遇到一个长得很挺好看的女生,由于这一层我们公司,然后看样子好像是直播部门的同事,虽然平时也都不怎么遇见,更没什么交集,只是公司偶尔让大家去主播间刷下人气,有点印象,猜想应该是直播部门的吧啦吧啦**蕾


虽然是同事,却不熟悉,想打个招呼都不知道说啥,有点尴尬。然后我索性无所是事刷微信列表去了,虽然微信列表里一条消息也没有。。。


突然她给我来了句:“小哥哥你是我们公司的吧,你们平时下班都这么晚的吗?”一边哈气搓手。


我礼貌性笑了一下:“嗯,不是每天都这么晚。”,然后继续低头无所是事的刷微信列表。


大约一两秒之后,她说:“哦”。然后再停顿一会,好像又找到了什么话题:“那你们最近工作也很忙吗?但是我前两几天也没基本没遇到我们公司这么晚下班的人”。


这句话听起来好像传达了很多信息。但时间可不允许我慢慢思考每个信息应该如何正确应对,就像领导给的项目开发时间根据来不及完善好每一个细节一样。


我只能粗略回答:“没有特别忙,只是有时候我喜欢弄些小工具啥的,一不小心就已很晚了”。我心里想:感觉有点像面试,没有说公司不好,又显得自己爱学习,是一个能沉浸于思考的人,应该没问题吧。


“真好,看得出来你还十分热爱你这份职业。能够愿意花自己的时候去研究它们。”听语气好像是有一点羡慕我,感觉还有一点就是她不太喜欢她现在的工作。是呀,我也经常在想,做直播的那些人,有多少是喜欢整蛊自己,取悦别人,有多少是喜欢见人就哥哥好,哥哥帅,哥哥真的好可爱?


“只是觉得,能有一些工具,能帮助减少些重复劳动,就挺好”。


“对对对,小哥哥你说得太对了,就是因为有工具,减少了很多像机器一样的工作,人才可以去做更多有意义的,不像是机器那样的事情。”


当她说这句话的时候,我想,有一种不知道是不是错觉的错觉,真是个有想法的人!还有,这难道是在夸我们这些做工具的人吗?但是她说这句时的微笑,一下子就让人感到了她的热情和礼貌。


我心想,竟然这么有亲和力,很想有愿意继续沟通的想法。对!不然人家怎么能做主播?要换我上去做主播,绝对场也冷了,人也散了。


我一边告诉自己,因为能做主播,所以她本身就很有亲和力,所以你感觉她很热情,是因为这个热情是固有属性,并不是对于你热情。


一边竟开始好奇,这么漂亮又有亲和力的妹子,谁的下属?忍心让她上班这么晚?谁的女朋友?忍心让她上班这么晚?


好奇心害死猫。我竟然还是问出那句话:


“为什么你这么晚才下班呢?”


“最近销售量有点下滑,我想保住我销售额前一至少前二名的位置。”听到这句话的时候,我有点惊讶。我靠,居然是销冠。然后我不温不火的说到:“是啊,快过年了,得拿年终奖。”


“不是,就是想着让成绩保持着,马上快一年了。”尴尬,人家只是想保持成绩。是我肤浅了。等等!保持快一年?没记错的话她好像也才在公司直播一年不到吧!这就是传说中的入职即巅峰吗?我突然觉得我好菜!好想快点自觉走开,奈何地铁还没到!


“原来是销冠,这么厉害!我以为是年底为了冲年终奖,是我肤浅了~”我简单表达一下敬意和歉意。有颜值有能力,突然人与人之间的距离一下就拉开了。


“没有没有!钱也重要,钱也重要!”她噗呲一笑。然后用期盼的眼神看着我,“对了,你喜欢研究小工具来着,你有没有知道哪种可以在直播时做一些辅助的小工具?我网上找了好多,都是只能用在抖音斗鱼这些大公司的辅助工具,我们公司的这个直播平台的一直没有找到。哎呀!好烦~”


完犊子了,这题我不会。看着她好像是工具花了很久没有找到,焦急得好像就要跺脚的样子,我只感觉头皮发麻,要掉头发了!怎么办?怎么办?哪里有这种工具?


但话题还是要接着啊!我开始答非所问:“到没关注过这方面的小工具,但我听说现在有些自动直播的工具,可以克隆人像和声音二十四小时直播。”


“不需要不需要,我不需要这么高端的工具,而且那些自动播的很缺少粉丝互动的。我只要可以帮我定时上下架商品啥的就好。”


我心想,这不搞个脚本 setInterval 一下就行?虽然但是要做得方便他们使用的形式还是得折腾下。我们这个直播平台又不是大平台,网上肯定也没有现成的,不过要做一个没什么难度。


我回答说:“那我帮你找找。”


“谢谢谢谢小哥哥!你人真好!”看着她一边开心的笑着一边双手拜托的样子,我既感觉完犊子了入坑了,又恨不得现在就给她做一个出来!


车来了。她转头看了一下,然后又转过头来问我“小哥哥可以加下你微信吗?你有消息的话随随时通知我,我都在的。”


我:“行的。”


她:“我加你我加你~”


我竟然一下子没有找到我的微信二维码名片在哪,确实,从来就没有其他女生加过我,没什么经验倒也正常,是吧?她又转头看了看停车的车,我知道她是她的车,可她还告诉我没事的慢慢来。


她加上了我的微信,然后蹦上滴滴滴快要关门的列车,在窗口笑着向我挥手告别。在转角那一刻她指了指手机,示意我看微信。


“我叫李蕾^_^”。


“收到”。


alt


功能设计


在上一节为什么要开发这样的程序花费了一定量的与技术无关的笔墨,可能有些读者会反感。对此我表示:你以为呢?接到一个项目哪那么容易?手动狗头。


在功能方面,主要考虑以下特性:


开发时方便


不方便肯定会影响开发进度啦。热更新热部署啥的,如果没有这些开发体验那可是一点都不快乐~


使用时点开就能用


解压、下一步下一步安装了都不要了。


多设备下多平台下多配置支持


如果不做设备隔离,万一主播把这软件发给别人用,岂不是乱套了。多平台的考虑是因为反正都是注入脚本,就统一实现。多配置主要实现每个配置是不同的浏览器配置和数据隔离。


便于更新


减少文件发来发去、版本混乱等问题。


便于风控


如果改天主播说这软件不想用了,那我远程关闭就行。


看下总体界面


alt


一个设备支持多个主配置,每个主配置可以绑定密钥进行验证。


主配置验证通过之后,才是平台配置,平台配置表示系统已支持自动化的平台,例如疼训筷手这些平台。这些每个平台对应自己的 logo、自动化脚本文件和状态。


自动化脚本文件在开发过程中可以修改,用户侧不可见,直接使用即可。


每个平台下有多个配置,比如疼训这个平台下,创建配置A作为账号A的自动化程序,配置B作为账号B的自动化程序。因为每个配置启动的是不同的浏览器实例,所以疼训理论上不会认为多个账号在同一浏览器下交叉使用。反正我司的平台肯定不会认为~


然后配置下有一些通用的功能:例如智能客服可以按关键字进行文字或语音回复。


例如假设你配置了一个关键字列表为


keys = [`小*姐姐`, `漂亮`]
reply = [`谢谢大哥`, `大哥你好呀`, `你也好帅`]

当你进入直播间,发了一句小*姐姐真漂亮时,就可能会自动收到小*姐姐的语音谢谢大哥, 你也好帅


在场控助手这边,根据场控需求,直播间可以按指定规则进行自动发言,自动高亮评论(就是某个评论或系统设定的内容以很抢眼的形式展示在屏幕上),这是防止直播间被粉丝门把话题逐渐带偏的操作方法之一。


商品助手这边,有一些按指定规则、时间或顺序等配置展示商品的功能。


技术选型



  • 使用 vue3/vite 进行界面开发。这部分众所周知是热更新的,并且可以在浏览器中进行调试。

  • 使用 sys-shim 提供的 js api 进行浏览器窗口创建、读写操作系统的文件。当创建浏览器窗口后,需要关闭窗口。

  • 使用 mockm 进行接口开发,自动实现多设备、平台、配置的 crud 逻辑支持。


在 vue3 进行界面开发的过程中,这个过程可以在浏览器里面也可以 sys-shim 的 app 容器中。因为界面与 sys-shim 进行通信主要是通过 websocket 。前端调用某个函数,例如打开计算器,然后这个函数在内部构造成 websocket 的消息体传给 sys-shim 去调用操作系统的 api 打开计算器。就像是前端调用后端提供的 api 让后端调用数据库查询数据,返回数据给前端。


在界面完成之后,把界面部署在服务器上,这样如果有更新的话,就像普通的前端项目一样上传 dist 中内容在服务器上即可。发给主播的 app 读取服务器内容进行界面展示和功能调用。


计划安排



  • 周五加加班,用两小时完成数据模型、API实现

  • 周六完成主要功能界面、交互开发

  • 周日上午进行体验完善、发布测试


开发过程


由于我只是个做前端的,并且只是个实习生。所以用到的技术都很简单,下面是具体实现:


数据模型、API实现


由于是多设备、多平台、多配置,所以数据模型如下:


const db = util.libObj.mockjs.mock({
// 设备
'device|3-5': [
{
'id|+1': 1,
电脑名: `@cname`,
},
],
// 主配置
'config|10': [
{
'id|+1': 1,
deviceId() {
const max = 3
return this.id % max || 3
},
名称: `@ctitle`,
卡密: `@uuid`,
激活时间: `@date`,
过期时间: `@date`,
},
],
// 平台
platform: [
{
id: 1,
封面: [
{
label: `@ctitle`,
value: `@image().jpg`,
},
],
网址: `https://example.com/`,
状态: `可使用`,
脚本文件: [
{
label: `@ctitle().js`,
value: `@url().js`,
},
],
名称: `豆印`,
},
],
'devicePlatformConfig|1-3': [
{
'id|+1': 1,
名称: `默认`,
deviceId() {
const max = 3
return this.id % max || 3
},
platformId() {
const max = 3
return this.id % max || 3
},
configId() {
const max = 3
return this.id % max || 3
},
数据目录() {
return `data/${this.id}`
},
// 功能配置
action: {
智能客服: {
文字回复: {
频率: `@integer(1, 5)-@integer(6, 10)`,
启用: `@boolean`,
'配置|1-5': [
{
关键词: `@ctitle`,
回复: `@ctitle`,
},
],
},
// ... 省略更多配置
},
// ... 省略更多配置
},
},
],
}),

观察上面的数据模型, 例如主配置中有一个 deviceId,由于这个字段是以驼峰后缀的 Id 结尾,所以会自动与 device 表进行关联。


platform 这张表由于没有与其他表有关联关系,所以无需添加含有 ...Id 的字段。


devicePlatformConfig 平台设备配置这张表,是某设备创建的针对于某一主配置下的某平台下的某一配置,所以会有 deviceId / platformId / configId


这样如何要查某设备下的所有平台的配置,直接 /devicePlatformConfig?deviceId=查某设备ID 即可。


由于上面这些表声明关联关系之后,模拟数据和接口都是自动生成的,所以这一块并没有其他步骤。


在 api 层面,有一个需要处理的小地方,就是类似于登录(token/用户标识)的功能。由于这个程序并不需要登录功能,所以使用设备ID作为用户标记。


const api = {
async 'use /'(req, res, next) {
// 不用自动注入用户信息的接口, 一般是系统接口, 例如公用字典
const publicList = [`/platform`]
const defaultObj =
!publicList.includes(req.path) &&
Object.entries({ ...req.headers }).reduce((acc, [key, value]) => {
const [, name] = key.match(/^default-(.*)$/) || []
if (name) {
const k2 = name.replace(/-([a-z])/g, (match, group) => group.toUpperCase())
acc[k2] = value
}
return acc
}, {})
if (req.method !== `GET`) {
req.body = {
...defaultObj,
...req.body,
}
}
req.query = {
...defaultObj,
...req.query,
}
next()
},
}

在后端 api 入口上,我们添加了一个拦截器,含有 default- 开头的键会被当成接口的默认参数。如果传设备 id 就相当于给每个接口带上设备标记,后面这个设备创建和修改、查询配置都会被限定在改设备下,实现了类似某用户只能或修改查看某用户的数据的功能。对于之前提到的公用数据,例如 /platform 这个接口的数据是所有用户都能看到,那直接配置到上面的 publicList 中即可。


前端的请求拦截器是这样的:


http.interceptors.request.use(
(options) => {
options.headers[`default-device-id`] = globalThis.userId
return options
},
(error) => {
Promise.reject(error)
},
)

什么?并不严谨?啊对对对!


界面实现:首先做一个浏览器


由于只会一些简单的跑在浏览器里的 js/css ,所以我们要先做一个浏览器来显示我们的软件界面。


经常用 google chrome,用习惯了,并且听说它还不错。所以打算做一个和它差不多的浏览器。


它封装了 chromium 作为内核,那我们也封装 chromium 吧。


微软听说大家都想要做个基于 chromium 的的界面渲染程序,于是微软就给我们做好了,叫 microsoft-edge/webview2


听说大家都在用这个渲染引擎,那么微软干脆把它内置于操作系统中,目前 win10/win11 都有,win7/8 也可以在程序内自动在线安装或引用安装程序离线安装。


不知不觉的浏览器就做好了。


如何使用这个做好的浏览器


由于只会 js ,所以目前我使用 js 创建这个 webview 实例是这样的:


const hwnd = await hook.openUrl({
url: platformInfo.value.网址,
preloadScript,
userDataDir: row.数据目录 || `default`,
})

可以看到,上面的 js 方法支持传入一个网址、预加载脚本和数据目录。


在这个方法的内部,我们通过构造一个 aardio 代码片段来创建 winform 窗口嵌入 webview 实例。


至于要构造什么 aardio 片段,是 aardio 已经做好相关示例了。复制粘贴就能跑,需要传参的地方,底层是使用 ipc 或 rpc 进行通信的。


ipc 是进程之前通知,可以简单的理解为一个基于事件的发布订阅程序。


rpc 是远程调用,可以简单理解为我们前端经常调用的 api。服务端封装好的 api,暴露给前端,直接调用就好了。


aardio示意片段


var winform = win.form({text: `sys-shim-app`}) // 创建一个 windows 窗口
var wbPage = web.view(winform, arg.userDataDir, arg.browserArguments) // 使用指定配置启动一个浏览器示例
wbPage.external = { // 向浏览器注入全局变量
wsUrl: global.G.wsUrl;
}
wbPage.preloadScript(arg.preloadScript) // 向浏览器注入 js 脚本
wbPage.go(arg.url) // 使用创建的浏览器打开指定 url
winform.show() // 显示窗口

有了上面的代码,已经可以做很多事情了。因为向浏览器注入了一个全局变量 wsUrl,这是服务端的接口地址。然后在注入的脚本里去连接这个接口地址。


脚本由于是先于 url 被加载的,所以首先可以对页面上的 fetch 或者页面元素这些进行监听,实现拦截或代理。另外 webview 也提供了 cdp 层面实现的数据监听。


功能实现:让宿主与实现分离


这里的宿主是指除开 注入自定义脚本 的所有功能。根据之前的设计,网站地址是用户配置的,脚本也是用户上传的。所以一切都是用户行为,与平台无关?


啊对对对就这样!


把自动化这块功能分离出去,让其他人写(我不会!手动狗头)。然后我们在程序里为现有功能做一个事件发布。当用户开启了某个功能,脚本可以知道,并可以得到对应配置的值,然后去做对应功能的事。


const keyList = Object.keys(flatObj(getBase()))
keyList.forEach((key) => {
watch(
() => {
return deepGet(devicePlatformConfig.value, key)
},
(newVal, oldVal) => {
keyByValueUpdate(key, newVal, oldVal)
},
{
immediate: true,
},
)
})


getBase 是一个配置的基础结构对象。把这个对象扁平化,就能等到每个对象的 key,使用 vue 的 watch 监听每个 key 的变化,变化后分别发布 [key, 当前值, 占值, 整个配置对象]


这样在自动化脚本那边只需要订阅一下他关心的 key 即可。


例如:当 场控助手.直播间发言.频率 从 2 变成 6 。


alt


ws.on(`action.场控助手.直播间发言.频率`, (...arg) => {
console.log(`变化了`, ...arg)
})

好了,接下来的内容就是在群里 v50 找人写写 js 模拟事件点击、dom监听啥的了(具体自动化脚本略,你懂的~手动狗头)。


alt


测试过程


总算赶在了周一完成了功能,终于可以进行测试啦~


alt


她同事进行功能测试的时候,提出了一些修改意见(还好是自己写的,不然真改不动一点),然后有个比较折腾的是原来我的配置窗口和平台直播页面是分别在不同的 windows 窗口下的,可以相互独立进行拖拽、最小化等控制,因为想着独立开来的话配置窗口就不会挡住直播页面的窗口了。


没想到她希望配置窗口可以悬浮在直播平台的页面上,并且可以展开折叠拖动。这对于之前设计的架构有一些差异,修改花了点时间。


alt


alt


alt


最终结果


alt


我很满意,手动狗头。


相关内容



声明:本文仅作为 sys-shim 的程序开发技术交流,本人没有也不提供可以自动化操作某直播平台的脚本。


作者:四叶草会开花
来源:juejin.cn/post/7448951076685119529
收起阅读 »

BOE(京东方)“向新2025”年终媒体智享会首站落地上海 六大维度创新开启产业发展新篇章

12月17日,BOE(京东方)以“向新2025”为主题的年终媒体智享会在上海启动。正值BOE(京东方)新三十年的开局之年,活动全面回顾了2024年BOE(京东方)在各领域所取得的领先成果,深度解读了六大维度的“向新”发展格局,同时详细剖析了BOE(京东方)在智...
继续阅读 »

12月17日,BOE(京东方)以“向新2025”为主题的年终媒体智享会在上海启动。正值BOE(京东方)新三十年的开局之年,活动全面回顾了2024年BOE(京东方)在各领域所取得的领先成果,深度解读了六大维度的“向新”发展格局,同时详细剖析了BOE(京东方)在智能制造领域的领先实践。BOE(京东方)执行委员会委员、副总裁贠向南,BOE(京东方)副总裁、首席品牌官司达出席活动并发表主旨演讲。

经过三十年创新发展,秉持着对技术的尊重和对创新的坚持,在“屏之物联”战略指导下,BOE(京东方)从半导体显示领域当之无愧的领军巨擘迅速蝶变,成功转型为全球瞩目的物联网创新企业,并不断引领行业发展风潮。面对下一发展周期,BOE(京东方)将从战略、技术、应用、生态、模式、ESG六大方面全方位“向新”突破,以实现全面跃迁,并为产业高质发展注入强劲动力。

战略向新:自2021年“屏之物联”战略重磅发布以来,BOE(京东方)又于2024年京东方全球创新伙伴大会(BOE IPC·2024)上发布了基于“屏之物联”战略升维的“第N曲线”理论,以半导体显示技术、玻璃基加工、大规模集成智能制造三大核心优势为基础,精准布局玻璃基封装、钙钛矿光伏器件等前沿新兴领域,全力塑造业务增长新赛道。目前,玻璃基封装领域,BOE(京东方)已布局试验线,成立了玻璃基先进封装项目组,实现样机产出;钙钛矿领域,仅用38天就已成功产出行业首片2.4×1.2m中试线样品,标志着钙钛矿产业化迈出了重要一步。

技术向新:2021年,BOE(京东方 )发布了中国半导体显示领域首个技术品牌,开创了产业“技术+品牌”双价值驱动的新纪元。以技术品牌为着力点,BOE(京东方)深入赋能超5000家全球顶尖品牌厂商和生态合作伙伴,包括AOC、ROG、创维、华硕、机械师、雷神、联想等,助力行业向高价值增长的路径迈进,也为用户提供了众多行业领先、首发的更优选择。 BOE(京东方)还将全力深化人工智能与半导体显示技术以及产业发展的深度融合,并在AI+产品、AI+制造、AI+运营三大关键领域持续深耕,并依托半导体显示、物联网创新、传感器件三大技术策源地建设,与产业伙伴和产学研合作伙伴共同创新,为产业高质量可持续发展保驾护航。

应用向新: BOE(京东方)不仅是半导体显示领域的领军企业,也是应用场景创新领域的领跑者,BOE(京东方)秉持“屏之物联”战略,以全面领先的显示技术为基础,通过极致惊艳的显示效果、颠覆性的形态创新,为智慧座舱、电竞、视觉艺术、户外地标等场景注入了新鲜血液,带给用户更加美好智慧的使用体验。以智慧座舱为例,根据市场调研机构Omdia最新数据显示,2024年前三季度京东方车载显示出货量及出货面积持续保持全球第一,在此基础上BOE(京东方)还推出“HERO”车载场景创新计划,进一步描绘智能化时代汽车座舱蓝图。

生态向新: BOE(京东方)持续深化与电视、手机、显示器、汽车等众多品牌伙伴的合作,共同打造“Powered by BOE”产业生态集群,赢得众多客户的认可与赞誉。与此同时,BOE(京东方)还持续拓展跨产业生态,通过与上海电影集团、故宫博物院、微博等文化产业领先机构展开跨界合作,以创新技术赋能传统文化艺术与影像艺术。此外,通过战略直投、产业链基金等股权投资方式协同众多生态合作伙伴,通过协同合作、资源聚合共同构筑产业生态发展圈层。

模式向新: 为适配公司国际化、市场化、专业化的长远发展,BOE(京东方)持续深化“1+4+N+生态链”的业务发展架构,以及“三横三纵”组织架构和运营机制。在充分市场化和充分授权的机制保障下,形成了以半导体显示核心业务为牵引,传感、物联网创新、MLED业务、智慧医工四大高潜航道全面开花,聚焦包括智慧车联、工业互联、数字艺术、3D光场等规模化应用场景,生态链确保产业上下游合作伙伴协同跃迁的“万马奔腾”的发展图景。此外,BOE(京东方)还鼓励员工创新创业,通过激发人才创新热情,共同为集团发展注入强劲内生动力。

ESG向新: 2024年,BOE(京东方)承诺将在2050年实现自身运营碳中和,并通过坚持“Green+”、“Innovation+”、“Community+”可持续发展理念,推动全球显示产业高质永续发展。“Green+”方面,BOE(京东方)依托 16 家国家级绿色工厂、1 座灯塔工厂及1座零碳工厂,以绿色产品、制造与运营践行低碳路径;“Innovation+”方面,BOE(京东方)凭借全部为自主创新的9万件专利的行业佳绩,以及技术策源地、技术公益池等举措,携手产业上下游伙伴协同创新;“Community+”方面,BOE(京东方)在教育、医疗、环境等公益领域持续投入,积极履行社会责任,例如,在“照亮成长路”公益项目中,BOE(京东方)十年间在偏远地区建设的智慧教室已经突破120所。

BOE(京东方)智能制造:铸就行业新典范

BOE(京东方)智能制造在引领标准、数字化变革、AI+制造和可持续发展四个方面,树立了全球智能制造卓越标杆,并引领产业迈向智能化、绿色化新时代。在引领标准方面,BOE(京东方)已建立起遍布全球的智能制造体系,包括18条半导体显示生产线和6大全球智能终端服务平台,并荣膺全球智能制造最高荣誉——世界经济论坛“灯塔工厂”。为应对布局全球的产供销业务体系,BOE(京东方)已构建起设供产销集成管理系统,可实现业财一体的全生命周期智能决策;在数字化变革方面,BOE(京东方)正致力于打造“一个、数字化、可视的京东方”,包括流程、组织、IT、数据四大管理要素,通过建立一个基于流程的、端到端的、高效的数字化管理体系为智能制造赋能;在AI+制造方面,通过系统化运用AI、大数据等技术,BOE(京东方)结合生产制造痛点难点问题,聚焦效率领先、品质卓越,务实高效地执行AI+制造规划。在品质把控方面,BOE(京东方)打造的IDM质检平台(Intelligence defect management)是面向业务人员开发的系统,功能覆盖工业质检全场景及AI建模全流程,引入大模型标注、判定技术,打通场景、工序、代际壁垒,极大提升了人机协同下的复判效率和判定准确率,在确保产品高质量的同时实现成本的有效控制;在可持续发展方面,BOE(京东方)始终秉承“以绿色科技推动生产发展理念”,旗下16家工厂获得国家级“绿色工厂”称号,以绿色制造助力产业可持续升维发展。

“向新2025”年终媒体智享会,是BOE(京东方)2024创新营销的收官之作和全新实践,系统深化了大众对BOE(京东方)品牌和技术创新实力的认知与理解。近年来,BOE(京东方)通过多种创意独具的品牌破圈推广,包括“你好BOE”系列品牌线下活动、技术科普综艺《BOE解忧实验室》等生动鲜活地传递出BOE(京东方)以创新科技赋能美好生活的理念,为企业业务增长提供了强大动力,也为科技企业品牌推广打造了全新范式。据了解,BOE(京东方)该“向新2025”主题系列活动还将于12月20日和12月27日分别落地成都和深圳。

面向未来,BOE(京东方)将胸怀“Best on Earth”宏伟愿景,坚持“屏之物联”战略引领,持续推动显示技术和物联网、AI等前沿技术的深度融合。从提升产品视觉体验到优化产业生态协同,从升级智能制造体系到践行社会责任担当,BOE(京东方)将砥砺奋进、创新不辍,为全球用户呈献超凡科技体验,领航全球产业创新发展的新篇章。

收起阅读 »

BOE(京东方)北京京东方医院主体结构开工 打造医工融合创新典范

12月12日,BOE(京东方)旗下北京京东方医院主体结构正式开工。北京京东方医院是2024年北京市“3个100”重点工程项目,定位为BOE(京东方)智慧物联网医院总院,位于房山区京东方生命科技产业基地,总占地面积约152亩, 总床位1500张,其中一期建设10...
继续阅读 »

12月12日,BOE(京东方)旗下北京京东方医院主体结构正式开工。北京京东方医院是2024年北京市“3个100”重点工程项目,定位为BOE(京东方)智慧物联网医院总院,位于房山区京东方生命科技产业基地,总占地面积约152亩, 总床位1500张,其中一期建设1000床,预计2026年建成开诊。北京京东方医院的建设将打造分级诊疗体系区域样板,为大众提供优质便捷高效的医疗服务,同时积极构建医工融合的产业集群,为区域医疗产业的智慧转型注入强劲动力。

京东方科技集团党委书记、副董事长冯强在致辞中表示,智慧医工业务作为BOE(京东方)在大健康领域的重要战略布局,历经十年发展,形成了以健康管理为核心,医工产品为牵引、数字医院为支撑的健康物联生态系统,并按照医工融合的发展理念,在房山布局了集“医教研产用”于一体的生命科技产业基地,北京京东方医院正是基地的核心支撑平台。医院以三级综合医院为基础,结合BOE(京东方)在显示、物联网、智慧医工等方面的核心优势,按照“补缺、合作、差异化”的原则,着力打造“技术领先、数字驱动、模式创新”的BOE(京东方)智慧物联网医院总院。

北京京东方医院将聚焦重症康复、急诊急救等重点学科,积极引进顶尖技术,推动诊疗技术向精准医学方向发展,做卓越医疗的践行者,并充分利用物联网和人工智能技术,建立数字孪生医院,做智慧医疗的引领者。同时,建立开放创新的平台化体系,汇聚专家资源,建立核心能力,将服务体系延伸到社区和家庭,做新型服务模式的创建者。作为生命科技产业基地的核心支撑,北京京东方医院将持续与全国一流高校、医疗机构、创新企业等有机协同,共同构建“从临床来,到临床去”的创新转化体系,推动生命科技产业新质生产力发展。

多年来,BOE(京东方)不断探索未来医疗健康产业的高潜发展方向,通过科技与医学融合创新,打通了“防治养”的全链条,实现了“医”为“工”提供需求来源和临床转化,“工”为“医”的技术提升持续赋能。目前,BOE(京东方)已在北京、合肥、成都、苏州四地建设并运营5家数字医院。面向未来,BOE(京东方)将坚持以“屏之物联”战略为牵引,积极推动医疗健康产业的智慧转型,以创新驱动和科技赋能开启医工融合发展的崭新篇章。

收起阅读 »

大学生编程第一人,一人抵一城!

就在上个月底,领域内知名的自动驾驶公司小马智行在纳斯达克成功挂牌上市,成为自动驾驶领域新焦点,这个新闻相信不少同学都看到了。 提到小马智行这个公司,可能有些同学还不是很了解。 但是提到小马智行公司的CTO,那相信不少同学就耳熟能详了。 没错,他就是那个神一...
继续阅读 »

就在上个月底,领域内知名的自动驾驶公司小马智行在纳斯达克成功挂牌上市,成为自动驾驶领域新焦点,这个新闻相信不少同学都看到了。



提到小马智行这个公司,可能有些同学还不是很了解。



但是提到小马智行公司的CTO,那相信不少同学就耳熟能详了。


没错,他就是那个神一般的男人,有着「中国大学生计算机编程第一人」称号,并且江湖人尊称为「楼教主」的计算机编程大牛:


楼天城博士



2016年底,楼天城和他的合伙人创立了小马智行,致力于提供安全、先进、可靠的全栈式自动驾驶技术。



不过在此之前,楼天城就以比赛ID “ACRush” 为计算机编程界所熟知。


楼教主毕业于清华,曾连续11年蝉联TopCoder中国区冠军,并且两次获得谷歌全球编程挑战赛冠军。他的职业生涯开始于谷歌,并且在创立小马智行之前,也曾是百度历史上最年轻的T10工程师。


2024年,楼天城以60亿元人民币财富登上《2024年·胡润百富榜》第875位。



从某一程度上也实现了靠写代码,登上了胡润财富榜!


看到这里,相信大家也都好奇这位编程大佬的的成长经历和成神之路。


究竟是怎样一个环境和经历才能造就今天的楼教主呢,今天这篇文章我们就来好好聊一聊。


0x01


1986年,楼天城出生于一个知识分子家庭,父母均为教育工作者,这样的家庭环境为他提供了良好的学习氛围。


尽管童年时期的他并未直接接触到计算机编程,但楼天城却展现出了对数学和逻辑的浓厚兴趣。


楼天城的理科思维十分敏捷,甚至在踢球时,也会计算战术中的各种概率,展现了他对细节的极致追求,这也为他日后的编程之路奠定了坚实的基础。


楼天城高中毕业于杭十四中。


众所周知,这是一所具有悠久历史的百年名校,位于美丽的西子湖畔,同时也是浙江省一级重点中学。



在进入杭十四中后,一次偶然的机会,楼天城加入了学校的计算机兴趣小组,从此开启了与编程的不解之缘。


在短短三个月内,他凭借自学,就在全国青少年信息学联赛(浙江赛区)中脱颖而出,荣获一等奖,这标志着他在编程领域的初步成功,也点燃了他对计算机和编程的巨大热情。


而这,只是他成绩的开端。


0x02


高中时期的楼天城由于竞赛成绩十分优秀,因此也成功保送进入清华大学计算机系,并在后来加入由图灵奖得主姚期智院士领导的清华计算机科学实验班(俗称“姚班”),并成为姚班的首届学生之一。



姚期智先生是世界著名的计算机科学家,唯一的图灵奖华人得奖者;而姚班则是中国乃至全球顶尖的计算机科学人才培养基地。


在这里,楼天城不仅系统学习了计算机科学的基础理论,更是参加了各类编程竞赛,成为校园内的传奇人物。


本科期间,楼天城以其深厚的编程功底和卓越的思维能力在各类竞赛中大放异彩,经常以一人单挑一个队,并且曾多次带领团队获得国内外编程竞赛的冠军,在ACM等编程竞赛界可谓是无人不知、无人不晓,因此也获得了“楼教主”的称号。


本科毕业以后,楼天城选择继续留校读博,并且继续师从姚期智院士。


在这个阶段,楼天城接受了更为严格的学术训练和前沿的科研指导。


在博士阶段,楼天城前期做的偏向theoretical computer science(理论计算机科学),后期偏向Social Network。


这个阶段的积累,为他日后的科研和创业之路打下了坚实的基础。


从高中到本科到读研,回顾楼天城的竞赛经历,可以说堪称传奇。
从高中开始,他几乎每一年都能拿到有关数学或者计算机方面的奖。


而且楼天城的竞赛经历几乎囊括了国内外所有重要的编程赛事,每一次参赛都留下了令人瞩目的成绩。


从高中时期的全国青少年信息学竞赛,到国际信息学奥林匹克竞赛(IOI)金牌,再到Google全球编程挑战赛冠军,楼天城用一次次胜利证明了自己的实力。


尤其是TopCoder,楼天城曾连续11年蝉联TopCoder中国区冠军,并且两次获得谷歌全球编程挑战赛冠军。


这些竞赛经历不仅锻炼了楼天城的编程能力和团队协作能力,更为他日后的职业发展奠定了坚实的基础。


0x03


毕业后的楼天城,职业生涯开始于谷歌,曾在谷歌总部从事社交网络和机器学习相关问题的研究。



2016年,楼天城受邀加入了百度美国研发中心,并且与当时的百度自动驾驶部门首席架构师彭军一起参与百度无人车和自动驾驶技术的研发。



在百度的工作期间,楼天城致力于自动驾驶相关技术的研究,同时他凭借卓越的技术实力和丰富的经验,也成为了百度历史上最年轻的T10工程师。
这段经历不仅加深了他对自动驾驶技术的理解,也让他看到了自动驾驶技术改变未来出行的巨大潜力,更是为即将到来的创业埋下了伏笔。


在百度工作了一段时间后,楼天城选择了从百度离职,并且与同时离职百度的首席架构师彭军一起创业,创立了自动驾驶创业公司小马智行。


至此,小马智行正式诞生



公司成立以后,彭军担任小马智行首席执行官,负责公司总体事务,而热爱技术和编程的楼天城则担任小马智行首席技术官,主要负责技术研发。


而且小马智行的初创团队成员不少都是楼天城曾经在竞赛中认识的朋友,或者是因其名气而慕名加入的。


楼天城作为公司的CTO,亲自参与了自动驾驶相关技术的设计与测试,带领团队攻克了一个个技术难题,推动小马智行的自动驾驶技术不断向前发展。



小马智行自成立之日起,就以其技术实力和前瞻性市场布局迅速成为了自动驾驶领域的佼佼者。而经过这几年的发展,如今的小马智行已经成为自动驾驶领域的领军企业之一。



0x04


回顾楼天城一路走来的成长经历:


从中学时期对编程的兴趣出露,到大学成为一个顶尖算法竞赛选手,再到研究生期间从事理论计算机等研究,最后再到走向业界与创业。


虽说这个过程充满了传奇色彩,但其中也不乏兴趣务实长期主义,以及坚持


楼教主曾说:


要学习认识你自己,了解什么样的事情会让自己开心,爱好就像一个多臂老虎机,要每个臂多拉几下才能知道自己的兴趣所在。


的确,像楼教主这样,在前行的路上找到自己所热爱的事业,并为之全力奔赴,这是一件何其幸运的事情。



注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。



作者:CodeSheep
来源:juejin.cn/post/7447103424360349732
收起阅读 »

87年,37岁,脱下孔乙己的长衫,我开了家一人公司。

你好, 我是微笑哥。在职场做了 10 年的程序员,从 Java 开发做到研发副总,离职前带领着100+的研发队伍。19年开启自由职业,全世界一边旅行一边工作,2020年回西安创业,换了3个客厅2个办公楼走到今天。这中间,真的是脱了几层皮,从一名少年成为现在的沧...
继续阅读 »

你好, 我是微笑哥。
在职场做了 10 年的程序员,从 Java 开发做到研发副总,离职前带领着100+的研发队伍。
19年开启自由职业,全世界一边旅行一边工作,2020年回西安创业,换了3个客厅2个办公楼走到今天。
这中间,真的是脱了几层皮,从一名少年成为现在的沧桑大叔。借着这个日子,和大家聊聊我的经历。


1


毕业即失业

去流水线感悟人生


我大学学的是信息与科学,是一个没有对口就业的专业,我的同学各凭本事分布在各行各业。

大学玩了4年,到大四我慌了,我没背景也没能力,真不知道未来怎么走,只能疯狂去面试。

当时在宝鸡上的大学,大四上半年全部时间都去西安面试,2个月时间面试了几十上百家,最后入职了一家炒股软件公司。

说是储备干部,其实就是销售,去各个证券交易所里面找人要电话号码,然后邀请去公司做活动买软件。

这不是我想要的工作,年前快放寒假的时候辞职了,没有底薪只有提成,一分钱没有拿到回学校了。

临近过年,更着急了。

那个时候老大在比亚迪工厂,于是想去深圳看看有没有就业机会,于是坐了20多个小时火车来到深圳。

求职无果后,入职了比亚迪流水线车间,车间里面做了一周时间,差点和车间主任干起来了,工作是给硬纸板打孔很危险,墙上到处是事故照片,没有指头的那种,想了想还是辞了。

我在富士康工厂,周边环境

临近过年总不能空手回家吧,又去了富士康观澜的物流部,不说有多辛苦好歹扎扎实实工作了2个月。

人生真的很神奇,我去的那个厂子就是富士康当年十几跳的第一跳,我刚去就有工友给我说前几天有人跳楼了。

那是09年,我人生第二次在外地过春节;第一次是06年被我兄弟骗到传销组织安徽阜阳,被限制自由了28天。

而最夸张的是,我有个很铁的朋友,妹妹被骗到传销组织中,中间我竟然还请了2周假,想办法把他妹妹从传销组织搞了出来,不过那就是另外一个故事了。

总感觉大四那一年,经历了很多事情,很混乱很懵懂也很迷茫,就像青春少年本身就是迷茫一样

我在流水线挥汗如雨的时候,我就只想了一件事情,我一个大学生要在这流水线干一辈子吗,我这大学不是白读了。

于是3月份春暖花开的时候,又回陕西了。



2


误入程序员


人生有时候就是需要一个契机,刚经历的时候没觉得有啥,当把这些都串起来的时候,就成为了人生。

我在大三的时候,突然感觉大学白读了,不学点啥好像又对不起这4年,上课感觉毫无意义工作又不用(我们是数学专业,天天玩高数、微积分)

刚好听说有数学建模竞赛,我就报名了,玩命的学了3个月,我是小组负责编程的那位也是组长,把C语言玩的贼溜。

没想到几个月后也获奖了,好像是省级二等奖,上图左下方那个人就是我,在学校宾馆小组3天3夜写一份论文出来。

让我对程序有了启蒙,毕业的时候也不是没有找过程序员岗位,但都是简历投递出去没有人回复的状态。

但也一直有关注,知道程序员有培训这么一回事,也知道程序员工资蛮高的,但有一定的门槛。

从深圳回来后,我下定了决心学软件开发,计划做一名程序员。

本来去咨询培训C语言、C++的,结果跑了一圈都说 Java 好就业,就最后选择了培训 Java。

找我姐借了1万元(我大学四年,学费也是借的)。

去银行取钱的那一天,我抱着1万元在柜台业务员的注视下,数了好多遍,那是我第一次见这么钱(09年)

培训班的事情就不说了,从刚开始的二指禅敲键盘,以及对 Java 一无所知,不过培训班氛围不错都是大四学生,有不会的大家都相互帮助。

培训班期间某次团建

我在编程这方面还是有点天赋的,很快到毕业的时候,已经给同班其他人讲编程了。



3


入职2周就被辞退了。


培训班会推荐几个公司,我是第一批推荐名单里的,很快就拿到入职的 Offer 了。

对比了一下,选择当时李嘉诚儿子的一家公司,是港企。

确实不一样,入职先培训一个月再上岗,那一批入职了18个人,其中就有我们培训中的5个人。

当时心态还是学生状态, 做出成果之后也不主动汇报,一个人坐在角落里面瞎捣鼓。

2周后,我被辞退了。

给我姐打电话的时候,我姐说,关键18个人里面仅仅辞退了2个人,就有你


可能当时也不服气吧,憋着一股劲投了几十家公司,最后被华为的外包公司录取了,人力外包直接到华为公司上班。

一个月工资,1800元。

当时华为比较正规,平时加班有加班费,周末加班双倍工资,于是我疯狂加班,一个月工资3000+。

外包是学不到东西的,虽然领导对我都很不错,入职几个月竟然又搞起了C++编程。

第一份工作,小巴组集体去团建

我一想花1万学的Java不就白费了,而且 Java 未来的就业更广阔一点,我不能就这样走弯了。

9个月后,离职,又找了一家港资,做电信业务,工资4000。

在这家公司,我遇到了人生最好的几个朋友,Jerry、鸽子、不会笑,我们四个人一起入职。

Flyever户外组织,秦岭某个地方

在这家公司和鸽子、不会笑,还创建了一个户外组织 Flyever,其实就是组织一群年轻人去山里玩。

几乎每周都有活动,不是吃饭喝酒唱歌通宵,就是半夜4、5起床去爬山游水,度过了人生中最快乐的一个阶段。

也和Jerry、鸽子、不会笑建立起来了革命友谊,我们四个后来在创业的路上有过多次交集。

在这家公司工作了1年半,从一个初级Java工程师开始做起,走的时候已经可以独立负责一个项目了。

当时又有一个契机

我妈要在老家盖房,我给了2万还不够,又借了亲戚大概十几万,我想了想我这4000的工资。

除去日常花销,要还多少年呀?

听说我们一个同事去了北京工资1万,我同事说那个人技术水平还没有我高,说者无心听者有意。

当年 Jeryy 也要去北漂,他想创业,我说那一起吧。

一起就一起。



4


北漂7年。


他比我早一周到北京,找了一家做 OA 的公司。

那个时候,北京的分钟寺还没拆,一个巨大无比的城中村,我当时拿了3000元,租房一个月1000。

刚去北京第二天,在鸟巢留念

我用了一周时间,拿到了4个Offer。

印象中北京的风是真大,能把我吹上天,当时还用的是小米1,一接电话就死机,耽搁了我好多事。

最高的一家给了1.3万,不过我当时目标很明确,只进互联网公司,1万的月薪入职了一家第三方支付公司。

其实我北漂7年,几乎可以说就在这一家公司干了。


2013年,那真是一个波澜壮阔的时代,也是移动互联网的时代,更是万众创业的时候。

我们公司就在中关村创业一条街附近,当时还叫海淀图书街,因为创业氛围太过浓烈,后面才改名叫做创业街。

Jeryy想创业,我们几乎跑遍了这条街的所有咖啡馆,车库咖啡、3W咖啡等等,周末点上一本咖啡坐上一整天。

一起想创业的点子,一起开发程序做Demo,写商业策划书去融资,刚去的1年,我几乎没有任何空余时间。

失败是必然的。

Flyever,被我带到了北京

组建了一个叫做陕西人在北京的

继续组织爬山、吃饭、唱歌、喝酒

不过,我工作的那家支付公司1年后迎来了一个契机。

当时互联网金融大行其道,我们作为第三方支付公司,董事长旗下还有不少其它业务需要融资。

于是决定要做一个互联网借贷平台,当时称P2P,我们的模式是B2P,企业向用户借钱,给用户利息。

刚开始是一个百度过来的高管负责,也是清华大学毕业的,结果干了半年后,这名高管自己创业了。

团队也被带走了,技术开发就剩了一个人。


我的领导的领导的领导,临危受命要把这项目接下来,但是找不到合适的人选来负责落地。

我们那一批来不少人,但大部分技术高手1年后都离职了,我其实也刚好找到了外部的工作。

领导说让我接手,我说可以呀,得涨工资,领导说行,那就我干了。

领导说,可以让我在部门调两个人过去,我选择了一个实习生,一名年轻有潜力的程序员。

就这样把这个项目接了起来。


一个月后项目上线,当天融资了1000万;但没过多久,一天的融资能力就变成了好几个亿。

项目成了,从一个项目组变成集团的一个子公司。

3年时间,

我就从一个项目组长,变成了技术经理、部门经理(测试、运维都管),再到公司的技术负责人。

2015年,北戴河团建
生命不息,奋斗不止!

互联网金融真是一个疯狂的年代,是我人生中最忙的一个阶段,也是我成长最为迅速的一个阶段

早上第一个来公司,晚上也几乎是最晚离开,23点之前没有离开过公司,有一次下班出车祸也就隔了一天又去上班了。

因为所有的事儿,都是必须我来扛。


16年,

也就是那个时候,我觉得应该把我段人生经历记录下来,特别是在互联网金融公司工作的那些事。

太好玩了。

高并发、大数据、微服务、分布式、黑客攻击、黑客勒索等等,我们又用的是市面上最新的技术栈,遇到了很多很多问题,也解决了很多很多问题。

我想要有一个窗口和技术人交流。

就到博客园写文章,没想到写一篇火上一篇,1年的时间,我就成为博客园推荐博客排名前10。

老版博客园的推荐博客列表

也写了一些开源项目,Github关注数量中国排名前50。

1年后,开通了个人公众号纯洁的微笑。


2017年,因为母公司要上市,我们被卖给了海淀国资委,公司的风格一下子就变了。

于是,我离职又重新去了母公司。

从这个互联网金融公司离开时

团队瞒着我,送了一个全体签名的短袖


从一名架构师开始做起,负责公司新支付系统的大数据平台建设,处理每天几十亿交易数据。

半年后,连跳3级被提拔为研发副总,当时研发事业部已经有超过100+的研发人员。

我只负责一个事情,把公司研发的第四代收单平台,推动上线迁移替换旧的收单系统。

中间又是遇到了 N 多的事情,除了技术问题之后,在高管的位置上自然有其它是非在其中。

有时候,你不站队也是一种站队。


18年,萌生去意,但老领导们提拔我不容易,至少要有知遇之恩,又咬着牙坚持了1年。

2018年,上海出差

19年,研发中心出现一次事故,借此就离开了。

19年离职的时候,我的副业自媒体收入早已经超过了主业,收入这方面倒也不是很在乎。

只是那个时候,也有别的互联网公司开出年薪百万工资挖我去做技术总监,当然了也是和支付相关的。

那我就面临人生一个最重要的选择,继续在北京互联网公司做高管,还是尝试一个人做点事情。

其实也没太多纠结,因为上班确实上够了。

从09年开始培训到19年离职,我的每一份工作和上一份工作都是连着的,10年时间没休息过。

HR劝我的时候,我说你觉得我现在创业风险大,还是几年后创业风险大,HR说应该是几年后吧。

我说,那就对了。



5


自由职业


离职之后,我就成为自由职业了。

那个时候,我还不敢对外说是创业,毕竟在我的印象中,创业应该是一件蛮大的事情了。

那个时候,我只能养活自己。

在离职之前,公司在美国有一个业务,需要派别人过去,我当时也申请了,顺势办理了美国10年签证。

于是,离职第一件事,搬家。

从三环边搬到五环旁,先改变自己的居住环境,毕竟自由职业要在家里上班的。

第二件事情,就是到处溜达,当然也包括美国。

现实自驾草原天路,再是北海道那一带,然后就是环京大环线,还在深圳附近自驾来半个月。

感觉还是不过瘾,又去美国旅游了一个月,从东海岸到西西海岸,从黄石公园到约书亚树国家公园。

2019年,纽约时代广场

所以,自由职业的头一年,大概就是不断的旅游,不断的折腾自媒体事业。


不过,从美国回来之后,让我意识到了一件事。

好像出国旅游花不了多少钱,既然是这样,何不计划一下环游世界,我从来都是一个说干就干的人。

要环游世界,那就要把英语学好,也要把北京的房子退了(一个月租金6500,几乎一半时间都空着)。

当时的计划是这样的。

第一步,把北京房子退了,东西都打包回老家。

第二步,在菲律宾报了一个语言学校,3个月先把英语给学扎实了,并且要拿到托福证书。

第三步,西安去亚洲,再去新西兰澳洲,最后环游欧洲,欧洲走完了再说后面的人生计划。


如果不是当初的那一场疫情,还不知道现在我在世界的哪个角落呆着,不知道从事着什么样的工作?

谋事在人,成事在天!



6


回到西安,创业


2019年,春节。

行李均已经邮寄到了老家,我从北京回到西安,准备在西安待上几天见见老朋友回家过春节。

当时菲律宾签证已经搞定,菲律宾当地的学校也联系好了,就连定金也给学校交了。

同时日本、新西兰的签证也办理好了。

一切静等在老家过完春节,开启自己环游世界的人生。


我在西安计划住5天,到了第3天就从新闻上传出,武汉那边好像出了一点事情,而且有蔓延的趋势。

到第3天,我看趋势不太对,没有坐班车,就打车直接回老家了。

在老家整整呆了6个月,这应该是我毕业以后,在老家呆过最久的一段时间了。

在老家的年轻人,也都陆陆续续回城市工作了,村里面的老大爷老大哥问我,你怎么还不走。

我是闲不住的人,既然环球旅游无望了,那就踏踏实实创业。

到西安后,就申请创建了公司。

在淘宝上,花了 600 元买了一个很大的黑色桌子,放在了客厅,作为办公桌,我坐这边,不会笑青年坐对面。

就这样开启创业了。


1月后,又搬到了不会笑青年家的客厅。

2020年,不会笑青年家的客厅

在他们小区可以打乒乓球,于是每天下午3点,无论夏日还是寒冬,我们都准时下去和老大爷抢场地打乒乓球。

1年后(2022年),不会笑青年要结婚,又搬到我新装修的房子客厅办公,顺势又把团队从2个人扩展到4个人。

公司变成了4个人。

集团活动从乒乓球又变成了羽毛球,于是4个人每天下午3点,又准时去小区打羽毛球。

这一打又是1年。


到了2023年,AI爆发。

我们觉得这是未来的大趋势,公司的整体发展方向全部转向AI,于是创建了 AI 俱乐部星球。

AI俱乐部用户已高达800人,AI俱乐部社群的推出

代表公司全部转向AI创业,后面的业务都和AI有关系。

2023年7月,又把公司从西安东北角搬到了西南角的高新区,终于有了一个相对正式的办公室。

搬家有两方面的原因:

一方面,不会笑青年当时住高新,来回通勤的压力很大;另一方面,想找一个办公楼方便后续招聘扩张。


搬家1个月后(2023年9月),掩体来西安旅游。

带来了AI变现训练营这个项目,我们当时测试了一下,一个月的时间80%的人可以月入过万。

那还有什么说的,全力 All in 继续搞!

又1年后,这个项目越做越大,团队规模又翻了一倍,从最初的4个人变成了8个人。

原来的办公司又坐不下了。

2023年,第一次搬到写字楼办公,在这个小办公室里面,加了N多次班。

今年7月,又重新租下了现在的办公室,也就是视频开头我拍摄的那个办公环境,可以容纳12人。

现在的这个办公室,才真正有点像一家公司的样子,在这之前我们都是草根游击队。

未来,公司能够发展多大,能够赚多少钱?已经不是我第一关心的事情了,更关心的是有没有更享受创业的这个过程。

从来没有想过要做多大的一个公司,一直想的是怎么做一个小而美的公司,有激情有奋斗有冲劲但又不是那么忙。

当然了,现在还差很远,还在既苦逼又享受的那个阶段。


人必有痴,而后有成,痴于物,而成于心。

09年到24年,

眨眼间,已经过去了15年,最大的感慨就是,人如果真要做成一件事,是不容易的。

特别对于我们这种出身贫穷的普通人。

每一步都不能错,错了就没有回头路能走,能够帮你的人永远只有你自己而已。

当然除了下苦功夫努力,也需要机缘更需要运气和贵人,才能让努力可以开花结果。

我特别喜欢一段话,作为这篇文章的结尾:

北冥有鱼,其名为鲲。鲲之大,不知其几千里也;化而为鸟,其名为鹏。鹏之背,不知其几千里也,怒而飞,其翼若垂天之云


作者:纯洁的微笑
来源:mp.weixin.qq.com/s/TBlJdOfzDCYDZ9NQVcTneQ
收起阅读 »

狂肝两周,写了一个"本地化"的桌面端工具:超级待办

web
事情是这样的,上一年写了一个基于uTools的插件,超级待办,目前日活300+,插件累计下载量24000+,有很多用户期望能够开发独立版本且数据可控(在本地保存),所以在插件版的基础上,优化UI,交互,新增相关功能,开发了一个桌面端版本(win+mac)的超级...
继续阅读 »

事情是这样的,上一年写了一个基于uTools的插件,超级待办,目前日活300+,插件累计下载量24000+,有很多用户期望能够开发独立版本且数据可控(在本地保存),所以在插件版的基础上,优化UI,交互,新增相关功能,开发了一个桌面端版本(win+mac)的超级待办。


1.gif


应用特色:


一、快速记录待办


二、所有数据保存在本地


很多人在记了一两条就放弃了,很难坚持下来,所以先把待办记下来,后续使用拖拽的形式快速修改待办状态,在此场景下,设计了多个快速记录待办的方式:


一、使用快捷键Ctrl+Space快捷键(可自定义)快速呼出应用,自动聚焦输入待办


二、剪切板转待办,应用会记录剪切板记录,你可以查看记录并转为待办


该应用共分为任务主面板、日历、列表、随心记(富文本)、分类、剪切板等模块。


任务主面板模块


在这里根据完成状态把任务分成未开始,进行中,已完成三个框体,不同状态之间支持拖拽,排序等快速操作;并且根据任务的紧急状态把每个任务分为紧急,高,中,低四个状态,方便标记任务,区分优先级;支持关联随心记,编辑,修改,删除,归档等操作。
image.png


日历模块


在这里根据每个任务的时间,分布在日历视图上,日历包含节假日等信息,能够查看任务分布及完成情况,支持关键字搜索,编辑任务,删除任务等操作。


image.png


列表模块


把任务汇总为列表模式,你可以在这里取消归档,按照任务状态进行筛选,关键字搜索,分类搜索,查看关联的随心记,删除,编辑等操作,并能直观的查看你所记录的待办事项。
image.png


随心记


这里使用了富文本编辑器,支持上传图片,方便记录待办所关联的一些信息,例如:我完成了一个模块的开发,可以把具体的注意事项关联写到随心记里,方便日后查看。不仅如此,你可以记录你想记录的任何文本。


image.png


分类


在这里你可以去维护应用的分类信息,在创建任务及随心记时,可以进行关联,方便归类管理。虽然模块名是分类,但在使用过程中,你可以把分类定义为项目名,日期归总等等。
image.png


剪切板


我们在应用后台,实时记录剪切板内容并保存到本地,你可以在这里把剪切板某个内容转为待办,也可以根据关键词进行搜索,同时可以一键删除所有,删除单个内容等。
image.png


设置


如果你觉得不希望每次都出现很大的框体,只希望在桌面上出现一个还未完成的任务面板并进行操作,你可以在设置中开启待办事项面板,开启后,你可以固定到桌面的任意位置。我们默认保存该面板的位置信息,在你重启电脑后,面板依旧在你设置的位置。


同时你可以在这里设置是否开机自启,是否显示快速添加待办的面板,启动时是否只显示一个输入框还是整个应用,以及呼出应用的快捷键,怎么使用,任你发挥。


为了极少的收集用户信息,我们采用微信绑定许可码(一机一码)形式,当应用安装后,可以按照操作申请试用许可码,每个微信支持绑定多个机器。
image.png


在公测期间,一共收获了300个用户,并提了很多宝贵的建议,后续迭代会持续的进行优化更新~


最后,如果你也有记录待办的需求,不妨体验一下呀~


作者:探路
来源:juejin.cn/post/7423583639081664564
收起阅读 »

极狐 GitLab 双重风波,Git 私服该如何选择?

极狐 GitLab 的双重风波 (一)间谍风波 前两天,极狐 GitLab 陷入了员工实名举报公司高管为美国间谍的漩涡之中。这一事件犹如一颗重磅炸弹,在业界引起了轩然大波。尽管目前尚未被实锤,但此消息一经传出,便迅速吸引了众多目光,也让极狐 GitLab 的企...
继续阅读 »

极狐 GitLab 的双重风波


(一)间谍风波


前两天,极狐 GitLab 陷入了员工实名举报公司高管为美国间谍的漩涡之中。这一事件犹如一颗重磅炸弹,在业界引起了轩然大波。尽管目前尚未被实锤,但此消息一经传出,便迅速吸引了众多目光,也让极狐 GitLab 的企业形象蒙上了一层阴影。这一事件不仅引发了内部员工的震动,也使得外界对其公司的信任度产生了动摇,其后续发展仍有待进一步观察。


(二)绝户网计划


就在今天早上,极狐 GitLab 的 “绝户网计划” 也浮出水面。据爆料,极狐 GitLab 要求销售人员在与使用 GitLab CE(社区版)免费版的用户交流时,引导用户明确表达正在使用免费版,并将此作为证据存档,以便未来可能的发函或起诉。 从其告知函来看,极狐 GitLab 强调其核心产品 GitLab 受法律保护,指出用户涉嫌未经授权使用软件,违反相关法律法规。公司内部对此计划也存在分歧,部分销售和技术同事反对,认为这会得罪潜在客户,影响长期生意,但公司高层却决定推行,寄希望于小部分害怕被起诉的用户付费来提升今年业绩。此计划引发了广泛争议,因为 GitLab 的免费版在全球范围内被大量程序员使用,且一直以来被认为是可商用的,这一举措无疑打破了以往的认知,让许多用户感到不满和担忧。



告知函内容如下:



GitLab 替代品分析


巧合的是,前不久我刚好对 GitLab 私服替代品进行了一波调研,同时成功将 GitLab 仓库迁移到新的仓库中,这次正好分享一下替代品的优缺点。


(一)Gitea



  • 优点

    • 轻量级:资源占用较低,对于硬件配置要求不高,适合小型团队或个人开发者在有限资源环境下搭建 Git 私服。

    • 功能较为完善:能够满足基本的代码托管、版本控制、分支管理等常见需求,支持多种主流操作系统的部署。

    • 社区活跃:有大量的开发者参与社区贡献,遇到问题时能够在社区中较快地获取解决方案和技术支持。



  • 缺点

    • 在大规模团队协作和复杂项目管理场景下,一些高级功能可能相对薄弱,例如在代码审查流程的精细化管理方面不如一些大型商业 Git 工具。

    • 与一些大型企业级工具相比,其集成能力可能稍逊一筹,在与其他企业内部系统如 CI/CD 平台、项目管理软件等的深度整合上存在一定局限性。

    • Gitea 的域名和商标在社区不知情、未经社区批准的情况下被转让给一家营利性公司,有一定开源风险。




(二)Gogs



  • 优点

    • 易于安装和使用:安装过程简单,即使是技术基础相对薄弱的用户也能快速上手搭建自己的 Git 私服。

    • 性能表现不错:在处理中等规模的代码仓库和团队协作时,能够保持较为稳定的运行速度和响应效率。

    • 界面简洁:对于注重简洁操作界面的用户来说,Gogs 的界面设计较为友好,易于操作和管理。



  • 缺点

    • 功能扩展性相对有限:虽然基本功能齐全,但在面对一些特殊需求或新兴技术场景时,可能难以通过插件或扩展机制快速实现功能增强。

    • 社区规模和活跃度不如一些头部的 Git 工具,这可能导致在长期发展过程中,功能更新和问题修复的速度相对较慢。




(三)OneDev



  • 优点

    • 强大的项目管理功能:除了基本的 Git 代码托管功能外,OneDev 在项目管理方面表现出色,提供了丰富的项目进度跟踪、任务分配、团队协作等功能,适合以项目为导向的团队使用。

    • 支持多语言:能够很好地适应不同语言环境下的开发团队需求,方便国际化团队协作。

    • 可定制性强:用户可以根据自己团队的特定需求,对 OneDev 的功能和界面进行一定程度的定制,以提高工作效率。



  • 缺点

    • 学习成本相对较高:由于其功能丰富且较为复杂,新用户需要花费一定时间来熟悉和掌握其操作流程和功能配置。

    • 部署相对复杂:相比一些轻量级的 Git 私服工具,OneDev 的部署过程需要更多的配置和环境依赖,对于运维人员的技术要求较高。




(四)GitBucket



  • 优点

    • 与 GitHub 风格相似:对于熟悉 GitHub 操作的用户来说,GitBucket 的界面和操作方式具有较高的相似度,降低了用户的迁移成本。

    • 支持多种数据库:可以灵活选择数据库类型,如 MySQL、PostgreSQL 等,方便根据现有技术架构进行整合。

    • 插件丰富:提供了大量的插件来扩展其功能,例如代码质量检测、代码统计等插件,能够满足不同团队的多样化需求。



  • 缺点

    • 性能优化方面可能存在不足:在处理大规模代码库和高并发请求时,可能会出现性能瓶颈,需要进行额外的性能调优工作。

    • 社区文档相对不够完善:在一些复杂功能的使用和问题排查上,由于社区文档的不全面,可能会给用户带来一定困扰。




(五)Gitblit



  • 优点

    • 专注于 Git 核心功能:对 Git 的核心功能支持得非常稳定和高效,如代码托管、分支管理、权限管理等,适合那些只需要基本 Git 功能且追求稳定性的团队。

    • 轻量级且资源占用少:在硬件资源有限的情况下,能够稳定运行,不会对服务器资源造成过大压力。

    • 安全性能较高:提供了较为完善的权限管理和安全机制,能够有效保护代码仓库的安全。



  • 缺点

    • 功能相对单一:缺乏一些现代 Git 工具所具备的高级项目管理和团队协作功能,如敏捷项目管理工具集成等。

    • 用户界面相对简陋:在美观度和交互体验上不如一些新兴的 Git 私服工具,可能会影响用户的使用感受。




Forgejo 的选择理由


经过个人调研和综合考量,最终选择了 Forgejo 替代 GitLab。Forgejo 是 Gitea 的一个硬分叉,它继承了 Gitea 的所有优点,如轻量级、功能完善、社区活跃等。同时,Forgejo 还具有自身独特的优势,其界面美观,给用户带来了良好的视觉体验;部署简单,降低了迁移成本和技术门槛,即使是非专业运维人员也能轻松上手;加载速度快,能够提高团队成员的工作效率,减少等待时间。


在迁移项目时,可以参考我之前写的迁移教程:



综上所述,个人认为 Forgejo 在应对极狐 GitLab 近期风波所带来的不确定性时,是一个较为理想的 Git 私服替代品。


相关资料



作者:子洋
来源:juejin.cn/post/7446578471901626420
收起阅读 »

面试了一个45岁的程序员,他要月薪2万,我同意了。结果面试完送他到电梯口,他说14薪月薪1.8万也行。

电梯口最后的那句话,让我整整失眠一夜。震惊!昨天的一场面试,让这个面试官陷入深深的自我反思。作为一个互联网公司的技术总监,8年来面试过上千名候选人,但这次经历让面试官久久不能平静,甚至彻夜难眠...在BAT大厂干了12年技术管理,面试过形形色色的程序员。有意气...
继续阅读 »

电梯口最后的那句话,让我整整失眠一夜。


震惊!昨天的一场面试,让这个面试官陷入深深的自我反思。作为一个互联网公司的技术总监,8年来面试过上千名候选人,但这次经历让面试官久久不能平静,甚至彻夜难眠...


在BAT大厂干了12年技术管理,面试过形形色色的程序员。有意气风发的应届生,有经验丰富的大牛,有从外企跳槽的专家,但从没有一次面试像这次一样,让我感受如此复杂,如此揪心。


昨天下午3点,一个穿着深色格子衬衫的中年人准时出现在会议室门口。他个子不高,微微有些谢顶,背着一个看起来有些年头的双肩包。包的肩带有些磨损,但被主人仔细地缝补过。


"您好,我是来面试Java开发岗位的。"他的声音透着一丝紧张,手里紧握着一份打印得整整齐齐的简历。


看了看简历,我心里一惊 —— 45岁,20年开发经验,科班出身,Java后端出身,精通分布式架构,曾经在几个知名公司待过,技术栈相当扎实。"这么资深为什么还在找工作?这个年龄段找工作会遇到什么困难?"我心里冒出一连串疑问。


面试正式开始。我们聊了他最近在研究的技术领域,他说起SpringCloud微服务架构时,眼睛里闪烁着光芒:"最近我在研究服务网格,看到社区有个问题,还提了个PR..."


当我抛出一个个技术难题,他的回答让我眼前一亮。Spring源码分析、JVM调优、分布式架构设计、性能优化经验...每个答案都精准到位,不仅能说出理论基础,更难得的是能结合实际场景,说出各种坑的实践经验。这绝对是一位技术大牛!


面试中他提到一个有趣的细节:"有次生产环境遇到一个诡异的Bug,年轻同事们试了好多方案都没解决。我凭着多年经验,想起12年前遇到过类似问题,最后找到根源是JVM的一个默认参数设置有问题..."


就在我准备问收入预期时,他的手机响了。他有些慌乱地掏出手机,看了一眼赶紧按掉:"对不起,是我女儿的电话,她今天考试..."我注意到他手机屏幕上的裂痕和掉漆的边框。


"您期望的薪资是多少?"问到这个问题时,他略显犹豫,目光有些闪躲。


"如果可以的话...希望月薪2万。"他的声音有点发抖,"我知道可能比市场价低一些..."


2万?我愣住了。以他的技术水平和经验,在深圳互联网公司要个35k-40k一点问题都没有。难道是我听错了?记得上个月刚招的一个3年经验的小伙子,谈价时底气十足地要价25k,最后我们也给了。


"您确定只要2万吗?以您的能力和经验..."


"是的,我觉得够用了。"他打断了我的话,语速突然变快,"主要是想找一个稳定的工作环境。现在这个行业...您也知道...我的年龄...其实能有工作机会我就很感激了。"


看着他局促不安的样子,我决定不再讨价还价:"好的,2万没问题,我们这边非常认可您的能力。"


他的眼睛一下子亮了起来,露出欣慰的笑容,甚至有些激动:"真的非常感谢!我一定会好好干,加班什么的完全没问题,我身体很好的,每天都在坚持跑步..."


面试结束后,按惯例我送他到电梯口。看得出来他心情不错,脚步都轻快了许多。


就在电梯门快关上的瞬间,他突然回头说了一句话:"其实...如果是14薪的话,月薪1.8万也可以...我就是希望能有个稳定的岗位..."


那一刻,我的心狠狠地揪了一下。电梯门缓缓关上,我却久久站在原地。


回到办公室,我的脑海里一直回荡着他说的那句话。打开电脑,我忍不住搜索了一下"中年程序员"这个话题。一篇篇文章像刀子一样戳着我的心:"35岁程序员职业危机"、"互联网公司青睐年轻人"、"中年程序员如何自救"...


原来在这个年轻人主导的互联网行业,45岁居然已经被贴上"高龄"的标签。这个数字像一道无形的门槛,挡住了多少技术大牛的职业发展之路?


想起面试时的细节:问到加班时,他特别强调自己的抗压能力,说周末随叫随到,身体素质一点问题都没有。一个45岁的技术大牛,却要用"随叫随到"来证明自己的价值...这句话背后,是多少无奈和妥协?


这个行业太狂热地追逐年轻,以至于忽视了经验的珍贵。 那些深夜里背后的debug经历、重重坑里趟出来的实践经验、危机时刻的临危不乱,都是年轻人用996都换不来的宝贵财富。


我开始回想他说过的话:"现在的新技术学起来也很快,我每天都在看技术博客,Github上的开源项目也一直在关注..."这句话背后,是多少个加班后的深夜自学时光?是多少个被质疑"老了学不动"后的倔强证明?


突然想起去年一个深夜,生产环境出现重大事故,年轻员工们手忙脚乱,是一个45岁的老程序员站了出来,凭着多年经验在半小时内定位到问题。那一刻,我就明白了什么叫"经验的价值"。


我立刻给HR打了电话:"这位候选人非常合适,请按2万月薪给offer,年终奖至少3个月。对了,入职后把他安排到王工组,让他带带新人。"


与其花20万培养一个年轻人踩坑,不如用同样的钱留住一个踩过无数坑的技术专家。 这不仅是对团队负责,更是对整个行业的价值观负责。


今天早上,我收到了他愉快的入职确认。想起昨天电梯口那一幕,我暗暗发誓:一定要让团队里的每个人,不分年龄,都能被公平对待,都能实现自己的价值。


也许有一天,当我也到了45岁,也会面临同样的困境。但我希望到那时,整个行业都能多一些包容,少一些偏见。技术,永远不该被年龄定义。


朋友们,你们觉得技术行业真的存在"年龄歧视"吗?在你心目中,技术人的黄金年龄是多少?欢迎在评论区分享你的想法和经历。


PS:今天我特意找物业,把他的停车位安排在了地下车库最方便的位置。这或许是我能做的,最微小但也最真诚的关照了。


PPS:刚才和CTO聊了这事,我们决定在公司推动一个"经验导师"计划,专门邀请一些资深程序员来做技术分享。毕竟,程序员的价值,不应该用年龄来衡量。


为什么他愿意主动降薪?


他的降薪行为背后,藏着许多深层次的职场逻辑。


1. 现实的自知之明


45岁,对于程序员来说已经算是“高龄”。在互联网行业,35岁被称为“职场分水岭”,他显然很清楚自己的处境。


他主动降低薪资,是在用“性价比”消除我的顾虑,同时也为自己争取更多的机会。


2. 对稳定的渴求


从简历上看,她的职业生涯很稳定,前一份工作待了十几年。这种“稳定感”不仅是他的一种职场标签,也是他的核心诉求。对于45岁的他来说,找一份能长期发展的工作,远比短期的高薪重要。


3. 一种不显山露水的智慧


他看似是退让,实际上是在“示弱”中展现了自己的诚意。他让招聘方觉得,他不是一个只谈薪资、不顾团队的人,而是愿意为公司利益妥协的合作者。这样的沟通方式,比硬碰硬的谈判更容易让人接受。


高龄程序员的职场困境与价值


他的故事,让我重新思考了“高龄程序员”在职场中的价值和困境。


1. 困境:行业的无情现实


  • 技术更新速度快,高龄程序员容易被贴上“跟不上时代”的标签。

  • 用人成本高,很多企业更愿意选择年轻且“便宜”的新人。

  • 体力和精力相比年轻人有劣势,难以适应高强度加班文化。


2. 价值:丰富经验不可替代


  • 高龄程序员的优势在于对复杂系统的理解能力,这种能力并不是通过短时间学习能掌握的。

  • 他们往往有很强的团队协作能力,能起到“传帮带”的作用。

  • 稳定性和责任感是他们的职业标签,尤其适合对稳定性要求高的岗位。


这次面试经历让我明白了两个道理:


  1. 职场不是简单的年龄比较,而是价值的体现。
    他虽然45岁,但他的经验和态度是团队里稀缺的宝藏。年轻人的速度和创造力,和老员工的稳定与深度,是互补关系,而不是对立关系。


  2. 谈判不是赢输,而是双赢。
    他的降薪行为,并不是单纯的退让,而是一种职场智慧。他通过主动示弱,换来了更多的机会,也打消了用人方的顾虑。


朋友们,你们对高龄程序员怎么看?你是否愿意为团队引入一个经验丰富但年龄偏大的同事?欢迎在评论区分享你的看法!


作者:小乐

来源:编程技术圈

收起阅读 »

真.i18n自动化翻译

web
背景 懒,不想因为文案的问题复制,所以做一个全自动翻译脚本(插件) 前置 想要的功能 开发者无感,不用做任何和翻译有关的工作,开过过程中只需要将文案写到标签中 不影响现存的文案 思路 通过husky,将脚本写在pre-commit 通过git diff,...
继续阅读 »

背景


懒,不想因为文案的问题复制,所以做一个全自动翻译脚本(插件)


前置


想要的功能



  1. 开发者无感,不用做任何和翻译有关的工作,开过过程中只需要将文案写到标签中

  2. 不影响现存的文案


思路



  1. 通过husky,将脚本写在pre-commit

  2. 通过git diff,获取发生变动的文件

  3. 对文件做一层过滤, 只对.vue、js文件中的文案进行翻译

  4. fs读取发生变动的文件的内容,最好将其解析成ast

  5. 遍历行数提取待翻译的文本,需要过滤掉注释中的文案,将需要翻译的文案回写到源语言json中

  6. 将每个文件内容中的文案替换成对应的i18n键值

  7. 读取源语言json,和目标语言json对比找出需要翻译的文案,文案调用第三方翻译接口对进行翻译(不要用公开的,容易挂)

  8. 统一将新翻译的文案注入json中


准备



  1. 一个第三方接口

  2. npm i husky

  3. 框架接入i18n


代码


const fs = require('fs')
const crypto = require('crypto')
const path = require('path')
const { execSync } = require('child_process')
const fetch = require('node-fetch').default

const apiUrl = '自己的接口'
const scriptDirectory = __dirname
const projectRootDir = path.resolve(scriptDirectory, '../..')
const sourceFilePath = path.join(projectRootDir, './src/assets/lang/json/zh-CN.json')
const targetFilePath = path.join(projectRootDir, './src/assets/lang/json/en-US.json')
const source = require(sourceFilePath)
const target = require(targetFilePath)

function md5(text) {
return crypto.createHash('md5').update(text).digest('hex')
}

function containsChinese(text) {
return /[\u4e00-\u9fa5]/.test(text)
}

async function translate(data, languageCode) {
let sign = 'bwcode.'
sign += data.map(item => item.fieldName).sort().join('.')
sign = md5(sign)
const bodyRequest = {
sign,
languageCode,
translateList: data,
}
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(bodyRequest),
})
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
const resJSON = await response.json()
if (resJSON.code !== 0) {
throw new Error(`result error! Status: ${JSON.stringify(resJSON)}`)
}
return resJSON.data.translateList
} catch (error) {
console.error('Error during translation request:', error.message)
}
}

// 暂时只翻译英语,后面有需要再拓展
async function startTranslate(data) {
const res = await translate(data, 'en-US')
// 转成对象
const fileContent = target
res.map(item => {
const key = item.fieldName.split('-')
if (key.length === 2) {
if (!fileContent[key[0]]) {
fileContent[key[0]] = {}
}
fileContent[key[0]][key[1]] = item.translateContent
} else {
fileContent[key[0]] = item.translateContent
}
})
fs.writeFileSync(targetFilePath, JSON.stringify(fileContent, null, 2), 'utf-8')
}

// 获取在 Git 中修改的文件列表
function getModifiedFiles() {
try {
// const result = execSync('git diff --name-only --cached', { encoding: 'utf-8' }) // 获取暂存区的修改
const result = execSync('git diff --name-only', { encoding: 'utf-8' }) // 获取工作区的修改
return result.split('\n').filter(item => item.includes('views'))
} catch (error) {
console.error('Error getting modified files:', error)
return []
}
}

function extractTemplateChinese(node) {
// 在线ast tree解析:https://astexplorer.net/
if (node.children && node.children.length) {
// console.log('node parent ------------------', node)
node.children.forEach(item => {
extractTemplateChinese(item)
})
} else if (containsChinese(node.value)) {
const regex = /[\u4e00-\u9fa5\s]+/
const content = node.value.match(regex)
source[content] = content
const newValue = node.value.replace(regex, match => `{{ $t('${match}') }}`)
node.value = newValue
}
}

function scanAndReplace(fileDirectory) {
const filePath = path.join(projectRootDir, `/${fileDirectory}`)
const pageContent = fs.readFileSync(filePath, 'utf8')
// vue2好像不支持ast,没有相关的ast库,所以还是直接用文本替换吧
// extractTemplateChinese(ast.templateBody)
// 匹配非注释的中文
const translationRegex = /[\u4e00-\u9fa5\s]+/g
// todo: js文件、template标签、scripts标签中的文案替换格式会不一样的
const translateData = pageContent.match(translationRegex)
const replacedPageContent = pageContent.replace(translationRegex, match => `{{ $t('${match}') }}`)
if (translateData && translateData.length) {
// 将替换后的内容写回文件
fs.writeFileSync(filePath, replacedPageContent)
// 记录文案,等所有文件扫描完毕后再回填数据
translateData.forEach(item => { source[item] = item })
}
}

function main() {
console.log('-- start translate --')
// 获取git diff的文件,寻找需要翻译的文案,并将文案提取出来新增到json后,文案替换成i18n格式
// 提取文案的过程最好用ast的方法,否则很难判断哪些中文是需要提取,哪些是注释
// 然而vue2的库太少了,要自己写,后面升级到vue3再完善这个自动提取的过程吧。目前要翻译什么文案还是手动去提取吧
// const modifiedFiles = getModifiedFiles()
// console.log('need translate file:', modifiedFiles)
// modifiedFiles.forEach(item => {
// // 暂时只对.vue文件进行翻译
// if (item.includes('.vue')) scanAndReplace(item)
// })

// 读取json,批量进行翻译(需要过滤掉已经翻译的文案)
const flat = []
// const sourceEntries = Object.entries(source)
// const targetEntries = Object.entries(target)
const translate = {}
for (const key in source) {
if (source.hasOwnProperty(key) && !target.hasOwnProperty(key)) {
translate[key] = source[key]
}
}
const translateEntries = Object.entries(translate)
translateEntries.forEach(([sourceKey, sourceValue]) => {
flat.push({ fieldName: sourceKey, content: sourceValue })
// if (typeof sourceValue === 'object' && sourceValue !== null) {
// const entriesChild = Object.entries(sourceValue)
// entriesChild.forEach(([entriesChildKey, entriesChildValue]) => {
// flat.push({
// fieldName: sourceKey+'-'+entriesChildKey,
// content: entriesChildValue,
// })
// })
// } else {
// flat.push({ fieldName: sourceKey, content: sourceValue })
// }
})
if (flat && flat.length) startTranslate(flat)
else console.log('no translate data')
console.log('-- end translate --')
}

main()

后续



  1. 因为vue2支持的ast转化库太少了,没找到合适的,需要自己写,懒得写了,所以2~6步跳过,代码上面也有,无非就是递归遍历ast树,替换文案,再转回字符串会写到文件中

  2. 感觉写成webpack/vite的插件会更好。有空在做吧。


作者:濷褚餾㨉㺭
来源:juejin.cn/post/7316357622847782931
收起阅读 »

为什么小明刚入职就碰到跨域问题然后转身离职?

故事的开始 故事还要从某个大厂的前端工程师 小明 说起。 小明兴高采烈的入职了某大厂的前端工程师岗位,就小明的水平的话,写写前端代码不在话下。 周四这天,小明吃完肯德坤之后,打开电脑浏览器,刷新页面,控制台就出现了下面的报错信息: 小明摸了摸自己大大的鼻子,...
继续阅读 »

故事的开始


故事还要从某个大厂的前端工程师 小明 说起。


小明兴高采烈的入职了某大厂的前端工程师岗位,就小明的水平的话,写写前端代码不在话下。


周四这天,小明吃完肯德坤之后,打开电脑浏览器,刷新页面,控制台就出现了下面的报错信息:


QQ_1733404303364.png


小明摸了摸自己大大的鼻子,一眼就看到了问题:



demo.hamm.cn 访问 api.hamm.cn 跨域了。



凭借多年的经验,小明瞬间就想到了解决方案:


那就摇人


因为刚入职,碰到这个问题他也不知道该找哪个人来处理,因为他没有 API 服务器的权限,于是在群里说了一句:



小明(前端):“哪位大哥帮忙配置下跨域, api.hamm.cn 需要允许来自 demo.hamm.cn 的跨域请求。”



三分钟后...



小贾(Java):“收到,我看看”


小董(运维):“好的,现在在外面,半小时后回”



五分钟后,小明收到了来自 Java 同学 小贾 的私聊



小贾(Java):“好的,已配置好跨域头,你看看”


小明(后端):“咦,可以了。感谢大哥!”



出幺蛾子了


半小时后,运维的 小董 同学回到公司,马不停蹄的给配了跨域头。


刚准备私聊告诉小明,却见 Java 的小贾同学在群里 @ 自己:



小贾(Java):“@小董(运维) 董哥,你的网关侧是不是有问题,我配置了跨域头,小明这边刚才还好好的可以跨域,这会又炸了。。。”



只见小明发了张图:


QQ_1733404994187.png


群里炸锅了


咦,两个请求头


QQ_1733405055850.png


怎么请求头两个了之后反而跨域失败了呢?


查找问题


运维哥 和 Java哥 一脸懵,于是开始查找问题。


只见小明不慌不忙的在群里 @ 了两人:



小明(前端):“@小董(运维) @小贾(Java) 两位哥,你们俩应该都配置了跨域头,两个跨域头也会跨域失败的哦~”


小董(运维):“@小贾(Java) 你应用服务里的去掉吧”


小贾(Java):“...”,心想 不是我先配置的么,凭什么。。。



你们都是在网关侧还是业务侧配置的允许跨域呢?欢迎评论区讨论


多跨域头禁止跨域


原来,多跨域头下,浏览器认为数据可能是被中间代理过,觉得不够安全,所以禁止了跨域访问。


MDN关于跨域 origin 的解释:


developer.mozilla.org/zh-CN/docs/…


第二天


小明递上了自己的离职申请书。


作者:Hamm
来源:juejin.cn/post/7444840771779690530
收起阅读 »

跨平台开发的新纪元:Tauri 2.0 横空出世,移动端、桌面端一网打尽!

前言 Tauri 2.0 正式版终于在 2024 年 10 月 2 日正式发布了。这次重大更新不仅带来了令人兴奋的新特性,更是为跨平台应用开发开辟了一条全新的道路。让我们一起来看看这个重量级版本带来了哪些惊喜! 1.移动端支持:拥抱全平台时代 Tauri 2...
继续阅读 »


前言


Tauri 2.0 正式版终于在 2024 年 10 月 2 日正式发布了。这次重大更新不仅带来了令人兴奋的新特性,更是为跨平台应用开发开辟了一条全新的道路。让我们一起来看看这个重量级版本带来了哪些惊喜!


1.移动端支持:拥抱全平台时代



Tauri 2.0 最引人注目的特性莫过于对 iOS 和 Android 的全面支持。现在,您可以用同一套代码库开发桌面端(Windows、macOS、Linux)和移动端应用,真正实现"一次编写,到处运行"的梦想。这不仅大大提高了开发效率,还为您的应用打开了更广阔的市场。


2.插件系统升级:灵活性与可扩展性的完美结合



新版本中,Tauri 将大量核心功能转移到了插件中。这意味着您可以根据需求自由选择功能,让应用更加轻量化。同时,插件系统的改进也为社区贡献打开了大门,期待看到更多创新的插件涌现。Tauri 2.0 的插件系统不仅更加灵活,还提供了丰富的官方插件,满足各种开发需求。以下是部分官方插件及其功能:



  • 自动启动 (Autostart): 让您的应用在系统启动时自动运行。

  • 条形码扫描器 (Barcode Scanner): 在移动应用中使用相机扫描二维码和条形码。

  • 生物识别 (Biometric): 在Android和iOS上进行生物识别认证。

  • 剪贴板 (Clipboard): 读取和写入系统剪贴板。

  • 命令行接口 (CLI): 解析命令行参数。

  • 深度链接 (Deep Linking): 将您的Tauri应用设置为特定URL的默认处理程序。

  • 对话框 (Dialog): 用于打开/保存文件和显示消息的原生系统对话框。

  • 文件系统 (File System): 访问文件系统。

  • 全局快捷键 (Global Shortcut): 注册全局快捷键。

  • HTTP客户端: 使用Rust编写的HTTP客户端。

  • 本地主机 (Localhost): 在生产应用中使用本地主机服务器。

  • 日志 (Logging): 可配置的日志记录。

  • NFC: 在Android和iOS上读写NFC标签。

  • 通知 (Notifications): 向用户发送原生通知。

  • 操作系统信息 (OS Information): 读取操作系统信息。

  • 持久化作用域 (Persisted Scope): 在文件系统中持久化运行时作用域更改。

  • 定位器 (Positioner): 将窗口移动到常用位置。

  • 进程 (Process): 访问当前进程。

  • Shell: 使用默认应用程序管理文件和URL,以及生成子进程。

  • 单实例 (Single Instance): 确保Tauri应用同时只运行一个实例。

  • SQL: 提供前端与SQL数据库通信的接口。

  • 存储 (Store): 持久化的键值存储。

  • Stronghold: 加密、安全的数据库。

  • 系统托盘 (System Tray): 系统托盘功能。

  • 更新器 (Updater): Tauri应用的应用内更新。

  • 上传 (Upload): 通过HTTP进行文件上传。

  • WebSocket: 在JavaScript中使用Rust客户端打开WebSocket连接。

  • 窗口自定义 (Window Customization): 自定义窗口外观和行为。

  • 窗口状态 (Window State): 保存窗口大小和位置。


这些插件涵盖了从基础功能到高级特性的广泛范围,让开发者能够根据项目需求灵活选择。通过这种模块化的方式,Tauri不仅保持了核心框架的轻量级,还为开发者提供了强大的扩展能力。无论您是开发一个简单的工具还是复杂的企业级应用,Tauri的插件系统都能满足您的需求。


3.安全性大幅提升:告别allowlist,迎接新的权限系统


Tauri 2.0 抛弃了旧的 allowlist 系统,引入了更加灵活和强大的权限、作用域和功能系统。这不仅提高了安全性,还让开发者能够更精细地控制应用的权限。值得一提的是,Tauri 还通过了独立的安全审计,让您使用起来更加放心。


4.性能优化:IPC层重写,更快更强


通过重写进程间通信(IPC)层,Tauri 2.0现在支持原始有效载荷,这意味着在前端和后端之间传输大量数据时,性能得到了显著提升。如果您的应用需要处理大量数据,这个特性绝对不容错过。


5.开发体验升级:HMR支持更给力


热模块替换(HMR)现在扩展到了移动设备和模拟器。这意味着您可以实时预览应用在不同设备上的表现,大大加速了开发和调试过程。


6.分发更简单:一站式解决方案



Tauri 2.0提供了详尽的分发指南,覆盖了从App Store到Google Play,再到Microsoft Store等多个平台。无论您的目标市场在哪里,Tauri都能帮您轻松应对。


结语


Tauri 2.0 的正式发布无疑是跨平台开发领域的一个重要里程碑。它不仅延续了 Tauri 一贯的轻量、快速的特点,还通过移动端支持、增强的插件系统和改进的安全机制等特性,为开发者提供了更强大、更灵活的工具。


如果您正在寻找一个能够同时覆盖桌面端和移动端的开发框架,Tauri 2.0绝对值得一试。它不仅能帮您节省时间和资源,还能为您的应用带来卓越的性能和安全性。


参考文章


v2.tauri.app/blog/tauri-…


v2.tauri.app/blog/tauri-…


作者:前端徐徐
来源:juejin.cn/post/7423231530498031631
收起阅读 »

跟 Antfu 一起学习 CSS 渐入动画

web
周末无事,翻阅 Antfu 的博客,发现一篇很有意思的文章,用简单的 CSS animation 动画实现博客文章按照段落渐入,效果如下: 是不是很有意思呢?作为一名前端开发,如果产品给你提出这样的动画需求,你能否实现出来呢?在继续阅读之前,不妨先独立思考一...
继续阅读 »

周末无事,翻阅 Antfu 的博客,发现一篇很有意思的文章,用简单的 CSS animation 动画实现博客文章按照段落渐入,效果如下:


sliding-p.gif


是不是很有意思呢?作为一名前端开发,如果产品给你提出这样的动画需求,你能否实现出来呢?在继续阅读之前,不妨先独立思考一下,如何用 CSS 来完整这种动画。


PS:什么,你问 Antfu 是谁?他可是前端圈里面的偶像级人物:



Antfu 是 Anthony Fu 的昵称,他是一位知名的开源软件开发者,活跃于前端开发社区。Anthony Fu 以其对 Vue.js 生态系统的贡献而著名,包括但不限于 Vite、VueUse 等项目。Antfu 也因为他在 GitHub 上的活跃参与和贡献而受到许多开发者的尊敬和认可。



首先用 CSS 写一个渐入动画,相信这个大家都看得懂:


@keyframes enter {
0% {
opacity: 0;
transform: translateY(10px);
}

to {
opacity: 1;
transform: none;
}
}

上述代码定义了一个名为 enter 的关键帧动画,其效果使得元素从透明度为0(完全透明)逐渐变为透明度为1(完全不透明),同时元素会在垂直方向上从 10px 以上的位置移动到最终位置。具体来说,关键帧如下:



  • 0%:动画的起始状态(动画开始时刻)。在这个状态中,元素的透明度 opacity设置为0,表示元素是完全透明的,看不见的。同时,transform: translateY(10px); 属性表示元素在垂直方向上被推移了 10px,即元素的起始位置是它最终位置的上方 10px



  • to100%:动画的结束状态(动画结束时刻)。在这个状态中,元素的透明度 opacity设置为1,表示元素完全不透明,完全可见。transform: none; 表示取消了之前的变换效果,元素恢复到它的原始形态和位置。


难道这样就行了吗?当然不行,如果仅仅对内容添加上述动画,效果是文章整体渐入,效果如下:


sliding.gif


然而我们想要的效果是一段一段渐入呀,那怎么办呢?思路很简单:



给每个段落分别添加上述动画,然后按照先后顺序延迟播放动画。



[data-animate] {
--stagger: 0;
--delay: 120ms;
--start: 0ms;
animation: enter 0.6s both;
animation-delay: calc(var(--stagger) * var(--delay) + var(--start));
}

上面的关键就是 animation-delay 这个属性,为了方便 HTML 编码,这里使用了 CSS 变量来进行控制,把元素的延迟时间总结到如下的公式里面:



calc(var(--stagger) * var(--delay) + var(--start));



其中变量的含义如下:



  • --stagger 是段落序号,值为1、2、3...

  • --delay 是上下两个段落的延迟时间间隔

  • --start 是初始延迟时间,即整片文章第一段的延迟偏移量


有了这些变量,就可以按照段落的前后顺序,写出如下 HTML 代码了:


<p style="--stagger: 1" data-animate>Block 1</p>
<p style="--stagger: 2" data-animate>Block 2</p>
<p style="--stagger: 3" data-animate>Block 3</p>
<p style="--stagger: 4" data-animate>Block 4</p>
<p style="--stagger: 5" data-animate>Block 5</p>
<p style="--stagger: 6" data-animate>Block 6</p>
<p style="--stagger: 7" data-animate>Block 7</p>
<p style="--stagger: 8" data-animate>Block 8</p>

实现的效果如下:


sliding-d.gif


可以说相当棒了!但是这里还有个问题,就是 markdown 文章转成 HTML 的时候,不会总是 p 标签吧,也有可能是 divpre 等其他标签,而且你还要手动给这些标签添加 --stagger 变量,这个简直不能忍啊。Antfu 最后给出的解决方案是这样的:


slide-enter-content > * {
--stagger: 0;
--delay: 150ms;
--start: 0ms;
animation: slide-enter 1s both 1;
animation-delay: calc(var(--start) + var(--stagger) * var(--delay));
}

.slide-enter-content > *:nth-child(1) { --stagger: 1; }
.slide-enter-content > *:nth-child(2) { --stagger: 2; }
.slide-enter-content > *:nth-child(3) { --stagger: 3; }
.slide-enter-content > *:nth-child(4) { --stagger: 4; }
.slide-enter-content > *:nth-child(5) { --stagger: 5; }
.slide-enter-content > *:nth-child(6) { --stagger: 6; }
.slide-enter-content > *:nth-child(7) { --stagger: 7; }
.slide-enter-content > *:nth-child(8) { --stagger: 8; }
.slide-enter-content > *:nth-child(9) { --stagger: 9; }
.slide-enter-content > *:nth-child(10) { --stagger: 10; }
.slide-enter-content > *:nth-child(11) { --stagger: 11; }
.slide-enter-content > *:nth-child(12) { --stagger: 12; }
.slide-enter-content > *:nth-child(13) { --stagger: 13; }
.slide-enter-content > *:nth-child(14) { --stagger: 14; }
.slide-enter-content > *:nth-child(15) { --stagger: 15; }
.slide-enter-content > *:nth-child(16) { --stagger: 16; }
.slide-enter-content > *:nth-child(17) { --stagger: 17; }
.slide-enter-content > *:nth-child(18) { --stagger: 18; }
.slide-enter-content > *:nth-child(19) { --stagger: 19; }
.slide-enter-content > *:nth-child(20) { --stagger: 20; }

只要给文章容器增加 slide-enter-content 样式,那么通过 nth-child() 就能为其直接子元素按照顺序设置 stagger 变量啦!


image.png


秒啊,实在是妙!不得不佩服大佬的脑洞,不过,杠精的你可能会说,我的文章又不止 20 个子元素,超过 20 怎么办呢?我说哥,你不会自己往后加嘛!


感兴趣的同学可以查看最终的样式代码,跟上述 demo 有一点点区别,相信你能从中学到不少东西,例如 Antfu 把 data-animate 属性关联的样式拆成了两段:


[data-animate] {
--stagger: 0;
--delay: 120ms;
--start: 0ms;
}

@media (prefers-reduced-motion: no-preference) {
[data-animate] {
animation: enter 0.6s both;
animation-delay: calc(var(--stagger) * var(--delay) + var(--start));
}
}

写前端这么多年,我是第一次见到 @media (prefers-reduced-motion: no-preference) 这个媒体查询的用法,一脸懵逼,赶紧恶补了一把才知道:



在 CSS 中,@media 规则用于包含针对不同媒体类型或设备条件的样式。prefers-reduced-motion 是一个媒体查询的功能,该功能用于检测用户是否有减少动画和动态效果的偏好。一些用户可能对屏幕上的快速或复杂动作敏感,这可能会导致不适或干扰体验,因此他们在操作系统中设置了减少动画的选项。



因此,对于那些讨厌动画的用户,就不用展示这么花哨的效果,直接展示文章就行啦!


作者:乔珂力
来源:juejin.cn/post/7338742634167205900
收起阅读 »

写了个自动化打包工具,大大滴解放了电脑性能

web
前段时间手底下的小伙伴跟我吐槽,说后端一点小改动就马上要包,电脑性能很差一旦run build之后就得等好几分钟的空窗期,被迫摸鱼导致加班,我灵机一动,是不是可以利用服务器的性能,编写自动化构建从而实现让后端、测试点点点,就能得到他们想要的不同版本的包、或者不...
继续阅读 »

前段时间手底下的小伙伴跟我吐槽,说后端一点小改动就马上要包,电脑性能很差一旦run build之后就得等好几分钟的空窗期,被迫摸鱼导致加班,我灵机一动,是不是可以利用服务器的性能,编写自动化构建从而实现让后端、测试点点点,就能得到他们想要的不同版本的包、或者不同分支的构建产物呢?


于是乎就有了我的设计并产出的开源:Sa-io https://github.com/LIAOJIANS/sa-io.git


Sa-io操作流程:新建项目(指定gitURL) => 内部执行(npm install)=> run build => SE(推送Sucesss日志) => publish(指定目标地址)=> dowl (下载专属产物)


image.png


项目架构


1、UI层


image.png


2、逻辑层


image.png
3、数据层


image.png


4、所需环境层


image.png


核心实现逻辑


1、技术清单



  • child_process:创建子进程并执行构建脚本;

  • chokidar: 监听日志文件内容;

  • scp2:建立SSH连接并传输文件;

  • Vue3:UI界面采用VUE3 + TS


2、核心逻辑


Run Build


router.post('/build', [
(() =>
['shell', 'install', 'projectName'].map((fild) =>
body(fild)
.notEmpty()
.withMessage('username or token is null'),
))(),
], (req, res, next) => {
checkBeforRes(next, req, async () => {

const {
shell,
install,
removeNm,
shellContent,
branch,
projectName,
pull,
...onter
} = req.body

if (os.platform() !== 'linux' && shell) {
return new Result(null, 'Running shell scripts must be in a Linux environment!!!')
.fail(res)
}

const curTime = Date.now()
const id = `${projectName}-${curTime}`
const fileName = `${id}.log`
const logPath = path.resolve(__dirname, `../log/${fileName}`)

let status = 'success'

const getHistory = () => getFileContentByName('history', [])

// 生成构建历史
let data = [
...getHistory(),
{
id,
projectName,
buildTime: curTime,
status: '',
branch
}
]

// 生成日志文件
getFileContentByName(
'',
'',
logPath
)

// 写入history基本信息
setFileContentByName(
'history',
data,
true
)

if (removeNm) {
await rmDir(projectName, 'node_modules') // 删除node_modules 防止不同分支不同版本的依赖冲突

rmFile(`${projectName}/package-lock.json`) // 删除安装依赖日志,防止版本缓存
}

if (branch) { // 如果有分支,并且分支不能等于当前分支,否则切换分支并拉取最新
const projects = getFileContentByName('projects')

const project = projects.find(p => p.projectName === projectName)

if (project.branch !== branch) {
try {
if (install) {

rmFile(`${projectName}/package-lock.json`) // 删除安装依赖日志,防止版本缓存
}

await gitCheckoutPro(projectName, branch)

setFileContentByName('projects', [
...projects.map(p => {
if (p.projectName === projectName) {
p.branch = branch
}

return p
})
], true)
} catch (e) {

console.log(e)

setFileContentByName(
'history',
[
...data,
{
projectName,
buildTime: curTime,
status: 'error',
branch
}
],
true
)

res.status(500).send('checkout error!!! Please review the log output!!!!!!')
}

} else if (pull) { // 拉取最新
try {
await gitPullPro(projectName, logPath)
} catch (e) {
res.status(500).send('checkout error!!! Please review the log output!!!!!!')
}
}
}


new Result(`${id}`, 'building, Please review the log output!!!!!!').success(res)

const compressedPro = () => {
status = 'success'
compressed(`${projectName}-${curTime}`, projectName)

console.log('success')
copyFile(
path.resolve(__dirname, `../project/${projectName}/dist`),
path.resolve(__dirname, `../builds/${projectName}-${curTime}`)
)

const {
publish,
...left
} = onter

if (publish) {
publishTragetServer({
...left,
localPath: path.resolve(__dirname, `../builds/${projectName}-${curTime}`)
})
}

}

if (shell) { // 执行sh脚本

setFileContentByName(
projectName,
shellContent,
true,
path.resolve(__dirname, `../project/${projectName}/build.sh`)
)

await shellPro(projectName, logPath)
.then(compressedPro)
.catch(() => {
status = 'error'
console.log('error')
})
} else { // 执行打包工作流
(
await (install ? installAfterBuildPro : buildPro)(projectName, logPath)
.then(compressedPro)
.catch(() => {
status = 'error'
console.log('error')
})
)
}

let newData = getHistory()

newData = newData.map(c => {
if (c.id === id) {
c.status = status
}

return c
})

setFileContentByName(
'history',
newData,
true
)
})
})

UI界面展示


build.gif


history.gif


最后放个项目地址:github.com/LIAOJIANS/s…


作者:大码猴
来源:juejin.cn/post/7445098587808514082
收起阅读 »

一个js库就把你的网页的底裤🩲都扒了——import-html-entry

web
概述 import-html-entry 是一个用于动态加载和处理 HTML 和 JS 文件的库,主要用于微前端架构中。它能够从远程服务器拉取 HTML 内容,并对其中的 JS 和 CSS 进行处理,以便在主应用中加载和执行。这个库是 qiankun 微前端框...
继续阅读 »

概述


import-html-entry 是一个用于动态加载和处理 HTML 和 JS 文件的库,主要用于微前端架构中。它能够从远程服务器拉取 HTML 内容,并对其中的 JS 和 CSS 进行处理,以便在主应用中加载和执行。这个库是 qiankun 微前端框架的核心依赖之一,提供了强大的动态加载和执行能力。在微前端框架 qiankun 中,import-html-entry 被用来解决 JS Entry 的问题,通过 HTML Entry 的方式,让用户接入微应用就像使用 iframe 一样简单。


使用方法


安装


首先,你需要通过 npm 或 yarn 安装 import-html-entry


npm install import-html-entry

或者


yarn add import-html-entry

基本使用


以下是一个简单的示例,展示如何使用 import-html-entry 加载一个远程的 HTML 文件,
我们看官网的例子

在index.html中
使用import-html-entry加载./template.html


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>index</title>
</head>
<body>
<script type="module">
window.onerror = e => {
console.log('error', e.message);
};
window.onunhandledrejection = (e) => {
console.log('unhandledrejection', e.reason.message);
};

import('./dist/index.js').then(({ importEntry }) => {
importEntry('./template.html').then(res => {
console.log(res);
return res.execScripts().then(exports => {
console.log(exports);
});
}).catch(e => {
console.log('importEntry failed', e.message);
});
});
</script>
</body>
</html>


template.html如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<link href="https://unpkg.com/antd@3.13.6/dist/antd.min.css" rel="stylesheet">
<link href="https://unpkg.com/bootstrap@4.3.1/dist/css/bootstrap-grid.min.css" rel="stylesheet">
</head>
<body>

<script src="./a.js"></script>
<script ignore>alert(1)</script>
<script src="./b.js"></script>
<script src="./c.js"></script>
<script src="https://unpkg.com/react@16.4.2/umd/react.production.min.js"></script>
<script src="https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js"></script>
<script src="https://www.baidu.com"></script>
</body>
</html>


template.html被import-html-entry处理过后如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<style>
/* antd样式被内链进入 */
</style>
<style>
/* bootstrap样式被内链进入 */
</style>
</head>
<body>

<!-- script http://127.0.0.1:7001/a.js replaced by import-html-entry -->
<!-- ignore asset js file replaced by import-html-entry -->
<!-- script http://127.0.0.1:7001/b.js replaced by import-html-entry -->
<!-- script http://127.0.0.1:7001/c.js replaced by import-html-entry -->
<!-- script https://unpkg.com/react@16.4.2/umd/react.production.min.js replaced by import-html-entry -->
<!-- script https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js replaced by import-html-entry -->
<!-- script https://www.baidu.com/ replaced by import-html-entry -->
</body>
</html>


可以发现html中的css被处理成为内链样式的了,其中的js代码script被注释掉了


importHTML返回值有如下几个:

1、template---处理过后的html

2、assetPublicPath---资源路径

3、getExternalScripts---执行后返回脚本信息

image.png
4、getExternalStyleSheets---执行后返回样式信息


image.png
5、execScripts---js代码执行器,可以传入代理的window对象


我们可以看出来,经过import-html-entry 处理后能够拿到这个html中的js、css内容,其中css会被处理成为内链样式嵌入HTML中,js我们可以通过execScripts传入自己的代理window可以实现js沙箱隔离


qiankun中如何使用的?


我们观察qiankun源码中是如何使用的import-html-entry

在src/loader.js中如下:


// 266行
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 347行
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
// get the lifecycle hooks from module exports
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);

可以看到和预期一样

1、使用import-html-entry拿到js执行器

2、执行execScripts,并且传入自己的globalContext

3、根据导出,拿到生命周期函数lifecycle


源码解析


import-html-entry 的核心功能是通过 fetch 获取指定 URL 的 HTML 内容,然后解析并处理这个 HTML 模板,最终返回一个包含处理后的 HTML、CSS 和 JS 的 Promise 对象。具体步骤如下:



  1. 拉取 HTML 并处理:通过 fetch 获取到 URL 对应的全部内容(即 HTML 文件的字符串),然后解析出以下内容:经过初步处理后的 HTML(去掉外链 CSS 和外链 JS)、由所有 script 组成的数组、由所有 style 组成的数组。

  2. 嵌入 CSS:通过 fetch 拉取到上述 style 数组里面对应的 CSS,然后将拉取到的每一个 href 对应的 CSS 通过 <style> 包裹起来且嵌入到 HTML 中。

  3. 执行 JS 脚本:支持执行页级 JS 脚本以及拉取上述 HTML 中所有的外联 JS 并支持执行。因此,在微前端中,使用此依赖可以直接获取到子应用(某 URL)对应的 HTML 且此 HTML 上已经嵌好了所有的 CSS,同时还可以直接执行子应用的所有 JS 脚本且此脚本还为 JS 隔离(避免污染全局)做了预处理。


整体流程如下图所示:
image.png


execScripts


image.png


code = getExecutableScript()


通过function+with实现js沙箱


function getExecutableScript(scriptSrc, scriptText, opts = {}) {
const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts;

const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;

// 将 scopedGlobalVariables 拼接成变量声明,用于缓存全局变量,避免每次使用时都走一遍代理
const scopedGlobalVariableDefinition = scopedGlobalVariables.length ? `const {${scopedGlobalVariables.join(',')}}=this;` : '';

// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)('window');
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? (
scopedGlobalVariableDefinition
? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();`
: `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
)
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}


evalCode(scriptSrc, code)


通过eval执行代码


export function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `(function(){${code}})`;
evalCache[key] = (0, eval)(functionWrappedCode);
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}

processTpl


const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath, postProcessTemplate);

看一下执行结果。


image.png


通过processTpl实现。

1、替换HTML

2、导出js入口列表

3、style列表

4、找到入口文件


作者:吃饺子不吃馅
来源:juejin.cn/post/7445090940278276147
收起阅读 »

大屏适配方案--scale

web
CSS3的scale等比例缩放 宽度比率 = 当前网页宽度 / 设计稿宽度 高度比率 = 当前网页高度 / 设计稿高度 设计稿: 1920 * 1080 适配屏幕:1920 * 1080 3840 * 2160(2 * 2) 7680 * 2160(4 * ...
继续阅读 »

CSS3的scale等比例缩放


宽度比率 = 当前网页宽度 / 设计稿宽度


高度比率 = 当前网页高度 / 设计稿高度


设计稿: 1920 * 1080


适配屏幕:1920 * 1080 3840 * 2160(2 * 2) 7680 * 2160(4 * 2)


方案一:根据宽度比率进行缩放(超宽屏比如9/16的屏幕会出现滚动条)


方案二:动态计算网页的宽高比,决定根据宽度比率还是高度比率进行缩放


首先基于1920 * 1080进行基础的布局,下面针对两种方案进行实现


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body,
ul {
margin: 0;
padding: 0;
}
body {
width: 1920px;
height: 1080px;
box-sizing: border-box;

/* 在js中添加translate居中 */
position: relative;
left: 50%;

/* 指定缩放的原点在左上角 */
transform-origin: left top;
}

ul {
width: 100%;
height: 100%;
list-style: none;

display: flex;
flex-direction: row;
flex-wrap: wrap;
}

li {
width: 33.333%;
height: 50%;
box-sizing: border-box;
border: 2px solid rgb(198, 9, 135);
font-size: 30px;
}
</style>
</head>
<body>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>

<script>
// ...实现适配方案
</script>
</body>
</html>

方案一:根据宽度比率进行缩放


// 设计稿尺寸以及宽高比
let targetWidth = 1920;

// html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;

console.log(currentWidth);
// 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;

// 进行缩放
document.body.style = `transform: scale(${scaleRatio})`;

实现效果如下:


b.gif
这时我们发现在7680 * 2160尺寸下,屏幕根据宽度缩放会出现滚动条,为了解决这个问题,我们就要动态的选择根据宽度缩放还是根据高度缩放。


方案二:动态计算网页的宽高比,决定根据宽度比率还是高度比率进行缩放


// 设计稿尺寸以及宽高比
let targetWidth = 1920;
let targetHeight = 1080;
let targetRatio = 16 / 9; // targetWidth /targetHeight

// 当前屏幕html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
// 当前屏幕html的高 || body的高
let currentHeight =
document.documentElement.clientHeight || document.body.clientHeight;

// 当前屏幕宽高比
let currentRatio = currentWidth / currentHeight;

// 默认 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
if (currentRatio > targetRatio) {
scaleRatio = currentHeight / targetHeight;
}

// 进行缩放
document.body.style = `transform: scale(${scaleRatio}) translateX(-50%);`;

效果如下:


b.gif
这样就可以解决在超宽屏幕下出现滚动条的问题,另外我们做了居中的样式处理,这样在超宽屏幕时,两边留白,内容居中展示显得更加合理些。


作者:Polepole
来源:juejin.cn/post/7359077652416725018
收起阅读 »

使用uniapp制作安卓app容器

1. 背景项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。用webview也方便快速修复页面问题...
继续阅读 »

1. 背景

项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。

webview也方便快速修复页面问题。

所以最后选择了uniapp,但是uniapp本身就是套在一个大的webview下的, 所以再套一个webview难免会有一些意想不到的问题,下面就是一些踩过的坑记录。

2. 项目初始化

新建项目就默认模板就行,我只需要壳子。

image.png 启动了之后可以看到有两个调试工具

image.png

第一个就是网页上常用的vue调试工具,可以看到vue组件属性啥的,第二个就是类似chrome的控制台,但是无法查看元素,还有就是必须让设备和电脑在同一个网段下才行,不然连接不上。

hbuilder的控制台本身也有一些输出,比如页面的console

image.png

但是这里输出对象的时候不是很方便查看,如果你需要的话就打开上面说的第二个调试工具。

3. webview使用

整个项目很简单,大概就这样一个页面

<template>
<web-view :src='PROJECT_PATH' @message="onMessage">web-view>
template>
<script>
// ...
script>

3.1 网页与app通信

这是最重要的一个功能,可以参考官方文档

网页和app交互总结起来就是这两点:

  • 网页 -> APPwindow.uni.postMessage();
  • APP -> 网页webview.evalJS()

3.1.1. 网页 -> APP

首先要在项目中引入uni.webview.js,这个就相当于jsbridge,可以让网页操作uniapp

初始化完成后会在window上挂载一个uni对象,通过uni.postMessage就能往app发送消息,app中监听onMessage就行。

这里有几个小坑:

  1. 发送的格式window.uni.postMessage({ data: 数据 }),必须要有个字段data,这样app才能收到数据。源码

image.png 2. 发送的数据不需要序列化成字符串,uniapp会转换json。 3. appmessage事件中接收到事件参数应该这样解构

function onMessage(e) {
const {
type,
data
} = e.detail.data[0]
}

3.1.2. APP -> 网页

app向网页传输消息就直接调用网页的js就行了。这里我统一封装了一个函数:

// app向网页发送消息
const deliverMessage = (msg) => {
// 调用webview中的deliverMessage函数
// 这个函数是我在网页挂载的一个全局函数,调用deliverMessage后会触发页面中的一些事件
currentWebview.evalJS(`deliverMessage(${JSON.stringify(msg)})`)
}

上面的代码例子中出现的currentWebview需要我们自己去获取。

// vue2中
const rootWebview = this.$scope.$getAppWebview()
this.currentWebview = rootWebview.children()[0]

// vue3中
import {
getCurrentInstance,
ref,
} from "vue";
const currentWebview = ref(null)
const vueInstance = getCurrentInstance()
const rootWebview = vueInstance.proxy.$scope.$getAppWebview()
currentWebview.value = rootWebview.children()[0]

这里也有一个坑,rootWebview.children()如果你一渲染就获取是无法获取到webview实例的,具体原因没有深入研究,估计是异步的原因

这里提供两个思路:

  1. 加一个定时器,延迟获取webview,这个方法虽然听起来不保险,但是实际测试还是挺稳当的。关键是简单
setTimeout(() => {
currentWebview.value = rootWebview.children()[0]
}, 1000)
  1. 你要是觉得定时器不保险,那就使用plusapi手动创建webview。但是消息处理这块比较麻烦。官网参考
<template>

template>
// 我这里vue3为例
onMounted(() => {
plus.globalEvent.addEventListener('plusMessage', ({data: {type, args}}) => {
// 是网页调用uni的api
if(type === 'WEB_INVOKE_APPSERVICE') {
const {data: {name, arg}} = args
// 是发送消息事件
if(name === 'postMessage') {
// arg就是传过来的数据
}
}
})
const wv = plus.webview.create("", "webview", {
'uni-app': 'none',
})
wv.loadURL(网页地址)
rootWebview.append(wv);
})

plus.globalEvent.addEventListener这个是翻源码找到的,主要是我不想改uni.webview.js的源码,所以只有找到正确的监听事件。

WEB_INVOKE_APPSERVICEuniapp内部定义的一个名字,反正就是用来交互操作的命名空间。

这样基础的互操作就有了。

3.1.3. 整个流程

  1. 网页调用window.uni.postMessage({ data }) => app监听(用组件的onMessage或者自定义的globalEvent
  2. app调用网页定义的函数deliverMessage并传递参数,网页中的deliverMessage内部处理监听
// 网页中的deliverMessage
window.deliverMessage = (msg) => {
// 触发网页注册的监听器
eventListeners.forEach((listener) => {

});
};

3.2. 返回拦截

默认情况下,手机按下返回键,app会响应提示是否退出,但是实际我需要网页进入二级路由的时候,按下手机返回键是返回上一级路由而不是退出。当路由是一级路由时才提示是否退出app

import {
onBackPress,
onShow,
} from '@dcloudio/uni-app'
// 页面当前的路由信息
const pageRoute = shallowRef()
onBackPress(() => {
// tab页正常app返回逻辑
if (pageRoute.value?.isTab) {
return false
} else {
// 二级路由拦截app返回
return true
}
})

pageRoute是页面当前路由信息,页面通过监听路由变化触发routeChange事件,将路由信息传给app。当按下返回键的时候,判断当前路由配置是不是tab页,如果是就正常退出,不是就拦截返回。

4. 总结

有了通信功能,很多操作就可以实现了,比如获取设备safeArea,获取设备联网状态等等。


作者:头上有煎饺
来源:juejin.cn/post/7313740940773097482

收起阅读 »

即梦AI上线新功能,可一句话生成中文海报

近日,即梦AI升级了图片生成功能,用户使用即梦pc版或app时,选择最新上线的图片2.1生图模型,通过输入文本描述,即可生成带有指定文字的海报。例如,输入:生成一张含有筷子夹起饺子的冬至插画海报,标题是“Winter Solstice”下方是“冬至”两字,即梦...
继续阅读 »

近日,即梦AI升级了图片生成功能,用户使用即梦pc版或app时,选择最新上线的图片2.1生图模型,通过输入文本描述,即可生成带有指定文字的海报。

图片5.png

例如,输入:生成一张含有筷子夹起饺子的冬至插画海报,标题是“Winter Solstice”下方是“冬至”两字,即梦就能按照指令快速完成。

图片6.png

据测试用户反馈,即梦AI新功能已经可以较为准确地生成中文文字,生图效果也更具影视质感。测试期间,用户已衍生出表情包、四格漫画、手写风格等多种玩法,更大限度地释放了创意。

字节豆包大模型团队相关负责人表示,豆包文生图模型通过打通LLM和DIT架构,具备更好的原生中文数据学习能力,并在此基础上强化汉字生成能力,大幅提升了生成效果。不过目前对于复杂的汉字生成还有提升的空间。据即梦相关负责人,团队正持续对文生图功能进行优化升级,近期还将上线对生成文字进行涂抹修改的功能,助力创作者们更好地实现想象力。

即梦AI是字节跳动旗下的AI内容平台,支持通过自然语言及图片输入,生成高质量的图像及视频。平台提供智能画布、故事创作模式,以及首尾帧、对口型、运镜控制、速度控制等AI编辑能力,并有海量影像灵感及兴趣社区,一站式提供用户创意灵感、流畅工作流、社区交互等资源,为用户的创作提效。(作者:李双)

收起阅读 »

前端实现画中画超简单,让网页飞出浏览器

web
Document Picture-in-Picture 介绍     今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏 🎬 视频流媒...
继续阅读 »

Document Picture-in-Picture 介绍


    今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏


🎬 视频流媒体的画中画功能


        你可能已经在视频平台(如腾讯视频哔哩哔哩等网页)见过这种效果:视频播放时,可以点击画中画后。无论你切换页面,它都始终显示在屏幕的最上层,非常适合上班偷偷看电视💻


pip.gif


        在今天的教程中,不仅仅是视频,我将教你如何将任何 HTML 内容放入画中画模式,无论是动态内容、文本、图片,还是纯炫酷的 div,统统都能“飞”起来。✨


        一个如此有趣的功能,在网上却很少有详细的教程来介绍这个功能的使用。于是我决定写一篇详细的教程来教大家如何实现画中画 (建议收藏)😁


体验网址:Treasure-Navigationpip08.gif


github地址




📖 Document Picture-in-Picture 详细教程


🛠 HTML 基本代码结构


    首先,我们随便写一个简单的 HTML 页面,后续的 JS 和样式都会基于它实现。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>

<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>
<script>
// 在这里写你的 JavaScript 代码
</script>
</body>
</html>



1️. 请求 PiP 窗口


    PiP 的核心方法是 window.documentPictureInPicture.requestWindow。它是一个 异步方法,返回一个新创建的 window 对象。

    PIP 窗口可以将其看作一个新的网页,但它始终悬浮在屏幕上方。


document.getElementById("clickBtn").addEventListener("click", async function () {
// 获取将要放入 PiP 窗口的 DOM 元素
const pipContent = document.getElementById("pipContent");
// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});

// 将原始元素添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent);
});

演示:


pip01.gif


👏 现在,我们已经成功创建了一个画中画窗口!
这段代码展示了如何将网页中的元素放入一个新的画中画窗口,并让它悬浮在最上面。非常简单吧


关闭PIP窗口


可以直接点右上角关闭PIP窗口,如果我们想在代码中实现关闭,直接调用window上的api就可以了


window.documentPictureInPicture.window.close();



2️. 检查是否支持 PiP 功能


    一切不能兼容浏览器的功能介绍都是耍流氓,我们需要检查浏览器是否支持PIIP功能
实际就是检查documentPictureInPicture属性是否存在于window上 🔧


if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}

    如果是只需要将视频实现画中画功能,视频画中画 (Picture-in-Picture) 的兼容性会好一点,但是它只能将元素放入画中画窗口。它与本文介绍的 文档画中画(Document Picture-in-Picture) 使用方法也是十分相似的。




3️. 设置 PiP 样式


    我们会发现刚刚创建的画中画没有样式,一点都不美观。那是因为我们只放入了dom元素,没有添加css样式。


3.1. 全局样式同步


假设网页中的所有样式如下:


<head>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
<link rel="stylesheet" type="text/css" href="https://abc.css">
</head>

为了方便,我们可以直接把之前的网页的css样式全部赋值给画中画


// 1. document.styleSheets获取所有的css样式信息
[...document.styleSheets].forEach((styleSheet) => {
try {
// 转成字符串方便赋值
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
// 创建style标签
const style = document.createElement('style');
// 设置为之前页面中的css信息
style.textContent = cssRules;
console.log('style', style);
// 把style标签放到画中画的<head><head/>标签中
pipWindow.document.head.appendChild(style);
} catch (e) {
// 通过 link 引入样式,如果有跨域,访问styleSheet.cssRules时会报错。没有跨域则不会报错
const link = document.createElement('link');
/**
* rel = stylesheet 导入样式表
* type: 对应的格式
* media: 媒体查询(如 screen and (max-width: 600px))
* href: 外部样式表的 URL
*/

link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
console.log('error: link', link);
pipWindow.document.head.appendChild(link);
}
});

演示:

image.png




3.2. 使用 link 引入外部 CSS 文件


向其他普通html文件一样,可以通过link标签引入特定css文件:


创建 pip.css 文件:


#pipContent {
width: 600px;
height: 300px;
background: skyblue;
}

js引用:


// 其他不变
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = './pip.css'; // 引入外部 CSS 文件
pipWindow.document.head.appendChild(link);
pipWindow.document.body.appendChild(pipContent);

演示:

pip02.gif


3.3. 媒体查询的支持


可以设置媒体查询 @media (display-mode: picture-in-picture)。在普通页面中会自动忽略样式,在画中画模式会自动渲染样式


<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}

<!-- 普通网页中会忽略 -->
@media (display-mode: picture-in-picture) {
#pipContent {
background: lightgreen;
}
}
</style>

在普通页面中显示为粉色,在画中画自动变为浅绿色


演示:

pip03.gif




4️. 监听进入和退出 PiP 模式的事件


我们还可以为 PiP 窗口 添加事件监听,监控画中画模式的 进入退出。这样,你就可以在用户操作时,做出相应的反馈,比如显示提示或执行其他操作。


// 进入 PIP 事件
documentPictureInPicture.addEventListener("enter", (event) => {
console.log("已进入 PIP 窗口");
});

const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 退出 PIP 事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});

演示


pip04.gif




5️. 监听 PiP 焦点和失焦事件


const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});

pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});

pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});

演示


pip05.gif




6. 克隆节点画中画


我们会发现我们把原始元素传入到PIP窗口后,原来窗口中的元素就不见了。

我们可以把原始元素克隆后再传入给PIP窗口,这样原始窗口中的元素就不会消失了


const pipContent = document.getElementById("pipContent");
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 核心代码:pipContent.cloneNode(true)
pipWindow.document.body.appendChild(pipContent.cloneNode(true));

演示


pip07.gif


PIP 完整示例代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>
<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>

<script>
// 检查是否支持 PiP 功能
if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}

// 请求 PiP 窗口
document.getElementById("clickBtn").addEventListener("click", async function () {
const pipContent = document.getElementById("pipContent");

// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});

// 将原始元素克隆并添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent.cloneNode(true));

// 设置 PiP 样式同步
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pipWindow.document.head.appendChild(style);
} catch (e) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
pipWindow.document.head.appendChild(link);
}
});

// 监听进入和退出 PiP 模式的事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});

pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});

pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});
});

// 关闭 PiP 窗口
// pipWindow.close(); // 可以手动调用关闭窗口
</script>
</body>
</html>




总结


🎉 你现在已经掌握了如何使用 Document Picture-in-Picture API 来悬浮任意 HTML 内容!
希望能带来更灵活的交互体验。✨


如果你有什么问题,或者对 PiP 功能有更多的想法,欢迎在评论区与我讨论!👇📬


作者:前端金熊
来源:juejin.cn/post/7441954981342036006
收起阅读 »

new Image() 预加载 为什么比 <img>直接加载要好?

web
<img> 直接加载对比 new Image() 预加载 1. 加载时机和页面渲染的差异 直接渲染到 <img> 标签: 当你直接在 HTML 中通过 <img> 标签加载图片时,浏览器在遇到 <img> 标签...
继续阅读 »

<img> 直接加载对比 new Image() 预加载


1. 加载时机和页面渲染的差异



  • 直接渲染到 <img> 标签: 当你直接在 HTML 中通过 <img> 标签加载图片时,浏览器在遇到 <img> 标签时会立即开始加载图片。这意味着浏览器在渲染页面的过程中,会同步进行图片请求。当页面需要渲染图片时,可能会导致图片显示之前页面的其它部分无法完全显示,或者图片加载的过程中页面会出现闪烁或布局跳动。


    这种加载方式是 同步 的,即浏览器渲染页面时,图片的加载和显示是直接相关的。如果图片较大或者网络慢,用户可能会看到空白的占位符,直到图片加载完成。


  • 使用 new Image()img.src = src: 这种方式会在后台加载图片,不直接影响页面的渲染。也就是说,图片资源在浏览器缓存中已经加载好了,页面显示图片时,浏览器能快速地从缓存读取图片,而不必等待网络请求。浏览器不会因为加载图片而延迟页面的渲染。


    关键点是:通过 new Image() 加载图片会提前发起请求,将图片缓存到浏览器中,这意味着你可以在用户滚动或需要展示图片时,直接从缓存加载,而不需要重新请求网络资源。这个过程是 异步 的。



2. 浏览器的资源管理和缓存



  • 图片预加载的缓存: 当你通过 new Image() 加载图片时,图片会被缓存在浏览器的内存中(通常是浏览器的资源缓存),因此如果图片已经被加载过,后续使用该图片时会直接从缓存读取,而不需要重新请求网络资源。


    而如果你直接用 <img> 标签来加载图片,浏览器同样会请求并缓存图片,但如果图片在初次加载时不可见(比如在页面下方),用户滚动到该位置时,可能会再次触发网络请求,尤其是在使用懒加载(lazy load)等技术时。如果图片已经预加载过,浏览器就可以从缓存中直接加载,避免了再次请求。



3. 避免页面阻塞



  • 直接使用 <img> :当浏览器在解析页面时遇到 <img> 标签,会立即发起网络请求来加载图片。如果图片资源很大或者服务器响应很慢,浏览器可能需要等待这些资源加载完成,才能继续渲染其他部分。这会导致页面的 渲染阻塞,即页面内容渲染较慢,特别是在图片多的情况下。

  • 使用 new Image() 预加载:通过 new Image() 预加载图片,可以避免渲染时对页面的阻塞。浏览器在后台加载图片,直到需要展示图片时,图片已经准备好了,这样页面展示可以更快,用户体验也更好。


4. 适用场景



  • 直接 <img> 标签加载:适用于图片较少且页面上几乎所有图片都需要立即展示的场景。例如,单一图片展示的页面。

  • new Image() 预加载:适用于图片较多或需要延迟加载的场景,例如动态加载的图片、长页面或者需要懒加载的图片库。它允许你提前将图片加载到浏览器缓存中,减少后续显示时的加载时间。


5. 加载速度和时间


如果从加载速度和时间上来看,两者的差别可能不大,因为它们最终都会发起一次网络请求去加载图片。但是,new Image() 的优势在于:



  • 它允许你在图片真正需要显示之前就开始加载,这样当用户需要看到图片时,图片已经在浏览器缓存中,可以即时显示。

  • 使用 new Image() 可以提前加载图片,而不会影响页面的渲染顺序和内容显示,不会造成页面的阻塞。


6. 网络请求优化


new Image() 还可以和 并发请求 进行优化。如果你有多个图片需要预加载,可以通过多个 new Image() 实例来并行加载这些图片,而不影响页面的渲染。并且,如果你知道某些图片很可能会被需要(例如图片懒加载场景中的下拉加载图片),你可以提前加载这些图片,确保用户滚动时能立刻看到图片。


image.png


7. 总结对比


特性<img> 标签加载new Image() 预加载
渲染影响直接渲染图片,可能导致页面闪烁或布局跳动异步加载图片,不影响页面渲染
缓存图片加载后会缓存,但可能会重复请求图片预先加载到缓存中,避免重复请求
适用场景单一图片,少量图片,图片快速加载图片较多,懒加载,预加载
加载时机页面渲染时加载,可能导致渲染延迟提前加载,确保图片准备好时显示

结论


虽然从技术上讲,直接在 <img> 标签中加载图片和使用 new Image() 设置 src 都会触发相同的图片加载过程,但是 使用 new Image() 进行预加载 提供了更灵活的控制,使得你可以在页面渲染时避免图片加载阻塞,提升页面的加载速度和用户体验。


(补充:代码示例小demo传送门)


1再见.png


作者:不爱说话郭德纲
来源:juejin.cn/post/7441246880666107931
收起阅读 »

微信小程序批量自动化部署

CI/CD这个概念很实用,但我们这种小作坊,没有一些很高大上的应用。 最常见的使用场景就是,开发者一键提交分支master,交给工作流工具完成构建,部署的后续操作,自动更新测试或线上环境。 个人博客等项目可以使用Github action来实现,但公司的代码在...
继续阅读 »

CI/CD这个概念很实用,但我们这种小作坊,没有一些很高大上的应用。


最常见的使用场景就是,开发者一键提交分支master,交给工作流工具完成构建,部署的后续操作,自动更新测试或线上环境。


个人博客等项目可以使用Github action来实现,但公司的代码在云效上,我更习惯于使用云效Flow来实现自动化部署。他的操作菜单是可视化的,非常方便,还有一些推送机器人消息的傻瓜化配置插件。


目前遇到一个需求,就是同一个uni-app小程序项目,需要部署到多个不同的小程序上。每个小程序的主要功能类似,但都有一些定制改动。


每次项目发版时,如果要手动挨个在微信开发者工具上切换、上传,会非常繁琐,而且uni-app使用dev命令输出的开发环境微信小程序项目代码也没有优化,正式发版时哪怕只有一个小程序也需要在dev、build两个项目里来回切。


因此非常需要自动化部署来节省精力。


下面梳理一下微信小程序的批量自动化部署实现流程。


准备工作


常见的web项目自动化部署,至少包含代码触发、构建、部署这3个步骤。


其中构建步骤中操作的产物会被打包上传,并在部署步骤中,下载到目标服务器,然后执行后续目录操作、启动等操作。


但是微信小程序的部署不需要这些操作,而是通过在node脚本中执行miniprogram-ci这个工具的相关方法来实现的。


miniprogram-ci的相关文档请参考这里


密钥及IP白名单配置


跟着文档操作,首先需要到微信小程序管理后台的开发设置中进行配置。


点击生成按钮即可创建密钥,关闭后只能重新生成。


1.png


将密钥文件下载到安全的位置。由于我们的项目是私有库,这里就直接放到了项目deploy目录下。多个小程序的密钥可以放在一起,默认已经用appId做了区分。


云效Flow的构建集群提供了一组IP地址,将这些IP地址加入白名单即可。地址如下:


47.94.150.88
47.94.150.17
47.93.89.246
123.56.255.38
112.126.70.240

来源


IP地址不在白名单的话,调用上传时会报错。如果在本地调试,别忘了将本机的公网IP加入白名单,或者临时关闭。


构建脚本


uni-app项目使用vite框架,这里用到了.env环境变量的相关功能,原生微信小程序请自行实现或省略此功能。


更新版本号


微信小程序上传时,需要指定版本号。


版本号标准的用法还是放在package.json中,所以我在自动化部署实现过程中,顺便就引入了standard-version版本号管理。(项目被标记为deprecated,但我没有找到其他适合私有库的版本号管理工具,欢迎指点。)


standard-version可以自动根据git提交记录生成CHANGELOG.md


并按照以下初始规则来生成版本号:


如果上个版本之间的提交只有fixed,更新patch版本号,比如1.0.0更新到1.0.1


否则更新minor版本号,比如1.0.0更新到1.1.0


更新版本号的同时,它会将CHANGELOG.md与更新版本号以后的package.json一同提交到git,同时创建一个版本号对应的tag。




在一般项目中这样就足够了,但是如果还想在小程序中展示这个版本号,就会存在问题——无法引入package.json文件。


而且使用wx.getAccountInfoSync虽然也能获取版本号,但只有正式环境能用,在体验版、开发版中是空字符串。


因此,我只能修改部署版本命令,加上了一些后处理脚本,将版本号同步更新到环境变量中。


在package.json的script中,添加以下命令:


{
"scripts": {
"release": "standard-version && node deploy/deploy.js version",
"release:minor": "standard-version -- --release-as minor && node deploy/deploy.js version",
"release:beta": "standard-version -p beta && node deploy/deploy.js version"
}
}

多提一嘴,release:minor是在提交记录只有fixed但又希望更新minor版本时使用的,可以无视默认规则。当然也可以无视所有规则直接指定具体版本号,具体使用可查看文档(github.com/conventiona…


后处理脚本


在deploy目录下,创建deploy.js文件,内容如下:


const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const { execSync } = require('node:child_process')
const JSON5 = require('json5')
const ci = require('miniprogram-ci')
const { Command } = require('commander')
const dayjs = require('dayjs')
const dotenv = require('dotenv')
const { version } = require('../package.json')

const program = new Command()

// 同步版本号
program
.command('version')
.option('-a, --appid <type>', 'application id')
.action((options) => {
const envPath = path.resolve(__dirname, '../.env')
// 读取 .env 文件的内容
const envContent = fs.readFileSync(envPath, 'utf8')

// 分割每一行
const lines = envContent.split('\n')

// 定义新的内容数组
const newLines = []

// 遍历每一行,查找并修改 VITE_APP_VERSION 的值
lines.forEach((line) => {
if (line.startsWith('VITE_APP_VERSION=')) {
newLines.push(`VITE_APP_VERSION=${version}`)
}
else {
newLines.push(line) // 保留其他行,包括注释
}
})

// 将修改后的内容写回 .env 文件
fs.writeFileSync(envPath, newLines.join('\n'))

// 添加文件到暂存区
execSync(`git add ${envPath}`)

// 获取前一次提交的标签
let tag
try {
tag = execSync('git describe --tags --abbrev=0').toString().trim()
}
catch (error) {
console.error('没有找到标签')
process.exit(1)
}

// 将当前暂存区的改动追加到前一次提交中
execSync('git commit --amend --no-edit')

// 删除旧的标签
execSync(`git tag -d ${tag}`)

// 将标签移动到新的提交
execSync(`git tag ${tag}`)
})

program.parse(process.argv)

这个脚本会读取.env文件,找到VITE_APP_VERSION这一行,将其值更新为package.json中的version,然后将改动合并到前一次的git提交中,也就是standard-version所创建的提交。


没有用dotenv是因为这个工具更适合读取配置,但写入时会丢失注释信息。


构建小程序


如果只有一个小程序,可以略过此步,直接执行构建命令然后上传。


有多个小程序时,需要先执行一些定制脚本,再执行构建。比如至少要做的一项操作是更新appId,在uni-app中,这项配置位于manifest.json中。


deploy/deploy.js中添加以下代码:


// 切换小程序
program
.command('toggle')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 定义文件路径
const filePath = path.join(__dirname, '../src/manifest.json')
// 读取 JSON 文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件失败:', err)
return
}

try {
// 解析 JSON 数据(支持注释)
const jsonData = JSON5.parse(data)

// 修改 appid 字段
jsonData['mp-weixin'].appid = options.appid

// 将修改后的 JSON 数据转换为字符串(支持注释格式)
console.log(jsonData)
const updatedData = JSON.stringify(jsonData, null, 2)
console.log(updatedData)
// 写入修改后的数据到 JSON 文件
fs.writeFile(filePath, updatedData, 'utf8', (err) => {
if (err) {
console.error('写入文件失败:', err)
return
}
console.log('文件已成功更新')
})
}
catch (err) {
console.error('解析 JSON 数据失败:', err)
}
})
})

这个脚本会读取manifest.json文件,找到mp-weixin.appid这一行,将其值更新为命令行参数中的appid,然后将改动写入manifest.json文件。


调用脚本的命令例子为:


node deploy/deploy.js toggle --appid=你的appid

上传小程序


deploy/deploy.js中添加以下代码:



// 上传小程序
program
.command('upload')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}

// 获取当前工作目录的父路径
const projectDir = path.join(__dirname, '../')

const project = new ci.Project({
appid: options.appid,
type: 'miniProgram',
projectPath: `${projectDir}/dist/build/mp-weixin`,
privateKeyPath: `${projectDir}/deploy/private.${options.appid}.key`,
// ignores: ['node_modules/**/*'],
})
ci.upload({
project,
version,
desc: `CI机器人于${dayjs().format('YYYY-MM-DD HH:mm:ss')}上传`,
setting: {
es6: true,
es7: true,
minify: true,
// autoPrefixWXSS: true,
minifyWXML: true,
minifyJS: true,
},
}).then((res) => {
console.log(res)
console.log('上传成功')
process.exit(0)
}).catch((error) => {
if (error.errCode === -1) {
console.log('上传成功')
process.exit(0)
}
console.log(error)
console.log('上传失败')
process.exit(-1)
})
})

program.parse(process.argv)


这个脚本会调用微信小程序的CI接口,将小程序上传到微信服务器。调用脚本的命令例子为:


node deploy/deploy.js upload --appid=你的appid

其中appid在命令行中传入,而version是从package.json中读取的。


完整的deploy.js文件


const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const { execSync } = require('node:child_process')
const JSON5 = require('json5')
const ci = require('miniprogram-ci')
const { Command } = require('commander')
const dayjs = require('dayjs')
const dotenv = require('dotenv')
const { version } = require('../package.json')

const program = new Command()

// 同步版本号
program
.command('version')
.option('-a, --appid <type>', 'application id')
.action((options) => {
const envPath = path.resolve(__dirname, '../.env')
// 读取 .env 文件的内容
const envContent = fs.readFileSync(envPath, 'utf8')

// 分割每一行
const lines = envContent.split('\n')

// 定义新的内容数组
const newLines = []

// 遍历每一行,查找并修改 VITE_APP_VERSION 的值
lines.forEach((line) => {
if (line.startsWith('VITE_APP_VERSION=')) {
newLines.push(`VITE_APP_VERSION=${version}`)
}
else {
newLines.push(line) // 保留其他行,包括注释
}
})

// 将修改后的内容写回 .env 文件
fs.writeFileSync(envPath, newLines.join('\n'))

// 添加文件到暂存区
execSync(`git add ${envPath}`)

// 获取前一次提交的标签
let tag
try {
tag = execSync('git describe --tags --abbrev=0').toString().trim()
}
catch (error) {
console.error('没有找到标签')
process.exit(1)
}

// 将当前暂存区的改动追加到前一次提交中
execSync('git commit --amend --no-edit')

// 删除旧的标签
execSync(`git tag -d ${tag}`)

// 将标签移动到新的提交
execSync(`git tag ${tag}`)
})

// 切换小程序
program
.command('toggle')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 定义文件路径
const filePath = path.join(__dirname, '../src/manifest.json')
// 读取 JSON 文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件失败:', err)
return
}

try {
// 解析 JSON 数据(支持注释)
const jsonData = JSON5.parse(data)

// 修改 appid 字段
jsonData['mp-weixin'].appid = options.appid

// 将修改后的 JSON 数据转换为字符串(支持注释格式)
console.log(jsonData)
const updatedData = JSON.stringify(jsonData, null, 2)
console.log(updatedData)
// 写入修改后的数据到 JSON 文件
fs.writeFile(filePath, updatedData, 'utf8', (err) => {
if (err) {
console.error('写入文件失败:', err)
return
}
console.log('文件已成功更新')
})
}
catch (err) {
console.error('解析 JSON 数据失败:', err)
}
})
})

// 上传小程序
program
.command('upload')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}

// 获取当前工作目录的父路径
const projectDir = path.join(__dirname, '../')

const project = new ci.Project({
appid: options.appid,
type: 'miniProgram',
projectPath: `${projectDir}/dist/build/mp-weixin`,
privateKeyPath: `${projectDir}/deploy/private.${options.appid}.key`,
// ignores: ['node_modules/**/*'],
})
ci.upload({
project,
version,
desc: `CI机器人于${dayjs().format('YYYY-MM-DD HH:mm:ss')}上传`,
setting: {
es6: true,
es7: true,
minify: true,
// autoPrefixWXSS: true,
minifyWXML: true,
minifyJS: true,
},
}).then((res) => {
console.log(res)
console.log('上传成功')
process.exit(0)
}).catch((error) => {
if (error.errCode === -1) {
console.log('上传成功')
process.exit(0)
}
console.log(error)
console.log('上传失败')
process.exit(-1)
})
})

program.parse(process.argv)


如果是原生微信小程序,且使用了npm依赖,只需要在upload之前执行一下构建命令即可:


// 在有需要的时候构建npm
const warning = await ci.packNpm(project, {
ignores: [],
reporter: (infos) => { console.log(infos) }
})
console.warn(warning)
// ci.upload()

在本地调试时,用以下命令即可模拟构建的完整操作了:


node deploy/deploy.js toggle --appid=小程序A
pnpm run build:mp-weixin:小程序A
node deploy/deploy.js upload --appid=小程序A

注意这里的build命令,对应package.json中脚本的写法为:


"build:mp-weixin:小程序A": "uni build -p mp-weixin --mode 小程序A",

传入mode参数时,执行时会读取.env.小程序A中定义的环境变量,从而实现一些定制化的操作。


可以将这组命令写成sh脚本,每个小程序一个,都放在deploy目录下,在Flow工作流中调用。


上传命令执行成功后,微信小程序后台版本管理中就可以看到这个版本了:


2.png


后续的提审、发布操作目前仍需人工操作。


配置云效Flow


本地调试正常后,最后来配置云效Flow。


前面的代码触发不变,后面的部署步骤可以直接删除。


构建脚本为:


npm i -g pnpm
pnpm config set registry https://registry.npmmirror.com
pnpm i
node deploy/deploy.js toggle --appid=小程序A
pnpm run build:mp-weixin:小程序A
node deploy/deploy.js upload --appid=小程序A

如果有多个小程序,可以配置多个并行步骤:


3.png


待优化


依赖应该只需要安装一次,即将安装依赖步骤与构建步骤分开。


(可选)配置通知机器人


4.png


在构建步骤窗口的底部,可以添加通知插件。


这里使用的是钉钉机器人,教程参考这里


大致步骤为:



  1. 在钉钉中拉上同事或者小号,凑满3人,创建一个外部群。

  2. 在钉钉群的群设置中,添加机器人,获得api接口地址与签名。

  3. 在云效Flow的钉钉机器人插件中填入接口地址与签名。


此后每次发版,只需提版合并到master分支,等待片刻收到钉钉机器人的提示,就可以准备提审了。


5.png


参考来源:



作者:mirari
来源:juejin.cn/post/7392558409743548466
收起阅读 »

为了解决小程序tabbar闪烁的问题,我将小程序重构成了 SPA

web
(日落西山,每次看到此景,我总是会想到明朝(明朝那些事儿第六部的标题,日落西山))  前言 几个月前,因工作需求,我开发了一个小程序,当时遇到了一个需求,是关于tabbar权限的问题。小程序的用户分两种,普通用户和vip用户,普通用户tabbar有两个,vip...
继续阅读 »

1.jpg


(日落西山,每次看到此景,我总是会想到明朝(明朝那些事儿第六部的标题,日落西山))


 前言


几个月前,因工作需求,我开发了一个小程序,当时遇到了一个需求,是关于tabbar权限的问题。小程序的用户分两种,普通用户和vip用户,普通用户tabbar有两个,vip用户小程序下面的tabbar有五个。  

因为涉及自定义tabbar的问题,所以官方自带的tabbar肯定就不能用了,我们需要自定义tabbar。官方也提供了自定义tabbar的功能。


官网自定义tabbar


官网地址:基础能力 / 自定义 tabBar (qq.com)


{
"tabBar": {
"custom": true,
"list": []
}
}

就是需要在 app.json 中的 tabBar 项指定 custom 字段,需要注意的是 list 字段也需要存在。


然后,在代码根目录下添加入口文件:


custom-tab-bar/index.js
custom-tab-bar/index.json
custom-tab-bar/index.wxml
custom-tab-bar/index.wxss

具体代码,大家可以参考官网案例。


需要注意的是每个tabbar页面 / 组件都需要在onshow / show 函数中执行以下函数,否则就会出现tabbar按钮切换两次,才会变成选中色的问题。


      if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().setData({
selected: 0 // 第n个tabbar页面就填 n-1
})
}

接下来就是我的思路


2.png


我在 custom-tab-bar/index.js 中定义了一个函数,这个函数去判断当前登录人是否为vip,如果是就替换掉tabbar 的数据。


那么之前每个页面的代码就要写成这样


      if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().change_tabbar_list()
this.getTabBar().setData({
selected: 0 // 第n个tabbar页面就填 n-1
})
}

ok,我们来看一下效果。注意看视频下方的tabbar,每个页面,第一次点击的时候,有明显的闪烁bug。(大家也可以参考一下市面上的小程序,小部分的小程序有这个闪烁问题,大部分的小程序没有这个闪烁的问题(如:携程小程序))



bug产生原因


那么我们就要去思考了,为什么人家的小程序没有这个bug呢?


想这个问题前,要先去想这个bug是怎么产生的,我猜测是每个tabbar页面都有个初始化的过程,第一次渲染页面的时候要去重新渲染tabbar,每个页面的tabbar都是从0开始渲染,然后会缓存到每个页面上,所以第二次点击就没有这个bug了。


解决tabbar闪烁问题


为了解决这个问题,我想到了SPA ,也就是只留一个页面,其他的tabbar页面都弄成组件。


效果展示



已经解决,tabbar闪烁的问题。


代码思路,通过wx:if 控制组件的显示隐藏。


3.png


4.png


源码地址:gitlab.com/wechat-mini…

https克隆地址:gitlab.com/wechat-mini…


写在最后


1、我也是在网上见过别人的一些评论,说如果将小程序重构成这种单页面,会有卡顿问题,我目前没有发现这个问题,可能是我做的小程序功能比较少。


2、至于生命周期,将页面切换成组件后,页面的那些生命周期也肯定都不能使用了,只能用组件的生命周期,我之前开发使用组件的生命周期实现业务逻辑也没什么问题。 触底加载这些也只能换成组件去实现了。


3、小程序最上面的标题,也可以使用以下代码来实现。就是在每个组件初始化的时候要去执行下列代码。


            wx.setNavigationBarTitle({
title: '',
});

作者:楚留香Ex
来源:juejin.cn/post/7317281367111827475
收起阅读 »

一句话让cursor爬取到大量美女图片!!!

AI编程大大的提高了人们的开发效率。 cursor cursor是一个集成了GPT4、Claude 3.5等先进LLM的类VScode的编译器,可以理解为在vscode中集成了AI辅助编程助手。 cursor内置了很多LLMs,包括最先进的GPT4s、Clau...
继续阅读 »

AI编程大大的提高了人们的开发效率。


cursor


cursor是一个集成了GPT4、Claude 3.5等先进LLM的类VScode的编译器,可以理解为在vscode中集成了AI辅助编程助手。


cursor内置了很多LLMs,包括最先进的GPT4s、Claude3.5s和openai最新发布的推理模型o1-preview和o1-mini,在右上角的设置中即可打开相应的模型进行辅助编程。
最常用的快捷键就下面四个:



  • Tab:自动填充

  • Ctrl+K:编辑代码

  • Ctrl+L:(compose模式对话)回答用户关于代码和整个项目的问题,适合复杂的多轮对话,需要处理文件的场景,能长期保存对话历史

  • Ctrl+i:(chat模式对话)简单的问答,系统快速的文本,生成实时对话需求


下面将带大家使用cursor去爬取美女图片。这个项目并不复杂,我们使用chat模式来进行对话。我们使用ctrl+i调出对话框,输入要求即可


image.png
接着cursor就会给你回复,直接按照回复运行即可。可以看到图片已经爬取出来了
image.png


当然,这种方式也能很快的帮我们学习。当我们成品做出来之后,我们可以使用vscode的marscode进行问答,让他告诉我们代码的作用是什么,小编称之为面向实战学习,下面带着大家迅速学习一个简单demo感受一下学习的效率。


marscode


marscode是vscode集成的一款AI插件,能够让AI给用户进行页面内部的问答,这样就不用打开外置的AI应用进行询问,给程序员沉浸式的体验。
这是一个爬取微博热榜的简单小demo


const request = require('request-promise');
const cheerio = require('cheerio');
const createCsvWriter = require('csv-writer').createObjectCsvWriter;

// 目标URL
const url = 'https://tophub.today/n/KqndgxeLl9';

// 发送HTTP请求并解析HTML
request(url)
.then(html => {
const $ = cheerio.load(html);
const hotList = [];

// 选择表格中的每一行
$('table tr').each((index, element) => {
if (index === 0) return; // 跳过表头行

const rank = $(element).find('td:nth-child(1)').text().trim();
const title = $(element).find('td:nth-child(2) a').text().trim();
const heat = $(element).find('td:nth-child(3)').text().trim();
const link = $(element).find('td:nth-child(2) a').attr('href');

hotList.push({ rank, title, heat, link });
});

// 创建CSV写入器
const csvWriter = createCsvWriter({
path: 'hot_list.csv',
header: [
{ id: 'rank', title: 'Rank' },
{ id: 'title', title: 'Title' },
{ id: 'heat', title: 'Heat' },
{ id: 'link', title: 'Link' }
]
});

// 写入CSV文件
csvWriter.writeRecords(hotList)
.then(() => console.log('CSV file has been saved.'))
.catch(err => console.error('Error writing CSV file:', err));
})
.catch(err => console.error('Error fetching the webpage:', err));

我们是没有学过爬虫的哦,里面的内容都看不太懂,我们让AI来带着我们学习。


const request = require('request-promise');
const cheerio = require('cheerio');
const createCsvWriter = require('csv-writer').createObjectCsvWriter;

首先发起疑惑,这个require是什么啊?我们打开marscode选中要询问的代码就可以直接询问


image.png


image.png
可以看到marscode已经给了我们回复,原来这是es6之前的导入模块的方式。这三个模块的作用分别是发送请求的模块,解析HTML的模块和将爬取到的文本写入csv文件的模块。
我们可以接着询问。我们发现了一个 $ 符号看不懂,我们再问问AI这是什么


image.png
更多的就不再演示了,总之通过这种方式能极大的提高学习的效率


作者:ZXT
来源:juejin.cn/post/7442731383615389733
收起阅读 »

优雅解决uniapp微信小程序右上角胶囊菜单覆盖问题

web
前言 大家好,今天聊一下在做uniapp多端适配项目,需要用到自定义导航时,如何解决状态栏塌陷及导航栏安全区域多端适配问题,下文只针对H5、APP、微信小程序三端进行适配,通过封装一个通用高阶组件包裹自定义导航栏内容,主要是通过设置padding来使内容始终保...
继续阅读 »

前言


大家好,今天聊一下在做uniapp多端适配项目,需要用到自定义导航时,如何解决状态栏塌陷及导航栏安全区域多端适配问题,下文只针对H5、APP、微信小程序三端进行适配,通过封装一个通用高阶组件包裹自定义导航栏内容,主要是通过设置padding来使内容始终保持在安全区域,达到低耦合,可复用性强的效果。


一、创建NavbarWrapper.vue组件


大致结构如下:


<template>
<view class="navbar-wrapper" :style="{
paddingTop: statusBarHeight,
paddingRight: rightSafeArea
}"
>

<slot/>
</view>

</template>

<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
}
}
</script>


<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/

box-sizing: border-box;
}
</style>


目的


主要是动态计算statusBarHeight和rightSafeArea的值。


解决方案


APP端只需一行css代码即可


.navbar-wrapper {
padding-top: var(--status-bar-height);
}

下面是关于--status-bar-height变量的介绍:


image.png


从上图可以知道--status-bar-height只在APP端是手机实际状态栏高度,在微信小程序是固定的25px,并不是手机实际状态栏高度;


微信小程序时,除了状态栏高度还需要获取右上角的胶囊菜单所占宽度,保持导航栏在安全区域。


以下使用uni.getWindowInfo()uni.getMenuButtonBoundingClientRect()来分别获取状态栏高度和胶囊相关信息,api介绍如下图所示:


image.png


image.png


主要逻辑代码


在NavbarWrapper组件创建时,做相关计算


created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif

// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}

用法


<NavbarWrapper>
<view class="header">header</view>
</NavbarWrapper>

二、多端效果展示


微信小程序


b15a0866000c13e58259645f2459440.jpg


APP端


45ee33b12dcf082e5ac76dc12fc41de.jpg


H5端


22b1984f8b21a4cb79f30286a1e4161.jpg


三、源码


NavbarWrapper.vue


<template>
<view class="navbar-wrapper" :style="{
paddingTop: statusBarHeight,
paddingRight: rightSafeArea
}"
>

<slot/>
</view>

</template>

<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
},
created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif

// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}
}
</script>


<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/

box-sizing: border-box;
background-color: deeppink;
}
</style>


往期文章回顾


一文学会请求中断、请求重发、请求排队、请求并发


一文学会vue3如何自定义hook钩子函数和封装组件


table表格自适应浏览器窗口变化解决方案


作者:vilan_微澜
来源:juejin.cn/post/7309361597556719679
收起阅读 »

uniapp微信小程序授权后得到“微信用户”

web
背景 近日在开发微信小程序的时候,发现数据库多了很多用户名称是"微信用户"的账号信息。接口的响应信息如下。 (nickName=微信用户, avatarUrl=https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4...
继续阅读 »

背景


近日在开发微信小程序的时候,发现数据库多了很多用户名称是"微信用户"的账号信息。接口的响应信息如下。


(nickName=微信用户, avatarUrl=https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132, gender=0, country=, province=, city=, language=), code=0e1abNFa1dBwRG0lnoJa18qT0i2abNFk)

经过排查,发现官方是对微信授权的接口做出了调整。小程序用户头像昵称获取规则调整公告


WX20240206-112518@2x.png

根据上面标红的字体说明,官方的意图就是只提供openid和unionid, 不暴露用户头像昵称数据。

基于此才会在新版的接口中返回"微信用户"的信息。



  • 针对这个问题,官方提供的解决方案如下。


WX20240206-112912@2x.png
以上解决方案,表达的意思是新版用户授权的接口中, 官方只会给你提供unionid和openid.

至于用户的昵称和头像,开发者可以提供功能,以用户的意志去完成修改和更新。

tips: 建议授权接口生成用户名和昵称,采用系统默认的方式。


微信授权流程


152f3cb28a734e768381f986cec1dd26.png


uniapp代码实现


uni.login接口文档


WX20240207-221556@2x.png


后端代码


WX20240207-221641@2x.png


异常分析


//如果你的接口出现如下信信息,该如何处理呢?
# {errMsg: “getUserProfile:fail api scope is not
declared in the privacy agreement“, errno: 112}

出现问题的原因: api 范围未在隐私协议中声明,建议大家更具公告,更新对应的隐私协议。

【设置-服务内容声明-用户隐私保护指引】,更新隐私协议,在第一条:开发者处理的信息中,点击【增加信息类型】,选择需要授权的信息,头像昵称我已经勾选了,所以列表中不显示了,根据需求选择和填写其他内容,最后确定并生成协议。等待隐私协议审核通过。


68b3f3f0c4ee419d9ca5dec8aa5b0a4c.png
建议按需添加,以防审核不通过。


为了分辨用户,开发者将在获取你的明示同意后,收集你的微信昵称、头像。
为了显示距离,开发者将在获取你的明示同意后,收集你的位置信息。
开发者收集你的地址,用于获取位置信息。
开发者收集你的发票信息,用于维护消费功能。
为了用户互动,开发者将在获取你的明示同意后,收集你的微信运动步数。
为了通过语音与其他用户交流互动,开发者将在获取你的明示同意后,访问你的麦克风。
开发者收集你选中的照片或视频信息,用于提前上传减少上传时间。
为了上传图片或者视频,开发者将在获取你的明示同意后,访问你的摄像头。
为了登录或者注册,开发者将在获取你的明示同意后,收集你的手机号。
开发者使用你的通讯录(仅写入)权限,用于方便用户联系信息。
开发者收集你的设备信息,用于保障你正常使用网络服务。
开发者收集你的身-份-证号码,用于实名认证后才能继续使用的相关网络服务。
开发者收集你的订单信息,用于方便获取订单信息。
开发者收集你的发布内容,用于用户互动。
开发者收集你的所关注账号,用于用户互动。
开发者收集你的操作日志,用于运营维护。
为了保存图片或者上传图片,开发者将在获取你的明示同意后,使用你的相册(仅写入)权限。
为了用户互动,开发者将在获取你的明示同意后,收集你的车牌号。
开发者访问你的蓝牙,用于设备连接。
开发者使用你的日历(仅写入)权限,用于用户日历日程提醒。
开发者收集你的邮箱,用于在必要时和用户联系。
开发者收集你选中的文件,用于提前上传减少上传时间。


当你选择所需的接口后,需要您填写使用说明。 可以参考上面的内容进行填写。

给大家看一下我申请的接口。折腾半天终于把授权登录给整好了。


WX20240208-100953@2x.png


做完上述隐私设置后,需要你重新发布自己的小程序。 并且设置成采集用户隐私。

审核通过后就可以啦。如下图, 请一定注意!!!


WX20240208-101216@2x.png


参考文档


头像昵称填写-微信官方文档

uniapp头像昵称填写

getUserProfile:fail api scope is not declared in the privacy agreement


作者:代码次位面
来源:juejin.cn/post/7332113324651610150
收起阅读 »

被antdesign的恐怖的scripts吓到了

web
近日无意中打开antdesign的package.json,然后就看到一砣恐怖的scripts "scripts": { "api-collection": "antd-tools run api-collection", "authors"...
继续阅读 »

近日无意中打开antdesignpackage.json,然后就看到一砣恐怖的scripts


 "scripts": {
"api-collection": "antd-tools run api-collection",
"authors": "tsx scripts/generate-authors.ts",
"build": "npm run compile && cross-env NODE_OPTIONS='--max-old-space-size=4096' npm run dist",
"changelog": "npm run lint:changelog && tsx scripts/print-changelog.ts",
"check-commit": "tsx scripts/check-commit.ts",
"clean": "antd-tools run clean && rimraf es lib coverage locale dist report.html artifacts.zip oss-artifacts.zip",
"clean:lockfiles": "rimraf package-lock.json yarn.lock",
"precompile": "npm run prestart",
"compile": "npm run clean && antd-tools run compile",
"predeploy": "antd-tools run clean && npm run site && cp CNAME _site && npm run test:site",
"deploy": "gh-pages -d _site -b gh-pages -f",
"deploy:china-mirror": "git checkout gh-pages && git pull origin gh-pages && git push git@gitee.com:ant-design/ant-design.git gh-pages -f",
"predist": "npm run version && npm run token:statistic && npm run token:meta",
"dist": "antd-tools run dist",
"format": "biome format --write .",
"install-react-16": "npm i --no-save --legacy-peer-deps react@16 react-dom@16 @testing-library/react@12",
"install-react-17": "npm i --no-save --legacy-peer-deps react@17 react-dom@17 @testing-library/react@12",
"bun-install-react-16": "bun remove react react-dom @testing-library/react && bun add --no-save react@16 react-dom@16 @testing-library/react@12",
"bun-install-react-17": "bun remove react react-dom @testing-library/react && bun add --no-save react@17 react-dom@17 @testing-library/react@12",
"prelint": "dumi setup",
"lint": "npm run version && npm run tsc && npm run lint:script && npm run lint:biome && npm run lint:md && npm run lint:style && npm run lint:changelog",
"lint:changelog": "tsx scripts/generate-component-changelog.ts",
"lint:deps": "antd-tools run deps-lint",
"lint:md": "remark . -f -q",
"lint:script": "eslint . --cache",
"lint:biome": "biome lint",
"lint:style": "tsx scripts/check-cssinjs.tsx",
"prepare": "is-ci || husky && dumi setup",
"prepublishOnly": "tsx ./scripts/pre-publish.ts",
"prettier": "prettier -c --write . --cache",
"prettier-import-sort": "npm run prettier -- --plugin=@ianvs/prettier-plugin-sort-imports",
"biome": "biome check --write",
"pub": "echo 'Please use `npm publish` instead.'",
"postpublish": "tsx scripts/post-publish.ts",
"presite": "npm run prestart",
"site": "npm i --no-save --legacy-peer-deps react@18.3.0-canary-c3048aab4-20240326 react-dom@18.3.0-canary-c3048aab4-20240326 && dumi build && cp .surgeignore _site",
"size-limit": "size-limit",
"sort:api-table": "antd-tools run sort-api-table",
"sort:package-json": "npx sort-package-json",
"prestart": "npm run version && npm run token:statistic && npm run token:meta && npm run lint:changelog",
"start": "tsx ./scripts/set-node-options.ts cross-env PORT=8001 dumi dev",
"pretest": "npm run version",
"test": "jest --config .jest.js --no-cache",
"test:all": "sh -e ./scripts/test-all.sh",
"test:dekko": "node ./tests/dekko/index.test.js",
"test:image": "jest --config .jest.image.js --no-cache -i -u --forceExit",
"test:node": "npm run version && jest --config .jest.node.js --no-cache",
"test:package-diff": "antd-tools run package-diff",
"test:site": "jest --config .jest.site.js",
"test:site-update": "npm run site && npm run test:site -- -u",
"test:update": "jest --config .jest.js --no-cache -u",
"test:visual-regression": "tsx scripts/visual-regression/build.ts",
"token:meta": "tsx scripts/generate-token-meta.ts",
"token:statistic": "tsx scripts/collect-token-statistic.ts",
"tsc": "tsc --noEmit",
"tsc:old": "tsc --noEmit -p tsconfig-old-react.json",
"version": "tsx scripts/generate-version.ts"
},

面对如此复杂的scripts,有没有被吓到。


相信我,在团队开发中,不要说团队成员,就算是开发者本身一段时间后也不一定能一眼就看到每一条脚本的作用了。


怎么呢?最好是加点注释


但是众所周知,package.json是不支持注释了。


这里给大家推荐一个VSCODE插件json_comments_extension,可以用来给任意JSON文件添加注释.


效果如下:


preview.gif


preview2.gif


开源推荐


以下是我的一大波开源项目推荐:



作者:渔夫正在掘金
来源:juejin.cn/post/7442573821444227109
收起阅读 »

Canvas 轻量图文编辑器的一些实践

web
1. 前言 简而言之,我们需要一个能够在 H5 端和桌面端使用的轻量级图文编辑器。具体的使用流程是在桌面端制作编辑模板(上传一张底图,指定编辑区域的大小),然后在 H5 端允许用户在模板的基础之上添加文本,图片...
继续阅读 »

1. 前言


简而言之,我们需要一个能够在 H5 端和桌面端使用的轻量级图文编辑器。具体的使用流程是在桌面端制作编辑模板(上传一张底图,指定编辑区域的大小),然后在 H5 端允许用户在模板的基础之上添加文本,图片,支持对文本图片的多种编辑等。


2. 核心问题和分析


主要诉求是需要自研一套商品图文定制编辑器,在 PC 上支持模板定制,在 H5 上支持图文编辑。模板定制主要是确定底图的编辑区域,图文编辑器则是在底图上添加图片和文字。


2.1 社区现状


在图文编辑器上,目前社区中各式各样的编辑器非常丰富:



  • 专业的修图软件:PS、Pixelmator 等

  • 手机 App:美图秀秀、Picsart 等,功能也非常完善且强大,不比 PS 差

  • 轻量级编辑器:视频封面编辑、公众号图文排版、商品定制等面向业务场景


PhotoShopPixelmator
imageimage
美图秀秀Picsart
image

在 Web 上的编辑器种类也非常丰富,毕竟 canvas 能做的事情非常多。比如 miniPaint基本复刻了 ps,基于 farbic.js的 Pintura.和 tui.image-editor,基于 Konva的 polotno等等。这些编辑器也基本是个 app 级别的应用了。


miniPainttui.image-editor
imageimage
polotnopintura
imageimage

总结一下:


1、不论是软件型应用还是 Web 编辑器,一种是做得非常通用的编辑器,功能丰富且完善,另一种就是面向业务流程定制的轻量型编辑器,只有一些特定交互操作和属性配置能力,可操作内容很少;


2、上述的这些 Web 编辑器大部分都是在 PC 上被使用,在手机上的编辑器也基本是在 Native 容器里开发。所以可以参考的 H5 编辑器基本没有。


3、PC 和 H5 编辑器一个明显的不同是,在 PC 上编辑操作,是选中元素后,元素的属性在工具栏或侧边栏进行编辑,画布上的操作只有缩放和旋转。在 H5 上的编辑器,元素选中后的操作会主要放在四个锚点控制器上,添加自定义操作,其余一些次相关的操作放在底部操作栏。所以在设计和实现这个编辑器的过程中,我们参考了很多类似手机 App 的交互。



2.2 分析


操作流程


1、在 PC 设置模板,上传底图,并设置定制区域,定制区域可调整


2、在 H5 上基于模板进行图文编辑,可添加图片和文字,文字可修改字体 颜色 大小。同时可控制元素的缩放旋转、层级移动、删除和复制。


3、最后基于模板和元素,导出定制图。


我们这次的场景显然只需要一个轻量型的图文编辑器,技术上如何选型?



  • 如果基于完整的第三方编辑类库(如 polotno),太重了,可能有现成的功能,但改造成本更高;

  • 基于图形处理库(封装了 Cavnas 或者 SVG 的 API)直接开发会更容易管理,但可能需要从头实现一些功能。


我们准备基于 Konva 来实现这次的编辑器需求。也想借这次机会,沉淀一些通用的编辑能力,如元素锚点操作的控制、拖转限制的计算逻辑、蒙层遮罩的绘制逻辑、坐标转换的逻辑等等。


Why Konva?


Konva 和 Fabric 都是比较热门的开源 2D 图形库,封装了 Canvas 的一系列 API。


FarbicKonva
比较老牌,比 Konva上线时间更早一些。使用 TypeScript 编写,TS 原生支持
常用转换(放大、缩小、拖拽)都已经封装好,内置了丰富的笔刷,基本的对齐、标线都有,特别适合用 Canvas 写交互性的界面渲染分层比较清晰,Stage -> Layer -> Gr0up -> Shape
代码集成度比较高,内置了可交互富文本(纯 Canvas 实现)代码简洁、干净,易于阅读
代码使用 ES5开发,不能很好的支持 TypeScript,开发效率可能会有影响文档清晰,容易上手
由于库本身集成了很多功能点,代码包的大小偏大(压缩后308 kB)核心代码精简,代码包较小(压缩后155 kB
细节功能还需要完善,比如标线系统实现相对简单部分功能实现基于 DOM(富文本)
.后起之秀,周边生态还比较薄弱

2.3 编辑器设计思路


编辑器按照图层叠加的顺序自上而下是 底图 -> 蒙层 -> 元素 -> 控制器




3. 详细功能设计


3.1 数据


3.1.1 数据格式定制


目前支持两种编辑区域,圆形和矩形。编辑区域的数据类型为


    export type EditAreaType = RectArea | CircleArea;

export interface RectArea {
/** 类型 */
type: 'Rect';
/** 属性 */
attrs: { x: number, y: number, width: number, height: number };
}

export interface CircleArea {
/** 类型 */
type: 'Circle';
/** 属性 */
attrs: { x: number, y: number, radius: number };
}


其中,x,y 均是相对于底图所在容器的坐标。


3.1.2 坐标转换


由于服务端考虑到数据流量成本,在PC和H5的底图会做分辨率的限制,例如在PC上传的底图是 1200x1200,在 H5 上提供的底图是 400x400(但最后合成的时候会用原图)。因此定义编辑器数据过程中,元素和蒙层的坐标不能相对于底图,需要相当于容器大小计算。同时能够互相转换。


如下图所示,用户可以再 PC 端定制编辑区域的大小和位置,然后将模板的数据导出到 h5。这里的问题就是 PC 端制作的模板数据(底图,编辑区域相对于容器的位置,宽高)如何做转换的问题。





但本质上也是三个坐标系之间的转换问题。第一个坐标系是 PC 端底图的容器,第二个坐标系是图片底图本身,第三个坐标系是 h5 端底图的容器。底图填充容器的逻辑为:保持宽高比,填满容器的宽或高,另一个方向上居中处理。


用户在定制编辑区域的时候其实是以底图为坐标系的,但为了方便处理,我们将编辑区域的数据保存为以容器为坐标系。这样在 h5 端加载编辑区域的时候需要一套转换逻辑。实际的转换过程如下图所示,我们只需要计算出将底图填充到两个容器的的变换的 ”差“,或者说两个变换结果之间的变换即可,然后就是将求出的变换应用到编辑区域或具体的元素上。



实际的代码可能更好理解一些


/**
* 映射编辑区域,将编辑区域从旧容器映射到新容器
* @param area 原始编辑区域数据
* @param ratio 底图比例
* @param containerSize 原始容器尺寸
* @param newContainerSize 新容器尺寸
* @returns 映射后的编辑区域 EditAreaType
*/

export const projectEditArea = (
area: EditAreaType,
ratio: number,
containerSize: Vector2,
newContainerSize: Vector2,
) => {
const { type, attrs } = area;
// 编辑区域相对于旧的容器的 transform
const transform = {
x: attrs.x,
y: attrs.y,
rotation: 0,
scaleX: 1,
scaleY: 1,
};
// 编辑区域相对于旧容器的 transform 转换为相对于 新容器的 transform
const newTransform = projectTransform(transform, ratio, containerSize, newContainerSize);
// 编辑区域是矩形
if (type === 'Rect') {
const { width, height } = attrs as { width: number, height: number };
return {
type,
attrs: {
x: newTransform.x,
y: newTransform.y,
width: width * newTransform.scaleX,
height: height * newTransform.scaleY,
},
};
}
// 编辑区域是圆形
if (type === 'Circle') {
attrs as { x: number, y: number, radius: number };
const { radius } = attrs as { radius: number };
return {
type,
attrs: {
x: newTransform.x,
y: newTransform.y,
radius: radius * newTransform.scaleX,
},
};
}

return area;
};


/**
* 映射元素的形变
* @param transform 原始容器下的形变
* @param ratio 底图比例
* @param containerSize 原始容器尺寸
* @param newContainerSize 新容器尺寸
* @returns { TransformAttrs } 新容器下的形变
*/

export const projectTransform = (
transform: TransformAttrs,
ratio: number,
containerSize: Vector2,
newContainerSize: Vector2,
) => {
const {
x, y, rotation, scaleX, scaleY,
} = transform;

const [oldContainerWidth, oldContainerHeight] = containerSize;
const oldContainerRatio = oldContainerWidth / oldContainerHeight;

// 底图相对于旧容器的位置,按比例缩放后居中
let origin: null | { x: number, y: number } = null;
// 底图在旧容器按比例缩放后的 size
let imgSize: null | { width: number, height: number } = null;
// 图片宽高比 < 旧容器宽高比 旧容器更宽,横向有空白
if (ratio < oldContainerRatio) {
imgSize = {
height: oldContainerHeight,
width: oldContainerHeight * ratio,
};
origin = {
x: (oldContainerWidth - oldContainerHeight * ratio) / 2,
y: 0,
};
} else {
// 图片宽高比 > 容器宽高比 旧容器更高,上下有空白
imgSize = {
width: oldContainerWidth,
height: oldContainerWidth / ratio,
};
origin = {
x: 0,
y: (oldContainerHeight - oldContainerWidth / ratio) / 2,
};
}

const [newContainerWidth, newContainerHeight] = newContainerSize;
const newContainerRatio = newContainerWidth / newContainerHeight;

let newOrigin: null | { x: number, y: number } = null;
let newImgSize: null | { width: number, height: number } = null;
// 底图比例小于新容器的宽高比,新容器更宽,缩放后横向有空白
if (ratio < newContainerRatio) {
newImgSize = {
width: newContainerHeight * ratio,
height: newContainerHeight,
};

newOrigin = {
y: 0,
x: (newContainerWidth - newContainerHeight * ratio) / 2,
};
} else {
// 底图比例大于新容器的宽高比,新容器更高,缩放后上下有空白
newImgSize = {
width: newContainerWidth,
height: newContainerWidth / ratio,
};
newOrigin = {
x: 0,
y: (newContainerHeight - newContainerWidth / ratio) / 2,
};
}

// 保持宽高比
// 计算旧容器内底图到新容器内底图的缩放比例
const scale = Math.min(newImgSize.width / imgSize.width, newImgSize.height / imgSize.height);
// 累积两次缩放,实现到新容器保持宽高比缩放效果
const newScaleX = scaleX * scale;
const newScaleY = scaleY * scale;

// 编辑区域相对于旧容器底图的位置转换为相对于新容器底图的位置
const newX = (x - origin.x) * scale + newOrigin.x;
const newY = (y - origin.y) * scale + newOrigin.y;

return {
x: newX, y: newY, rotation, scaleX: newScaleX, scaleY: newScaleY,
};
};


3.2 元素操作


3.2.1 缩放 && 旋转元素


缩放和旋转元素的功能如下图所示,要求按住元素右下角的 icon 的时候,可以绕元素中心旋转元素或缩放元素。


image


这里最好是有一些 2维 平面上仿射变换的知识,理解起来会更轻松,可以参考  闫令琪关于计算机图形学入门的课程中的介绍,这里就直接介绍解法了。


上面动图中所展示的一共有三种仿射变换,缩放,旋转,还有平移。缩放和旋转都很明显,但是为什么有平移 ?因为 Konva 默认的旋转是围绕 ”左上角“ 的,而实际位移的又是 “右下角”,所以如果想要一个围绕中心旋转的效果,就需要移动 “左上角” 把 “右下角”的位移抵消掉。举个例子,放大的时候,右下角向编辑器右下方移动,左上角向编辑器左上方移动,他们的位移方向总是相反且距离相等。


这里我们只需要在拖拽过程中计算出此刻 ”右下角“ 和元素中心构成的向量 和 上个时刻 ”右下角“ 和元素中心构成的向量,之间的比值,角度,和位移。然后再将这三中变换应用到元素上即可,如下图所示,具体的代码这里不再讲解。


image


3.2.2 拖拽区域限制


元素的拖拽范围限制是一个常见的问题,h5 上期望的效果为元素不可拖出蒙版所在区域,也就是 h5 上底图实际所在的区域。


image


实现拖拽范围限制功能的一个思路是在拖拽的回调函数中判断当前的元素坐标是否越界,如果越界则修改元素的坐标为不越界的合法坐标。拖动是一个连续的过程,元素在被拖出限定区域之前会有一个临界的时刻,在此之前元素完全在限定区域内,在此之后,元素开始被拖出限定区域。所以,将元素限制在编辑区域内就是要在元素将要离开的最后一刻,修改元素下一刻的位置把它拉回来。


image


Konva 也直接提供了一个元素的 dragBoundFunc(pos: Konva.vector2d) => Konva.vector2d函数,其入参是下一个拖动过程中下一个时刻元素 “左上角” 本来的坐标,返回值是下一个时刻元素 “左上角” 最终的坐标。该函数会在拖动过程中不断执行,只需在此函数中填入限制逻辑即可。


需要注意的是,这里面有两个棘手的问题



  1. 由于元素自身支持旋转,元素的 “左上角” 并不一定一直处于左上角的位置

  2. 只有元素 “左上角” 下一时刻的坐标,无法计算下一个时刻元素是否越界 


image
image


这两个问题的解决过程可谓是一波三折。这里需要注意两个点:一是,拖拽是一个连续的过程,拖拽的过程中只有位移,没有其他变换。二是,我们知道的不仅仅是 dragBoundFunc 传入的下一个时刻的 “左上角” 的坐标,我们还可以计算出当前时刻的元素的四个顶点的坐标。


所以,我们可以计算出下一个时刻 “左上角” 坐标和此刻 “左上角” 坐标的偏移量,从而计算出下一个时刻元素的四个顶点的坐标。然后检测,下个时刻的元素是否在限制区域内即可。如下图所示。


image


好的,现在我们找到了那个将要越界的时刻,我们该如何计算出一个合法的坐标作为下个时刻元素 “左上角” 的坐标 ?你不能直接把边界值,minX minY maxX maxY 这些值返回,因为“左上角”不一定在左上角。


那如果我找到越界的那个点,然后把对应的点和边界对齐,然后再通过三角函数计算呢 ?就像下图中画的这样。


image


当然可以 😂 ,但是这也太复杂,太不优雅了,你还要获取元素当前旋转的角度,还要判断到底是哪个点越界 ...


有没有更快更简单的方法,当然也有,这又不是在造火箭。如果精确解很困难,找到一个准确度还不错的近似解就是有价值的。 越界的上一刻还是合法的,我们可以“时间回溯”,用上一个时刻 左上角合法的坐标来返回就行了。


    if(crossLeft || crossRight || crossTop || crossBottom){
pos = lastPos;
}else {
lastPos = pos;
}


到此为止就已经能实现开头动图中的效果了。


3.3 控制器


Konva 虽然提供了 Transfomer,可以用于实现拖拽缩放、旋转元素。但在 H5 上对操作功能做了定制,如调整层级,删除元素等等,仍然需要自己定义和实现一个元素控制器。


如下图所示,控制器主要包含虚线边框和四角的可点击 icon。要求点击 icon 分别实现弹窗调整层级,复制,删除,按住拖拽缩放大小的能力。


image
image


3.3.1 单例模式


控制器最开始是根据元素实例化的,即每添加一个元素都有一个控制器实例。元素被激活(点击)时会显示该元素的控制器 同时隐藏其他所有控制器,元素失焦之后会隐藏该元素的控制器。拖拽元素,缩放元素的过程中需要同步元素的大小到其自身的控制器。


image


如上图所示,每个 Shape 类都有一个控制器属性,绘制控制器的时候,会传入包含icon 的回调函数的配置。Shape 的拖拽,缩放过程中需要调用控制器提供的公有方法 updateByShape 来同步位置和缩放比例。


这种做法较为简单,易于理解,但会带来以下两个问题



  1. 画布上的 Shape 增多,难以区分不同元素的 Shape,对于调整元素之间的层级关系(zIndex)造成困难。

  2. 画布上的控制器的 Shape 增多,可能会造成性能变差。

  3. 控制器和 Shape 类混杂在一起,概念不清晰,代码上不好维护。


将控制器和 Shape 类拆分后,两个类的职责更单一。 Shape 类面相外部导出,可以做更多定制。控制器类只面相交互,实现编辑功能。


后面梳理后发现并不需要多个控制器实例子,同一时刻处于激活状态的元素只有一个,不会同时编辑(拖拽,缩放)两个元素。使用一个控制器实例,能够减少画布上的 Shape,便于控制元素的层级。后续的代码逐步演变成下图所示。


image


控制器通过 id 关联当前激活的 ShapeElementShapeElement 类是对 Konva.Shape 类的简单包装,在其上添加了一些生命周期方法和导出方法等。 而控制器类中则实现了 缩放,拖拽等编辑能力,这种模式下,用户缩放和拖拽的其实是外层的控制器,然后控制器再将这些编辑操作通过 syncBorderRect 方法同步到当前激活的 ShapeElement


而为了实现点击不同的 ShapeElement 时切换控制器的效果,我们提供了 updateByShapeElement 方法,在 shape 的 onClick 回调中,只需要调用该方法即可。


在这种模式下,原来控制器位于蒙层之上的效果也容易实现了。如下图所示,画布上从下到上分别是:底图,文本/图片元素,蒙层,控制器。


imageimage


3.3.2 判断当前选中元素


实现当前控制器的另一个难点在于,元素处于蒙版的遮盖的时候,点击元素如何唤起控制器。如上图所示,当元素完全被蒙版遮盖的时候,Konva 提供的元素的 onClick 事件是不会触发的。


image


这样只能回到在 canvas 上实现点击事件的思路,监听点击事件,根据点击事件的坐标和元素的位置关系来判断选中的元素。


具体的逻辑为:



  1. 获取点击事件中的坐标

  2. 通过 d3-polygon 提供的方法判断点击事件的坐标在不在元素的包围盒中。

  3. 排序找到命中的最上层的元素

  4. 激活对应元素,直接执行元素的 onClick 回调函数。


3.4 蒙层


3.4.1 蒙层绘制 


蒙层的功能主要有两个:1. PC 端方便用户定制编辑区域的大小。2 H5 端起到编辑区域外起到半透明遮盖的效果,编辑区域内可视的效果。


image
image


蒙层的元素主要有三个部分,一是背景的半透明的黑色区域,二是拖拽编辑区域大小时外层的框所在的矩形,三是实现透明效果的矩形。可拖拽,缩放的透明矩形框的实现是 Konva Rect + Konva Transformer,借助了 transfomer 提供的能力实现编辑区域的缩放。而透明效果的矩形主要是借助 Konva Shape 的 sceneFunc 定制形状的能力,通过 canvas 中的 clip 函数实现透明的矩形或者圆形的效果。


3.4.2 导出特定区域


导出图片时限定只导出编辑区域内的功能主要依赖 Konva 提供的 clipFunc 函数,该函数会传入 canvas2d 绘制上下文,只需要绘制出特定的区域,konva 会自动帮我们只导出区域内的内容。


4. 总结


本文介绍了基于 Konva 实现 H5端的轻量级图文编辑器的一种方法,在实现这个轻量级的图文编辑器的过程中我们总结了设计思路和常见的问题处理方案。当然,编辑器的实现是一个需要不断打磨交互和细节的过程,比如像拖拽过程中的辅助线提示、支持文本和图片更丰富的属性等等。篇幅所限,这里不再展开介绍了。希望本文对有志于动手实现编辑器的前端同学能有所助益。


作者:ES2049
来源:juejin.cn/post/7312243176835334196
收起阅读 »

three.js实现3D汽车展厅效果展示

web
今天简单实现一个three.js的小Demo,加强自己对three知识的掌握与学习,只有在项目中才能灵活将所学知识运用起来,话不多说直接开始。 相关源码和模型的下载链接地址 点击链接进行跳转 项目搭建 本案例还是借助框架书写three项目,借用vite构建工具...
继续阅读 »

今天简单实现一个three.js的小Demo,加强自己对three知识的掌握与学习,只有在项目中才能灵活将所学知识运用起来,话不多说直接开始。


相关源码和模型的下载链接地址 点击链接进行跳转


项目搭建


本案例还是借助框架书写three项目,借用vite构建工具搭建vue项目,搭建完成之后,用编辑器打开该项目,在终端执行 npm i 安装一下依赖,安装完成之后终端在安装 npm i three 即可。


因为我搭建的是vue3项目,为了便于代码的可读性,所以我将three.js代码单独抽离放在一个组件当中,在App根组件中进入引入该组件。具体如下:


<template>
<!-- 3D汽车展厅 -->
<CarShowroom></CarShowroom>
</template>

<script setup>
import CarShowroom from './components/CarShowroom.vue';
</script>

<style lang="less">
*{
margin: 0;
padding: 0;
}
</style>

初始化three.js代码


three.js开启必须用到的基础代码如下:


导入three库


import * as THREE from 'three'

初始化场景


const scene = new THREE.Scene()

初始化相机


// 创建相机
const camera = new THREE.PerspectiveCamera(40,window.innerWidth / window.innerHeight,0.1,1000)
camera.position.set(4.25,1.4,-4.5)

初始化渲染器


// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth,window.innerHeight)
document.body.appendChild(renderer.domElement)

监听屏幕大小的改变,修改渲染器的宽高和相机的比例


window.addEventListener("resize",()=>{ 
renderer.setSize(window.innerWidth,window.innerHeight)
camera.aspect = window.innerWidth/window.innerHeight
camera.updateProjectionMatrix()
})

导入轨道控制器


// 添加轨道控制器
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
// 添加控制器
const controls = new OrbitControls(camera,renderer.domElement)
controls.enableDamping = true // 设置控制阻尼

设置渲染函数


// 设置渲染函数
const render = (time) =>{
controls.update()
renderer.render(scene,camera)
requestAnimationFrame(render)
}
render()

ok,写完基础代码之后,接下来开始具体的Demo实操。


加载汽车模型


通过使用模型加载器GLTFLoader,然后使用DRACOLoader加载Draco压缩过的模型可以显著减小模型文件体积,从而加快加载速度和提高用户体验。代码如下:


import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader";

// 加载汽车模型
const loader = new GLTFLoader()
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath("/draco/")
loader.setDRACOLoader(dracoLoader)
loader.load("/public/model/Lamborghini.glb",(gltf)=>{
scene.add(gltf.scene)
})

模型加载完成,画面如下:

图片.png


因为没有灯光,所以我们需要给一个灯光让模型展现出来,这里设置一下环境光源:


// 设置环境光源
const ambientLight = new THREE.AmbientLight('#fff',0.5)
scene.add(ambientLight)

3a886e62e2d949ee88132dcb5a9ff1cb.gif


设置展厅效果


这里通过three库中自带的一些模型来实现展厅的效果,如下:


设置地板样式


// 设置地板样式
const floorGeometry = new THREE.PlaneGeometry(20,20)
const floormaterial = new THREE.MeshPhysicalMaterial({
side: THREE.DoubleSide,
color: 0x808080,
metalness: 0, // 设置金属度
roughness: 0.1, // 设置粗糙度
wireframe: false // 关闭网格线
})
const mesh = new THREE.Mesh(floorGeometry,floormaterial)
mesh.rotation.x = Math.PI / 2
scene.add(mesh)

7c03c298b206422eab96aa86adbfa69f.gif


底部样式设置完,设置一个圆柱体将整个地板进行包裹:


// 设置圆柱体模拟展厅
const cylinder = new THREE.CylinderGeometry(12,12,20,32)
const cylindermaterial = new THREE.MeshPhysicalMaterial({
color: 0x6c6c6c,
side: THREE.DoubleSide
})
const cylinderMesh = new THREE.Mesh(cylinder,cylindermaterial)
scene.add(cylinderMesh)

图片.png


接下来在圆柱体中设置一个聚光灯,让聚光灯偏垂直照射汽车模型,如下:


// 设置聚光灯(让汽车更具有立体金属感)
const spotLight = new THREE.SpotLight('#fff',2)
spotLight.angle = Math.PI / 8 // 散射角度,和水平线的夹角
spotLight.penumbra = 0.2 // 横向,聚光锥的半影衰减百分比
spotLight.decay = 2 // 纵向,沿着光照距离的衰减量
spotLight.distance = 30
spotLight.shadow.radius = 10
spotLight.shadow.mapSize.set(4096,4096)
spotLight.position.set(-5,10,1)
spotLight.target.position.set(0,0,0) // 光照射的方向
spotLight.castShadow = true
scene.add(spotLight)

图片.png


为了不让展厅穿帮,这里将控制器的缩放以及旋转角度进行一个限制,让其只能在展厅中灵活查看而不能跑到展厅外面去:


controls.maxDistance = 10 // 最大缩放距离
controls.minDistance = 1 // 最小缩放距离
controls.minPolarAngle = 0 // 最小旋转角度
controls.maxPolarAngle = 85 / 360 * 2 * Math.PI // 最大旋转角度

设置GUI面板动态控制车身操作


这里我使用three.js库中自带的gui库,来动态的改变车身相关操作,因为我仅仅是控制车身材质和玻璃材质相关的数据操作,这里就线设置一下其相关的材质:


// 车身材质
let bodyMaterial = new THREE.MeshPhysicalMaterial({
color: 'red',
metalness: 1,
roughness: 0.5,
clearcoat: 1.0,
clearcoatRoughness: 0.03
})
// 玻璃材质
let glassMaterial = new THREE.MeshPhysicalMaterial({
color: '#793e3e',
metalness: 0.25,
roughness: 0,
transmission: 1.0 // 透光性
})

在glb模型中,通过traverse函数遍历场景中的所有对象(包括Mesh、Gr0up、Camera、Light等),并对这些对象进行相应操作或处理(这里的门操作后面会讲解到):


loader.load("/public/model/Lamborghini.glb",(gltf)=>{
const carModel = gltf.scene
carModel.rotation.y = Math.PI
carModel.traverse((obj)=>{
if(obj.name === 'Object_103' || obj.name === 'Object_64' || obj.name === 'Object_77'){
// 车身
obj.material = bodyMaterial
}else if(obj.name === 'Object_90'){
// 玻璃
obj.material = glassMaterial
}else if(obj.name === 'Empty001_16' || obj.name === 'Empty002_20'){
// 门
// doors.push(obj)
}else{
return true
}
})
scene.add(gltf.scene)
})

最后得到的结果如下:


图片.png


接下来通过控制面板来动态的监视汽车模型的车身和玻璃材质:


// 设置gui模板控制
// 修改默认面板名称
gui.domElement.parentNode.querySelector('.title').textContent = '3D汽车动态操作'

const bodyChange = gui.addFolder("车身材质设置")
bodyChange.close() // 默认关闭状态
bodyChange.addColor(bodyMaterial,'color').name('车身颜色').onChange(value=>{
bodyMaterial.color.set(value)
})
bodyChange.add(bodyMaterial,'metalness',0,1).name('金属度').onChange(value=>{
bodyMaterial.metalness = value
})
bodyChange.add(bodyMaterial,'roughness',0,1).name('粗糙度').onChange(value=>{
bodyMaterial.roughness = value
})
bodyChange.add(bodyMaterial,'clearcoat',0,1).name('清漆强度').onChange(value=>{
bodyMaterial.clearcoat = value
})
bodyChange.add(bodyMaterial,'clearcoatRoughness',0,1).name('清漆层粗糙度').onChange(value=>{
bodyMaterial.clearcoatRoughness = value
})
const glassChange = gui.addFolder("玻璃设置")
glassChange.close() // 默认关闭状态
glassChange.addColor(glassMaterial,'color').name('玻璃颜色').onChange(value=>{
glassMaterial.color.set(value)
})
glassChange.add(glassMaterial,'metalness',0,1).name('金属度').onChange(value=>{
glassMaterial.metalness = value
})
glassChange.add(glassMaterial,'roughness',0,1).name('粗糙度').onChange(value=>{
glassMaterial.roughness = value
})
glassChange.add(glassMaterial,'transmission',0,1).name('透光性').onChange(value=>{
glassMaterial.transmission = value
})

f2a6a032280c4325bb3416c60485421f.gif


车门操作与车身视角展示


这里依然用GUI控制面板来动态实现开关车门以及车内车外视角动态切换的操作,如下:


var obj = { carRightOpen,carLeftOpen,carRightClose,carLeftClose,carIn,carOut }
// 设置车身动态操作
const doChange = gui.addFolder("车身动态操作设置")
doChange.close() // 默认关闭状态
doChange.add(obj, "carLeftOpen").name('打开左车门')
doChange.add(obj, "carRightOpen").name('打开右车门')
doChange.add(obj, "carLeftClose").name('关闭左车门')
doChange.add(obj, "carRightClose").name('关闭右车门')
doChange.add(obj, "carIn").name('车内视角')
doChange.add(obj, "carOut").name('车外视角')

每个操作都对应一个函数,如下:


// 打开左车门
const carLeftOpen = () => {
setAnimationDoor({ x: 0 }, { x: Math.PI / 3 }, doors[1])
}
// 打开右车门
const carRightOpen = () => {
setAnimationDoor({ x: 0 }, { x: Math.PI / 3 }, doors[0])
}
// 关闭左车门
const carLeftClose = () => {
setAnimationDoor({ x: Math.PI / 3 }, { x: 0 }, doors[1])
}
// 关闭右车门
const carRightClose = () => {
setAnimationDoor({ x: Math.PI / 3 }, { x: 0 }, doors[0])
}

// 车内视角
const carIn = () => {
setAnimationCamera({ cx: 4.25, cy: 1.4, cz: -4.5, ox: 0, oy: 0.5, oz: 0 }, { cx: -0.27, cy: 0.83, cz: 0.60, ox: 0, oy: 0.5, oz: -3 });
}
// 车外视角
const carOut = () => {
setAnimationCamera({ cx: -0.27, cy: 0.83, cz: 0.6, ox: 0, oy: 0.5, oz: -3 }, { cx: 4.25, cy: 1.4, cz: -4.5, ox: 0, oy: 0.5, oz: 0 });
}

这里使用了补间动画tween.js,其github网址为 github.com/tweenjs/twe… ,终端安装其第三方插件之后,直接引入即可,如下(这里不再过多介绍该库的使用,想学习的可以自行寻找其官方文档学习):


图片.png


接下来借助tween.js库实现补间动画,如下:


// 设置补间动画
const setAnimationDoor = (start, end, mesh) => {
const tween = new TWEEN.Tween(start).to(end, 1000).easing(TWEEN.Easing.Quadratic.Out)
tween.onUpdate((that) => {
mesh.rotation.x = that.x
})
tween.start()
}
const setAnimationCamera = (start, end) => {
const tween = new TWEEN.Tween(start).to(end, 3000).easing(TWEEN.Easing.Quadratic.Out)
tween.onUpdate((that) => {
// camera.postition 和 controls.target 一起使用
camera.position.set(that.cx, that.cy, that.cz)
controls.target.set(that.ox, that.oy, that.oz)
})
tween.start()
}

最终实现的效果如下:


bd52745477bb428b88ba49e0220e634f.gif


点击查看车内视角的话,画面如下:


图片.png


设置手动点击打开关闭车门


通过设置监听点击事件函数来动态实现打开关闭车门:


// 设置点击打开车门的动画效果
window.addEventListener('click', onPointClick);
function onPointClick(event) {
let pointer = {}
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = - (event.clientY / window.innerHeight) * 2 + 1;
var vector = new THREE.Vector2(pointer.x, pointer.y)
var raycaster = new THREE.Raycaster()
raycaster.setFromCamera(vector, camera)
let intersects = raycaster.intersectObjects(scene.children);
intersects.forEach((item) => {
if (item.object.name === 'Object_64' || item.object.name === 'Object_77') {
if (!carStatus || carStatus === 'close') {
carLeftOpen()
carRightOpen()
} else {
carLeftClose()
carRightClose()
}
}
})
}

然后给每个车门设置汽车状态,如下:


图片.png


a7c25f153c814007aede998980cfef64.gif


设置图片背景


为了让展厅更具有视觉效果,接下来设置一个画面背景让其更具有画面感,如下:


// 创建聚光灯函数
const createSpotlight = (color) => {
const newObj = new THREE.SpotLight(color, 2);
newObj.castShadow = true;
newObj.angle = Math.PI / 6;;
newObj.penumbra = 0.2;
newObj.decay = 2;
newObj.distance = 50;
return newObj;
}

// 设置图片背景
const spotLight1 = createSpotlight('#ffffff');
const texture = new THREE.TextureLoader().load('src/assets/imgs/奥特曼.jpg')
spotLight1.position.set(0, 3, 0);
spotLight1.target.position.set(-10, 3, 10)
spotLight1.map = texture
const lightHelper = new THREE.SpotLightHelper(spotLight1);
scene.add(spotLight1);

最终呈现的效果如下:


图片.png


demo做完,本案例的完整代码获取 地址


作者:亦世凡华
来源:juejin.cn/post/7307146429004333094
收起阅读 »

实现敏感字段脱敏注解@Sensitive

前言 在B2C项目中,就以电商项目举例,都有前台与后台。并且这类项目的后台往往都会开放给公司内大部分人,甚至有些是将电商项目作为Saas服务提供给外部厂商的,这样后台中记录的用户数据就成为一个风险点,随着越来越多的人可以接触到后台系统,我们必须对用户的数据进行...
继续阅读 »

前言


在B2C项目中,就以电商项目举例,都有前台与后台。并且这类项目的后台往往都会开放给公司内大部分人,甚至有些是将电商项目作为Saas服务提供给外部厂商的,这样后台中记录的用户数据就成为一个风险点,随着越来越多的人可以接触到后台系统,我们必须对用户的数据进行加密不仅限于在数据库层面加密存储,前端展示的时候也必须要对例如:手机号,地址,身-份-证号等等隐私数据进行脱敏处理。


实现方式


1.最容易想到的就是利用硬编码的形式,哪些接口中涉及到了隐私数据,我们就去接口中对隐私数据进行脱敏。(ps一开始我确实是这么做的)


2.但是我发现太多太多接口都需要使用用户隐私数据了,我人工一个一个手工改也太不优雅了!我就想到我们能不能在SpringMVC将数据写入response的时候就将他拦截住,然后我实现一个注解,其实这个注解也就是一个标识。我们通过反射对于被这个注解标注的字段进行脱敏处理,然后再写回对象中。


这样不就可以只对响应类中加一个注解,然后所有使用用户敏感数据的接口都直接脱敏了吗,而且我们也可以很方便的改变我们的脱敏策略!!!


代码


hutools工具依赖


最适合中国宝宝体质的中国工具包,虽然网上很多人喷他,但是我个人觉得还是挺好用的,可能是我段位还不够。


<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.11</version>
</dependency>

@Sensitive注解


/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.annotaion
* @className: Sensitive
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:36
* @version: 1.0
*/

@Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {
SensitiveDataType type() default SensitiveDataType.PASSWORD;

}

脱敏策略枚举类


/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.enums
* @className: SensitiveDataType
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:40
* @version: 1.0
*/

public enum SensitiveDataType {
//脱敏数据类型
NAME("name"),
ID_CARD("idCard"),
PHONE("phone"),
EMAIL("email"),
BANK_CARD("bankCard"),
ADDRESS("address"),
PASSWORD("password"),
;

SensitiveDataType(String type) {
this.type = type;
}
@Getter
private String type;
}

响应拦截器


这里就是最核心的代码了,利用了SpringMVC提供的钩子接口,ResponseBodyAdvice接口,其中提供了一个beforeBodyWrite方法,这个方法就可以在数据写入响应前可以对数据进行处理。


/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.enums
* @className: SensitiveDataType
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:40
* @version: 1.0
*/

@ControllerAdvice
public class SensitiveDataAdvice implements ResponseBodyAdvice<Object> {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 拦截所有响应
return true;
}

@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, org.springframework.http.MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
org.springframework.http.server.ServerHttpRequest request,
org.springframework.http.server.ServerHttpResponse response
) {
// 如果返回类型是result
if (body instanceof Result<?>){
// 处理对象,进行脱敏操作
handleSensitiveFields((Result<?>) body);
}

return body;
}

private void handleSensitiveFields(Result<?> res) {
Object data = res.getData();
//获取data的下的全部字段
if (data == null) {
return;
}
Field[] fields = data.getClass().getDeclaredFields();
for (Field field : fields) {
// 判断是否有 @SensitiveData 注解
if (field.isAnnotationPresent(Sensitive.class)) {
Sensitive annotation = field.getAnnotation(Sensitive.class);
SensitiveDataType sensitiveDataType = annotation.type();
field.setAccessible(true);
try {
Object value = field.get(data);
if (value instanceof String) {
// 执行脱敏操作
String maskedValue = DesensitizationUtils.maskData((String) value, sensitiveDataType.getType());
field.set(data, maskedValue);
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
}
}

脱敏工具类


这个工具类依赖于hutools提供的DesensitizedUtil


public class DesensitizationUtils {

public static String maskData(String data, String type) {
if (data == null) {
return null;
}
//使用switch匹配SensitiveDataType枚举中的类型,并且使用hutool脱敏工具类进行脱敏
return switch (type) {
case "name" -> DesensitizedUtil.chineseName(data);
case "idCard" -> DesensitizedUtil.idCardNum(data, 2, data.length() - 2);
case "phone" -> DesensitizedUtil.mobilePhone(data);
case "email" -> DesensitizedUtil.email(data);
case "bankCard"-> DesensitizedUtil.bankCard(data);
case "address" -> DesensitizedUtil.address(data, data.length() - 6);
default -> data;
};

}
}

效果演示



作者:refreshGogo
来源:juejin.cn/post/7419148660796293139
收起阅读 »

Spring Boot + liteflow 居然这么好用!实战

在我们的日常开发中,经常会遇到一些需要串行或并行处理的复杂业务流程。那我们该如何利用Spring Boot结合liteflow规则引擎来简化我们的业务流程先看一个实战案例!!在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。这时候,我们可以利用l...
继续阅读 »

在我们的日常开发中,经常会遇到一些需要串行并行处理的复杂业务流程。

那我们该如何利用Spring Boot结合liteflow规则引擎来简化我们的业务流程

先看一个实战案例!!

在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。

这时候,我们可以利用liteflow进行规则编排,处理这些并行任务。

1. 引入依赖

首先,在pom.xml文件中添加liteflow的依赖:

xml

<dependency>
<groupId>com.yomahubgroupId>
<artifactId>liteflow-spring-boot-starterartifactId>
<version>2.6.5version>
dependency>

2. 增加配置

application.yml文件中添加liteflow的配置:

yaml

spring:
application:
name: liteflow-demo

liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间

3. 编写规则文件

resources目录下创建flow.xml文件,编写规则文件内容:

xml

<flow>
<parallel>
<node id="pointNode"/>
<node id="messageNode"/>
parallel>
flow>

4. 编写业务逻辑组件

按照规则文件中的定义,编写相应的业务逻辑组件:

java

@LiteflowComponent("pointNode")
public class PointNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发放积分逻辑
System.out.println("Issuing points for the order");
}
}

@LiteflowComponent("messageNode")
public class MessageNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发送消息逻辑
System.out.println("Sending message for the order");
}
}

5. 流程触发

当订单完成后,我们需要触发liteflow的流程来执行积分发放和消息发送的逻辑。

我们可以在订单完成的服务方法中添加如下代码:

java

@Service
public class OrderService {

@Autowired
private FlowExecutor flowExecutor;

public void completeOrder(Order order) {
// 完成订单的其他逻辑
System.out.println("Order completed: " + order.getId());

// 执行liteflow流程
flowExecutor.execute2Resp("flow", order);
}
}

在上述代码中,我们使用FlowExecutor来执行liteflow流程,并将订单对象传递给流程。

这将触发flow.xml中定义的规则,执行并行的积分发放和消息发送逻辑。

性能统计

liteflow在启动时完成规则解析和组件注册,保证高性能的同时,还能统计各业务环节的耗时,帮助我们进行性能优化。

以下是一个性能统计示例:

java

@LiteflowComponent("performanceNode")
public class PerformanceNode extends NodeComponent {
@Override
public void process() throws Exception {
long start = System.currentTimeMillis();
// 业务逻辑
long end = System.currentTimeMillis();
System.out.println("PerformanceNode execution time: " + (end - start) + "ms");
}
}

liteflow组件概览

liteflow中,主要有以下几种组件:

  • 普通组件:集成NodeComponent,用于执行具体的业务逻辑;
  • 选择组件:通过业务逻辑选择不同的执行路径;
  • 条件组件:基于条件返回结果,决定下一步的业务流程。
java

// 普通组件示例
@LiteflowComponent("commonNode")
public class CommonNode extends NodeComponent {
@Override
public void process() throws Exception {
// 业务逻辑
System.out.println("Executing commonNode logic");
}
}

// 选择组件示例
@LiteflowComponent("choiceNode")
public class ChoiceNode extends NodeSwitchComponent {
@Override
public String processSwitch() throws Exception {
// 根据条件返回不同的节点ID
return "nextNodeId";
}
}

// 条件组件示例
@LiteflowComponent("conditionNode")
public class ConditionNode extends NodeIfComponent {
@Override
public boolean processIf() throws Exception {
// 判断条件
return true;
}
}

EL规则文件

liteflow中,规则文件可以采用XML格式编写,下面是一个简单的规则文件示例。

xml


id="commonNode"/>
id="conditionNode">
id="nextNode"/>
id="otherNode"/>

id="choiceNode">
id="case1" to="node1"/>
id="case2" to="node2"/>


如何使用EL规则文件

  1. 创建规则文件:将上述规则文件保存为flow.xml,放在项目的resources目录下;
  2. 配置liteflow:在Spring Boot项目中添加liteflow的配置,指定规则文件的位置;
yaml

liteflow:
rule-source: "classpath:flow.xml"
node-retry: 3
thread-executor:
core-pool-size: 10
max-pool-size: 20
keep-alive-time: 60
  1. 编写业务逻辑组件:按照规则文件中的定义,编写相应的组件逻辑。

数据上下文

liteflow中,数据上下文非常重要,它用于参数传递业务逻辑的执行。

我们可以通过以下代码示例了解数据上下文的用法。

java

@LiteflowComponent("contextNode")
public class ContextNode extends NodeComponent {
@Override
public void process() throws Exception {
// 获取数据上下文
LiteflowContext context = this.getContextBean();
// 设置数据
context.setData("key", "value");
// 获取数据
String value = context.getData("key");
System.out.println("Context data: " + value);
}
}

配置详解

在使用liteflow时,我们需要对一些参数进行配置,如规则文件地址、节点重试、线程池参数等。

以下是一个配置示例。

yaml

liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间

总的来说,liteflow在简化业务流程管理方面起到了非常重要的作用,可以提升开发效率和业务流程管理能力。



作者:程序员蜗牛
来源:juejin.cn/post/7388033492570095670
收起阅读 »

只CURD的Java后端要如何提升自己?

你是否工作3~5年后,发现日常只做了CURD的简单代码。 你是否每次面试就会头疼,自己写的代码,除了日常CURD简历上毫无亮点可写 抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情我们是可以做的更好的。 于是有了这篇文章。 小北将带大家从六...
继续阅读 »

你是否工作3~5年后,发现日常只做了CURD的简单代码。

你是否每次面试就会头疼,自己写的代码,除了日常CURD简历上毫无亮点可写



抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情我们是可以做的更好的。


于是有了这篇文章。


小北将带大家从六个方面深入探讨如何在Java后台管理开发中不断进步,帮助你在职业道路上稳步前行


一、写优雅的代码


优雅代码的重要性


优雅的代码不仅易于阅读和维护,还能减少错误,提高开发效率。对于后台管理系统,代码的整洁与规范尤为重要,因为它们通常涉及复杂的业务逻辑和大量的数据处理。


我们看一个简单的案例,我们直观的感受下,需求如下:



用户可以通过银行网页转账给另一个账号,支持跨币种转账。

同时因为监管和对账需求,需要记录本次转账活动



拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:



1、从MySql数据库中找到转出和转入的账户,选择用MyBatis的mapper实现DAO

2、从Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是http开放接口)

3、计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限

4、实现转入和转出操作,扣除手续费,保存数据库

5、发送Kafka审计消息,以便审计和对账用



而一个常规的代码实现如下:



public class TransferServiceImpl implements TransferService {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;

@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);

// 2. 业务参数校验
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}

// 3. 获取外部数据,并且包含一定的业务逻辑
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

// 4. 业务参数校验
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}
if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}

// 5. 计算新值,并且更新字段
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);

// 6. 更新到数据库
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);

// 7. 发送审计消息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);

return Result.success(true);
}
}


我们可以看到,一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。

在这个案例里虽然是写在了同一个方法里,在真实代码中经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。


那么优雅的代码应该是什么样的?


public class TransferServiceImplNew implements TransferService {

// 可以看出来,经过重构后的代码有以下几个特征:
// 业务逻辑清晰,数据存储和业务逻辑完全分隔。
// Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,
// 但是却包含了所有核心业务逻辑,可以单独完整测试。
// 原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,
// 所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。
// 我们可以根据新的结构重新画一张图:

private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;

@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 参数校验
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));

// 读数据
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));

// 获取汇率
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(
sourceAccount.getCurrency(), targetMoney.getCurrency()
);

// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

// 保存数据
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);

// 发送审计消息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);

return Result.success(true);
}
}

虽然功能都一样,但是在面试的时候写了上面的代码能得到了面试官的赞扬,而如果写成了上面的样子,估计不会有这种效果。



最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的,[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]



二、提升代码质量


如果说优雅的代码是我们程序员的里子,那代码质量就是我们的面子。


想象一下,如果你写的代码,提测后测试出来各种bug,上线后也出现bug,就算你代码写的再优雅也没用了。


如何提升代码质量


想提升代码质量,最理想的是靠 code review,但是实际上这玩意在大多数公司根本就推行不下去。



为什么呢?因为大家都很忙,忙着改上一个迭代的bug,忙着写下一个迭代的需求,忙着做各种性能优化,忙着做各种日报、周报、月报等等...



所以靠人不如靠己,我们在日常工作中要善于利用工具,来帮我们发现问题,解决问题。


例如以下实践方法:



  1. 自动化测试:编写单元测试、集成测试,确保代码功能的正确性和稳定性。使用JUnit、Mockito等工具进行测试驱动开发(TDD)。

  2. 持续集成(CI):通过Jenkins、GitHub Actions等工具,自动化构建和测试流程,及时发现并解决问题。

  3. 静态代码分析:使用工具如SonarQube,对代码进行静态分析,检测代码中的潜在问题和代码风格违规。

  4. 合理利用大模型,对我们的代码进行分析,发现bug。


三、关注业务



看到这里有的人不禁要问,我一个后端开发,写好代码就行了,还需要关注业务吗?



如果你有这样的想法,那就大错特错了。


中国的企业,90%的开发都是面向业务开发,纯做研究的公司少之又少。所以你想要在互联网行业走的更高,那就一定不能脱离业务。


而且只有深刻理解业务了,才能对系统有一个整体的规划意识,才能设计出一个好的系统。


实践方法



  1. 多与业务团队沟通:定期与产品经理、业务分析师沟通,了解业务流程和需求变化。

  2. 参与需求讨论:积极参与需求评审和讨论,提出技术上的可行性建议和优化方案。

  3. 业务文档学习:阅读业务相关的文档和资料,全面了解系统的功能和使用场景。

  4. 业务架构梳理:梳理公司整体系统业务领域架构图,先从整体对公司业务有一个清晰的概念


实践建议



  1. 业务流程图:绘制业务流程图,帮助理解各个业务环节之间的关系和数据流动。

  2. 用户故事:通过用户故事的方式,站在用户角度思考功能设计,提高系统的用户体验。

  3. 持续学习:随着业务的发展,持续学习和更新业务知识,确保技术方案与业务需求保持一致。



四、培养架构思维


5年以上的程序员,就一定要培养自己的架构思维了,也就是要把自己的技术视角由自己的点扩展到线,再扩展到面。

从而对公司整体系统技术架构有一个整体的认知。


例如到一个公司之后,你一定要具有自我绘制如下技术架构图的能力。



架构思维的重要性


良好的架构设计是系统稳定、高效运行的基础。

培养架构思维,能够帮助你在项目初期做出合理的技术选型和系统设计,提升系统的可扩展性和维护性。


实践方法



  1. 学习架构设计原则:如单一职责原则(SRP)、开闭原则(OCP)、依赖倒置原则(DIP)等,指导架构设计。

  2. 分层架构:采用DDD领域分层架构,如适配层、应用层和领域层、防腐层,明确各层的职责,降低耦合度。

  3. 模块化设计:将系统拆分为独立的领域模块或微服务,提升系统的可维护性和可扩展性。



最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的,[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]



五、关注源码


源码学习的价值


其实学习源码最市侩的价值那就是面试会问了,比如说 HashMap 的一些经典问题:



1、加载因子为什么是 0.75?

2、为什么链表改为红黑树的阈值是 8?

3、HashMap的底层数据结构是什么?

4、解决hash冲突的办法有哪些?

5、HashMap数组的长度为什么是 2 的幂次方?

6、HashMap 的扩容方式?



这些问题只有通过源码才能得出比较准确的回答。


但是我个人认为阅读源码对我们最大的价值其实是我们可以学习借鉴源码设计中的优秀思想。



想象一下,我们每天做着CURD的996工作,根本没有机会接触优秀的项目设计思想。而阅读源码是我们最容易接触到优秀项目设计核心思想的机会。



其次阅读源码也可以在系统出现棘手的问题时候,可以快速定位解决。大大提升自己在职场中的核心竞争力。



有个同学说过一句话,给我的印象特别深刻,就是“有啥解决不了的?只要你肯阅读源码。”



六、项目管理能力


实现一个软件系统的过程,不仅只有编码,还涉及到项目安排,团队协调等一系列非技术因素,如果想从一名程序员走向管理岗,成为 team leader 或者开发经理,软件工程方面的知识就必须得跟得上。


要想写出一个好而美的程序,需要经过三个阶段。


第一阶段:有扎实的基本功,简单点说,就是要做到语法熟练、框架熟练,成为一名能够完成开发任务的“码农”。


第二阶段:从“码农”到“工程师”,在局部上,不仅要能够实现功能,还能关注功能之外的维度,比如健壮性、低耦合、可扩展等指标。


第三阶段:从“工程师”到“架构师”,不仅在局部上追求一个模块的好坏,而且还要从整个系统层面去掌控,合理安排资源的优先级,保证整个系统不会出现腐败等等。


所以要想成为一名优秀的架构师,项目管理能力是必不可少的。


比如项目范围管理、质量管理、资源/成本管理、风险管理等一系列管理能力。有兴趣的同学可以学习PMP,提升一下自己的项目管理能力。


传统预测项目管理



敏捷开发项目管理



说在最后


学习的过程,就好像登山一样,大概有 80% 的人在这个过程中会掉队。要想成为一名优秀的架构师,除了自身的努力,也需要一点点运气。


那么请相信我,只要目标明确,努力加上坚持,再加上一点点好运气,你就能登顶!


免费看 500 套技术教程的网站,希望对你有帮助


程序员快看-教程,程序员编程资料站 | CXYKK.COM


*最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的, *[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]


求一键三连:点赞、分享、收藏


我的技术网站:cxykk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,全部免费,欢迎收藏和转发。


作者:江小北
来源:juejin.cn/post/7418061055228215322
收起阅读 »

买了个mini主机当服务器

虽然有苹果的电脑,但是在安装一些软件的时候,总想着能不能有一个小型的服务器,免得各种设置导致 Mac 出现异常。整体上看了一些小型主机,也看过苹果的 Mac mini,但是发现它太贵了,大概要 3000 多,特别是如果要更高配置的话,价格会更高,甚至更贵。所以...
继续阅读 »

虽然有苹果的电脑,但是在安装一些软件的时候,总想着能不能有一个小型的服务器,免得各种设置导致 Mac 出现异常。整体上看了一些小型主机,也看过苹果的 Mac mini,但是发现它太贵了,大概要 3000 多,特别是如果要更高配置的话,价格会更高,甚至更贵。所以,我就考虑一些别的小型主机。也看了一些像 NUC 这些服务器,但是觉得还是太贵了。于是我自己去淘宝搜索,找到了这一款 N100 版的主机。


成本的话,由于有折扣,所以大概是 410 左右,然后自己加了个看上去不错的内存条花了 300 左右。硬盘的话我自己之前就有,所以总成本大概是 700 左右。大小的话,大概是一台手机横着和竖着的正方形大小,还带 Wi-Fi,虽然不太稳定。


iowejofwjeofjwoeifjwoe


一、系统的安装


系统我看是支持windows,还有现在Ubuntu,但是我这种选择的是centos stream 9, 10的话我也找过,但是发现很多软件还有不兼容。所以最终还是centos stream 9。


1、下载Ventoy软件


去Ventoy官网下载Ventoy软件(Download . Ventoy)如下图界面


QQ_1727625608185


2、制作启动盘


选择合适的版本以及平台下载好之后,进行解压,解压出来之后进入文件夹,如下图左边所示,双击打开Ventoy2Disk.exe,会出现下图右边的界面,选择好自己需要制作启动盘的U盘,然后点击安装等待安装成功即可顺利制作成功启动U盘。


3、centos安装


直接取官网,下载完放到u盘即可。


QQ_1727625711792


它的BIOS是按F7启动,直接加载即可。


image-20241007222938414


之后就是正常的centos安装流程了。


二、连接wifi


因为是用作服务器的,所以并没有给它配置个专门的显示器,只要换个网络,就连不上新的wifi了,这里可以用网线连接路由器进行下面的操作即可。


在 CentOS 系统中,通过命令行连接 Wi-Fi 通常需要使用 nmcli(NetworkManager 命令行工具)来管理网络连接。nmcli 是 NetworkManager 的一个命令行接口,可以用于创建、修改、激活和停用网络连接。以下是如何使用 nmcli 命令行工具连接 Wi-Fi 的详细步骤。


步骤 1: 检查网络接口


首先,确认你的 Wi-Fi 网络接口是否被检测到,并且 NetworkManager 是否正在运行。


nmcli device status

输出示例:


DEVICE         TYPE      STATE         CONNECTION
wlp3s0 wifi disconnected --
enp0s25 ethernet connected Wired connection 1
lo loopback unmanaged --

在这个示例中,wlp3s0 是 Wi-Fi 接口,它当前处于未连接状态。


步骤 2: 启用 Wi-Fi 网卡


如果你的 Wi-Fi 网卡是禁用状态,可以通过以下命令启用:


nmcli radio wifi on

验证 Wi-Fi 是否已启用:


nmcli radio

步骤 3: 扫描可用的 Wi-Fi 网络


使用 nmcli 扫描附近的 Wi-Fi 网络:


nmcli device wifi list

你将看到可用的 Wi-Fi 网络列表,每个网络都会显示 SSID(网络名称)、安全类型等信息。


步骤 4: 连接到 Wi-Fi 网络


使用 nmcli 命令连接到指定的 Wi-Fi 网络。例如,如果你的 Wi-Fi 网络名称(SSID)是 MyWiFiNetwork,并且密码是 password123,你可以使用以下命令连接:


nmcli device wifi connect 'xxxxxx' password 'xxxxx'

你应该会看到类似于以下输出,表明连接成功:


Device 'wlp3s0' successfully activated with 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.

步骤 5: 验证连接状态


验证网络连接状态:


nmcli connection show

查看当前连接的详细信息:


nmcli device show wlp3s0

三、VNC远程连接


桌面还是偶尔需要用一下的,虽然用的不多。


root@master:~# dnf install  -y  tigervnc-server
root@master:~# vncserver
bash: vncserver: command not found...
Install package 'tigervnc-server' to provide command 'vncserver'? [N/y] y


* Waiting in queue...
* Loading list of packages....
The following packages have to be installed:
dbus-x11-1:1.12.20-8.el9.x86_64 X11-requiring add-ons for D-BUS
tigervnc-license-1.14.0-3.el9.noarch License of TigerVNC suite
tigervnc-selinux-1.14.0-3.el9.noarch SELinux module for TigerVNC
tigervnc-server-1.14.0-3.el9.x86_64 A TigerVNC server
tigervnc-server-minimal-1.14.0-3.el9.x86_64 A minimal installation of TigerVNC server
Proceed with changes? [N/y] y


* Waiting in queue...
* Waiting for authentication...
* Waiting in queue...
* Downloading packages...
* Requesting data...
* Testing changes...
* Installing packages...

WARNING: vncserver has been replaced by a systemd unit and is now considered deprecated and removed in upstream.
Please read /usr/share/doc/tigervnc/HOWTO.md for more information.

You will require a password to access your desktops.

getpassword error: Inappropriate ioctl for device
Password:

之后在mac开启屏幕共享就可以了


image-20241007225855305


QQ_1728313164289


四、docker 配置


docker安装我以为很简单,没想到这里是最难的一步了。安装完docker之后,总是报错:


Error response from daemon: Get "https://registry-1.docker.io/v2/": context deadline exceeded

即使改了mirrors也毫无作用


{
"registry-mirrors": [
"https://ylce84v9.mirror.aliyuncs.com"
]
}

看起来好像是docker每次pull镜像都要访问一次registry-1.docker.io,但是这个网址国内已经无法连接了,各种折腾,这里只贴一下代码吧,原理就就不讲了(懂得都懂)。


img


sslocal -c /etc/猫代理.json -d start
curl --socks5 127.0.0.1:1080 http://httpbin.org/ip

sudo yum -y install privoxy

vim /etc/systemd/system/docker.service.d/http-proxy.conf


[Service]
Environment="HTTP_PROXY=http://127.0.0.1:8118"

/etc/systemd/system/docker.service.d/https-proxy.conf


[Service]
Environment="HTTPS_PROXY=http://127.0.0.1:8118"

最后重启docker


systemctl start privoxy
systemctl enable privoxy
sudo systemctl daemon-reload
sudo systemctl restart docker

QQ_1729956484197


五、文件共享


sd卡好像读取不了,只能换个usb转换器


fdisk -l
mount /dev/sdb1 /mnt/usb/sd

在CentOS中设置文件共享,可以使用Samba服务。以下是配置Samba以共享文件的基本步骤:



  1. 安装Samba


sudo yum install samba samba-client samba-common


  1. 设置共享目录


    编辑Samba配置文件/etc/samba/smb.conf,在文件末尾添加以下内容:



[shared]
path = /path/to/shared/directory
writable = yes
browseable = yes
guest ok = yes


  1. 设置Samba密码


    为了允许访问,需要为用户设置一个Samba密码:



sudo smbpasswd -a your_username


  1. 重启Samba服务


sudo systemctl restart smb.service
sudo systemctl restart nmb.service


  1. 配置防火墙(如果已启用)


    允许Samba通过防火墙:



sudo firewall-cmd --permanent --zone=public --add-service=samba
sudo firewall-cmd --reload

现在,您应该能够从网络上的其他计算机通过SMB/CIFS访问共享。在Windows中,你可以使用\\centos-ip\shared,在Linux中,你可以使用smbclient //centos-ip/shared -U your_username


QQ_1730035390803


参考:


https://猫代理help.github.io/猫代理/linux.html


stackoverflow.com/questions/4…


作者:wwwzh
来源:juejin.cn/post/7430460789067055154
收起阅读 »

three 写一个溶解特效,初探 three 着色系统

web
背景溶解特效是一个在游戏里非常常见的特效,通常用来表示物体消失或者出现,它的原理也比较简单,这次就来实现一下这个效果,并且通过它来探究下 three.js 的着色器系统。原理使用一张噪波图,根据时间动态改变进度 progress,...
继续阅读 »

Fire.gif

背景

溶解特效是一个在游戏里非常常见的特效,通常用来表示物体消失或者出现,它的原理也比较简单,这次就来实现一下这个效果,并且通过它来探究下 three.js 的着色器系统。

原理

使用一张噪波图,根据时间动态改变进度 progress, 根据这个值与噪波图数值做比较,决定使用过渡色还是舍弃当前片元。

过渡色

为了使用过渡色,我们定义一个作用范围变量 edgeWidth 用来表示当前进度和 噪波数值(noiseValue) 之间的区域,这个区域填充 过渡色(edgeColor)

变化速度

progress 的变化通过变化速度(DissolveSpeed) 来控制。

类型

溶解可以分为 出现和消失 两种类型,两种类型可以互相转换,我们可以通过判断 progress 的边界来重新设置 progress 的增加量符号(加号变减号,减号变加号),并重新设置 progress 的值等于 0 || 1 来重新设置变化边界。

原理讲完了,接下来进入实践。

实践

先从最简单的 wavefront 格式说起,再拓展到其他更通用模型或者材质的用法。

波前 wavefront 格式

作为 3D 模型最早的格式之一,.obj 后缀的格式是由 wavefront 公司开发的,由于容易和其他常见类型的文件比如 gcc 编译的过程文件 .obj 混淆,将其表述为 wavefront 模型格式。

对于这个格式来说,几何数据和材质数据是分开加载的,你需要先加载 .obj 格式的文件,然后再去加载材质数据文件 .mtl。对于我们的示例来说是需要使用 ShaderMaterial 来自定义着色效果,因而我们直接加载对应的 材质贴图 做原理展示,就不使用 .mtl 的加载器了。

需要做的其实只有两步:

    1. 读取的模型后用 Geometry 和 ShaderMaterial 创建新的 Mesh 。
    1. ShaderMaterial 的 unifroms.progress 在 requestAnimationFrame 里做更新。

直接来看下着色器怎么写:

顶点着色器:

let vertexShader = /* glsl */`
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
;

主要是定义了 vUv 这个可传递变量,为了把内置的纹理坐标传递到 fragmentShader

片元着色器:

 let fragShader = /* glsl */`
uniform float progress;
uniform float edgeWidth;
uniform vec3 edgeColor;
uniform sampler2D mainTexture;
uniform sampler2D noiseTexture;
varying vec2 vUv;void main(void){

vec4 originalColor = texture2D(mainTexture, vUv);
float noiseValue = texture2D(noiseTexture, vUv).r;
vec4 finalColor = originalColor;

if(noiseValue > progress)
{
discard;
}

if(noiseValue + edgeWidth > progress){
finalColor = vec4(edgeColor, 1.0);
}

gl_FragColor = finalColor;

}
`
;

其中 originColor 是原始材质贴图,类型是 vec4noiseValue 是读取的噪波贴图取 r 通道的值,事实上,噪波图是灰度图,所以取 rgb 任意通道的都可以。然后对于 noiseValue ,随着 progress 逐渐增大,小于 progress 数值的噪波片元越来越少,模型出现。下面那句 + edgeWidth 则是把 edgeColor 填充到里面,原理是一样的。最后输出颜色。

这是出现的逻辑,如果是要消失呢?控制下边界条件就可以了:

function render() {
requestAnimationFrame(render);

controller.update();
// 出现
if (uniforms.progress.value < 0) {
uniforms.progress = 0;
stride = dissolveSpeed;
}
// 消失
if (uniforms.progress.value > 1) {
uniforms.progress = 1;
stride = -dissolveSpeed;
}
uniforms.progress.value += stride;

renderer.render(scene, camera);
}

效果立竿见影:

Spot.gif

再想一遍

写着色器和通用程序不大一样,单纯按上面这么讲可能不是很清晰,我们更深度地分析下,培养一下 rgb 思维。

已知出现和消失是互为逆过程,通过 CPU 端程序重新改变变化方向即可,我们按照一个状态,关注边界条件,分别从正向和逆向进行思考,给出两个版本分别的代码。

按照上面说的,我们关注,比如就 出现 的状态吧,边界条件是 阈值 和 噪波值 的比较结果。也就是 progress 和 noiseValue

用 Exclidraw 画下示意图:

Exclidraw_explain.png

考虑 出现 的情况,剩余的进度或者叫阈值(越来越小), 与当前片元噪声值比较大小,如果更大则舍弃掉表示还没出现的部分;与当前值往前剪掉的部分比较,如果更大则使用这个过渡色;其他情况是已经出现的部分,直接保留就可以了。

写成代码:

void main() {
...

float restProgress = 1.0 - dissolveProgress;
if(noiseValue < restProgress) {
discard;
}
if(noiseValue - edgeWidth < restProgress ) {
gl_FragColor = finalColor;
}

...
}

反向来思考,随着阈值增加,出现的图像越来多,往前减掉过渡值(edgeWidth), 这部分呈现过渡色;小于当前 noiseValue 的部分舍弃,是还没出现的部分。

写成代码:

void main() {
...
if(noiseValue > dissolveProgress)
{
discard;
}

if(noiseValue + edgeWidth > dissolveProgress){
gl_FragColor = vec4(edgeColor, 1.0);
}
...
}

这样,我们就用两种等价的方法实现了同一效果,后面的章节我们使用 glsl 函数把 条件判断 语句去掉。

这里其实叫 edgeWidth 有歧义,换成 edgeThickness 可能比较符合,如果这个值过大,就会超出变化范围出现异常,所以还是要把其限制在一个比较小的范围,这里为了调试先让它最大值等于 1

edgeWidth 值过大:

Over_Thickness.png

其他格式

我们拿更常用的其他格式来研究一下。通常 web 端会使用 gltf, fbx 等通用格式,我们这里拿 web 端最通用的 gltf 格式模型来说明,其他通用模型类型道理一样。

对于 gltf 格式来说,加载完模型就赋予了材质,可能的类型有 MeshStandardMaterial, MeshPhongMaterial, MeshBasicMaterial 等,我用封面的士兵模型,使用的是 MeshStandardMaterial 类型的材质,接下来看如何修改内置着色器而实现效果。

ShaderChunk 和 ShaderLib

来看下 three 的目录,较新版本的 three 把核心代码安排在 src 目录下,/examples/jsm 目录下则是以 插件addons的形式引入的额外功能,比如 GLTFLoader 之类比较通用的功能。而内部着色器的实现在 src/renderers/shaders 目录下:

Three-Shaders.png

我们直接打开 ShaderLib.js 文件找下模型使用的 MeshStandardMaterial 的定义:

Standard_Shader.png

可以看到是复用了 meshphysical 的着色器,这对着色器还在 MeshPhysicalMaterial 材质里被使用,通过材质类定义的 defines 字段来开启相应的计算,这样的做法使得 MeshStandardMaterial 作为 MeshPhysicalMaterial 的回退选项。到 ShaderChunk目录下打开 meshphysical.glsl.js 看下宏定义:

Macro_Define.png

OK,已经了解了材质定义和对应着色器的关系了,接下来就是如何把我们的逻辑加到相应着色器字符串里了。

onBeforeCompile

官方文档约等于没写,还是去看 examples 的代码吧,关键字 onBeforeCompile 搜索下:

OnBeforeCompile_Case.png

右下角点进去看代码:

OnBeforeCompile_Code.png

这下就明白了,顾名思义,这个函数可以在编译着色器程序之前允许我们插入自己的代码, 我们可以根据功能对相应模块进行覆写或者添加功能,我们不希望修改修改默认着色器的内容,直接把溶解效果加到最后,接下来看下怎么做。

调试

按照这个做法,非常依赖 javascript 的 replace 方法,我们需要小心操作,经过实验,把所有代码放到同一串里是没问题的,这里需要反复打印调试,如果有问题请使用双引号来使用原始字符串。

如果没有处理格式,直接塞进去不会对齐的,很好辨认:

Log_Shader.png

接下来直接移植代码:

Code_No_Linear_Convertion.png

看到注释的那句话了吗,如果注释掉,并把阈值开到最大覆盖全部范围,可以明显看到和设置的颜色不一样,原因是因为之前的 shader 代码处理结果是转化到线性输出显示的,我们在标准着色器最后处理,一样要做线性转化。这个线性转化的意思是 gamma 变换的逆变换, gamma 变换是由于人眼对于颜色的感知非线性,非线性的原因和视锥细胞,视杆细胞数量比例不一样有关,省略一万字,大家有兴趣自己去搜~

没有线性转换:

Result_No_Linear_Convertion.png

线性转换后颜色就正常了:

Result_Linear_Convertion.png

拓展

再换一种写法

之前我们用直接舍弃片元的方法来实现过渡,接下来我们使用更 shader 风格的写法来重写,因为这个效果显示和消失具有二值性(要么有颜色要么透明),可以用 step(x,y) 函数来写,这个函数比较 y > x 的结果,true 则返回 1,否则返回 0 , 正好可以来表达透明度。

看代码,只有 fragmentShader 不一样:

Use_GLSL_STEP.png

这里的想法是先控制是否显示颜色,找的边界就是 noiseValue - edgeWidth,然后再判断使用原来的像素或者过渡色,如果大于 noiseValue 使用原来的像素,否则使用过渡的颜色,然后 mix 函数这里的第三个变量刚好是 step 函数的结果,所以就可以切换这两颜色了。

哦对,记得设置这个 material.transparent = true; ,否则会使用默认的混合颜色白色:

No_Transparent.png

整活

昨天在沸点发了两张图,其实很简单,到这里把过渡色换成贴图采样就行了,比如这样:

Flower_Body.png

学会了吗?赶紧搬到项目里惊艳领导吧。

思考

  • 能否和环境做交互?

更新

代码: github.com/wwjll/three…

在线 Demo: wwjll.github.io/three-pract…

写文章不易,点赞收藏是最好的支持~


作者:网格体
来源:juejin.cn/post/7344958089429254182
收起阅读 »

别再手动拼接 SQL 了,MyBatis 动态 SQL 写法应有尽有,建议收藏!

1.Mybatis 动态 sql 是做什么的? Mybatis 动态 sql 可以让我们在 Xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。 2.Mybatis 的 9 种 动 态 sql 标 签有哪些? 3.动态 ...
继续阅读 »

1.Mybatis 动态 sql 是做什么的?


Mybatis 动态 sql 可以让我们在 Xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。


2.Mybatis 的 9 种 动 态 sql 标 签有哪些?


图片


3.动态 sql 的执行原理?


原理为:使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。


MyBatis标签


1.if标签:条件判断


MyBatis if 类似于 Java 中的 if 语句,是 MyBatis 中最常用的判断语句。使用 if 标签可以节省许多拼接 SQL 的工作,把精力集中在 XML 的维护上。


1)不使用动态sql


<select id="selectUserByUsernameAndSex"
        resultType="user" parameterType="com.ys.po.User">

    <!-- 这里和普通的sql 查询语句差不多,对于只有一个参数,后面的 #{id}表示占位符,里面          不一定要写id,
         写啥都可以,但是不要空着,如果有多个参数则必须写pojo类里面的属性 -->

    select * from user where username=#{username} and sex=#{sex}
</select>

if 语句使用方法简单,常常与 test 属性联合使用。语法如下:


<if test="判断条件">    SQL语句</if>

2)使用动态sql


上面的查询语句,我们可以发现,如果 #{username} 为空,那么查询结果也是空,如何解决这个问题呢?使用 if 来判断,可多个 if 语句同时使用。


以下语句表示为可以按照网站名称(name)或者网址(url)进行模糊查询。如果您不输入名称或网址,则返回所有的网站记录。但是,如果你传递了任意一个参数,它就会返回与给定参数相匹配的记录。


<select id="selectAllWebsite" resultMap="myResult">  
    select id,name,url from website 
    where 1=1    
   <if test="name != null">        
       AND name like #{name}   
   </if>    
   <if test="url!= null">        
       AND url like #{url}    
   </if>
</select>

2.where+if标签


where、if同时使用可以进行查询、模糊查询



注意,<if>失败后, <where> 关键字只会去掉库表字段赋值前面的and,不会去掉语句后面的and关键字,即注意,<where> 只会去掉<if> 语句中的最开始的and关键字。所以下面的形式是不可取的



<select id="findQuery" resultType="Student">
    <include refid="selectvp"/>
    <where>
        <if test="sacc != null">
            sacc like concat('%' #{sacc} '%')
        </if>
        <if test="sname != null">
            AND sname like concat('%' #{sname} '%')
        </if>
        <if test="sex != null">
            AND sex=#{sex}
        </if>
        <if test="phone != null">
            AND phone=#{phone}
        </if>
    </where>
</select>

这个“where”标签会知道如果它包含的标签中有返回值的话,它就插入一个‘where’。此外,如果标签返回的内容是以AND 或OR 开头的,则它会剔除掉。


3.set标签


set可以用来修改


<update id="upd">
    update student
    <set>
        <if test="sname != null">sname=#{sname},</if>
        <if test="spwd != null">spwd=#{spwd},</if>
        <if test="sex != null">sex=#{sex},</if>
        <if test="phone != null">phone=#{phone}</if>
    sid=#{sid}
    </set>
    where sid=#{sid}
</update>

4.choose(when,otherwise) 语句


有时候,我们不想用到所有的查询条件,只想选择其中的一个,查询条件有一个满足即可,使用 choose 标签可以解决此类问题,类似于 Java 的 switch 语句


<select id="selectUserByChoose" resultType="com.ys.po.User" parameterType="com.ys.po.User">
      select * from user
      <where>
          <choose>
              <when test="id !='' and id != null">
                  id=#{id}
              </when>
              <when test="username !='' and username != null">
                  and username=#{username}
              </when>
              <otherwise>
                  and sex=#{sex}
              </otherwise>
          </choose>
      </where>
  </select>

也就是说,这里我们有三个条件,id、username、sex,只能选择一个作为查询条件



  • 如果 id 不为空,那么查询语句为:select * from user where id=?

  • 如果 id 为空,那么看username 是否为空,如果不为空,那么语句为 select * from user where username=?;

  • 如果 username 为空,那么查询语句为 select * from user where sex=?


5.trim


trim标记是一个格式化的标记,可以完成set或者是where标记的功能


①、用 trim 改写上面第二点的 if+where 语句


<select id="selectUserByUsernameAndSex" resultType="user" parameterType="com.ys.po.User">
    select * from user
    <!-- <where>
        <if test="username != null">
           username=#{username}
        </if>
         
        <if test="username != null">
           and sex=#{sex}
        </if>
    </where>  -->
    <trim prefix="where" prefixOverrides="and | or">
        <if test="username != null">
           and username=#{username}
        </if>
        <if test="sex != null">
           and sex=#{sex}
        </if>
    </trim>
</select>


  • prefix:前缀

  • prefixoverride:去掉第一个and或者是or


②、用 trim 改写上面第三点的 if+set 语句


<!-- 根据 id 更新 user 表的数据 -->
<update id="updateUserById" parameterType="com.ys.po.User">
    update user u
        <!-- <set>
            <if test="username != null and username != ''">
                u.username = #{username},
            </if>
            <if test="sex != null and sex != ''">
                u.sex = #{sex}
            </if>
        </set> -->

        <trim prefix="set" suffixOverrides=",">
            <if test="username != null and username != ''">
                u.username = #{username},
            </if>
            <if test="sex != null and sex != ''">
                u.sex = #{sex},
            </if>
        </trim>
     
     where id=#{id}
</update>


  • suffix:后缀

  • suffixoverride:去掉最后一个逗号(也可以是其他的标记,就像是上面前缀中的and一样)


③、trim+if同时使用可以添加


<insert id="add">
    insert  int0 student
    <trim prefix="(" suffix=")" suffixOverrides=",">
        <if test="sname != null">sname,</if>
        <if test="spwd != null">spwd,</if>
        <if test="sex != null">sex,</if>
        <if test="phone != null">phone,</if>
    </trim>

    <trim prefix="values (" suffix=")"  suffixOverrides=",">
        <if test="sname != null">#{sname},</if>
        <if test="spwd != null">#{spwd},</if>
        <if test="sex != null">#{sex},</if>
        <if test="phone != null">#{phone}</if>
    </trim>

</insert>

6.MyBatis foreach标签


foreach是用来对集合的遍历,这个和Java中的功能很类似。通常处理SQL中的in语句。


foreach 元素的功能非常强大,它允许你指定一个集合,声明可以在元素体内使用的集合项(item)和索引(index)变量。它也允许你指定开头与结尾的字符串以及集合项迭代之间的分隔符。这个元素也不会错误地添加多余的分隔符


你可以将任何可迭代对象(如 List、Set 等)、Map 对象或者数组对象作为集合参数传递给 foreach。当使用可迭代对象或者数组时,index 是当前迭代的序号,item 的值是本次迭代获取到的元素。当使用 Map 对象(或者 Map.Entry 对象的集合)时,index 是键,item 是值


//批量查询
<select id="findAll" resultType="Student" parameterType="Integer">
    <include refid="selectvp"/> WHERE sid in
    <foreach item="ids" collection="array"  open="(" separator="," close=")">
        #{ids}
    </foreach>
</s
elect>
//批量删除
<delete id="del"  parameterType="Integer">
    delete  from  student  where  sid in
    <foreach item="ids" collection="array"  open="(" separator="," close=")">
        #{ids}
    </foreach>
</d
elete>

整合案例

xml


<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.yzx.mapper.StuMapper">
    <sql id="selectvp">
        select  *  from  student
    </sql>
    
    <select id="find" resultType="Student">
        <include refid="selectvp"/>
    </select>

    <select id="findbyid"  resultType="student">
        <include refid="selectvp"/>
        WHERE 1=1
        <if test="sid != null">
            AND sid like #{sid}
        </if>
    </select>

        <select id="findQuery" resultType="Student">
            <include refid="selectvp"/>
            <where>
                <if test="sacc != null">
                    sacc like concat('%' #{sacc} '%')
                </if>
                <if test="sname != null">
                    AND sname like concat('%' #{sname} '%')
                </if>
                <if test="sex != null">
                    AND sex=#{sex}
                </if>
                <if test="phone != null">
                    AND phone=#{phone}
                </if>
            </where>
        </select>

    <update id="upd">
        update student
        <set>
            <if test="sname != null">sname=#{sname},</if>
            <if test="spwd != null">spwd=#{spwd},</if>
            <if test="sex != null">sex=#{sex},</if>
            <if test="phone != null">phone=#{phone}</if>
        sid=#{sid}
        </set>
        where sid=#{sid}
    </update>

    <insert id="add">
        insert  int0 student
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="sname != null">sname,</if>
            <if test="spwd != null">spwd,</if>
            <if test="sex != null">sex,</if>
            <if test="phone != null">phone,</if>
        </trim>

        <trim prefix="values (" suffix=")"  suffixOverrides=",">
            <if test="sname != null">#{sname},</if>
            <if test="spwd != null">#{spwd},</if>
            <if test="sex != null">#{sex},</if>
            <if test="phone != null">#{phone}</if>
        </trim>

    </insert>
    <select id="findAll" resultType="Student" parameterType="Integer">
        <include refid="selectvp"/> WHERE sid in
        <foreach item="ids" collection="array"  open="(" separator="," close=")">
            #{ids}
        </foreach>
    </select>

    <delete id="del"  parameterType="Integer">
        delete  from  student  where  sid in
        <foreach item="ids" collection="array"  open="(" separator="," close=")">
            #{ids}
        </foreach>
    </delete>



</mapper>

测试类:


package com.yzx.test;

import com.yzx.entity.Student;
import com.yzx.mapper.StuMapper;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

public class StuTest {
    SqlSession sqlSession=null;
    InputStream is=null;

    @Before
    public   void  before() throws IOException {
        //1.读取核心配置文件
        is= Resources.getResourceAsStream("sqlMapperConfig.xml");
        //2.拿到工厂构建类
        SqlSessionFactoryBuilder sqlSessionFactoryBuilder=new SqlSessionFactoryBuilder();
        //3.拿到具体工厂
        SqlSessionFactory build=sqlSessionFactoryBuilder.build(is);
        //4.拿到session
        sqlSession = build.openSession();
    }

    @After
    public  void  after(){
        //7,提交事务
        sqlSession.commit();
        //8.关闭资源
        sqlSession.close();
        if(is!=null){
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        };
    }

    //查询所有
    @Test
    public  void  find(){
        //5.获取具体的mapper接口
        StuMapper mapper=sqlSession.getMapper(StuMapper.class);
        //6.调用执行
        List<Student> list=mapper.find();
        list.forEach(a-> System.out.println(a));
    }
    //查询单个
    @Test
    public  void  findbyid(){

        StuMapper mapper=sqlSession.getMapper(StuMapper.class);
        List<Student> list=mapper.findbyid(2);
        list.forEach(a-> System.out.println(a));
    }
    //模糊查询
    @Test
    public  void  findQuery(){

        StuMapper mapper=sqlSession.getMapper(StuMapper.class);

        Student  stu=new Student();
        stu.setSname("小");
        stu.setSex("男");
        List<Student> list=mapper.findQuery(stu);
        list.forEach(a-> System.out.println(a));
    }
    //修改
    @Test
    public  void  upd(){

        StuMapper mapper=sqlSession.getMapper(StuMapper.class);

        Student  stu=new Student();
        stu.setSid(3);
        stu.setSname("小若");
        stu.setSex("人妖");
        int i=mapper.upd(stu);
        System.out.println("修改了"+i+"条数据"+"  "+stu.toString());

    }
    //添加
    @Test
    public  void  add(){

        StuMapper mapper=sqlSession.getMapper(StuMapper.class);

        Student  stu=new Student();
        stu.setSname("小贺");
        stu.setSex("男");
        stu.setPhone("99999999");
        int i=mapper.add(stu);
        System.out.println("添加了"+i+"条数据"+"  "+stu.toString());

    }

    //批量操作
    @Test
    public  void  findAll(){

        StuMapper mapper=sqlSession.getMapper(StuMapper.class);
        Integer[] i={1,2,3,4};
        List<Student> list=mapper.findAll(i);
        list.forEach(a-> System.out.println(a));
    }
    //批量操作

    //批量删除
    @Test
    public  void  del(){
        StuMapper mapper=sqlSession.getMapper(StuMapper.class);
        Integer[] i={1,2,3,4};
        int i1=mapper.del(i);
        System.out.println("删除了"+i1+"条数据");
    }
}

7.sql


在实际开发中会遇到许多相同的SQL,比如根据某个条件筛选,这个筛选很多地方都能用到,我们可以将其抽取出来成为一个公用的部分,这样修改也方便,一旦出现了错误,只需要改这一处便能处处生效了,此时就用到了<sql>这个标签了。


当多种类型的查询语句的查询字段或者查询条件相同时,可以将其定义为常量,方便调用。为求<select>结构清晰也可将 sql 语句分解。


<sql id="selectvp">
    select  *  from  student
</sql>

8.include


这个标签和<sql>是天仙配,是共生的,include用于引用sql标签定义的常量。比如引用上面sql标签定义的常量


refid这个属性就是指定<sql>标签中的id值(唯一标识)


<select id="findbyid"  resultType="student">
    <include refid="selectvp"/>
    WHERE 1=1
    <if test="sid != null">
        AND sid like #{sid}
    </if>
</select>

9.如何引用其他XML中的SQL片段


比如你在com.xxx.dao.xxMapper这个Mapper的XML中定义了一个SQL片段如下:


<sql id="Base_Column_List"> ID,MAJOR,BIRTHDAY,AGE,NAME,HOBBY</sql>

此时我在com.xxx.dao.PatinetMapper中的XML文件中需要引用,如下:


<include refid="com.xxx.dao.xxMapper.Base_Column_List"></include>

MyBatis关联查询


1.MyBatis一对多关联查询


<!--一对多-->
<resultMap id="myStudent1" type="student1">
    <id property="sid" column="sid"/>
    <result property="sname" column="sname"/>
    <result property="sex" column="sex"/>
    <result property="sage" column="sage"/>
    <collection property="list" ofType="teacher">
        <id property="tid" column="tid"/>
        <result property="tname" column="tname"/>
        <result property="tage" column="tage"/>
    </collection>
</resultMap>

<!--一对多-->
<select id="find1" resultMap="myStudent1">
    select  *  from  student1  s  left  join  teacher  t  on s.sid=t.sid
</select>

2.MyBatis多对一关联查询


<!--多对一-->
<resultMap id="myTeacher" type="teacher">
    <id property="tid" column="tid"/>
    <result property="tname" column="tname"/>
    <result property="tage" column="tage"/>
    <association property="student1" javaType="Student1">
        <id property="sid" column="sid"/>
        <result property="sname" column="sname"/>
        <result property="sex" column="sex"/>
        <result property="sage" column="sage"/>
    </association>
</resultMap>


<!--多对一-->
<select id="find2" resultMap="myTeacher">
select  *  from  teacher  t right join student1 s on  t.sid=s.sid
</select>

3.MyBatis多对多关联查询


<!--多对多 以谁为主表查询的时候,主表约等于1的一方,另一方相当于多的一方-->
<select id="find3" resultMap="myStudent1">
    select  *  from  student1 s  left join relevance r on  s.sid=r.sid  left join teacher t on  r.tid=t.tid
</select>

最后说一句(求关注!别白嫖!)


如果这篇文章对您有所帮助,或者有所启发的话,求一键三连:点赞、转发、在看。


关注公众号:woniuxgg,在公众号中回复:笔记  就可以获得蜗牛为你精心准备的java实战语雀笔记,回复面试、开发手册、有超赞的粉丝福利!


作者:程序员蜗牛
来源:juejin.cn/post/7382394009199034387
收起阅读 »