普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月6日掘金 前端

面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了

作者 sunny_
2026年3月5日 21:12

一道"经典"的 Event Loop 面试题,背了八股文的你以为稳了,结果一运行却被 ESM 背刺。本文带你深挖 Node.js 事件循环中一个 99% 的人都不知道的坑:同样的代码,CJS 和 ESM 输出顺序竟然不一样。

前言

如果你准备过前端/Node.js 面试,大概率刷到过这类题目:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

你自信地写下答案:3 → 2 → 6 → 4 → 5 → 1

面试官微微一笑,说:"没问题,回去等通知吧。"

你心满意足地回家,顺手建了个项目想验证一下,npm init,改了下 package.jsonnode index.js 一跑——

3
4
2
5
6
1

你揉了揉眼睛,又跑了一遍。没错,4 跑到 2 前面去了

你开始怀疑人生:是我八股文背错了?还是 Node.js 出 bug 了?

都不是。是 ESM 在背后捅了你一刀。


一、先复习:Node.js 事件循环到底怎么转的

在搞清楚这个坑之前,我们得先把 Node.js 的事件循环机制理清楚。这部分是基础,老手可以快速跳过,但建议还是扫一遍,因为后面的分析会用到。

1.1 事件循环的六个阶段

Node.js 的事件循环基于 libuv,分为以下几个阶段,每一轮循环(tick)按顺序执行:

   ┌───────────────────────────┐
┌─>│           timers          │  ← setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  ← 系统级回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  ← 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  ← I/O 回调(fs.readFile 等)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  ← setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  ← socket.on('close') 等
│  └───────────────────────────┘

1.2 微任务的位置

在 Node.js v11 之后,每个阶段之间以及每个宏任务执行之后,都会清空微任务队列。微任务分为两类:

队列 API 优先级
nextTick 队列 process.nextTick()
微任务队列 Promise.then() / queueMicrotask()

也就是说,在传统认知中(CJS 模式下),优先级排序是:

同步代码 > process.nextTick > Promise 微任务 > 宏任务(timers / check / ...)

这也是为什么你面试时会回答 3 → 2 → 6 → 4 → 5 → 1 的原因——nextTick 队列会在 Promise 微任务之前被清空

1.3 微任务递归清空

一个重要的细节:nextTick 队列在清空时,如果回调中又注册了新的 nextTick,会在同一轮继续清空,直到队列为空。Promise 微任务也是同理。整个流程:

1. 清空 nextTick 队列(递归)
2. 清空 Promise 微任务队列(递归)
3. 如果上述步骤产生了新的 nextTick 或微任务,回到 1
4. 全部清空后,进入下一个事件循环阶段

到这里,一切都很"标准",也符合绝大多数八股文的描述。


二、验证"标准答案"——CJS 模式

我们先在 CJS 模式下跑一下,证明八股文没有骗你。

创建一个 test-cjs.js(注意:不要在 package.json 里设置 "type": "module"):

// test-cjs.js(CommonJS 模式)
setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

执行:

node test-cjs.js

输出:

3
2
6
4
5
1

完美,和八股文一模一样。我们来走一遍流程:

第一步:执行同步代码

  • setImmediate(cb) → 注册到 check 阶段队列
  • process.nextTick(cb) → 注册到 nextTick 队列(打印 2)
  • console.log(3)输出 3
  • Promise.resolve().then(cb) → 注册到微任务队列(打印 4)

此时队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:清空 nextTick 队列

  • 执行 cb(2)输出 2,并注册 nextTick(cb(6))
  • 队列没清空,继续 → 执行 cb(6)输出 6

第三步:清空微任务队列

  • 执行 cb(4)输出 4,并注册 nextTick(cb(5))
  • 微任务执行完毕,检查 nextTick → 执行 cb(5)输出 5

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 2 → 6 → 4 → 5 → 1


三、翻车现场——ESM 模式

现在,我们做一件"无害"的事情——在 package.json 里加上一行:

{
  "type": "module"
}

代码一个字都不改,再跑一次:

node index.js

输出:

3
4
2
5
6
1

4 跑到 2 前面去了!Promise 微任务比 nextTick 先执行了!

等等,不是说好了 nextTick > Promise 吗?

这并不是 Node.js 的 bug,这是 ESM 模块系统的执行机制 导致的必然结果。


四、为什么 ESM 会改变执行顺序?

这是本文的核心。要理解这个差异,必须搞清楚 CJS 和 ESM 在 Node.js 中的执行方式有什么本质不同。

4.1 CJS 的执行方式

在 CJS 模式下,Node.js 的执行流程大致是:

1. 同步加载模块(require 是同步的)
2. 同步执行模块代码
3. 模块代码执行完毕,进入事件循环
4. 事件循环开始前,先清空 nextTick 队列,再清空微任务队列

关键点:模块代码的执行是在 Node.js 的"主执行流"中完成的。执行完毕后,Node.js 通过自己的调度逻辑先处理 nextTick,再处理 Promise 微任务。

4.2 ESM 的执行方式

ESM 就完全不一样了。根据 ECMAScript 规范,ES Module 的加载和求值是 异步 的:

1. 解析模块依赖图(静态分析 import/export)
2. 异步加载所有模块
3. 按照依赖顺序对模块进行求值(evaluate)

关键来了:ESM 模块的求值(evaluate)过程本身就是在一个微任务(microtask)上下文中进行的。

这意味着什么?当你的 ESM 代码执行时,它已经处在 V8 引擎的微任务调度体系中了。代码执行完毕后:

  1. V8 引擎会先执行自己的微任务检查点(microtask checkpoint)
  2. Promise 微任务是 V8 原生管理的,所以会被 V8 先消费
  3. 然后控制权交还给 Node.js
  4. Node.js 再清空 nextTick 队列

用一张对比图来看:

CJS 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  Node.js 接管                    │
│  1. 清空 nextTick 队列            │  ← Node.js 自己的机制
│  2. 清空 Promise 微任务队列        │  ← V8 的微任务
│  3. 进入事件循环                   │
└─────────────────────────────────┘

ESM 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  V8 微任务检查点触发               │
│  1. 清空 Promise 微任务队列        │  ← V8 先动手了!
│  2. Node.js 接管                  │
│  3. 清空 nextTick 队列            │  ← Node.js 的 nextTick 被延后了
│  4. 进入事件循环                   │
└─────────────────────────────────┘

本质区别:在 CJS 中,Node.js 拥有微任务调度的主动权,所以它能让 nextTick 先走;而在 ESM 中,V8 引擎的微任务检查点先于 Node.js 的 nextTick 调度触发,所以 Promise 反而先执行了。

4.3 一句话总结

process.nextTick 是 Node.js 的"私货",不属于 ECMAScript 标准。在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,自然不会优先照顾 Node.js 的私货。


五、用代码证明这不是玄学

为了彻底证实这个结论,我们做一组最简实验——只用 nextTickPromise 对比:

实验 1:CJS 模式

node -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

nextTick
promise

nextTick 先于 Promise

实验 2:ESM 模式

node --input-type=module -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

promise
nextTick

Promise 先于 nextTick

同样的代码,两行都没改,只是切换了模块系统,执行顺序就反过来了。

实验 3:在事件循环内部(两者一致)

// 无论 CJS 还是 ESM,事件循环内部的行为是一致的
setTimeout(() => {
    process.nextTick(() => console.log('nextTick'));
    Promise.resolve().then(() => console.log('promise'));
}, 0);

输出(两种模式都一样):

nextTick
promise

这说明:ESM 只影响顶层代码(Top-Level)的微任务执行顺序,一旦进入事件循环内部,nextTick 和 Promise 的优先级关系恢复正常。


六、回到那道面试题——ESM 下的完整解析

现在我们用 ESM 的规则重新分析原题:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

第一步:执行同步代码

和 CJS 完全一样:

  • 注册 setImmediate(cb(1)) → check 队列
  • 注册 nextTick(cb(2)) → nextTick 队列
  • 输出 3
  • 注册 Promise.then(cb(4)) → 微任务队列

队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:V8 微任务检查点(ESM 的关键差异!)

因为是 ESM 模式,V8 的微任务检查点先触发:

  • 执行 cb(4)输出 4,注册 nextTick(cb(5))

此时队列状态:

nextTick 队列:[cb(2), cb(5)]
微任务队列:  [](已清空)
check 队列:  [cb(1)]

第三步:Node.js 清空 nextTick 队列

  • 执行 cb(2)输出 2,注册 nextTick(cb(6))
  • 执行 cb(5)输出 5
  • 执行 cb(6)输出 6

此时队列状态:

nextTick 队列:[](已清空)
微任务队列:  []
check 队列:  [cb(1)]

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 4 → 2 → 5 → 6 → 1


七、面试怎么答?

如果你在面试中遇到这道题,我建议分三层回答:

第一层:给出标准答案

在 CJS 模式下,输出顺序是 3 → 2 → 6 → 4 → 5 → 1。因为同步代码优先执行,然后 process.nextTick 队列会在 Promise 微任务之前被清空,最后才是 setImmediate 宏任务。

第二层:主动提出 ESM 的差异

但如果这段代码运行在 ESM 模式下("type": "module".mjs 文件),输出顺序会变成 3 → 4 → 2 → 5 → 6 → 1。因为 ESM 模块的求值本身处于 V8 的微任务上下文中,Promise 微任务会被 V8 引擎优先消费,先于 Node.js 的 nextTick 队列。

第三层:解释根本原因

这个差异的本质是 process.nextTick 是 Node.js 自己的调度机制,不属于 ECMAScript 标准。在 CJS 模式下,Node.js 对执行流有完全的控制权,可以让 nextTick 优先;但在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,Node.js 的私有调度会被延后。不过这个差异只存在于顶层代码中,进入事件循环后两者行为一致。

这三层答出来,面试官绝对会对你刮目相看。


八、延伸思考

8.1 这算是 Node.js 的 Bug 吗?

不算。这是 CJS 和 ESM 两种模块系统的 设计差异 导致的必然结果。Node.js 官方文档中也有相关说明:

Microtask callbacks take priority over nextTick callbacks in this specific case because of V8's microtask checkpoint behavior during ES module evaluation.

8.2 process.nextTick 还值得用吗?

process.nextTick 在 Node.js 生态中仍然有其存在价值,比如:

  • 在事件循环内部,它的优先级依然高于 Promise
  • 用来确保回调在当前操作完成后、I/O 之前执行
  • 在流(Stream)和 EventEmitter 中广泛使用

但考虑到 ESM 正在成为 Node.js 的主流模块系统,你需要意识到 在顶层代码中,nextTick 的优先级不再是绝对的。如果你对执行顺序有严格要求,应该通过代码结构来保证,而不是依赖 nextTick 和 Promise 的微妙优先级差异。

8.3 queueMicrotask vs process.nextTick

还有一个相关的知识点:queueMicrotask() 是 Web 标准 API,Node.js 也支持。它创建的微任务和 Promise 处于同一级别。在 CJS 中,queueMicrotask 的回调在 nextTick 之后执行;在 ESM 中,它和 Promise 一样会先于 nextTick 执行。

process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('promise'));

CJS 输出:nextTick → queueMicrotask → promise ESM 输出:queueMicrotask → promise → nextTick

8.4 面试中常见的相关题目

理解了上面的原理后,下面这些变种你也能轻松应对:

题目 1:async/await 的执行顺序

async function foo() {
    console.log(1);
    await Promise.resolve();
    console.log(2);
}

process.nextTick(() => console.log(3));
foo();
console.log(4);

CJS 下:1 → 4 → 3 → 2 ESM 下:1 → 4 → 2 → 3

原理相同:await 后面的代码本质上就是 Promise.then,在 ESM 中会先于 nextTick 执行。

题目 2:setTimeout vs setImmediate

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

这道题的输出顺序是 不确定的(取决于系统调度),但如果放在 I/O 回调中,setImmediate 一定先于 setTimeout

const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
});
// 始终输出:immediate → timeout

九、总结

维度 CJS ESM
模块加载 同步 (require) 异步 (import)
顶层代码执行上下文 Node.js 主执行流 V8 微任务上下文
顶层 nextTick vs Promise nextTick 优先 Promise 优先
事件循环内部 nextTick vs Promise nextTick 优先 nextTick 优先
"type" 设置 默认 / "commonjs" "module"
文件扩展名 .js / .cjs .js(需配置) / .mjs

一句话记忆:CJS 中 Node.js 说了算,nextTick 是大哥;ESM 中 V8 说了算,Promise 是大哥。但进了事件循环,nextTick 依然是大哥。


写在最后

这个坑之所以"阴间",是因为:

  1. 代码完全一样,只是 package.json 多了一行 "type": "module"
  2. 绝大多数八股文和面试题都没有区分模块系统来讨论
  3. 现在的新项目基本都用 ESM 了,所以你跑出来的结果大概率和背的不一样

下次面试官再问事件循环,别忘了反问一句:"请问这段代码是跑在 CJS 还是 ESM 下?"

如果面试官愣住了——恭喜你,你已经赢了。


如果这篇文章帮到了你,欢迎点赞收藏,关注我获取更多 Node.js 深水区技术分享。

移动端调试工具VConsole初始化时的加载阻塞问题

2026年3月5日 21:00

问题复现(场景:小程序应用)

将VConsole下载到本地进行,在main.js中进行初始化,当上线后,页面首次加载会出现卡顿空白页,刷新之后显示正常


import Vue from 'vue'

import App from './App.vue'

import router from './router'

import ElementUI from 'element-ui'

import 'element-ui/lib/theme-chalk/index.css'

import VConsole from 'vconsole'

new VConsole();

new Vue({

render: h => h(App),

router,

}).$mount('#app')

阻塞原因

首次加载无缓存 + VConsole 初始化抢占主线程,阻塞了页面渲染;第二次刷新有缓存,VConsole 加载 / 初始化耗时骤降,不再阻塞

「第一次卡顿白屏」

当在 main.js 里直接 new VConsole() 时,会发生下面过程:

  • ✅ 资源无缓存:第一次进页面,浏览器要从网络下载 vconsole.js 源码(约 150KB),占网络带宽,拖慢页面 JS 加载;
  • ✅ 抢占主线程:VConsole 初始化时,会做「重写 console、插 DOM 面板、监听网络请求」等操作,这些操作和 Vue 初始化、页面 DOM 渲染抢同一根「主线程」,浏览器顾此失彼,页面来不及渲染就白屏;
  • ✅ 时序冲突:VConsole 初始化和 Vue 挂载($mount('#app'))同时执行,甚至更早,直接打乱页面渲染节奏,导致首屏出不来。

2.「第二次就正常」

第二次进页面时:

  • ✅ 资源有缓存:浏览器已经把 vconsole.js 存在本地,不用再从网络下载,加载耗时从几百毫秒降到几毫秒;
  • ✅ 初始化变快:VConsole 初始化的核心资源(如样式、面板模板)都在缓存里,主线程占用时间大幅减少,不会再阻塞 Vue 渲染和页面显示。

即使下载依赖到本地,只要在main.js立刻初始化同样会造成阻塞


加延迟让vue先完初始化,console再进行加载

加延迟解决卡顿

import { createApp } from 'vue'

import App from './App.vue'

// 1. 先挂载 Vue 应用

const app = createApp(App)

app.mount('#app')

// 2. 延迟初始化 VConsole(仅开发环境)

if (process.env.NODE_ENV === 'development') {

setTimeout(() => {

import('vconsole').then(({ default: VConsole }) => {

new VConsole()

})

}, 100)

}

虽然加延迟能解决卡顿,但是看不到初始化运行时加载的其他js文件

利用async和preload解决卡顿问题,但是会丢失少部分初始化最开始的加载日志文件

比如这样:、

<head>

<!-- VConsole异步加载 -->

<script src="https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js" async></script>

<!-- 项目JS正常引入(也可加async) -->

<script src="./js/chunk-vendors.js"></script>

<script src="./js/app.js"></script>

<script>

// 等页面所有资源加载完,再初始化VConsole(避免卡顿)

window.addEventListener('load', () => {

if (window.VConsole) { // 检查VConsole是否加载完成

new window.VConsole({ disableLogScrolling: true });

console.log('VConsole初始化完成(async方案)');

// 此时执行你的日志打印逻辑

window.printNewJS();

}

});

// 你的printNewJS等逻辑保留

window.printedJS = new Set();

window.printNewJS = function() 

</script>

</head>

终极解决方案:先完成console的初始化再引入打包配的.js文件

<head>

<!-- 预加载VConsole,加速下载 -->

<link rel="preload" href="https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js" as="script">

<script>

// 先加载并初始化VConsole

const scriptVConsole = document.createElement('script');

scriptVConsole.src = "https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js";

scriptVConsole.onload = () => {

new VConsole();

console.log('VConsole初始化完成');

// VConsole就绪后,动态引入项目JS(保留日志)

loadProjectJS();

};
document.head.appendChild(scriptVConsole);

// 动态引入项目JS(原逻辑)

function loadProjectJS() {

const projectJS = ['./js/chunk-vendors.js', './js/app.js'];

projectJS.forEach(path => {

const script = document.createElement('script');

script.src = path;

document.body.appendChild(script);

});

}

</script>

</head>

但是这样有一个缺点:会有一个短暂的空白,所以可以给idnex.html中加入一个loading提示过渡,这样就可以查看项目运行过程中整个所有加载的文件运行状态结果

昨天 — 2026年3月5日掘金 前端

Coze+ VisActor Skill:智能图表,触手可及

作者 玄魂
2026年3月5日 20:08

Coze 技能商店简介

Coze 简介

Coze(扣子)是字节跳动推出的新一代 AI Agent 平台。

使用入口

  • 公开版(国内):coze.cn(网页端)/ 扣子 APP(移动端)
  • 公开版(海外):coze.com

核心能力

扣子提供了强大的功能,全面提升生产力,支持多种应用场景,满足不同用户的需求。

  • 生产力全面提升:从回答问题,到解决问题,让 AI Agent 帮你完成更多的工作。
  • 技能:AI 从“理解指令”向“掌握方法”跨越的核心。
  • 能力边界拓展延伸:MCP 扩展集成,无限拓展 AI Agent 能力边界。

Coze 技能商店

熟悉AI 应用的同学,对 skill 的概念和应用一定不陌生,Coze 也推出了 技能商店,各种技能如雨后春笋般涌现。

VisActor图表技能

VisActor 图表技能以其图表种类丰富、图表主题美观、智能配置灵活、代码生成准确 等诸多优点,在众多图表(可视化)技能 中脱颖而出。

VisActor 图表技能背后依托 字节跳动开源可视化解决方案 VisActor,基础组件和智能化生成能力千锤百炼。目前的图表Skill 背后主要依托 VChart 和 VBI 组件库。

官网visactor.com/visactor.com/vchartvisactor.github.io/VBI/

githubgithub.com/VisActorgithub.com/VisActor/VC…github.com/VisActor/VB…

VisActor 图表技能快速体验

通过链接 www.coze.cn/?skill_shar… 或者 在Coze 技能商店搜索“VisActor” 都可以找到 VisActor图表技能。

打开卡片弹窗,点击“使用”。

接下来,我们可以在Coze中 @ VisActor 图表技能来实现可视化了。

输入需求之后,静静等待Coze执行任务。

Coze 会自动搜集数据,然后调用 VisActor 图表skill 生成图表。

VisActor 图表skill 会生成一个可以交互的图表,而且你可以通过修改VSeed spec 的方式进一步对图表进行调整,满意之后可以下载图片使用,同时可以分享页面链接。

VisActor 图表skill 支持丰富的图表类型,具体可参考 www.visactor.com/vchart/exam…

数据透视

我们也可以传入自定义数据,供 VisActor 图表skill 进行分析。例如:

VisActor 图表skill 具备其他同类skill 不具备的数据透视能力。例如:

运行结果如下:

多轮编辑

VisActor 图表skill 提供了强大的编辑能力,用户可以持续在Coze中通过对话修改图表。例如这里我可以继续将一组柱图变成红色。

数据标注

数据标注也是 VisActor 图表skill 独有的强大能力。比如,我想标记一条折线的最高点。

更多标注能力可以参考:www.visactor.com/vchart/demo…

动态图表

动态图表多用于数据作品或者数据视频中,可以动态展示数据变化过程, VisActor 图表skill 目前提供了动态条形图,动态折线图,动态饼图,未来会追加更多的动态图表。

出错了怎么办?

因为用户的需求千差万别,偶尔会有无法生成或者出错的情况,用户可以将报错信息同步给Coze,让它修复重新生成。

当然我们会搜集用户的反馈,不断改进。

信息图、数据视频——值得期待

单一的图表生成并不能满足可视化的应用场景,接下来我们会推出信息图和数据视频的生成功能,敬请期待。

欢迎交流

最后,欢迎大家以各种方式参与 VisActor 的开源建设,提 issue,写代码,加评论,点 Star,都是对开源项目的支持。

VisActor:visactor.com/

VBI:github.com/VisActor/VB…

VChart:GitHub - VisActor/VChart: VChart, more than just a cross-platform charting library, but also an expr

VTable:github.com/VisActor/VT…

VisActor 飞书交流群:

VisActor 微信公众号:

KeepAlive:组件缓存实现深度解析

作者 wuhen_n
2026年3月4日 14:23

在前面的文章中,我们学习了 Suspense 如何处理异步组件加载。今天,我们将探索Vue3中另一个强大的特性:KeepAlive。它允许我们在组件切换时缓存组件实例,避免重复渲染,极大地提升了用户体验和性能。理解它的实现原理,将帮助我们更好地处理需要保持状态的组件。

前言:为什么需要组件缓存?

在构建大型单页应用时,我们经常会遇到这样的场景:

  • 用户频繁切换标签页,每次切换回来表单数据却丢失了。
  • 一个复杂的图表组件每次重新进入都要重新渲染,造成性能浪费。

Vue3 的 KeepAlive 组件正是为了解决这些问题而生。本文将深入剖析 KeepAlive 的工作原理、LRU缓存策略、生命周期变化,并手写一个简易实现。

KeepAlive 组件概述

什么是 KeepAlive

KeepAlive 是 Vue 的内置组件,它能够在组件切换时,自动将组件实例保存在内存(缓存)中,而不是直接将其销毁。当组件再次被切回时,直接从缓存中恢复实例和 DOM,从而避免重复渲染和状态丢失:

<template>
  <keep-alive>
    <component :is="currentTab" />
  </keep-alive>
</template>

核心优势

  • 状态保持:表单输入、滚动位置等状态在切换后依然保留
  • 性能提升:避免重复创建和销毁组件实例,减少DOM操作
  • 数据复用:避免重复请求相同的数据,减少网络开销

KeepAlive 的工作机制

核心原理:DOM的"搬家"

很多人误以为 KeepAlive 只是简单的 display: none,其实不然:它的本质是将组件的 DOM 节点从页面上摘下来,并将组件实例和 DOM 引用保存在内存中。当再次切回来时,直接从内存中取出这个 DOM 节点重新挂上去。

这个过程可以简化为:

  • 组件失活时:container.removeChild(dom) ,移除组件节点,但在内存中保留实例
  • 组件激活时:container.appendChild(dom) ,挂载组件节点,并恢复组件状态

缓存队列的设计

KeepAlive 内部使用两个核心数据结构来管理缓存:

const cache: Map<string, VNode> = new Map();  // 缓存存储
const keys: Set<string> = new Set();          // 缓存key顺序队列
  • cache:存储组件 VNode 的 Map 结构,key 通常是组件的 id 或 key 属性
  • keys:维护缓存 key 的访问顺序,用于实现 LRU 淘汰策略

核心配置属性

<keep-alive
  :include="['ComponentA', 'ComponentB']"  
  :exclude="/ComponentC/"                  
  :max="10"                                 
>
  <component :is="currentComponent" />
</keep-alive>
  • include:只有名称匹配的组件才会被缓存,支持字符串、正则、数组
  • exclude:名称匹配的组件不会被缓存
  • max:最多缓存多少组件实例,超过时按 LRU 策略淘汰

激活与失活:特殊的生命周期

activated 和 deactivated

当组件被 KeepAlive 包裹时,它会多出两个生命周期钩子:

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  // 调用时机:
  // 1. 组件首次挂载
  // 2. 每次从缓存中被重新插入时
  console.log('组件被激活了')
  // 适合恢复轮询、恢复动画等
})

onDeactivated(() => {
  // 调用时机:
  // 1. 从 DOM 上移除、进入缓存时
  // 2. 组件卸载时
  console.log('组件被停用了')
  // 适合清除定时器、暂停网络请求等
})
</script>

与普通生命周期的关系

被缓存的组件在切换时不会触发 unmountedmounted,而是触发 deactivatedactivated。这意味着组件实例一直活着,只是暂时休眠,其生命周期流程如下:

  • 首次进入: beforeMount -> mounted -> activated
  • 切换出去: -> deactivated
  • 切换回来: -> activated
  • 最终销毁: -> beforeUnmount -> unmounted -> deactivated

源码实现机制

Vue3 内部通过 registerLifecycleHook 来管理这些钩子:

function registerLifecycleHook(type, hook) {
  const instance = getCurrentInstance()
  if (instance) {
    (instance[type] || (instance[type] = [])).push(hook)
  }
}

// 激活时执行
function activateComponent(instance) {
  if (instance.activated) {
    instance.activated.forEach(hook => hook())
  }
}

// 失活时执行
function deactivateComponent(instance) {
  if (instance.deactivated) {
    instance.deactivated.forEach(hook => hook())
  }
}

LRU 淘汰策略深度解析

为什么需要 LRU

当设置了 max 属性后,缓存池容量有限。如果没有淘汰策略,无限缓存会导致内存溢出。LRU(Least Recently Used)算法正是解决这个问题的经典方案。

LRU 核心思想

LRU 基于"最近被访问的数据将来被访问的概率更高"这一假设:

  • 新数据插入到链表尾部
  • 每当缓存命中,将数据移到链表尾部
  • 链表满时,丢弃链表头部的数据(最久未使用)

KeepAlive 中的 LRU 实现

KeepAlive 利用 Set 的迭代顺序特性来实现 LRU,即:每次访问时先删除再添加,就实现了"移到末尾"的效果:

// 核心LRU逻辑
if (cachedVNode) {
  // 缓存命中:删除旧key,重新添加到末尾(表示最新使用)
  keys.delete(key)
  keys.add(key)
  return cachedVNode
} else {
  // 缓存未命中:添加新key
  keys.add(key)
  
  // 检查是否超过最大限制
  if (max && keys.size > max) {
    // 淘汰最久未使用的key(Set的第一个元素)
    const oldestKey = keys.values().next().value
    pruneCacheEntry(oldestKey)
  }
  cache.set(key, vnode)
  return vnode
}

手写实现简易 KeepAlive 组件

核心实现思路

// MyKeepAlive.ts
import { defineComponent, h, onBeforeUnmount, getCurrentInstance } from 'vue'

export default defineComponent({
  name: 'MyKeepAlive',
  
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
  },
  
  setup(props, { slots }) {
    // 缓存容器
    const cache = new Map()
    const keys = new Set()
    
    // 当前渲染的 vnode
    let current = null
    
    // 工具函数:检查组件名是否匹配规则
    const matches = (pattern, name) => {
      if (Array.isArray(pattern)) {
        return pattern.includes(name)
      } else if (pattern instanceof RegExp) {
        return pattern.test(name)
      } else if (typeof pattern === 'string') {
        return pattern.split(',').includes(name)
      }
      return false
    }
    
    // 工具函数:获取组件名称
    const getComponentName = (vnode) => {
      const type = vnode.type
      return type.name || type.__name
    }
    
    // 淘汰缓存
    const pruneCacheEntry = (key) => {
      const cached = cache.get(key)
      if (cached && cached.component) {
        // 如果不是当前激活的组件,需要卸载
        if (cached !== current) {
          cached.component.unmount()
        }
      }
      cache.delete(key)
      keys.delete(key)
    }
    
    // 根据 include/exclude 清理缓存
    const pruneCache = (filter) => {
      cache.forEach((vnode, key) => {
        const name = getComponentName(vnode)
        if (name && filter(name)) {
          pruneCacheEntry(key)
        }
      })
    }
    
    // 监听 include/exclude 变化
    if (props.include || props.exclude) {
      watch(
        () => [props.include, props.exclude],
        ([include, exclude]) => {
          include && pruneCache(name => !matches(include, name))
          exclude && pruneCache(name => matches(exclude, name))
        },
        { flush: 'post' }
      )
    }
    
    // 组件卸载时清理所有缓存
    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        if (vnode.component) {
          vnode.component.unmount()
        }
      })
      cache.clear()
      keys.clear()
    })
    
    return () => {
      // 获取默认插槽的第一个子节点
      const vnode = slots.default?.()[0]
      if (!vnode) return null
      
      const name = getComponentName(vnode)
      
      // 检查 include/exclude
      if (
        (props.include && name && !matches(props.include, name)) ||
        (props.exclude && name && matches(props.exclude, name))
      ) {
        // 不缓存,直接返回
        return vnode
      }
      
      // 生成缓存key
      const key = vnode.key ?? vnode.type.__id ?? name
      
      // 命中缓存
      if (cache.has(key)) {
        const cachedVNode = cache.get(key)
        // 复用组件实例和DOM
        vnode.component = cachedVNode.component
        vnode.el = cachedVNode.el
        
        // 标记为 KeepAlive 组件
        vnode.shapeFlag |= 1 << 11 // ShapeFlags.COMPONENT_KEPT_ALIVE
        
        // LRU: 刷新key顺序
        keys.delete(key)
        keys.add(key)
        
        current = vnode
        return vnode
      }
      
      // 未命中缓存
      cache.set(key, vnode)
      keys.add(key)
      
      // LRU: 检查是否超过max限制
      if (props.max && keys.size > Number(props.max)) {
        const oldestKey = keys.values().next().value
        pruneCacheEntry(oldestKey)
      }
      
      // 标记为需要被 KeepAlive 的组件
      vnode.shapeFlag |= 1 << 12 // ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
      
      current = vnode
      return vnode
    }
  }
})

原生 JS 模拟演示

为了更直观地理解 KeepAlive 的"DOM搬家"原理,这里提供一个原生 JS 的简单实现:

<div id="app"></div>
<button onclick="switchTab('home')">首页</button>
<button onclick="switchTab('profile')">个人</button>

<script>
  const cache = {}
  const container = document.getElementById('app')
  let currentTab = null

  function createHomePage() {
    const div = document.createElement('div')
    div.innerHTML = `
      <h3>首页</h3>
      <input placeholder="试试输入内容..." />
    `
    return div
  }

  function createProfilePage() {
    const div = document.createElement('div')
    div.innerHTML = `<h3>个人中心</h3><p>这是个人页</p>`
    return div
  }

  function switchTab(tab) {
    // 移除当前页面
    if (currentTab && cache[currentTab]) {
      container.removeChild(cache[currentTab])
      console.log(`[缓存] ${currentTab} 已暂停 (DOM移除)`)
    }

    // 加载新页面
    if (cache[tab]) {
      // 命中缓存,直接复用DOM
      container.appendChild(cache[tab])
      console.log(`[缓存] ${tab} 命中缓存,恢复DOM`)
    } else {
      // 首次创建
      const page = tab === 'home' ? createHomePage() : createProfilePage()
      cache[tab] = page
      container.appendChild(page)
      console.log(`[缓存] ${tab} 首次创建并缓存`)
    }
    
    currentTab = tab
  }
</script>

常见陷阱

陷阱1:组件名不匹配导致缓存失效

KeepAliveinclude/exclude 是根据组件的 name 选项来匹配的,而不是文件名或路径,因此必须显示地声明组件的 name

陷阱2:滥用缓存导致内存溢出

对于频繁切换且数量众多的组件,务必设置合理的 max 值,避免无限缓存。

陷阱3:WebSocket 等全局资源重复创建

// ❌ 错误:每次激活都新建连接
onActivated(() => {
  ws = new WebSocket('wss://...') // 重复创建
})

// ✅ 正确:全局单例 + 按需消费
const socketStore = useSocketStore() // Pinia 全局单例
onActivated(() => {
  socketStore.subscribe('chat')
})
onDeactivated(() => {
  socketStore.unsubscribe('chat')
})

清除缓存的几种方式

方法1:动态修改 include/exclude

const cachedComponents = ref(['ComponentA', 'ComponentB'])
const clearCache = () => {
  cachedComponents.value = []  // 清空 include,所有组件不再缓存
}

方法2:改变 key 强制重新渲染

const componentKey = ref(0)
const forceRerender = () => {
  componentKey.value++  // key 变化,组件重新创建
}

方法3:调用 unmount(不推荐)

const clearCache = (key) => {
  // 通过 ref 访问组件实例,调用 unmount
}

结语

KeepAlive 是 Vue 中提升性能的重要工具,它通过缓存组件实例,避免重复渲染。理解它的实现原理,不仅帮助我们更好地使用它,也能在遇到性能问题时找到合适的优化方案。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Tauri 踩坑 appLink 修改后闪退

作者 ssshooter
2026年3月4日 12:51

如题,Grok 救我狗命,我得重新苹果 Grok 搜索能力的优势了。

**问题归根到底应该就是 Info.plist 没有在修改 appLink 之后自动更新,导致匹配不上。**解决方案是清理掉原来的自动生成内容,重新运行 ios init。估计不止这种问题,tauri + iOS 本来能正常跑,改了配置突然运行不了的情况,都可以尝试一下重新 init。这里还有点感悟,一旦出了舒适区,即使有 AI 也要花费不少时间解决这些问题,如果是熟练的 iOS 开发者应该一早就感应到这种问题了吧

1. “appLink”: false 的正确含义和用法

  • appLink: false(或省略) = 自定义 URI Scheme(如 myapp://xxx),不需要服务器、不需要 .well-known 文件。
  • 插件会在构建时自动往 Info.plist 里写入 CFBundleURLTypes
  • appLink: true(默认) = Universal Link / App Link(https + host),需要苹果验证文件。

正确配置(tauri.conf.json)

{
  "plugins": {
    "deep-link": {
      "mobile": [
        {
          "scheme": ["myapp"],        // ← 必须有这个,数组形式,方案名建议全小写
          "appLink": false            // 自定义 scheme 必须显式 false 或省略
        }
        // 如果你还想同时支持 Universal Link,可以再加一个对象
        // {
        //   "host": "yourdomain.com",
        //   "pathPrefix": ["/open"]
        //   // appLink 默认 true,可不写
        // }
      ]
    }
  }
}

常见写错导致闪退的几种情况

  • appLink: false 直接写在有 host 的对象里(混合使用)。
  • 只写 appLink: false 却没写 scheme(插件生成空/无效的 CFBundleURLTypes)。
  • 用错字段名:老版本可能是 "schemes",新版是 "scheme"(注意单数)。
  • appLink 写在 deep-link 顶层而不是 mobile 数组里的对象里。

2. 为什么会闪退?

  • Info.plist 生成出错(空 array、重复 key、XML 格式坏掉)。
  • iOS 系统在启动时解析 plist 失败,直接 kill 进程。
  • 之前用 Universal Link(appLink true)时 Xcode 已经加了 com.apple.developer.associated-domains entitlement,切换到 false 后 entitlement 和 provisioning profile 不匹配,也会导致启动失败。

3. 立即修复步骤(必做)

  1. 修正配置 为上面正确的格式(只保留 scheme + appLink: false)。
  2. 彻底清理构建缓存
    tauri ios clean          # 如果有这个命令
    # 或者手动
    rm -rf target/ src-tauri/target src-tauri/gen/apple
    
  3. 删除 Xcode DerivedData(最重要):
    rm -rf ~/Library/Developer/Xcode/DerivedData
    
  4. 重新生成/运行:
    tauri ios dev            # 或 tauri ios build
    
  5. 如果还是闪退,打开 Xcode 项目(target/ios 里生成的 .xcodeproj),找到 Info.plist,搜索 CFBundleURLTypes,确认里面有你的 scheme,且没有空数组。

4. 验证是否成功

  • 构建成功后,用模拟器测试:
    xcrun simctl openurl booted myapp://test
    
  • App 应该正常启动并收到 deep-link 事件。

总结:99% 是配置写错了(少了 scheme 或混用了 host),导致 Info.plist 坏掉。改对配置 + 清理缓存基本就能解决。如果清理后还闪退,把你的 plugins.deep-link 那段完整配置贴出来,我再帮你看。

封装一个vue2的elementUI 表格组件(包含表格编辑以及多级表头)

作者 大金乄
2026年3月5日 17:59

1.直接复制代码就能用。

效果图

screenshot-1772704592282-0.009.png

<template>
    <!-- 数据表格 -->
    <!-- 树形需要指定row-key -->
    <section class="ces-table">
      <el-table
        :data="tableData"
        :show-header="showHeader"
        :size="size"
        fit
        :key="keyDom"
        :header-cell-style="headClass"
        :border="isBorder"
        :max-height="tableHeight"
        :row-key="rowKey"
        :row-class-name="handleRowClassName"
        :cell-style="handleCellStyle"
        v-loading="loading"
        highlight-current-row
        ref="cesTable"
        @select="checkboxSelect"
        @select-all="selectAll"
        @row-click="handleRowItem"
        @selection-change="handleSelectionChange"
        @expand-change="expandChange"
        @sort-change="handleSortChange"
        @cell-mouse-enter="enterSelectionRows"
        @cell-mouse-leave="leaveSelectionRows"
      >
        <!-- 多选框 -->
        <el-table-column
          v-if="isSelection"
          type="selection"
          :reserve-selection="true"
          align="center"
          :selectable="columObj.selectable"
        >
        </el-table-column>
        <el-table-column v-if="isSelectRadio" width="80">
          <template #default="scope">
            <el-radio
              :label="scope.row.id"
              v-model="selectRadioValaue"
              @change="changeSelectRadioVal(scope.row)"
            ></el-radio>
          </template>
        </el-table-column>
        <!-- 任意插槽 -->
        <slot></slot>
        <!-- 序号 -->
        <el-table-column
          v-if="isIndex"
          type="index"
          fixed="left"
          :label="indexLabel"
          align="center"
          width="50"
        >
          <template #default="scope">
            <span>{{ indexAdd(scope.$index) }}</span>
          </template>
        </el-table-column>
        <!-- 数据栏 -->
        <template v-for="(item, index) in tableCols">
          <!-- 多级表头 -->
          <template v-if="item.isMultiHeader && item.children.length > 0">
            <el-table-column
              :key="`${item.prop}-multi`"
              :label="item.label"
              :align="item.align || 'center'"
            >
              <el-table-column
                v-for="(col, colIndex) in item.children"
                :key="`col-${colIndex}`"
                :prop="col.prop"
                :sortable="col.sortable"
                :label="col.label"
                :min-width="col.width"
                :align="col.align"
                :render-header="col.require ? renderHeader : null"
                :show-overflow-tooltip="
                  col.showOverflowTooltip !== undefined
                    ? col.showOverflowTooltip
                    : true
                "
                :fixed="col.fixed !== undefined ? col.fixed : undefined"
              >
                <!-- 新增表头插槽 -->
                <template #header v-if="col.slotHeader">
                  <slot :name="col.slotHeader"></slot>
                </template>

                <template #default="scope">
                  <slot
                    v-if="col.slot && col.istrue"
                    :name="col.slot"
                    :row="scope.row"
                    :index="scope.$index"
                  ></slot>
                  <!-- a -->
                  <a
                    style="color: #0080ff"
                    v-if="col.type === 'Text'"
                    @click="handlerTypeText(scope.row)"
                    >{{ scope.row[col.prop] }}</a
                  >
                  <!-- html -->
                  <span
                    v-if="col.type === 'Html'"
                    v-html="col.html(scope.row)"
                  ></span>
                  <!-- 按钮 -->
                  <div v-if="col.type === 'Button'" class="but_sty_flex">
                    <div v-for="(btn, index) in col.btnList" :key="index">
                      <template v-if="btn.type">
                        <el-button
                          :type="btn.type"
                          :size="btn.size || size"
                          :icon="btn.icon"
                          :class="btn.className"
                          @click="btn.handle?.(scope.row, scope.$index)"
                        >
                          <template v-if="btn.setValue">
                            <slot
                              :name="btn.setValue"
                              :row="scope.row"
                            ></slot>
                          </template>
                          <p v-else style="padding: 0 5px">{{ btn.label }}</p>
                        </el-button>
                      </template>
                      <template v-else>
                        <div
                          @click="btn.handle?.(scope.row, scope.$index)"
                          class="btn_item"
                        >
                          <template v-if="btn.setValue">
                            <slot
                              :name="btn.setValue"
                              :row="scope.row"
                            ></slot>
                          </template>
                          <template v-else>{{ btn.label }}</template>
                        </div>
                      </template>
                    </div>
                  </div>
                  <!-- 输入框 -->
                  <el-input
                    v-if="col.type === 'Input'"
                    v-model.trim="scope.row[col.prop]"
                    :size="size"
                    :placeholder="col.placeholder || '请填写'"
                    :maxlength="col.maxlength"
                    :disabled="col.disabled && scope.row.disabled"
                    @focus="col.focus && col.focus(scope.row, scope.$index)"
                    @blur="col.blur && col.blur(scope.row, scope.$index)"
                  ></el-input>
                  <!-- 下拉框 -->
                  <el-select
                    v-if="col.type === 'Select'"
                    v-model="scope.row[col.prop]"
                    :size="size"
                    :clearable="col.clearable"
                    :multiple="col.multiple"
                    :disabled="
                      col.disabled && col.hasDisabled(scope.row, scope.$index)
                    "
                    :filterable="col.filterable"
                    :class="`id${scope.$index}`"
                    @change="
                      col.change && col.change(scope.row, scope.$index)
                    "
                    style="width: 100%"
                  >
                    <el-option
                      v-for="op in col.options"
                      :props="col.props"
                      :label="op[col.props.label]"
                      :value="op[col.props.value]"
                      :key="op[col.props.value]"
                    ></el-option>
                  </el-select>
                  <!-- 分组下拉框 -->
                  <el-select
                    v-if="col.type === 'SelectGroup'"
                    v-model="scope.row[col.prop]"
                    :filterable="col.filterable"
                    :clearable="col.clearable"
                    :multiple="col.multiple"
                    :size="size"
                    collapse-tags
                    :class="`id${scope.$index}`"
                    @change="col.change && col.change(scope.row)"
                    style="width: 100%"
                  >
                    <el-option-group
                      v-for="op in col.options"
                      :key="op[col.props.value]"
                      :label="op[col.props.label]"
                    >
                      <el-option
                        v-for="val in op.children"
                        :key="val[col.props.value]"
                        :label="val[col.props.label]"
                        :value="val[col.props.value]"
                      >
                      </el-option>
                    </el-option-group>
                  </el-select>
                  <!-- 评价 -->
                  <el-rate
                    v-if="col.type === 'Rate'"
                    v-model="scope.row[col.prop]"
                    :disabled="col.isDisabled && col.isDisabled(scope.row)"
                    @change="col.change && col.change(scope.row)"
                  ></el-rate>
                  <!-- 开关 -->
                  <el-switch
                    v-if="col.type === 'Switch'"
                    v-model="scope.row[col.prop]"
                    active-color="#17C3E6"
                    inactive-color="#ccc"
                    :disabled="col.isDisabled && col.isDisabled(scope.row)"
                    @change="col.change && col.change(scope.row)"
                  ></el-switch>
                  <!-- 单张图片预览 -->
                  <span v-if="col.type === 'Image'">
                    <el-image
                      style="max-width: 30px; max-height: 30px"
                      :src="scope.row[col.prop]"
                      :preview-src-list="[scope.row[col.prop]]"
                    >
                    </el-image>
                  </span>
                  <!-- 多张图片预览 -->
                  <span>
                    <el-image
                      style="width: 23px; height: 25px"
                      v-if="col.type === 'ImageArr'"
                      :src="scope.row[col.prop]"
                      :preview-src-list="guidePic"
                      @click.stop="handlerImage(scope.row)"
                    ></el-image>
                  </span>
                  <!-- 滑块 -->
                  <el-slider
                    v-if="col.type === 'Slider'"
                    v-model="scope.row[col.prop]"
                    :disabled="col.isDisabled && col.isDisabled(scope.row)"
                    @change="col.change && col.change(scope.row)"
                  ></el-slider>
                  <!-- 默认 -->
                  <span
                    v-if="!col.type && !col.slot"
                    :style="col.itemStyle && col.itemStyle(scope.row)"
                    :class="col.itemClass && col.item.itemClass(scope.row)"
                    >{{
                      (col.formatter && col.formatter(scope.row)) ||
                      scope.row[col.prop] ||
                      "-"
                    }}</span
                  >
                </template>
              </el-table-column>
            </el-table-column>
          </template>
          <!-- 一级表头 -->
          <el-table-column
            v-else
            :key="index"
            :prop="item.prop"
            :sortable="item.sortable"
            :label="item.label"
            :min-width="item.width"
            :align="item.align"
            :render-header="item.require ? renderHeader : null"
            :show-overflow-tooltip="
              item.showOverflowTooltip !== undefined
                ? item.showOverflowTooltip
                : true
            "
            :fixed="item.fixed !== undefined ? item.fixed : undefined"
          >
            <!-- 新增表头插槽 -->
            <template #header v-if="item.slotHeader">
              <slot :name="item.slotHeader"></slot>
            </template>
            <template #default="scope">
              <slot
                v-if="item.slot && item.istrue"
                :name="item.slot"
                :row="scope.row"
                :index="scope.$index"
              ></slot>
              <!-- a -->
              <a
                style="color: #0080ff"
                v-if="item.type === 'Text'"
                @click="handlerTypeText(scope.row)"
                >{{ scope.row[item.prop] }}</a
              >
              <!-- html -->
              <span
                v-if="item.type === 'Html'"
                v-html="item.html(scope.row)"
              ></span>
              <!-- 按钮 -->
              <div v-if="item.type === 'Button'" class="but_sty_flex">
                <div v-for="(btn, index) in item.btnList" :key="index">
                  <template v-if="btn.type">
                    <el-button
                      :type="btn.type"
                      :size="btn.size || size"
                      :icon="btn.icon"
                      :class="btn.className"
                      @click="btn.handle?.(scope.row, scope.$index)"
                    >
                      <template v-if="btn.setValue">
                        <slot :name="btn.setValue" :row="scope.row"></slot>
                      </template>
                      <p v-else style="padding: 0 5px">{{ btn.label }}</p>
                    </el-button>
                  </template>
                  <template v-else>
                    <div
                      @click="btn.handle?.(scope.row, scope.$index)"
                      class="btn_item"
                    >
                      <template v-if="btn.setValue">
                        <slot :name="btn.setValue" :row="scope.row"></slot>
                      </template>
                      <template v-else>{{ btn.label }}</template>
                    </div>
                  </template>
                </div>
              </div>
              <!-- 输入框 -->
              <el-input
                v-if="item.type === 'Input'"
                v-model.trim="scope.row[item.prop]"
                :size="size"
                :placeholder="item.placeholder || '请填写'"
                :maxlength="item.maxlength"
                :disabled="item.disabled && scope.row.disabled"
                @focus="item.focus && item.focus(scope.row, scope.$index)"
                @blur="item.blur && item.blur(scope.row, scope.$index)"
              ></el-input>
              <!-- 下拉框 -->
              <el-select
                v-if="item.type === 'Select'"
                v-model="scope.row[item.prop]"
                :size="size"
                :clearable="item.clearable"
                :multiple="item.multiple"
                :disabled="
                  item.disabled && item.hasDisabled(scope.row, scope.$index)
                "
                :filterable="item.filterable"
                :class="`id${scope.$index}`"
                @change="item.change && item.change(scope.row, scope.$index)"
                style="width: 100%"
              >
                <el-option
                  v-for="op in item.options"
                  :props="item.props"
                  :label="op[item.props.label]"
                  :value="op[item.props.value]"
                  :key="op[item.props.value]"
                ></el-option>
              </el-select>
              <!-- 分组下拉框 -->
              <el-select
                v-if="item.type === 'SelectGroup'"
                v-model="scope.row[item.prop]"
                :filterable="item.filterable"
                :clearable="item.clearable"
                :multiple="item.multiple"
                :size="size"
                collapse-tags
                :class="`id${scope.$index}`"
                @change="item.change && item.change(scope.row)"
                style="width: 100%"
              >
                <el-option-group
                  v-for="op in item.options"
                  :key="op[item.props.value]"
                  :label="op[item.props.label]"
                >
                  <el-option
                    v-for="val in op.children"
                    :key="val[item.props.value]"
                    :label="val[item.props.label]"
                    :value="val[item.props.value]"
                  >
                  </el-option>
                </el-option-group>
              </el-select>
              <!-- 评价 -->
              <el-rate
                v-if="item.type === 'Rate'"
                v-model="scope.row[item.prop]"
                :disabled="btn.isDisabled && btn.isDisabled(scope.row)"
                @change="item.change && item.change(scope.row)"
              ></el-rate>
              <!-- 开关 -->
              <el-switch
                v-if="item.type === 'Switch'"
                v-model="scope.row[item.prop]"
                active-color="#17C3E6"
                inactive-color="#ccc"
                :disabled="btn.isDisabled && btn.isDisabled(scope.row)"
                @change="item.change && item.change(scope.row)"
              ></el-switch>
              <!-- 单张图片预览 -->
              <span v-if="item.type === 'Image'">
                <el-image
                  style="max-width: 30px; max-height: 30px"
                  :src="scope.row[item.prop]"
                  :preview-src-list="[scope.row[item.prop]]"
                >
                </el-image>
              </span>
              <!-- 多张图片预览 -->
              <span>
                <el-image
                  style="width: 23px; height: 25px"
                  v-if="item.type === 'ImageArr'"
                  :src="scope.row[item.prop]"
                  :preview-src-list="guidePic"
                  @click.stop="handlerImage(scope.row)"
                ></el-image>
              </span>
              <!-- 滑块 -->
              <el-slider
                v-if="item.type === 'Slider'"
                v-model="scope.row[item.prop]"
                :disabled="btn.isDisabled && btn.isDisabled(scope.row)"
                @change="item.change && item.change(scope.row)"
              ></el-slider>
              <!-- 默认 -->
              <span
                v-if="!item.type && !item.slot"
                :style="item.itemStyle && item.itemStyle(scope.row)"
                :class="item.itemClass && item.item.itemClass(scope.row)"
                >{{
                  (item.formatter && item.formatter(scope.row)) ||
                  scope.row[item.prop] ||
                  "-"
                }}</span
              >
            </template>
          </el-table-column>
        </template>
      </el-table>
    </section>
    <section class="ces-pagination" v-if="isPagination">
      <Pagination
        :total="pagination.total"
        :page="pagination.pageNum"
        :limit="pagination.pageSize"
        @pagination="handPagination"
      />
    </section>
  </section>
</template>

<script>
  import Pagination from "../pagination";

  export default {
    components: {
      Pagination,
    },
    data() {
      return {
        selectRadioValaue: "",
        isShowTips: false,
      };
    },
    props: {
      // 表格型号:mini,medium,small
      size: { type: String, default: "medium" },
      //表格设置边框
      isBorder: { type: Boolean, default: false },
      //表格加载loading
      loading: { type: Boolean, default: false },
      // 表格操作
      isHandle: { type: Boolean, default: false },
      //表格新增按钮
      tableHandles: { type: Array, default: () => [] },
      // 表格数据
      tableData: { type: Array, default: () => [] },
      // 表格列配置
      tableCols: { type: Array, default: () => [] },
      // 是否显示表格复选框
      isSelection: { type: Boolean, default: false },
      // 是否显示表格单选框
      isSelectRadio: { type: Boolean, default: false },
      defaultSelections: { type: [Array, Object], default: () => null },
      // 是否显示表格索引
      isIndex: { type: Boolean, default: true },
      //是否显示序号
      indexLabel: { type: String, default: "序号" },
      //是否显示复选框已勾选
      isDisableCheckbox: { type: Boolean, default: true },
      // 是否显示分页
      isPagination: { type: Boolean, default: true },
      // 分页数据
      pagination: {
        type: Object,
        default: () => ({ pageNum: 1, pageSize: 10, total: 0 }),
      },
      guidePic: {
        //图片接受的存储地址
        type: Array,
        default: () => [],
      },
      tableHeight: {
        //表格高度
        type: String,
        default: "385",
      },
      rowKey: {
        //树形需要指定row-key
        type: String,
      },
      showHeader: {
        //默认显示表头
        type: Boolean,
        default: true,
      },
      // 表格行key
      keyDom: {
        type: Number,
        default: 0,
      },
      // 表头设置
      columObj: {
        type: Object,
      },
      // 是否选中复选框
      isSelectCheckbox: {
        type: Boolean,
        default: false,
      },
      // 是否设置表格背景颜色
      setBcColor: {
        type: Boolean,
        default: false,
      },
    },
    methods: {
      // 表格背景颜色设置
      handleCellStyle({ row, column, rowIndex, columnIndex }) {
        if (this.setBcColor) {
          if (row.dataProblemType === "1") {
            row.isShowTips = true;
            return "background:rgb(255, 166, 0, 0.8)";
          }
        }
      },
      /**
       * 表格单选处理函数
       * 当选择多行时自动取消之前的选择,保持单选效果
       * @param {Array} rows - 当前选中的行数组
       * @param {Object} row - 最新选中的行对象
       * @emits radioSelect - 触发单选选择事件,传递选中行信息
       */
      checkboxSelect(rows, row) {
        // 表格单选
        this.$emit("radioSelect", rows, row);
      },
      selectAll(rows) {
        // 全选
        this.$emit("selectAll", rows);
      },
      // 表头带红星
      renderHeader(h, { column }) {
        return [
          h("span", { style: "color: red" }, "*"),
          h("span", " " + column.label),
        ];
      },
      headClass() {
        //设置表头颜色
        return "background:#F5F6F7";
      },
      handlerTypeText(row) {
        //点击文本类型
        this.$emit("clickTypeText", row);
      },
      handleSelectionChange(val) {
        //多选的值
        this.$emit("selectionChange", val);
      },
      handlerImage(row) {
        //点击图片
        this.$emit("imageEvent", row);
      },
      changeSelectRadioVal(row) {
        //单选的值
        this.$emit("SelectRadioVal", row);
      },
      handleRowItem(row) {
        //点击表格某一行
        this.$emit("eventTableRowItem", row);
      },
      handPagination(obj) {
        //分页事件
        this.$emit("eventPagination", obj);
      },
      expandChange(row, expandedRows) {
        this.$emit("eventToggleRowExpansion", row, expandedRows);
      },
      handleSortChange({ column, prop, order }) {
        this.$emit("handleSortChange", { column, prop, order });
      },
      // 选中行背景色
      handleRowClassName({ row }) {
        if (row.isHight === true) {
          return "bgc_hight";
        }
      },
      // 默认勾选复选框
      toggleSelection(rows) {
        if (!rows) return;
        rows.forEach((row) => {
          this.$nextTick(() => {
            this.$refs.cesTable?.toggleRowSelection(row, true);
          });
        });
      },
      // 清空选中
      clearSelection() {
        this.$nextTick(() => {
          this.$refs.cesTable?.clearSelection();
        });
      },
      getHeadTop() {
        this.$nextTick(() => {
          this.$refs.cesTable?.doLayout();
        });
      },
      // type序号 - 页面切换递增
      indexAdd(index) {
        const page = this.pagination.pageNum; // 当前页码
        const pagesize = this.pagination.pageSize; // 每页条数
        return index + 1 + (page - 1) * pagesize;
      },
      // 鼠标移入一行中
      enterSelectionRows(row, column, cell, event) {
        if (row.isShowTips && column.property) {
          this.createTips(
            event,
            row,
            "因模板更新导致该事项审批流程节点被删除,请及时更新"
          );
        }
      },
      // 鼠标移出一行中
      leaveSelectionRows(row, column, cell, event) {
        if (row.isShowTips) {
          this.removeTips(row);
        }
      },
      // 创建toolTip
      createTips(el, row, value) {
        const { matterId } = row;
        const tooltipDom = document.createElement("div");
        tooltipDom.style.cssText = `
        display: inline-block;
        max-width: 400px;
        max-height: 400px;
        position: absolute;
        top: ${el.clientY + 5}px;
        left: ${el.clientX}px;
        padding:5px 10px;
        overflow: auto;
        font-size: 12px;
        font-family: PingFangSC-Regular, PingFang SC;
        font-weight: 400;
        color: rgb(255, 166, 0, 0.8);
        background: #000;
        border-radius: 5px;
        z-index: 19999;
        box-shadow: 0 4px 12px 1px #ccc;
      `;
        tooltipDom.innerHTML = value;
        tooltipDom.setAttribute("id", `tooltip-${matterId}`);
        // 将浮层插入到body中
        document.body.appendChild(tooltipDom);
      },
      // 删除tooltip
      removeTips(row) {
        const { matterId } = row;
        const tooltipDomLeave = document.querySelectorAll(
          `#tooltip-${matterId}`
        );
        if (tooltipDomLeave.length) {
          tooltipDomLeave.forEach((dom) => {
            document.body.removeChild(dom);
          });
        }
      },
    },
    directives: {
      // 自动获取光标
      focus: {
        inserted: function (el) {
          el.querySelector("input").focus();
        },
      },
    },
    watch: {
      tableData: {
        handler(newV, oldV) {
          if (this.isSelectCheckbox) {
            // 默认选中
            this.toggleSelection(newV);
          }
        },
        deep: true,
      },
    },
    mounted() {
      this.getHeadTop(); //表格高度
    },
  };
</script>

<style lang="less" scoped>
  .hiddenCheck {
    display: none;
  }
  .ces-handle {
    text-align: left;
    margin-bottom: 10px;
  }
  .but_sty_flex {
    display: flex;
    justify-content: center;
    align-items: center;
    .btn_item {
      cursor: pointer;
      color: #1890ff;
    }
  }
  .ces-table-require::before {
    content: "*";
    color: red;
  }
  .ces-pagination {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    padding-bottom: 20px;
    padding-right: 20px;
  }

  // 高亮选中行背景色
  :v-deep(.el-table tr) {
    &.bgc_hight {
      background-color: #1d528f !important;
    }
  }
  .ces-table {
    ::-webkit-scrollbar {
      width: 10px;
      height: 10px;
    }
    /*外层轨道。可以用display:none让其不显示,也可以添加背景图片,颜色改变显示效果*/
    ::-webkit-scrollbar-track {
      width: 6px;
      // background-color: #fff;
      -webkit-border-radius: 2em;
      -moz-border-radius: 2em;
      border-radius: 2em;
    }
    /*滚动条的设置*/
    ::-webkit-scrollbar-thumb {
      background-clip: padding-box;
      min-height: 28px;
      -webkit-border-radius: 2em;
      -moz-border-radius: 2em;
      border-radius: 2em;
    }
    /deep/ .el-table__fixed::before {
      background-color: transparent !important;
    }

    /deep/ .el-table__fixed-right::before,
    .el-table__fixed::before {
      background-color: transparent !important;
    }

    /deep/ .el-table__fixed {
      height: auto !important;
      bottom: 10px !important;
    }

    /deep/ .el-table__fixed-right {
      height: auto !important;
      bottom: 10px !important;
    }
  }
</style>

2.创建一个pagination分页文件,里面建一个index.vue,存放分页组件

<template>
  <div :class="{ hidden: hidden }" class="pagination-container clearfix">
    <el-pagination class="fr" :background="background" :small="small"
      :current-page.sync="currentPage" :page-size.sync="pageSize" :layout="layout"
      :page-sizes="pageSizes" :total="total" v-bind="$attrs" @size-change="handleSizeChange"
      @current-change="handleCurrentChange" />
  </div>
</template>

<script>
import { scrollTo } from "@/utils/scroll-to";
export default {
  name: "Pagination",
  props: {
    total: {
      required: true,
      type: Number,
    },
    page: {
      type: Number,
      default: 1,
    },
    limit: {
      type: Number,
      default: 20,
    },
    pageSizes: {
      type: Array,
      default () {
        return [10, 20, 50, 100];
      },
    },
    layout: {
      type: String,
      default: "total, sizes, prev, pager, next, jumper",
    },
    background: {
      type: Boolean,
      default: true,
    },
    autoScroll: {
      type: Boolean,
      default: true,
    },
    hidden: {
      type: Boolean,
      default: false,
    },
    small: {
      type: Boolean,
      default: false,
    },
  },
  computed: {
    currentPage: {
      get () {
        return this.page;
      },
      set (val) {
        this.$emit("update:page", val);
      },
    },
    pageSize: {
      get () {
        return this.limit;
      },
      set (val) {
        this.$emit("update:limit", val);
      },
    },
  },
  methods: {
    handleSizeChange (val) {
      this.$emit("pagination", { page: this.currentPage, limit: val });
      if (this.autoScroll) {
        scrollTo(0, 800);
      }
    },
    handleCurrentChange (val) {
      this.$emit("pagination", { page: val, limit: this.pageSize });
      if (this.autoScroll) {
        scrollTo(0, 800);
      }
    },
  },
};
</script>

<style lang="less" scoped>
.pagination-container {
  /* padding: 25px 0; */
  display: flex;
  justify-content: flex-end;
  align-items: center;
  :v-deep(.el-input__inner) {
    background-color: #0e2b55;
    border: 1px solid #409eff;
    color: #fff;
  }
  :v-deep(.btn-prev) {
    background-color: transparent;
    color: #409eff;
  }
  :v-deep(.btn-next) {
    background-color: transparent;
    color: #409eff;
  }
  :v-deep(.el-pager li) {
    background-color: transparent;
    color: #fff;
  }
  :v-deep(.el-pagination.is-background .el-pager li:not(.disabled).active) {
    // width: 28px !important;
    height: 28px !important;
    background: #409eff;
    text-align: center;
    line-height: 28px;
    border-radius: 50%;
  }
}
.pagination-container.hidden {
  display: none;
}
:v-deep(.el-pagination__jump) {
  color: #fff;
}
</style>

3.在utils文件夹中建立一个scroll-to.js文件

Math.easeInOutQuad = function(t, b, c, d) {
  t /= d / 2
  if (t < 1) {
    return c / 2 * t * t + b
  }
  t--
  return -c / 2 * (t * (t - 2) - 1) + b
}

// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts
var requestAnimFrame = (function() {
  return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) }
})()

/**
 * Because it's so fucking difficult to detect the scrolling element, just move them all
 * @param {number} amount
 */
function move(amount) {
  document.documentElement.scrollTop = amount
  document.body.parentNode.scrollTop = amount
  document.body.scrollTop = amount
}

function position() {
  return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop
}

/**
 * @param {number} to
 * @param {number} duration
 * @param {Function} callback
 */
export function scrollTo(to, duration, callback) {
  const start = position()
  const change = to - start
  const increment = 20
  let currentTime = 0
  duration = (typeof (duration) === 'undefined') ? 500 : duration
  var animateScroll = function() {
    // increment the time
    currentTime += increment
    // find the value with the quadratic in-out easing function
    var val = Math.easeInOutQuad(currentTime, start, change, duration)
    // move the document.body
    move(val)
    // do the animation unless its over
    if (currentTime < duration) {
      requestAnimFrame(animateScroll)
    } else {
      if (callback && typeof (callback) === 'function') {
        // the animation is done so lets callback
        callback()
      }
    }
  }
  animateScroll()
}

4.页面使用组件的方法(可以使用插槽)

      <CompreTable
        tableHeight="614"
        :loading="loadingObj.tableLoading"
        :tableData="tableData"
        :tableCols="tableCols"
        :pagination="pagerParams"
        @eventPagination="eventPagination"
      >
        <template #isHasTerm="{ row }">
          <div class="flex_a_c">
            <el-button
              type="text"
              class="pl_5"
              @click="handlerTerm(row)"
              :disabled="row.setStatus === 'STOP'"
            >
              终止
            </el-button>
          </div>
        </template>
        <template #isHasEdit="{ row }">
          <div class="flex_a_c">
            <el-button
              type="text"
              class="pl_5"
              @click="hanlderEdit(row)"
              :disabled="row.setStatus === 'STOP'"
            >
              编辑
            </el-button>
          </div>
        </template>
      </CompreTable>

5.表格组件中使用到的js

  export default {
    data() {
      return {
        loadingObj: {
          tableLoading: false,
        },
        pagerParams: {
          pageNum: 1,
          pageSize: 10,
          total: 0,
        },
        tableData: [],
        tableCols: [
          {
            label: "所属公司",
            prop: "companyName",
            align: "center",
            width: 140,
          },
          {
            label: "对接系统",
            prop: "businessSystemName",
            align: "center",
            width: 120,
          },
          {
            label: "对接系统属主",
            prop: "businessSystemOwner",
            align: "center",
            width: 120,
          },
          {
            label: "是否纳入推广",
            prop: "isPromotionDesc",
            align: "center",
            width: 120,
          },
          {
            label: "系统供应商",
            prop: "systemSupplier",
            align: "center",
            width: 120,
          },
          {
            label: "对接人邮箱",
            prop: "contactPersonEmail",
            align: "center",
            width: 130,
          },
          {
            label: "对接人电话",
            prop: "contactPersonPhone",
            align: "center",
            width: 120,
          },
          {
            label: "操作人",
            prop: "operationUserName",
            align: "center",
            width: 80,
          },
          {
            label: "操作时间",
            prop: "operationTime",
            align: "center",
            width: 80,
            formatter: (row) => {
              return row.operationTime
                ? moment(row.operationTime).format("YYYY-MM-DD")
                : "-";
            },
          },
          {
            label: "终止人",
            prop: "terminateUserName",
            align: "center",
            width: 80,
          },
          {
            label: "终止时间",
            prop: "terminateTime",
            align: "center",
            width: 80,
            formatter: (row) => {
              return row.terminateTime
                ? moment(row.terminateTime).format("YYYY-MM-DD")
                : "-";
            },
          },
          {
            label: "操作",
            type: "Button",
            align: "center",
            width: 85,
            btnList: [
              {
                setValue: "isHasTerm",
              },
              {
                setValue: "isHasEdit",
              },
            ],
          },
        ],
      };
    },
    methods: {
     
      /**
       * 处理事件分页
       * @param {Object} params - 分页参数对象
       * @param {number} params.page - 当前页码
       * @param {number} params.limit - 每页显示条数
       */
      eventPagination({ page, limit }) {
        this.pagerParams.pageNum = page;
        this.pagerParams.pageSize = limit;
        this.getList();
      },
      async getList() {
        this.loadingObj.tableLoading = true;
        const data = {
          ...this.searchData,
          ...this.pagerParams,
          setType: this.setType,
        };
        if (this.setType == "2") {
          delete data.companyIdList;
          delete data.systemSupplier;
        }
        const res = await this.$QueryService.fetch({
          url: "/api/al/publicPromotionSet/pageList",
          methods: "post",
          data: JSON.stringify(data),
          headers: {
            "Content-Type": "application/json",
          },
        });
        if (res.code === "1") {
          if (res?.result?.list && res?.result?.list.length > 0) {
            const data = res?.result?.list;
            this.tableData = data;
            this.pagerParams.total = res.result.total;
          } else {
            this.tableData = [];
            this.pagerParams.total = 0;
          }
        } else {
          this.$message.error(res.message);
        }
        this.loadingObj.tableLoading = false;
      },

      handlerTerm(row) {

      },

      hanlderEdit(row) {

      },
    },
  };

6.多级表格只显示内容的示例,不可编辑

tableCols:[
    {
        label: "工作信息",
        align: "center",
        isMultiHeader:true,
        children: [
            {
                prop: "字段名",
                label: "部门",
                width: "120",
                align: "center",
            },
            {
                prop: "字段名",
                label: "职位",
                width: "120",
                align: "center",
            },
            {
                prop: "字段名",
                label: "薪资",
                width: "100",
                align: "center"
            },
        ],
    },
]

7.多级表格支持input,select,switch等,可编辑操作示例

tableCols:[
    {
        label: "工作信息",
        align: "center",
        isMultiHeader:true,
        children: [
            {
                prop: "字段名",
                label: "部门",
                width: "120",
                align: "center",
                type:"Input"
            },
            {
                prop: "字段名",
                label: "职位",
                width: "120",
                align: "center",
                type:"Select",
                props:{
                  label:"label",
                  value:"value"
                },
                options: [
                  {
                    label: "前端开发",
                    value: "1",
                  },
                  {
                    label: "后端开发",
                    value: "2",
                  },
                ]
            },
            {
                prop: "字段名",
                label: "薪资",
                width: "100",
                align: "center",
                type:"Switch",
            },
        ],
    },
]

JitWord 2.3: 墨定,行远

作者 徐小夕
2026年3月5日 17:22

今天,我们宣布推出 JitWord AI文档 2.3版本。

图片

在持续两年的研究和技术难点攻克下,我们取得了如下成果:

  • 实现了高效的Word在线协同编辑能力
  • 实现了高效的Excel在线协同编辑能力
  • 实现了业内领先的Docx/PDF高精度导入导出能力
  • 实现了Office办公套件的嵌入和预览(Word,PDF, PPT, Excel)
  • 实现了国产化环境兼容适配(本地部署安全可靠)
  • 支持多端适配和编辑(PC,移动, IPad等设备)
  • 兼容市面上所有主流AI模型,并研发设计了AI Native组件,全面打造智慧办公场景
  • 多模态能力(图文/音视频/思维导图/图表/电子签名等)
  • 实现了高性能文档渲染引擎(支持50W字超大文档渲染,目前还在持续优化)
  • 实现了复杂的数学公式渲染引擎(支持导出为word可编辑的公式)

 当然我们的目标是全面对齐 Office,并基于AI Native 的设计理念,打造国产化的 AI Office 解决方案。同时我们是开源了一个基础版的SDK(v1.0版本),供大家直接本地调用:

图片我们的目标是为全球的科研人员、企业和组织赋能,帮助他们利用我们的前沿解决方案和AI能力构建安全可靠,符合企业自身需求的创新协同AI办公解决方案。

github地址: github.com/MrXujiang/j…

体演示地址:jitword.com

接下来我就和大家分享一下 JitWord 2.3 版本带来的新功能。

一、电子签名功能:数字化时代的"最后一公里"

去年我们团队在做用户调研时,遇到一个令人意外的场景。

某建筑设计公司的项目经理李哥向我们吐槽:"我们用你们的 jitword 写方案、改图纸备注都很爽,但每到签合同环节,就得全部打印出来,手写签字,再扫描回传。一套流程下来,半天没了,纸摞得比字典还厚。"

这番话让我们愣住了。

在 All-in-Digital 的今天,我们实现了云端协作、AI辅助写作、多人实时编辑,却在最原始的"确认"环节卡了壳。

电子签名这个看似简单的功能,成了文档数字化流程中的"断点"。

更让我们震惊的是数据:据我们抽样调研,73%的企业用户仍在使用"打印-签字-扫描"的传统模式处理合同和确认文件,平均每周浪费4.6小时在这类机械操作上。

这不是技术问题,这是体验设计的失职。

那一刻我们决定:JitWord 2.3 必须解决这个"最后一公里"问题。而且,不能做成简单的图片贴入,要让它真正可用、好用、让人愿意用

于是我们研发并上线了电子签名组件:

图片

大家可以在 jitword 编辑器的插入分类下使用电子签名,插入到文档的效果如下:

图片

我们在用户体验和界面设计上做了大量的优化,保证用户能以最好的体验使用这个功能。

大家可以在文档的任何位置插入电子签名,并且能一键导出为PDF和Docx文件,直接用于合同等场景的打印流程:

图片

二、分栏布局:回归文档的"阅读本质"

图片

如果说电子签名解决的是"出口"问题,那么分栏功能解决的就是"呈现"问题。

2.1 为什么传统文档编辑器"不好看"?

长期以来,Web文档编辑器有个通病:它们更像是"网页"而不是"文档"。单栏通顶的布局适合屏幕阅读,但一旦需要打印成册、制作手册、设计简报,就显得笨拙不堪。

我们观察到一个趋势:越来越多的用户把jitword当作轻量级排版工具使用。市场部门做产品手册,教研组编试卷,律师团队整理证据目录...他们不需要InDesign的专业,但Word的分栏功能又总让他们在Web和桌面软件之间来回切换。

2.2 技术实现:浏览器里的"排版引擎"

分栏功能看似简单,在Web技术栈里却是个硬骨头。

浏览器的流式布局天生是单栏的,要实现像LaTeX那样的专业分栏,需要重写文本流算法。我们团队花了两个月时间,基于CSS Columns规范做了深度定制:

  • 智能断栏:避免标题孤行、段落割裂,确保阅读连贯性
  • 图文混排:图片跨栏、文字环绕的像素级精准控制
  • 动态平衡:根据内容长度自动调整栏高,告别"最后一栏空荡荡"
  • 打印还原:屏幕所见即打印所得,解决Web文档打印走样的顽疾

特别值得一提的是分栏协同编辑的难点。当两个用户同时编辑不同分栏的内容时,光标定位、选区计算、冲突合并的复杂度呈指数级上升。

图片

我们重构了底层的 CRDT 算法,确保分栏场景下的协同体验与单栏一样流畅。

2.3 应用场景:从"能用"到"好用"的跃迁

现在,jitword 的分栏功能已经成为一些用户的"秘密武器":

  • 教育行业:老师用双栏排版制作试卷,左栏题干右栏答题区,直接导出印刷
  • 法律行业:律师用三栏整理证据清单,证据编号、内容摘要、页码索引一目了然
  • 市场营销:运营用混栏设计制作产品白皮书,图文穿插,专业度不输设计公司

三、表格多人协同:复杂数据的"共舞"方案

图片

3.1 被低估的协同场景

表格,是文档中最复杂的数据结构,也是协同编辑的"雷区"。

传统方案要么采用"锁定整表"的保守策略(一个人改,其他人看),要么"自由混战"(最后保存的人覆盖一切)。前者效率低下,后者数据灾难。

在 jitword 2.3中,我们实现了单元格级细粒度协同——这是技术架构上的重大突破。

3.2 技术架构:从"文档"到"数据"的视角转换

要实现真正的表格协同,必须改变底层思维:把表格不再视为"文档的一部分",而是视为嵌入式数据库

我们的技术方案包含三个层次:

第一,结构层解耦。  表格的每个单元格都是独立的数据对象,拥有唯一的CRDT(无冲突复制数据类型)标识。这意味着A用户在改A1单元格,B用户在改B2单元格,两者完全隔离,互不阻塞。

第二,冲突层智能。  当两人同时修改同一单元格时,系统不是简单"后覆盖前",而是基于语义合并策略:如果是数值,做算术合并;如果是文本,做差异对比;如果是公式,重新计算依赖链。冲突解决过程可视化呈现,用户可选择接受哪个版本或手动合并。

第三,感知层细腻。  我们设计了"单元格 occupancy"机制:当某人正在编辑某单元格,该单元格边缘会显示其头像呼吸灯,其他人点击时会收到友好提示"某某正在编辑,是否加入协作?"。这种"软阻塞"既避免了冲突,又保留了灵活性。

3.3 真实场景:一场没有"等等我"的会议

想象一下这个场景:周五下午,财务、销售、运营三个部门负责人要赶在下班前确认Q3预算表。以前,他们需要:

  1. 各自填好Excel分表
  2. 发给财务汇总
  3. 发现数据对不上,群里@来@去
  4. 修改,再发,再核对...
  5. 三小时后,终于搞定

现在,大家在 jitword 中打开同一张表格,各自在自己负责的栏目实时填写,公式自动计算,批注即时可见,有疑问直接@相关人在单元格内讨论。20分钟,预算表确认完毕,直接签名定稿。

这不是未来场景,这是 jitword 2.3 用户的日常。

四、价值重构:我们到底在做什么?

写到这里,我想停下来回答一个根本问题:jitword 2.3的这三个功能,到底创造了什么价值?

效率价值:时间的复利

电子签名节省的"打印-签字-扫描"流程,按每次30分钟、每周3次计算,一年就是78小时,相当于10个工作日;

分栏功能节省的软件切换和格式调整时间;

表格协同节省的汇总核对时间...这些碎片时间积累起来,是组织效率的复利增长。

体验价值:心流的守护

更重要的是认知成本的降低。当工具不再打断你的工作流——不需要为了签个字打开另一个系统,不需要为了排个版导出到另一个软件,不需要为了合个表发无数封邮件——你就能保持专注,进入心流状态。这种"不卡顿"的体验,是数字化办公的稀缺品。

信任价值:数字的确定性

电子签名的法律效力、协同编辑的版本可追溯、分栏排版的所见即所得,共同构建了一种数字确定性

在远程办公常态化的今天,这种确定性是团队协作的基石。我们知道谁在什么时候做了什么修改,我们知道这份文件被谁确认过,我们知道打印出来和屏幕上看到的一样——这些"知道",就是信任。

写在最后

我们团队的一个共识:最好的技术,是让人感受不到技术的存在,却能感受到人的温度。

电子签名的笔迹,是承诺的温度;分栏排版的精致,是专业的温度;表格协同的流畅,是协作的温度。

JitWord 2.3 不是功能的堆砌,是我们对"文档应该是什么样"的持续思考。在这个AI重构一切的时代,我们选择先做好人与文档、人与人之间的连接

如果大家也厌倦了工具的割裂、流程的繁琐、协作的摩擦,欢迎体验 jitword 。我们相信,好的工具,会让你重新爱上工作本身。


关于JitWord

JitWord 是面向企业的下一代协同AI文档平台,致力于让文档创作更智能、协作更流畅、知识更有序。

2.3版本现已全面上线,访问官网即可体验电子签名、分栏排版、表格协同等全新功能。

【性能优化篇】面对万行数据也不卡顿?揭秘协同服务器的“片段机制 (Fragments)”

2026年3月5日 17:19

在实时协同的领域,流传着这样一句话:

“小文档看算法,大文档看架构。”

当我们在处理只有几百行数据的简单表格时,任何协同方案看起来都行云流水。但对于金融、制造或大型零售企业来说,Excel 往往承载着成千上万行数据、数百个工作表以及错综复杂的公式引用。在这种“巨型文档”面前,传统的协同架构往往会遭遇性能天花板:由于每次操作都要读写完整的文档快照(Snapshot),服务器 I/O 会不堪重负,用户侧则会感受到明显的指令延迟甚至卡顿。

在这里插入图片描述

作为系列文章的第四篇,我们将深入 SpreadJS 协同服务器的底层性能核心,为你揭秘专门为处理超大规模协作而设计的**“片段机制(Fragments)”**。

一、 传统模式的瓶颈:巨型快照带来的“重量级”负担

在深入片段机制之前,我们需要理解不使用该机制时,协同服务器是如何处理一次用户编辑(Op)的。

通常情况下,服务器处理操作的流程分为三步:

  1. 读取:从数据库中取出该文档当前的完整快照(例如一个包含 50 个 Sheet 的大工作簿)。
  2. 应用:在内存中将用户的 Op 应用到快照上,生成新版本。
  3. 写回:将更新后的完整快照再次保存到数据库。

如果这个快照的大小是 10MB,那么哪怕用户只是修改了一个单元格的值(Op 可能只有几字节),服务器也必须进行 10MB 的读操作和 10MB 的写操作。在高并发场景下,这种极高的 I/O 开销会迅速消耗服务器资源,导致响应速度断崖式下跌。

在这里插入图片描述

二、 什么是片段机制(Fragments)?

为了解决这一难题,SpreadJS 协同服务器引入了片段机制。这是一种高级服务器端功能,其核心思想是“化整为零,按需存取”。

片段机制允许服务器将一个大型文档(如 Workbook)拆分为多个较小的、独立的片段(Fragments)。例如,我们可以将每一个工作表(Worksheet)定义为一个独立的片段。

片段机制的运作逻辑:

  1. 分段存储:文档在数据库中不再以一个巨大文件的形式存在,而是被拆解为多个片段记录。
  2. 局部更新:当用户修改 Sheet1 的某个单元格时,服务器仅加载 Sheet1 对应的片段,应用变更后仅写回该片段。
  3. 按需合并:只有当新用户加入房间需要获取初始状态时,服务器才会将所有片段“组装”成一个完整的快照发送给客户端。

这种“外科手术式”的精确操作,将 I/O 开销从文档总大小降低到了受影响片段的大小,性能提升往往是量级式的。

在这里插入图片描述

三、 技术深度:在 OT 类型中实现片段化

片段机制通过扩展服务器端的 OT 类型(OT_Type) 接口实现。开发者需要实现以下三个核心方法:

1. createFragments (拆分)

当一个新文档被创建时,该方法负责将原始快照数据“炸裂”成多个片段。

  • 示例:在工作簿场景下,我们可以遍历 sheets 数组,为每个工作表生成一个 Key(如 sheet_id1),并将对应的 dataTable 存入片段集合。

2. applyFragments (局部应用)

这是最核心的性能优化点。它不再接收整个快照,而是接收一个 ISnapshotFragmentsRequest 对象。

  • API 特性:开发者可以使用 request.getFragment(id) 异步获取特定片段,使用 request.updateFragment(id, data) 更新它。
  • 价值:如果 Op 只涉及 Sheet1,服务器绝不会去碰 Sheet2 到 Sheet50 的数据。

3. composeFragments (组装)

当客户端请求完整快照(如调用 fetchsubscribe)时,服务器调用此方法。

  • 逻辑:它将分散在数据库中的片段重新聚合成客户端能够理解的完整 JSON 结构。

在这里插入图片描述

四、 性能对比:有无片段机制的代差

为了更直观地展示其价值,我们可以对比一下在处理大型工作簿时的表现:

在这里插入图片描述

结论:片段机制是 SpreadJS 支撑“企业级生产力”的关键。它确保了即使在处理 TB 级别的协作数据积累时,系统的响应延迟依然能维持在毫秒级。

五、 开发者如何启用片段机制?

片段机制是一项服务器端特有的技术,客户端对此是无感知的。这意味着客户端依然使用完整的 SpreadJS 数据模型进行编辑,而性能优化的重任由后端 DocumentServices 承担。

在服务端初始化时,你可以通过配置 DocumentServices 来集成支持片段的自定义数据库适配器:

// 1. 定义支持片段的 OT 类型
const workbook_ot_with_fragments = {
    uri: 'workbook-ot-type',
    createFragments: (data) => { /* 拆分逻辑 */ },
    applyFragments: async (request, op) => { /* 局部更新逻辑 */ },
    composeFragments: (fragments) => { /* 合并逻辑 */ },
    transform: (op1, op2, side) => { /* 冲突处理保持不变 */ }
};

// 2. 在 DocumentServices 中使用
const docService = new DocumentServices({
    db: new PostgresAdapter(pool), // 使用持久化适配器支持片段存储
    submitSnapshotBatchSize: 50    // 累积 50 个 Op 后更新快照片段
});

server.useFeature(OT.documentFeature(docService));

六、 总结:为海量协作保驾护航

对于企业而言,协同办公的失败往往不是因为“功能不够”,而是因为“速度太慢”。一旦协作出现延迟,用户对系统的信任感就会迅速流失。

SpreadJS 协同服务器的片段机制,通过对大规模文档的精细化治理,彻底解决了实时协作中的性能顽疾。它不仅提升了系统的吞吐量,更为企业构建超大型、高频交互的在线数据中台提供了坚实的技术底座。

现在,我们已经解决了通信(js-collaboration)一致性(OT)、**协作感知(Presence)和性能(Fragments)**的问题。接下来的挑战是:在如此开放的协作环境下,如何确保只有授权用户能修改数据?如何防止误操作并实现精准的版本回溯?

下一篇文章,我们将进入系列第五篇:【安全与管控篇】协同不代表权限开放:深度定制协同环境下的权限与版本追踪。敬请期待。

技术要点回顾:

  • 片段(Fragment):大型快照的子集,通常以工作表为单位。
  • I/O 优化:片段机制通过减少单次 Op 的读写数据量,显著降低数据库压力。
  • 核心 APIcreateFragmentsapplyFragmentscomposeFragments
  • 适用场景:Sheet 数量多、单 Sheet 数据量大的企业级复杂报表。

在这里插入图片描述

前端手写: new操作符

2026年3月5日 16:50

手写 new 操作符

1. new 操作符的工作原理

// new 操作符做了以下4件事:
// 1. 创建一个空对象
// 2. 将这个空对象的原型指向构造函数的 prototype
// 3. 将构造函数的 this 绑定到这个新对象
// 4. 执行构造函数
// 5. 返回这个新对象(如果构造函数没有返回对象,则返回 this)

function Person(name, age) {
  this.name = name;
  this.age = age;
}

// 使用 new
const p1 = new Person('张三', 25);

2. 手写 myNew 函数

2.1 基础版本

function myNew(constructor, ...args) {
  // 1. 创建一个新对象
  const obj = {};
  
  // 2. 将对象的原型指向构造函数的 prototype
  obj.__proto__ = constructor.prototype;
  // 或者使用:Object.setPrototypeOf(obj, constructor.prototype);
  
  // 3. 将构造函数的 this 绑定到新对象,并执行构造函数
  const result = constructor.apply(obj, args);
  
  // 4. 判断构造函数返回值类型
  // 如果构造函数返回一个对象,则返回这个对象
  // 否则返回新创建的对象
  return result instanceof Object ? result : obj;
}

3. 面试常考版本(精简)

// 面试时能写出的最简版本
function myNew(Con, ...args) {
  const obj = Object.create(Con.prototype);
  const result = Con.apply(obj, args);
  return result instanceof Object ? result : obj;
}

4. 实现原理详解

// 详细解释每一步
function explainNew(constructor, ...args) {
  console.log('1. 获取构造函数:', constructor);
  
  // 步骤1:创建空对象
  console.log('2. 创建一个空对象');
  const obj = {};
  
  // 步骤2:设置原型链
  console.log('3. 设置对象的原型为构造函数的 prototype');
  console.log('   构造函数 prototype:', constructor.prototype);
  obj.__proto__ = constructor.prototype;
  console.log('   新对象的 __proto__:', obj.__proto__);
  
  // 步骤3:执行构造函数
  console.log('4. 执行构造函数,绑定 this 到新对象');
  console.log('   构造函数参数:', args);
  const result = constructor.apply(obj, args);
  console.log('   构造函数返回值:', result);
  console.log('   新对象当前状态:', obj);
  
  // 步骤4:判断返回值
  console.log('5. 判断构造函数返回值类型');
  const shouldReturnResult = result && (typeof result === 'object' || typeof result === 'function');
  console.log('   应该返回构造函数返回值吗?', shouldReturnResult);
  
  return shouldReturnResult ? result : obj;
}

// 测试
function Demo(name) {
  this.name = name;
  this.createdAt = new Date();
}
const demo = explainNew(Demo, '测试');

5. 常见面试问题

Q1: new 操作符做了什么?

A:

  1. 创建一个新对象
  2. 将这个对象的原型指向构造函数的 prototype
  3. 将构造函数的 this 绑定到这个新对象,并执行构造函数
  4. 如果构造函数返回一个对象,则返回这个对象;否则返回新创建的对象

Q2: 手写 new 操作符的思路?

A:

function myNew(Con, ...args) {
  // 1. 创建对象,设置原型
  const obj = Object.create(Con.prototype);
  // 2. 执行构造函数
  const result = Con.apply(obj, args);
  // 3. 判断返回值
  return result instanceof Object ? result : obj;
}

Q3: 构造函数返回基本类型会怎样?

A: 如果构造函数返回基本类型(string, number, boolean, null, undefined),这个返回值会被忽略,new 操作符会返回新创建的对象。

Q4: 构造函数返回对象会怎样?

A: 如果构造函数返回一个对象(包括数组、函数),那么这个对象会作为 new 表达式的结果,而不是新创建的对象。

Q5: 箭头函数能用 new 调用吗?

A: 不能。箭头函数没有自己的 this,也没有 prototype 属性,所以不能作为构造函数使用。

6. 总结

手写 new 的核心步骤

  1. 创建对象:创建一个新对象
  2. 设置原型:将对象的 __proto__指向构造函数的 prototype
  3. 绑定 this:使用 applycall将构造函数的 this 绑定到新对象
  4. 执行构造函数:传入参数执行
  5. 返回结果:判断构造函数返回值,如果是对象则返回,否则返回新对象

一句话总结:new 操作符就是创建一个新对象,将其原型指向构造函数的 prototype,然后以这个对象为 this 执行构造函数,最后根据构造函数返回值决定返回什么。

React 事件订阅的稳定引用问题:从 useEffect 到 useEffectEvent

作者 ZengLiangYi
2026年3月5日 16:37

在 React 里订阅 WebSocket / EventEmitter 时,把 handler 直接放进 effect 依赖会导致反复 subscribe/unsubscribe。用 useRef 代理最新 handler 可以解决——但渲染阶段直接赋值 ref 在 Strict Mode 下有副作用风险。本文拆解这个模式的三个演进版本,以及 React 19 的终极解法。

本文假设你理解 React useEffect 的依赖数组机制和闭包基础。


问题:handler 是新的,订阅也是新的

写 WebSocket 消息监听的第一版,大多数人会这样写:

// ❌ 版本 1:handler 变化 = 重新订阅
function ChatPanel({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    socket.on('message:new', (msg) => {
      setMessages((prev) => [...prev, msg]);
    });
    return () => socket.off('message:new', /* 哪个函数? */);
  }, [conversationId]);
}

第一个问题显而易见:socket.off 需要传入与 socket.on 完全相同的函数引用,但内联箭头函数每次渲染都是新对象,off 根本移除不掉正确的监听器,导致监听器堆积。

修复方式是把 handler 提出来,加入依赖数组:

// ❌ 版本 2:监听器能正确移除了,但每次渲染都重订阅
useEffect(() => {
  const handler = (msg: Message) => {
    setMessages((prev) => [...prev, msg]);
  };
  socket.on('message:new', handler);
  return () => socket.off('message:new', handler);
}, [conversationId, setMessages]); // handler 是函数,引用每次都变

更典型的场景是 handler 来自 props:

// ❌ 每次父组件重渲染,onMessage 是新函数 → 重新订阅
function useSocketEvent(event: string, onMessage: (msg: Message) => void) {
  useEffect(() => {
    socket.on(event, onMessage);
    return () => socket.off(event, onMessage);
  }, [event, onMessage]); // onMessage 每次都是新引用
}

父组件只要重渲染(比如 state 更新),onMessage 就是新函数,effect 就重跑,WebSocket 就重新订阅一次。在高频更新的组件里,这意味着每秒可能订阅/取消订阅数十次。


心理模型:代理人

解法的核心思路是引入一个稳定的代理人

想象有个翻译:客户(WebSocket)只认识这个翻译(stableHandler),不管雇主(handler)换了几茬,客户永远对着同一个翻译说话。翻译内部维护一个指针,永远转发给最新的雇主。

WebSocket → stableHandler(稳定,不变)→ handlerRef.current(总是最新的 handler)

用代码表示:

const handlerRef = useRef(handler);
// handlerRef.current 永远是最新 handler

const stableHandler = (payload: T) => handlerRef.current(payload);
// stableHandler 是稳定函数引用,只在组件挂载时创建一次

socket.on('message:new', stableHandler); // 只订阅一次

三个版本的演进

版本 1:渲染阶段赋值(常见但有隐患)

export function useStableHandler<T>(
  event: string,
  handler: (payload: T) => void,
) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler; // ← 直接在渲染阶段赋值

  useEffect(() => {
    const stableHandler = (payload: T) => handlerRef.current(payload);
    socket.on(event, stableHandler);
    return () => socket.off(event, stableHandler);
  }, [event]);
}

这个版本能运行,也是网上最常见的写法。但 handlerRef.current = handler 写在渲染函数体里,是渲染阶段的副作用。

React Strict Mode 在开发环境下会故意执行两次渲染函数体(不含 effects),目的是暴露副作用。在并发模式(Concurrent Mode)下,React 可以中断、暂停、重播渲染——如果渲染阶段有副作用,可能在预期之外的时机被多次执行。

对于 ref 赋值,实践中通常没有问题(ref 赋值是幂等的),但这是 React 文档明确标注为"不推荐"的模式。

版本 2:独立 effect 同步(正确且 Strict Mode 安全)

export function useStableHandler<T>(
  subscribe: (handler: (payload: T) => void) => () => void,
  handler: (payload: T) => void,
): void {
  const handlerRef = useRef(handler);

  // Effect 1:同步最新 handler 到 ref(Strict Mode 安全)
  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  // Effect 2:订阅,只在 subscribe 变化时重跑
  useEffect(() => {
    const stableHandler = (payload: T) => handlerRef.current(payload);
    const unsubscribe = subscribe(stableHandler);
    return unsubscribe;
  }, [subscribe]);
}

两个 effect 分工明确:

Effect 职责 依赖 重跑频率
Effect 1 保持 ref 最新 [handler] handler 变化时(可能很频繁)
Effect 2 管理订阅生命周期 [subscribe] subscribe 变化时(应该很少)

关键点:Effect 1 频繁重跑没有性能问题,因为它只做一次 ref 赋值,没有 I/O。Effect 2 重跑才是代价高的(涉及 socket.on/off),而它的依赖 subscribe 应该是稳定的。

版本 3:useEffectEvent(React 19+,最简洁)

import { useEffectEvent } from 'react';

export function useStableHandler<T>(
  subscribe: (handler: (payload: T) => void) => () => void,
  handler: (payload: T) => void,
): void {
  // useEffectEvent 返回一个稳定函数,内部始终能访问最新 handler
  const stableHandler = useEffectEvent(handler);

  useEffect(() => {
    return subscribe(stableHandler);
  }, [subscribe]); // stableHandler 不需要放进依赖
}

useEffectEvent 是 React 官方对这个模式的标准答案。它做的事和版本 2 完全一样,只是封装成了语言原语。被 useEffectEvent 包裹的函数:

  • 稳定引用:不会触发 effect 重跑
  • 始终最新:调用时看到的是最新的 props/state
  • 不可在 effect 外调用(React 会报错,因为语义不同)

最容易踩的坑:subscribe 必须稳定

这个 hook 把订阅稳定性的责任转移到了 subscribe 参数上。如果调用时传入内联函数:

// ❌ 每次渲染 subscribe 都是新函数 → Effect 2 每次都重订阅
useStableHandler(
  (handler) => {
    socket.on('message:new', handler);
    return () => socket.off('message:new', handler);
  },
  (msg) => setMessages((prev) => [...prev, msg]),
);

修复:用 useCallback 稳定 subscribe

// ✅ subscribe 只在 socket 变化时重新创建
const subscribe = useCallback((handler: (msg: Message) => void) => {
  socket.on('message:new', handler);
  return () => socket.off('message:new', handler);
}, [socket]);

useStableHandler(subscribe, (msg) => setMessages((prev) => [...prev, msg]));

handler 参数则没有这个限制——内联函数完全可以,这正是 hook 的价值所在。


实际用例对比

Socket.IO 消息监听

function ChatPanel({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  const subscribe = useCallback((handler: (msg: Message) => void) => {
    socket.on('message:new', handler);
    return () => socket.off('message:new', handler);
  }, []); // socket 是模块级单例,依赖为空

  useStableHandler(subscribe, (msg) => {
    if (msg.conversation_id === conversationId) {
      setMessages((prev) => [...prev, msg]);
    }
  });

  // handler 每次渲染都是新函数(因为依赖 conversationId),
  // 但订阅不会重建 ✅
}

原生 resize 监听

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  const subscribe = useCallback((handler: () => void) => {
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  useStableHandler(subscribe, () => setWidth(window.innerWidth));

  return width;
}

取舍

优点 缺点
handler 无需 useCallback,调用处更干净 subscribe 必须稳定,调用处需要 useCallback
订阅/取消订阅次数最小化 两个 effect 之间存在一帧的 handler 不同步窗口(极罕见)
适用于任何 subscribe/unsubscribe 接口 版本 2 写法对团队有一定理解门槛

一帧不同步窗口是指:Effect 1(同步 handler)和 Effect 2(使用 handler)在同一个 commit 里按顺序执行,正常情况下没有问题。但如果 subscribe 变化的同时 handler 也变化,理论上可能先执行 Effect 2 再执行 Effect 1,导致新订阅在一帧内用了旧 handler。实践中这种场景几乎不会出现,且影响仅限一次事件处理。

React 19 的 useEffectEvent 从根本上消除了这个窗口,是该模式的最终形态。


完整代码

react/use-stable-handler.ts


延伸阅读

解锁 JavaScript 的灵魂:深入浅出原型与原型链

作者 Lee川
2026年3月5日 16:30

引言

在 JavaScript 的世界里,没有传统意义上的“类”作为蓝图来构建对象(至少在 ES6 之前是这样)。取而代之的,是一套独特而优雅的机制——原型(Prototype)与原型链(Prototype Chain)。这套机制不仅是 JavaScript 面向对象编程的基石,更是其灵活性与动态性的源泉。

本文将结合具体的代码实例,带你彻底揭开原型与原型链的神秘面纱,理解它们如何协同工作,让对象之间实现高效的属性共享与继承。


一、从“造车”说起:为什么需要原型?

想象一下,你是一家汽车工厂的工程师。如果每生产一辆车,你都要重新编写一遍“这辆车有四个轮子、一个引擎、能跑”的代码,那将是多么低效且浪费资源!

在 JavaScript 中,构造函数(Constructor)就像是一个模具。我们来看一个经典的例子,定义一个 Car 构造函数:

function Car(color) {
    // 每辆车独特的属性,放在构造函数内部
    this.color = color; 
    // 如果把所有属性都放这里:
    // this.name = 'su7';
    // this.height = 1.4;
    // this.drive = function() { console.log('driving...'); };
}

如果我们把 nameheight 或者 drive 方法直接写在构造函数里,意味着每 new 一辆车,内存中就会复制一份完全相同的数据和方法。对于成千上万辆车来说,这是巨大的浪费。

原型的出现,就是为了解决“共享”的问题

我们可以将那些所有车辆共有的属性和方法,挂载到构造函数的 prototype 对象上:

// 共享的属性和方法,只存一份!
Car.prototype = {
    name: 'su7',
    height: 1.4,
    weight: 1.5,
    drive() {
        console.log('drive, 下赛道');
    }
};

const car1 = new Car('霞光紫');
const car2 = new Car('海湾蓝');

console.log(car1.name); // 输出: su7
console.log(car2.name); // 输出: su7
car1.drive();           // 输出: drive, 下赛道

在这个例子中,car1car2 虽然颜色不同(实例自有属性),但它们共享了 nameheight 以及 drive 方法。这些共享内容并没有存储在 car1car2 自身内部,而是存在于 Car.prototype 中。

核心概念 1prototype 是函数(构造函数)的一个属性,它是一个对象。这个对象上的属性和方法,会被该构造函数创建的所有实例共享


二、探秘内部机制:__proto__ 与寻找之旅

既然属性不在实例自己身上,那当我们执行 car1.drive() 时,JavaScript 引擎是如何找到 drive 方法的呢?这就引出了另一个关键角色:__proto__

1. 隐式原型链接

在 JavaScript 中,几乎每个对象(除了 null)都有一个内部的私有属性,通常表示为 __proto__(在标准中称为 [[Prototype]])。

  • 当你使用 new Car() 创建一个实例时,这个实例的 __proto__ 会自动指向构造函数的 prototype 对象。
  • 也就是说:car1.__proto__ === Car.prototype 成立。

我们可以用代码验证这一点:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.speci = '人类';

const p1 = new Person('张三', 18);
console.log(p1.__proto__); // 指向 Person.prototype
console.log(p1.__proto__ === Person.prototype); // true

2. 属性查找的“接力赛”

当你访问一个对象的属性(例如 p1.speci)时,JavaScript 引擎会启动一场查找接力赛

  1. 第一棒:先在对象自身(实例)上查找。如果有,直接返回;如果没有,进入下一棒。
  2. 第二棒:沿着 __proto__ 指针,去它的原型对象Person.prototype)上查找。
  3. 第三棒:如果原型对象上也没有,就继续沿着原型对象的 __proto__ 往上找。默认情况下,它指向 Object.prototype
  4. 终点Object.prototype 是所有普通对象的终极原型。它的 __proto__null。如果连这里都找不到,引擎就会返回 undefined

这条由 __proto__ 串联起来的链条,就是著名的原型链(Prototype Chain)。

核心概念 2:原型链是对象通过 __proto__ 属性向上追溯,直到 null 的一条链路。它是 JavaScript 实现属性继承和共享的根本机制。


三、🗺️ 全景图解:一张图看懂复杂关系

文字描述虽然逻辑清晰,但原型系统中错综复杂的引用关系往往让人在脑海中难以构建完整的模型。为了让你彻底“看见”原型链,我们引入下面这张JavaScript 原型关系全景图

这张图完美地串联了我们前面提到的所有概念:构造函数、实例、prototype__proto__ 以及 constructor

image.png

深度读图指南

请跟随图中的箭头,我们将这张图拆解为三个关键视角:

1. 横向视角:构造函数与原型的“双向奔赴”

请看图的左上部分:

  • **Person **(构造函数) 通过黑色的 prototype 箭头指向 Person.prototype
    • 这意味着:构造函数拥有一个“仓库”,用来存放共享给实例的方法。
  • Person.prototype 通过黑色的 constructor 箭头指回 Person
    • 这意味着:原型对象记得是谁创造了它。这是一个闭环,确保了 Person.prototype.constructor === Person 成立。

2. 纵向视角:实例与原型的“隐形脐带”

请看图中那条醒目的蓝色曲线

  • **person **(实例) 通过 __proto__ 箭头指向 Person.prototype
    • 这是原型链的起点。当你访问 person 的属性时,如果自身没有,JS 引擎就会顺着这条蓝色箭头,去 Person.prototype 里找。
    • 口诀:实例的 __proto__ 永远等于构造函数的 prototype

3. 链条视角:通往顶端的“天梯”

请看图右侧垂直向下的蓝色直线

  • Person.prototype 也有自己的 __proto__,它指向了 Object.prototype
    • 这说明 Person.prototype 本身也是一个对象,它也受 Object 管辖。
  • Object.prototype__proto__ 指向了 null
    • 这是原型链的终点null 意味着“无路可走”,查找至此结束。

结合代码的读图体验: 当你执行 person.toString() 时:

  1. 引擎看 person 自身?没有 toString
  2. 顺着蓝色曲线去 Person.prototype 找?没有。
  3. 顺着蓝色直线去 Object.prototype 找?找到了! (toString 是 Object 内置方法)。
  4. 任务完成。

这张图告诉我们:原型链本质上就是一串由 __proto__ 连接起来的对象链表,而 prototypeconstructor 则是维护这个系统结构完整性的关键纽带


四、实战演练:彻底搞懂继承

理解了原型和原型链,继承就变得顺理成章。假设我们要创建一个 SportsCar(跑车),它应该拥有普通 Car 的所有特性,还要有自己的特技。

// 父构造函数
function Car(color) {
    this.color = color;
}
Car.prototype.drive = function() {
    console.log('普通驾驶');
};

// 子构造函数
function SportsCar(color, speed) {
    // 借用父构造函数,继承实例属性
    Car.call(this, color); 
    this.speed = speed;
}

// 关键步骤:建立原型链继承
// 让 SportsCar 的原型指向一个由 Car 创建的实例
SportsCar.prototype = new Car(); 

// 修正 constructor 指向(最佳实践,对应图中 constructor 箭头的修复)
SportsCar.prototype.constructor = SportsCar;

// 添加子类特有的方法
SportsCar.prototype.race = function() {
    console.log('赛道狂飙,速度:' + this.speed);
};

const myCar = new SportsCar('红色', 300);

myCar.drive(); // 来自父级原型链:普通驾驶
myCar.race();  // 来自子类原型:赛道狂飙,速度:300
console.log(myCar.color); // 来自实例自身:红色

在这个过程中发生了什么?(对照全景图想象)

  1. SportsCar.prototype = new Car():这行代码创建了一个临时的 Car 实例。
  2. 这个临时实例的 __proto__ 指向 Car.prototype
  3. 我们将这个临时实例赋值给 SportsCar.prototype
  4. 此时,SportsCar.prototype__proto__ 就自然地指向了 Car.prototype
  5. myCar 访问 drive 方法时,查找路径变成了:
    • myCar -> SportsCar.prototype -> Car.prototype (找到!) -> Object.prototype -> null

这就是原型链继承的精髓:通过修改原型链的指向,让子类的实例能够访问到父类原型上的方法


五、总结与启示

回顾全文,结合那张清晰的全景图,我们可以提炼出以下核心要点:

  1. 构造函数与 Prototype:每个函数都有一个 prototype 属性,用于存放供实例共享的属性和方法。
  2. 实例与 __proto__:每个实例都有一个 __proto__ 属性,它在实例化时自动指向构造函数的 prototype(图中蓝色曲线的含义)。
  3. 原型链查找机制:访问属性时,JS 引擎会沿 __proto__ 链条逐级向上查找,直到 Object.prototypenull(图中蓝色直线的含义)。
  4. 闭环的重要性constructor 属性确保了原型对象能找回构造函数,维持系统的完整性(图中黑色反向箭头的含义)。
  5. 继承本质:JS 的继承不是拷贝,而是原型链的委托查找。

理解原型和原型链,是掌握 JavaScript 高级特性的必经之路。无论是后续的 class 语法糖,还是框架源码中的巧妙运用,其底层逻辑都离不开这套精妙的原型机制。

下次当你写下 new 关键字时,不妨在脑海中浮现出那张全景图:描绘出那条连接着实例、原型、再通向 Object 的隐形链条。正是这条链条,赋予了 JavaScript 无限的可能。

2026前端必备:TensorFlow.js,浏览器里的AI引擎,不写Python也能玩转智能

2026年3月4日 15:49

各位前端大佬们,大家好!今天我们就彻底抛开那些晦涩的“神经网络”、“反向传播”术语,站在2026年纯前端开发的角度,来聊聊 TensorFlow.js 为什么已经成为了你工具库里必须掌握的“瑞士军刀”

不知道你们有没有这种感觉:这两年,“AI”这个词出现的频率高到令人发指,好像哪个App不会点“智能”功能,都不好意思上架。以前,我们总觉得AI是后端Python工程师的专利,是那些搭建在云服务器上的庞然大物。我们前端能做的,就是调调接口,然后把结果渲染在页面上。

2026年的今天,局面已经完全不一样了。

随着设备性能的提升和浏览器技术的飞跃,我们迎来了  “边缘AI”  的时代。这意味着,AI的能力正在从云端下沉到我们的客户端,下沉到每一个用户的浏览器里。而在这场变革中,TensorFlow.js 就是那把让我们前端开发者也能亲手打造“智能”体验的钥匙

今天这篇博客,我们不谈复杂的数学公式,也不深入算法原理,我们就从一个纯前端开发者的视角,来聊聊TensorFlow.js是什么,它凭什么值得我们学习,以及它能帮我们做出怎样炫酷又实用的功能。

一、TensorFlow.js 是什么?一个让你在浏览器里“运行大脑”的库

想象一下,你以前想做一个能识别图片中猫狗的功能,流程通常是:前端上传图片 → 后端接收 → 调用Python模型推理 → 返回结果给前端。这一来一回,不仅慢,而且服务器的成本居高不下。

TensorFlow.js 改变了这一切。它是 Google 推出的一个开源 JavaScript 库,它的核心能力是:让你完全用JavaScript,就能在浏览器或Node.js中,运行甚至训练机器学习模型 。

你可以把它理解成一个超级强大的数学计算库。它能把你的普通数据(比如图片像素、鼠标轨迹、语音波形)转换成一种叫做“张量”的数据结构,然后进行各种复杂的运算 。最终,让你的网页不再是“指哪打哪”,而是拥有了一定的“感知”和“预测”能力。

二、为什么2026年的前端必须关注它?三大核心价值

我知道你可能会问:“这东西听起来很酷,但对我做后台管理系统、做活动页有什么实际帮助吗?” 当然有!而且是颠覆性的。

1. 极致的用户体验:零延迟与离线可用

这是客户端AI最大的魅力所在。因为所有的计算都在用户的手机或电脑上完成,无需等待网络请求

  • 实时性:比如做一个AR试妆、手势控制PPT翻页、或者是实时语音降噪。数据直接在本地处理,响应是毫秒级的,体验无比丝滑。
  • 离线可用:你的Web App可以在没有网络的情况下,依然提供核心的智能服务。比如一个野外探险的H5,可以在离线时识别植物种类。

2. 终极的隐私保护:数据永不离开设备

GDPR、个人信息保护法……用户对隐私的敏感度越来越高。当AI模型在本地运行时,用户的图片、语音、行为数据根本不需要上传到你的服务器 

  • 合规无忧:你不再需要为如何安全存储用户数据而头疼。
  • 建立信任:“本地处理,不上传”是对用户隐私最大的尊重,这在产品层面是巨大的加分项。

3. 极致的成本控制:省下一大笔服务器开销

这是老板和CTO最爱听的。当推理计算发生在用户的浏览器里时,你就不需要为每一次AI功能调用支付云服务器的GPU费用 。对于用户量巨大的产品来说,这能节省高达70%  的基础设施成本 。这种“计算资源民主化”的趋势,让中小企业也能玩得起AI了。

三、在2026年,TensorFlow.js能帮我们做什么?

说了这么多好处,我们来看看具体能玩出什么花活儿。TensorFlow.js在前端的应用已经相当成熟,主要可以分为三大类:

1. 用“开箱即用”的预训练模型,给你的应用装上“感官”

这是最快捷的路径。Google和社区提供了大量预训练好的模型,你只需要几行代码就能引入,就像引入一个jQuery插件一样简单。

  • 计算机视觉

    • 人体姿态检测:通过PoseNetBodyPix模型,你可以实时追踪用户的骨架或身体轮廓。想象一下,做一个体感控制的H5小游戏,或者一个远程健身App,自动纠正用户的深蹲姿势,是不是很酷?
    • 面部特征点检测face-landmarks-detection模型可以帮你识别出数百个面部关键点。这不仅仅是加个面具,还能实现眼神追踪、情绪识别。
    • 图像分类/目标检测MobileNetCoco-SSD模型能让你轻松识别图片中是猫还是狗,或者画面里有什么物体 。
  • 多媒体处理

    • 手写识别:在Canvas上写字,模型实时识别成文本,可以做出体验极佳的笔记应用。
    • 语音命令:在浏览器里识别简单的语音指令,比如“拍照”、“下一页” []。

2. 用“迁移学习”,打造“千人千面”的个性化体验

这是TensorFlow.js的进阶玩法。你可以在预训练模型的基础上,只用用户的少量数据,就在浏览器里快速“微调”出一个属于用户自己的模型

比如,Teachable Machine 这个项目就是典型例子。你可以让用户对着摄像头做几个A动作,再做几个B动作,TensorFlow.js 就能实时训练出一个能识别这两个手势的分类器 。

  • 个性化推荐:根据用户在页面上的点击和停留行为,在本地训练一个简单模型,实时调整内容的展示优先级。
  • 用户画像:根据用户的浏览偏好,实时调整UI主题或推荐关键词,所有数据都在本地,既安全又贴心。

3. 用“完全自定义训练”,处理你业务里的特定数据

如果你的数据比较特殊,比如要预测某个工厂设备的剩余寿命,或者根据前端的几个表单字段预测销售额,你完全可以在浏览器里用TensorFlow.js搭建和训练一个小型的神经网络。

  • 智能表单:根据用户输入的前几个字,实时预测并补全剩余信息。
  • 数据分析:在数据可视化大屏上,结合SheetJS等库,直接在浏览器端对导入的Excel数据进行回归分析或趋势预测,实时生成图表。

四、上手 TensorFlow.js 比你想象的简单

看到这里,你可能已经跃跃欲试了。别担心,2026年的TensorFlow.js开发者体验已经非常友好了。

1. 环境搭建:两行代码搞定

最简单的方式,直接在HTML里引入CDN:

html

<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest/dist/tf.min.js"></script>

如果你是React、Vue或Next.js项目,直接用npm安装 

bash

npm install @tensorflow/tfjs

2. 你的第一行“智能”代码:图片分类

下面这段代码,就实现了一个最基础的图片分类器:

javascript

// 1. 加载一个预训练的MobileNet模型
const model = await mobilenet.load();

// 2. 获取页面上的图片元素
const img = document.getElementById(‘myImage‘);

// 3. 对图片进行分类!
const predictions = await model.classify(img);

// 4. 在控制台看看结果
console.log(‘预测结果:‘, predictions);

就这么简单。你不需要了解MobileNet的内部结构,只需要知道它能干什么,然后用它就行了。

3. 聊聊性能:WebGPU 和 WebAssembly 给你“神装”

2026年,浏览器的能力又上了一个台阶。

  • WebGPU 已经逐渐普及,它为TensorFlow.js提供了接近原生硬件的图形计算能力,让复杂模型的运行速度大幅提升 。
  • WebAssembly (WASM)  作为性能的“倍增器”,让用C++写的底层计算模块能在浏览器中以接近机器码的速度运行 。

即使是在配置不高的设备上,你也可以通过模型量化(减小模型体积)等技术,保证应用的流畅运行。

五、总结:拥抱2026年的前端新范式

TensorFlow.js 不仅仅是又一个JavaScript库,它代表了一种前端开发的新范式。它让我们从单纯的“界面交互”构建者,变成了“智能体验”的创造者。

2026年,当用户期待的不再是“能点”,而是“懂我”的应用时,掌握TensorFlow.js将不再是加分项,而是我们前端开发者应对未来挑战的核心竞争力

别再觉得AI遥不可及了,它就在你的浏览器里,就在你的指尖下。去Google搜索“TensorFlow.js”,打开官方文档,试着运行第一个demo吧。相信我,你会发现一个新世界的大门正在向你敞开。

#前端 #Web开发 #TensorFlow.js #干货

Vue Router与响应式系统的集成

作者 wuhen_n
2026年3月4日 14:25

在前面的文章中,我们深入学习了 Vue3 的响应式系统、组件渲染、生命周期等核心机制。今天,我们将探索 Vue Router 是如何与 Vue 的响应式系统无缝集成的。理解路由的实现原理,将帮助我们更好地处理页面导航、路由守卫等复杂场景。

前言:路由的核心挑战

Vue Router 作为 Vue 的官方路由管理器,其最精妙的设计之一就是与 Vue 响应式系统的无缝集成。Vue 作为单页应用(SPA),在路由管理中,面临的核心挑战是:在URL变化时,不刷新页面,而是动态切换组件: 路由的核心挑战 同时,也面临诸多问题:

  • 如何监听URL变化而不刷新页面?
  • 如何让路由变化触发组件重新渲染?
  • 如何管理路由历史?

Vue Router 响应式设计总览

响应式数据的核心

Vue Router 实现响应式导航的核心是:将当前路由状态(currentRoute)作为响应式数据。当路由发生变化时,依赖这个响应式数据的组件(如 router-view)会自动重新渲染:

// 简化的核心代码
const currentRoute = shallowRef(initialRoute);

整体架构

Vue Router 的响应式集成主要包含三个层次:

  • 数据层:currentRoute 响应式对象
  • 视图层:router-view 组件监听路由变化
  • 交互层:router-link 组件和编程式导航

currentRoute:路由响应式数据的实现

核心响应式设计

在 Vue Router 4 中,当前路由状态被设计为一个 shallowRef 响应式对象:

import { shallowRef } from 'vue'

function createRouter(options) {
  // 初始化路由状态
  const START_LOCATION_NORMALIZED = {
    path: '/',
    matched: [],
    meta: {},
    // ... 其他路由属性
  }
  
  // 核心响应式数据
  const currentRoute = shallowRef(START_LOCATION_NORMALIZED)
  
  const router = {
    // 暴露当前路由为只读属性
    get currentRoute() {
      return currentRoute.value
    },
    // ... 其他方法
  }
  
  return router
}

为什么使用 shallowRef 而不是 ref?因为路由对象结构较深,shallowRef 只代理 .value 的变更,内部属性变更不需要触发响应式,这样可以获得更好的性能。

路由响应式数据的使用

Vue Router 通过依赖注入将响应式路由数据提供给所有组件:

install(app) {
  // 注册路由实例
  app.provide(routerKey, router)
  app.provide(routeLocationKey, reactive(this.currentRoute))
  
  // 注册全局组件
  app.component('RouterLink', RouterLink)
  app.component('RouterView', RouterView)
  
  // 在原型上挂载 $router 和 $route
  app.config.globalProperties.$router = router
  app.config.globalProperties.$route = reactive(this.currentRoute)
}

这样,我们在组件中就可以通过 $route 或 useRoute() 访问响应式路由数据:

<script setup>
import { useRoute } from 'vue-router'

// 返回一个响应式对象,当路由变化时会自动更新
const route = useRoute()

console.log(route.path) // 当前路径
console.log(route.params) // 路由参数
</script>

<template>
  <div>当前路径: {{ $route.path }}</div>
</template>

路由变化时如何触发更新

当路由发生变化时,Vue Router 会更新 currentRoute.value,从而触发所有依赖的重新渲染:

// 路由导航的核心逻辑
async function navigate(to, from) {
  // ... 执行导航守卫、解析组件等
  
  // 更新当前路由(触发响应式更新)
  currentRoute.value = to
  
  // 调用 afterEach 钩子
  callAfterEachGuards(to, from)
}

router-view 组件的渲染原理

router-view 的作用

router-view 是一个函数式组件,它的核心职责是:根据当前路由的匹配结果,渲染对应的组件:

<template>
  <div id="app">
    <!-- 路由匹配的组件会在这里渲染 -->
    <router-view></router-view>
  </div>
</template>

router-view 的源码实现

const RouterView = defineComponent({
  name: 'RouterView',
  setup(props, { attrs, slots }) {
    // 注入路由实例和当前路由
    const injectedRoute = inject(routeLocationKey)
    const router = inject(routerKey)
    
    // 获取深度(用于嵌套路由)
    const depth = inject(viewDepthKey, 0)
    const matchedRouteRef = computed(() => {
      // 获取当前深度对应的匹配记录
      const matched = injectedRoute.matched[depth]
      return matched
    })
    
    // 提供下一层的 depth
    provide(viewDepthKey, depth + 1)
    
    return () => {
      const match = matchedRouteRef.value
      const component = match?.components?.default
      
      if (!component) {
        return slots.default?.() || null
      }
      
      // 渲染匹配到的组件
      return h(component, {
        ...attrs,
        ref: match.instances?.default,
      })
    }
  }
})

嵌套路由的处理

router-view 通过 depth 参数支持嵌套路由:

<template>
  <div>
    <h1>用户中心</h1>
    <!-- 默认 depth = 1,会渲染子路由组件 -->
    <router-view></router-view>
  </div>
</template>

每个嵌套的 router-view 都会通过 provide/inject 获得递增的深度值,从而从 matched 数组中取出对应的组件记录。

路由钩子的实现机制

钩子函数分类

Vue Router 提供了三类导航守卫:

  • 全局守卫:beforeEach、beforeResolve、afterEach
  • 路由独享守卫:beforeEnter
  • 组件内守卫:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave

钩子执行流程源码简析

async function navigate(to, from) {
  const guards = []
 
  // 收集所有需要执行的守卫,并按顺序执行
  // 1. 执行 beforeRouteLeave(从最深的路由记录开始)
  const leaveGuards = extractLeaveGuards(from.matched)
  guards.push(...leaveGuards)
  
  // 2. 执行全局 beforeEach
  guards.push(router.beforeEachGuards)
  
  // 3. 执行 beforeRouteUpdate(如果组件复用)
  const updateGuards = extractUpdateGuards(from.matched, to.matched)
  guards.push(...updateGuards)
  
  // 4. 执行路由配置的 beforeEnter
  const enterGuards = extractEnterGuards(to.matched)
  guards.push(...enterGuards)
  
  // 5. 执行全局 beforeResolve
  guards.push(router.beforeResolveGuards)
  
  // 串行执行所有守卫
  for (const guard of guards) {
    const result = await guard(to, from)
    // 如果守卫返回 false 或重定向路径,中断导航
    if (result === false || typeof result === 'string') {
      return result
    }
  }
  
  // 6. 执行全局 afterEach(不阻塞导航)
  callAfterEachGuards(to, from)
}

组件内守卫的实现

组件内守卫通过 Vue 的生命周期钩子集成:

// 组件内守卫的注册
export default {
  beforeRouteEnter(to, from, next) {
    // 在渲染前调用,不能访问 this
    // 可以通过 next 回调访问组件实例
    next(vm => {
      // 通过 `vm` 访问组件实例
    })
  },
  beforeRouteUpdate(to, from, next) {
    // 路由改变但组件复用时调用
    // 可以访问 this
  },
  beforeRouteLeave(to, from, next) {
    // 离开路由时调用
    // 可以访问 this
  }
}

Hash模式 vs History模式

两种模式的本质区别

Vue Router 支持两种路由模式:

模式 创建方式 URL格式 服务器配置 原理
Hash createWebHashHistory() /#/home 不需要 监听 hashchange 事件 + pushState
History createWebHistory() /home 需要 HTML5 History API

Hash模式的实现

// hash.js - Hash模式实现
function createWebHashHistory(base = '') {
  // Hash模式本质上是在 History 模式基础上加了 '#' 前缀
  return createWebHistory(base ? base : '#')
}

// 处理 Hash 路径
function getHashLocation() {
  const hash = window.location.hash.slice(1) // 去掉开头的 '#'
  return hash || '/' // 空 hash 返回根路径
}

// 监听 hash 变化
window.addEventListener('hashchange', () => {
  const to = getHashLocation()
  // 更新路由状态
  changeLocation(to)
})

注:在 Vue Router 4 中,Hash 模式也统一使用 History API 进行导航,hashchange 仅作为兜底监听。

History模式的实现

// html5.js - History模式实现
function createWebHistory(base = '') {
  // 创建状态管理器
  const historyState = useHistoryState()
  const currentLocation = ref(createCurrentLocation(base))
  
  // 监听 popstate 事件
  window.addEventListener('popstate', (event) => {
    const to = createCurrentLocation(base)
    currentLocation.value = to
    // 触发路由更新
  })
  
  function push(to) {
    // 调用 history.pushState
    window.history.pushState({}, '', to)
    currentLocation.value = to
  }
  
  function replace(to) {
    window.history.replaceState({}, '', to)
    currentLocation.value = to
  }
  
  return {
    location: currentLocation,
    push,
    replace
  }
}

History模式的服务器配置

History 模式需要服务器配置支持,否则刷新页面会 404。Nginx 配置示例:

location / {
  try_files $uri $uri/ /index.html;
}

createRouter核心逻辑源码简析

createRouter的整体结构

function createRouter(options) {
  // 1. 创建路由匹配器
  const matcher = createRouterMatcher(options.routes)
  
  // 2. 创建响应式路由状态
  const currentRoute = shallowRef(START_LOCATION)
  
  // 3. 根据模式创建 history 实例
  const history = options.history
  
  // 4. 定义路由方法
  const router = {
    // 响应式路由
    currentRoute,
    
    // 导航方法
    push(to) {
      return pushWithRedirect(to)
    },
    
    replace(to) {
      return push(to, true)
    },
    
    // 后退
    back() {
      history.go(-1)
    },
    
    // 前进
    forward() {
      history.go(1)
    },
    
    // 插件安装方法
    install(app) {
      // 提供路由实例
      app.provide(routerKey, router)
      app.provide(routeLocationKey, reactive(currentRoute))
      
      // 注册全局组件
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)
      
      // 挂载到全局属性
      app.config.globalProperties.$router = router
      app.config.globalProperties.$route = reactive(currentRoute)
      
      // 初始化路由
      if (currentRoute.value === START_LOCATION) {
        // 解析初始路径
        history.replace(history.location)
      }
    }
  }
  
  return router
}

createRouterMatcher的实现

路由匹配器负责将配置的路由表拍平,建立父子关系:

function createRouterMatcher(routes) {
  const matchers = []
  
  // 递归添加路由记录
  function addRoute(record, parent) {
    // 标准化路由记录
    const normalizedRecord = normalizeRouteRecord(record)
    
    // 创建匹配器
    const matcher = createRouteRecordMatcher(normalizedRecord, parent)
    
    // 处理子路由
    if (normalizedRecord.children) {
      for (const child of normalizedRecord.children) {
        addRoute(child, matcher)
      }
    }
    
    matchers.push(matcher)
  }
  
  // 初始化所有路由
  routes.forEach(route => addRoute(route))
  
  // 解析路径,返回匹配的路由记录
  function resolve(location) {
    const matched = []
    let path = location.path
    
    // 找到匹配的 matcher
    for (const matcher of matchers) {
      if (path.startsWith(matcher.path)) {
        matched.push(matcher.record)
      }
    }
    
    return {
      path,
      matched
    }
  }
  
  return {
    addRoute,
    resolve
  }
}

手写简易路由实现

// 简易路由实现
import { ref, shallowRef, reactive, computed, provide, inject } from 'vue'

const ROUTER_KEY = '__router__'
const ROUTE_KEY = '__route__'

// 创建路由
function createRouter(options) {
  // 1. 创建匹配器
  const matcher = createMatcher(options.routes)
  
  // 2. 响应式路由状态
  const currentRoute = shallowRef({
    path: '/',
    matched: []
  })
  
  // 3. 处理历史模式
  const history = options.history
  
  // 4. 监听 popstate
  window.addEventListener('popstate', () => {
    const path = window.location.pathname
    const matched = matcher.match(path)
    currentRoute.value = { path, matched }
  })
  
  // 5. 导航方法
  function push(path) {
    window.history.pushState({}, '', path)
    const matched = matcher.match(path)
    currentRoute.value = { path, matched }
  }
  
  const router = {
    currentRoute,
    push,
    install(app) {
      app.provide(ROUTER_KEY, router)
      app.provide(ROUTE_KEY, reactive(currentRoute))
      
      app.component('RouterLink', {
        props: { to: String },
        setup(props, { slots }) {
          const router = inject(ROUTER_KEY)
          return () => (
            h('a', {
              href: props.to,
              onClick: (e) => {
                e.preventDefault()
                router.push(props.to)
              }
            }, slots.default?.())
          )
        }
      })
      
      app.component('RouterView', {
        setup() {
          const route = inject(ROUTE_KEY)
          const depth = inject('depth', 0)
          provide('depth', depth + 1)
          
          return () => {
            const component = route.value.matched[depth]?.component
            return component ? h(component) : null
          }
        }
      })
    }
  }
  
  return router
}

// 简易匹配器
function createMatcher(routes) {
  const records = []
  
  function normalize(route, parent) {
    const record = {
      path: parent ? parent.path + route.path : route.path,
      component: route.component,
      parent
    }
    
    records.push(record)
    
    if (route.children) {
      route.children.forEach(child => normalize(child, record))
    }
  }
  
  routes.forEach(route => normalize(route))
  
  return {
    match(path) {
      return records.filter(record => path.startsWith(record.path))
    }
  }
}

性能优化与最佳实践

路由懒加载

const routes = [
  {
    path: '/dashboard',
    // 使用动态导入实现懒加载
    component: () => import('./views/Dashboard.vue')
  }
]

避免不必要的响应式开销

// 如果只需要一次性值,可以不用解构
const route = useRoute()
// ❌ 避免:每次路由变化都会重新计算
const id = computed(() => route.params.id)

// ✅ 推荐:直接在需要的地方使用
watch(() => route.params.id, (newId) => {
  // 只在变化时执行
})

路由守卫的最佳实践

// 全局前置守卫:适合做权限验证
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isLoggedIn()) {
    next('/login')
  } else {
    next()
  }
})

// 组件内守卫:适合做数据预加载
beforeRouteEnter(to, from, next) {
  fetchData(to.params.id).then(data => {
    next(vm => vm.data = data)
  })
}

结语

Vue Router 与响应式系统的集成是 Vue 生态中最精妙的设计之一,理解这些原理不仅帮助我们更好地使用 Vue Router,也为处理复杂路由场景(如权限控制、动态路由、嵌套路由等)提供了理论基础。在实际开发中,合理利用路由响应式特性和导航守卫,可以构建出既高效又易维护的单页应用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue 3 性能优化的 5 个隐藏技巧,第 4 个连老手都未必知道

作者 前端Hardy
2026年3月4日 10:50

上周,我们上线了一个数据看板页面,本地跑得飞快,一上生产——滚动卡成 PPT

Profiler 一抓,发现:

  • 每次滚动都在重复创建 computed 函数
  • 列表项里嵌套了 3 层 <Suspense>
  • 一个 watch 竟然监听了整个 reactive 对象……

问题不在逻辑,而在 “你以为没问题的写法”

今天,我就分享 5 个 Vue 3 中少有人提、但效果惊人的性能优化技巧,尤其第 4 个,连很多 5 年经验的老手都没用过。


技巧 1:别在模板里写“方法调用”,用 computed + 缓存

反面教材:

<template>
  <div>{{ formatUserName(user) }}</div> <!-- 每次渲染都执行! -->
</template>

<script setup>
const formatUserName = (user) => `${user.firstName} ${user.lastName}`;
</script>

正确做法:

const formattedName = computed(() => 
  `${user.value.firstName} ${user.value.lastName}`
);
<template>
  <div>{{ formattedName }}</div> <!-- 响应式缓存,依赖不变不重算 -->
</template>

关键点:模板中的函数调用 没有缓存,每次 re-render 都会执行!


技巧 2:v-for 里的组件,记得加 key —— 但别用 index

很多人知道要加 key,但随手写:

<div v-for="(item, index) in list" :key="index">
  <ItemCard :data="item" />
</div>

问题:当列表发生插入/删除时,index 会变,导致 Vue 错误复用组件实例,引发状态错乱 or 不必要的销毁重建。

正确做法:用唯一 ID

<div v-for="item in list" :key="item.id">
  <ItemCard :data="item" />
</div>

如果真没 ID?考虑用 Symbol()crypto.randomUUID() 生成稳定 key(仅限静态列表)。


技巧 3:慎用 watch 监听整个 reactive 对象

const state = reactive({ a: 1, b: 2, c: 3 });

watch(state, () => {
  console.log('state changed');
});

这会导致:只要 abc 任意一个变了,回调就触发,即使你只关心 a

更精准的写法:

// 方案 A:监听具体属性
watch(() => state.a, (newVal) => { ... });

// 方案 B:用 toRefs 解构后监听
const { a } = toRefs(state);
watch(a, (newVal) => { ... });

高级技巧:如果必须监听多个字段,用 getter 函数组合:

watch(
  () => ({ a: state.a, b: state.b }),
  (newVals) => { /* 只有 a 或 b 变才触发 */ }
);

技巧 4:用 shallowRefmarkRaw 跳过不必要的响应式(隐藏大招!)

这是 Vue 3 响应式系统中最被低估的 API

场景:你有一个大型配置对象 or 第三方库实例(如 echarts 实例),不需要响应式?

默认写法(性能杀手):

const chart = ref(null); // Vue 会尝试把 echarts 实例变成响应式!
onMounted(() => {
  chart.value = echarts.init(dom); // 内部 thousands of properties!
});

正确做法:

// 方案 A:用 shallowRef(只让 .value 响应,内部不递归)
const chart = shallowRef(null);

// 方案 B:用 markRaw 明确告诉 Vue “别动它”
const chartInstance = markRaw(echarts.init(dom));
const chart = ref(chartInstance);

效果:避免 Vue 递归遍历大型对象,节省内存 + 提升初始化速度 10x+

适用场景:

  • 图表实例(ECharts、Chart.js)
  • 复杂配置对象(如 Monaco Editor options)
  • 不变的数据结构(如路由 meta、常量字典)

技巧 5:懒加载组件 + 异步 setup,减少首屏负担

别让所有组件都在首屏加载!

<!-- 同步引入,打包进主 chunk -->
<script setup>
import HeavyChart from './HeavyChart.vue';
</script>

改成动态导入 + Suspense:

<template>
  <Suspense>
    <template #default>
      <LazyChart />
    </template>
    <template #fallback>
      <div>Loading chart...</div>
    </template>
  </Suspense>
</template>

<script setup>
// 自动代码分割
const LazyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));
</script>

进阶:配合 IntersectionObserver 实现滚动到可视区再加载

const isVisible = ref(false);
// 当元素进入视口,isVisible = true → 再加载组件

总结:5 个技巧速查表

技巧 适用场景 性能收益
模板中用 computed 代替方法调用 频繁渲染的格式化逻辑 避免重复计算
v-for 用唯一 ID 做 key 动态列表(增删改) 减少 DOM 重建
精准 watch 而非监听整个对象 复杂状态管理 避免无效回调
shallowRef / markRaw 跳过响应式 大型对象、第三方实例 内存 & 初始化提速
异步组件 + Suspense 重型组件(图表、编辑器) 首屏加载更快

最后说两句

Vue 3 的性能,80% 取决于你如何使用响应式系统,而不是框架本身慢。

真正的优化,不是“加缓存”“开 SSR”,而是:

在正确的地方,用正确的 API,做最小化的响应式。

下次写组件前,先问自己:

“这个数据,真的需要响应式吗?”


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

速通Canvas指北🦮——路径与形状篇

2026年3月4日 10:48

引言

本文缘起自笔者开发一个基于 PIXI.js 的在线动画编辑器时,想系统学习 Canvas 相关知识,却发现缺少合适的中文入门资料,于是萌生了撰写这份“速通指北”的想法,欢迎感兴趣的朋友订阅我的 《Canvas 指北》专栏。

第3章:描边与填充

在上一章中,我们学习了如何绘制线条并控制其样式。本章将正式介绍 Canvas 中两个核心的渲染操作:描边(stroke)  和 填充(fill) 。你会学到如何设置颜色、应用填充规则(包括路径方向的影响),以及如何清除画布。

3.1 描边基础

描边是指绘制路径的轮廓。

3.1.1 描边颜色:strokeStyle

设置描边使用的颜色,可以是颜色名、十六进制、rgb/rgba 等。

ctx.strokeStyle = 'red';
ctx.strokeStyle = '#00ff00';
ctx.strokeStyle = 'rgba(0,0,255,0.5)';

3.1.2 执行描边:stroke()

对当前路径进行描边:

ctx.moveTo(10, 10);
ctx.lineTo(100, 10);
ctx.strokeStyle = 'blue';
ctx.stroke();

image.png

3.2 填充基础

填充是指用颜色或图案填充路径的内部区域。

3.2.1 填充颜色:fillStyle

与 strokeStyle 类似,fillStyle 接受颜色值、渐变或图案(渐变和图案将在后续章节介绍)。

ctx.fillStyle = 'green';
ctx.fillStyle = '#ff8800';
ctx.fillStyle = 'rgba(255,0,0,0.3)';

3.2.2 执行填充:fill()

ctx.rect(50, 50, 100, 80);  // 矩形路径(rect 是直接添加矩形路径的方法)
ctx.fillStyle = 'yellow';
ctx.fill();

注意: 填充时会自动闭合路径(即使你没有调用 closePath),它会从当前点画一条直线到子路径起点,然后填充内部。

3.3 同时使用描边与填充

你可以先填充再描边,或者反过来。顺序通常不影响视觉效果(除非使用半透明颜色)。

ctx.rect(30, 30, 100, 80);
ctx.fillStyle = 'lightblue';
ctx.fill();
ctx.strokeStyle = 'navy';
ctx.lineWidth = 3;
ctx.stroke();

image.png

3.4 填充规则:nonzeroevenodd

填充规则决定了当路径自交或包含多个子路径时,哪些区域算作“内部”。fill() 方法接受一个可选的 fillRule 参数,默认为 "nonzero"

3.4.1 非零环绕规则 ("nonzero")

从待测点向任意方向发射一条射线(通常取水平向右),统计射线与路径的交点,并根据路径穿过射线时的方向进行计数:

  • 顺时针穿过 → 计数器 +1
  • 逆时针穿过 → 计数器 -1

若最终计数不为零,则点在内部。

方向的影响:路径的方向由绘制时点的顺序决定。当两个子路径重叠时:

  • 同向(如都是顺时针):重叠区计数叠加(+2),仍被填充。
  • 反向(一顺一逆):重叠区计数抵消(0),成为空洞。

下面示例用两个重叠矩形演示 nonzero 规则:

<canvas id="nonzeroDemo" width="300" height="250"></canvas>
<script>
  const canvas = document.getElementById('nonzeroDemo');
  const ctx = canvas.getContext('2d');

  // 两个矩形同向(均为顺时针)
  ctx.beginPath();
  ctx.moveTo(50, 50); ctx.lineTo(200, 50); ctx.lineTo(200, 180); ctx.lineTo(50, 180); ctx.closePath();
  ctx.moveTo(120, 80); ctx.lineTo(270, 80); ctx.lineTo(270, 210); ctx.lineTo(120, 210); ctx.closePath();
  ctx.fillStyle = 'rgba(255,100,100,0.7)';
  ctx.fill('nonzero');          // 默认规则
  ctx.strokeStyle = 'red';
  ctx.stroke();

  // 标注
  ctx.font = '14px sans-serif';
  ctx.fillStyle = 'black';
  ctx.fillText('同向(均顺时针)', 50, 230);
</script>

若将第二个矩形改为逆时针(改变点的顺序),重叠区域将变为空洞:

<canvas id="nonzeroReverse" width="300" height="250"></canvas>
<script>
  const canvas = document.getElementById('nonzeroReverse');
  const ctx = canvas.getContext('2d');

  // 第一个顺时针,第二个逆时针
  ctx.beginPath();
  ctx.moveTo(50, 50); ctx.lineTo(200, 50); ctx.lineTo(200, 180); ctx.lineTo(50, 180); ctx.closePath(); // 顺
  ctx.moveTo(120, 80); ctx.lineTo(120, 210); ctx.lineTo(270, 210); ctx.lineTo(270, 80); ctx.closePath(); // 逆
  ctx.fillStyle = 'rgba(100,100,255,0.7)';
  ctx.fill('nonzero');
  ctx.strokeStyle = 'blue';
  ctx.stroke();

  ctx.fillText('反向(顺+逆)', 50, 230);
</script>

nonzero 规则的设计初衷之一,就是让开发者可以通过控制路径的方向(顺时针/逆时针),来决定重叠区域是“合并填充”还是“挖成空洞”。

3.4.2 奇偶规则 ("evenodd")

该规则忽略路径方向,只统计射线与路径的交点个数:

奇数 → 内部

偶数 → 外部

对于同样的两个重叠矩形,无论它们方向如何,重叠区域交点数为 2(偶数),因此都是空洞。

<canvas id="evenoddCompare" width="600" height="250"></canvas>
<script>
  const canvas = document.getElementById('evenoddCompare');
  const ctx = canvas.getContext('2d');

  // 左侧:两个矩形同向(均为顺时针)
  ctx.save();
  ctx.translate(0, 0);
  ctx.beginPath();
  ctx.moveTo(50, 50); ctx.lineTo(200, 50); ctx.lineTo(200, 180); ctx.lineTo(50, 180); ctx.closePath();
  ctx.moveTo(120, 80); ctx.lineTo(270, 80); ctx.lineTo(270, 210); ctx.lineTo(120, 210); ctx.closePath();
  ctx.fillStyle = 'rgba(100,255,100,0.7)';
  ctx.fill('evenodd');
  ctx.strokeStyle = 'green';
  ctx.stroke();
  ctx.restore();

  // 右侧:两个矩形反向(第一个顺时针,第二个逆时针)
  ctx.save();
  ctx.translate(300, 0);
  ctx.beginPath();
  ctx.moveTo(50, 50); ctx.lineTo(200, 50); ctx.lineTo(200, 180); ctx.lineTo(50, 180); ctx.closePath(); // 顺时针
  ctx.moveTo(120, 80); ctx.lineTo(120, 210); ctx.lineTo(270, 210); ctx.lineTo(270, 80); ctx.closePath(); // 逆时针
  ctx.fillStyle = 'rgba(100,255,100,0.7)';
  ctx.fill('evenodd');
  ctx.strokeStyle = 'green';
  ctx.stroke();
  ctx.restore();

  // 标注
  ctx.font = '14px sans-serif';
  ctx.fillStyle = 'black';
  ctx.fillText('同向 (evenodd)', 50, 230);
  ctx.fillText('反向 (evenodd)', 350, 230);
</script>

image.png

3.4.3 在 fill() 中指定规则

ctx.fill('nonzero');   // 默认
ctx.fill('evenodd');

也可以在 isPointInPath 中指定规则进行命中检测(参见第四章)。

第4章:路径

在上一章中,我们为了对比演示绘制多条独立线条时,每次都使用了 beginPath()。你可能已经隐约感觉到它的作用——它帮助我们开始一条新的路径,避免之前绘制的图形被重复描边。本章将正式介绍 Canvas 中的核心概念:路径。你会学到什么是路径,如何构建和闭合路径,以及如何判断点是否位于路径内。

4.1 什么是路径

路径(Path)是 Canvas 2D API 中定义图形轮廓的一系列绘图命令的集合。你可以把它想象成一支画笔在画布上移动时留下的轨迹记录——它记录了所有 moveTolineTo、arc 等操作,但并没有真正绘制出可见的图形。

  • 路径只是一个抽象的轮廓,要让它显示出来,必须调用 fill()(填充)或 stroke()(描边)——这两个方法将在下一章详细讲解。
  • 路径可以包含多个子路径(subpath),每个子路径通常由一次 moveTo 开始。
  • 路径可用于重复渲染与命中检测;如果需要显式复用一组路径数据,通常使用 Path2D。命中检测时,isPointInPath 用于填充区域,isPointInStroke 用于描边(线条)区域。

为什么要使用路径?

  • 构建复杂图形:可以先描述图形的线段与曲线,再统一执行渲染,结构更清晰。
  • 性能优化:将多个图形片段合并到同一路径后一次性 fill()stroke(),通常比每画一小段就立即绘制更高效(具体收益取决于场景与状态切换次数)。
  • 交互判定:可结合 isPointInPath(填充区)与 isPointInStroke(描边区)实现点击选中等交互。

4.2 路径的基本操作

4.2.1 开始新路径:beginPath()

beginPath() 的作用是清空当前路径,让你可以开始定义一个新的图形。如果不调用它,后续的路径命令会追加到现有路径上,导致绘制时出现意外的重叠或重复。下面通过颜色覆盖的对比示例,让你直观感受它的重要性。

错误示例: 忘记 beginPath(),颜色被覆盖

<canvas id="badPath" width="300" height="120"></canvas>
<script>
  const canvas = document.getElementById('badPath');
  const ctx = canvas.getContext('2d');

  // 第一条线(红色)
  ctx.strokeStyle = 'red';
  ctx.moveTo(20, 20);
  ctx.lineTo(130, 20);
  ctx.stroke();

  // 想画第二条线(蓝色),但没有 beginPath()
  ctx.strokeStyle = 'blue';
  ctx.moveTo(20, 50);
  ctx.lineTo(130, 50);
  ctx.stroke();  // 结果:两条线都变成了蓝色(蓝色覆盖了第一条红色线)
</script>

结果: 左边两条线最终都是蓝色——因为第二次 stroke() 时,路径中包含了第一条线,所以它也被用蓝色重新描了一遍,蓝色覆盖在原来的红色之上。

正确示例: 每次新图形前调用 beginPath(),颜色独立

<canvas id="goodPath" width="300" height="120"></canvas>
<script>
  const canvas = document.getElementById('goodPath');
  const ctx = canvas.getContext('2d');

  // 第一条线(红色)
  ctx.beginPath();
  ctx.strokeStyle = 'red';
  ctx.moveTo(170, 20);
  ctx.lineTo(280, 20);
  ctx.stroke();

  // 第二条线(蓝色),新路径
  ctx.beginPath();
  ctx.strokeStyle = 'blue';
  ctx.moveTo(170, 50);
  ctx.lineTo(280, 50);
  ctx.stroke();  // 两条线颜色独立,互不干扰
</script>

结果: 右边两条线红蓝分明,互不影响。

4.2.2 闭合路径:closePath()

closePath()会从当前点绘制一条直线回到当前子路径的起点(即最近一次 moveTo 的位置),并闭合该子路径。它与直接用 lineTo 回到起点的区别在于:

  • 如果路径已经闭合,closePath 不会重复添加线段;
  • 它会标记该子路径为闭合,影响后续填充时的边界处理(在填充时,闭合与未闭合的效果通常相同,但描边时闭合会明确连接起点和终点)。

下面通过一个对比示例,直观展示使用 closePath() 与不闭合(即缺少最后一条边)的区别。

<canvas id="closeCompare" width="320" height="150"></canvas>
<script>
  const canvas = document.getElementById('closeCompare');
  const ctx = canvas.getContext('2d');

  // 左侧:不闭合(只有两条边)
  ctx.beginPath();
  ctx.moveTo(30, 20);
  ctx.lineTo(100, 60);
  ctx.lineTo(30, 100);
  // 没有 closePath(),也没有手动 lineTo 回起点
  ctx.stroke();

  // 右侧:使用 closePath() 闭合
  ctx.beginPath();
  ctx.moveTo(190, 20);
  ctx.lineTo(260, 60);
  ctx.lineTo(190, 100);
  ctx.closePath(); // 自动绘制从 (190,100) 回到 (190,20) 的直线
  ctx.stroke();
</script>

如果不使用 closePath(),而是用 lineTo(起点)手动闭合,视觉效果与 closePath() 相同,但 closePath() 更简洁,并且会标记路径为闭合状态。对于大多数描边场景,手动闭合也完全可行。但在涉及填充或复杂路径操作时,建议使用 closePath() 以确保正确的闭合标记。

4.3 子路径

一个路径可以包含多个子路径。每次调用 moveTo 都会开始一个新的子路径,后续的 lineTo 会附加到这个子路径上。

示例:绘制两个独立的三角形(同一个路径中的两个子路径)

<canvas id="subPath" width="320" height="150"></canvas>
<script>
  const canvas = document.getElementById('subPath');
  const ctx = canvas.getContext('2d');

  ctx.beginPath();

  // 第一个三角形(子路径1)
  ctx.moveTo(20, 20);
  ctx.lineTo(80, 60);
  ctx.lineTo(20, 100);
  ctx.closePath();

  // 第二个三角形(子路径2)
  ctx.moveTo(120, 20);
  ctx.lineTo(180, 60);
  ctx.lineTo(120, 100);
  ctx.closePath();

  ctx.stroke(); // 一次描边画出两个三角形
</script>

这样,一个路径包含多个子路径,所有子路径会同时被描边或填充。

4.4 Path2D 对象

Canvas 2D API 提供了 Path2D 对象,用于缓存和复用路径。你可以先创建一个 Path2D 实例,然后用它来构建路径,最后直接传入 fillstrokeisPointInPath 等方法。

<canvas id="path2D" width="320" height="200"></canvas>
<script>
  const canvas = document.getElementById('path2D');
  const ctx = canvas.getContext('2d');

  const triangle = new Path2D();
  triangle.moveTo(80, 30);
  triangle.lineTo(220, 80);
  triangle.lineTo(80, 170);
  triangle.closePath();

  // 绘制
  ctx.fillStyle = 'orange';
  ctx.fill(triangle);
  ctx.stroke(triangle);

  // 检测点击
  ctx.isPointInPath(triangle, x, y); // 传入 Path2D 对象
</script>

这样,我们就不必重复构建路径了。Path2D 在现代浏览器中已得到良好支持(不支持 IE),如果项目需要兼容旧浏览器,请查阅 Can I use 确认。

4.5 路径检测:isPointInPath

isPointInPath(x, y, fillRule) 方法用于判断指定点是否位于当前路径内部。它非常有用,例如实现鼠标点击选中图形。

参数:

  • x, y:检测点的坐标(相对于画布)。
  • fillRule(可选):填充规则,默认 "nonzero",可选 "evenodd"。用于处理自交或嵌套路径的判定,参见 3.4 章节。

返回值:布尔值 truefalse

4.5.1 基础示例:点击检测三角形

下面代码演示如何用 isPointInPath 检测鼠标点击是否在三角形内。

<canvas id="hitCanvas" width="300" height="200"></canvas>
<script>
  const canvas = document.getElementById('hitCanvas');
  const ctx = canvas.getContext('2d');

  // 绘制一个三角形(填充为橙色,便于视觉参考)
  ctx.beginPath();
  ctx.moveTo(80, 30);
  ctx.lineTo(220, 80);
  ctx.lineTo(80, 170);
  ctx.closePath();
  ctx.fillStyle = 'orange';
  ctx.fill();

  // 点击检测
  canvas.addEventListener('click', (e) => {
    const rect = canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    // isPointInPath 基于当前路径,所以需要重新构建路径
    ctx.beginPath();
    ctx.moveTo(80, 30);
    ctx.lineTo(220, 80);
    ctx.lineTo(80, 170);
    ctx.closePath();

    if (ctx.isPointInPath(x, y)) {
      console.log('点在三角形内!');
    } else {
      console.log('点在三角形外');
    }
  });
</script>

分别点击三角形区域和外部空白区域,可以得到如下图中的日志: image.png

4.5.2 填充规则对比示例:自交路径

下面我们构建一个自交路径(两个部分重叠的矩形),分别用 nonzeroevenodd 规则填充,并演示点击检测时结果的差异。

<canvas id="fillRuleCanvas" width="600" height="250" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('fillRuleCanvas');
  const ctx = canvas.getContext('2d');

  // 定义一个自交路径:两个矩形(均为顺时针方向)
  const path = new Path2D();
  // 矩形1 (50,50) 到 (200,180)
  path.moveTo(50, 50);
  path.lineTo(200, 50);
  path.lineTo(200, 180);
  path.lineTo(50, 180);
  path.closePath();
  // 矩形2 (120,80) 到 (270,210)
  path.moveTo(120, 80);
  path.lineTo(270, 80);
  path.lineTo(270, 210);
  path.lineTo(120, 210);
  path.closePath();

  // 左侧:使用 nonzero 填充(默认)
  ctx.save();
  ctx.translate(0, 0);
  ctx.fillStyle = 'rgba(255,100,100,0.7)';
  ctx.fill(path, 'nonzero');
  ctx.strokeStyle = 'red';
  ctx.stroke(path);
  ctx.restore();

  // 右侧:使用 evenodd 填充(平移 300 像素)
  ctx.save();
  ctx.translate(300, 0);
  ctx.fillStyle = 'rgba(100,100,255,0.7)';
  ctx.fill(path, 'evenodd');
  ctx.strokeStyle = 'blue';
  ctx.stroke(path);
  ctx.restore();

  // 标注
  ctx.font = '14px sans-serif';
  ctx.fillStyle = 'black';
  ctx.fillText('nonzero', 50, 230);
  ctx.fillText('evenodd', 350, 230);

  // 点击检测
  canvas.addEventListener('click', (e) => {
    const rect = canvas.getBoundingClientRect();
    let x = e.clientX - rect.left;
    let y = e.clientY - rect.top;

    if (x < 300) {
      // 左侧区域,使用 nonzero 规则检测
      // 需要重建路径(相对原始坐标)
      ctx.beginPath();
      ctx.moveTo(50, 50);
      ctx.lineTo(200, 50);
      ctx.lineTo(200, 180);
      ctx.lineTo(50, 180);
      ctx.closePath();
      ctx.moveTo(120, 80);
      ctx.lineTo(270, 80);
      ctx.lineTo(270, 210);
      ctx.lineTo(120, 210);
      ctx.closePath();

      const inNonzero = ctx.isPointInPath(x, y, 'nonzero');
      console.log(`左侧 (nonzero): ${inNonzero ? '在内部' : '在外部'}`);
    } else {
      // 右侧区域,点击坐标需转换到左侧坐标系(因为路径定义在原始坐标)
      const xLocal = x - 300;
      ctx.beginPath();
      ctx.moveTo(50, 50);
      ctx.lineTo(200, 50);
      ctx.lineTo(200, 180);
      ctx.lineTo(50, 180);
      ctx.closePath();
      ctx.moveTo(120, 80);
      ctx.lineTo(270, 80);
      ctx.lineTo(270, 210);
      ctx.lineTo(120, 210);
      ctx.closePath();

      const inEvenodd = ctx.isPointInPath(xLocal, y, 'evenodd');
      console.log(`右侧 (evenodd): ${inEvenodd ? '在内部' : '在外部'}`);
    }
  });
</script>

分别点击左侧的重叠区域和右侧的重叠区域,可以得到如下图中的日志:

4.6 扩展:isPointInStroke

类似地,isPointInStroke(x, y) 判断点是否在描边路径上(即轮廓线上)。这在某些交互(如点击线条)中很有用。它不支持填充规则参数,因为描边不涉及内部填充规则。

ctx.lineWidth = 10;
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(100, 100);
ctx.stroke();

// 检测点 (60,60) 是否在描边路径上
if (ctx.isPointInStroke(60, 60)) {
  console.log('点在线上');
}

第5章:矩形与多边形

矩形是 Canvas 中最基础的形状,除了用路径绘制,Canvas 还提供了专门的矩形绘制 API。本章将介绍这些便捷方法,并在此基础上扩展,通过三角函数绘制正多边形和五角星。

5.1 矩形绘制

Canvas 提供了三个直接绘制矩形的方法,无需手动构建路径。

5.1.1 填充矩形:fillRect(x, y, width, height)

直接绘制一个填充矩形,参数分别为左上角坐标、宽度和高度。

ctx.fillStyle = 'orange';
ctx.fillRect(30, 30, 150, 100);

image.png

5.1.2 描边矩形:strokeRect(x, y, width, height)

绘制一个矩形边框(仅描边),使用当前的 strokeStyle 和线条样式。

ctx.strokeStyle = 'navy';
ctx.lineWidth = 4;
ctx.strokeRect(30, 30, 150, 100);

image.png

5.1.3 矩形路径:rect(x, y, width, height)

将矩形路径添加到当前路径中,之后可以调用 fill()stroke() 进行渲染。适合需要同时描边和填充,或与其他路径组合的场景。

ctx.beginPath();
ctx.rect(30, 30, 150, 100);
ctx.fillStyle = 'lightblue';
ctx.fill();
ctx.strokeStyle = 'blue';
ctx.stroke();

image.png

5.2 清空矩形区域:clearRect(x, y, width, height)

clearRect 将指定矩形区域内的所有像素变为透明(即清除内容),常用于动画中清空画布或部分区域。

<canvas id="clearDemo" width="300" height="150"></canvas>
<script>
  const canvas = document.getElementById('clearDemo');
  const ctx = canvas.getContext('2d');

  // 先画一些内容
  ctx.fillStyle = 'red';
  ctx.fillRect(20, 20, 100, 100);
  ctx.fillStyle = 'blue';
  ctx.fillRect(100, 50, 100, 100);

  // 等待 1 秒后清空中间区域
  setTimeout(() => {
    ctx.clearRect(50, 30, 120, 90);
  }, 1000);
</script>

image.png 等待一秒后: image.png

利用这一特性,可以通过:

cxt.clearRect(0, 0, ctx.width, ctx.height);

清空整个画布。

5.3 绘制正多边形

通过三角函数可以计算出正多边形各个顶点的坐标,然后使用路径绘制。正多边形的顶点均匀分布在圆周上,每个顶点对应角度为 (i * 2π) / sides,其中 sides 表示多边形的边数。

<script>
  const canvas = document.getElementById('polygonDemo');
  const ctx = canvas.getContext('2d');

  /**
   *
   * 绘制多边形
   * @param {CanvasRenderingContext2D} ctx
   * @param {number} cx 中心点x坐标
   * @param {number} cy 中心点y坐标
   * @param {number} r 外接圆半径(顶点到中心的距离)
   * @param {number} sides 多边形边数
   * @param {number} rotation 旋转角度(弧度),用于调整多边形的朝向
   */
  function drawPolygon(ctx, cx, cy, r, sides, rotation = 0) {
    ctx.beginPath();
    for (let i = 0; i < sides; i++) {
      const angle = rotation + (i * 2 * Math.PI) / sides;
      const x = cx + r * Math.cos(angle);
      const y = cy + r * Math.sin(angle);
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.closePath();
  }

  // 绘制三个正多边形
  ctx.fillStyle = 'rgba(255,100,100,0.5)';
  ctx.strokeStyle = 'red';
  ctx.lineWidth = 2;

  // 三角形
  drawPolygon(ctx, 80, 100, 60, 3, -Math.PI/2);
  ctx.fill();
  ctx.stroke();

  // 四边形
  drawPolygon(ctx, 210, 88, 60, 4, -Math.PI/4);
  ctx.fill();
  ctx.stroke();

  // 五边形
  drawPolygon(ctx, 80, 220, 60, 5, -Math.PI/2);
  ctx.fill();
  ctx.stroke();

  // 六边形
  drawPolygon(ctx, 210, 220, 60, 6);
  ctx.fill();
  ctx.stroke();
</script>

image.png

5.4 绘制五角星

五角星可以看作由内、外两圈顶点交替连接而成:外圈顶点在半径为 r1 的圆上,内圈顶点在半径为 r2 的圆上,每两个外顶点之间插入一个内顶点。

<canvas id="starDemo" width="500" height="200"></canvas>
<script>
  const canvas = document.getElementById('starDemo');
  const ctx = canvas.getContext('2d');

  /**
   * 绘制星形
   * @param {CanvasRenderingContext2D} ctx
   * @param {number} cx 中心点x坐标
   * @param {number} cy 中心点y坐标
   * @param {number} r1 外半径
   * @param {number} r2 内半径
   * @param {number} points 星形点数
   * @param {number} rotation 旋转角度(弧度),用于调整星形的朝向
   */
  function drawStar(ctx, cx, cy, r1, r2, points = 5, rotation = 0) {
    ctx.beginPath();
    for (let i = 0; i < points * 2; i++) {
      const radius = i % 2 === 0 ? r1 : r2;
      const angle = rotation + (i * Math.PI) / points; // 计算当前顶点的角度
      const x = cx + radius * Math.cos(angle); // 计算当前顶点的x坐标
      const y = cy + radius * Math.sin(angle); // 计算当前顶点的y坐标
      if (i === 0) ctx.moveTo(x, y); // 如果是第一个顶点,则移动到当前顶点
      else ctx.lineTo(x, y); // 否则绘制线段到当前顶点
    }
    ctx.closePath();
  }

  ctx.fillStyle = 'gold';
  ctx.strokeStyle = 'orange';
  ctx.lineWidth = 2;

  // 五角星
  drawStar(ctx, 120, 100, 50, 25, 5, -Math.PI / 2);
  ctx.fill();
  ctx.stroke();

  // 七角星
  drawStar(ctx, 300, 100, 50, 20, 7, -Math.PI / 2);
  ctx.fillStyle = 'lightblue';
  ctx.fill();
  ctx.stroke();
</script>

image.png

第6章:圆形与弧线

圆形和弧线是 Canvas 中常用的基本图形。本章将介绍两种绘制圆弧的方法:arcarcTo,以及它们在实际开发中的应用技巧。

6.1 角度与弧度

在圆中,如果一段圆弧的长度恰好等于圆的半径,那么这段圆弧所对的圆心角的大小就是 1 弧度

  • 因为整个圆的周长 = 2π × 半径,所以整个圆(360°)对应的弧度是 2π。

  • 由此可得:

    • 180° = π 弧度
    • 90° = π/2 弧度
    • 60° = π/3 弧度
    • 45° = π/4 弧度
    • 30° = π/6 弧度

为什么用弧度? JavaScript 的三角函数(Math.sin, Math.cos 等)都采用弧度制,且弧度在微积分、物理模拟和图形学公式中更加自然简洁。虽然一开始可能不习惯,但只要记住几个常用转换,很快就能上手。

  • 角度转弧度:弧度 = 角度 × π / 180

  • 弧度转角度:角度 = 弧度 × 180 / π 例如,30° 转换为弧度:30 × π/180 = π/6 ≈ 0.5236。 例如,30° 转换为弧度:30 × π/180 = π/6 ≈ 0.5236。

6.2 绘制圆弧:arc()

arc() 是最常用的圆弧绘制方法,通过圆心、半径和起止角度定义一段圆弧。

ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise);
  • x, y:圆心坐标

  • radius:半径

  • startAngle, endAngle:起始角和结束角(弧度)

  • counterclockwise:可选,false 表示顺时针(默认),true 表示逆时针

起点方向:0 弧度位于 X 轴正方向(右侧)。

基础示例:绘制一个完整的圆

ctx.beginPath();
ctx.arc(100, 75, 50, 0, Math.PI * 2);
ctx.strokeStyle = 'blue';
ctx.stroke();

image.png

绘制半圆:

ctx.beginPath();
ctx.arc(100, 75, 50, 0, Math.PI);       // 上半圆(顺时针从 0 到 π)
ctx.stroke();

image.png

绘制扇形: 扇形需要连接圆心与圆弧起点、终点,形成一个封闭路径。 在绘制扇形时,moveTo(圆心) 的作用至关重要,它决定了最终绘制的是扇形还是弓形

moveTo 时,arc 执行时,由于当前点(圆心)与圆弧起点不同,它会自动添加一条从圆心到圆弧起点的直线,然后再绘制圆弧。

  // ========== 左侧:有 moveTo(扇形) ==========
  ctx.beginPath();
  ctx.moveTo(120, 120);                // 将画笔移动到圆心
  ctx.arc(120, 120, 80, 0, Math.PI/2); // 圆心 (120,120),半径80,从0°到90°(顺时针)
  ctx.closePath();                      // 从圆弧终点回到圆心
  ctx.fillStyle = 'gold';
  ctx.fill();
  ctx.strokeStyle = 'orange';
  ctx.lineWidth = 2;
  ctx.stroke();

  // ========== 右侧:无 moveTo(弓形) ==========
  ctx.beginPath();
  ctx.arc(380, 120, 80, 0, Math.PI/2); // 直接画圆弧,圆心 (380,120),参数同上
  ctx.closePath();                      // 从圆弧终点连接到圆弧起点(形成弦)
  ctx.fillStyle = 'lightblue';
  ctx.fill();
  ctx.strokeStyle = 'blue';
  ctx.stroke();

image.png

6.3 绘制圆弧:arcTo()

arcTo() 通过两条切线绘制一段圆弧,常用于创建圆角路径或连接两条直线。

ctx.arcTo(x1, y1, x2, y2, radius);
  • (x1, y1):第一个控制点
  • (x2, y2):第二个控制点
  • radius:圆弧半径

工作原理:

理解 arcTo()方法的一种方式是想象两条直线段:一条从起始点到第一个控制点,另一条从第一个控制点到第二个控制点。如果没有 arcTo()方法,这两条线段会形成一个尖角:arcTo() 方法在这个角落创建一个圆弧,并使其平滑连接。换句话说,这个圆弧与两条线段都相切。详见:CanvasRenderingContext2D:arcTo() 方法

特殊情况: 如果路径为空(没有当前点),Canvas 会隐式地将当前点设置为 (x1, y1),然后继续执行。此时 P0 与 (x1, y1) 重合,第一条射线退化为一个点,无法形成夹角,因此不会绘制圆弧,而是直接添加一条从 (x1, y1) 到 (x2, y2) 的直线。

<canvas id="arcToCompare" width="500" height="200"></canvas>
<script>
  const canvas = document.getElementById('arcToCompare');
  const ctx = canvas.getContext('2d');

  // 左侧:有 moveTo(正常圆角)
  ctx.beginPath();
  ctx.moveTo(50, 50);
  ctx.arcTo(180, 50, 180, 150, 40);
  ctx.lineTo(180, 150);
  ctx.strokeStyle = 'red';
  ctx.lineWidth = 3;
  ctx.stroke();

  // 右侧:无 moveTo(变成直线)
  ctx.beginPath();
  // 没有 moveTo,路径为空,arcTo 隐式将当前点设为 (330,50)
  ctx.arcTo(330, 50, 330, 150, 40);
  ctx.lineTo(330, 150);
  ctx.strokeStyle = 'blue';
  ctx.stroke();
</script>

image.png

绘制圆角矩形:

function roundedRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);                     // 起点
  ctx.arcTo(x + w, y, x + w, y + h, r);     // 右上角
  ctx.arcTo(x + w, y + h, x, y + h, r);     // 右下角
  ctx.arcTo(x, y + h, x, y, r);              // 左下角
  ctx.arcTo(x, y, x + w, y, r);              // 左上角
  ctx.closePath();
}

// 使用示例
roundedRect(ctx, 50, 50, 200, 100, 20);
ctx.fillStyle = 'lightblue';
ctx.fill();
ctx.strokeStyle = 'navy';
ctx.stroke();

image.png

🚀 下篇预告:图形样式篇

在下一篇中,你将学到:

  • 如何绘制贝塞尔曲线(二次和三次),创造光滑流畅的自由曲线;
  • 文本的渲染与样式控制,包括字体、对齐、度量等;
  • 图片的加载与绘制技巧,以及图像合成与裁剪;
  • 线性渐变、径向渐变的设计与使用,让图形拥有丰富的色彩过渡;
  • 阴影效果的实现,为图形添加立体感和深度。

掌握这些,你的画布将拥有专业级的视觉效果,为创作惊艳的作品打下坚实基础。

Shipfe — Rust 写的前端静态部署工具:一条命令上线 + 零停机 + 可回滚 + 自动清理

作者 Jans
2026年3月4日 10:46

GitHub:
github.com/Master-Jian…

文档:
master-jian.github.io/shipfe-rust…


你是否也在重复这个流程

npm run build
scp -r dist/* server:/var/www/app
ssh server # 然后手动替换 / 回滚 / 清理

能用,但痛点太多:

  • 每次都要手动 scp/ssh,容易出错
  • 多环境、多项目维护成本高
  • 发布时替换文件可能造成短暂异常
  • 线上出问题回滚麻烦
  • 多次部署后服务器磁盘越堆越满

Shipfe 就是把这件事变成:一条命令、安全、可回滚、可清理、零停机


Shipfe 是什么

Shipfe 是一个 Rust 编写的前端静态资源部署 CLI,专注解决「把 dist 静态资源部署到你自己的服务器」这件事。

适用于:

  • Vue / React / Vite
  • Next 静态导出 / Nuxt generate
  • 任意静态站点

设计原则:不走第三方平台、不会“偷偷联网”

Shipfe 的原则是:

工具本身不会向任何第三方服务发起请求。

部署路径只有:

你的电脑 → 你的服务器(SSH)

不依赖 CI/CD 平台,不上传任何数据到中间服务,更适合:

  • 内网环境
  • 企业环境
  • 对安全敏感的项目

核心能力一览

1)一键部署

shipfe deploy --profile prod

自动完成:

  • build
  • 打包
  • 上传
  • 更新部署目录

不需要手动 scp / ssh。


2)原子部署(默认零停机发布)

Shipfe 默认使用原子部署策略

服务器目录会变成标准 releases + current 结构:

/var/www/app
├── releases
│   ├── 20260303_034945
│   ├── 20260303_035012
│   └── 20260303_035045
└── current -> releases/20260303_035045

Nginx 永远指向:

/var/www/app/current

每次发布只需要切换软链接:

切换瞬间完成,实现零停机部署。


3)原子数据共享(Shared Assets / Snapshot)

很多人在实现原子部署时都会遇到一个问题:

新 release 切过去了,但一些旧资源不在新目录里,导致 404。

Shipfe 提供 原子资源共享机制

  • shared/assets:跨 release 共享静态资源
  • snapshot/manifest:记录每次部署的资源快照

带来的好处:

  • 避免资源重复上传
  • 避免切换后资源缺失
  • 可追踪每次发布的资源内容

4)自动清理:限制最大 release 数量

线上非常容易出现这种情况:

releases 越来越多,占满服务器磁盘。

Shipfe 支持限制 最多保留 N 个版本

配置示例:

{
  "environments": {
    "prod": {
      "enable_shared": true,
      "hashed_asset_patterns": ["assets/"],
      "keep_releases": 5
    }
  }
}

含义:

  • 只保留 最近 5 个 release
  • 更旧的版本自动删除
  • 同时清理不再被引用的 shared 资源

这样可以避免服务器磁盘持续膨胀。

如果你希望 只保留当前版本

"keep_releases": 1

5)一键回滚

回滚到指定版本:

shipfe rollback --profile prod --to 20260303_034945

由于使用的是原子结构,回滚本质只是:

重新指向旧 release 的软链接。

因此:

  • 回滚速度极快
  • 风险极低
  • 不需要重新上传文件

6)多环境 + 子环境部署

支持:

  • dev / test / prod
  • 一台服务器多个项目

例如:

admin
shop
customer

也支持:

shipfe deploy --all-sub

一次部署所有子环境。


安装与快速上手

安装:

npm i -g shipfe

初始化配置:

shipfe init

部署:

shipfe deploy --profile prod

什么时候你会需要 Shipfe

如果你符合其中任意一条:

  • 静态项目上线还在手动 scp / ssh
  • 想要零停机发布 + 快速回滚
  • 多环境部署越来越复杂
  • 服务器 releases / assets 越堆越多
  • 想要一个 离线、无第三方依赖 的部署工具

Shipfe 就是为这些场景设计的。


项目地址

GitHub:
github.com/Master-Jian…

文档:
master-jian.github.io/shipfe-rust…

欢迎 Star / Issue / PR。

拿来就用!Vue3+Cesium 飞入效果封装,3D大屏多场景直接复用

作者 李剑一
2026年3月4日 09:43

最近有点儿事儿,之前的大屏项目拖了一段时间,现在打算继续开发。原本以为用熟悉的Cesium能快速搞定,没想到还是踩了几个坑,整理出来和大家分享,避免后续有人走同样的弯路。

页面地球飞入效果采用 Cesium 进行开发,一来 Cesium 作为开源的3D地理信息可视化框架,API 封装完善,开发效率高。

二来个人长期使用该框架,对其核心逻辑比较熟悉,本以为能快速落地,实际开发中却遇到了加载、时机监听、多场景复用等多个问题,逐一排查解决后,才实现了流畅的飞入效果。

实现效果

Video Project 2.gif

Cesium 地球初始化完成后,自动触发指定地点的飞入动画,相机从初始视角平滑过渡到目标经纬度对应的视角,过程流畅无卡顿、无图层闪烁。

核心问题

简单的飞入效果之前都是使用的现成的方法,从零开始遇到点问题。

实际开发中需要兼顾加载性能、时机准确性、多场景复用等,具体问题如下:

  • Cesium 加载速度问题:在线地图服务加载延迟高,弱网环境下易报错,影响用户体验;
  • Cesium 加载完成时机的监听:若监听时机不准确,会导致飞入动画触发时,地图图层、影像未加载完成,出现“空地球”或“图层闪烁”问题;
  • 多地点复用问题:大屏项目中可能需要切换多个目标地点,需对飞入逻辑进行封装,实现灵活调用。

解决方案(附完整代码+细节说明)

加载速度优化:解决在线地图加载慢的问题

我最初采用的是 Cesium 官方的 Ion 在线地图服务,毕竟无需额外配置,直接调用即可,但实际测试后发现两个致命问题:

  1. 加载延迟高:官方服务器位于海外,国内网络环境下,地图影像加载速度极慢,甚至需要10秒以上才能完全渲染,远超用户6~8秒的等待极限;

  2. 稳定性差:弱网环境下,地图会直接加载失败,控制台报错“影像图层加载超时”,导致页面无法正常展示。

在线地图服务受网络环境影响极大,若要实现生产环境的稳定运行,优先替换为本地地图服务(如天地图本地部署、GeoServer 发布的本地影像),从根源上解决加载慢、报错的问题。

由于手头暂无本地地图部署工具,本次开发暂用在线地图过渡,后续会替换为本地服务。

以下是优化后的初始化代码,增加了加载超时处理,提升弱网环境下的容错性:

// 导入 Cesium 核心模块
import * as Cesium from 'cesium'
// 引入 Cesium 样式(必须,否则控件和地球样式异常)
import 'cesium/Build/Cesium/Widgets/widgets.css'

// 初始化 Cesium 地球(增加超时处理,优化加载体验)
const initCesium = async () => {
    try {
        // 配置 Cesium Token(可从 Cesium 官网免费申请,需注册账号)
        Cesium.Ion.defaultAccessToken = '你的官网Token';

        // 创建 Cesium 视图实例,精简界面控件,提升加载速度
        viewer.value = new Cesium.Viewer('cesiumContainer', {
            // 隐藏默认控件,适配大屏简洁风格
            timeline: false, // 时间轴控件
            animation: false, // 动画控件
            baseLayerPicker: false, // 底图切换控件
            geocoder: false, // 地理编码控件(搜索地点)
            homeButton: false, // 首页按钮
            infoBox: false, // 信息弹窗(点击要素时显示)
            sceneModePicker: false, // 场景模式切换(2D/3D/哥伦布视图)
            navigationHelpButton: false, // 导航帮助按钮
            // 性能优化配置
            scene3DOnly: true, // 仅开启3D模式,减少2D渲染开销
            requestRenderMode: true, // 开启请求渲染模式,降低CPU占用
            maximumRenderTimeChange: 1 / 60, // 控制渲染帧率,避免卡顿
            // 开启地形(如果不需要地形展示,可注释,进一步提升加载速度)
            // terrainProvider: Cesium.createWorldTerrain()
        });

        // 隐藏 Cesium 底部版权信息(可选,根据项目需求调整)
        viewer.value._cesiumWidget._creditContainer.style.display = 'none';

        // 等待 Cesium 完全加载完成(包括影像图层、场景渲染)
        await waitForCesiumFullyLoaded();
        
        // 触发 cesiumReady 事件,通知外部执行飞入等后续操作
        emit('cesiumReady', viewer.value);
    } catch (error) {
        console.error('Cesium 初始化失败:', error);
        // 加载失败提示,提升用户体验
        ElMessage.error('地球加载失败,请检查网络或刷新页面重试');
    }
}

加载时机监听

这是本次开发最容易踩坑的点,在创建 viewer 实例后,直接触发飞入动画,导致动画执行时,地图影像还未加载完成,出现“相机飞向空地球”的尴尬场景。

Cesium 初始化是异步过程,创建 viewer 实例只是第一步,后续还需要加载影像图层、渲染场景、初始化相机等操作,这些操作完成后,才能确保飞入动画的流畅性。

封装两个异步方法,分别监听场景渲染就绪影像图层加载完成,只有两个条件都满足,才触发后续的飞入操作,确保时机精准。

代码如下:

/**
 * 等待 Cesium 完全加载完成(包括场景渲染和影像图层)
 * 核心逻辑:先确保场景渲染就绪,再等待影像图层加载完成,双重校验
 * @returns {Promise}
 */
const waitForCesiumFullyLoaded = () => {
    return new Promise((resolve) => {
        const checkSceneReady = () => {
            // 先检查 viewer 和 scene 是否存在(避免初始化未完成时调用)
            if (!viewer.value || !viewer.value.scene) {
                // 每50ms检查一次,避免频繁占用资源
                setTimeout(checkSceneReady, 50);
                return;
            }
            
            // 使用 postRender 事件,确保场景至少完成一帧渲染
            viewer.value.scene.postRender.addEventListener(() => {
                // 场景就绪后,再等待影像图层加载完成
                waitForImageryLoaded().then(resolve);
            }, viewer.value.scene);
        };
        checkSceneReady();
    });
}

/**
 * 等待影像图层加载完成(单独封装,便于后续扩展)
 * 核心逻辑:遍历所有影像图层,检查是否有正在加载的图块
 * @returns {Promise}
 */
const waitForImageryLoaded = () => {
    return new Promise((resolve) => {
        // 若 viewer 或 scene 不存在,直接resolve(容错处理)
        if (!viewer.value || !viewer.value.scene) {
            resolve();
            return;
        }

        const imageryLayers = viewer.value.imageryLayers;
        // 若没有影像图层,直接resolve
        if (!imageryLayers || imageryLayers.length === 0) {
            resolve();
            return;
        }

        // 循环检查所有影像图层是否加载完成
        const checkLoaded = () => {
            let allLoaded = true;
            
            for (let i = 0; i < imageryLayers.length; i++) {
                const layer = imageryLayers.get(i);
                if (layer && layer.imageryProvider) {
                    // 检查当前图层是否有正在加载的图块(_loading 为Cesium内部属性)
                    if (layer._loading) {
                        allLoaded = false;
                        break;
                    }
                }
            }

            if (allLoaded) {
                // 确保影像加载完成后,场景再渲染一帧,避免闪烁
                viewer.value.scene.postRender.addEventListener(() => {
                    resolve();
                }, viewer.value.scene);
            } else {
                // 每100ms检查一次,平衡性能和准确性
                setTimeout(checkLoaded, 100);
            }
        };

        checkLoaded();
    });
}

关键注意点:将两个方法拆分开写,是为了后续扩展——比如项目中需要添加3D模型、矢量数据加载。

可直接在 waitForCesiumFullyLoaded 方法中添加对应的等待逻辑,无需大幅修改代码,提升可维护性。

封装飞入方法

大屏项目中,往往需要切换多个目标地点(如从全国视角飞入各省、从省视角飞入各市),若每次切换都重复编写代码冗余。

因此,简单封装一个通用的飞入方法。

/**
 * 控制 Cesium 相机飞往指定目标地点(通用封装,支持多场景复用)
 * @param {Object} options - 飞行配置项(必传参数标注,可选参数有默认值)
 * @param {Number} options.longitude - 目标经度(必传,如北京:116.4074)
 * @param {Number} options.latitude - 目标纬度(必传,如北京:39.9042)
 * @param {Number} options.height - 目标高度 (米,必传,根据场景调整,如大屏常用5000米)
 * @param {Number} [options.duration=3] - 飞行时长 (秒,可选,默认3秒,兼顾流畅度和效率)
 * @param {Number} [options.heading=0] - 相机朝向 (角度,可选,0 为正北,可根据需求调整)
 * @param {Number} [options.pitch=-60] - 俯仰角 (角度,可选,-90 为垂直向下,-60为常用视角)
 * @param {Function} [options.onComplete] - 飞行完成回调(可选,如飞行结束后加载区域数据)
 * @param {Function} [options.onCancel] - 飞行取消回调(可选,如用户手动中断飞行时的处理)
 */
const flyToLocation = async (options) => {
    // 校验 viewer 实例是否存在,避免报错
    if (!viewer.value) {
        console.warn('Viewer 实例不存在,无法执行飞行操作');
        ElMessage.warning('地球未加载完成,无法执行飞入操作');
        return;
    }

    // 解构配置项,设置默认值
    const {
        longitude,
        latitude,
        height,
        duration = 3,
        heading = 0,
        pitch = -60,
        onComplete,
        onCancel
    } = options

    // 由于 cesiumReady 触发时已确保影像加载完成,这里直接执行飞行
    viewer.value.camera.flyTo({
        // 将经纬度、高度转换为 Cesium 支持的笛卡尔坐标系
        destination: Cesium.Cartesian3.fromDegrees(longitude, latitude, height),
        // 相机朝向配置(heading:方位角,pitch:俯仰角,roll:翻滚角)
        orientation: {
            heading: Cesium.Math.toRadians(heading), // 角度转弧度(Cesium 内部使用弧度)
            pitch: Cesium.Math.toRadians(pitch),
            roll: 0.0 // 翻滚角,默认0,无需调整
        },
        duration: duration, // 飞行时长
        complete: () => {
            console.log('已飞到目标地点!');
            // 执行完成回调(若有)
            if (onComplete) onComplete();
        },
        cancel: () => {
            console.log('飞行被取消!');
            // 执行取消回调(若有)
            if (onCancel) onCancel();
        },
        canInterrupt: true // 允许用户手动中断飞行(如鼠标拖拽相机)
    })
}

注意:项目使用 Vue3 + setup 语法,需通过 defineExposeflyToLocation 方法导出,外部组件才能调用。

总结

Cesium 作为成熟的3D地理可视化框架,本身的 API 封装已经非常完善,实现飞入效果的核心逻辑并不复杂。

但实际开发中,往往是细节问题导致踩坑,总结几点关键经验,供大家参考:

  1. 加载优化优先选本地地图:生产环境中,务必替换掉官方在线地图,改用本地部署的地图服务(天地图、高德地图本地切片等),彻底解决加载慢、报错的问题;

  2. 加载时机监听不能省:不要省略 waitForCesiumFullyLoaded 方法,否则会出现图层闪烁、空地球等问题,拆分方法便于后续扩展;

  3. 封装逻辑提升复用性:多地点切换场景,一定要封装通用的飞入方法,明确配置项的必传/可选,增加容错处理,减少代码冗余;

  4. 内存管理要注意:页面卸载时,务必销毁 Cesium 实例(包括 viewer事件监听等),避免内存泄漏,导致页面卡顿、崩溃,销毁代码示例如下:

// 页面卸载时销毁 Cesium 实例(Vue3 onUnmounted 中调用)
onUnmounted(() => {
    if (viewer.value) {
        // 销毁 viewer 实例,释放内存
        viewer.value.destroy();
        viewer.value = null;
    }
});

最后,Cesium 的坑大多集中在“加载时机”和“性能优化”上,只要理清初始化流程、做好细节校验,就能快速实现流畅的交互效果。

后续我会继续更新这个大屏项目中 Cesium 的其他坑点,欢迎大家留言交流,共勉!

都2026年了还不会Vite插件开发?手写一个版本管理插件,5分钟包会!

2026年3月4日 09:14

2026年了,不会还有人觉得Vite插件开发很难吧?今天就用一个实战案例,让你彻底掌握它!

开篇:为什么2026年你还需要学Vite插件?

说实话,2026年的前端生态已经相当成熟,各种轮子应有尽有。但正是这样的环境下,能解决特定场景痛点的定制化插件,才更能体现一个开发者的工程化能力。

之前项目上遇到个老生常谈的问题:

  • 线上出bug了,是哪个代码版本?
  • 测试环境明明修复了,生产怎么还有问题?
  • 构建时间是多少,缓存要不要刷新?

手动查Git?太low了。写个脚本?不够优雅。于是,我花10分钟写了个Vite插件发布到了npm仓库(搜索 vite-plugin-unified-version),从此版本信息自动注入构建产物,一劳永逸。

今天就手把手带你写出来,保证看完就会,会了就能用!

一、Vite插件到底是啥?3句话说明白

  • 本质:就是一个普通的JavaScript对象
  • 灵魂:对象里的各种钩子函数(Hooks)
  • 作用:在Vite构建的不同阶段「搞事情」

就像你在煮泡面时(构建过程),可以:

  • 烧水前决定用什么锅(config钩子)
  • 煮面时加点调料(transform钩子)
  • 煮完了关火盛出来(closeBundle钩子)

就这么简单!

二、实战:5分钟开发一个版本管理插件

Step 1:搭个架子

// vite-plugin-version.js
export default function versionPlugin(options = {}) {
  return {
    name: 'vite-plugin-version', // 插件名,必须唯一
    // 钩子函数往这里加
  }
}

这就完事了?对!一个合法的Vite插件就这么简单!

Step 2:获取版本信息

我们要拿到Git commit ID和构建时间:

import { execSync } from 'child_process';

export default function versionPlugin(options = {}) {
  // 获取Git commit ID
  let commitId = 'unknown';
  try {
    commitId = execSync('git rev-parse --short HEAD').toString().trim();
  } catch {
    console.log('⚠️ 不是Git仓库,使用unknown版本');
  }
  
  // 记录构建时间
  const buildTime = new Date().toLocaleString('zh-CN');
  
  return {
    name: 'vite-plugin-version',
    // 钩子函数...
  }
}

知识点execSync可以执行系统命令,但别忘了try-catch,不是所有项目都用Git!

Step 3:注入到HTML

这是最核心的部分,用 transformIndexHtml 钩子:

transformIndexHtml(html) {
  // 要注入的内容
  const injectContent = `
    <!-- 版本信息-自动注入 -->
    <meta name="app_version" content="${commitId}" />
    <meta name="app_build_time" content="${buildTime}" />
    <script>
      window.app_version = "${commitId}";
      window.app_build_time = "${buildTime}";
    </script>
  `;
  
  // 插入到</head>前面
  return html.replace('</head>', injectContent + '\n</head>');
}

核心技巧:字符串替换是最简单可靠的注入方式,不用怕出错!

Step 4:添加编译时常量)

想在Vue/React组件里直接用?用 config 钩子:

config() {
  return {
    define: {
      __APP_VERSION__: JSON.stringify(commitId),
      __BUILD_TIME__: JSON.stringify(buildTime)
    }
  };
}

然后在组件里:

<script setup>
console.log('当前版本:', __APP_VERSION__)
console.log('构建时间:', __BUILD_TIME__)
</script>

Step 5:友好的控制台提示

closeBundle 在构建完成后给点反馈:

closeBundle() {
  console.log(`
    ✅ 版本注入成功!
    版本号:${commitId}
    构建时间:${buildTime}
    访问方式:window.app_version 或 __APP_VERSION__
  `);
}

三、完整代码:拿去就能用!

把上面拼起来,再加点配置选项:

import { execSync } from 'child_process';

export default function versionPlugin(options = {}) {
  // 可配置的键名
  const VERSION_KEY = options.versionKey || 'app_version';
  const TIME_KEY = options.timeKey || 'app_build_time';
  const INJECT_META = options.injectMeta !== false;
  
  // 获取版本信息
  let commitId = 'unknown';
  let buildTime = new Date().toLocaleString('zh-CN');
  
  try {
    commitId = execSync('git rev-parse --short HEAD').toString().trim();
  } catch {}
  
  return {
    name: 'vite-plugin-version',
    
    // 注入编译时常量
    config() {
      return {
        define: {
          [`__${VERSION_KEY.toUpperCase()}__`]: JSON.stringify(commitId),
          [`__${TIME_KEY.toUpperCase()}__`]: JSON.stringify(buildTime)
        }
      };
    },
    
    // 注入HTML
    transformIndexHtml(html) {
      let injectContent = '';
      
      if (INJECT_META) {
        injectContent += `
    <meta name="${VERSION_KEY}" content="${commitId}" />
    <meta name="${TIME_KEY}" content="${buildTime}" />`;
      }
      
      injectContent += `
    <script>
      window.${VERSION_KEY} = "${commitId}";
      window.${TIME_KEY} = "${buildTime}";
    </script>`;
      
      return html.replace('</head>', injectContent + '\n</head>');
    },
    
    // 构建完成提示
    closeBundle() {
      console.log(`\n✅ [版本插件] 构建成功 v-${commitId}`);
    }
  };
}

四、如何使用?

// vite.config.js
import versionPlugin from './vite-plugin-version';

export default {
  plugins: [
    versionPlugin({
      versionKey: 'my_app_version',  // 自定义版本key
      injectMeta: true                // 是否注入meta标签
    })
  ]
}

运行 npm run build,你的HTML就会自动带上版本信息:

<head>
  <meta name="my_app_version" content="a3b9c2d" />
  <meta name="app_build_time" content="2026/3/15 14:30:22" />
  <script>
    window.my_app_version = "a3b9c2d";
    window.app_build_time = "2026/3/15 14:30:22";
  </script>
</head>

五、还能怎么玩?

学会了基础,你可以:

  1. 注入更多信息:分支名、构建环境、打包时间
  2. 生成版本文件:用generateBundle钩子输出version.json
  3. 版本对比:开发环境提醒版本更新
  4. 自动标签:构建成功自动打Git tag
// 扩展:生成version.json
generateBundle() {
  this.emitFile({
    type: 'asset',
    fileName: 'version.json',
    source: JSON.stringify({
      version: commitId,
      buildTime: buildTime,
      env: process.env.NODE_ENV
    })
  });
}

六、总结:学Vite插件值不值?

值!而且很值!

  • 学习成本低:一个对象+几个钩子函数
  • 应用场景广:任何自动化需求都能用插件解决
  • 提升工程化能力:从「用工具」到「造工具」的跨越

记住核心三要素:

  1. name:插件身份证
  2. 钩子函数:在正确的时间做正确的事
  3. 配置选项:让插件更灵活

最后留个作业:给这个插件加个功能,打包时如果版本号没变就警告,防止忘记更新版本。评论区等你答案!


关注我的公众号 大前端历险记 获取更多前端姿势!

前端分享一个33号远征队的效果!

作者 苏武难飞
2026年3月4日 08:53

在33号远征队中有一个效果是人物随风变成花瓣消失,最近在网上看到了用THREE.JS实现的类似效果,在这里也给大家分享一下。

cover

1. 加载文本

我们还是按照我们以前的经验从易到难实现这个效果,首先肯定是加载一段文本!

const loader = new FontLoader();
loader.load('/font/Zhi_Mang_Xing_Regular_2.json', function (font) {

    
    const shapes = font.generateShapes('春风若有怜花意,可否许我再少年', 5);

    const geometry = new THREE.ShapeGeometry(shapes);

    geometry.center();

    const material = new THREE.MeshBasicMaterial({
        color: 0xECCFA3
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
});

20260228161319.png

很简单,但是由于我们之后要对这个文本进行溶解效果,所以性能是很重要的,目前这样加载的文本顶点数量极大!对性能影响也非常大!

20260228161924

1.2 采用MSDF

“MSDF 是一种利用纹理通道存储矢量距离场信息的技术,它能让 3D 文字以极低的顶点成本(每个字仅需 2 个三角形),实现无论如何放大都始终如矢量般锐利、无锯齿的渲染效果。”

简单的说 MSDF 走了一条“数学曲线”道路:

它不在贴图里存储颜色,而是存储距离。它利用图片的 R、G、B 三个通道 分别存储不同的距离场信息。通过这三个通道的交集运算,GPU 可以在渲染时实时“计算”出极其锐利的边缘。

20260228163532.png

可以看到我们采用了MSDF之后顶点数量大幅下降!

1.3 应该如何使用MSDF

在1.2的例子中我们已经看到了MSDF的优势,我们来详细说说应该怎么使用~

首页我们先用msdf-bmfont-xml这个库生成我们需要的文字距离场

msdf-bmfont ZhiMangXing-Regular.ttf \
-f json \
-o ZhiMangXing.png \
--font-size 64 \
--distance-range 16 \
--texture-padding 8 \
--border 2 \
--smart-size

my-font

可以看到我们确实是生成了一个文字信息的图片距离场!

接下来由于THREE.JS中没有提供默认的MSDF实现所以我们还需要用到另一个库

npm i three-msdf-text-utils@^1.2.1

import {MSDFTextGeometry, MSDFTextNodeMaterial} from "three-msdf-text-utils";

const response = await fetch("/font/ZhiMangXing-Regular.json");
const fontData = await response.json();

// Create text geometry
const textGeometry = new MSDFTextGeometry({
    text: "春风若有怜花意,可否许我再少年",
    font: fontData,
    width: 1000,
    align: "center",
});

const textureLoader = new THREE.TextureLoader();
const fontAtlasTexture = await textureLoader.loadAsync('/font/font.png');
fontAtlasTexture.colorSpace = THREE.NoColorSpace;
fontAtlasTexture.minFilter = THREE.LinearFilter;
fontAtlasTexture.magFilter = THREE.LinearFilter;
fontAtlasTexture.wrapS = THREE.ClampToEdgeWrapping;
fontAtlasTexture.wrapT = THREE.ClampToEdgeWrapping;
fontAtlasTexture.generateMipmaps = false;

const textMaterial = new MSDFTextNodeMaterial({
    map: fontAtlasTexture,
    wireframe: true,
    transparent: true,
});

textMaterial.alphaTest = 0.1;
const mesh = new THREE.Mesh(textGeometry, textM aterial);

2. 文字溶解效果

我们已经成功用MSDF加载出来文本,之后就要考虑实现我们的溶解效果。

首先我们需要一个蒙版遮罩图片

perlin

THREE.JS中加载这个遮罩纹理

const textureLoader = new THREE.TextureLoader();
const perlinTexture = await textureLoader.loadAsync("/textures/perlin.webp");
perlinTexture.colorSpace = THREE.NoColorSpace;
perlinTexture.minFilter = THREE.LinearFilter;
perlinTexture.magFilter = THREE.LinearFilter;
perlinTexture.wrapS = THREE.RepeatWrapping;
perlinTexture.wrapT = THREE.RepeatWrapping;
perlinTexture.generateMipmaps = false;

使用TSL方式应用加载进来的纹理

createTextMaterial(fontAtlasTexture, perlinTexture) {
        const textMaterial = new MSDFTextNodeMaterial({map: fontAtlasTexture});
        const glyphUv = attribute("glyphUv", "vec2");
        const perlinTextureNode = texture(perlinTexture, glyphUv);
        const boostedPerlin = pow(perlinTextureNode, 4);
        textMaterial.colorNode = boostedPerlin;
        return textMaterial;
}

此时的效果

这个时候我们能发现字体的颜色已经混合了纹理图,而纹理图是一个黑白灰的图片,色值也就是0.0~1.0,所以如果我们设置小于0.5的不显示!

createTextMaterial(fontAtlasTexture, perlinTexture) {
        const textMaterial = new MSDFTextNodeMaterial({
            map: fontAtlasTexture,
            transparent: true,
        });

        const glyphUv = attribute("glyphUv", "vec2");

        const perlinTextureNode = texture(perlinTexture, glyphUv);
        const boostedPerlin = pow(perlinTextureNode, 2);
        const dissolve = step(boostedPerlin, 0.5);

        textMaterial.colorNode = boostedPerlin;
        const msdfOpacity = textMaterial.opacityNode;
        textMaterial.opacityNode = msdfOpacity.mul(dissolve);
        return textMaterial;
    }

20260302110234

这样就有了一个静态的字体溶解效果!

npm i tweakpane

接下来我们添加一个调试工具并且把我们的0.5改成动态调试看看效果

 const uProgress = uniform(0.0);

    
        const glyphUv = attribute("glyphUv", "vec2");

        const perlinTextureNode = texture(perlinTexture, glyphUv);
        const boostedPerlin = pow(perlinTextureNode, 2);
        const dissolve = step(uProgress, boostedPerlin);

        textMaterial.colorNode = boostedPerlin;
        const msdfOpacity = textMaterial.opacityNode;
        textMaterial.opacityNode = msdfOpacity.mul(dissolve);

20260302110234

此时我们能发现目前的溶解有两个问题

  • 每个文字的溶解都是一样的
  • 目前的溶解效果是从0.3~0.7

2.1 改变溶解中心

目前每个字体的溶解看起来同步的原因是因为我们用了glyphUv,glyphUv 通常是一个 0 到 1 之间的局部坐标。

  • 对于每一个字符(无论是“春”还是“风”),它们左上角的 glyphUv 都是 (0,0)(0, 0),右下角都是 (1,1)(1, 1)
  • 当直接用 glyphUv 来计算溶解(比如通过噪声函数)时,每个字在相同相对位置的采样值是完全一样的。
  • 结果:所有字会像排好队一样,整齐划一地同时开始、同时结束溶解
const center = attribute("center", "vec2");

所以我们应该获取每个字符自己的坐标系,center 是每个字符在屏幕或场景中的 世界坐标/中心坐标。

  • 唯一性:“春”字的 center 可能是 (10,5)(10, 5),“风”字的 center 可能是 (15,5)(15, 5)
 const uProgress = uniform(0.0);
        const uCenterScale = uniform(0.05);
        const uGlyphScale = uniform(0.75);
        
        debugFolder.addBinding(uProgress, "value", {
            min: 0,
            max: 1,
            label: "progress",
        });

        debugFolder.addBinding(uCenterScale, "value", {
            min: 0,
            max: 1,
            label: "centerScale",
        });

        debugFolder.addBinding(uGlyphScale, "value", {
            min: 0,
            max: 1,
            label: "glyphScale",
        });

        const glyphUv = attribute("glyphUv", "vec2");
        const center = attribute("center", "vec2");

        const customUv = center.mul(uCenterScale).add(glyphUv.mul(uGlyphScale));
        
        const perlinTextureNode = texture(perlinTexture, customUv);
        // const boostedPerlin = pow(perlinTextureNode, 2);
        const dissolve = step(uProgress, perlinTextureNode);

        textMaterial.colorNode = perlinTextureNode;
        const msdfOpacity = textMaterial.opacityNode;
        textMaterial.opacityNode = msdfOpacity.mul(dissolve);
        return textMaterial;

03.gif

2.2 归一化溶解进度

为什么现在溶解感觉是从 0.3 ~ 0.7开始呢,是因为我们的蒙版图片用的是Perlin 噪声。

  • 理论上:它的值域是 [0,1][0, 1]
  • 实际上:极端的 00(纯黑)和 11(纯白)出现概率极低。大部分像素值都挤在 0.30.30.70.7 之间。

      const uProgress = uniform(0.0);
        const uNoiseRemapMin = uniform(0.4);
        const uNoiseRemapMax = uniform(0.87);
        const uCenterScale = uniform(0.05);
        const uGlyphScale  = uniform(0.75);

        const perlinTextureNode = texture(perlinTexture, customUv);
        const perlinRemap = clamp(
            perlinTextureNode.sub(uNoiseRemapMin).div(uNoiseRemapMax.sub(uNoiseRemapMin)),
            0,
            1
        );
        const dissolve = step(uProgress, perlinRemap);

        textMaterial.colorNode = perlinRemap;
        const msdfOpacity = textMaterial.opacityNode;
        textMaterial.opacityNode = msdfOpacity.mul(dissolve);

perlinRemap 做了什么?

这行代码本质上是一个线性插值函数的逆运算:Result=NoiseMinMaxMinResult = \frac{Noise - Min}{Max - Min}

  • 减法 sub(uNoiseRemapMin):将整体亮度向下压。原本 0.30.3 的地方变成了 00
  • 除法 div(...):将剩下的区间“拉伸”开。原本 0.70.7 的地方经过减法变成了 0.40.4,再除以区间长度(0.70.3=0.40.7 - 0.3 = 0.4),结果变成了 1.01.0
  • clamp(..., 0, 1):确保拉伸后的值不会超出 [0,1][0, 1] 范围,防止产生奇怪的过曝或负值。

结果:把原来缩在 [0.3,0.7][0.3, 0.7] 的“窄窄的一团颜色”,暴力拉伸到了 [0.0,1.0][0.0, 1.0] 的整个空间。

04

2.3 加上字体颜色

    const uDissolvedColor = uniform(new THREE.Color("#5E5E5E"));
    const uBaseColor = uniform(new THREE.Color("#ECCFA3"));
    ...
    ...
    ...
    const colorMix = mix(uBaseColor, uDissolvedColor,desaturationProgress);
    textMaterial.colorNode = colorMix;
    const msdfOpacity = textMaterial.opacityNode;
    textMaterial.opacityNode = msdfOpacity.mul(dissolve);

05

2.4 使用gsap触发动画

目前的效果我们都是拖拽来展示的,手累的不行!我们直接改成用gsap来触发这一系列的动画

triggerGommage() {
        gsap.to(this.uProgress, {
            value: 1,
            duration: 4,
            ease: "linear",
        });
    }

    resetGommage() {
        this.uProgress .value = 0;
    }

06

3. 粒子灰尘

在具体实现这个粒子灰尘前我们应该提前思考我们的粒子灰尘都需要什么属性

  • 位置(用来做粒子的移动)
  • 生成的时间(用来处理最后的消失状态)
  • 存在的时间(用来判断何时消失)
  • 尺寸(粒子的大小)
  • 随机数(确保粒子不是都长一样)
    async initialize(perlinTexture, dustParticleTexture) {

        const dustGeometry = new THREE.PlaneGeometry(0.02, 0.02);
        this.spawnPos = new Float32Array(this.MAX_DUST * 3);
       
        this.birthLifeSeedScale = new Float32Array(this.MAX_DUST * 4);
        this.currentDustIndex = 0;

        dustGeometry.setAttribute(
            "aSpawnPos",
            new THREE.InstancedBufferAttribute(this.spawnPos, 3)
        );
        dustGeometry.setAttribute(
            "aBirthLifeSeedScale",
            new THREE.InstancedBufferAttribute(this.birthLifeSeedScale, 4)
        );

        const material = this.createDustMaterial(perlinTexture, dustParticleTexture);
        this.dustMesh = new THREE.InstancedMesh(dustGeometry, material, this.MAX_DUST);
        return this.dustMesh;
    }


     createDustMaterial(perlinTexture, dustTexture) {
        const material = new THREE.MeshBasicMaterial({
            map: dustTexture,
            transparent: true,
            depthWrite: false,
            depthTest: false,
        });

        return material;
    }

  • aSpawnPos用来保存位置信息
  • aBirthLifeSeedScale用来保存剩余的所有信息,用一个属性保存是为了性能考虑

纹理图片

20260302161636.png

此时我们的粒子已经加载到屏幕上了,然后我们调整一下aSpawnPos

spawnDust(spawnPos) {
    this.spawnPos[id * 3 + 0] = spawnPos.x;
    this.spawnPos[id * 3 + 1] = spawnPos.y;
    this.spawnPos[id * 3 + 2] = spawnPos.z;
    this.dustMesh.geometry.attributes.aSpawnPos.needsUpdate = true;
}



createDustMaterial(perlinTexture, dustTexture) {
    const material = new THREE.MeshBasicMaterial({
        map: dustTexture,
        transparent: true,
        depthWrite: false,
        depthTest: false,
    });

    const aSpawnPos = attribute("aSpawnPos", "vec3");
    const dustSample = texture(dustTexture, uv());
    const uDustColor = uniform(new THREE.Color("#FFFFFF"));
    material.colorNode = vec4(uDustColor, dustSample.a);
    material.positionNode = aSpawnPos.add(positionLocal);

    return material;
}

for (let i = 0; i < 100; i++) {
    this.spawnDust(
        new THREE.Vector3(
            (Math.random() * 2 - 1) * 0.5,
            (Math.random() * 2 - 1) * 0.5,
            0,
        )
    );
}

20260303100116

3.1 让粒子动起来

刚才我们只是用了aSpawnPos让粒子的位置确定下来,如果需要让粒子动起来其实就是根据一个最简单的公式

位移(Displacement=速度(Velocity×时间(Time位移(Displacement)= 速度(Velocity)× 时间(Time)


  spawnDust(spawnPos) {
        if (this.currentDustIndex === this.MAX_DUST) this.currentDustIndex = 0;
        const id = this.currentDustIndex;
        this.currentDustIndex = this.currentDustIndex + 1;
        this.spawnPos[id * 3 + 0] = spawnPos.x;
        this.spawnPos[id * 3 + 1] = spawnPos.y;
        this.spawnPos[id * 3 + 2] = spawnPos.z;
        this.birthLifeSeedScale[id * 4 + 0] = performance.now() * 0.001; // 👈诞生时间
        ...
        ...

        this.dustMesh.geometry.attributes.aSpawnPos.needsUpdate = true;
        this.dustMesh.geometry.attributes.aBirthLifeSeedScale.needsUpdate = true;
    }

    createDustMaterial(perlinTexture, dustTexture) {
        ...
        ...
        const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
        const aBirth = aBirthLifeSeedScale.x;


        const uDustColor = uniform(new THREE.Color("#8A8A8A"));
        const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
        const uWindStrength = uniform(0.3);

        
        const dustAge = time.sub(aBirth);

        const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);
        const driftMovement = windImpulse;

        const dustSample = texture(dustTexture, uv());
        material.colorNode = vec4(uDustColor, dustSample.a);
        material.positionNode = aSpawnPos
            .add(driftMovement)
            .add(positionLocal);

        return material;
    }

我们是模拟了一个风向左吹的方向const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());和一个风力强度const uWindStrength = uniform(0.3)然后通过const dustAge = time.sub(aBirth);获取时间最后通过material.positionNode = aSpawnPos .add(driftMovement) .add(positionLocal);把这个位移加到原始位置上

07

目前我们已经成功模拟了一个向左的移动,接下来继续添加一个向上方向的恒定力!

const uRiseSpeed = uniform(0.1);
const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);
const rise = vec3(0.0, dustAge.mul(uRiseSpeed), 0.0);
const driftMovement = windImpulse.add(rise);

08

3.2 添加一些随机性

现在我们的粒子的方向没什么问题了但是每个粒子的运动都是一致的显得非常呆板,接下来我们应该再添加一个随机的漩涡力,还记得我们的aBirthLifeSeedScale保存的信息么

  • 生成的时间(用来处理最后的消失状态)
  • 存在的时间(用来判断何时消失)
  • 尺寸(粒子的大小)
  • 随机数(确保粒子不是都长一样)

我们接下来就用随机数来让每个粒子的运动产生差异

createDustMaterial(perlinTexture, dustTexture) {

  ...
  ...

  const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
  const aBirth = aBirthLifeSeedScale.x;
  const aLife = aBirthLifeSeedScale.y;
  const aSeed = aBirthLifeSeedScale.z;
  const aScale = aBirthLifeSeedScale.w;
 
  const uNoiseSpeed = uniform(0.015);
  
  const randomSeed = vec2(aSeed.mul(1230.4), aSeed.mul(5670.8));
  
  const noiseUv = aSpawnPos.xz
    .add(randomSeed)
    .add(uWindDirection.xz.mul(dustAge.mul(uNoiseSpeed)));
  const noiseSample = texture(perlinTexture, noiseUv).x;
}

noiseSample的值是我们最开始的perlin图片也就是0.0~1.0,但是只有x的值还是不够的我们在y轴上的运动也应该受到湍流的影响所以我们还需要一个noise

 ...
 ...
 // x轴
 const noiseSample = texture(perlinTexture, noiseUv).x;
 // y轴
 const noiseSampleBis = texture(perlinTexture, noiseUv.add(vec2(13.37, 7.77))).x;

 // 把值从 0.0 ~ 1.0 变成 -1.0~1.0
 const turbulenceX = noiseSample.sub(0.5).mul(2);
 const turbulenceY = noiseSampleBis.sub(0.5).mul(2);

此时我们已经有了湍流的位移,接下来需要根据进度把这个力加到我们之前计算的位置上!那么进度就算我们的粒子的声明周期啦也就是aLife


createDustMaterial(perlinTexture, dustTexture) {
        const material = new THREE.MeshBasicMaterial({
            transparent: true,
            depthWrite: false,
            depthTest: false,
        });

        const aSpawnPos = attribute("aSpawnPos", "vec3");
        const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
        const aBirth = aBirthLifeSeedScale.x;
        const aLife = aBirthLifeSeedScale.y;
        const aSeed = aBirthLifeSeedScale.z;
        const aScale = aBirthLifeSeedScale.w;

        const uWobbleAmp = uniform(0.6);
        const uNoiseScale = uniform(30.0);
        const uNoiseSpeed = uniform(0.015);
        const uDustColor = uniform(new THREE.Color("#8A8A8A"));
        const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
        const uWindStrength = uniform(0.3);

        const dustAge = time.sub(aBirth);
        const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);

        const randomSeed = vec2(aSeed.mul(1230.4), aSeed.mul(5670.8));

        const noiseUv = aSpawnPos.xz
            .add(randomSeed)
            .add(uWindDirection.xz.mul(dustAge.mul(uNoiseSpeed)));

        const noiseSample = texture(perlinTexture, noiseUv).x;

        const noiseSampleBis = texture(perlinTexture, noiseUv.add(vec2(13.37, 7.77))).x;

        const turbulenceX = noiseSample.sub(0.5).mul(2);
        const turbulenceY = noiseSampleBis.sub(0.5).mul(2);

        const swirl = vec3(clamp(turbulenceX.mul(lifeInterpolation), 0, 1.0), turbulenceY.mul(lifeInterpolation), 0.0).mul(uWobbleAmp);

        const uRiseSpeed = uniform(0.1);

        const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);
        const riseFactor = clamp(noiseSample, 0.3, 1.0);
        const rise = vec3(0.0, dustAge.mul(uRiseSpeed).mul(riseFactor), 0.0);
        const driftMovement = windImpulse.add(rise).add(swirl);

        const dustSample = texture(dustTexture, uv());
        material.colorNode = vec4(uDustColor, dustSample.a);
        material.positionNode = aSpawnPos
            .add(driftMovement)
            .add(positionLocal);

        return material;
}

09

3.3 控制粒子的大小和显示

我们现在已经有了lifeInterpolation,所以可以很方便的控制粒子的缩放大小和渐隐渐显

createDustMaterial(perlinTexture, dustTexture) {
  ...
  const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);
  const scaleFactor = smoothstep(float(0), float(0.05), lifeInterpolation);
  const fadingOut = float(1.0).sub(
    smoothstep(float(0.8), float(1.0), lifeInterpolation)
  );
  ...
  material.positionNode = aSpawnPos
    .add(driftMovement)
    .add(positionLocal.mul(aScale.mul(scaleFactor)));
  material.opacityNode = fadingOut;
  ...
}

10

3.4 调整粒子初始位置

之前我们的粒子初始位置是我们为了测试用的

for (let i = 0; i < 40; i++) {
    this.spawnDust(
        new THREE.Vector3(
            (Math.random() * 2 - 1) * 0.5,
            (Math.random() * 2 - 1) * 0.5,
            0,
        )
    );
}

现在我们调整成文本的位置,然后配合之前完成的效果

getRandomPositionInMesh() {
    const min = this.worldPositionBounds.min;
    const max = this.worldPositionBounds.max;
    const x = Math.random() * (max.x - min.x) + min.x;
    const y = Math.random() * (max.y - min.y) + min.y;
    const z = Math.random() * 0.5;
    return new THREE.Vector3(x, y, z);
}


triggerGommage() {
       
        if (this.gommageTween || this.spawnDustTween) return;

        this.spawnDustTween = gsap.to({}, {
            duration: this.dustInterval,
            repeat: -1,
            onRepeat: () => {
                const p = this.msdfTextEntity.getRandomPositionInMesh();
                this.dustParticlesEntity.spawnDust(p);
            },
        });

        this.gommageTween = gsap.to(this.uProgress, {
            value: 1,
            duration: 5,
            ease: "linear",
            onComplete: () => {
                this.spawnDustTween?.kill();
                this.spawnDustTween = null;
                this.gommageTween = null;
            },
        });
}

11

4. 添加花瓣

花瓣和粒子的大体逻辑是一致的!花瓣有自己的模型文件可以参考我之前的博客用一个粒子效果告别蛇年迎来马年~

首先看看花瓣的模型是什么样子

12

ok,牌没有问题!

那我们直接照搬之前的粒子效果只不过把模型换成我们的花瓣模型


    async initialize(perlinTexture, petalGeometry) {


        const petalGeo = petalGeometry.clone();
        const scale = 0.15;
        petalGeo.scale(scale, scale, scale);

        this.spawnPos = new Float32Array(this.MAX_PETAL * 3);
        this.birthLifeSeedScale = new Float32Array(this.MAX_PETAL * 4);
        this.currentPetalIndex = 0;

        petalGeo.setAttribute(
            "aSpawnPos",
            new THREE.InstancedBufferAttribute(this.spawnPos, 3)
        );
        petalGeo.setAttribute(
            "aBirthLifeSeedScale",
            new THREE.InstancedBufferAttribute(this.birthLifeSeedScale, 4)
        );
        const material = this.createPetalMaterial(perlinTexture);
        this.petalMesh = new THREE.InstancedMesh(petalGeo, material, this.MAX_PETAL);
        return this.petalMesh;
    }

    createPetalMaterial(perlinTexture) {
        const material = new THREE.MeshBasicMaterial({
            transparent: true,
            side: THREE.DoubleSide,
        });

        const aSpawnPos = attribute("aSpawnPos", "vec3");
        const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
        const aBirth = aBirthLifeSeedScale.x;
        const aLife = aBirthLifeSeedScale.y;
        const aSeed = aBirthLifeSeedScale.z;
        const aScale = aBirthLifeSeedScale.w;

        const uDustColor = uniform(new THREE.Color("#8A8A8A"));
        const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
        const uWindStrength = uniform(0.3);
        const uRiseSpeed = uniform(0.1); 
        const uNoiseScale = uniform(30.0); 
        const uNoiseSpeed = uniform(0.015); 
        const uWobbleAmp = uniform(0.6); 

        const dustAge = time.sub(aBirth);
        const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);

        const randomSeed = vec2(aSeed.mul(123.4), aSeed.mul(567.8));
        const noiseUv = aSpawnPos.xz
            .mul(uNoiseScale)
            .add(randomSeed)
            .add(uWindDirection.xz.mul(dustAge.mul(uNoiseSpeed)));

        const noiseSample = texture(perlinTexture, noiseUv).x;
        const noiseSammpleBis = texture(perlinTexture, noiseUv.add(vec2(13.37, 7.77))).x;

        const turbulenceX = noiseSample.sub(0.5).mul(2);
        const turbulenceY = noiseSammpleBis.sub(0.5).mul(2);

        const swirl = vec3(clamp(turbulenceX.mul(lifeInterpolation), 0., 1.0), turbulenceY.mul(lifeInterpolation), 0.0).mul(uWobbleAmp);

        const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);

        const riseFactor = clamp(noiseSample, 0.3, 1.0);
        const rise = vec3(0.0, dustAge.mul(uRiseSpeed).mul(riseFactor), 0.0);
        const driftMovement = windImpulse.add(rise).add(swirl);

        const scaleFactor = smoothstep(float(0), float(0.05), lifeInterpolation);
        const fadingOut = float(1.0).sub(
            smoothstep(float(0.8), float(1.0), lifeInterpolation)
        );

        material.colorNode = vec4(uDustColor, 1);
        material.positionNode = aSpawnPos
            .add(driftMovement)
            .add(positionLocal.mul(aScale.mul(scaleFactor)));
        material.opacityNode = fadingOut;

        return material;
    }


13.gif

花瓣已经成功加载并且运动轨迹也和之前的粒子灰尘保持一致,现在的问题是花瓣不可能是一个直线型的,所以我们要先扭曲这个花瓣模型


  createPetalMaterial(perlinTexture) {
    const material = new THREE.MeshBasicNodeMaterial({
      transparent: true,
      side: THREE.DoubleSide,
    });

    function rotX(a) {
      const c = cos(a);
      const s = sin(a);
      const ns = s.mul(-1.0);
      return mat3(1.0, 0.0, 0.0, 0.0, c, ns, 0.0, s, c);
    }
    function rotY(a) {
      const c = cos(a);
      const s = sin(a);
      const ns = s.mul(-1.0);
      return mat3(c, 0.0, s, 0.0, 1.0, 0.0, ns, 0.0, c);
    }

    function rotZ(a) {
      const c = cos(a);
      const s = sin(a);
      const ns = s.mul(-1.0);
      return mat3(c, ns, 0.0, s, c, 0.0, 0.0, 0.0, 1.0);
    }

    const aSpawnPos = attribute("aSpawnPos", "vec3");
    ...
    ...

    // 获取花瓣的uv坐标尖端部分y=1根部y=0
    const y = uv().y;
    // 越靠近尖端部分弯曲的越大
    const bendWeight = pow(y, float(3.0));
    // 随时间流逝动态改变完全的程度
    const bend = bendWeight.mul(uBendAmount).mul(sin(dustAge.mul(uBendSpeed.mul(noiseSample))));
    // 沿着x轴弯曲
    const B = rotX(bend);

    const positionLocalUpdated = B.mul(positionLocal);

    material.colorNode = vec4(uDustColor, 1);
    material.positionNode = aSpawnPos
            .add(driftMovement)
            .add(positionLocalUpdated.mul(aScale.mul(scaleFactor)));
    material.opacityNode = fadingOut;

  }

14

可以看到原本平整的花瓣模型现在已经被扭曲的更像是一个真实的飞舞的花瓣,接下来我们需要旋转整个花瓣营造一种随风飘动的效果。

   // x轴的初始相位范围0 ~ 2PI
   const baseX = aSeed.mul(1.13).mod(1.0).mul(TWO_PI);
   // y轴的初始相位范围0 ~ 2PI
   const baseY = aSeed.mul(2.17).mod(1.0).mul(TWO_PI);
   // z轴的初始相位范围0 ~ 2PI
   const baseZ = aSeed.mul(3.31).mod(1.0).mul(TWO_PI)   
   // 时间×速度×振幅
   const spin = dustAge.mul(uSpinSpeed).mul(uSpinAmp);
   // 受湍流影响的x轴旋转
   const rx = baseX.add(spin.mul(0.9).mul(turbulenceX.add(1.5)));
   // 受湍流影响的y轴旋转
   const ry = baseY.add(spin.mul(1.2).mul(turbulenceY.add(1.5)));
   // 受湍流影响的z轴旋转
   const rz = baseZ.add(spin.mul(0.7).mul(turbulenceZ.add(1.5))) 
   // 应用旋转  
   const R = rotY(ry).mul(rotX(rx)).mul(rotZ(rz))   
   const positionLocalUpdated = R.mul(B.mul(positionLocal));

15.gif

Ok,接下来我们给花瓣上颜色!

const uRedColor = uniform(new THREE.Color("#9B0000"));
const uWhiteColor = uniform(new THREE.Color("#EEEEEE"));

// instanceIndex当前处理的模型下标
const petalColor = mix(
            uRedColor,
            uWhiteColor,
            instanceIndex.mod(3).equal(0)
        );

material.colorNode = petalColor;

16.gif

这样已经非常接近最终的效果了,最后的最后我们再添加一个光照!让整个花瓣显得更加立体

const uRedColor = uniform(new THREE.Color("#9B0000"));
const uWhiteColor = uniform(new THREE.Color("#EEEEEE"));
const uLightPosition = uniform(new THREE.Vector3(0, 0, 5));

const positionLocalUpdated = R.mul(B.mul(positionLocal))
// 计算经过旋转后的法线方向
const normalUpdate = normalize(R.mul(B.mul(normalLocal)))
const worldPosition = aSpawnPos
    .add(driftMovement)
    .add(positionLocalUpdated.mul(aScale.mul(scaleFactor)))

// 更新法线方向
material.normalNode = normalUpdate;
material.positionNode = worldPosition;
material.opacityNode = fadingOut;

// 计算花瓣到光源的向量
const lightDirection = normalize(uLightPosition.sub(worldPosition));
// 计算受光照影响之后的颜色
const facing = clamp(abs(dot(normalUpdate, lightDirection)), 0.4, 1);

material.colorNode = petalColor.mul(facing);

17.gif

5. 整合所有效果

OKKKK,到这一步我们所有的分解动作都已经处理完了,最后就是节后字体溶解->粒子灰尘->花瓣实现我们最终的效果就可以了!

    triggerGommage() {
        // Don't start if already running
        if (this.#gommageTween || this.#spawnDustTween || this.#spawnPetalTween) return;
        this.#uProgress.value = 0;
        

        this.#spawnDustTween = gsap.to(
            {},
            {
                duration: this.#dustInterval,
                repeat: -1,
                onRepeat: () => {
                    const p = this.#MSDFTextEntity.getRandomPositionInMesh();
                    this.#DustParticlesEntity.spawnDust(p);
                },
            }
        );

        this.#spawnPetalTween = gsap.to(
            {},
            {
                duration: this.#petalInterval,
                repeat: -1,
                onRepeat: () => {
                    const p = this.#MSDFTextEntity.getRandomPositionInMesh();
                    this.#PetalParticlesEntity.spawnPetal(p);
                },
            }
        );

        this.#gommageTween = gsap.to(this.#uProgress, {
            value: 1,
            duration: 6,
            ease: 'linear',
            onComplete: () => {
                this.#spawnDustTween?.kill();
                this.#spawnPetalTween?.kill();
                this.#spawnDustTween = null;
                this.#gommageTween = null;
                this.#spawnPetalTween = null;
                gsap.delayedCall(1, () => {
                    this.gommageButton.disabled = false;
                    this.gommageButton.classList.remove('disabled');
                });
            },
        });
    }

cover

参考链接

WebGPU Gommage Effect: Dissolving MSDF Text into Dust and Petals with Three.js & TSL

❌
❌