普通视图

发现新文章,点击刷新页面。
今天 — 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提示过渡,这样就可以查看项目运行过程中整个所有加载的文件运行状态结果

基于Spark的配置化离线反作弊系统

作者 百度Geek说
2026年3月5日 16:17

导读 introduction

在作弊手段日益隐蔽和复杂的背景下,单纯依赖在线或实时风控已难以满足深度治理需求。本文系统介绍了一套基于 Spark 的配置化离线反作弊挖掘框架,重点解析其 Extract、Accumulate、Join、Policy 四大核心模块,以及“视图构建”“动态 SQL 生成”“多阶特征计算”“滑动窗口”等关键能力。该框架支持全量历史重算与大规模 Shuffle 计算,通过高度配置化设计,将字段抽取、特征定义、策略判定彻底从代码中解耦,实现策略快速迭代与低成本上线。同时结合数据倾斜治理、列裁剪优化等工程实践,大幅提升稳定性与性能,成为风控体系的重要计算底座。

01 简介

在互联网业务高速发展的大背景下,作弊手段层出不穷,从恶意点击、流量造假,到批量刷单、黑产“薅羊毛”,手法不断翻新、隐蔽性持续增强。这些行为不仅侵蚀了平台的公平秩序,更直接带来显著的经济损失,并严重损害广告主利益和普通用户的体验与信任。因此,全方位、持续演进的反作弊能力已成为互联网产品生态稳定运行的关键基石。

百度基于以上问题构建了一套系统化的企业级反作弊系统,根据时效性和业务需求分为三类:在线反作弊、实时反作弊与离线反作弊。这三类反作弊能力相互补充,共同构建起完整的风控防线,但在防护策略、检测深度和业务价值上各有侧重。

在线反作弊主要负责毫秒级别的请求风险判定,适用于简单规则和轻量级指标,例如从请求头部字段、访问频率等维度快速判断风险,并结合 Redis 等缓存计算实现即时响应。这类机制非常适合于即时性要求极高的场景,例如登录请求拦截或简单阈值规则拦截,但受限于可实时访问的数据维度较少。

实时反作弊在此基础上,通过流式计算分析序列行为、业务上下文和多维特征,在秒级甚至分钟级实现更加精准的策略判定。实时系统能够响应更复杂的行为模式,例如账户连续异常操作、设备跨地域跳变等行为,兼具时效性与一定程度的特征深度,是在线与离线反作弊之间的关键桥梁。具体介绍见基于Flink的配置化实时反作弊系统

然而,在整个百度反作弊体系中,离线反作弊系统的战略价值与日俱上,是构建高精度模型、深度分析行为模式和提升整体风控能力的“底座“

与在线和实时系统相比,离线反作弊不受时效性的约束,可以充分利用完整历史数据进行大规模的批量分析与深度挖掘。其价值主要体现在以下几个方面:

  • 全面的数据视图:离线系统可以访问业务全量日志、用户历史轨迹、跨周期行为等丰富维度的数据,这些数据在在线场景中往往无法实时获取或难以完成整合。
  • 深度行为建模:通过对长期行为序列的分析,可以发现复杂的作弊模式,例如跨账号关联、长期周期异常趋势、人机行为判别等,这些模式在短周期内往往难以捕捉。
  • 特征工程与策略优化:离线挖掘计算出的高维特征是构建机器学习模型的基础,也是实时风控策略得以优化的重要来源。无论是统计类指标、聚合行为分布还是时序特征,这些信号都能够显著提升模型精度。
  • 黑产库与历史知识积累:离线分析能够构建不断增长的“黑产行为库”和风险特征库,支持跨业务线共享和复用。这种长期积累的“经验库”是在线/实时系统难以替代的。

正因如此,百度在反作弊领域投入多年经验,构建了高效的离线挖掘框架,用于批量处理用户行为日志、提取高维特征、训练模型并验证策略,为线上策略提供长期优化与精准判定的动力支持,使整套反作弊体系具备更强的防护能力和持续学习能力。本文介绍该离线挖掘框架的整体架构和设计亮点,并深度解读特征计算链路、性能优化实践以及配置化模块化能力,展示其在刷量识别、账号行为分析、广告作弊治理等场景中的工程价值。

02 离线挖掘框架解决的核心问题

2.1 成本和实现平衡

流式实现特征计算往往需要更高的计算成本,而对于大部分反作弊策略的实现并不需要极高的时效性要求,离线挖掘框架恰恰是解决流式运行高成本,高压力和运行时效进行平衡的媒介,小时级别的产出已可满足大部分业务需求。

2.2 全量历史重算能力和大规模Shuffle

离线的核心优势是:强全量能力 + 强历史回溯能力 + 强复杂聚合能力。

全量历史重算能力:

  • 可以直接扫描全量历史数据(天级、月级、年级)
  • 支持特征逻辑变更后的全量重算
  • 支持复杂回溯计算

大规模Shuffle:

  • 可以做大规模 Shuffle
  • 支持复杂 SQL(多层嵌套、窗口、分组)
  • 支持大表与大表 Join

2.3 多场景数据源和输出灵活对接

离线数据往往面临各种数据格式、表等复杂多样的数据源及灵活多变的输出格式。

  • 数据源类型:目前我们的框架现有数据源支持Turing表, UDW(hive)表, AFS(Parquet, CSV, Txt, PB)文件、用户自定义SQL等,并可以灵活增加wget接入数据源等功能。
  • 输出类型:对于输出也灵活实现了Turing表, UDW(hive)表, AFS(Parquet, CSV, Txt, PB)文件等格式功能,并可以增加输出至clickhouse、doris等存储媒介便于监控分析。
  • 多数据源输入:实现多种数据源同时输入解析,并支持对不同数据源分别清洗过滤,并支持对各数据源单独筛选 & 分区, 实现对不同数据的灵活操控。

03 反作弊离线挖掘框架介绍

3.1 离线挖掘整体框架

百度离线挖掘框架使用生效流程图如下:

图片

上图展示了离线挖掘框架在整个反作弊系统中的使用流程图,即框架在反作弊流程中的使用过程:

  • 用户在配置平台配置 数据源、特征、策略、输出维度等各项配置conf文件。
  • 用户通过配置平台打conf包到对应afs地址, 在TDS平台中筛选集群信息、资源配置等、读取conf配置文件, 并手动调起spark任务。
  • 离线挖掘框架会加载配置信息, 运行spark 任务, 任务结束后将结果输出到 AFS。
  • 用户使用一脉、Jupter等写ETL 任务评估策略是否符合预期, 若符合预期, 则将特征、策略配置上线, 否则修改特征、策略配置等重新运行。

具体离线挖掘框架流程图:

图片

上图展示了离线挖掘框架的整体流程图,分为 extractor 模块、accmulator 模块、joiner 模块、policy 模块等。

Extract (抽取)模块:

抽取(Extractor)模块是离线挖掘框架的数据入口与标准化核心,负责从原始日志或明细表中读取多源行为数据,按照既定 schema 进行字段筛选、类型转换、脏数据过滤和统一格式映射,将分散、异构的原始数据加工为结构清晰、字段规范、可计算的标准行为数据集;同时结合配置文件(如特征或字典配置)完成基础标签补充与维度对齐,为后续的视图构建与聚合计算提供稳定、统一的数据基础。

图片

这张图展示了抽取模块实现的功能:

  1. 输入数据:对原始输入数据源进行解析(包括Hive表,PB日志,parquet数据解析等)
  2. 解析特征配置文件:特征fea_001类型为segment(统计数据),维度为query,条件为:app_id=5&&city=‘北京’,即统计符合条件在app_id=5&&city=‘北京’的每个query的数量。同理特征fea_002为统计符合条件product_id不空的clkip的数量。
  3. 自定义字段:用户可以根据udf函数自定义所需要的字段。
  4. 结果数据:从日志中解析抽取出所有特征中所需要的字段,以图中示例结果为:fea_id,log_timestamp,query,app_id,agent_id,baiduid,product_id,…,其中log_timestamp为必输出数据。

除了 spark sql 支持的所有原生 functions 之外,结合业务实际使用场景,还支持了 多个自定义数据处理算子,并支持用户自定义udf扩展

图片

Accmulate (聚合)模块:

Accumulator(聚合)模块是整个系统的“计算引擎”,负责将海量的原始日志转化为具有统计意义的反作弊特征。基于指定维度和时间窗口对行为数据进行结构化聚合计算,将原始事件流转化为可用于策略判断和模型输入的指标特征。它支持多种聚合算子(如 count、sum、distinct 等)、条件过滤统计以及多维度分组能力,并通过状态管理机制维护窗口内历史数据,实现连续、可配置的特征生成。从工程视角来看,Accumulate 本质上是一个配置驱动的多维度窗口化统计计算模块,是连接原始行为数据与风险决策逻辑之间的关键桥梁。

以下是该模块的详细执行流程及功能解析:

图片

核心流程图解析
  • 数据准备:接收来自 Extractor 的标准化数据,并根据 feature.yml 加载特征定义。
  • 视图构建:这是 Themis 框架的特色,通过 View 和 DataView 概念,将数据按不同的维度(如 baiduid、IP、cookie)进行切分。
  • 动态 SQL 生成:框架不会硬编码聚合逻辑,而是根据配置动态拼接 Spark SQL 语句(如 SUM、COUNT、DISTINCT)。
  • 时间窗口:根据配置文件中的配置的时间窗口进行划分时间段
关键技术特性
  • 视图构建:视图构建,将同一批行为数据转换为带有“统计主体标识”的统一结构,从而支持多维度特征的动态聚合,是面向特征计算的维度抽象层。

在反作弊或行为分析场景中,同一条行为数据可以被多种“主体”统计,例如一条登录行为:

user_id,device_id,ip,cookie,ts

这条数据可以:统计到 user 维度、统计到 device 维度、统计到 ip 维度、统计到 cookie 维度,如果直接写 SQL 聚合,你需要:group by user_id,group by device_id,group by ip,… 。随着维度增加,代码会爆炸式增长。于是框架引入一个抽象,先构建一个逻辑视图,再根据视图去做聚合。

视图构建做了三件事:

  • 维度声明:将原始数据按指定字段组合成不同“统计视角”,这相当于提前确定这个特征是围绕谁统计的?
  • 维度映射:对应维度,记录对应的必要值,例如:(IP具体值,特征id)。
  • 维度参与聚合:不同统计维度通过 view_name / view_value 实现逻辑隔离。
  • 多阶特征计算:随着市场作弊手段的不断提高,普通的一阶策略已经无法识别潜藏的作弊数据,需要更高阶如三阶特征的策略来判定,并便于后期策略的多指标分析。

逻辑: 有些计费名(cntname)下不同的广告位区别很大,需要先算个tu维度的特征,然后tu维度又要先算下面的异常用户占比,就有了这个三阶特征。

例如:

  • 第一层为sn维度的普通比例特征,sn维度ip去重个数除以点击量的比例。
  • 第二层为tu维度,第一层的比例特征大于0.8的sn对应点击占tu全量点击的比例。
  • 第三层为计费名维度,第二层的比例特征大于0.4的tu对应点击占计费名全量点击的比例。

策略依赖的最终特征为计费名维度异常tu点击的比例,即第三层特征。

  • 数据倾斜治理:在聚合过程中,框架会根据配置文件设定开启/不开启识别热点 Key(如超大流量的 IP),广播热点数据,防止任务长尾,具体见4.2。

目前框架能够实现通用特征算子的新增和管理,目前已经支持的抽象化通用特征算子有以下 14 种:

图片

图中时间窗口windows逻辑解释:

在配置文件feature.yaml 中每个特征配置的字段

图片

支持大数据处理中经典的滚动窗口和滑动窗口模式

  • 滚动窗口定义:滚动窗口将每个元素指定给指定窗口大小的窗口。滚动窗口具有固定大小,且不重叠。例如,指定一个大小为 5 分钟的滚动窗口。在这种情况下,将每隔 5 分钟开启一个新的窗口,其中每一条数都会划分到唯一一个 5 分钟的窗口中,如下图所示。

图片

  • 滑动窗口定义:滑动窗口也是将元素指定给固定长度的窗口。与滚动窗口功能一样,也有窗口大小的概念。不一样的地方在于,滑动窗口有另一个参数控制窗口计算的频率(滑动窗口滑动的步长)。因此,如果滑动的步长小于窗口大小,则滑动窗口之间每个窗口是可以重叠。在这种情况下,一条数据就会分配到多个窗口当中。举例,有 10 分钟大小的窗口,滑动步长为 5 分钟。这样,每 5 分钟会划分一次窗口,这个窗口包含的数据是过去 10 分钟内的数据,如下图所示。

图片

Join (关联)模块:

Join(关联)模块是离线挖掘框架中的数据整合层,负责将来自不同视图或不同计算阶段产出的特征结果进行按键对齐与多维关联,通过统一主键(如 user_id、device_id、ip 等)将分散的聚合结果横向拼接成完整的特征宽表;同时处理字段冲突、空值补齐和粒度对齐等问题,确保不同维度、不同时间窗口的统计指标能够在同一维度下合并输出,为后续策略判定提供结构化综合特征数据集。具体是将抽取(Extract)模块与特征计算(Accmulate)模块数据关联, 并以logid进行Group By, 得到PV粒度全量数据, 将特征计算结果拼回各日志中,得到output2 结果 (产出为: log+ feature)。

图片

上图展示join模块的基本逻辑,即将特征聚合模块结果使用logid,拼接到原始日志中,使得抽取模块每条日志拼接到自己所命中的所有特征

  1. 对特征聚合模块(Accmulate)每条结果增加logid字段。
  2. 对特征聚合模块进行logid聚合,多个特征结果聚合到一条logid中。
  3. 抽取模块(Extract) 使用logid,Left join关联logid聚合后的特征聚合模块数据,得到joiner结果。
Policy (策略判定)模块:

Policy(策略判定)模块是离线挖掘框架中承接特征结果并输出最终风险结论的决策核心,负责将聚合产出的多维特征输入规则引擎或策略配置体系,根据预设阈值、组合条件与优先级逻辑进行匹配与计算,生成风险标签、命中规则、风险等级或处置建议;同时支持策略可配置化与版本管理,使风控逻辑能够在不改动底层计算代码的情况下灵活调整,实现特征到业务决策的闭环落地。该模块解析配置的策略文件policy.yaml, 根据policy_id 对 每条日志命中的features 进行策略判定, 输出最终结果,得到output3 结果 (产出为:  log + feature + policy)。

图片

这张图展示了反作弊规则的判定流程:

1.输入数据:每条日志包含多个字段,包括基础字段(如IP、手机号、UID等)、计算得到的特征(如统计特征fea1、fea2等)。

2.策略判定:系统基于预设的反作弊规则,对各字段、特征。例如,规则1要求【fea_001 > 100 && fea_002 < 10】,规则2要求 【IP like ‘192.%’ && fea_002 > 100】。多个规则都会执行判定逻辑,判断是否命中。

3.结果输出:最终的PV数据会带上反作弊命中结果。例如,在示例中,该PV数据命中了policy_002,表明该行为可能存在风险。

以上就是策略配置的所有介绍,通过配置化管理字段、特征、词表、模型和规则,反作弊系统能够快速响应业务需求,灵活调整检测逻辑。同时,配置化设计大幅降低了开发部署成本,提高了策略迭代效率。

3.2 流程汇总

以上3.1介绍了离线挖掘框架各个模块实现的功能,代码实现以scala的dataframe容器作为各个模块之间数据传输的媒介,此处以dataframe的计算步骤来汇总介绍框架是如何进行数据传输。

图片

04 离线挖掘框架设计亮点

4.1 模块化工程架构思想

框架整个代码实现力求模块化、轻量化;便于并行开发和测试,对后期维护升级铺平渠道。

图片

以上图为工程实现图,步骤解释:

  1. 通过TDS/spark-submit提交spark job

  2. runner调用context的init()方法,进行框架配置任务初始化

  3. init()过程中调用ConfLoader和DictLoader加载配置文件、词表,以及注册udf等等初始化操作

  4. init()返回封装好的context对象

5、6、7、8、执行各模块,将计算结果保存至context

9.根据配置的round轮数,输出对应结果的df

从运行图可以看到,这套离线反作弊挖掘框架并不是简单的“Spark 作业集合”,而是一个具备完整工程设计理念的 可编排计算引擎。其核心设计思想体现在四个方面:统一调度中枢、数据上下文抽象、算子标准化编排、配置驱动解耦

1. 统一调度中枢:构建“作业引擎”而非脚本集合

框架以 OfflineThemisRunner 作为唯一入口,负责生命周期管理、流程调度和执行编排。所有模块均由 Runner 驱动执行,而非模块间直接调用。体现“控制流集中管理,业务逻辑分散执行”。

工程优势:

  • 统一异常处理
  • 执行流程清晰、可追踪
  • 支持任务模板化和标准化运行

2. Context 抽象:解耦控制流与数据流

整个计算链路通过 Context 进行数据承载。各算子只与 Context 交互,而不直接依赖其他算子。

工程优势:

  • 消除模块间的强耦合
  • 实现数据语义统一管理
  • 支持中间结果复用与调试
  • 允许执行顺序灵活调整

从架构角度看,Context 是框架的“数据总线”,将数据流从算子依赖关系中剥离出来,使系统具备真正的模块化能力。

3. 算子标准化:构建可组合的计算流水线

框架将特征计算拆分为四类标准算子:Extractor(抽取)、Accumulator(聚合)、Joiner(拼接)、Policy(过滤)

所有算子遵循统一接口规范(run(context)),输入输出标准化,将复杂业务逻辑抽象为标准化计算单元

工程价值在于:

  • 新特征开发只需实现算子接口
  • 降低复杂链路的维护成本
  • 便于统一优化与性能调优
  1. 配置驱动:将策略从代码中剥离

通过配置来驱动计算流程和策略逻辑。代码负责能力,配置负责策略。

具体配置功能见4.3

4.2 运行优化

1、解决数据倾斜

在Accumulate特征聚合阶段,使用到groupby进行聚合操作,如果热key数据量大的情况下导致单个 Task 处理大量数据,即会出现严重的数据倾斜,甚至导致 OOM / 失败重算。

图片

以上图的优化思路:采样识别 + 拆分 Join (Skew Join)

  • 首先用 Spark API 的 sample() 统计左表 key 出现频次,先采样找出热点(大 Key)
  • 将左表按是否热点拆分
  • 将右表也对应拆分
  • 对热点 Key 用广播 Join ,避免 Shuffle
  • 非热点 Key 按常规 Join
  • 最后union all两份数据得到最终结果

对于采样解决数据倾斜已经配置化,用户可根据实际需求自定义配置是否启动优化和采样的比例,具体见4.3

2、列裁剪优化

Join拼接模块阶段,在优化前使用炸开后的Extract数据 Left Join Agg结果(view_name,view_value,window_start<=time_col<=window_end), 获取结果数据(Joiner), 结果数据包含(neededViews + agg聚合结果)。

我们假设:

1). 抽取出的Extract中含100个neededViews字段

2). feature.yml中feaList包含了80个featureId

那么就会出现以下情况:

1). 假设某条数据命中了50个feature条件,那么这条数据的聚合结果就有50条

2). 对Extract进行爆炸,也会爆成50条

注:

1). 以上方式使用Extract Left Join Agg结果时,每条数据会被扩充几十甚至上百倍,若每条Extract数据字段较多,则会造成很大的数据冗余,这些数据并不参与计算,浪费计算资源。

2). 因此再通过此方法进行group by聚合操作,浪费了很多不必要的内存,很容易发生数据倾斜,计算速度也会很慢。

图片

以上图为列裁剪后的优化,优化思路为:

其实优化前第一步的操作就是为了将logid赋值到每一条ACC特征计算结果上,那样接下来才能进行group by logid操作。

  1. 我们先对抽取模块结果列裁剪logid和关联键的hash()值,和特征计算模块同样的关联键的hash()进行join。

  2. 再对特征计算结果进行group by logid操作,就能减轻许多计算压力。

  3. 最后用Extract Left Join第2步的结果即可。

综上,经过列裁剪及聚合下沉操作后,实际工程速度在列数较多场景下均提升60%以上,并有效防止OOM,降低任务失败率。

4.3 配置化

为了满足反作弊策略快速上线、精细化模拟验证和灵活联调等高频迭代需求,我们的实时反作弊系统采用了高度配置化驱动架构,并将所有配置集中托管平台上进行统一管理。

在这一体系下,策略和计算逻辑不再硬编码到程序中,而是通过规范的配置文件描述出来,从字段抽取、特征定义、规则判定到结果产出,每一个步骤都可以通过配置完成。策略开发人员只需在平台上配置好各项参数,系统即可自动生成对应的作业,并支持一键打包和上线执行,大幅缩短了业务上线周期,降低了对底层框架开发的依赖。

图片

策略配置主要由以下几类配置模块组成:

主配置:全局环境配置,这是框架的主配置文件,定义了任务运行的基础环境和全局参数,控制任务的运行模式、资源分配和全局开关。

  • 输入输出:该配置决定了框架的输入地址、输入格式、输出地址、输出格式、控制框架需要的输出阶段等,例如round1,round2,round3。
  • 优化:还可在此配置中配置是否开启抽样优化及抽样的比例等。
  • udf自定义函数:用户可以自定义udf函数。

字段配置:负责将各种来源、各种格式的原始日志映射为框架可识别的标准字段。我们将字段抽取逻辑进行了配置化抽象,策略开发人员使用类似于写sql的方式即可完成简单字段的etl逻辑的开发,如常见的json字段抽取,字符串处理,反作弊内部的常用UDF等,配置能覆盖大部分字段抽取。根据抽取方式不同分为:

  • 基础字段:直接从原始数据流中提取的字段,例如设备 ID、用户 ID 等。
  • 二次计算字段:简单的字段转换逻辑(如 IP 转地域、UA 解析)。
  • 维表字段:通过查询词表映射关系获得的字段,例如黑名单匹配结果、分类标签等。

特征配置:特征是策略的重要判定依据,定义了如何从标准字段中计算出用于反作弊判定的统计特征。特征配置包括以下几个关键方面:

  • 特征类型:数据的聚合方式,如sum、count、distinct等。
  • 窗口信息:设置聚合特征的时间窗口范围和窗口形式,时间范围如:1 小时、1天等,窗口形式如:滑动窗口、滚动窗口等。
  • 特征维度:特征的聚合维度,如用户、设备、IP 地址等。

词表配置:词表通常是历史已知的黑名单、字段映射(如ip映射城市)等固定维表信息,在数据进入引擎之前,利用词表进行初步的“脏数据”清洗或黑名单过滤,提供外部参考数据,用于过滤或打标。配置内容需包括以下几个方面:

  • 词表路径:指定词表的存储位置,支持文件路径或分布式存储地址。
  • 词表类型:支持多种形式的词表,包括集合(set)、键值对映射(kv)、正则表达式(regex)等。

策略配置:规则配置决定了作弊行为的最终判定规则和处置方式,组合特征,输出最终的作弊名单或风险评分:

  • 策略判定阈值:定义触发策略的条件,例如基础字段匹配、词表匹配、风险评分的阈值、特征累积阈值、模型打分阈值等。
  • 策略判黑等级:设定风险等级,区分低、中、高风险及对应的处置措施。

以上总结配置文件的各个功能如下:

图片

05 总结

本文介绍了基于spark 的离线反作弊挖掘框架,围绕解决的基本问题、工程设计亮点等展开。通过特征计算和配置化管理,提升了反作弊系统的检测效率和稳定性。展望未来,离线反作弊挖掘框架将持续演进,与更多智能算法、大模型和业务系统深度融合,不断完善检测能力和可用性。借助持续优化的特征计算与策略模块,此框架将为百度生态提供更加坚实的反作弊保障。

昨天 — 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 无限的可能。

ss Command in Linux: Display Socket Statistics

ss is a command-line utility for displaying socket statistics on Linux. It is the modern replacement for the deprecated netstat command and is faster, more detailed, and available by default on all current Linux distributions.

This guide explains how to use ss to list open sockets, filter results by protocol and port, and identify which process is using a given connection.

ss Syntax

txt
ss [OPTIONS] [FILTER]

When invoked without options, ss displays all non-listening sockets that have an established connection.

List All Sockets

To list all sockets regardless of state, use the -a option:

Terminal
ss -a

The output includes columns for the socket type (Netid), state, receive and send queue sizes, local address and port, and peer address and port:

output
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
tcp ESTAB 0 0 192.168.1.10:ssh 192.168.1.5:52710
tcp LISTEN 0 128 0.0.0.0:http 0.0.0.0:*
udp UNCONN 0 0 0.0.0.0:bootpc 0.0.0.0:*

Filter by Socket Type

TCP Sockets (-t)

To list only TCP sockets:

Terminal
ss -t

To include listening TCP sockets as well, combine with -a:

Terminal
ss -ta

UDP Sockets (-u)

To list only UDP sockets:

Terminal
ss -ua

Unix Domain Sockets (-x)

To list Unix domain sockets used for inter-process communication:

Terminal
ss -xa

Show Listening Sockets

The -l option shows only sockets that are in the listening state:

Terminal
ss -tl

The most commonly used combination is -tulpn, which shows all TCP and UDP listening sockets with process names and numeric addresses:

Terminal
ss -tulpn
output
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=5678,fd=6))
udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:(("dhclient",pid=910,fd=6))

Each option in the combination does the following:

  • -t — show TCP sockets
  • -u — show UDP sockets
  • -l — show listening sockets only
  • -p — show the process name and PID
  • -n — show numeric addresses and ports instead of resolving hostnames and service names

Show Process Information

The -p option adds the process name and PID to the output. This requires root privileges to see processes owned by other users:

Terminal
sudo ss -tp
output
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.1.10:ssh 192.168.1.5:52710 users:(("sshd",pid=2341,fd=5))

Use Numeric Output

By default, ss resolves port numbers to service names (for example, port 22 becomes ssh). The -n option disables this and shows raw port numbers:

Terminal
ss -tn

This is useful when you need to match exact port numbers in scripts or when name resolution is slow.

Filter by Port

To find which process is using a specific port, filter by destination or source port. For example, to list all sockets using port 80:

Terminal
ss -tulpn | grep :80

You can also use the built-in filter syntax:

Terminal
ss -tnp 'dport = :443'

To filter by source port:

Terminal
ss -tnp 'sport = :22'

Filter by Connection State

ss supports filtering by connection state. Common states include ESTABLISHED, LISTEN, TIME-WAIT, and CLOSE-WAIT.

To show only established TCP connections:

Terminal
ss -tn state ESTABLISHED

To show only sockets in the TIME-WAIT state:

Terminal
ss -tn state TIME-WAIT

Filter by Address

To show sockets connected to or from a specific IP address:

Terminal
ss -tn dst 192.168.1.5

To filter by source address:

Terminal
ss -tn src 192.168.1.10

You can combine address and port filters:

Terminal
ss -tnp dst 192.168.1.5 dport = :22

Show IPv4 or IPv6 Only

To restrict output to IPv4 sockets, use -4:

Terminal
ss -tln -4

To show only IPv6 sockets, use -6:

Terminal
ss -tln -6

Show Summary Statistics

The -s option prints a summary of socket counts by type and state without listing individual sockets:

Terminal
ss -s
output
Total: 312
TCP: 14 (estab 4, closed 3, orphaned 0, timewait 3)
Transport Total IP IPv6
RAW 1 0 1
UDP 6 4 2
TCP 11 7 4
INET 18 11 7
FRAG 0 0 0

This is useful for a quick overview of the network state on a busy server.

Practical Examples

The following examples cover common diagnostics you will use together with tools like ip , ifconfig , and check listening ports .

Find which process is listening on port 8080:

Terminal
sudo ss -tlpn sport = :8080

List all established SSH connections to your server:

Terminal
ss -tn state ESTABLISHED '( dport = :22 or sport = :22 )'

Show all connections to a remote host:

Terminal
ss -tn dst 203.0.113.10

Count established TCP connections:

Terminal
ss -tn state ESTABLISHED | tail -n +2 | wc -l

Quick Reference

Command Description
ss -a List all sockets
ss -t List TCP sockets
ss -u List UDP sockets
ss -x List Unix domain sockets
ss -l Show listening sockets only
ss -tulpn Listening TCP/UDP with process and numeric output
ss -tp TCP sockets with process names
ss -tn TCP sockets with numeric addresses
ss -s Show socket summary statistics
ss -tn state ESTABLISHED Show established TCP connections
ss -tnp dport = :80 Filter by destination port
ss -tn dst 192.168.1.5 Filter by remote address
ss -4 IPv4 sockets only
ss -6 IPv6 sockets only

Troubleshooting

ss -p does not show process names
Process information for sockets owned by other users requires elevated privileges. Use sudo ss -tp or sudo ss -tulpn.

Filters return no results
Use quoted filter expressions such as ss -tn 'dport = :443', and verify whether you should filter by sport or dport.

Service names hide numeric ports
If output shows service names (ssh, http) instead of port numbers, add -n to keep numeric ports and avoid lookup ambiguity.

Output is too broad on busy servers
Start with protocol and state filters (-t, -u, state ESTABLISHED) and then add address or port filters to narrow results.

You need command-level context, not only sockets
Use ss with ps or pgrep when you need additional process detail.

FAQ

What is the difference between ss and netstat?
ss is the modern replacement for netstat. It reads directly from kernel socket structures, making it significantly faster on systems with many connections. netstat is part of the net-tools package, which is deprecated and not installed by default on most current distributions.

Why do I need sudo with ss -p?
Without root privileges, ss can only show process information for sockets owned by your own user. To see process names and PIDs for all sockets, run ss with sudo.

What does Recv-Q and Send-Q mean in the output?
Recv-Q is the number of bytes received but not yet read by the application. Send-Q is the number of bytes sent but not yet acknowledged by the remote host. Non-zero values on a listening socket or consistently high values can indicate a performance issue.

How do I find which process is using a specific port?
Run sudo ss -tulpn | grep :<port>. The -p flag adds process information and -n keeps port numbers numeric so the grep match is reliable.

Conclusion

ss is the standard tool for inspecting socket connections on modern Linux systems. The -tulpn combination covers most day-to-day needs, while the state and address filters make it easy to narrow results on busy servers. For related network diagnostics, see the ip and ifconfig command guides, or check listening ports for a broader overview.

less Cheatsheet

Basic Usage

Common ways to open files and piped output in less.

Command Description
less file.txt Open a file in the pager
less /var/log/syslog Read a system log file
command | less Pipe command output into less
less +G file.txt Open a file and jump to the end
less +/pattern file.txt Open a file and jump to first match

Navigation Keys

Move through content quickly in the pager.

Key Description
Space Move forward one page
b Move backward one page
Enter Move forward one line
k / y Move backward one line
d / u Move forward / backward half a page
g Jump to top of file
G Jump to bottom of file
50g Jump to line 50
F Follow mode — stream new content as it is appended (press Ctrl+C to stop)
q Quit less

Search and Match Navigation

Find text and move between matches.

Key Description
/pattern Search forward for pattern
?pattern Search backward for pattern
n Next match in current search direction
N Previous match in current search direction
&pattern Show only lines matching pattern
& (empty) Clear &pattern filtered view

Useful Options

Flags that improve readability in daily use.

Command Description
less -N file.txt Show line numbers
less -S file.txt Disable line wrap (horizontal scrolling)
less -R file.txt Show ANSI colors in output
less -i file.txt Case-insensitive search by default
less -F file.txt Quit automatically if content fits one screen
less -X file.txt Keep screen content after quitting

Log and Output Workflows

Practical examples for troubleshooting and analysis.

Command Description
less +F /var/log/syslog Follow a log file live (like tail -f); press Ctrl+C to stop and search
journalctl -xe | less Page through recent systemd logs
dmesg | less Inspect kernel messages page by page
ps aux | less Browse long process listings
git log | less Read long commit history safely
grep -R \"ERROR\" /var/log | less Review search results interactively

Troubleshooting

Quick fixes for common less issues.

Issue Check
Search does not find expected text Retry with exact case or use -i for case-insensitive search
Color codes look broken Use less -R when input includes ANSI colors
Lines wrap and are hard to read Use less -S and move sideways with arrow keys
Pager exits immediately Content may fit one screen; remove -F if you need manual paging
You opened from pipe and lost context Rerun command with | less and search using /pattern

Related Guides

Use these guides for deeper command and text-processing workflows.

Guide Description
less Command in Linux Full less tutorial with examples
head Command in Linux Show the first lines of files
tail Command in Linux Follow file growth and recent lines
grep Command in Linux Search text patterns in files
wc Command in Linux Count lines, words, and bytes

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。

❌
❌