普通视图

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

深入 HTML-in-Canvas:当 Canvas 学会了渲染 DOM,前端图形生态要变天了

2026年4月30日 20:08

你有没有想过一个问题:为什么 Canvas 里的文字永远那么丑?为什么游戏里的 UI 只能用 Canvas API 手画,而不能直接写个 <div> 上去?为什么每次做图表都要在 ctx.fillText 和 CSS 字体之间反复拉扯?今天,一个正在 Chromium 中孵化的 WICG 提案,要彻底终结这个延续了二十年的困局。

📋 目录

背景:Canvas 二十年的「盲人」困境

2004 年,Apple 在 Safari 中引入了 <canvas> 元素,随后被 WHATWG 和 W3C 标准化。二十多年来,Canvas 成为 Web 上最强大的 2D/3D 图形基元——游戏、图表、数据可视化、创意工具、图像编辑……几乎一切"像素级别的操作"都跑在 Canvas 上。

但它有一个致命的短板:渲染不了真正的 HTML 内容

这听起来像是一个不应该存在的问题——我明明有一个 <div>,为什么不能把它「画」到 Canvas 上?但现实是,开发者们二十年来的解决方案只有这些:

方案 原理 问题
ctx.fillText() 手写文字排版 不支持复杂文本、RTL、国际化排版
html2canvas / dom-to-image 用 JS 重绘整个渲染树 慢(JS 模拟渲染引擎)、不完整、不全准
SVG <foreignObject> 在 SVG 中嵌入 HTML 无法与 Canvas 2D API 交互、不支持 WebGL/WebGPU
截图上传(你没看错) 手动截图当纹理 根本不能算方案

这些问题带来的连锁反应非常具体:

  • 可访问性灾难:Canvas fallback 内容和实际画出来的像素,本质上没有约束关系。开发者写了一个披着 aria-label 外衣的 <canvas>,但里面到底画了什么,屏幕阅读器和实际视觉内容完全是两套。
  • 国际化短板ctx.fillText 不处理 RTL(阿拉伯语/希伯来语)、竖排文字、复杂脚本连字。如果你的图表需要显示阿拉伯语标签,要么自己实现排版引擎,要么放弃。
  • 游戏 UI 的割裂:3D 场景中的 2D 界面(菜单、对话气泡、HUD),要么用 3D 引擎的内置 UI 系统(学习成本高),要么用 Canvas 手绘(质量差),要么用 DOM 覆盖层(无法与 3D 场景融合)。

HTML-in-Canvas 提案的目标就是:让开发者能把真正的 DOM 元素渲染到 Canvas 上,用浏览器的原生排版引擎干活

前传:为什么 Canvas 一直渲染不了 HTML

在深入 API 之前,先搞明白一个核心问题:为什么这件事以前做不到?

浏览器的渲染流水线大致是这样的:

JS → Style → Layout → Paint → Composite
  • Style:计算 CSS 规则
  • Layout:计算盒模型位置
  • Paint:生成绘制指令列表(display list)
  • Composite:合成图层

Canvas 的渲染是脱离这个流水线的。Canvas 的内容通过 JS 调用 Canvas API(fillRectdrawImage 等)写入一个位图缓冲区,然后直接作为一个纹理交给 GPU。浏览器的渲染引擎(Paint/Composite)对 Canvas 内部发生了什么一无所知。

而 HTML 元素的渲染走的是完整的 Style → Layout → Paint → Composite 管道。

所以,要把 HTML 渲染到 Canvas 上,本质上是要让浏览器的渲染管道和 Canvas 的像素缓冲区之间建立一座桥——而且这座桥不能破坏安全模型,不能引入性能问题,还要支持可访问性。

这比听起来难得多。直到 WICG/html-in-canvas 提出了一套优雅的解决方案。

核心原语一:layoutsubtree——一纸「委任状」

提案的第一个原语是一个 HTML 属性——layoutsubtree

<canvas id="myCanvas" layoutsubtree>
  <div id="myContent">
    <h2>Hello Canvas!</h2>
    <p>我可以在 Canvas 里用 HTML 渲染了!</p>
  </div>
</canvas>

加上这个属性的瞬间,发生了三件关键的事情:

  1. Canvas 的子元素获得了 stacking context,成为了其后代元素的 containing block
  2. Canvas 的子元素拥有了 paint containment(绘制包含)
  3. Canvas 的子元素参与了正常布局和 hit testing

翻译成人话:Canvas 的孩子虽然还在 DOM 树里,但它们的视觉渲染被「截胡」了——浏览器的 Paint 阶段不会再把这些孩子渲染到屏幕上,而是把它们的绘制结果存起来,等着开发者用 API 取走。 同时,它们仍然参与布局、可访问性树和事件命中测试。

这个设计有一个非常精妙的双重角色:同一个元素既是视觉内容(被绘制到 Canvas 中),也是可访问性内容(作为 Canvas fallback)。不像现在的 <canvas> fallback——画出来的东西和 fallback 内容是两套,永远有不同步的风险。在 HTML-in-Canvas 中,它们就是同一个东西。

layoutsubtree 就像是一纸「委任状」:告诉浏览器「这些孩子交给我来画,但请帮我把它们的布局和绘制结果准备好。」

核心原语二:drawElementImage——画布上的「复印机」

有了 layoutsubtree 把布局和可访问性安排好,下一步就是把子元素「印」到 Canvas 上。这就是 drawElementImage()

const ctx = canvas.getContext('2d');

canvas.onpaint = () => {
  ctx.reset();
  // 把 form_element 画到 Canvas 的 (100, 0) 位置
  const transform = ctx.drawElementImage(form_element, 100, 0);
  // 同步 DOM 位置
  form_element.style.transform = transform.toString();
};

核心行为

  • 只接受 Canvas 的直接子元素(这就是 layoutsubtree 标记的那些孩子)
  • 调用时,返回一个 DOMMatrix(CSS transform 矩阵),你需要把这个矩阵应用到元素的 style.transform 上,让 DOM 位置和画上去的位置保持一致
  • Canvas 的当前变换矩阵(CTM,Current Transformation Matrix)会作用于绘制——也就是说,你可以在画布上 ctx.rotate(45) 然后 drawElementImage,元素就会旋转
  • 子元素的 CSS transform 被忽略(原因见下文——如果不忽略会导致双重变换)
  • 溢出内容被裁切到元素的 border box

destination rect 参数(和 drawImage 一模一样):

// 最简形式:在 (x, y) 处以原始尺寸绘制
ctx.drawElementImage(element, x, y);

// 指定目标尺寸
ctx.drawElementImage(element, x, y, width, height);

// 带 source rect 裁剪
ctx.drawElementImage(element, sx, sy, sw, sh, dx, dy, dw, dh);

WebGL 版本的接口texElementImage2D,把元素渲染到纹理:

// 当你需要把 HTML 内容作为 3D 纹理时
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, myElement);

WebGPU 版本的接口copyElementImageToTexture

queue.copyElementImageToTexture(myElement, destination);

一个 API,覆盖 2D Canvas、WebGL、WebGPU 三大图形上下文。

形象一点理解:drawElementImage 就像是把浏览器的渲染引擎当作一台复印机,你传一个 DOM 元素进去,它返回一页「复印件」——而且附带一个坐标映射表(DOMMatrix),告诉你怎么把这页「复印件」在画布上的位置同步给 DOM。

核心原语三:paint 事件——智能触发器

drawElementImage 画的是快照。问题来了:当元素的内容发生变化时(比如输入框里有文字输入),开发者怎么知道需要重新绘制?

这就是 paint 事件的用武之地:

canvas.addEventListener('paint', (event) => {
  // event.changedElements 包含渲染发生了变化的子元素
  ctx.reset();
  for (const el of event.changedElements) {
    const t = ctx.drawElementImage(el, 0, 0);
    el.style.transform = t.toString();
  }
});

关键特性

  1. 智能触发:只有当子元素的视觉渲染真正发生变化时才会触发,而不是 60fps 无脑循环。省电、省 CPU。
  2. 时机:在浏览器每一帧渲染管线的 update-the-rendering 阶段中,紧跟在 intersection observer 步骤之后、Paint 步骤之前触发。
... → IntersectionObserver → paint event → Paint → Composite → ...
  1. CSS transform 变化不触发:因为 transform 影响的是位置而非渲染内容,改变 transform 不会重新生成 paint 指令,所以不触发 paint 事件。
  2. paint 内的 DOM 改动推迟到下一帧:你在 paint 回调里改了元素的 class/文本,这一帧不会生效,下一帧才会。
  3. requestPaint():如果你需要每帧都重绘(类似游戏循环),可以调用 canvas.requestPaint() 强制触发 paint 事件,行为和 requestAnimationFrame 类似。

Bonus:captureElementImage——通往 Worker 的传送门

有一个问题:上面的所有 API 都依赖 DOM 元素引用,但 Web Worker 中无法访问 DOM

解决方案是 captureElementImage()

// 主线程:捕获快照并传送到 Worker
canvas.onpaint = () => {
  const elementImage = canvas.captureElementImage(form_element);
  worker.postMessage({ elementImage }, [elementImage]);  // Transferable!
};

// Worker:直接绘制
self.onmessage = (e) => {
  if (e.data.elementImage) {
    ctx.drawElementImage(e.data.elementImage, 100, 0);
  }
};

ElementImage 是一个 Transferable 对象,和 ImageBitmapArrayBuffer 一样,支持零拷贝传输。这为 OffscreenCanvas 在 Worker 中高性能渲染 HTML 内容铺平了道路。

整个对象只有三个方法/属性:

  • width / height:快照的尺寸
  • close():释放资源

轻量、简洁、高效。

深水区:事件循环中的时序博弈

如果说前面的 API 是"皮毛",那下面这部分才是 HTML-in-Canvas 最深的设计决策——paint 事件到底应该在哪一刻触发?

规范文档中记录了三种方案,我们逐一分析:

Option A:在 ResizeObserver 时机触发(带循环)

位置:在 update-the-rendering 流程的第 16.2.6 步(Deliver resize observations),如果 paint 事件中又修改了样式,就循环回到第 16.2.1 步(Recalculate styles and update layout)。

问题

  • 需要在这个时间点同步执行 Paint 步骤来生成子元素的绘制快照。Paint 本身很消耗性能,还要可能跑多次。
  • Gecko(Firefox 的渲染引擎)的架构导致了这里实现困难——某些引擎在这个时间点根本拿不到完整的绘制结果。
  • 最致命的问题:WebGL。WebGL 的 gl.getError()gl.getParameter() 等 API 需要触发 GPU 命令缓冲区刷新(flush),如果在 Paint 完成之前调用,会导致死锁或不一致的渲染状态。

Option B:紧接在 Paint 步骤之后触发(带循环)

位置:在浏览器的 Paint 步骤完成后立即触发。

优势:不需要上面那种"同步 Paint Canvas 子元素"的操作——因为 Paint 已经跑完了,每个元素的绘制结果是可用的。

问题:仍然需要循环。如果 paint event 中改动了 DOM,又得回退到 style recalc → layout → paint 的完整循环,可能在一次帧中跑多次,十分昂贵。

Option C:紧接在 Paint 步骤之后触发(循环)—— 被选中的方案

核心思想:paint event 在一帧中只跑一次

如果开发者在 paint event 中修改了 DOM?改了就改了,但这一帧已经锁死了——DOM 修改的效果留到下一帧的渲染管线去处理。

这带来一个非常有趣的对称性:浏览器的 Paint 步骤也是不可循环的——你无法在一个帧内让浏览器画两次。paint event 的行为和浏览器原生的 Paint 步骤完全对齐。

方案  |  循环  |  是否需要同步 Paint  |  兼容 WebGL  |  复杂度
A     |  是    |  是                  |  否(死锁)  |  高
B     |  是    |  否                  |  是          |  高
C     |  否    |  否                  |  是          |  低 ✅

这个决策过程是 HTML-in-Canvas 提案中最精妙的设计之一。它不强求"开发者改了我就立刻刷新",而是承认一帧内做到绝对实时是不现实的,通过"延迟到下一帧"来换取架构的简洁性和跨浏览器兼容性。

就像 React 的虚拟 DOM 不追求"每次修改立刻更新真实 DOM"一样,HTML-in-Canvas 也不追求"每个 CSS 变化都立刻刷新 Canvas 绘制"。延迟带来一致性。

同步公式:CSS Transform 背后的线性代数

前面提到,drawElementImage() 返回一个 DOMMatrix,需要设置到元素的 style.transform 上。为什么要这么做?

因为浏览器的 hit testing(点击命中测试)、intersection observer、可访问性功能都依赖元素的 DOM 位置。如果你把一个 <div> 画到了 Canvas 的 (100, 200) 位置,但它在 DOM 树中还在原始位置,点击 (100, 200) 就命中不了这个元素。

解决方案是:把 DOM 元素通过 CSS transform 移动到与绘制位置匹配。

drawElementImage() 返回的 DOMMatrix 就是按如下公式计算的:

T_sync = T_origin⁻¹ · S_css→grid⁻¹ · T_draw · S_css→grid · T_origin

其中:

  • T_draw:绘制到 Canvas 上的变换矩阵,等于 CTM · T(x, y) · S(destScale)(CTM + 位置偏移 + 缩放)
  • T_origin:元素的 transform-origin 矩阵
  • S_css→grid:CSS 像素到 Canvas 网格像素的缩放矩阵

直观理解:这个公式做的事情就是把"在 Canvas 网格坐标系中的绘制位置"反向映射回"DOM 中的 CSS 像素位置"

对于 WebGL/WebGPU 中的 3D 场景,还有一个辅助方法 canvas.getElementTransform(element, drawTransform),让你传入任意变换矩阵并计算出对应的 CSS transform。

// 2D Canvas 直接返回
const transform = ctx.drawElementImage(element, x, y);

// WebGL/WebGPU 需要手动计算
const drawTransform = new DOMMatrix([...]); // 你自定义的 3D 变换
const cssTransform = canvas.getElementTransform(element, drawTransform);
element.style.transform = cssTransform.toString();

重要提醒:CSS transform 的变化不会触发 paint 事件——因为 transform 只影响位置,不影响绘制内容,所以 paint event 不会因为你在同步 transform 而反复触发。这避免了死循环。

隐私保护:看不见的边界

drawElementImage() 能让 Canvas 读取 DOM 元素的像素,这就带来了一个安全问题:如果 Canvas 能读取任何元素的内容,那跨域保护怎么办?

提案的隐私模型遵循一个核心理念:drawElementImage 不会暴露任何 JavaScript 当前不可访问的信息。 换句话说,它不会打开新的攻击面。

被排除在绘制之外的敏感内容:

排除项 原因
跨域 iframe、跨域图片 同 Canvas drawImage 的跨域保护一致
CSS url() 引用的跨域资源(如 background-image 同上
系统颜色/主题/偏好 否则可通过像素读取猜出系统主题
拼写/语法检查标记 可能暴露用户的拼写习惯
已访问链接的颜色 经典的隐私泄露向量
自动填充(autofill)预览内容 包含敏感个人信息
次像素抗锯齿 可用作浏览器指纹

不被视为敏感(允许绘制)的内容:

保留项 理由
页面查找(Find in Page)高亮 低安全性影响
滚动条和表单控件外观 已可通过 SVG foreignObject 检测
光标闪烁频率 低熵信息
forced-colors 模式 已可通过 CSS media query 获取

注意:这是预防性设计——在提案还处于 WICG 孵化阶段就考虑了完整的安全模型。这与 W3C TAG 审查(issue #1204)和 WHATWG 标准化讨论中的安全关注点保持一致。

生态地图:谁已经上车了

虽然提案还在孵化中,浏览器端只有 Chrome Canary 和 Brave Stable (Chromium 147+) 通过 flag 支持,但开源社区已经在积极适配:

three.js — 原生 WebGL 纹理集成

mrdoob/three.js 已经在 WebGL 和 WebGPU 两个渲染后端中集成了 HTML-in-Canvas:

// three.js 内部实现(简化)
if ('texElementImage2D' in gl) {
  const canvas = gl.canvas;
  if (!canvas.hasAttribute('layoutsubtree')) {
    canvas.setAttribute('layoutsubtree', 'true');
  }
  // HTML 元素直接作为纹理源
  gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, htmlElement);
}

这意味你可以把任意的 HTML 内容作为 three.js 的纹理,直接贴到 3D 模型上。

相关 PR: mrdoob/three.js#31233

PlayCanvas — 3D 产品配置器 + HtmlSync

PlayCanvas 引擎甚至在官方示例中完整实现了基于 HTML-in-Canvas 的交互式 3D 产品配置器。一个 HTML 面板被渲染为 WebGL 纹理,用户点击 3D 场景中的 HTML 按钮时的 hit testing 完全由浏览器的原生 DOM 事件处理——通过 getElementTransform 同步位置。

关键助手类 HtmlSync 被设计为可复用的工具类,处理 canvas ↔ 3D 平面的坐标映射。

VFX-JS — 视觉特效框架

fand/vfx-js 提供了一个优雅的 addHTML() 方法:

const vfx = new VFX();
await vfx.addHTML(element, { shader: 'liquidGlass' });

它内部先检查 supportsHtmlInCanvas(),如果可用就使用原生 API,否则优雅降级到传统的 dom-to-canvas 方案——渐进增强的最佳实践。

three-html-render — 纯 JS Polyfill

最令人兴奋的生态项目之一是 repalash/three-html-render——一个在浏览器不支持原生 API 时的 Polyfill。它通过 CSS matrix3d() 变换和 iframe / embed 技术模拟了 drawElementImage 的核心行为。

即使你的用户没有启用 chrome://flags/#canvas-draw-element,这个 Polyfill 也能工作。这是一个很聪明的策略——用 Polyfill 降低采用门槛,让框架生产环境可用。

未解之谜与未来方向

提案仍处于活跃讨论中(仓库中有 16 个 open issues),以下几个话题值得关注:

Open Issues 选读

Issue 核心问题
#94 — Hit testing and layer ordering draw 多个元素时,z-index 如何与 hit testing 协调?
#85removedElements 当子元素被删除,paint 事件是否需要提供单独的 removedElements 列表?
#82 — 新的指纹向量 onpaint 事件即使不读取像素,也能通过监听事件频率来获取指纹信息(如光标闪烁频率)
#31 — 动图/视频支持 GIF、WebP 动画、视频元素如何支持?
#47mix-blend-modebackdrop-filter 效果在 Canvas 中未正确反映

未来:自动更新 Canvas

规范文档中提到了一个令人兴奋的未来方向——auto-updating canvas

目前的模型是:你在 paint 事件中调用 drawElementImage,浏览器绘制快照。但如果支持了「自动更新模式」,drawElementImage 会在 Canvas 的命令缓冲区中记录一个"占位符",浏览器可以在滚动或动画更新时自动重新执行绘制,无需阻塞 JS 主线程

这意味着 Canvas 中的 HTML 内容可以和原生滚动完美同步,不再受 JS 事件循环的延迟影响。这个模式对 2D Canvas 已可行,对 WebGPU 也只需少量 API 扩展。

标准化进程

提案正处于标准化流程的以下位置:

WICG 孵化(当前)→ WHATWG Stage 2 → WHATWG Standard → 浏览器默认启用
  • WHATWG Spec PR: #11588
  • W3C TAG 早期审查: #1204(2026年3月启动)
  • 跨浏览器共识:Chromium / Gecko / WebKit 已在设计上达成一致(paint 事件 Option C 时序)

总结

HTML-in-Canvas 不只是 Canvas 的一个新功能——它是 Web 图形平台二十年来最重要的一次基础能力补全。

它的核心贡献不是加了几个 API,而是在浏览器的渲染流水线和 Canvas 的像素缓冲区之间,架起了一座精心设计的桥梁

  • layoutsubtree 用属性声明边界
  • drawElementImage 用返回值解决同步
  • paint 用精妙的时序设计避免死循环和性能灾难
  • captureElementImage 用 Transferable 搞定 Worker 并行

Three primitives + one helper,四个接口把"把 DOM 渲染到 Canvas"从不可能变成了可能——而且是在不破坏现有安全模型、不影响性能、保持可访问性的前提下。

三个核心判断:

  1. 技术设计质量很高:从事件时序的选择(Option C)到隐私模型的预防性设计,到 drawElementImage 的返回值用作 style.transform,每个决策都有清晰的权衡分析。这不是一个"先上线再说"的功能。

  2. 生态已经开始拥抱:three.js、PlayCanvas、VFX-JS 等知名图形项目的积极适配远超预期。尤其在 3D 游戏和可视化领域,需求非常强烈。

  3. 还有一段路要走:目前只在 Chromium flag 后可用,Firefox 和 Safari 还没有明确的实现计划。标准化进程仍在 WICG 阶段。

如果你是图形/可视化方向的开发者,建议立刻打开 Chrome Canary,启用 chrome://flags/#canvas-draw-element,跑一下官方 Demo。虽然它还不是正式标准,但方向已经明确——而且这个方向,可能改变前端图形生态的底层逻辑。


关注 【iDao技术魔方】,获取更多全栈到AI可落地的实战干货。

React 19 源码主线拆解 04:Fiber 到底是什么,React 为什么需要 Fiber?

作者 倾颜
2026年4月30日 19:47

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着进入 beginWorkcompleteWork 和完整 render 流程,而是先把 React 运行时里最关键的工作节点:Fiber,单独理清楚。

前言

上一篇里,我们已经把 React 主线从 ReactElement 推进到了根级更新系统。

简单回顾一下:

  • createRoot(container) 初始化了 Root 系统
  • root.render(element) 把 ReactElement 送进了根级更新流程
  • ReactElement 会被包装成一次 Update
  • 这次 Update 会挂到 HostRoot Fiber 的 updateQueue

也就是说,主线已经走到了这里:

ReactElement → Update → HostRoot Fiber.updateQueue

到这一步,Fiber 这个词已经绕不开了。

因为继续往后看,很快就会遇到这些问题:

  • HostRoot Fiber 到底是什么?
  • FiberRoot 和 Fiber 是一个东西吗?
  • Update 为什么要挂到 Fiber 的 updateQueue 上?
  • 后面的 render 阶段,为什么不是直接处理 ReactElement,而是围绕 Fiber 展开?
  • commit 阶段为什么又要看 Fiber 上的 flags?

这些问题继续往下追,都会回到一个更基础的问题上:

Fiber 到底是什么?React 为什么需要 Fiber?

所以这一篇不急着进入 beginWork,也不急着讲完整的 render work loop,而是先把 Fiber 这个运行时工作节点本身讲清楚。

这篇文章主要想回答几个问题:

  • Fiber 到底是什么,为什么不能把它理解成 ReactElement
  • React 为什么需要 Fiber 这样的运行时工作节点
  • FiberRoot、HostRoot Fiber、FiberNode、Fiber 树分别是什么关系
  • Fiber 树是怎么通过 child / sibling / return 组织起来的
  • alternate 和双缓存树到底解决了什么问题

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、先说结论:Fiber 是 React 的运行时工作节点

先把这一篇最核心的结论放在前面:

ReactElement 描述“我要渲染什么”,Fiber 承载“React 怎么处理这次工作”。

这句话是理解 Fiber 的第一层边界。

第二篇里,我们已经讲过 ReactElement。它是 JSX 编译并运行之后产出的描述对象,大致描述了:

  • 这是什么节点
  • 节点的 type 是什么
  • 节点上有什么 props
  • 有没有 key
  • 有没有 ref

但 ReactElement 本身很轻。
它只是描述“我要渲染什么”,并不负责记录后续工作过程。

它不会保存:

  • 当前节点的状态
  • 当前节点的 updateQueue
  • 当前节点有没有副作用
  • 当前节点的调度优先级
  • 当前节点和旧节点之间的复用关系

这些运行时信息,真正会落到 Fiber 上。

所以如果把 React 主线从输入到落地简单画一下,大概是这样:

graph TD
    A["JSX"] --> B["ReactElement:描述要渲染什么"]
    B --> C["Update:进入更新系统"]
    C --> D["Fiber:承载运行时工作"]
    D --> E["DOM:宿主环境真实节点"]

这张图里最重要的不是箭头本身,而是分层:

  • ReactElement 是输入描述
  • Fiber 是运行时工作结构
  • DOM 是最终落到宿主环境里的真实节点

所以这一篇最先要立住的边界就是:

Fiber 不是 ReactElement 的别名,而是 React 运行时用来组织更新、保存状态、标记副作用、支持调度和复用的工作节点。

后面再看 updateQueuelanesflagsalternate,这些东西才不会变成一堆散乱字段。


二、为什么 React 需要 Fiber

理解 Fiber,最好先从它解决的问题开始。
如果只盯着字段看,很容易把 Fiber 理解成一个很大的对象。

但 Fiber 真正重要的地方,不是“字段很多”,而是它改变了 React 组织更新工作的方式。

在旧式同步递归模型里,一次更新更像是:

从根节点开始
一路递归往下处理
直到整棵树处理完

这种方式的问题是:一旦开始处理一棵大树,就容易一口气跑到底。

如果组件树很大,中间又有很多节点需要计算,就可能长时间占用主线程。
而主线程一旦被长时间占用,页面响应、用户输入、动画这些事情都会受到影响。

更关键的是,纯同步递归模型不容易回答这些问题:

  • 工作做到哪里了?
  • 能不能先暂停一下?
  • 能不能稍后继续?
  • 能不能让更高优先级的更新先处理?
  • 能不能复用上一次已经创建过的节点信息?

React 需要的不只是“递归渲染一棵树”,而是要把一次更新拆成一批更容易管理的工作单元。

Fiber 就是在这个背景下出现的。

一个 Fiber 可以理解成一个节点级工作单元。
它不仅知道自己代表什么节点,还能记录:

  • 自己在树里的位置
  • 本轮待处理的 props
  • 上一次保存下来的状态
  • 当前节点上有没有更新
  • 当前节点或子树有没有副作用
  • 当前节点和旧节点之间怎么复用
  • 当前节点上有哪些优先级的工作

所以 Fiber 最大的意义,不是把树换一种写法,而是:

让 React 不只是递归渲染一棵树,而是能管理一批可记录、可复用、可调度的工作单元。

Scheduler、时间切片、Concurrent Rendering 的完整细节可以先放一放。
这一篇先抓住一点就够了:

Fiber 是 React 后续更新、render、commit 能够围绕一套工作节点推进的基础。


三、Fiber、FiberNode、Fiber 树、FiberRoot、HostRoot Fiber 是什么关系

Fiber 这个词很容易让人混,因为它在不同上下文里会指向不同层次的东西。

比如我们会看到:

  • Fiber
  • FiberNode
  • Fiber 树
  • FiberRoot
  • HostRoot Fiber
  • ReactDOMRoot

这些词如果不先拆开,后面源码会越看越乱。

这一节先把它们的关系讲清楚。

1. FiberNode:单个工作节点

从源码结构上看,具体的数据结构通常是 FiberNode。

我们平时说“一个 Fiber”,很多时候指的就是一个 FiberNode。

它可以对应不同类型的 React 节点,比如:

  • 函数组件
  • 类组件
  • 原生 DOM 标签
  • Fragment
  • Suspense

这里不用把所有 tag 类型都列出来。
先把它粗略理解成一句话:

一个 FiberNode,就是 React 运行时里的一个节点级工作单元。

2. Fiber 树:由 FiberNode 连接成的工作树

单个 FiberNode 不是孤立存在的。
很多 FiberNode 会通过指针连接起来,形成一棵 Fiber 树。

这棵树不是 DOM 树,也不是 ReactElement 树。
它是 React 运行时真正用来推进工作的树。

后面的 render 阶段,React 会围绕 Fiber 树进行计算。
commit 阶段,也会根据 Fiber 上记录的副作用标记去执行真实更新。

3. HostRoot Fiber:Fiber 树最顶层的根 Fiber

第三篇里,我们已经见过 HostRoot Fiber。

它是 Fiber 树内部最顶层的根 Fiber。

注意这里的关键词是:Fiber 树内部

HostRoot Fiber 本身是一个 FiberNode,只不过它处在整棵 Fiber 树的最顶层。
根级 updateQueue 也会挂在这个 HostRoot Fiber 上。

所以第三篇里看到:

HostRoot Fiber.updateQueue

其实就是在说:

根更新先挂到 Fiber 树最顶层的那个 Fiber 上。

4. FiberRoot:树外侧的根级状态容器

FiberRoot 和 HostRoot Fiber 不是一个东西。

FiberRoot 更像是整棵树外侧的根级状态容器。
它保存根级别的信息,比如:

  • 宿主容器 container
  • 当前 Fiber 树入口
  • pending lanes
  • 根级调度状态

FiberRoot 会通过 current 指向当前 Fiber 树的 HostRoot Fiber:

FiberRoot.current -> HostRoot Fiber

也就是说:

  • FiberRoot 在树外,管理整棵树的根级状态
  • HostRoot Fiber 在树内,是 Fiber 树的根节点

这个边界非常重要。

5. ReactDOMRoot:对外暴露的 root 句柄

业务代码里写的是:

const root = createRoot(container)

这里拿到的 root,不是 FiberRoot,也不是 HostRoot Fiber,而是 ReactDOMRoot。

ReactDOMRoot 是 React DOM 暴露给业务代码使用的 root 句柄。
它内部再通过 _internalRoot 持有真正的 FiberRoot。

把这几层合起来,大概是这样:

graph TD
    A["ReactDOMRoot:业务代码拿到的 root"] --> B["_internalRoot"]
    B --> C["FiberRoot:树外根级状态容器"]
    C --> D["current"]
    D --> E["HostRoot Fiber:Fiber 树根节点"]
    E --> F["child"]
    F --> G["App Fiber"]

所以这一节最重要的结论是:

FiberRoot 是树外的根级状态容器,HostRoot Fiber 才是 Fiber 树内部的根节点。

只要这个边界分清楚,后面再看到 root.currentroot.stateNode、HostRoot Fiber,就不会混成一团。


四、ReactElement 和 Fiber 到底有什么区别

第二篇里,我们已经讲过 ReactElement。
这一篇从 Fiber 的角度,再把两者边界补完整。

很多人第一次看源码时,会把这条链想得过于简单:

JSX → ReactElement → Fiber

好像 ReactElement 只是换个名字就变成了 Fiber。

但实际上,ReactElement 和 Fiber 是两层完全不同的东西。

1. ReactElement:描述“我要渲染什么”

ReactElement 是输入描述对象。

它主要描述:

  • type
  • key
  • ref
  • props

它的任务是表达:

“我要渲染一个什么东西?”

比如:

<App count={1} />

最终会变成一个 ReactElement。
这个 ReactElement 描述了:这里要渲染一个 App,并且带着 count: 1 这样的 props。

但 ReactElement 本身不负责保存运行时工作信息。

它没有:

  • updateQueue
  • lanes
  • flags
  • subtreeFlags
  • alternate
  • memoizedState

所以它不是后续 render / commit 真正围绕处理的工作节点。

2. Fiber:承载“React 怎么处理这次工作”

Fiber 则不一样。

Fiber 要回答的问题不是“我要渲染什么”,而是:

“这次更新里,这个节点要怎么被处理?”

所以 Fiber 上会保存更多运行时信息,比如:

  • 当前节点在 Fiber 树里的位置
  • 当前节点上一次保存下来的 props / state
  • 当前节点本轮待处理的新 props
  • 当前节点上有没有 updateQueue
  • 当前节点或子树有没有副作用标记
  • 当前节点有哪些 lanes
  • 当前节点和另一棵树里的对应 Fiber 是什么关系

如果用表格对比,会更清楚:

对比项 ReactElement Fiber
定位 输入描述对象 运行时工作节点
来源 JSX 编译后运行时创建 render 过程中创建或复用
是否保存状态 不保存 保存 memoizedState
是否有更新队列 没有 可以有 updateQueue
是否记录副作用 不记录 通过 flags 等字段记录
是否参与调度 不直接参与 通过 lanes / childLanes 参与
是否关联旧节点 不关联 通过 alternate 关联

所以这一节最核心的一句话是:

ReactElement 是输入描述,Fiber 是运行时工作结构。

这一篇先把两者边界立住。
后面再进入 render 阶段时,才能更顺地理解 Fiber 子树是怎么被构建出来的。


五、child / sibling / return 如何组织 Fiber 树

既然 Fiber 是一棵运行时工作树,那这棵树是怎么组织起来的?

为了更直观,可以先看一个很简单的 JSX:

function App() {
  return (
    <>
      <Header />
      <Main />
      <Footer />
    </>
  )
}

从 ReactElement 的角度看,这里描述的是 App 下面有三个子节点:HeaderMainFooter

而到了 Fiber 这层,React 不会简单把它们理解成一个普通数组,而是会用 child / sibling / return 把它们串成一棵 Fiber 树。

FiberNode 里有三个非常关键的树结构字段:

  • child
  • sibling
  • return

它们共同把一个个 FiberNode 连接成 Fiber 树。

1. child:第一个子 Fiber

child 指向当前 Fiber 的第一个子 Fiber。

比如 App 下面有 Header、Main、Footer 三个子节点。
那么 App Fiber 的 child 会指向第一个子节点,也就是 Header Fiber。

2. sibling:下一个兄弟 Fiber

如果 Header 后面还有 Main,那么 Header Fiber 的 sibling 会指向 Main Fiber。
Main 后面还有 Footer,那么 Main Fiber 的 sibling 会指向 Footer Fiber。

也就是说,同一层的兄弟节点,不是都放在一个数组里,而是通过 sibling 串起来。

3. return:父 Fiber

return 指向当前 Fiber 的父 Fiber。

这里的 return 不是 JavaScript 里的 return 语句,而是 FiberNode 上的一个字段。
可以先把它理解成:

“处理完当前节点后,应该回到哪里?”

用一张图看会更直观:

graph LR
    A["App Fiber"] --> B["child:Header Fiber"]
    B --> C["sibling:Main Fiber"]
    C --> D["sibling:Footer Fiber"]

这里的 Header Fiber、Main Fiber、Footer Fiber 的 return 都会指回 App Fiber。
这样 React 在处理完某个子节点或兄弟节点之后,就能继续回到父节点,接着推进后续工作。

所以这一节可以先收成一句话:

child / sibling / return 让 Fiber 树可以用链表式结构表示,也为后面的 work loop 遍历打下基础。

这里先不展开 beginWorkcompleteWork 和完整深度优先遍历。
那是后面讲 render 阶段时要重点看的内容。


六、alternate 和双缓存树:current / workInProgress 是什么

讲 Fiber,绕不开 alternate

因为后面进入 render 阶段时,会不断看到这些词:

  • current
  • workInProgress
  • alternate

如果这里不先建立基本认知,后面看 render 会非常容易乱。

1. current 树:当前已经提交的 Fiber 树

React 已经提交到页面上的那棵 Fiber 树,可以先理解成 current 树。

FiberRoot 的 current 会指向当前这棵树的 HostRoot Fiber。

也就是说,当前页面已经对应着一棵 Fiber 树,这棵树就是 current 树。

2. workInProgress 树:本轮更新正在构建的新树

当一次新的更新开始时,React 不会直接在 current 树上乱改。

它会基于 current 树创建或复用一棵新的工作树,也就是 workInProgress 树。

这棵树可以先理解成:

本轮更新正在计算中的下一版 UI 状态。

也就是说:

  • current 树代表当前页面已经确认的状态
  • workInProgress 树代表本轮更新正在计算的新状态

3. alternate:连接 current Fiber 和 workInProgress Fiber

current 树和 workInProgress 树不是完全孤立的。

两个树里对应的 Fiber,会通过 alternate 关联起来。

可以粗略理解成:

current Fiber  <── alternate ──>  workInProgress Fiber

用图表示就是:

graph LR
    A["current Fiber:当前已提交"] <-->|alternate| B["workInProgress Fiber:本轮正在构建"]

有了这个关联之后,React 就能知道:

  • 旧 Fiber 是谁
  • 新 Fiber 是谁
  • 哪些信息可以复用
  • 哪些信息需要更新
  • 本轮工作和上一轮工作之间是什么关系

create-work-in-progress-alternate.png

这张图不用逐行记。这里最关键的是三件事:

  • React 会先从 current.alternate 上尝试取得对应的 workInProgress Fiber
  • 如果不存在,就创建新的 Fiber,并让 currentworkInProgress 通过 alternate 双向连接
  • 如果已经存在,就复用这个 workInProgress,同时重置本轮更新相关的 flags / subtreeFlags / deletions

所以 alternate 不是一个孤立字段,它正是 current 树和 workInProgress 树之间的连接点。

4. 为什么要有双缓存树

双缓存树可以先用一个很直观的比喻理解:

前台展示一棵已经稳定的树,后台准备另一棵新的树。

React 不直接在 current 树上乱改,而是先构建 workInProgress 树。
等这棵新树处理完成,并且进入 commit 阶段后,再把它切换成新的 current 树。

也就是说:

current tree
    ↓  基于它构建
workInProgress tree
    ↓  commit 后
new current tree

更完整一点可以这样理解:

graph TD
    A["FiberRoot.current"] --> B["current tree"]
    B <-->|alternate| C["workInProgress tree"]
    C --> D["commit 后成为新的 current"]

这一节最重要的结论是:

alternate 把旧 Fiber 和新 Fiber 关联起来,是双缓存树、节点复用和后续可中断渲染的重要基础。

createWorkInProgressreconcileChildren、bailout 这些细节可以先放一放。
这一层先把 current / workInProgress / alternate 的关系理清就够了。


七、FiberNode 的关键字段:为什么它不是普通树节点

如果只把 Fiber 看成一个树节点,就会低估它。

Fiber 不只是有 child / sibling / return 这些树结构字段。
它还会把 React 后续工作需要的信息都组织在一个节点上。

先看一张 FiberNode 构造函数里的源码截图:

fiber-node-fields.png

这张图不用逐行记。重点是先看到几个分组:

  • tag / key / type / stateNode:节点身份和实例连接
  • return / child / sibling:Fiber 树结构
  • pendingProps / memoizedProps / memoizedState:输入和状态
  • updateQueue:更新队列
  • flags / subtreeFlags / deletions:副作用标记
  • lanes / childLanes:调度相关信息
  • alternate:连接另一棵树里的对应 Fiber

如果把这些字段再按功能整理一下,大概是这样:

FiberNode
├── 身份:tag / type / key
├── 树结构:return / child / sibling
├── 输入与状态:pendingProps / memoizedProps / memoizedState
├── 更新:updateQueue
├── 副作用标记:flags / subtreeFlags / deletions
├── 调度:lanes / childLanes
├── 复用:alternate
└── 宿主连接:stateNode

这里不需要逐个背字段,更适合按功能看它们分别解决什么问题。

1. 身份信息:tag / type / key

这一组字段回答的是:

这个 Fiber 代表什么类型的节点?

比如:

  • tag 表示 Fiber 类型
  • type 表示具体组件函数、类、原生标签等
  • key 用于同层节点比较和复用

这决定了 React 后面应该用什么方式处理这个 Fiber。

2. 树结构:return / child / sibling

这一组字段回答的是:

这个 Fiber 在树里的位置在哪里?

前面已经讲过:

  • child 指向第一个子 Fiber
  • sibling 指向下一个兄弟 Fiber
  • return 指向父 Fiber

它们让 Fiber 能够连成一棵可遍历的工作树。

3. 输入与状态:pendingProps / memoizedProps / memoizedState

这一组字段回答的是:

当前节点的新输入是什么,已经保存下来的状态是什么?

大致可以先这样理解:

  • pendingProps:本轮待处理的新 props
  • memoizedProps:上一次已经确认下来的 props
  • memoizedState:当前 Fiber 上保存的状态

这里可以先记住一点:Fiber 不只是描述节点,它还保存运行时状态。

函数组件 Hooks 的状态,后面也会和 memoizedState 这条线有关。
但这里先不展开 Hook 链表,后面讲 Hooks 内部实现时再继续看。

4. 更新:updateQueue

这一组字段回答的是:

这个节点上有没有等待处理的更新?

第三篇里,我们已经看到 HostRoot Fiber 上有 updateQueue
root.render(element) 触发的根更新,就会挂到 HostRoot Fiber 的 updateQueue 上。

到了更一般的组件更新里,Fiber 也会成为更新队列挂载和后续消费的工作节点。

这里先不展开 updateQueue 内部结构。
下一篇进入“一次更新怎么进入系统”时,会继续看 Update、Queue、Lane 和 Schedule。

5. 副作用标记:flags / subtreeFlags / deletions

这一组字段回答的是:

这个节点或它的子树,在 commit 阶段有没有事情要做?

比如:

  • 是否需要插入 DOM
  • 是否需要更新 DOM
  • 是否有节点需要删除
  • 子树里是否存在副作用

这三个字段可以先这样理解:

  • flags:当前 Fiber 自身的副作用标记
  • subtreeFlags:当前 Fiber 子树里的副作用汇总标记
  • deletions:本轮需要删除的子 Fiber

这里有一个边界必须说清楚:

Fiber 上记录的是副作用标记,不是立刻执行副作用。

真正执行 DOM 操作和 effect 的地方,是后面的 commit 阶段。
Fiber 在这里做的是记录和标记,让 commit 阶段知道后面要做什么。

6. 调度:lanes / childLanes

这一组字段回答的是:

这个 Fiber 以及它的子树上,有哪些优先级的工作需要处理?

先不用急着进入位运算细节。
可以粗略理解成:

  • lanes 和当前 Fiber 自身的更新有关
  • childLanes 和子树里的更新有关

它们会帮助 React 判断哪些工作需要被处理,哪些子树里还有待完成的更新。

这一部分也会在后面讲 Update 和 Lane 时继续接上。

7. 复用:alternate

alternate 回到前面第六节讲的双缓存树。

它回答的是:

这个 Fiber 和另一棵树里的对应 Fiber 是什么关系?

有了 alternate,React 才能在 current 树和 workInProgress 树之间建立关联,从而为复用和后续工作推进打基础。

8. 宿主连接:stateNode

stateNode 的含义会随着 Fiber 类型不同而不同。

比如:

  • HostComponent 对应的 stateNode 可能是 DOM 节点
  • ClassComponent 对应的 stateNode 可能是类组件实例
  • HostRoot Fiber 的 stateNode 指向 FiberRoot

这里不用展开所有情况。
先知道 stateNode 是 Fiber 和实际实例 / 宿主对象之间的一条连接即可。

所以这一节最后可以收成一句话:

Fiber 不是普通树节点,而是 React 运行时把状态、更新、副作用标记、调度优先级和复用关系组织在一起的工作节点。


八、把 Fiber 接回 React 主线

到这里,Fiber 这层结构就基本立起来了。

如果把前几篇串起来,现在 React 主线已经可以这样理解:

JSX
→ ReactElement
→ Root / update system
→ Fiber 作为运行时工作节点

也就是说:

  • 第二篇讲清楚了 React 的输入对象是 ReactElement
  • 第三篇讲清楚了 ReactElement 会先进入根级更新系统
  • 这一篇则把 Fiber 这个运行时工作节点立起来了

从这里开始,React 主线就真正进入运行时工作阶段。

后面的更新、render、commit,都会继续围绕 Fiber 展开:

  • 更新会挂到 Fiber 的 updateQueue
  • render 阶段会构建 workInProgress Fiber 树
  • commit 阶段会读取 Fiber 上的 flags 去执行 DOM 更新和副作用

用一张图收一下:

graph TD
    A["JSX"] --> B["ReactElement:输入描述"]
    B --> C["Root / update system:进入更新系统"]
    C --> D["Fiber:运行时工作节点"]
    D --> E["render / commit:围绕 Fiber 展开"]

所以这一篇真正要建立的,不是对某个字段的记忆,而是这个判断:

从这里开始,React 主线真正进入运行时工作阶段。后面的更新、render、commit,都要围绕 Fiber 树继续展开。


结语

React 源码最难的地方,从来都不是某一个字段本身。

真正难的是:如果没有主线,ReactElement、FiberRoot、HostRoot Fiber、alternate、lanes、flags 这些词会看起来彼此割裂。

所以这一篇真正想补上的,不是 Fiber 的所有实现细节,而是先把 Fiber 在 React 主线里的位置立住:

ReactElement 是输入描述,Fiber 是运行时工作节点。

当这个边界立住以后,后面再看 Update、Queue、Lane、render、commit,落点就会稳定很多。

Fiber 这层结构立住以后,下一步就可以看一次真正的组件更新:setState / Hook dispatch 触发后,React 是怎么创建 Update、进入 Queue、分配 Lane,并最终走到调度入口的。

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub:github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化方向感兴趣,欢迎来看看:

GitHub:github.com/HWYD/ai-min…

如果觉得还不错,也欢迎顺手点个 Star。

别让爬虫白嫖你的导航站了:纯免费,手把手实现加密字体防爬

2026年4月30日 18:41

一种让爬虫抓狂、用户无感的轻量级反爬方案

你是否也经历过这样的痛?

熬夜整理的导航站,上线没几天,内容就被竞品爬虫一锅端。链接、描述、分类,一字不差地出现在别人网站上。而你除了骂两句,似乎什么都做不了。

传统的反爬手段——验证码、频率限制、IP封禁——要么影响用户体验,要么成本高昂,要么道高一尺魔高一丈。

今天,我想和你分享一个「四两拨千斤」的方案:自定义加密字体

它的核心逻辑简单到令人意外:让浏览器正常显示文字,但爬虫抓到的源码全是乱码。整个过程完全免费,无需写代码,10分钟就能搞定第一版

先看一眼最终效果:

  • 你看到的网页:AI写作工具、AI图像生成
  • 爬虫抓到的源码&#xe073; &#xe219;  —— 一串毫无意义的私有编码

这就是加密字体的魔力。下面,我从原理到实操,完整拆解给你。


一、先理解原理:为什么几个字符能挡住爬虫?

要理解加密字体,我们得先回到文字显示的最底层。

正常情况下的文字显示:

每个字符在计算机世界都有一个唯一的「身份证号」,也就是 Unicode 编码。比如:

  • 字母 A → U+0041
  • 数字 1 → U+0031
  • 汉字"测" → U+6D4B

普通字体文件的作用,就是在这些标准编码位置上,放上对应的字形图案。浏览器读取到 U+0041,就去字体里找到那个位置,把"A"画出来。爬虫抓取源码时,自然也拿到的是明文。

加密字体做了什么?

这里引入一个关键概念:Unicode 私有空白区域(U+E000 ~ U+F8FF)。

这是 Unicode 标准里专门预留的一段编码空间,没有任何系统默认字体覆盖。普通环境下,这些编码对应的位置是空的,浏览器只能显示方框或问号。

加密字体的核心策略,就是「鸠占鹊巢」:

  1. 前端代码写的是正常明文:ChatGPT、AI导航、工具
  2. CSS 指定使用自定义加密字体
  3. 字体内部做了强制映射:把"A"的字形从标准位置 U+0041,搬到了私有区域 U+E073;把"工"的字形从 U+5DE5,搬到了 U+E331
  4. 同时删掉原位置的字形:标准编码 U+0041 的位置变成空白

于是产生了奇妙的双面效果:

  • 普通爬虫/默认字体下 → 访问私有编码区域 → 乱码/方框
  • 你的网页 + 专属加密字体 → 正确读取私有区域字形 → 完美显示

就像你给所有文字换了锁,只有你的网站拿着对应的钥匙。


二、第一步:确定你要保护哪些字符

加密字体不是越大越好,恰恰相反——越小越好

每多一个字符,字体文件就大一点,加载就慢一点。所以我们要精打细算,只加密「真正需要保护」的内容。

对于大多数导航站,三类字符就够了:

1. 大小写英文字母:a-z A-Z
2. 数字:0-9  
3. 导航常用中文(精简到极致):
   AI导航、电商、素材、开发、办公、智能体、
   聊天、模型、设计、工具、资源、免费、付费、
   官网、链接、搜索、推荐、热门、最新
4. 常用符号:/ : . - _ @

按照这个范围精简,最终字体文件只有 20~80KB。作为对比,一个普通中文字体动辄 5MB 以上。这个体积对网页加载几乎没影响,放到 CDN 上更是秒开。


三、方案一:在线可视化制作(零基础首选)

如果你不想安装任何软件,这套方案 10 分钟就能跑通。

用到的工具(全部免费):

  • Glyphr Studio 在线字体编辑器:glyphr.studio/
  • 任意 Unicode 编码查询网站(百度搜一下就有)

完整制作步骤:

步骤1:导入基础字体骨架

打开 Glyphr Studio,新建一个空白字体项目。给它起个名字,比如 mysite-encrypt-font

然后导入一个轻量级的基础字体作为骨架。推荐用 Inter(英文)或 思源黑体精简版(中英文)。这个基础字体只是提供初始字形轮廓,后面会被我们大幅改造。

步骤2:清空私有编码区

这是最关键的一步。

在左侧字形列表里,找到编码范围 E000EFFF。这些就是我们要利用的私有区域。把它们全部清空,确保每个位置都是空白画布。

步骤3:建立字符映射表

现在,我们需要决定每个文字搬到哪个私有编码位置。

这条是安全核心——映射必须随机打乱,绝不能顺序排列。

❌ 错误示范(顺序递增,容易被逆向推演):

A → E001
B → E002  
C → E003

✅ 正确做法(完全随机乱序):

A → E073
B → E219
C → E156
工具 → E331 / E092
AI导航 → E400 / E512 / E087

你完全可以自己设计一套只有你知道的映射规则。就算扔个骰子随机分配,也比顺序排列安全百倍。

这是我推荐的一套映射结构,你可以直接参考:

原文字 标准编码 私有加密编码
A U+0041 U+E073
I U+0049 U+E219
U+5BFC U+E087
U+822A U+E512
U+5DE5 U+E331
U+5177 U+E092
1 U+0031 U+E201
. U+002E U+E3A1

具体操作:

  1. 在编辑器里选中正常文字"A"的字形
  2. 复制这个字形
  3. 跳到私有编码 U+E073 的画布,粘贴进去
  4. 重复这个过程,把你需要的所有文字都「搬家」到各自的私有编码位置

步骤4:删掉原位置的字形

所有文字都搬完家后,回到它们的标准编码位置(U+0041、U+5DE5 等),把这些位置的原始字形全部删除

这一步相当于「毁掉原路」——任何使用标准编码来访问你字体的人,只能看到一片空白。

步骤5:导出字体文件

顶部菜单选择导出,格式务必选 WOFF2。这是专门为网页优化的字体格式,体积最小,兼容性也好。

下载下来,你得到了一个 encrypt.woff2 文件。这就是你的专属加密武器。


四、方案二:FontForge 专业工具(适合长期维护)

如果你需要加密的字符特别多,或者计划定期更换加密方案,推荐用 FontForge。

下载地址:fontforge.org/ 免费开源,Windows 和 Mac 都能用。

它的核心操作逻辑和在线版完全一致,但支持批量操作

  1. 打开字体文件
  2. 批量选中需要加密的字符
  3. 「编辑 → 复制字形」
  4. 跳转到 Private Use Area(U+E000 ~ F8FF)
  5. 批量粘贴并重新绑定编码
  6. 删除原生编码位置的原始字形
  7. 压缩并导出 woff2

FontForge 的学习曲线稍陡,但一旦上手,制作一套新映射字体只需几分钟。适合需要定期轮换加密方案的场景。


五、进阶安全:让破解者无从下手

加密字体的原理决定了它有天然的安全性,但想做得更稳妥,还有几件事值得注意。

1. 映射关系的随机性

再次强调:不要有任何可循的规律。不要按字母顺序,不要按拼音顺序,不要用简单算法。最安全的方式就是真随机——把要保护的字符列表扔进随机数生成器,随机分配私有编码位置。

2. 定期轮换加密方案

任何防御都不是一劳永逸的。如果有心人花大量时间手工对比你的网页显示和源码输出,理论上可以逐渐还原映射表。

但你可以让这个成本变得高到不划算:

  • 每 30 天重新制作一套新的映射字体
  • R2 上替换文件,前端改一行 CSS 链接
  • 映射规则全部重排,旧的爬虫规则立刻失效

维护成本几乎为零,但破解者的积累全部归零。

3. 字体文件的极致精简

再强调一次:只打包你真正用到的字符

全量中文字体 5MB+,精简到导航常用字 30KB 以内。这不仅提升了加载速度,也减少了字体文件被反编译分析的风险面。

4. SEO 的平衡处理

加密字体主要针对的是恶意爬虫,而不是搜索引擎。如果你的站点依赖 SEO 流量,可以加一层中间件判断:

  • 识别百度/谷歌爬虫的 User-Agent → 返回纯文本版本,不加载加密字体
  • 普通无头浏览器/未知爬虫 → 走加密字体渲染

这样既保护了内容不被同行抓取,又不影响正常收录。


六、前端接入:三步上线

制作好的字体怎么部署到网站上?非常简单。

步骤1:上传字体到 Cloudflare R2

在 Cloudflare 后台新建一个 R2 存储桶,把 encrypt.woff2 上传进去,开启公开访问权限。

你会得到一个直链地址,类似:

https://xxx.r2.cloudflarestorage.com/fonts/encrypt.woff2

R2 有每月 10GB 的免费流量额度,对字体文件这种小体积资源完全够用。如果你习惯用其他对象存储(阿里云 OSS、腾讯云 COS 等),流程一样。

步骤2:在 CSS 中声明加密字体

@font-face {
  font-family: 'site-encrypt';
  src: url('https://你的R2地址/fonts/encrypt.woff2') format('woff2');
  font-weight: normal;
  font-style: normal;
  /* 指定字体生效的编码范围 */
  unicode-range: U+0030-007A, U+4E00-9FA5, U+E000-F8FF;
  font-display: swap;
}

.text-encrypt {
  font-family: 'site-encrypt' !important;
  /* 额外保护:禁止选中文本 */
  user-select: none;
}

步骤3:给需要保护的文字加上类名

<!-- 源码里是明文,但渲染出来的效果取决于字体 -->
<div class="text-encrypt">
  AI写作工具、AI图像生成、https://idao.fun
</div>

就这么简单。你的 HTML 代码里写的仍然是正常文字,方便维护。但在浏览器渲染层面,走的已经是加密字体的私有编码映射。


七、验证效果:眼见为实

部署完成后,你可以从三个角度验证:

普通用户视角: 打开你的网页,文字显示完全正常。字体、大小、颜色、主题适配——一切如常,用户感知不到任何差异。

爬虫视角(右键查看网页源码): 你看到的不是 AI写作工具,而是一堆类似 &#xe073; &#xe219;  的乱码字符串。爬虫无法从中提取可读的站点名称、链接描述等结构化信息。

开发者工具检测: 打开 F12,检查被保护的文字元素。你会看到浏览器实际渲染的是私有编码区域的内容,但 CSS 的 font-family 指向了你的加密字体,所以显示正常。


八、关于这个方案的一些补充说明

这种加密字体方案的本质,是增加了爬虫批量提取内容的成本,而不是绝对阻止。

如果对方真的铁了心要爬你的站,可以通过截图 OCR、手工比对等方式逐步还原。但对绝大多数批量抓取、自动采集的爬虫而言,这个额外成本足以让它们放弃你的站点,转向更轻松的猎物。

对于个人开发者和小团队来说,这是一个性价比极高的方案:零成本、低维护、不影响用户体验、不需要写复杂反爬逻辑。

你只需要花 10 分钟制作一个字体文件,就可以一劳永逸地保护你的导航站资源。

也许多年以后回头看,今天花在设置防爬上的这半小时,会是你整个项目里 ROI 最高的半小时。

连载06 - Hooks 源码深度解析:Claude Code 的确定性自动化体系

2026年4月30日 18:22

我是东厂:Skill管不了的,我Hooks管

AI Coding 系列第 05d 篇 · 自动化体系


上一篇讲 Skill 的时候,我提过一个判断:Skill 的本质是固化默认动作。

这话没问题,但有一个场景它盖不住。

你写了一个 code review Skill,步骤里写得清清楚楚:"改完代码后跑 prettier --write,确保格式统一。" Claude 大部分时候会照做,但偶尔它就是忘了。不是因为 Skill 写得不好,而是因为这一步对它来说只是"一段建议"——它理解了,但在上下文繁忙的时候,它可能觉得"格式化不是最紧急的",于是跳过了。

你把这行加粗,加感叹号,甚至在 Skill 里写"这一步绝对不能跳过"。大部分时候管用,但你永远没法保证 100%。

因为 Skill 的执行本质上是概率型的——它靠模型理解指令来行动,而理解本身就带不确定性。

Hooks 做的是完全不同的事:它不经过模型,不需要理解,不存在"忘了"的可能。 你配好一条规则"每次 Write 工具执行完毕后跑 prettier --write",它就是每次都跑,没有例外。

这就是 Hooks 的定位:Skill 管"怎么做",Hooks 管"做完之后必须发生什么"。 一个靠理解,一个靠规则。一个是概率型的,一个是确定型的。

理解了这个区分,后面所有内容都会顺下来。

Hooks核心概念:确定性自动化层


这篇文章对谁有用

  • 你已经用 Skill 约束过 Claude 的行为,但发现有些步骤它偶尔还是会漏
  • 你希望 Claude 改完代码自动跑 lint / 格式化 / 类型检查,不需要每次提醒
  • 你想在 Claude 调用危险命令之前自动拦截,而不是事后补救
  • 你想把 Claude 的工作流和团队的 CI / 通知 / 日志系统打通
  • 你好奇 Hooks 除了"跑 shell 命令"还能做什么(剧透:远不止这些)

先说结论

  • Hooks 是事件驱动的确定性自动化,不经过模型推理,100% 执行
  • 它和 Skill 不是替代关系,而是互补:Skill 管任务流程,Hooks 管质量保障和安全边界
  • Hooks 有五种类型:command(跑脚本)、http(调接口)、mcp_tool(调 MCP 工具)、prompt(让 Claude 做一次性判断)、agent(起子 Agent 评估)
  • 事件分三层:Session 级(启动/结束)、Turn 级(用户输入/Claude 回复完成)、Tool 级(工具调用前后)
  • PreToolUse 是最强大的事件——它可以拦截、放行、修改 Claude 即将执行的操作
  • Hook 的退出码和 JSON 输出决定了它如何反馈给 Claude,这不是 shell 技巧,是通信协议
  • Hooks 可以写在 Skill 的 frontmatter 里,和 Skill 的生命周期绑定

一、Hooks 是什么,不是什么

先把边界画清楚。

Hooks 是事件驱动的自动化层。当 Claude Code 执行到某个特定节点——比如它刚用 Write 工具写了一个文件,或者它准备调用 Bash 执行一条命令——你预先配好的动作就会被触发。

它的思路和 Git hooks 几乎一样:pre-commit 在提交前跑检查,post-merge 在合并后跑脚本。只不过 Claude Code 的 hooks 绑定的不是 Git 操作,而是 Claude 的工具调用和会话生命周期。

但 Hooks 不是

  • 不是 AI 行为规则——那是 CLAUDE.md 和 Skill 的事
  • 不是修改 Claude 思考方式的机制——它不碰模型推理
  • 不是万能拦截器——它能拦截工具调用,但拦不住 Claude 的思考过程

一句话定位:Hooks 是 Claude Code 工作流里的"系统级回调"。 你定义条件和动作,系统在满足条件时无条件执行。

Skill vs Hooks:概率型 vs 确定型

1.1 从源码看类型系统:Hooks 的精确定义

理解 Hooks 的最好方式,是看源码里它如何被类型系统精确定义。Claude Code 用 Zod 构建了完整的 Hook 类型体系,这份严谨是整个 Hook 系统可靠性的基石。

关于 Zod:TypeScript 的类型检查只在编译时生效——代码一跑起来,类型就"消失"了。Zod 做的事是在运行时校验数据——它定义一套 schema(数据结构规则),然后在你拿到任何数据(用户配置、Hook 输出、API 响应)时,当场检查这数据是否符合规则。你可以把它理解为"运行时类型警察":编译期 TypeScript 保证代码逻辑类型正确,运行时 Zod 保证外部数据格式正确。Claude Code 为什么依赖它?因为 Hook 的配置和输出都来自外部——用户写的 JSON、shell 脚本的 stdout——这些在编译期完全不可知,只有 Zod 能在程序跑起来之后拦住格式错误。

关于源码:本文引用的源码路径(如 src/schemas/hooks.ts)来自 Claude Code 的开源参考实现 claude-code-cli。这些文件不在你本地安装的 Claude Code 目录里——需要 clone 仓库才能看到完整实现。如果你对某个机制的细节感兴趣,沿着路径翻源码会比读任何二手解释都更透彻。

从源码可以知道:Hook 的四种类型(command/prompt/agent/http)通过 Zod 的 discriminatedUnion 严格定义,返回值结构根据事件类型展开 15 种分支。输出格式之所以必须精确,不是系统挑剔,而是 Zod 在运行时强制校验——多一个字段或少一个字段都会产生错误。

🔬 展开查看源码详情

src/schemas/hooks.ts:176-189 定义了四种可持久化的 Hook 类型,用 Zod 的 discriminatedUnion 模式——也就是用 type 字段做标签,区分布局的四种具体形态:

// 实际源码中的四种 Hook 类型定义
z.discriminatedUnion('type', [
  BashCommandHookSchema,  // { type: 'command', command: '...' }
  PromptHookSchema,       // { type: 'prompt', prompt: '...' }
  AgentHookSchema,        // { type: 'agent', prompt: '...' }
  HttpHookSchema,         // { type: 'http', url: '...' }
])

这个设计意味着:你的配置 JSON 里 type 字段写错一个字,Zod 会在启动时直接报解析错误,不会等到运行时才发现。这就是"确定性"的第一层体现——类型系统保证配置是正确的,否则不让通过。

src/types/hooks.ts:50-166 定义了 Hook 返回值的完整类型——syncHookResponseSchema。这不是一个简单的 {ok: true} 结构,而是一个联合类型(union),根据 hookSpecificOutput.hookEventName 的不同值,展开不同的字段结构:

// hookSpecificOutput 是一个 discriminator union
// hookEventName 的值决定了能用哪些字段
z.union([
  { hookEventName: 'PreToolUse',     → updatedInput, permissionDecision
  { hookEventName: 'PostToolUse',    → updatedMCPToolOutput, additionalContext
  { hookEventName: 'SessionStart',   → additionalContext, initialUserMessage, watchPaths
  { hookEventName: 'PermissionRequest'decision: { behavior, updatedInput } | { behavior, message }
  { hookEventName: 'Elicitation',     → action: 'accept' | 'decline' | 'cancel'
  // ... 共 15 种 discriminator
])

这意味着:你的 PostToolUse Hook 返回了 updatedInput 字段?Zod 校验会拒绝它,因为 PostToolUse 的 hookSpecificOutput 里根本没定义这个字段。Hook 返回正确的 JSON 不只是"建议",而是被 Zod 运行时强制校验的。

src/types/hooks.ts:169-176 还有一个关键设计——同步和异步响应的区分。hookJSONOutputSchema 是一个 union([asyncHookResponseSchema, syncHookResponseSchema])。Hook 输出的第一行如果是 {"async": true},系统就知道这不是同步结果,而是"我已经在后台运行了"的信号。这个协议在 execCommandHook 里通过实时解析 stdout 的第一行来实现,后面会展开讲。

这对你有什么用:理解了这个类型体系,你就知道为什么 Hook 输出格式必须精确——不是系统"挑剔",而是 Zod 在运行时执行严格校验(src/utils/hooks.ts:382-397validateHookJson)。如果你的 JSON 里多了一个不该有的字段,或者少了一个必需的字段,Hook 不会静默失败,而是会产生一条带详细错误信息的 non_blocking_error 消息。


二、配置放在哪,决定谁受影响

和 Skill 的存放位置逻辑一样,Hooks 的配置位置决定了它的作用域。

用户级(所有项目都生效):

~/.claude/settings.json

项目级(仅当前项目,可以提交到仓库):

.claude/settings.json

项目级本地(仅当前项目,不提交):

.claude/settings.local.json

插件级(插件启用时生效):

<plugin>/hooks/hooks.json

Skill / Agent 级(Skill 激活期间生效):

写在 SKILL.md 的 YAML frontmatter 里,后面会展开讲。

判断标准很简单:这个 Hook 换个项目还适用吗?"写文件后自动 prettier"大概率适用所有项目,放用户级。"改了 .prisma 文件后自动跑 prisma generate"只在用 Prisma 的项目里有意义,放项目级。

2.1 源码级:配置合并引擎如何工作

你把 Hook 配置在这五个层级,系统怎么决定最终生效哪些?从源码可以知道:合并走五层优先级链,managed(企业策略)层拥有最高控制权——用户级 disableAllHooks 关不掉 managed 层的东西。去重用 seenFiles 防止同一文件被读两次,Session hooks 存在内存 Map 里不落盘。

🔬 展开查看源码详情

src/utils/hooks/hooksConfigSnapshot.ts:18-53getHooksFromAllowedSources() 是合并逻辑的入口。它按以下优先级链依次决策:

1. policySettings.disableAllHooks == true?
   → 返回 {}(所有 Hook 禁用,包括 managed hooks)

2. policySettings.allowManagedHooksOnly == true?
   → 只返回 policySettings 里的 hooks(企业策略模式)

3. isRestrictedToPluginOnly('hooks') == true?
   → 只返回 policySettings 里的 hooks(插件限制模式)

4. 用户级 settings.disableAllHooks == true?
   → 只返回 policySettings 里的 hooks(用户禁用但 managed 不受影响)

5. 正常模式:getSettings_DEPRECATED() 合并 user + project + local

这个链表设计体现了安全分层思想:managed(企业策略)层可以在第1步就关掉所有 Hook,但用户层(第4步)不能关掉 managed 层的 Hook——因为 disableAllHooks 在用户级只是"关掉非 managed 的 Hook",不影响 policySettings。

src/utils/hooks/hooksSettings.ts:92-161getAllHooks() 进一步合并来自 editable 源(userSettings / projectSettings / localSettings)和 session hooks。注意它用 seenFiles 做了去重——当用户主目录就是项目目录时,~/.claude/settings.json.claude/settings.json 是同一个文件,不去重就会导致同一条配置执行两次。

Session hooks 是另一个维度的配置——它们存在内存中(src/utils/hooks/sessionHooks.ts:62SessionHooksState = Map<string, SessionStore>),不落盘。Session hooks 来自三个渠道:

  • Skill frontmatter 里的 hooks 字段
  • Agent 定义里的 hooks 字段
  • 内部系统注册的 function hooks(比如 structured output enforcement)

src/utils/hooks.ts:1492-1566getHooksConfig() 把所有来源合并成最终要执行的 Hook 列表。合并顺序是:snapshot hooks → registered hooks(SDK/plugin)→ session hooks → session function hooks。

这对你有什么用:如果你发现某个 Hook 总是被覆盖,用 /hooks 命令查看当前生效列表,对照上面的合并链就能定位是哪个源覆盖了它。也意味着你不能在用户级 disableAllHooks 来关掉企业策略层的 Hook——这是故意的安全设计。

配置的基本 JSON 结构长这样:

{
  "hooks": {
    "事件名": [
      {
        "matcher": "匹配模式",
        "hooks": [
          {
            "type": "command",
            "command": "你要执行的命令"
          }
        ]
      }
    ]
  }
}

三层嵌套:事件名 → 匹配规则 → 具体动作。看起来层级多,但逻辑很清晰:什么时候触发(事件)→ 在什么条件下触发(matcher)→ 触发后做什么(hooks 数组)。


三、事件分三个层级,从粗到细

Hooks 能绑定的事件不只是"工具调用前后"。它覆盖了 Claude Code 会话的整个生命周期,按粒度分三层。

Session 级:会话生命周期节点触发

SessionStart — 会话启动时。适合加载开发环境上下文、设置环境变量、打印项目状态。

它有四种子场景,通过 matcher 区分:

  • startup:全新会话启动
  • resume:恢复之前的会话
  • clear:清空对话后重新开始
  • compact:上下文压缩后重新加载
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "echo '当前分支:'$(git branch --show-current) '| 未提交文件:'$(git status --short | wc -l | tr -d ' ')"
          }
        ]
      }
    ]
  }
}

这样每次新会话启动,Claude 第一眼就能看到当前分支和未提交文件数。不需要它自己去查,上下文一开始就对了。

SessionEnd — 会话结束时。适合做清理、统计、日志归档。

Turn 级:每轮对话触发

UserPromptSubmit — 用户发送消息后、Claude 开始处理之前。可以用来做输入校验、自动注入上下文。

Stop — Claude 完成一轮回复时。适合做收尾检查、发通知。

这里有个特别实用的用法:如果你的 Stop Hook 返回 { "continue": false, "stopReason": "请先跑完测试再结束" },Claude 会看到这个消息并继续工作,而不是直接结束。这等于给了你一个"拦截 Claude 过早收手"的能力。

StopFailure — Claude 非正常停止时(比如限流、认证失败、账单错误)。matcher 可以区分具体原因:rate_limitauthentication_failedbilling_error 等。

Tool 级:每次工具调用触发

PreToolUse — Claude 即将调用某个工具之前。这是最强大的事件,因为它能拦截、放行、甚至修改即将执行的操作。后面单独展开。

PostToolUse — 工具调用成功之后。最常用的事件——写文件后跑格式化、编辑后跑 lint、执行命令后记日志。

PostToolUseFailure — 工具调用失败后。适合做错误收集和诊断。

PermissionRequest — 权限弹窗出现时。可以用来自动批准或拒绝特定操作。

还有一些更细粒度的事件,比如 FileChanged(文件变更时)、CwdChanged(工作目录切换时)、PreCompact / PostCompact(上下文压缩前后)、WorktreeCreate / WorktreeRemove(git worktree 操作时)。不是每个都常用,但知道它们存在很重要——当你遇到"我想在某个时机自动做某件事"的需求时,很可能已经有对应的事件。

Hooks事件体系:三层从粗到细


四、matcher:决定什么条件下才触发

配了事件之后,matcher 决定"在这个事件里,我只关心哪些情况"。

对于 Tool 级事件(PreToolUse、PostToolUse 等),matcher 匹配的是工具名称

写法 含义 例子
省略 / "" / "*" 匹配所有工具 任何工具调用都触发
纯字母数字 精确匹配 "Write" 只在写文件时触发
| 分隔 匹配多个工具 "Edit|Write" 写或编辑文件时触发
其他字符 当作正则表达式 "^Notebook" 匹配所有笔记本工具

MCP 工具的匹配格式是 mcp__<server>__<tool>,比如 mcp__memory__.* 匹配 memory 服务器的所有工具。

还有一个更精细的过滤:if 字段。它用权限规则语法做二次筛选。

{
  "matcher": "Bash",
  "if": "Bash(git *)",
  "hooks": [{ "type": "command", "command": "echo 'Git 操作被执行'" }]
}

这个 Hook 只在 Claude 用 Bash 执行 git 开头的命令时才触发——不是所有 Bash 调用,只是 git 相关的。ifmatcher 配合,能做到非常精准的条件筛选,不会误触发。

4.1 源码级:matcher 匹配引擎的三层判断

你写的 matcher 字符串是怎么被解析和匹配的?从源码可以知道:matcher 经历三层判断——空值/* 全部放行 → 纯字母数字做精确或管道匹配 → 含特殊字符走正则。if 条件二次筛选用的是权限规则引擎的 AST 匹配,不是正则。

🔬 展开查看源码详情

src/utils/hooks.ts:1346-1381matchesPattern() 函数执行了三层判断:

第一层:空值和通配符(matchesPattern:1347-1349

if (!matcher || matcher === '*') {
  return true  // 匹配所有
}

省略 matcher、写空字符串、写 "*",三者等效——全部放行。

第二层:纯字母数字模式(matchesPattern:1351-1361

if (/^[a-zA-Z0-9_|]+$/.test(matcher)) {
  // 精确匹配或管道分隔的多个精确匹配
  if (matcher.includes('|')) {
    const patterns = matcher.split('|').map(p => normalizeLegacyToolName(p.trim()))
    return patterns.includes(matchQuery)
  }
  return matchQuery === normalizeLegacyToolName(matcher)
}

关键细节:normalizeLegacyToolName 会把旧版工具名映射到新名。也就是说你写 "Write""WriteFile"(旧名)都能正确匹配。管道符 | 会被拆成数组逐一比对——不是正则的或运算符,是精确匹配的或逻辑。

第三层:正则表达式模式(matchesPattern:1363-1380

// 包含任何非字母数字下划线管道符的字符 → 当作正则
const regex = new RegExp(matcher)
if (regex.test(matchQuery)) return true
// 也对旧版工具名做正则匹配,兼容 legacy 名称
for (const legacyName of getLegacyToolNames(matchQuery)) {
  if (regex.test(legacyName)) return true
}

如果你在 matcher 里写了 ^.*() 等任何特殊字符,系统会自动切换到正则模式。这就是为什么 "^Notebook" 能用、".*" 也能用——因为在 [a-zA-Z0-9_|] 之外就进入正则分支。

if 条件的二次筛选用的是权限规则引擎src/utils/hooks.ts:1390-1421prepareIfConditionMatcher() 针对不同工具类型调用各自的 preparePermissionMatcher 方法。比如 Bash 工具的 matcher 会解析你的命令字符串("rm -rf *"),生成一个 AST 节点,然后用 tree-sitter 做真正的命令模式匹配——不是正则,是 AST。

一个常见坑:你在 matcher 里写 "Bash"(希望匹配 Bash 工具名),它确实全由字母数字组成——满足 [a-zA-Z0-9_|]+ 检测,所以它被当作精确匹配,不会进正则分支。这和有些人的直觉不同——他们认为 "Bash" 混合大小写、看起来像正则。实际上只有包含 ^.$( 等特殊字符的字符串才会进正则分支。


五、不只是跑 shell 命令:Hook 的五种类型

很多人以为 Hooks 就是"跑 shell 命令",其实远不止。Claude Code 支持五种 Hook 类型,覆盖了从本地脚本到远程服务到 AI 判断的完整谱系。

1. command — 执行 shell 命令

最常用的类型。Claude 的操作信息以 JSON 格式通过 stdin 传给你的命令。

{
  "type": "command",
  "command": "npx prettier --write $(cat /dev/stdin | jq -r '.tool_input.file_path')",
  "timeout": 30
}

支持 async: true 让命令在后台执行不阻塞 Claude,还有 asyncRewake: true 可以在异步命令完成后唤醒 Claude 继续处理。

2. http — 调用外部 HTTP 接口

把事件信息发到外部服务。非常适合做日志收集、团队通知、审计系统对接。

{
  "type": "http",
  "url": "https://your-team-webhook.com/claude-events",
  "headers": {
    "Authorization": "Bearer ${API_TOKEN}"
  }
}

请求体是 JSON POST,包含完整的事件信息。headers 里支持 ${ENV_VAR} 语法引用环境变量,通过 allowedEnvVars 字段控制哪些环境变量可以被引用。非 2xx 响应不会阻断 Claude,只记录错误。

3. mcp_tool — 调用已连接的 MCP 工具

直接调用当前会话里已经连接的 MCP 服务器工具。输入支持模板变量。

{
  "type": "mcp_tool",
  "server": "memory",
  "tool": "add_memory",
  "input": {
    "content": "Claude edited file: ${tool_input.file_path}"
  }
}

这个例子在每次文件编辑后,自动把操作记录写进 MCP memory 服务器。不需要写 shell 脚本,不需要 HTTP 请求,直接用已有的 MCP 连接。

4. prompt — 让 Claude 做一次性判断

这是一个很有意思的类型:它在 Hook 触发时起一个轻量的 Claude 实例,做一次 yes/no 判断。

{
  "type": "prompt",
  "prompt": "以下 Bash 命令是否可能造成数据丢失或不可逆操作?只回答 yes 或 no。命令:${tool_input.command}",
  "model": "haiku",
  "timeout": 30
}

prompt 类型的 Hook 适合做那些不好用规则硬写、但又需要快速判断的场景。比如"这条命令是不是危险的"这个问题,用正则匹配只能覆盖已知的危险模式,用 prompt Hook 可以做更灵活的语义判断。

不过要注意:它每次触发都会消耗 token,而且有延迟。只在 PreToolUse 这类需要做决策的事件上用才划算,别挂在 PostToolUse 这种高频事件上。

5. agent — 起子 Agent 做深度评估

最重量级的类型。它会起一个有 Read、Grep、Glob 工具的子 Agent,做更复杂的评估。

{
  "type": "agent",
  "prompt": "检查当前工作目录下是否有未提交的敏感文件(.env, credentials, private keys)。如果发现,返回 block 决策。",
  "timeout": 60
}

agent 类型目前还是实验性的,但它代表了一个很有意思的方向:用 AI 来守护 AI 的操作边界。 主 Agent 准备执行某个操作,另一个独立的 Agent 先跑一轮检查,觉得安全才放行。

Hook五种类型完整谱系

5.1 源码级:execCommandHook — 命令执行引擎的内部运作

整个 Hooks 系统最核心的函数是近600行的 execCommandHook()从源码可以知道:Windows bash 模式自动做 Cygwin 路径转换,PowerShell 模式走原生路径;异步检测协议通过实时解析 stdout 第一行 JSON 实现;asyncRewake 让后台任务完成后能唤醒 Claude。

🔬 展开查看源码详情

src/utils/hooks.ts:747-1335 是执行引擎,处理 shell 选择、路径转换、环境变量注入、stdin/stdout/stderr 生命周期、异步检测、超时控制。

Windows 路径的 Cygwin 转换陷阱。 execCommandHook:808-810 对 Windows bash 模式下的所有路径做 POSIX 转换:C:\Users\foo/c/Users/foo。这是因为 Git Bash 底层是 Cygwin,不认识 Windows 路径。PowerShell Hook(shell: "powershell")走的是原生路径,不做此转换。这意味着:如果你在 Windows 上写 bash Hook,环境变量 CLAUDE_PROJECT_DIR 的值是 /c/Users/... 格式,不是 C:\Users\...

环境变量注入清单(execCommandHook:882-926)。 每个 Hook 进程获得 CLAUDE_PROJECT_DIR(repo root,不是 worktree 路径)、CLAUDE_PLUGIN_ROOT(plugin/skill 的根目录)、CLAUDE_PLUGIN_DATA(plugin 数据目录)、CLAUDE_PLUGIN_OPTION_*(plugin 的用户配置项)。SessionStart/Setup/CwdChanged/FileChanged 事件还额外获得 CLAUDE_ENV_FILE——Hook 把 export VAR=value 写进这个 .sh 文件,系统随后把所有 .sh 文件拼接注入后续 Bash 命令。PowerShell Hook 不会获得此变量(PS 写的是 $env:FOO = 'bar',bash 无法解析)。

异步检测协议(execCommandHook:1117-1164)。 系统不需要等 Hook 执行完才判断它是同步还是异步——它实时解析 stdout 的第一行:如果第一行是 {"async": true},进程立刻被转入后台执行,主逻辑不等待。这个协议的精妙之处在于:Hook 的脚本可以先 echo 一行 JSON 告知"我要异步了",然后慢慢做耗时操作。配置级的 "async": true(第 995-1030 行)则更早——spawn 之后直接 stdin 写入、直接后台化,不等第一行。

asyncRewake 机制(execCommandHook:206-245)。asyncRewake: true 的后台进程以 exit code 2 结束时,stderr 内容会通过消息队列注入 Claude 的上下文,让它"醒来"处理阻断信息。典型场景:写文件后异步跑测试套件,测试失败(exit 2)时 Claude 自动看到失败信息并修复。


六、退出码和 JSON 输出:Hook 如何和 Claude 通信

这是很多人忽略的关键细节。Hook 不是"跑完就完了"——它的退出码和标准输出决定了后续 Claude 的行为。

退出码

  • exit 0:成功。如果 stdout 有内容,尝试解析为 JSON
  • exit 2:阻断错误。Claude 会看到 stderr 里的信息,当前操作被中止
  • 其他:非阻断错误。stderr 信息记录到日志,但 Claude 继续工作

exit 2 是一个特别设计的"强制刹车"。比如你的 PreToolUse Hook 检测到 Claude 要删一个关键文件,exit 2 + stderr 写上"禁止删除 production.env",这个操作就会被直接拦住。

JSON 输出格式

如果 Hook 以 exit 0 退出,stdout 输出的 JSON 可以精确控制 Claude 的后续行为:

{
  "continue": true,
  "systemMessage": "lint 发现 3 个警告,已自动修复 2 个",
  "decision": "allow",
  "reason": "操作安全,已通过检查",
  "hookSpecificOutput": {
    "additionalContext": "修复了 import 排序和尾部逗号"
  }
}

几个关键字段:

continue — 设为 false 时 Claude 停止当前任务,stopReason 会显示给用户。

systemMessage — 以系统消息的形式显示给用户,适合传达重要但不阻断的信息。

decision — 只在 PreToolUse 和 PermissionRequest 事件中有效。可选值:

  • allow:放行,不再弹权限确认
  • deny(或 block):拒绝执行,Claude 会看到拒绝原因并尝试其他方案
  • ask:交给用户决定
  • defer:这个 Hook 不做判断,留给后续 Hook 或默认逻辑

hookSpecificOutput.updatedInput — 这个最有意思:它可以修改 Claude 即将执行的工具输入。比如 Claude 要写文件到 /tmp/test.js,你的 Hook 可以把路径改成 /tmp/sandbox/test.js。Claude 不知道路径被改了,但实际操作发生在你指定的安全位置。

理解了这套通信协议,你对 Hooks 能做什么的认知会完全不一样。它不只是"跑个脚本",它是一个双向通信通道——Claude 告诉 Hook "我要做什么",Hook 告诉 Claude "可以做 / 不可以做 / 改一下再做"。

6.1 源码级:退出码和 JSON 输出的处理管线

从 Hook 进程退出到 Claude 感受到反馈,中间经过三层处理管线。从源码可以知道:stdout 第一字符必须是 { 才能进 JSON 解析;Zod 校验失败会生成精确的错误路径;hookEventName 与实际事件不匹配会被直接抛错丢弃。

🔬 展开查看源码详情

第一层:parseHookOutputsrc/utils/hooks.ts:399-451

function parseHookOutput(stdout: string) {
  const trimmed = stdout.trim()
  if (!trimmed.startsWith('{')) {
    return { plainText: stdout }  // 非 JSON → 当作纯文本
  }
  // 尝试用 Zod 校验这个 JSON
  const result = validateHookJson(trimmed)
  ...
}

这里有一个关键点:stdout 不是以 { 开头的,系统就直接把它当纯文本输出。所以如果你的 Hook 在 JSON 之前 echo 了任何东西(比如 bashrc 的 motd),JSON 解析会失败。这也是为什么 || true 不能放在 stdout 输出 JSON 的命令里——|| true 不解决问题,echo 的杂质字面量才是问题。

第二层:validateHookJsonsrc/utils/hooks.ts:382-397

function validateHookJson(jsonString: string) {
  const parsed = jsonParse(jsonString)
  const validation = hookJSONOutputSchema().safeParse(parsed)
  if (validation.success) {
    return { json: validation.data }
  }
  // 构造详细的错误信息
  const errors = validation.error.issues
    .map(err => `  - ${err.path.join('.')}: ${err.message}`)
    .join('\n')
  return { validationError: `Hook JSON output validation failed:\n${errors}\n...` }
}

Zod 在这里做运行时校验。你的 JSON 结构如果不对——比如 PostToolUse Hook 的 hookSpecificOutput 里写了 updatedInput(这是 PreToolUse 才有的字段)——Zod 会生成一条具体的错误路径,告诉你是哪个字段不合法。

第三层:processHookJSONOutputsrc/utils/hooks.ts:489-737

这是最长的处理函数。它根据 hookSpecificOutput.hookEventName 的值,走不同的 switch 分支提取相应字段。例如:

case 'PreToolUse':
  result.updatedInput = json.hookSpecificOutput.updatedInput
  result.additionalContext = json.hookSpecificOutput.additionalContext
  // 权限决策覆盖
  if (json.hookSpecificOutput.permissionDecision === 'allow') {
    result.permissionBehavior = 'allow'
  } else if (...) { ... }
  break

case 'PostToolUse':
  result.updatedMCPToolOutput = json.hookSpecificOutput.updatedMCPToolOutput
  result.additionalContext = json.hookSpecificOutput.additionalContext
  break

注意第 583-590 行——如果 hookSpecificOutput.hookEventName 和实际触发的事件名不匹配(比如你在 PostToolUse Hook 里返回了 hookEventName: 'PreToolUse'),系统会直接抛错。这是一个常见的坑:你需要保证返回的 hookEventName 和实际事件一致,否则整个 Hook 结果会被丢弃。

退出码的实际处理流程(src/utils/hooks.ts:2617-2696

exit 0stdout 是 JSON → processHookJSONOutput → 结构化处理
exit 0stdout 不是 JSON → 创建 hook_success 消息,stdout 作为纯文本内容
exit 2 → 创建 hook_blocking_error 消息,stderr 作为阻断原因
其他 exit code → 创建 hook_non_blocking_error 消息,记录错误但不阻断

这里有两个容易被忽略的细节:

  1. exit 2 的 stderr 内容会被包装成 [hookCommand]: ${stderr} 格式传给 Claude。所以你的 stderr 文字应该直接是 Claude 能理解的说明,不需要额外格式。

  2. exit 0 但 stdout 是 JSON 且 suppressOutput: true 时,即使 JSON 校验通过,stdout 也不会出现在对话记录里——只处理结构化字段。这在你只想修改 updatedInput 而不想污染上下文时很有用。

退出码与JSON输出处理管线


七、PreToolUse 深入:拦截、放行、修改

PreToolUse 值得单独拉出来讲,因为它是整个 Hooks 体系里最强大、也最需要谨慎使用的事件。

拦截危险操作

最直接的用法:阻止 Claude 执行你不想让它碰的命令。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(rm -rf *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"decision\": \"deny\", \"reason\": \"禁止执行 rm -rf,请使用更安全的删除方式\"}'"
          }
        ]
      }
    ]
  }
}

Claude 会看到拒绝原因,然后尝试用其他方式完成任务——比如逐个删除,或者用 trash 命令。它不会傻等,也不会崩溃,而是理解了约束之后调整策略。

自动放行可信操作

反过来,你也可以让某些操作自动通过,不再弹权限确认框。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(npm test *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"decision\": \"allow\"}'"
          }
        ]
      }
    ]
  }
}

每次 Claude 要跑 npm test,不再问你"确认执行吗?",直接跑。这在你信任测试命令的安全性时很有用——减少人工确认的打断,让 Claude 的工作流更顺畅。

修改工具输入

最高级的用法:在 Claude 不知情的情况下,修改它即将执行的操作。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 -c \"import sys,json; d=json.load(sys.stdin); d['hookSpecificOutput']={'updatedInput':{'file_path': d['tool_input']['file_path'].replace('/src/','/src/sandbox/')}}; print(json.dumps(d))\""
          }
        ]
      }
    ]
  }
}

这个 Hook 把所有写入 /src/ 的文件重定向到 /src/sandbox/。Claude 以为自己在正常写文件,实际上所有改动都进了沙箱。等你确认没问题,再合并回去。

这种能力在高风险场景下特别有价值——你不需要告诉 Claude "先写到沙箱里"(它可能忘),而是从系统层面保证它的所有写操作都是安全的。


八、Hooks 和 Skills 的联动:frontmatter 里的 hooks 字段

前面说了 Hooks 的配置放在 settings.json 里。但还有一种更精细的用法:把 Hooks 写在 Skill 的 frontmatter 里,让它只在这个 Skill 激活期间生效。

---
name: secure-deploy
description: Deploy with security checks. Manual trigger only.
disable-model-invocation: true
context: fork
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/deploy-security-check.sh"
          statusMessage: "Running security scan..."
  Stop:
    - hooks:
        - type: command
          command: "./scripts/notify-deploy-complete.sh"
          once: true
---

执行部署流程...

这里有几个值得注意的点。

hooks 字段的格式和 settings.json 里的一模一样,只是写成了 YAML。当这个 Skill 被触发时,这些 Hooks 才生效;Skill 结束后,Hooks 自动失效。

statusMessage 字段可以自定义 Hook 执行时的等待提示,用户会在界面上看到"Running security scan..."而不是默认的等待动画。

once: true 表示这个 Hook 在当前会话里只执行一次。比如部署完成通知,你只想收到一次,不想每次 Claude 说完话都发一遍。这个字段只在 Skill / Agent 的 frontmatter hooks 里有效。

这个组合的威力在于:Skill 定义了"做什么",Hooks 保证了"做的过程中,哪些安全检查绝对不能跳过"。 Skill 是概率型的任务流程,Hooks 是确定型的安全护栏。两者绑在一起,一个能力包就同时有了灵活性和可靠性。

8.1 源码级:Hook 的并行执行与权限优先级仲裁

当同一个事件上挂了多个 Hook,它们并行执行而非串行。从源码可以知道:权限优先级为 deny > ask > allow(安全优先),仅 allow/ask 的 Hook 可修改工具输入,去重用 \0 分隔符确保不同 plugin 的同名 Hook 不被误合并。

🔬 展开查看源码详情

src/utils/hooks.ts:2142-2972executeHooks()all(hookPromises) 将同一个事件的所有匹配 Hook 并行启动。每个 Hook 在自己的 Promise 里独立运行,有自己的超时和 abort signal。

但并行执行带来了一个问题:多个 Hook 可能返回不一致的权限决策。比如 Hook A 说 allow,Hook B 说 deny,应该听谁的?

权限优先级规则在 src/utils/hooks.ts:2820-2847

deny  >  ask  >  allow

一个 deny 会覆盖所有 allow 和 ask;一个 ask 会覆盖 allow;allow 只是最后的 fallback。这个顺序不是随意的——它体现了安全优先的原则:任何 Hook 的拒绝都能推翻其他 Hook 的放行,但任何一个 Hook 的放行不能推翻其他 Hook 的拒绝。

updatedInput 的合并也值得注意(src/utils/hooks.ts:2850-2880:只有标记了 allowask 的 Hook 才能修改工具输入。标记为 deny 的 Hook 的 updatedInput 会被忽略——既然都拒绝执行了,修改输入就没有意义。

去重机制(src/utils/hooks.ts:1453-1455

function hookDedupKey(m: MatchedHook, payload: string): string {
  return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}`
}

同一个 Hook 命令可能同时出现在用户级和项目级配置中,去重机制用 \0 分隔符 + plugin/skill 命名空间确保:同一个来源内的重复只执行一次,但不同 plugin 的同名 Hook 不会相互覆盖。去重键包含 command/prompt/url 内容、shell 类型、和 if 条件——也就是说,{command: "echo x", shell: "bash"}{command: "echo x", shell: "powershell"} 被视为不同的 Hook。

内部 callback hook 的性能优化(src/utils/hooks.ts:2036-2067:当所有匹配的 Hook 都是 callback 类型(内部 Hook,如 sessionFileAccessHooks、attributionHooks)时,系统跳过 span/tracing/progress/resultLoop 等全套开销,直接同步调用。实测这个 fast-path 将 PostToolUse 的每次命中从 6µs 降到 ~1.8µs。这对理解系统设计很重要——内部 Hook 和用户 Hook 是两条不同的执行路径。


九、Hook 的输入:Claude 告诉你它在做什么

每个 Hook 触发时,都会收到一个 JSON 输入(对 command 类型是 stdin,对 http 类型是请求体)。这个输入包含了当前事件的完整上下文。

所有事件都有的公共字段:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/your/project/root",
  "hook_event_name": "PostToolUse"
}

Tool 级事件额外包含:

{
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/src/index.ts",
    "content": "..."
  },
  "tool_output": "File written successfully"  // 只在 PostToolUse 里有
}

SessionStart 事件有一个特殊能力:通过 CLAUDE_ENV_FILE 环境变量,你可以把 Hook 设置的环境变量持久化到整个会话。

#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo "export PROJECT_VERSION=$(cat package.json | jq -r '.version')" >> "$CLAUDE_ENV_FILE"
  echo "export GIT_BRANCH=$(git branch --show-current)" >> "$CLAUDE_ENV_FILE"
fi

这样 Claude 在整个会话里都能访问到 $PROJECT_VERSION$GIT_BRANCH,不需要每次重新查。


十、实战:一个项目级配置的完整例子

把前面讲的这些组合成一个真实可用的项目配置。这个配置覆盖了日常开发最常用的五个自动化场景。

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"分支: $(git branch --show-current) | 待提交: $(git status --short | wc -l | tr -d ' ') 个文件\""
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(rm -rf *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"decision\": \"deny\", \"reason\": \"rm -rf 被禁止,请用更安全的删除方式\"}'"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write $(cat /dev/stdin | jq -r '.tool_input.file_path') 2>/dev/null || true"
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)",
        "hooks": [
          {
            "type": "command",
            "command": "npx tsc --noEmit 2>&1 | head -15 || true"
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$(date '+%H:%M:%S') $(cat /dev/stdin | jq -r '.tool_name'): $(cat /dev/stdin | jq -r '.tool_input.file_path')\" >> .claude-activity.log"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"任务完成\" with title \"Claude Code\"' 2>/dev/null || notify-send 'Claude Code' '任务完成' 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

这个配置做了五件事:

  1. 新会话启动时,显示当前分支和未提交文件数——Claude 从第一步就有正确的上下文
  2. 拦截 rm -rf——不管 Claude 出于什么理由想执行这个命令,直接拒绝
  3. 写 / 编辑文件后自动格式化——|| true 确保不支持的文件类型不会阻断流程
  4. TypeScript 文件改动后自动类型检查——Claude 立刻看到类型错误,下一步就能修复
  5. 任务完成后桌面通知——macOS 和 Linux 都覆盖,你可以安心去做别的事

10.1 源码级:信任检查——为什么所有 Hook 都要过一道安全闸

从源码可以知道shouldSkipHookDueToTrust() 是所有 Hook 执行前的统一守门人——交互模式下未通过信任对话框则所有 Hook 跳过。这个集中检查源于两个历史漏洞(SessionEnd 和 SubagentStop 绕过信任),修复后成为唯一的安全入口。

🔬 展开查看源码详情

src/utils/hooks.ts:286-296shouldSkipHookDueToTrust() 是 Hook 执行的第一道守门人,在 executeHooks() 的第 1994 行被调用。它强制执行一条简单但重要的规则:

export function shouldSkipHookDueToTrust(): boolean {
  const isInteractive = !getIsNonInteractiveSession()
  if (!isInteractive) {
    return false  // SDK/CI 模式:隐式信任,直接执行
  }
  const hasTrust = checkHasTrustDialogAccepted()
  return !hasTrust  // 交互模式下:没通过信任对话框 → 跳过
}

所有 Hook——无一例外——都需要 workspace trust。 这不是针对某个具体事件的检查,而是对所有事件的集中拦截。源码注释里明确写了两条历史漏洞:SessionEnd Hook 在用户拒绝信任对话框时仍执行了;SubagentStop Hook 在子代理完成时绕过了信任检查。这两个漏洞促使团队把信任检查集中到一个地方——executeHooks() 入口处——而不是分散在每个事件调用点。

与之配套的是 hooksConfigSnapshot.ts:18-53 的快照机制:信任对话框弹出之前,系统就通过 captureHooksConfigSnapshot() 拍了一张 Hook 配置的快照。即使用户拒绝信任,后来 Hook 配置也不会被动态加载和解析。先截屏、再弹窗、再执行——用一个时间顺序保证了"拒绝信任后,没有新的 Hook 能被注入"。

在企业策略层面,shouldDisableAllHooksIncludingManaged()hooksConfigSnapshot.ts:83-88)和 isEnvTruthy(CLAUDE_CODE_SIMPLE)hooks.ts:1982)提供了两个额外的全局开关:前者由企业策略层的 manage settings 控制,后者是一个环境变量级别的紧急熔断。


十一、避坑指南

Hook 命令要快。 Hook 在 Claude 工作流里同步执行(除非你用了 async: true)。一个跑 5 秒的 Hook,每次写文件都阻塞 5 秒。全量测试套件不适合放在 PostToolUse 里——只跑相关测试或做快速检查。

|| true 处理非关键 Hook 的失败。 Prettier 不支持的文件类型会报错,这不应该影响 Claude 继续工作。非关键的 Hook 加上 || true,让失败静默通过。

stdout 不要有杂质。 如果你的 Hook 返回 JSON,确保 stdout 里只有 JSON。shell 的 .bashrc / .zshrc 里的 echo、motd、conda 提示等都会污染输出,导致 JSON 解析失败。

PreToolUse 的 deny 不是终点。 Claude 收到 deny 后不会崩溃,它会理解拒绝原因并尝试其他方案。所以 reason 字段尽量写清楚"为什么不行"和"建议怎么做",这样 Claude 的调整方向会更准。

多个 Hook 的执行顺序。 同一个事件上挂了多个 Hook,它们并行执行(源码中用 all(hookPromises) 并发调度)。返回的权限决策按 deny > ask > allow 的优先级仲裁。需要注意的是:并不是配得越靠前 Hook 就越先生效——安全优先的仲裁规则才是决定最终行为的机制。

/hooks 命令调试。 在 Claude Code 里输入 /hooks,可以查看当前生效的所有 Hook 配置。配完之后先确认它们真的被加载了,再去测试效果。


十二、什么该用 Skill,什么该用 Hooks

到这里,一个自然的问题是:同样是"让 Claude 写完文件后跑 lint",我到底该写在 Skill 里还是配成 Hook?

判断标准只有一个:

这件事需不需要模型理解?

  • 需要理解上下文才能决定怎么做 → Skill
  • 不需要理解,每次都一样执行 → Hook

"代码审查时按固定顺序检查数据库、异步和错误处理" → 这需要 Claude 理解代码逻辑,Skill。

"每次写文件后跑 prettier" → 不需要理解,纯机械执行,Hook。

"检测到 Claude 要执行危险命令时阻止" → 不需要理解(或者用 prompt 类型做轻量判断),Hook。

"根据 PR 的改动范围决定需要跑哪些测试套件" → 需要理解改动语义,Skill。

更精确地说:Skill 是你给 Claude 的任务书,Hooks 是你给系统的执行规则。 Skill 的执行者是模型,Hooks 的执行者是系统。当你发现自己在 Skill 里写的某一步"每次都一样、不需要判断",那一步就该提取成 Hook。

最强的组合是两者配合:Skill 定义任务流程和判断逻辑,Hooks(尤其是 Skill frontmatter 里的 hooks)保证流程中的确定性步骤不会被跳过。


本篇实践任务

任务一(5 分钟,马上做): 在你的 ~/.claude/settings.json 里加一个 Stop Hook,任务结束后发桌面通知。做完之后,开一个新会话让 Claude 帮你改一个文件,然后去泡杯茶——等通知弹出来再回来看结果。

任务二(有 TypeScript 项目必做): 加上 PostToolUse Hook,Write 和 Edit 之后自动跑 prettier --write + tsc --noEmit。观察 Claude 的行为变化:当它看到类型错误时,会不会主动去修?

任务三(想做安全防护的): 配一个 PreToolUse Hook,拦截 rm -rf 命令。然后故意让 Claude 执行一个可能触发 rm -rf 的任务,观察它收到 deny 后的调整策略。


下篇预告

第 05b 篇:Plugins 打包与分发——把 Skill + Hooks + CLAUDE.md 封装成团队可复用的能力包

上一篇讲了 Skill(任务模板),这一篇讲了 Hooks(确定性守卫),加上第 04 篇的 CLAUDE.md(行为约束)——这三者刚好是 Plugin 的三块核心砖。下一篇聚焦怎么把它们打包成一个可安装、可共享的 Plugin,让你把个人的最佳实践变成团队的基础设施。


AI Coding 系列持续更新。Skill 管"怎么做",Hooks 管"做完之后必须发生什么"——把概率型和确定型的控制手段分清楚,你对 Claude 的掌控感会提升一个量级。

uniapp引入tailwindcss4.x

2026年4月30日 18:01

为什么要引入tailwindcss?

tailwindcss的核心优势:

  • CSS不会随着项目增长而膨胀,不需要重复造轮子
  • 可以快速迭代项目,尤其对于个人开发者,能够有效提高生产力
  • 提供统一的设计标准,无需为类的命名烦恼

需要准备添加的文件

  • vite.config.js
  • tailwind.config.js
  • style.css

步骤

  1. 用hbuildx新建一个uniapp项目。
  2. 使用pnpm进行初始化
> pnpm init
  1. 添加相关依赖
pnpm i -D @tailwindcss/postcss @tailwindcss/vite tailwindcss weapp-tailwindcss
  1. 项目根目录添加vite.config.js文件
import {
    defineConfig
} from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import path from 'path'
import autoprefixer from 'autoprefixer'
import tailwindcss from '@tailwindcss/postcss'
import { UnifiedViteWeappTailwindcssPlugin as uvwt } from "weapp-tailwindcss/vite";

const isH5 = process.env.UNI_PLATFORM === "h5";
const isApp = process.env.UNI_PLATFORM === "app";
const WeappTailwindcssDisabled = isH5 || isApp;

const resolve = (p) => {
    return path.resolve(__dirname, p);
};

export default defineConfig({
    plugins: [
        uni(),
        uvwt({
            rem2rpx: true,
            disabled: WeappTailwindcssDisabled,
            // 由于 hbuilderx 会改变 process.cwd 所以这里必须传入当前目录的绝对路径
            tailwindcssBasedir: __dirname,
            cssEntries: [
                resolve('./style.css'),
            ]
        })
    ],
    css: {
        postcss: {
            plugins: [
                tailwindcss({
                    base: resolve('./'),
                    optimize: true
                }),
                autoprefixer({}),
            ]
        }
    },
    // 路径别名
    resolve: {
        alias: {
            '@': path.resolve(__dirname, 'src')
        }
    },
    // 开发服务器(H5 有效)
    server: {
        port: 3000,
        host: '0.0.0.0',
    }
});
  1. 项目根目录添加tailwind.config.js文件
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./index.html", "./pages/**/*.vue", './App.vue'],
  // 2. 核心:禁用全局样式重置(uni-app 自带重置)
  corePlugins: {
    preflight: false
  },
  theme: {
    extend: {},
  },
  plugins: [],
}
  1. 项目根目录添加style.css文件
@import "tailwindcss";

tailwindcss快查工具

梦叶的Tailwind - Tailwind CSS 速查表

支持tailwindcss 3.x和tailwindcss 4.x查询,尤其适合初学者。

【uniApp开发】微信小程序 web-view 内嵌 H5 跳转支付踩坑实录

2026年4月30日 17:53

微信小程序 web-view 内嵌 H5 跳转支付踩坑实录:从 postMessage 失效到 navigateTo 没反应的源码级排查

在 uni-app 微信小程序中嵌入 H5 页面,并实现 H5 向小程序跳转支付页,看似一个简单的需求,实际踩了三个大坑。本文记录从 postMessage 不实时、到 navigateTo 没反应、最终翻到 uni.webview.js 压缩源码找到根因的完整过程。


一、业务背景与需求

我们的项目是 uni-app + Vue3 编译的微信小程序,首页用 <web-view> 全屏嵌入了一个 React 开发的 H5 页面。核心需求有两个:

  1. H5 → 小程序通信:H5 页面点击按钮,能向小程序发送消息
  2. H5 → 小程序跳转支付:H5 页面点击支付,能跳转到小程序原生支付页,调起微信支付

技术栈:

  • 小程序端:uni-app(Vue3)
  • H5 端:React + Vite
  • 通信桥接:uni.webview.js + 微信 JS-SDK

为什么选择 uni.webview.js

DCloud 提供 uni.webview.js 的核心目的是跨平台一致性——同一套 H5 代码,既能被 uni-app 编译的 App 加载,也能被 微信小程序、支付宝小程序、百度小程序 等加载,统一通过 window.uni.navigateTo / postMessage / getEnv 等 API 与宿主交互。

我们最初在 App 端 测试时完全正常,切换到 微信小程序 后才出现问题。这说明问题不是出在"要不要用 uni.webview.js",而是出在该 SDK 对微信小程序环境的适配存在漏洞


二、踩坑一:postMessage 在微信小程序中不实时

现象

H5 中按照官方文档调用 uni.postMessage

window.uni.postMessage({
  data: { type: 'test', msg: 'Hello from H5!' }
});

小程序端 <web-view @message="handleMessage"> 监听,但点击后没有任何反应

原因

这是微信小程序的原生限制,不是 uni-app 的 bug。官方文档明确说明:

wx.miniProgram.postMessage 向小程序发送的消息,不会实时触发,而是被微信暂存起来,只在特定时机批量发送:

  • 用户点击右上角转发分享
  • 用户返回上一页(navigateBack
  • 页面被销毁/重载
  • 下拉刷新

也就是说,你点了按钮,消息已经发出去了,但小程序端收不到,除非你手动返回或分享。

结论

postMessage 只能用于非实时的、伴随页面生命周期的通信场景(比如返回时顺带传数据)。对于"点击后立即通信"的需求,这条路走不通。


三、踩坑二:navigateTo 调用成功但"没下文"

现象

既然 postMessage 不实时,我们改用 navigateTo 直接跳转:

window.uni.navigateTo({
  url: '/pages/pay/pay?orderId=xxx&amount=1'
});

H5 端调用后打印日志显示成功,页面顶部环境检测显示 env = wx-miniprogramwindow.uni 也存在。

小程序端没有任何反应,既没有打开支付页,也没有报错。

初步排查

  1. ✅ 确认 pages.json 已注册 /pages/pay/pay
  2. ✅ 确认路径以 / 开头,不带 .html
  3. ✅ 确认 window.uni.navigateTo 存在
  4. ❌ 模拟器和真机都没反应

翻源码找根因

这里要先解释一个背景:uni.webview.js 的设计初衷是跨平台适配,不是单独为微信小程序"包的饺子"。

DCloud 的思路很清晰:

  • App 端:H5 在 plus.webview 中运行,通过 plus.webview.postMessageToUniNView 通信
  • 微信小程序端:H5 在 <web-view> 中运行,通过 wx.miniProgram.navigateTo / postMessage 通信
  • 支付宝/百度/字节端:各自使用对应的小程序桥接 API

H5 开发者只需要调用统一的 window.uni.navigateTouni.webview.js 内部会自动检测平台并映射到对应的原生 API。这套机制在 App 端和支付宝小程序端 运行良好,但在 微信小程序端 存在一个隐蔽的漏洞。

我怀疑是 uni.webview.js 内部实现有问题,于是直接翻它的压缩源码(uni.webview.1.5.6.js),发现了关键逻辑:

// uni.webview.js 的平台检测数组
var y = [
  // 百度小程序
  function(e){ if(v) return window.swan.webView },
  // 字节小程序
  function(e){ if(p) return window.tt.miniProgram },
  // 微信小程序(关键!)
  function(e){ ... return window.wx.miniProgram },
  // ... 其他平台
];

微信小程序的检测条件是:

window.wx && window.wx.miniProgram
  && /micromessenger/i.test(navigator.userAgent)
  && /miniProgram/i.test(navigator.userAgent)

只有当这四个条件同时满足时,uni.webview.js 才会把 API 映射到 wx.miniProgram.navigateTo

但我们 H5 的 index.html 只引入了 uni.webview.js,没有引入微信官方的 JS-SDK:

<!-- 错误 ❌:缺少微信 JS-SDK -->
<script src="/uni.webview.1.5.6.js"></script>

没有引入 https://res.wx.qq.com/open/js/jweixin-1.6.0.jswindow.wx 根本不存在,平台检测失败!

注意:这不是说 uni.webview.js"设计错了",而是它的**平台检测前提假设**在小程序 web-view 中不成立——它假设 window.wx.miniProgram已经存在,但实际上微信小程序不会自动向 H5 注入wx` 对象,必须手动引入微信 JS-SDK。

fallback 的致命陷阱

检测失败后,uni.webview.js 会 fallback 到一个默认实现 d

var d = {
  navigateTo: function(e) {
    r("navigateTo", { url: encodeURI(e.url) });
  },
  // ...
};

r 函数的内部实现是:

var r = function(e, n) {
  // 检测 uni-app x / uni-app / 5+ App ...
  if (a()) { ... }
  else if (o()) { ... }
  else {
    // 没有 window.plus,走 window.parent.postMessage
    if (!window.plus) {
      return window.parent.postMessage(
        { type: "WEB_INVOKE_APPSERVICE", data: i, pageId: "" }, "*"
      );
    }
    // ...
  }
};

在微信小程序 web-view 中:

  • 没有 window.__uniapp_x_
  • 没有 window.__dcloud_weex_
  • 没有 window.plus(这是 App 端的)

所以它走了 window.parent.postMessage。但微信小程序的 <web-view>沙箱隔离的 iframewindow.parent.postMessage 根本不会到达小程序逻辑层,也不会触发 <web-view>@message 事件。

这就是"调用成功但没下文"的根本原因。


四、解决方案:三级降级跳转策略

4.1 H5 端:引入微信 JS-SDK + uni.webview.js

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>H5 项目</title>
</head>
<body>
  <div id="root"></div>

  <!-- 关键:必须先引入微信 JS-SDK,否则 uni.webview.js 无法识别微信小程序环境 -->
  <script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
  <script src="/uni.webview.1.5.6.js"></script>
  <script type="module" src="/src/main.tsx"></script>
</body>
</html>

⚠️ 顺序很重要:微信 JS-SDK 必须在 uni.webview.js 之前加载,因为后者的平台检测依赖 window.wx 的存在。

为什么保留 uni.webview.js 而不是直接全部用 wx.miniProgram

因为 H5 页面需要同时支持 App 端微信小程序端。在 App 端,wx 对象不存在,只有 window.uni 可用;在微信小程序端,两者都存在。保留 uni.webview.js 作为跨平台抽象层,同时引入微信 JS-SDK 填补小程序端的适配漏洞,是最兼顾一致性的方案。

4.2 H5 支付跳转工具:三级降级

实现一个健壮的跳转函数,优先用原生 API,逐级降级:

// src/utils/uniPay.ts

export interface PayParams {
  orderId: string;
  amount: number; // 单位:分
  description?: string;
  attach?: string;
}

/**
 * 跳转到微信小程序支付页(三级降级)
 */
export function navigateToWxPay(params: PayParams): {
  success: boolean;
  method?: string;
  reason?: string;
  debug: object;
} {
  // 构建小程序支付页 URL
  const query = new URLSearchParams({
    orderId: params.orderId,
    amount: String(params.amount),
    ...(params.description && { description: params.description }),
    ...(params.attach && { attach: params.attach }),
  });
  const url = `/pages/pay/pay?${query.toString()}`;

  // ========== 方案 1: 微信小程序原生 API(最可靠)==========
  if (window.wx?.miniProgram?.navigateTo) {
    window.wx.miniProgram.navigateTo({ url });
    return { success: true, method: 'wx.miniProgram.navigateTo', debug: getDebugInfo() };
  }

  // ========== 方案 2: uni.webview.js 封装 ==========
  if (window.uni?.navigateTo) {
    window.uni.navigateTo({ url });
    return { success: true, method: 'window.uni.navigateTo', debug: getDebugInfo() };
  }

  // ========== 方案 3: 备用 - postMessage + navigateBack ==========
  if (window.uni?.postMessage && window.uni?.navigateBack) {
    window.uni.postMessage({
      data: { type: 'navigate_to_pay', url, payload: params }
    });
    setTimeout(() => window.uni?.navigateBack?.(), 100);
    return { success: true, method: 'postMessage+navigateBack', debug: getDebugInfo() };
  }

  return { success: false, reason: '无可用跳转方式', debug: getDebugInfo() };
}

4.3 uni-app 端:web-view + 支付页

login.vue(承载 web-view):

<template>
  <view class="container">
    <web-view
      src="https://your-h5-domain.com/"
      @message="handleMessage"
      @error="handleError"
    />
  </view>
</template>

<script setup>
const handleMessage = (event) => {
  const data = event.detail.data;
  const lastMsg = Array.isArray(data) ? data[data.length - 1] : data;

  // 处理备用方案的 postMessage 指令
  if (lastMsg?.type === 'navigate_to_pay' && lastMsg?.url) {
    uni.navigateTo({ url: lastMsg.url });
    return;
  }

  uni.showModal({
    title: '收到 H5 消息',
    content: JSON.stringify(data, null, 2),
    showCancel: false,
  });
};

const handleError = (event) => {
  console.error('web-view 加载失败:', event);
};
</script>

pay.vue(小程序支付页):

<template>
  <view class="pay-page">
    <view class="pay-card">
      <text class="pay-title">确认支付</text>
      <text class="pay-amount">¥{{ (amount / 100).toFixed(2) }}</text>
      <text class="pay-desc">{{ description }}</text>
      <button @click="handlePay">立即支付</button>
    </view>
  </view>
</template>

<script setup>
import { onLoad } from '@dcloudio/uni-app';
import { ref } from 'vue';

const orderId = ref('');
const amount = ref(0);
const description = ref('');

onLoad((options) => {
  orderId.value = options.orderId || '';
  amount.value = parseInt(options.amount) || 0;
  description.value = decodeURIComponent(options.description || '');
});

const handlePay = () => {
  // 1. 调用后端获取微信支付参数
  uni.request({
    url: 'https://your-api.com/wxpay/create',
    method: 'POST',
    data: { orderId: orderId.value, amount: amount.value },
    success: (res) => {
      const { prepayId, nonceStr, timeStamp, signType, paySign } = res.data;

      // 2. 调起微信支付(微信小程序格式,非 App 的 orderInfo)
      uni.requestPayment({
        provider: 'wxpay',
        timeStamp: String(timeStamp),
        nonceStr: nonceStr,
        package: `prepay_id=${prepayId}`,
        signType: signType || 'RSA',
        paySign: paySign,
        success: () => {
          uni.showToast({ title: '支付成功', icon: 'success' });
          setTimeout(() => uni.navigateBack(), 1500);
        },
        fail: (err) => {
          uni.showToast({ title: '支付取消或失败', icon: 'none' });
        },
      });
    },
  });
};
</script>

pages.json 注册:

{
  "pages": [
    {
      "path": "pages/login/login",
      "style": { "navigationStyle": "custom" }
    },
    {
      "path": "pages/pay/pay",
      "style": { "navigationBarTitleText": "支付" }
    }
  ]
}

五、核心踩坑点总结

坑点 现象 根因 解决方案
postMessage 不实时 H5 发了消息,小程序收不到 微信小程序原生限制,只在返回/分享/销毁时批量触发 navigateTo 跳转传参替代
navigateTo 没反应 H5 调用成功,小程序没跳转 uni.webview.js 没检测到 window.wx,fallback 到不工作的 window.parent.postMessage H5 引入 jweixin-1.6.0.js,确保 window.wx.miniProgram 存在
uni.requestPayment 参数错误 支付调用报错 微信小程序端不用 orderInfo,直接用顶层字段 timeStampnonceStrpackagesignTypepaySign 放顶层
pages.json 条件编译逗号 编译失败 #endif 前后缺少逗号 确保 }{ 之间有逗号分隔

六、完整通信链路

┌─────────────────────────────────────────────────────────┐
│  微信小程序端 (uni-app)                                   │
│  ┌─────────────┐      ┌─────────────┐      ┌──────────┐ │
│  │ login.vue   │ ──►  │  pay.vue    │ ──►  │  微信支付 │ │
│  │ (web-view)  │      │ (支付页面)   │      │          │ │
│  └──────┬──────┘      └─────────────┘      └──────────┘ │
│         ▲                                               │
└─────────┼───────────────────────────────────────────────┘
          │ navigateTo / postMessage
┌─────────┼───────────────────────────────────────────────┐
│  H5 端  │                                               │
│  ┌──────┴──────┐                                        │
│  │ React 页面   │                                        │
│  │ · 检测环境   │                                        │
│  │ · 跳转支付   │                                        │
│  └─────────────┘                                        │
│                                                         │
│  依赖: jweixin-1.6.0.js + uni.webview.1.5.6.js          │
└─────────────────────────────────────────────────────────┘

七、验证效果

  1. H5 页面顶部环境信息正确显示:
    运行环境: wx-miniprogram | wx=true wxMP=true uni=true
    
  2. 点击"Test 小程序支付跳转",toast 显示:
    ✅ 已触发跳转 (wx.miniProgram.navigateTo)
    
  3. 小程序端正常打开 /pages/pay/pay,显示订单金额和支付按钮
  4. 点击支付,调起微信支付,成功/失败后自动返回 web-view

八、结语

这次踩坑最大的收获是:不要只看官方文档的"用法",关键时刻要敢翻源码。

uni.webview.js 的设计目标是跨平台一致性,它让同一套 H5 代码能在 App、微信小程序、支付宝小程序等多个平台运行。但在微信小程序 web-view 中,它的平台检测逻辑依赖 window.wx.miniProgram,而 window.wx 不会凭空出现——这不是设计缺陷,而是文档没有明确说明的隐性依赖

核心结论

  1. uni.webview.js 不是"为了这顿醋包的饺子",它的跨平台抽象价值在 App 端是真实存在的
  2. 微信小程序端需要额外引入微信 JS-SDK,补齐 window.wx 这个前提条件
  3. 最佳实践是两者共存uni.webview.js 提供跨平台一致性,jweixin-1.6.0.js 补齐微信小程序的桥接能力

如果你的 H5 页面在微信小程序 web-view 中也遇到了"调用成功但没反应"的问题,不妨先检查一下控制台里 window.wx 是否为 undefined


示例代码以及参考资料见

CSS transform scale:图片放大效果背后的原理

作者 陆枫Larry
2026年4月30日 17:45

在开发过程中,我们经常遇到这样的需求:UI 设计稿上的某张图片,视觉上比容器"溢出"一点,看起来有一种放大、充盈的感觉。

直觉反应可能是直接改 widthheight,但更优雅的方式是用 transform: scale()

这篇文章从最基础的原理出发,把这个知识点讲透。

1、transform 是什么

transform 是 CSS 的一个属性,专门负责对元素做视觉上的变换

它能做的事情包括:

函数 效果
scale() 缩放
translate() 平移
rotate() 旋转
skew() 倾斜

这些变换有一个共同的核心特点:

只改变视觉呈现,不影响文档流。

这一点非常关键,后面会专门解释。

2、scale() 是怎么工作的

scale(1.4) 的意思是:把元素放大到原来的 1.4 倍

transform: scale(1.4);   /* 等比放大 1.4 倍 */
transform: scale(0.8);   /* 等比缩小到 0.8 倍 */
transform: scale(1.4, 1);  /* 只横向放大,纵向不变 */

数字规律很简单:

  • 1 = 原始大小
  • 大于 1 = 放大
  • 小于 1 = 缩小

3、transform-origin:缩放的基准点

这是很多人忽略的关键属性。

scale() 在放大或缩小时,以某一个点为中心向四周扩展。这个"中心点"就是 transform-origin 控制的。

transform-origin: center center;  /* 默认值,以元素中心为基准 */
transform-origin: top left;       /* 以左上角为基准 */
transform-origin: 0% 100%;        /* 以左下角为基准 */
transform-origin: 50px 50px;      /* 以距左上角 50px 处为基准 */

用一个图来理解:

以 center center 为基准放大:

放大前:          放大后:
┌────────┐      ┌──────────────┐
│        │  →   │              │
│   ·    │      │      ·       │
│        │      │              │
└────────┘      └──────────────┘
  四个方向均匀扩展

以 top left 为基准放大:

放大前:          放大后:
┌────────┐      ┌──────────────────┐
│        │  →   │                  │
│        │      │                  │
└────────┘      │                  │
                └──────────────────┘
  只向右和向下扩展,左上角固定不动

在车图场景中,transform-origin: center center 让图片以自身中心向四周均匀放大,视觉上图片"鼓"出来,营造充盈感。

4、为什么不直接改 width?

这是理解 transform 的核心问题。

直接对比两种方式:

方案一:改 width

.car-picture {
  width: 140%;  /* 原来 100%,现在放大 1.4 倍 */
}

方案二:用 scale

.car-picture {
  width: 100%;
  transform: scale(1.4);
}

看起来效果差不多,但本质完全不同:

改 width transform: scale
文档流 会影响,可能撑开父容器 不影响,周围元素感知不到变化
触发重排 是(layout) (只触发 composite)
性能 较差 更好,GPU 加速
动画流畅度 一般 流畅(60fps 友好)

transform 的变换发生在绘制阶段之后,浏览器把元素当作一个整体位图来处理,不重新计算布局。所以它既不会影响周围元素,性能也更好。

5、回到实际代码

.phone-picture {
  position: absolute;
  width: 100%;
  top: 32%;
  z-index: 0;
  transform: scale(1.4);
  transform-origin: center center;
}

逐行解读:

  • width: 100%:图片宽度撑满父容器,这是布局上的实际宽度
  • transform: scale(1.4):在视觉上再放大 1.4 倍,不影响布局
  • transform-origin: center center:以图片中心为基准放大,四周均匀溢出

效果就是:图片在布局上占据正常位置,但视觉上"膨胀"出来,超出自身原始边界,形成 UI 想要的充盈放大感。

由于父容器设置了 overflow: hidden,溢出的部分被裁掉,最终呈现出一张"刚好铺满、略有放大"的图片。

6、配合 overflow: hidden 的经典组合

这是实际开发中最常见的用法:

/* 父容器:裁切溢出内容 */
.phone-picture-wrap {
  overflow: hidden;
}

/* 子图片:视觉上放大,溢出部分被父容器裁掉 */
.phone-picture {
  width: 100%;
  transform: scale(1.4);
  transform-origin: center center;
}

scale 负责放大,overflow: hidden 负责裁切,两者配合产生"图片铺满容器且略有放大"的视觉效果,比直接改尺寸更干净,也更好维护。

7、一句话总结

transform: scale() 改变的是视觉大小,不改变布局占位。配合 transform-origin 控制缩放基准点,配合 overflow: hidden 裁切溢出内容,是实现图片放大视觉效果的标准做法。

[Vue]可重置的响应式状态reactive

作者 焰火1999
2026年4月30日 17:37

本文介绍了一个Vue框架下的可重置的响应式状态创建函数,用于创建出可重置的reactive。

源码

useResettableState.ts

import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';

/**
 * 创建一个可重置的响应式状态
 * @param initialStateFactory 返回初始状态的函数
 * @returns 包含响应式 state 和 reset 方法的对象
 */
export function useResettableState<T extends Record<string, any>>(initialStateFactory: () => T) {
  // 获取初始状态并深度克隆(用于后续重置)
  const initialState = cloneDeep(initialStateFactory());

  // 创建响应式状态(深度克隆避免引用共享)
  const state = reactive(cloneDeep(initialState)) as T;

  /**
   * 重置状态为初始值
   */
  const reset = (): void => {
    const freshState = cloneDeep(initialState);
    // 清除当前所有属性(处理动态增删字段的场景)
    Object.keys(state).forEach((key) => {
      delete (state as Record<string, any>)[key];
    });
    // 恢复初始结构
    Object.keys(freshState).forEach((key) => {
      (state as Record<string, any>)[key] = freshState[key];
    });
  };

  return {
    state,
    reset,
  };
}

使用示例

import { useResettableState } from '@/tools/composables/useResettableState';

// 一个表单对象
const { state: stateForm, reset: resetStateForm } = useResettableState(() => ({
  name: '',
  type: 'user' as 'user' | 'system'
  isEnabled: true, 
  file: undefined as File | undefined, 
}));

function submitForm() {
  // 模拟提交
  ...
  
  // 提交成功后重置表单
  resetStateForm();
}

前端项目国际化解决方案

作者 _柴富自由
2026年4月30日 16:33

前端项目国际化解决方案

面向多语言文案管理的全栈示例:Vue 3 管理端 + Koa + MySQL 服务端,支持项目维度管理、词条增删改查、Excel 导入导出、翻译对比与简单数据看板;前端通过 vue-i18n 消费语言包,并可用脚本从服务端拉取 JSON 生成本地语言文件。

企业微信截图_cda91cd3-01b1-4298-962a-8066dd16e8b0.png

仓库结构

目录 说明
i18n-server/ Node.js(Koa)API、Sequelize/MySQL、静态资源与上传目录
i18n-app/ Vue 3 + Vite + Arco Design 管理界面与 vue-i18n

两个子目录各自为独立 npm 包,可分别安装依赖与启动;本地联调时需先起后端,再配前端代理

环境要求

  • Node.js:后端要求 ≥ 18(见 i18n-server/package.json);前端建议 Node 18+。
  • MySQL:与 i18n-server/dabaseConfig.js 中配置一致(默认库名 i18n_database)。
  • 包管理:下文以 npm 为例,亦可使用 yarn / pnpm

1. 数据库初始化

  1. 在 MySQL 中创建数据库,例如:

    CREATE DATABASE i18n_database DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
    
  2. 按顺序导入 i18n-server/sql/ 下的建表脚本(或合并执行),至少包含:

    • i18n_project.sql
    • translate_dict.sql
    • i18n_upload_history.sql
  3. 修改 i18n-server/dabaseConfig.js 中的 库名、用户名、密码、主机、端口,使之与你的 MySQL 一致。开源前请勿提交真实生产库密码,建议改为环境变量或本地未跟踪的配置文件。

2. 启动后端(i18n-server)

cd i18n-server
npm install
npm run serve

默认监听 http://127.0.0.1:3000(可通过环境变量 PORT 覆盖)。

其他脚本:

  • npm run dev:nodemon 热重载开发。
  • npm run prd:使用 pm2 启动(需全局安装 pm2)。

主要 API 前缀为 /i18n/app/(见 i18n-server/routes/index.js),例如项目管理、词条 CRUD、导入导出、翻译文件获取等。

3. 启动前端(i18n-app)

cd i18n-app
npm install
npm run serve

默认开发服务器为 http://localhost:8081(以 package.jsonvite --port 8081 为准)。

与后端联调

i18n-app/vite.config.js 中将请求代理到后端,请把 TARGET_URL 改成你本机或内网的后端地址,例如:

const TARGET_URL = 'http://127.0.0.1:3000/'

前端 Axios 的 baseURL 为空,接口路径以 /i18n/app/ 开头,由 Vite 代理转发到上述 TARGET_URL

4. 从服务端拉取语言包(可选)

管理端之外,若要在业务前端工程里生成静态 JSON 语言包,可使用:

cd i18n-app
npm run getLanguage

脚本读取 src/locales/languageConfig.js,向服务端请求 getTranslateFiles 等配置。使用前请修改其中的 ipportprojectCodeorigindatabase / file)等,与当前环境一致。

另有 npm run getNoTranslate 用于辅助扫描未翻译词条(见 src/locales/getNoTranslateScript.js)。

5. 构建与预览(前端)

cd i18n-app
npm run build
npm run preview

6. 代码风格

两个子项目均提供 Prettier:

npm run format        # 写入格式化
npm run format:check  # 仅检查

7. 仓库地址

i18n-app: gitee.com/icdong/i18n…

i18n-server: gitee.com/icdong/i18n…

许可证

请自行在仓库中添加许可证文件;在未声明前,本项目代码默认仍归原作者所有。

【5】微前端知识点总结

作者 有马贵将
2026年4月30日 16:31

一、什么是微前端

微前端借鉴后端微服务理念,将前端单体应用拆分为多个可独立开发、独立部署、独立运行的子应用,再通过主应用统一整合展示。

核心特征:

  • 技术栈无关,子应用可自由选择框架(React、Vue、Angular 等)
  • 独立开发,各团队在独立仓库互不干扰
  • 独立部署,每个子应用单独构建发布
  • 运行时集成,子应用动态加载而非构建时打包在一起
  • 样式与 JS 隔离,子应用间互不污染

解决的核心问题:

  • 巨石应用难以维护:代码量膨胀,构建缓慢,模块耦合严重
  • 多团队协作冲突:共同维护一个仓库,代码冲突频繁,发布相互阻塞
  • 技术栈升级困难:整个应用绑定同一技术栈,无法局部渐进升级
  • 部署耦合:任何小改动都需重新构建部署整个应用,风险高
  • 历史系统整合:新旧系统无法共存,完全重写成本极高

二、技术实现方案

iframe

最简单的隔离方案,通过 <iframe> 标签嵌入子应用。浏览器天然提供 JS 和 CSS 的完全隔离,但 UI 体验差(弹窗、滚动、路由同步问题),通信只能依赖 postMessage,且每次加载都是全新页面,性能较差。

<!-- 主应用嵌入子应用 -->
<iframe src="https://sub-app.example.com" style="width:100%;height:100%;border:none;"></iframe>

<script>
  // 主应用向子应用发送消息
  document.querySelector('iframe').contentWindow.postMessage({ type: 'TOKEN', token: 'xxx' }, '*');

  // 子应用接收主应用消息
  window.addEventListener('message', (e) => {
    if (e.data.type === 'TOKEN') console.log(e.data.token);
  });
</script>

Web Components

利用浏览器原生 Custom Elements 将子应用封装为自定义组件,Shadow DOM 提供天然样式隔离。属于浏览器标准能力,无需额外框架,但生态尚不成熟,与主流框架集成有一定成本,IE 兼容性差。

class SubApp extends HTMLElement {
  connectedCallback() {
    // Shadow DOM 内部样式与外部完全隔离
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>.title { color: red; }</style>
      <div class="title">子应用内容</div>
    `;
  }
  disconnectedCallback() {
    // 组件卸载时清理资源
  }
}

customElements.define('sub-app', SubApp);
<!-- 主应用中像使用普通标签一样嵌入 -->
<sub-app></sub-app>

NPM 包

将子应用打包为 NPM 包由主应用引入,属于构建时集成。优点是简单直接、TypeScript 支持好,缺点是无法独立部署,版本升级需要主应用重新发布,不能做到真正的技术栈无关。

# 子应用发布为 npm 包
npm publish @company/sub-app

# 主应用安装依赖
npm install @company/sub-app
// 主应用直接 import,构建时打包在一起
import SubAppPage from '@company/sub-app';

// 子应用升级后,主应用必须更新依赖版本并重新构建部署

模块联邦(Module Federation)

Webpack 5 内置特性,支持在运行时跨应用共享模块。可以按模块粒度共享,避免重复加载公共依赖。强依赖 Webpack 5,沙箱隔离能力较弱,应用级别的路由管理需要自行实现。

// 子应用 webpack.config.js —— 暴露模块
new ModuleFederationPlugin({
  name: 'subApp',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/components/Button',
    './utils': './src/utils',
  },
  shared: ['vue'], // 声明共享依赖,避免重复加载
});

// 主应用 webpack.config.js —— 消费模块
new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    subApp: 'subApp@http://localhost:3001/remoteEntry.js',
  },
});
// 主应用中运行时动态加载子应用暴露的模块
const Button = React.lazy(() => import('subApp/Button'));

JS 沙箱

通过代理或快照机制隔离子应用对全局 window 的读写,防止全局变量污染。是当前微前端框架实现 JS 隔离的核心技术手段,具体分为:

  • 快照沙箱:激活时对 window 拍快照,卸载时 diff 还原。简单但性能差,不支持多实例并行。
  • 代理沙箱(Proxy):基于 ES6 Proxy 拦截 window 操作,每个子应用维护独立的虚拟 window,读写互不干扰,支持多实例并行,是现代浏览器下的推荐方式。
// 快照沙箱:激活时保存快照,卸载时还原
class SnapshotSandbox {
  activate() {
    this.snapshot = Object.assign({}, window); // 保存当前 window 快照
  }
  deactivate() {
    for (const key in window) {
      if (window[key] !== this.snapshot[key]) {
        window[key] = this.snapshot[key]; // 还原所有变更
      }
    }
  }
}

// 代理沙箱:每个子应用独立的虚拟 window,互不污染
class ProxySandbox {
  constructor() {
    const fakeWindow = Object.create(null);
    this.proxy = new Proxy(fakeWindow, {
      set(target, prop, value) {
        target[prop] = value;          // 只写虚拟 window,真实 window 不变
        return true;
      },
      get(target, prop) {
        return prop in target ? target[prop] : window[prop]; // 取不到再读真实 window
      },
    });
  }
}

// 多子应用并行效果
// 子应用A: proxy.foo = 'A'  →  fakeWindowA.foo = 'A'
// 子应用B: proxy.foo = 'B'  →  fakeWindowB.foo = 'B'
// 真实 window.foo           →  undefined(始终干净)

2.6 CSS 隔离

防止子应用样式污染主应用或其他子应用,主要有三种方案:

  • 动态样式表:子应用激活时注入样式,卸载时移除。实现简单,但多应用并行展示时会冲突。
  • Shadow DOM:将子应用挂载在 Shadow DOM 内,浏览器原生保证内外样式完全隔离。隔离彻底,但挂载到 document.body 的弹窗、Tooltip 等组件样式会丢失,与主流 UI 库兼容性差。
  • Scoped CSS:运行时为子应用每条 CSS 规则动态添加属性选择器前缀(如 div[data-qiankun="app-name"]),实现作用域限定。兼容性好,但动态计算有一定性能开销,无法阻止主应用样式影响子应用。
// 动态样式表:挂载/卸载时插入和移除 <style> 标签
function mountStyles(cssTexts) {
  return cssTexts.map(css => {
    const el = document.createElement('style');
    el.textContent = css;
    document.head.appendChild(el);
    return el;
  });
}
function unmountStyles(styleEls) {
  styleEls.forEach(el => el.remove());
}

// Shadow DOM:子应用 DOM 和样式在 shadow-root 内完全隔离
const shadow = container.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>${subAppStyles}</style>${subAppHTML}`;

// Scoped CSS:运行时动态为每条规则加属性前缀
function scopeCSS(cssText, appName) {
  // .btn { color: red }  →  div[data-qiankun="app"] .btn { color: red }
  return cssText.replace(/(^|\})\s*([^{]+)\{/g, (_, prefix, selector) => {
    const scoped = selector.split(',')
      .map(s => `div[data-qiankun="${appName}"] ${s.trim()}`)
      .join(', ');
    return `${prefix} ${scoped} {`;
  });
}

三、微前端产品方案

qiankun

蚂蚁金服出品,基于 single-spa 封装,是目前社区最成熟的微前端框架。

核心技术:

  • HTML Entry:以子应用 index.html 为入口,由 import-html-entry 解析并加载资源,子应用无需改造构建配置
  • JS 隔离:现代浏览器默认使用 ProxySandbox(多实例代理沙箱),每个子应用拥有独立虚拟 window;不支持 Proxy 时降级为 SnapshotSandbox
  • CSS 隔离:支持 Shadow DOM(strictStyleIsolation)和 Scoped CSS(experimentalStyleIsolation)两种模式,默认使用动态样式表
  • 应用通信:props 单向传递 + initGlobalState 全局状态管理
  • 预加载:支持在空闲时预加载子应用资源,加快激活速度

适合场景: 追求稳定大生态、团队规模较大的企业级中后台系统。


无界(Wujie)

腾讯出品,将 iframe 和 Web Components 结合的创新方案。

核心技术:

  • JS 隔离:复用 iframe 的 JS 运行环境作为天然沙箱,彻底隔离,无需手动实现代理沙箱
  • CSS 隔离:子应用 DOM 渲染在 Web Components 的 Shadow DOM 内,样式天然隔离
  • 应用保活:子应用切换时不销毁实例,保留 DOM 和状态,再次进入时秒开
  • 预加载:支持子应用预加载和后台静默运行

适合场景: 对隔离性要求极高、需要应用保活(切换不重载)的场景。


MicroApp(京东)

京东出品,基于 Web Components 封装,接入方式最简单。

核心技术:

  • JS 隔离:自研 Proxy 沙箱,与 qiankun 类似但实现更轻量
  • CSS 隔离:自动为子应用样式添加 CSS 作用域前缀
  • 接入方式:以自定义 HTML 标签 <micro-app> 的形式嵌入,对主应用几乎零侵入
  • Vite 支持:原生支持 Vite 构建的子应用,qiankun 对 Vite 支持较弱

适合场景: 希望以最低成本快速接入微前端、或子应用使用 Vite 构建的项目。


Module Federation(Webpack 5)

Webpack 内置能力,严格来说是模块共享方案而非完整微前端框架。

核心技术:

  • 运行时模块共享:应用间可以在运行时互相暴露和消费模块,避免公共依赖重复打包
  • 去中心化:每个应用既可以是 Host(消费方)也可以是 Remote(提供方),无需统一主应用
  • 无沙箱:没有内置 JS 和 CSS 隔离机制,需要开发者自行约束

适合场景: 深度绑定 Webpack 5、更关注模块级别共享而非应用级别隔离的场景。


产品方案对比

方案 JS 隔离 CSS 隔离 接入成本 Vite 支持 社区生态
qiankun Proxy 沙箱 Shadow DOM / Scoped 较差 ⭐⭐⭐⭐⭐
无界 iframe 天然隔离 Shadow DOM ⭐⭐⭐
MicroApp Proxy 沙箱 Scoped CSS 极低 ⭐⭐⭐
Module Federation ❌ 无 ❌ 无 ⭐⭐⭐

四、qiankun 核心技术详解

JS 沙箱

SnapshotSandbox(快照沙箱)

激活时遍历 window 保存快照,卸载时对比快照还原所有变更。实现简单,但每次激活/卸载都需遍历整个 window,性能较差,且同一时刻只能运行一个子应用。

class SnapshotSandbox {
  activate() {
    this.windowSnapshot = {};
    for (const key in window) {
      this.windowSnapshot[key] = window[key];
    }
    // 恢复上次该沙箱运行时的修改
    Object.keys(this.modifyPropsMap).forEach(key => {
      window[key] = this.modifyPropsMap[key];
    });
  }

  deactivate() {
    this.modifyPropsMap = {};
    for (const key in window) {
      if (window[key] !== this.windowSnapshot[key]) {
        this.modifyPropsMap[key] = window[key]; // 记录变更
        window[key] = this.windowSnapshot[key]; // 还原
      }
    }
  }
}

ProxySandbox(多实例代理沙箱)

每个子应用拥有独立的 fakeWindow,所有对 window 的读写都发生在各自的 fakeWindow 上,真实 window 始终保持干净,天然支持多实例并行。

class ProxySandbox {
  constructor() {
    const fakeWindow = Object.create(null);

    this.proxy = new Proxy(fakeWindow, {
      set(target, prop, value) {
        target[prop] = value; // 只写入虚拟 window
        return true;
      },
      get(target, prop) {
        // 优先读虚拟 window,取不到再读真实 window
        return prop in target ? target[prop] : window[prop];
      },
      has(target, prop) {
        return prop in target || prop in window;
      }
    });
  }
}

多实例并行效果:

子应用A: window.foo = 'A'  →  fakeWindowA.foo = 'A'
子应用B: window.foo = 'B'  →  fakeWindowB.foo = 'B'
真实 window.foo            →  undefined(完全干净)

沙箱盲区: JS 沙箱只拦截 window 属性读写,对 document.body.appendChild、setTimeout、addEventListener 等操作无能为力,子应用卸载时需在 unmount 钩子中手动清理,否则会内存泄漏。


CSS 隔离

Shadow DOM(strictStyleIsolation)

start({ sandbox: { strictStyleIsolation: true } });

子应用的 DOM 被挂载在 Shadow Root 内,浏览器原生保证边界内外样式互不穿透。隔离最彻底,但挂载到 document.body 的弹窗、下拉菜单等组件会逃出 Shadow DOM 边界导致样式丢失,需要手动将这类组件的挂载节点指定到子应用容器内。

主应用 DOM
└── #micro-container
    └── shadow-root        ← 样式边界
        ├── <style>子应用样式</style>
        └── <div id="app">子应用内容</div>

Scoped CSS(experimentalStyleIsolation)

start({ sandbox: { experimentalStyleIsolation: true } });

qiankun 拦截子应用的样式注入,在运行时为每条 CSS 规则动态添加属性选择器前缀,将样式的作用域限定在子应用容器内:

/* 原始 */
.btn { color: red; }

/* 处理后 */
div[data-qiankun="vue-app"] .btn { color: red; }

兼容性好,弹窗问题少,是日常更推荐的方案。但动态计算有性能开销,且无法阻止主应用样式向下影响子应用。


CSS 隔离方案对比:

方案 隔离方向 弹窗兼容 推荐场景
动态样式表(默认) 子应用间不同时存在 基础场景
Shadow DOM 双向完全隔离 ❌ 需额外处理 隔离要求极高
Scoped CSS 子应用不影响外部 日常推荐

4.3 应用间通信

子应用间的通信分为三种场景:主应用向子应用传递数据、子应用向主应用反馈、子应用之间互相通信。

props 传递

最简单直接的方式,主应用在注册子应用时通过 props 字段传入数据或回调函数。子应用在 mount 钩子中接收。这种方式是单向的,适合传递初始配置、用户信息、或让子应用调用主应用提供的方法(如全局登出)。

// 主应用
registerMicroApps([{
  name: 'sub-app',
  props: {
    token: 'xxx',
    userInfo: { name: 'John' },
    onLogout: () => { /* 主应用处理登出逻辑 */ }
  }
}]);

// 子应用 mount 钩子中接收
export async function mount(props) {
  const { token, userInfo, onLogout } = props;
}

initGlobalState(全局状态)

qiankun 内置的发布订阅机制,主应用初始化一个全局状态对象,主应用和所有子应用都可以监听状态变化、也可以更新状态。适合需要跨应用共享且频繁变化的数据,如当前用户信息、主题、语言等。

需要注意的是,子应用只能调用 setGlobalState 修改已存在的一级属性,不能新增顶层字段,状态的结构由主应用初始化时决定。

// 主应用初始化
import { initGlobalState } from 'qiankun';
const actions = initGlobalState({ user: null, theme: 'light' });

actions.onGlobalStateChange((state, prev) => {
  console.log('状态变更:', prev, '→', state);
});

// 子应用中(通过 mount props 获取 actions)
export async function mount(props) {
  props.onGlobalStateChange((state) => {
    console.log('子应用收到状态:', state);
  });
  props.setGlobalState({ theme: 'dark' }); // 触发所有监听者
}

自定义事件总线

当需要子应用之间直接通信,而不必经过主应用中转时,可以在主应用初始化时挂载一个全局事件总线,所有子应用共享使用。这种方式灵活性最高,但需要注意子应用卸载时要及时 off 事件,避免监听器堆积。

// 主应用初始化,挂载到全局
class EventBus {
  constructor() { this.events = {}; }
  on(event, fn) { (this.events[event] ??= []).push(fn); }
  emit(event, data) { (this.events[event] ?? []).forEach(fn => fn(data)); }
  off(event, fn) { this.events[event] = (this.events[event] ?? []).filter(f => f !== fn); }
}
window.__BUS__ = new EventBus();

// 子应用A 发送
window.__BUS__.emit('order:created', { id: 123 });

// 子应用B 接收(unmount 时记得 off)
window.__BUS__.on('order:created', handler);

4.4 生命周期

子应用需要导出三个生命周期钩子供 qiankun 调用:

钩子 触发时机 调用次数 常见用途
bootstrap 资源加载完成后,首次激活前 仅一次 初始化全局配置
mount 路由匹配,子应用激活 多次 渲染应用、绑定事件
unmount 路由离开,子应用卸载 多次 销毁实例、清理定时器和事件监听
首次进入:bootstrap → mount
路由切换:unmount → mount(重复)

五、微前端优势

  • 技术栈自由:各子应用独立选型,新技术可在单个子应用中试用,不影响整体
  • 独立部署:子应用单独构建发布,发布频率和节奏互不干扰
  • 团队自治:团队边界与应用边界对齐,减少跨团队协作摩擦
  • 渐进式迁移:可将旧系统逐模块替换为新技术栈,无需一次性重写
  • 故障隔离:单个子应用崩溃不影响主应用和其他子应用
  • 按需加载:用户只加载当前访问模块,首屏资源体积更小

六、参考资料

一篇看懂国内外主流大模型:GPT、Claude、Gemini、DeepSeek、通义千问有什么区别?

2026年4月30日 16:28

今天的大模型,可以先粗略理解成“特别聪明的输入法”。

它不只是帮你补几个字,而是能读资料、写文章、看图片、写代码,甚至调用工具去完成一整件事。

国外主流模型厂商主要是 OpenAI、Anthropic、Google、xAI、Mistral、Meta;国内的话包括 DeepSeek、通义千问、豆包、腾讯混元、百度文心、蚂蚁百灵,以及被称为“AI 六小龙”的 Kimi、MiniMax、阶跃星辰、智谱、百川智能、零一万物。

选模型不用只盯着“谁最强”。普通人更应该看三件事:你要做什么、你在哪个产品里用、能不能稳定便宜地用到

大模型到底是什么?

大模型的英文常见说法是 Large Language Model,简称 LLM。名字里有“语言”,但现在的大模型早就不只处理文字了。

你可以把它想成一个“读过大量资料的通用助手”:

  • 你给它一段文字,它能总结、改写、翻译。
  • 你给它一张图片,它能描述、识别、分析。
  • 你给它一个表格,它能帮你找规律。
  • 你让它写代码,它能生成、修改、排查问题。
  • 你给它工具权限,它还能联网搜索、读文件、操作软件,这时就更接近 Agent。

为什么会有这么多模型?

因为大模型不是只有一种用法。

同样是车,有家用车、货车、跑车、越野车。大模型也一样,有的擅长聊天,有的擅长写代码,有的适合企业自己部署。

模型类型 适合做什么 普通人怎么理解
通用聊天模型 问答、写作、总结、翻译 日常全能助手
推理模型 数学、逻辑、复杂规划 更愿意“想一会儿”的助手
多模态模型 图片、音频、视频、文档 能看、能听、能读图表
编程模型 写代码、修 Bug、做网页 程序员助手
开源/开放权重模型 本地部署、企业私有化 可自己装进电脑或服务器的模型
Agent 模型 调工具、跑流程、做任务 不仅回答,还能动手

国外主流大模型

国外主流大模型厂商主要集中在美国和欧洲

1. OpenAI:GPT 系列

OpenAI 是普通用户最熟悉的大模型公司,ChatGPT 就是它的代表产品。

截至 2026 年 4 月 29 日,OpenAI 已发布 GPT-5.5。官方重点强调的是 Agent 编程、电脑操作、知识工作和早期科研任务,也就是让模型不只回答问题,而是能连续处理一串任务。

GPT 系列适合这样理解:

  • 优势:综合能力强,生态成熟,产品体验好。
  • 适合:写作、学习、办公、编程、资料整理、Agent 工作流。
  • 特点:从“聊天助手”往“能干活的 AI 同事”发展。

2. Anthropic:Claude 系列

Claude 是 Anthropic 的模型。近几年,它在写作、长文本理解、代码和 Agent 场景里很受欢迎。

Claude Opus 4.7 是目前较新的高端版本之一。Anthropic 官方强调它适合复杂软件工程、专业知识工作和多步骤 Agent 任务,并提供 1M 上下文窗口。

Claude 的感觉更像这样:

  • 优势:长文档理解强,文字表达自然,代码能力强。
  • 适合:读长文、写方案、写代码、处理复杂文档。
  • 特点:像一个比较稳、比较细的专业助手,尤其适合处理大段资料。

3. Google:Gemini 系列

Gemini 是 Google 的大模型系列。Google 的优势在搜索、安卓、浏览器、云服务和多模态技术。

Google 已发布 Gemini 3.1 Pro,官方定位是处理复杂任务的模型,可通过 Gemini API、Vertex AI、Gemini App 和 NotebookLM 使用。

Gemini 最大的看点是生态:

  • 优势:多模态、搜索和 Google 生态结合紧密。
  • 适合:资料研究、图片理解、文档分析、和 Google 工具配合使用。
  • 特点:背后有 Google 的搜索、云和办公生态,适合和 Google 工具一起用。

4. xAI:Grok 系列

Grok 是马斯克旗下 xAI 的模型,和 X 平台关系紧密。

Grok 的定位比较鲜明:

  • 优势:和 X 信息流结合,风格更直接。
  • 适合:热点信息、社交媒体内容、轻松对话。
  • 特点:产品气质更像“网络热点助手”,不太像传统办公软件里的严肃助手。

5. Mistral:欧洲代表

Mistral AI 是欧洲最重要的大模型公司之一,长期强调开放模型和企业级部署。

Mistral 3 包含 Mistral Large 3 和 Ministral 3 系列,官方强调多模态、多语言和开放权重,适合企业和开发者按成本、速度、性能做取舍。

Mistral 更适合从企业和开发者角度看:

  • 优势:开放、轻量、企业部署友好。
  • 适合:企业系统、欧洲合规场景、本地化部署。
  • 特点:它不只做聊天产品,更偏“模型基础设施”。

6. Meta:Llama 系列

Meta 的 Llama 系列最大特点是开放权重生态。很多开发者、研究者和公司会基于 Llama 做二次开发。

Llama 4 系列在 2025 年发布了 Scout、Maverick 等模型;到 2026 年,Meta 也在继续推进新的 AI 模型与 Meta AI 产品。

Llama 的重点不是普通聊天产品,而是开放生态:

  • 优势:开放生态大,适合改造和私有部署。
  • 适合:开发者、本地模型、企业定制。
  • 特点:它更像“AI 世界的基础零件”,很多应用会基于它做自己的产品。

国内主流大模型

国内大模型竞争激烈,既有大厂,也有创业公司

1. DeepSeek:深度求索

DeepSeek 是过去两年最受关注的国产大模型之一。它最容易被记住的标签,是性价比、开源影响力和推理能力。

DeepSeek 官方已在 2026 年 4 月发布 DeepSeek-V4 Preview,包括 DeepSeek-V4-Pro 和 DeepSeek-V4-Flash。官方强调 1M 上下文、Agent 能力和开源权重。

如果你只是普通用户,可以先这么看 DeepSeek:

  • 优势:推理能力强,性价比高,开源影响大。
  • 适合:学习、写作、代码、复杂问答、企业低成本接入。
  • 特点:把高能力模型的使用门槛往下拉了一截。

2. 通义千问:阿里 Qwen

通义千问,也叫 Qwen,是阿里云推出的大模型系列。

Qwen 的特点是“模型家族很全”:通用模型、代码模型、视觉模型、音频模型都有,也有不少开放权重版本。到 2026 年 4 月,Qwen 已发布 Qwen3.6-Plus、Qwen3.6-27B 等模型,重点强化 Agent、编程和多模态能力。它适合开发者,也适合接入阿里云和企业应用。

Qwen 更像一个“模型工具箱”:

  • 优势:开源生态强,模型类型丰富,中文能力好。
  • 适合:企业应用、开发者、本地部署、多模态任务。
  • 特点:不是单一聊天机器人,而是一整套模型体系。

3. 豆包:字节跳动

豆包是字节跳动的大模型产品,背后有抖音、今日头条、剪映等内容生态。

字节在 2026 年推出 Doubao 2.0 / Seed 2.0 相关能力,重点面向复杂任务、Agent 和多模态内容创作。

豆包离普通用户更近:

  • 优势:产品入口多,内容创作能力强,普通用户使用门槛低。
  • 适合:聊天、写文案、做短视频素材、语音和多媒体创作。
  • 特点:更贴近日常 App,而不是只面向开发者或企业客户。

4. 腾讯混元:Hunyuan

腾讯混元是腾讯自研的大模型体系,和腾讯云、腾讯元宝、办公协作、游戏、内容生态都有关系。

混元的一个重点方向,是和腾讯已有产品结合,比如 AI 助手、企业服务、代码工具和多媒体生成。

看混元,不能只看聊天:

  • 优势:腾讯生态大,适合和微信、QQ、腾讯云、企业服务结合。
  • 适合:办公、内容、企业服务、智能助手。
  • 特点:更看重能不能进入腾讯的产品体系,变成具体功能。

5. 百度文心:ERNIE

百度文心大模型是国内较早进入公众视野的大模型之一,和百度搜索、百度智能云、文心一言/文心助手关系紧密。

百度已推进 ERNIE 5.0 相关模型,方向包括全模态、搜索增强、产业应用和智能云服务。

文心的优势在百度生态里更明显:

  • 优势:搜索、知识库、产业场景积累多。
  • 适合:搜索问答、知识管理、企业应用、内容生成。
  • 特点:适合和百度搜索、智能云、行业解决方案结合使用。

6. 蚂蚁百灵:Ling / Ring / Ming

蚂蚁集团的百灵大模型体系包括 Ling、Ring、Ming 等模型线。它的方向和金融科技、企业服务、智能体、全模态能力关系比较密切。

蚂蚁百灵更偏业务底座:

  • 优势:金融科技和企业场景资源多。
  • 适合:金融、办公、企业服务、复杂推理。
  • 特点:更偏“严肃业务场景里的 AI 底座”,不是单纯面向大众聊天。

国内“AI 六小龙”

“AI 六小龙”不是严格官方称号,更多是媒体和投资圈对一批大模型创业公司的统称。这个说法会变,但它方便我们快速记住几家代表公司。

1. 月之暗面 Kimi

Kimi 最早因为“长文本能力”出圈,适合读论文、读报告、总结网页和长文档。后续 Kimi K2.5、K2.6、K2 Thinking 等模型继续强化多模态、推理、编程和 Agent 能力。

一句话理解:Kimi 像一个擅长读长资料的研究助手。

2. MiniMax

MiniMax 同时做文本、语音、视频、音乐和智能体产品,旗下海螺 AI 被很多内容创作者使用。MiniMax M2.7 等模型则更偏 Agent、编程和生产力任务。

一句话理解:MiniMax 更像全模态内容与 Agent 公司。

3. 阶跃星辰 StepFun

阶跃星辰的 Step 系列模型强调基础模型、推理效率和 Agent 能力。Step 3.5 Flash 等模型主打高效推理和开源生态。

一句话理解:StepFun 更偏基础模型和 Agent 引擎。

4. 智谱 AI:GLM

智谱 AI 的 GLM 系列是国内知名开源模型路线之一。GLM-5 系列重点提升编程、推理和智能体能力。

一句话理解:智谱像国内开源大模型路线的重要代表。

5. 百川智能 Baichuan

百川智能由搜狗前 CEO 王小川创立,早期以通用大模型出圈,后来也在医疗等专业场景上投入。

一句话理解:百川更强调通用模型到垂直行业的落地。

6. 零一万物 01.AI

零一万物由李开复创立,Yi 系列模型曾在开源社区有较高关注度,也探索面向普通用户和企业的 AI 应用。

一句话理解:零一万物更重视模型能力和应用产品并行。

一张图看懂中外模型格局

普通人应该怎么选?

别问“哪个模型最强”,先问“我拿它干什么”。

你的需求 优先考虑
日常聊天、写作、学习 GPT、Claude、Gemini、豆包、Kimi、通义千问
读长文档、总结报告 Claude、Kimi、Gemini、DeepSeek、Qwen
写代码、修代码 GPT、Claude、DeepSeek、Qwen、GLM、MiniMax
图片、音频、视频创作 Gemini、豆包、MiniMax、Qwen、文心、混元
企业私有化部署 Llama、Mistral、DeepSeek、Qwen、GLM
中文内容和国内产品生态 DeepSeek、Qwen、豆包、文心、混元、Kimi
低成本 API 调用 DeepSeek、Qwen、MiniMax、GLM、Mistral

别被这些词吓住

参数

参数可以粗略理解成模型内部的“知识和能力容量”。参数越大不一定越好,因为还要看训练数据、训练方法、推理效率和产品体验。

Token

Token 是模型计费和处理文本的基本单位。可以理解成“文字切成的小块”。你输入越多、输出越多,消耗的 token 越多。

上下文窗口

上下文窗口就是模型一次能“记住”和处理多少内容。窗口越大,越适合读长报告、长合同、长代码库。

多模态

多模态就是不只处理文字,还能处理图片、音频、视频、表格、PDF 等内容。

Agent

Agent 是大模型从“只回答问题”走向“能执行任务”的关键。比如你让它“帮我查资料并整理成表格”,它可能会搜索网页、打开文档、提取信息、生成表格。

未来趋势:大模型正在从“聊天”走向“干活”

2023 年,大家觉得 AI 会聊天已经很神奇。
2024 年,大家开始用 AI 写文章、画图、写代码。
2025 年以后,竞争重点越来越明显:谁能更稳定地完成复杂任务。

未来几年,大模型会有几个趋势:

  1. 更像 Agent:不只是回答,而是能调用工具、执行流程。
  2. 更懂多模态:文字、图片、视频、语音会混在一起处理。
  3. 更便宜:高能力模型会逐渐普及,API 成本继续下降。
  4. 更本地化:越来越多公司会把模型部署到自己的服务器。
  5. 更行业化:医疗、金融、教育、法律、制造业会有专用模型。

最后:模型很多,但普通人只要抓住一个原则

大模型不是越新越好,也不是国外一定比国内好,更不是参数越大越好。

普通人选模型,最实用的判断方式是:

它能不能稳定解决我的问题?
它是不是在我常用的软件里?
它的价格、速度、隐私和体验能不能接受?

今天的大模型竞争,本质上已经不是“谁会聊天”,而是“谁能进入真实工作和生活,把事情做完”。

参考资料

Uniapp 监听回到前台并全局唯一弹窗

作者 isixe
2026年4月30日 16:19

前言

我们知道在 APP 端,有回到前台的事件监听,在 uniapp 也有对应的 App.onHide()App.onShow() 事件能够实现,但是如果加上全局的弹窗显示,就得进一步处理弹窗的唯一显示。

事件监听

在 Uniapp 中监听前后台切换,只需要使用 App.onHide() 事件注册一个全局后台事件,然后在具体的弹窗组件中监听就行了。

//App.vue
export default {
  onHide: function () {
    uni.$emit("app:background");
  }
}
//Notice.vue
onMounted(async () => {
  uni.$on("app:background", async () => {
      //这里写弹窗显示逻辑
      noticeRef.show();
  }
}

唯一弹窗

因为弹窗是全局的,所以不管是 CLI 项目还是原生的 uniapp 项目,在界面加载时都会挂载到界面上。如果不做处理,每个界面都会出现弹窗轰炸,这对用户体验来说是毁灭性的。

状态失控

那么实现唯一弹窗呢?这时候你惯性思维一敲脑袋,想着在 Pinia 中保存一个状态,然后事件触发时,用这个状态做拦截。但是接着你会发现,所有界面的弹窗都在抢着拿到这个状态,弹窗变成在意料之外的界面显示,当前的界面反而不再显示了。

这会你才意识到,所有界面的弹窗组件都是竞争关系,用一个全局状态判断并不可行。而且 App.onHideApp.onShow 中触发的事件,可能比组件内部的 onShowonHide 更快执行,也没办法通过内部的生命周期去拦截。

标记控制

比起维护状态,我们应该回到组件本身,不是怎么去控制全局的弹窗显示状态,而是控制什么条件下去显示。我们只需要给每个界面标记打个标签,然后在事件执行的时候,对比一下就行了。最简单的标记就是通过通过 URL 实现。在 uniapp 中,我们可以借助 getCurrentPages() 返回的界面路径来标记。

我们在每个弹窗组件挂载的时候,都把界面路径缓存一份,这样在事件触发的时候再获取最新的 URL,只在 URL 和当前界面相同时,显示弹窗就行了。

//Notice.vue
onMounted(async () => {
  // 缓存挂载时的界面路径
  const pages = getCurrentPages();
  const page = pages[pages.length - 1];
  const cachePath = page.route || "";

  // APP后台返回,事件显示
  uni.$on("app:background", async () => {
    const pages = getCurrentPages();
    const page = pages[pages.length - 1];
    const curPath = page.route || "";

    // 拦截历史界面弹窗 -> 根据最上层界面,对比挂载路径,不相同不显示弹窗
    if (curPath !== cachePath) {
      return;
    }

    noticeRef.show();
  });
});

这样我们就能防止历史界面中的监听的事件也被触发,只保证当前进入后台的界面在回到前台时,显示弹窗。即使我们返回到 旧的界面 或者切换到 被缓存的底栏页 ,也不会出现弹窗二次显示的问题。

使用这种方法处理的弹窗,不管是在 APP 中,还是在 H5 中,都能很好的保持弹窗显示的唯一性。

总结

有时候我们太过于考虑如何去控制所有组件的状态显示,反而把一些无状态的场景复杂化了。当这种方法不再可靠时,抛弃状态控制,用最简单的条件约束去实现一些场景,可能会得到合适的效果。

beginPath-vs-save详解

2026年4月30日 16:11

Canvas 中的 beginPath() 与 save()/restore() 详解

一句话总结

beginPath() save()
作用对象 当前路径(草稿本) 绘图状态(属性)
类比 橡皮擦擦掉草稿 拍照记录当前设置
互补操作 无(路径只能被清空或叠加绘制) restore()
相互影响 ❌ 无 ❌ 无

它们是完全独立的两个机制,互不干扰。


一、beginPath() — 只管「路径」

作用

清空当前路径,重新开始绘制新的路径。

Canvas 内部维护了一个**当前路径(current path)**的概念,它就像一个「草稿本」:

ctx.moveTo(0, 0);        // 草稿本上写下:从 (0,0) 开始
ctx.lineTo(100, 0);      // 草稿本上写下:画到 (100,0)
ctx.lineTo(100, 100);    // 草稿本上写下:再画到 (100,100)

ctx.beginPath();         // 清空草稿本!前面的都清掉了

ctx.arc(50, 50, 30, 0, Math.PI * 2); // 新的路径
ctx.stroke();            // 只画了 arc,之前的 lineTo 都消失了

构成路径的命令

命令 说明
moveTo(x, y) 设置路径起点
lineTo(x, y) 从当前点画直线到目标点
arc(x, y, r, startAngle, endAngle) 画圆弧
rect(x, y, w, h) 添加矩形路径
quadraticCurveTo() 二次贝塞尔曲线
bezierCurveTo() 三次贝塞尔曲线
closePath() 闭合路径(连回起点)

关键点

  • 不调用 beginPath(),新路径会叠加到旧路径上
  • beginPath() 只影响路径,不影响任何样式属性

不调用 beginPath 的后果

ctx.strokeStyle = 'red';
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.stroke(); // 画了一条红线

ctx.strokeStyle = 'blue';
ctx.moveTo(0, 100);
ctx.lineTo(100, 0);
ctx.stroke(); // 两条线都变蓝了!因为旧路径还在,被一起重新描边了

第二次 stroke() 时,第一条线的路径依然存在,所以两条线都被用当前颜色(蓝色)重新描了一遍。


二、save() / restore() — 只管「状态」

作用

把当前 Canvas 的绘图状态压入 / 弹出状态栈。

ctx.save();    // 把当前所有属性拍一张快照,压入栈顶
ctx.restore(); // 把栈顶的快照弹出来,恢复到当时的属性

保存哪些状态

类别 属性
变换 translaterotatescale 等变换矩阵
裁剪 clip() 设置的裁剪区域
线条样式 lineWidthlineCaplineJoinmiterLimit
填充/描边 fillStylestrokeStyle
合成 globalAlphaglobalCompositeOperation
阴影 shadowBlurshadowColorshadowOffsetXshadowOffsetY
文本 fonttextAligntextBaselinedirection
滤镜 filter
图像平滑 imageSmoothingEnabled

不保存什么

  • 当前路径moveTolineTo 等积累的路径)
  • 已绘制的内容(画布上的像素)

示例

ctx.strokeStyle = 'red';
ctx.lineWidth = 2;
ctx.save(); // 保存状态:红色,宽度2

ctx.strokeStyle = 'blue';
ctx.lineWidth = 10;
ctx.strokeRect(10, 10, 50, 50); // 蓝色粗框

ctx.restore(); // 恢复状态:红色,宽度2
ctx.strokeRect(80, 10, 50, 50); // 红色细框

嵌套使用

save() 可以嵌套调用,restore() 按后进先出(LIFO)顺序弹出:

ctx.save();   // 栈: [状态A]
ctx.save(); // 栈: [状态A, 状态B]
ctx.restore();// 栈: [状态A] ← 回到状态B
ctx.restore();// 栈: []     ← 回到状态A

三、两者关系图解

┌─────────────────────┐      ┌─────────────────────────┐
│   Canvas 状态        │      │  Canvas 路径(草稿本)     │
├─────────────────────┤      ├─────────────────────────┤
│ fillStyle: 'red'    │      │ moveTo(0, 0)            │
│ strokeStyle: 'blue' │      │ lineTo(100, 100)        │
│ lineWidth: 5        │      │ arc(50, 50, 30, ...)    │
│ transform: [...]    │      │                         │
│                     │      │                         │
│ ← save() 保存这些    │      │ ← beginPath() 清空这些    │
└─────────────────────┘      └─────────────────────────┘

save() 只保存左边 ❌ 不保存右边

beginPath() 只清空右边,不影响左边任何属性

案例:


ctx.save();           // ① 保存原始状态
ctx.translate(100, 100); // 移动坐标系
ctx.rotate(Math.PI / 4);
ctx.strokeStyle = 'green';
ctx.lineWidth = 3;
ctx.rect(-25, -25, 50, 50);
ctx.stroke();
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);

ctx.restore();        // ②恢复原始状态
// 坐标系、颜色、线宽都回到 save 之前
ctx.strokeRect(80, 10, 50, 50); // 黑色细框
// ctx.beginPath();  //  ③ 开始新路径(跟 save 无关,只是为了不叠加旧路径)
ctx.stroke(); 

四、两者配合使用的典型场景

刮刮乐 / 橡皮擦效果

class Line {
    constructor(ctx) {
        this.ctx = ctx;
        this.drawing = false;
    }
    moveTo(x, y) {
        this.drawing = true;
        this.ctx.save();                         // ① 保存原始状态
        this.ctx.globalCompositeOperation = 'destination-out'; // 设置为擦除模式
        this.ctx.lineWidth = 30;
        this.ctx.beginPath();                    // ② 开始新路径
        this.ctx.moveTo(x, y);
    }
    lineTo(x, y) {
        if (!this.drawing) return;
        this.ctx.lineTo(x, y);
        this.ctx.stroke();
    }
    restore() {
        if (!this.drawing) return;
        this.ctx.restore();                      // ③ 恢复原始状态
        this.drawing = false;
    }
}

绘制旋转/变形的图形

// 画一个旋转的绿色方块
ctx.save();                          // 保存原始状态
ctx.translate(100, 100);             // 移动坐标系到方块中心
ctx.rotate(Math.PI / 4);             // 旋转 45 度
ctx.strokeStyle = 'green';
ctx.lineWidth = 4;

ctx.beginPath();                     // 开始新路径(与 save 无关)
ctx.rect(-25, -25, 50, 50);          // 以中心为原点的矩形
ctx.stroke();

ctx.restore();                       // 回到原始状态

绘制多个不同样式的图形

// 圆
ctx.save();
ctx.fillStyle = 'red';
ctx.beginPath();
ctx.arc(50, 50, 30, 0, Math.PI * 2);
ctx.fill();
ctx.restore();

// 矩形
ctx.save();
ctx.fillStyle = 'blue';
ctx.beginPath();
ctx.rect(100, 20, 60, 60);
ctx.fill();
ctx.restore();

// 三角形
ctx.save();
ctx.fillStyle = 'green';
ctx.beginPath();
ctx.moveTo(200, 20);
ctx.lineTo(260, 80);
ctx.lineTo(140, 80);
ctx.closePath();
ctx.fill();
ctx.restore();

五、常见误区

误区 1:以为 save 会保存路径

ctx.moveTo(30, 50);
ctx.lineTo(80, 50);
ctx.save();           // 路径不被保存!

ctx.beginPath();      // 清空路径
ctx.moveTo(120, 50);
ctx.lineTo(170, 50);

ctx.restore();        // 路径不会回来!
ctx.stroke();         // 只有右边那条线段被画出

正确做法:如果需要在 save 前保留路径,必须先 stroke()fill() 把它画出来。

误区 2:以为 beginPath 会恢复样式

ctx.strokeStyle = 'red';
ctx.lineWidth = 10;
ctx.beginPath();
ctx.rect(0, 0, 100, 100);

ctx.strokeStyle = 'blue';
ctx.lineWidth = 2;
ctx.beginPath();     // beginPath 只清空路径,不恢复属性!
ctx.rect(120, 0, 100, 100);

ctx.strokeStyle;     // 还是 'blue',不会变回 'red'
ctx.lineWidth;       // 还是 2,不会变回 10

正确做法:需要恢复属性时,用 save() / restore()

误区 3:忘了 beginPath 导致路径叠加

for (let i = 0; i < 5; i++) {
    // 没写 beginPath!每次 stroke 都会把所有历史路径重描一遍
    ctx.arc(50 + i * 30, 50, 10, 0, Math.PI * 2);
    ctx.stroke();
}

正确做法:循环或多次绘制时,每次画新图形前调用 beginPath()


六、速查表

需求 用什么
清空之前的路径,画新图形 beginPath()
临时改变颜色/线宽,之后恢复 save() → 改属性 → restore()
临时旋转/平移/缩放,之后恢复 save() → 变换 → restore()
临时裁剪区域,之后恢复 save()clip()restore()
保存当前绘制的路径 ❌ 做不到,路径只能画出来
撤销画布上已经画好的内容 ❌ 做不到,需要自己维护状态或重绘

案例

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        body{
            margin: 0;
            padding: 0;
        }
        canvas{
            display: block;
        }
    </style>
</head>
<body>
    <canvas id="myCanvas"></canvas>
    <script>
        const canvas = document.getElementById('myCanvas');
        canvas.width=385;
        canvas.height=188;
        const ctx = canvas.getContext('2d');
        // 1.beginPath() 作用
        // ctx.strokeStyle = 'red';
        // ctx.moveTo(0, 0);
        // ctx.lineTo(100, 100);
        // ctx.stroke(); // 画了一条红线

        // ctx.strokeStyle = 'blue';
        // ctx.moveTo(0, 100);
        // ctx.lineTo(100, 0);
        // ctx.stroke(); //// 两条线都变蓝了!因为旧路径还在,被一起重新描边了
        // 不 beginPath() → 路径会累积,每次 stroke/fill 都会画全部旧路径

        ctx.save();           // ① 保存原始状态
        ctx.translate(100, 100); // 移动坐标系
        ctx.rotate(Math.PI / 4);
        ctx.strokeStyle = 'green';
        ctx.lineWidth = 3;
        ctx.rect(-25, -25, 50, 50);
        ctx.stroke();
        ctx.moveTo(0, 0);
        ctx.lineTo(100, 100);

        ctx.restore();        // ②恢复原始状态
        // 坐标系、颜色、线宽都回到 save 之前
        ctx.strokeRect(80, 10, 50, 50); // 黑色细框
        // ctx.beginPath();  //  ③ 开始新路径(跟 save 无关,只是为了不叠加旧路径)
        ctx.stroke(); 
      
    </script>
</body>
</html>

Vue 全局监控用户行为,最强方案!

作者 Forever7_
2026年4月30日 15:58

📊 产品定位

WebTracing 是一款基于 JavaScript 开发的前端埋点工具包(SDK),专门为 Web 应用打造全链路监控方案,能够全方位覆盖前端监控场景,助力开发者实现应用的精准监控与优化。

🌟 核心能力

该SDK全面覆盖八大核心监控维度,实现前端场景无死角监控:

  • 行为监控:精准追踪用户各类交互操作,还原用户行为路径
  • 性能监控:深入分析页面加载全过程及运行时性能表现
  • 异常监控:自动捕获 JavaScript 运行过程中的各类错误
  • 请求监控:实时追踪 HTTP 请求的状态、耗时等关键指标
  • 资源监控:细致分析静态资源的加载速度与异常情况
  • 路由监控:适配 SPA 应用,精准追踪路由切换状态
  • 曝光监控:检测页面元素的可见性,统计曝光数据
  • 录屏功能:回放用户操作行为,便于问题回溯与分析

✨ 技术特性

  • 原生兼容:采用纯 JavaScript 开发,支持所有现代主流浏览器
  • 框架适配:针对性提供 Vue2、Vue3 专用版本,开箱即用
  • 轻量高效:采用轻量化设计,gzip 压缩后体积不足 15KB,不占用过多资源
  • 灵活可配:支持 20 余种定制化参数,可根据业务需求灵活调整
  • 数据优化:采用智能缓存与批量上报机制,有效降低网络开销

📦 快速集成

安装方式

# 原生 JavaScript 项目
pnpm install @web-tracing/core

# Vue2 项目
pnpm install @web-tracing/vue2

# Vue3 项目  
pnpm install @web-tracing/vue3

🌐 原生 JS 集成示例

<script src="https://cdn.jsdelivr.net/npm/@web-tracing/core"></script>
<script>
  webtracing.init({
    dsn'https://api.your-domain.com/track',
    appName'web_app',
    tracesSampleRate0.2,  // 生产环境采样率设置
    ignoreErrors: [/ResizeObserver loop/],
    beforeSendDatadata => {
      data.env"production";
      return data
    }
  })
</script>

🖥️ Vue3 集成示例

import WebTracing from '@web-tracing/vue3'

app.use(WebTracing, {
  dsn: '/track',
  performance: true,      // 开启性能监控功能
  error: {                // 精细化配置错误捕获规则
    captureUnhandledRejections: true
  },
  cacheMaxLength: 20,     // 扩大缓存队列容量
})

🔧 关键配置详解

配置项 类型 默认值 说明
tracesSampleRate number 1.0 数据采样率,取值范围为 0.1~1.0
cacheWatingTime number 1000 缓存批量上报的时间间隔(单位:ms)
scopeError boolean false Vue 专属配置,用于开启组件级错误捕获

⚡ 过滤规则配置

{
  ignoreErrors: [
    "CustomIgnoreError", 
    /^SecurityError:/
  ],
  ignoreRequests: [
    /healthcheck/,
    /.(png|css|js)$/
  ]
}

�深度解析核心功能

1. 全链路错误追踪

// 主动捕获异常并上报
webtracing.captureException(error, {
  tags: { module'checkout' },
  extra: { cartId'a1b2c3' }
})

// 监听未处理的Promise异常
window.addEventListener('unhandledrejection', e => {
  webtracing.captureException(e.reason)
})

2. 精细化性能分析

// 标记关键业务流程的开始与结束
webtracing.markStart('payment_processing')
processPayment()
webtracing.markEnd('payment_processing')

// 获取页面LCP(最大内容绘制)指标
const lcpEntry = performance.getEntriesByName('LCP')[0]
console.log(lcpEntry.startTime)

3. 智能曝光追踪

<!-- 采用声明式方式配置曝光监控 -->
<div data-exposure-track="promo_banner" data-exposure-ratio="0.6">
  <!-- 广告或需要监控曝光的内容 -->
</div>

🚀 最佳实践

生产环境推荐配置

{
  dsn'https://log.your-app.com',
  tracesSampleRate0.1,   // 高流量场景建议10%采样率
  cacheMaxLength30,      // 扩大缓存队列,减少上报次数
  cacheWatingTime2000,   // 设置2秒批量上报间隔
  ignoreErrors: [
    /^CanceledError/,
    /ResizeObserver loop/
  ]
}

用户行为追踪策略

// 封装关键转化事件追踪方法
exportconst trackConversion = (eventName, params) => {
  webtracing.track(eventName, {
    ...params,
    sessionId: getSessionId(),
    timestamp: Date.now()
  })
}

// 示例:追踪用户购买行为
trackConversion('purchase', {
orderId'ord_123'amount299.00
})

📈 监控数据示例

性能数据格式

{
  "type": "performance",
  "metrics": {
    "FCP": 1240,
    "LCP": 2850,
    "CLS": 0.08
  },
  "pageUrl": "/products/123"
}

错误数据格式

{
  "type": "error",
  "message": "Cannot read property 'price'",
  "stack": "...",
  "component": "ProductCard.vue",
  "environment": "production"
}

为什么 React 和 Vue 不一样?

作者 老王以为
2026年4月29日 14:15

依旧能记起当年 React 和 Vue 刚火时,前端之间一直有个争论:使用 React 还是使用 Vue。当年这个议题吵的热火朝天,当时就在想,为什么这两个框架会有这么大的差异?造成这些差异的原因是什么?为什么两个框架走的不同路径,但是给开发者的体验却是相似的?种种问题都在我的脑海中回荡,可惜当年还是一个初入门的小白,虽然有这些问题,但是还是没有自己找到答案。最近横向和纵向各个维度深度对比了这两个框架,答案就呼之欲出了。挺好,似乎回到了入门的起点。


一、为什么两种架构走向了不同的道路

1.1 UI 的本质是什么

要理解 React Fiber 和 Vue 响应式系统为何走向截然不同的架构路径,我们必须回到一个更根本的问题:用户界面的本质是什么。React 团队给出的答案是——UI 是状态的函数(UI = f(state))。这个看似简单的等式蕴含着深刻的架构决策:如果 UI 只是状态的纯粹映射,那么每次状态变化时,整个 UI 都应该被重新计算,框架的职责是通过 diff 算法来最小化实际的 DOM 操作。React 的虚拟 DOM 和 Fiber 架构都是这一观点的工程实现,它们假设状态变化是不可预测的、细粒度的,因此需要一个通用的运行时调度系统来处理任意复杂度的更新。这种设计赋予了 React 极强的灵活性和表达能力,但也带来了不可避免的运行时开销——每一次更新都需要走过"渲染 -> 虚拟 DOM 树构建 -> Diff -> Patch"的完整链路。

Vue 的创始人尤雨溪对这个问题给出了不同的回答。在他看来,UI 的本质是响应式数据与 DOM 之间的绑定关系。当开发者声明了一个模板(Template),其中的每一个插值表达式({{ }})、每一个指令(v-bindv-ifv-for)都是在建立数据到视图的明确映射。Vue 的核心在于:这种映射关系在编译时就可以被静态分析出来。因此,Vue 选择将大部分优化工作前置到编译阶段,通过编译器生成带有优化标记的渲染函数,让运行时的更新工作变得精准而高效。Vue 3 的 Proxy 响应式系统进一步强化了这种理念——当数据变化时,框架精确知道哪些组件、哪些 DOM 节点需要更新,不需要进行全树扫描。两种框架的分歧从这一刻起就已经注定:React 押注运行时调度的通用性和灵活性,Vue 押注编译时优化的精准性和效率。

1.2 两条路径的技术DNA

React 的技术 DNA 可以追溯到底层系统编程的启发。Fiber 架构的设计者 Andrew Clark 曾明确表示,Fiber 是对操作系统线程调度模型的借鉴。在操作系统中,进程调度器需要在多个任务之间分配 CPU 时间片,确保高优先级任务(如用户输入)能够及时响应,同时不让低优先级任务(如后台计算)饿死。React Fiber 将同样的思想引入了 JavaScript 的单线程环境:通过将渲染工作拆分为可中断的"工作单元",并利用浏览器的 requestIdleCallback 机制,React 可以在每一帧的空闲时间内执行一小部分渲染工作,高优先级更新则可以随时 "抢占" 当前工作。这种架构赋予了 React 时间切片并发渲染的能力,使得 React 能够在不阻塞主线程的前提下处理大规模组件树的更新。

Vue 的技术 DNA 则源于 数据绑定依赖追踪 。Vue 2 使用 Object.defineProperty 对数据对象进行递归劫持,在 getter 中收集依赖,在 setter 中触发更新。Vue 3 则将这一机制升级为基于 ES6 Proxy 的响应式系统,配合 Reflect API 实现更完整、更高效的拦截。Vue 的核心设计哲学是 让框架自动追踪数据与视图之间的依赖关系,开发者无需手动声明依赖(不像 React 的 useEffect 需要显式传递依赖数组)。当 refreactive 对象的值发生变化时,Vue 的响应式系统能够精确通知到依赖于该数据的每一个副作用(Effect),包括组件的重新渲染、computed 属性的重新计算、watch 回调的执行等。这种"自动追踪、精确触发"的机制,使得 Vue 在大多数场景下能够实现 O(1) 的更新复杂度 ——即更新成本与受影响的节点数量成正比,而非与组件树的总规模成正比。

1.3 核心差异一览

维度 React Fiber Vue 响应式系统
核心差异 UI = f(state),通用运行时调度 数据驱动视图,编译时优化 + 响应式追踪
更新粒度 组件级别(需要 diff 确定实际变更) 属性级别(精确追踪依赖)
调度模型 协作式多任务(Cooperative Scheduling) 依赖触发式(Dependency-driven)
可中断性 原生支持(Time Slicing) 需配合 nextTick 批量处理
编译角色 次要(JSX 转译) 核心(模板编译 + 优化标记生成)
内存模型 双缓冲(Current / WorkInProgress 两棵树) 代理对象 + Effect 依赖图
学习曲线 中等(需理解 hooks 规则、闭包陷阱) 平缓(模板语法直观)

二、React Fiber:在单线程世界里的调度器

2.1 Stack Reconciler 的困局

在 React 16 之前的 Stack Reconciler 时代,React 的更新过程可以简单概括为 "一撸到底" 。当组件状态发生变化时,React 会从根节点开始,递归遍历整棵组件树,计算新的虚拟 DOM 树,与旧的树进行 Diff,最后一次性将所有变更提交到真实 DOM。这个过程完全 同步不可中断 ——一旦开始,就必须等到全部完成才能将控制权交还给浏览器。对于小型应用,这种方式工作得很好,因为整个更新过程可能只需要几毫秒。但随着应用规模的增长,组件树可能包含数千个节点,一次完整的 reconciliation 可能消耗数十甚至上百毫秒,直接阻塞浏览器的主线程。

这种阻塞带来的用户体验问题是灾难性的。我们想象一下,用户在搜索框中输入文字,同时后台正在接收实时数据更新。在 Stack Reconciler 中,数据更新触发的重渲染可能会完全占用主线程 100ms,在这段时间内,用户的键盘输入事件被挂在事件队列中无法得到响应——用户会感觉"卡顿"。更严重的是,动画在这一期间完全停滞,因为浏览器没有机会执行 requestAnimationFrame 回调。React 团队意识到,问题的根源不在于虚拟 DOM 本身,而在于 JavaScript 的执行模型 ——调用栈是 后进先出的、不可抢占的数据结构,一旦进入深层递归,就没有优雅的方式来"暂停"当前工作去处理更紧急的任务。

2.2 Fiber 的创新:重新实现调用栈

React Fiber 的创新点在于它对这一底层问题的回应:如果浏览器的调用栈不够灵活,那就自己实现一个。Fiber 架构的本质是一种 用户空间调度器,它将原本由 JS 引擎管理的调用栈转换为显式维护的链表数据结构。每一个 React 组件实例不再只是一个函数调用,而是一个持久化的 Fiber 节点对象,其中包含了 child(第一个子节点)、sibling(下一个兄弟节点)和 return(父节点)三个指针,构成了一棵可任意遍历、暂停和恢复的树形链表。

这种数据结构的选择绝非偶然。链表结构使得 React 可以彻底放弃递归(recursion),改用循环(loop)来遍历组件树。在循环的每一次迭代中,React 处理一个 Fiber 节点,然后检查当前帧的剩余时间。如果剩余时间不足(React 默认设置了一个约 5ms 的帧预算),或者检测到有更高优先级的更新到来,React 可以立即保存当前的工作进度(记录下一个待处理的 Fiber 节点引用),将控制权交还给浏览器,然后在下一帧的 requestIdleCallback 回调中无缝恢复工作。Andrew Clark 将 Fiber 描述为 "一个专门用于 React 组件的虚拟栈帧" ——它的核心优势在于,这些栈帧存储在堆内存中,React 可以完全控制它们的执行顺序和时机,这是操作系统调用栈所不具备的能力。

2.3 双缓冲架构与两阶段提交

Fiber 架构引入了 双缓冲 的内存模型,这是另一个深刻影响 React 更新行为的创新。React 在内存中同时维护两棵 Fiber 树:一棵是 current 树,代表了当前屏幕上真实 UI 的状态;另一棵是 workInProgress 树,用于进行正在进行的渲染计算。当更新触发时,React 并不会直接修改 current 树,而是基于它克隆出一棵 workInProgress 树,所有的 reconciliation 工作都在这棵"草稿"树上进行。这个设计的精妙之处在于 渲染过程完全不会影响用户看到的界面——即使渲染过程中途被中断或完全丢弃,用户看到的依然是 current 树对应的一致 UI。

workInProgress 树的所有工作完成后,React 进入 提交阶段(Commit Phase)。这是一个 同步、不可中断 的阶段,React 将 workInProgress 树的所有副作用(DOM 插入、更新、删除,以及生命周期函数和 useEffect 回调的调度)一次性应用到真实 DOM 上,然后原子性地将 workInProgress 树切换为新的 current 树。两阶段架构的严格分离是 React 并发特性的基石:渲染阶段(Render Phase)可以被打断和重启,因为它只操作内存中的 workInProgress 树;提交阶段(Commit Phase)必须是原子的,因为此时正在修改用户可见的界面,任何不一致都会导致视觉闪烁。

graph TD
    A[状态更新触发] --> B{是否有更高<br/>优先级任务?}
    B -->|是| C[保存当前进度<br/>yield 控制权]
    C --> D[处理高优先级任务]
    D --> E[恢复之前工作]
    E --> B
    B -->|否| F[Render Phase<br/>构建 workInProgress 树]
    F --> G[生成 Effect List]
    G --> H[Commit Phase<br/>同步提交 DOM 变更]
    H --> I[切换 current 指针]
    I --> J[调度 useEffect]

2.4 优先级调度与 Lane 模型

React 18 进一步演化出了 Lane 优先级模型,用 31 位的二进制数来表示不同类型的更新优先级。每一位代表一个"通道"(Lane),不同的交互类型(用户输入、点击、数据加载、过渡动画等)被分配到不同的 Lane 上。React 可以精确判断哪些更新更紧急,并支持 Lane 的"纠缠"(entanglement)机制——当高优先级更新和低优先级更新之间存在数据依赖时,React 会自动将它们合并渲染,防止出现视觉不一致。这种精细的优先级控制系统使得 React 能够在极端复杂的并发场景中依然保持用户交互的流畅性,但也显著增加了框架的运行时复杂度和学习成本。


三、Vue 响应式系统:让数据自己告诉你它变了

3.1 从 defineProperty 到 Proxy:响应式技术的进化

Vue 的响应式系统经历了两代重大演进。Vue 2 使用 Object.defineProperty 为对象的每一个属性定义 getter 和 setter,在属性被读取时收集依赖,在被修改时触发更新。这个方案在当时是创新的,但它有几个根本性缺陷:首先,Object.defineProperty 只能拦截已经存在的属性,无法检测对象的新增属性和数组索引的变化(这也是 Vue 2 需要 Vue.setVue.delete API 的原因);其次,它需要对数据对象进行 深度递归遍历,在初始化时就为每一层嵌套对象的每一个属性都设置 getter/setter,这在处理大型数据对象时会产生大的性能开销。

Vue 3 的响应式系统基于 ES6 的 Proxy 对象进行了彻底重写。与 Object.defineProperty 不同,Proxy 可以拦截对目标对象的 任何操作 ——包括属性读取、赋值、删除、枚举、函数调用、in 运算符,甚至 new 操作。这意味着 Vue 3 不再需要深度递归初始化:代理是"懒"的,只有当访问到某个嵌套对象时,才会递归地为该对象创建代理。更重要的是,Proxy 让 Vue 3 天然支持 Map、Set、WeakMap、WeakSet 等 ES6 数据结构,以及数组的所有操作(包括直接通过索引赋值和修改 length),无需任何特殊处理。

在 Vue 3 的源码中,reactive() 函数通过 new Proxy(target, mutableHandlers) 创建响应式对象,其中 mutableHandlers 包含了 getset 拦截器。get 拦截器使用 Reflect.get(target, key, receiver) 读取属性值(Reflect API 的设计目的正是为了与 Proxy 配合使用,提供更完整和规范的元编程能力),同时调用 track() 函数进行依赖收集;set 拦截器使用 Reflect.set() 写入新值,然后调用 trigger() 函数通知所有依赖进行更新。这种 Proxy + Reflect 的组合已经成为现代 JavaScript 元编程的标准范式。

3.2 依赖收集的三剑客:TargetMap、Dep、Effect

Vue 3 的响应式系统内部维护了一个精巧的全局依赖追踪结构。其核心是三个关键数据结构:

首先是 targetMap,一个 WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>> 结构。它的作用是建立"响应式对象 -> 属性键 -> 依赖集合"的三层映射。WeakMap 的选择非常重要——它允许垃圾回收器在响应式对象不再被引用时自动回收其对应的依赖信息,防止内存泄漏。当 track() 被调用时,Vue 会根据当前被访问的响应式对象和属性键,找到或创建对应的依赖集合(Dep),然后将当前正在执行的 ReactiveEffect 实例添加到这个集合中。

其次是 ReactiveEffect 类,它是 Vue 响应式系统中"副作用"的抽象表示。组件的渲染函数、computed 属性的计算函数、watch 的回调函数,本质上都是 ReactiveEffect 的不同实例。每个 ReactiveEffect 有一个 run() 方法用于执行副作用,以及一个 deps 数组用于记录它依赖于哪些 Dep 集合。这种 双向记录 的机制——Effect 记录它依赖了哪些 Dep,Dep 记录哪些 Effect 依赖了它——是实现精确更新的关键。当响应式数据变化时,trigger() 函数只需要找到对应的 Dep 集合,遍历其中的所有 Effect 并重新执行即可。

最后是调度器(Scheduler)。Vue 并不会在数据变化时立即同步执行所有副作用,而是将它们推入一个队列,通过 nextTick 机制进行 异步批量刷新。这意味着在同一个事件循环中发生的多个数据变化,只会触发一次统一的 DOM 更新——这是 Vue 性能优化的重要手段。通过 Promise.then(或降级到 setImmediate / setTimeout),Vue 确保所有同步的数据变更都完成后,才在下一个微任务中执行副作用,这种批量处理策略大幅减少了不必要的重复渲染。

graph LR
    A[响应式对象 Proxy] -->|读取属性| B[track]
    B --> C{targetMap}
    C -->|对象| D[Map: key -> Dep]
    D -->|属性| E[Set of Effects]
    E -->|添加| F[当前 Effect]
    
    G[修改属性] -->|触发| H[trigger]
    H --> C
    D -->|获取 Effects| I[批量调度更新]
    I -->|nextTick| J[执行 Effect/DOM更新]

3.3 编译器的智慧:从模板到优化标记

Vue 的响应式系统之所以高效,很大程度上归功于其 编译器的静态分析能力。与 React 的 JSX 不同,Vue 使用基于 HTML 的模板语法。这种看似限制性的设计实际上为编译器优化打开了巨大的空间。当 Vue 编译器分析一个模板时,它能够识别出哪些部分是 静态的(不会随数据变化),哪些是 动态的(绑定响应式数据)。

Vue 3 的编译器引入了多项革命性的优化技术:静态提升(Static Hoisting)将静态节点从渲染函数中提取出来,只在首次渲染时创建一次,后续更新完全跳过这些节点;Patch Flags 为每一个动态节点打上一个优化标记,精确指示该节点的哪个部分可能变化(文本内容、类名、样式、属性等),这样运行时的 diff 算法可以跳过完整的 props 比较,只检查可能发生变化的特定部分;树扁平化 打破了传统的递归 diff 模式,将所有动态节点收集到一个扁平数组中,diff 时只需要遍历这个数组而非整棵树。这些编译时优化的综合效果,使得 Vue 3 的虚拟 DOM 更新效率远超传统的全树 diff 实现——虽然 Vue 仍然使用虚拟 DOM,但它已经是一个被编译器"武装到牙齿"的高度优化版虚拟 DOM。


四、业内其他框架:百花齐放的方案

4.1 Svelte:编译器即框架

如果说 React 代表了 "运行时最大化" 的极端,那么 Svelte 则代表了 "编译时最大化" 的另一个极端。Svelte 的创造者 Rich Harris 提出了一个激进的问题:如果框架在构建时就知道你的组件会如何变化,那为什么还要在运行时做这些工作。Svelte 的核心架构决策是将框架本身"编译掉"——最终运行在浏览器中的代码,几乎是纯粹的手写 JavaScript DOM 操作,没有虚拟 DOM,没有响应式运行时库,没有 diff 算法。

Svelte 5 进一步引入 Runes(如 $state$derived$effect),将响应式模型从隐式的编译器魔法转变为显式的信号(Signals)机制。编译器分析组件模板中的每一个响应式绑定,生成精确的 DOM 更新代码。当 $state 的值变化时,编译生成的代码直接调用 textNode.data = newValueelement.setAttribute('class', newClass),没有任何中间抽象层。这种架构的代价是 Svelte 需要一个功能强大的编译器来处理各种边界情况,但它的回报也是巨大的:Svelte 应用的运行时体积极其微小(约 2-3 KB gzip),更新性能接近原生 JavaScript,内存占用也远低于虚拟 DOM 方案。

4.2 SolidJS: Signals 驱动的细粒度响应式

SolidJS 的创造者 Ryan Carniato 将"细粒度响应式"(Fine-grained Reactivity)推向了一个极致。Solid 同样不使用虚拟 DOM,但它与 Svelte 的编译器驱动方式有所不同:Solid 保留了 JSX 语法,其编译器将 JSX 转换为高效的 DOM 创建和更新指令,而响应式追踪则在运行时通过 Signals 完成。Solid 的 createSignal 返回一个 getter/setter 对,当在 JSX 或其他响应式上下文中读取 signal 时,依赖关系被自动建立;当 signal 值变化时,只有直接依赖于该值的 DOM 节点会被更新。

SolidJS 的一个关键设计特点是 组件函数只执行一次。这与 React(组件函数在每次渲染时都重新执行)和 Vue(渲染函数在每次更新时重新执行)有着根本不同。在 Solid 中,组件的 setup 代码只在挂载时运行一次,后续所有的更新都通过信号系统精确到达对应的 DOM 节点,无需重新执行组件函数。这种设计消除了"重新渲染"的概念,从根本上避免了虚拟 DOM 方案中因组件重渲染而产生的计算开销。在 js-framework-benchmark 中,SolidJS consistently 排名最靠前,与原生 JavaScript 的性能差距极小,这验证了细粒度响应式架构在性能上的巨大潜力。

4.3 Angular:从 Zone.js 到 Signals 的转变

Angular 作为一个历史悠久的企业级框架,其架构演进代表了另一个维度的思考。长期以来,Angular 依赖 Zone.js 进行变化检测——Zone.js 通过猴子补丁(monkey-patching)浏览器的所有异步 API(setTimeout、Promise、XHR、DOM 事件等),在任何异步操作完成后自动触发 Angular 的全局变化检测。这种方案的优点是开发者完全不需要关心何时触发更新——任何异步操作后 Angular 都会自动检查所有组件是否需要更新;缺点是性能极差,因为即使是最微小的状态变化,也可能导致整个组件树的脏检查(Dirty Checking)。

Angular 16+ 开始引入 Signalssignal()computed()effect()),标志着 Angular 正在从 Zone.js 的全局脏检查模型向细粒度响应式模型迁移。Angular 的 Signals 设计与 SolidJS 类似,但提供了更渐进式的迁移路径——开发者可以逐步将组件从 Zone.js 迁移到 Signals,而无需重写整个应用。Angular 的转变印证了一个行业趋势:细粒度响应式正在成为前端框架的共识方向,即使是传统上采用完全不同架构的框架也在向这一方向靠拢。

4.4 各框架架构对比

框架 渲染策略 响应式模型 运行时体积 更新粒度 编译角色
React 19 Virtual DOM + Fiber Hooks + 自动 memoization ~45 KB 组件级 React Compiler (构建时 memo)
Vue 3 Compiler-optimized VDOM Proxy + Effect 追踪 ~34 KB 属性级 核心(静态提升、Patch Flags)
Vue Vapor 无 VDOM(直接 DOM) Proxy + Effect 追踪 ~10 KB 属性级 核心(编译为 DOM 操作)
Svelte 5 无 VDOM(编译后代码) Runes (Signals) ~3 KB 语句级 核心(编译器即框架)
SolidJS 无 VDOM(编译后代码) Signals (createSignal) ~7 KB DOM 节点级 JSX 编译 + 运行时追踪
Angular 19 incremental DOM Signals (迁移中) ~120 KB 属性级 AOT 编译 + Signals

前端框架运行时性能对比

前端框架 Bundle 体积对比

前端框架架构演进时间线


五、两种框架什么场景下使用(不一定对)

5.1 性能特性的场景化分析

React Fiber 的并发调度能力在大规模、高频率、高并发更新的场景下展现出独特优势。比如一个复杂的股票交易仪表盘:页面上有数十个实时数据流(价格、成交量、订单深度),同时用户正在与图表交互(缩放、平移、选择时间范围)。React 的 Fiber 架构允许 用户交互(高优先级)实时打断数据更新(低优先级),确保图表操作始终保持 60fps 的流畅度,而价格数据在后台以较低优先级逐步更新。如果没有 Fiber 的调度能力,大量数据更新可能导致用户交互出现明显卡顿。React Compiler(原 React Forget)进一步通过编译时自动插入 memoization 来减少不必要的重渲染,让开发者不再需要手动管理 useMemouseCallback

Vue 的响应式系统在大多数常规应用场景下提供更优异的更新效率和开发体验。由于 Vue 精确追踪了每一个数据属性与视图之间的依赖关系,更新成本天然地与变更的影响范围成正比,而非与组件树的总规模成正比。这意味着在一个包含 1000 个组件的页面中,如果只有底部一个计数器发生变化,Vue 只需要更新那个计数器对应的 DOM 节点,而 React(在没有 Compiler 优化的情况下)可能需要重新渲染整个受影响的组件子树,然后进行 diff。Vue 3.6 的 Vapor Mode 进一步将这一优势推向极致:对于使用 Composition API 的组件,Vapor Mode 可以在编译时直接生成 DOM 操作代码,完全跳过虚拟 DOM,实现与 SolidJS 媲美的性能。

5.2 开发者体验:心智模型与学习曲线

React 的编程模型更接近 JavaScript 的函数式编程范式。Hooks(useStateuseEffectuseMemo 等)的引入虽然解决了类组件的逻辑复用问题,但也带来了新的心智负担:hooks 的调用顺序必须严格一致(不能在条件语句中调用),依赖数组需要手动维护(遗漏依赖会导致 bug),闭包陷阱(stale closure)是新手最常遇到的问题之一。React 的灵活性是一把双刃剑——它允许你以几乎任何方式组织代码,但也意味着团队需要建立严格的代码规范来保持一致性。React Compiler 的出现正在缓解这些问题,通过编译时自动优化替代了大部分手动 memoization 的工作。

Vue 的编程模型则更加 约定优于配置(Convention over Configuration)。模板语法({{ }}v-ifv-forv-bind)对前端开发者来说非常直观,因为它们本质上就是增强的 HTML。Composition API 提供了与 React Hooks 类似的逻辑组合能力,但没有了调用顺序的限制,也没有了依赖数组——因为 Vue 的响应式系统 自动追踪依赖,开发者不需要手动声明。这种"自动依赖追踪"的设计极大地减少了与响应式相关的 bug。对于初学者来说,Vue 的渐进式设计意味着可以从一个简单的 script 标签引入开始,逐步学习到完整的单文件组件(SFC)、Composition API、状态管理(Pinia)和路由(Vue Router),每一步都有明确的指导路径。

5.3 生态

React 的生态系统无疑是前端领域最为庞大和成熟的。从状态管理(Redux、Zustand、Jotai、Recoil)到路由(React Router)、元框架(Next.js、Remix)、UI 组件库(Material-UI、Ant Design、Chakra UI)、表单处理(React Hook Form、Formik)、数据获取(TanStack Query、SWR),React 生态几乎覆盖了前端开发的每一个细分领域。Next.js 的 App Router 和 React Server Components (RSC) 代表了 React 生态在服务端渲染和全栈开发方向上的最新探索。对于大型企业和团队来说,React 生态的广度和深度意味着几乎任何需求都能找到成熟的解决方案,招聘拥有 React 经验的开发者也相对容易。

Vue 的生态系统虽然规模不及 React,但其 整合度更高、一致性更好。Vue 官方维护的核心生态库(Vue Router、Pinia、Vite、VueUse、Nuxt.js)在 API 设计和发布节奏上保持高度统一,这大大降低了开发者在不同库之间切换的认知成本。Nuxt.js 作为 Vue 的官方元框架,提供了开箱即用的服务端渲染、静态生成、API 路由、自动导入等全栈功能,其开发者体验在很多方面优于 Next.js。


六、融合的未来:架构趋同与各自进化

6.1 从对立到融合的行业趋势

一个值得思考的现象是:React 和 Vue 虽然起源于完全不同的架构哲学,但是在最近的两年里,两条技术路线正在呈现出 趋同。React 通过 React Compiler 在编译时自动完成原本需要手动进行的 memoization 优化,实质上是在"借用"编译时优化的思路来弥补虚拟 DOM 的性能缺陷;Vue 通过 Vapor Mode 探索无虚拟 DOM 的编译策略,实质上是在向 Svelte/Solid 的细粒度响应式范式靠拢。两者都在向对方擅长的领域延伸——React 增强编译时能力,Vue 增强运行时调度能力。

这种趋同也不是偶然,而是前端架构演进的必然结果。无论是虚拟 DOM 还是细粒度响应式,最终目标都是"在状态变化时高效地更新界面"。虚拟 DOM 的方案通过"通用运行时 diff"解决问题,优点是灵活性和可预测性,缺点是运行时开销;细粒度响应式的方案通过"编译时/运行时精确追踪"解决问题,优点是极致的性能,缺点是更强的编译依赖和对编程模式的约束。最优的架构必然是在两者之间找到平衡点——利用编译器做尽可能多的静态分析和优化,同时保留运行时的调度能力来处理动态和不可预测的场景。

6.2 React 的未来:Compiler + Server Components

React 团队正在全力推进三个方向的进化:React Compiler(构建时自动 memoization)、React Server Components(服务端组件,零客户端 bundle)、以及 Offscreen Rendering(离屏渲染,用于预加载和保留组件状态)。React Compiler 的成熟将从根本上改变 React 的性能优化范式——开发者不再需要手动编写 useMemouseCallbackReact.memo,编译器会在构建时自动完成这些优化,且粒度通常比手动优化更细。React Server Components 则代表了 React 对"如何减少客户端 JavaScript 体积"这一问题的回答:将纯数据展示型组件放在服务端执行,只将交互型组件发送到客户端。这种服务器优先的架构(Server-first Architecture)正在通过 Next.js 的 App Router 成为 React 生态的主流范式。

6.3 Vue 的未来:Vapor Mode + Alien Signals

Vue 的未来路线图同样清晰而且让人期待。Vapor Mode 的目标是让 Vue 在保持现有 API 不变的前提下,实现 SolidJS 级别的渲染性能——通过在编译时生成直接 DOM 操作代码,完全跳过虚拟 DOM。这意味着 Vue 开发者无需改变任何编程习惯,只需开启一个编译器选项,就能获得数倍的渲染性能提升。Vue 3.6 还在开发 Alien Signals——一套与框架无关的信号系统实现,旨在让 Vue 的响应式原语可以与其他信号库互操作。长期来看,Vue 的架构愿景是成为一个可适应不同场景的灵活系统:对于简单场景,Vapor Mode 提供极致性能;对于复杂场景,编译器优化的虚拟 DOM 提供完整的特性支持;响应式系统作为独立模块,可以与任何渲染层配合使用。

七、总结

React Fiber 和 Vue 响应式系统代表了前端架构设计中两种取向。React 选择了一条更接近计算机科学底层的路:重新设计调用栈,实现用户空间调度器,以通用性和灵活性为代价,换来了对极端并发场景的掌控力。Vue 选择了一条更接近应用开发本质的路:让数据自己说话,让编译器做苦力,以更强的编译时约束为代价,换来了大多数场景下的高效和优雅。

这两种选择没有高下之分,它们是前端技术生态的 阴阳两面——一方的创新会激发另一方的进化。Fiber 的并发调度启发了 Vue 对异步更新队列的重构;Vue 的编译器优化启发了 React Compiler 的方向;Svelte 的编译器范式启发了 Vue Vapor Mode 的探索;SolidJS 的细粒度响应式启发了 Angular Signals 的迁移。

这种跨框架的相互启发和借鉴,恰恰说明前端架构的进化不是线性的,而是辩证的。每一个看似对立的技术选择,实际上都在推动整个行业向前发展。React 的 Fiber 证明了在 JavaScript 单线程环境中实现复杂调度的可行性;Vue 的编译器证明了静态分析在现代 UI 框架中的巨大价值;Svelte 的编译器范式证明了"没有运行时"的可能性;SolidJS 的 Signals 证明了细粒度响应式的性能极限。这些探索共同构成了前端技术栈的知识积累,无论最终哪个框架占据主流,整个行业都从中受益。

别让一张 12MB 的照片拖垮页面:ImageSource / PixelMap / ImagePacker 的工程化处理链路

作者 李游Leo
2026年4月29日 10:25

前阵子做一个图片标注功能,需求听起来很简单:用户从相册里选一张图,加一层轻量处理,页面上能预览,点保存以后导出一张新图。

刚开始我也没太当回事。图片选择器拿到路径,页面里解码,拿到 PixelMap,再做一点像素改写,最后用 ImagePacker 编码。跑 demo 很顺,换到真机上的 5000px 原图,问题就出来了:预览偶发卡顿,连续点两次保存会生成黑图,页面返回以后内存没有立刻下来,有时日志里还夹着一堆不稳定的 BusinessError。

后来把这块重新拆了一遍,我的感受是:HarmonyOS 上做图片处理,不能把它当成“一个 API 调一下”的事情。它更像一条小型流水线,ImageSource 管解码入口,PixelMap 管内存里的像素对象,ImagePacker 管重新编码。中间任何一步偷懒,页面上看起来就是卡、黑、慢、偶现。

image.png

图片处理不是 UI 逻辑,别直接堆在 Page 里

我见过不少项目这么写:在页面 onClick 里选图,选完直接 createImageSource,然后 createPixelMap,处理完塞给 Image 组件展示。功能能跑,但后面会变得很难维护。

原因很直接:页面关心的是状态,图片链路关心的是资源。

页面需要知道:现在是不是处理中、预览图是什么、保存成功没有、失败原因能不能给用户看。图片链路需要知道:源图尺寸多大、是否需要降采样、像素格式是什么、PixelMap 什么时候释放、编码失败怎么兜底。

这两类事情混在一个 @Component 里,调试时会特别痛苦。尤其是用户连续选择、连续保存、处理中返回页面这几种场景,页面状态和底层对象生命周期很容易错位。

我现在比较习惯把结构拆成这样:

entry/src/main/ets/
├── common/
│   └── image/
│       ├── ImageJob.ets          // 任务参数、状态、错误码
│       ├── ImagePipeline.ets     // 解码、像素处理、编码
│       └── ImageReleaseBag.ets   // 统一释放对象
└── pages/
    └── ImageEditPage.ets         // 只处理 UI 状态

页面不直接碰 ImageSourceImagePackerPixelMap 如果要用于预览,可以短时间交给页面持有,但持有权要说清楚:谁创建,谁释放;谁交给 UI,谁在页面退出时兜底释放。

先把三个角色分清楚

ImageSource 是图片源。它适合做两件事:读图片基本信息、按解码参数创建 PixelMap。这里最值得注意的是,不要上来就把原图完整解到内存里。移动端相机图动不动几千像素宽,真按 RGBA 展开,内存占用不是文件大小那点数。

PixelMap 是内存里的像素对象。它不是普通字符串,也不是轻量 DTO。你可以把它交给 Image 显示,也可以读取像素缓冲区做算法处理,但用完要释放。图片类问题里很多“偶现”都和它有关:重复引用、跨页面持有、失败分支忘了释放、预览图和导出图混用。

ImagePacker 是重新编码。它负责把处理后的 PixelMap 编成 JPEG、PNG、WebP、HEIC 这类可保存、可上传、可分享的数据。这里别只关注 quality,还要考虑输出格式、文件体积、透明通道、保存路径、编码失败后的清理。

这三个角色分清楚以后,代码会自然变成管线,而不是一坨页面回调。

一条更稳的处理链路

我的习惯是把图片任务拆成五步:

输入源 -> 读取图片信息 -> 按目标尺寸解码 -> PixelMap 处理 -> 编码输出

这里有个小取舍:预览和导出不一定要用同一张 PixelMap

用户刚选完图,最重要的是页面别空着。可以先解一张长边 1280 左右的预览图,马上给 UI;用户真正点保存时,再按业务需要解更高质量的版本。很多时候用户只是看一眼效果,并不会保存。为了一个可能不会发生的保存动作,提前把原图完整处理一遍,体验上并不划算。

下面这段是我会放到 ImageJob.ets 里的基础类型。实际项目可以再细分错误码,这里保留核心结构。

// common/image/ImageJob.ets
import { image } from '@kit.ImageKit';

export enum ImageJobState {
  IDLE = 'IDLE',
  DECODING = 'DECODING',
  PROCESSING = 'PROCESSING',
  ENCODING = 'ENCODING',
  DONE = 'DONE',
  FAILED = 'FAILED'
}

export interface ImageProcessOptions {
  // 预览建议 1280~1600,导出按业务再放大
  maxSide: number;
  // 是否允许改写像素
  editable: boolean;
  // 导出质量,JPEG/WebP 有意义
  quality: number;
  // 输出格式,例如 image/jpeg、image/png
  format: string;
}

export interface ImageProcessResult {
  jobId: number;
  width: number;
  height: number;
  data: ArrayBuffer;
}

export interface ImageRuntimeState {
  jobId: number;
  state: ImageJobState;
  message?: string;
  preview?: image.PixelMap;
}

jobId 看着不起眼,实际很有用。用户连续选两张图时,第一张图的任务可能后返回。如果没有 jobId,旧任务会把新页面状态覆盖掉,表现出来就是“明明选了 B 图,预览忽然跳回 A 图”。

解码前先读尺寸,别赌设备内存

下面是管线里最关键的一段:先用 ImageSource 读取图片信息,再决定解码尺寸。

// common/image/ImagePipeline.ets
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ImageProcessOptions, ImageProcessResult } from './ImageJob';

export class ImagePipeline {
  async runToEncodedData(
    jobId: number,
    filePath: string,
    options: ImageProcessOptions
  ): Promise<ImageProcessResult> {
    let source: image.ImageSource | undefined = undefined;
    let pixelMap: image.PixelMap | undefined = undefined;
    let packer: image.ImagePacker | undefined = undefined;

    try {
      source = image.createImageSource(filePath);

      const info = await source.getImageInfo();
      const decodingOptions = this.buildDecodingOptions(info, options);

      pixelMap = await source.createPixelMap(decodingOptions);

      if (options.editable) {
        await this.applySoftGray(pixelMap);
      }

      const imageInfo = await pixelMap.getImageInfo();
      packer = image.createImagePacker();

      const data = await packer.packToData(pixelMap, {
        format: options.format,
        quality: options.quality
      });

      return {
        jobId,
        width: imageInfo.size.width,
        height: imageInfo.size.height,
        data
      };
    } catch (err) {
      const e = err as BusinessError;
      throw new Error(`图片处理失败:${e.code ?? '-'} ${e.message ?? ''}`);
    } finally {
      // 注意:如果 PixelMap 已经交给 UI 展示,不要在这里释放。
      // 本方法返回的是编码数据,PixelMap 只在管线内部使用,所以这里可以释放。
      await this.safeReleasePixelMap(pixelMap);
      await this.safeReleaseImageSource(source);
      await this.safeReleasePacker(packer);
    }
  }

  private buildDecodingOptions(
    info: image.ImageInfo,
    options: ImageProcessOptions
  ): image.DecodingOptions {
    const width = info.size.width;
    const height = info.size.height;
    const maxSide = Math.max(width, height);
    const ratio = maxSide > options.maxSide ? options.maxSide / maxSide : 1;

    return {
      desiredSize: {
        width: Math.max(1, Math.floor(width * ratio)),
        height: Math.max(1, Math.floor(height * ratio))
      },
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      editable: options.editable
    };
  }

  private async safeReleasePixelMap(pixelMap?: image.PixelMap): Promise<void> {
    if (!pixelMap) {
      return;
    }
    try {
      await pixelMap.release();
    } catch (_) {
      // release 失败不再向上抛,避免覆盖主错误
    }
  }

  private async safeReleaseImageSource(source?: image.ImageSource): Promise<void> {
    if (!source) {
      return;
    }
    try {
      await source.release();
    } catch (_) {}
  }

  private async safeReleasePacker(packer?: image.ImagePacker): Promise<void> {
    if (!packer) {
      return;
    }
    try {
      await packer.release();
    } catch (_) {}
  }
}

这段代码有几个点我会坚持保留。

getImageInfo() 要放在真正解码之前。它不是为了“显示图片尺寸”这么简单,而是为了决定这张图该不该被完整解码。只要业务不是专业修图,很多场景根本不需要原图级像素进入页面。

desiredPixelFormat 尽量明确写出来。后面如果要读写像素,像素格式不明确,处理函数就会变成猜谜。你以为自己按 RGBA 读,实际格式不一致,轻则偏色,重则整张图异常。

finally 里做释放。不要只在成功分支释放,也不要只在页面退出时释放。图片处理链路的失败分支很多:源文件不可读、格式不支持、解码失败、像素写回失败、编码失败。每个分支都指望业务代码记得释放,最后一定会漏。

像素改写:少做花活,先把格式和范围管住

下面这个 applySoftGray 只是示例:读取像素缓冲区,把图片轻微降饱和,再写回 PixelMap。实际项目里可以替换成水印、马赛克、局部遮挡、截图隐私高亮等逻辑。

// common/image/ImagePipeline.ets 片段
private async applySoftGray(pixelMap: image.PixelMap): Promise<void> {
  const bytes = pixelMap.getPixelBytesNumber();
  if (bytes <= 0) {
    return;
  }

  const buffer = new ArrayBuffer(bytes);
  await pixelMap.readPixelsToBuffer(buffer);

  const data = new Uint8Array(buffer);

  // 前面解码时指定了 RGBA_8888,这里才敢按 4 字节步长处理。
  for (let i = 0; i + 3 < data.length; i += 4) {
    const r = data[i];
    const g = data[i + 1];
    const b = data[i + 2];

    // 整数近似亮度,少一点浮点运算开销。
    const gray = (r * 77 + g * 150 + b * 29) >> 8;

    // 不做纯灰,保留一点原图色彩,预览观感会自然些。
    data[i] = Math.floor(r * 0.82 + gray * 0.18);
    data[i + 1] = Math.floor(g * 0.82 + gray * 0.18);
    data[i + 2] = Math.floor(b * 0.82 + gray * 0.18);
    // data[i + 3] 是 alpha,这里不动。
  }

  await pixelMap.writeBufferToPixels(buffer);
}

这类代码不要一上来就追求“算法高级”。先把三件事做好:格式明确、边界明确、失败可退。

如果是局部处理,不一定非要整图读出来。能按区域读写就按区域做。整张 4000 × 3000 的 RGBA 图,一次缓冲区就是四十多 MB,用户多点两次,内存曲线立刻难看。

还有一个细节:不要在 UI 线程里连续做重像素循环。轻量预览可以接受,重处理要么降尺寸,要么拆任务,要么把保存动作放到用户真正确认之后。很多图片需求不是不能做,是不该在用户刚进入页面时就全做。

页面侧只订阅状态,不接管管线

页面可以很薄。它负责启动任务、展示状态、处理过期结果。

// pages/ImageEditPage.ets
import { image } from '@kit.ImageKit';
import { ImagePipeline } from '../common/image/ImagePipeline';
import { ImageJobState, ImageRuntimeState } from '../common/image/ImageJob';

@Entry
@Component
struct ImageEditPage {
  private pipeline: ImagePipeline = new ImagePipeline();
  private currentJobId: number = 0;

  @State runtime: ImageRuntimeState = {
    jobId: 0,
    state: ImageJobState.IDLE
  };

  async startExport(filePath: string): Promise<void> {
    const jobId = Date.now();
    this.currentJobId = jobId;
    this.runtime = {
      jobId,
      state: ImageJobState.DECODING,
      message: '正在处理图片...'
    };

    try {
      const result = await this.pipeline.runToEncodedData(jobId, filePath, {
        maxSide: 1920,
        editable: true,
        quality: 88,
        format: 'image/jpeg'
      });

      // 旧任务后返回,直接丢弃,不要覆盖新图状态。
      if (result.jobId !== this.currentJobId) {
        return;
      }

      this.runtime = {
        jobId,
        state: ImageJobState.DONE,
        message: `导出完成:${result.width} × ${result.height}`
      };

      // result.data 可以继续写文件、上传或进入分享链路。
    } catch (err) {
      if (jobId !== this.currentJobId) {
        return;
      }
      this.runtime = {
        jobId,
        state: ImageJobState.FAILED,
        message: `${err}`
      };
    }
  }

  build() {
    Column({ space: 16 }) {
      Text('图片处理示例')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.runtime.message ?? '请选择图片')
        .fontSize(14)
        .fontColor('#666666')

      Button(this.runtime.state === ImageJobState.DECODING ? '处理中...' : '开始导出')
        .enabled(this.runtime.state !== ImageJobState.DECODING)
        .onClick(() => {
          // 示例里省略选择器代码,真实项目里传入 picker 返回的沙箱路径或文件路径。
          this.startExport('/data/storage/el2/base/haps/entry/files/demo.jpg');
        })
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }
}

这里用了一个很土但很好用的判断:result.jobId !== this.currentJobId 就丢。别觉得它简陋,线上很多“图片串了”的问题,就是没有这个判断。

如果你还要做预览,建议单独做 decodePreview(),返回 PixelMap 给页面持有。页面退出时释放它,不要让预览图跟导出任务共用同一个对象。

// 页面持有预览 PixelMap 时,退出页面要主动释放
aboutToDisappear(): void {
  const preview: image.PixelMap | undefined = this.runtime.preview;
  if (preview) {
    preview.release();
  }
}

常见坑位:不是 API 难,是边界太多

image.png

1. 原图直接解码,内存曲线很快失控

图片文件 12MB,不代表解码后只占 12MB。JPEG 是压缩格式,进到 PixelMap 后按像素展开。粗略估算一下:

4000 × 3000 × 4 ≈ 48MB

再加上缓冲区、编码临时对象、页面预览引用,内存压力很容易上去。预览场景一定要限制长边。导出场景也要问清业务:是真的需要原图尺寸,还是只是“看起来清楚”。

2. ImageSource 复用过头,容易把并发搞乱

ImageSource 适合一次任务内部使用,不建议做成全局单例复用。尤其是同一个页面可能连续处理多张图时,每张图单独创建、单独释放,反而更稳。

如果业务上要做队列,也不要让多个任务同时操作同一个 ImageSource。图片链路里共享对象越少,问题越好定位。

3. PixelMap 给了 UI,就别在管线里顺手 release

这是一个很常见的黑图来源。

有时为了预览,会把 PixelMap 直接赋给 Image 组件。这个时候它的生命周期就已经被页面接管了。管线函数如果在 finally 里顺手 release(),UI 还没来得及渲染,底层资源已经没了。

我的规则是:

返回 ArrayBuffer / 文件路径:管线内部 release PixelMap
返回 PixelMap 给 UI:页面负责 release PixelMap

不要两边都管,也不要两边都不管。

4. 编码格式别乱选

JPEG 适合照片,体积小,但没有透明通道。PNG 适合透明图、截图、图标类内容,但照片体积可能比较大。WebP 适合压缩收益更明显的业务,HEIC 则要看你的分发和兼容要求。

做头像、封面、帖子图片这类业务,我一般会给一层策略:

export function chooseOutputFormat(hasAlpha: boolean, isPhoto: boolean): string {
  if (hasAlpha) {
    return 'image/png';
  }
  if (isPhoto) {
    return 'image/jpeg';
  }
  return 'image/webp';
}

这段只是策略示意,项目里还要看上传服务、审核服务、分享链路是否支持对应格式。

5. 失败提示别把 BusinessError 原样甩给用户

日志里保留错误码,界面上给人话。

export function toUserMessage(err: Error): string {
  const text = `${err.message ?? err}`;
  if (text.includes('decode')) {
    return '图片读取失败,可以换一张图片试试';
  }
  if (text.includes('pack') || text.includes('encode')) {
    return '图片保存失败,请稍后重试';
  }
  return '图片处理失败,请重新选择图片';
}

调试时你当然需要完整堆栈,但用户不需要看到一串模块名。这个细节对工具类应用尤其重要,很多人并不关心你底层用了哪个 API,他只关心这张图为什么没保存上。

稳定性优化:把“能跑”变成“敢上线”

我会给图片链路加几条硬规则。

长边限制要前置。 预览和导出用不同配置。预览不超过 1280 或 1600,导出按业务走 1920、2560 或原图。别用一个配置打天下。

页面状态要可取消。 HarmonyOS 里异步任务返回顺序不可控,用户操作更不可控。jobIdcancelToken、旧结果丢弃,这些东西写起来不高级,但能挡住很多线上问题。

像素处理要有预算。 你处理的是 width × height 的数据,不是一个普通数组。每多一次整图遍历,耗时和耗电都会上去。能局部处理就局部处理,能复用缓冲区就不要重复申请。

释放必须统一。 不要在十几个 catch 里散着写 release()。写一个 ReleaseBag 也行,写 safeReleaseXxx 也行,总之要能保证失败分支不漏。

保存和预览拆开。 用户选图后的第一秒要让他看到东西,不要让完整导出流程挡住首屏。预览可以轻,保存可以慢一点,只要进度提示清楚。

一个 ReleaseBag 的小封装

项目稍微复杂一点,我会用一个小工具收口释放逻辑。它不复杂,但能减少很多漏网之鱼。

// common/image/ImageReleaseBag.ets
export interface Releasable {
  release(): Promise<void>;
}

export class ImageReleaseBag {
  private items: Releasable[] = [];

  add<T extends Releasable | undefined>(item: T): T {
    if (item) {
      this.items.push(item);
    }
    return item;
  }

  async releaseAll(): Promise<void> {
    for (let i = this.items.length - 1; i >= 0; i--) {
      try {
        await this.items[i].release();
      } catch (_) {}
    }
    this.items = [];
  }
}

管线里就可以这样用:

const bag = new ImageReleaseBag();

try {
  const source = bag.add(image.createImageSource(filePath));
  const pixelMap = bag.add(await source.createPixelMap(decodingOptions));
  const packer = bag.add(image.createImagePacker());

  return await packer.packToData(pixelMap, {
    format: 'image/jpeg',
    quality: 88
  });
} finally {
  await bag.releaseAll();
}

但还是那句话:如果 PixelMap 要返回给 UI,就不要放进这个 bag。释放权一定要跟对象去向绑定。

适合落地的场景

这条链路不只适合“图片滤镜”。很多业务都能用上。

比如截图整理工具,导入截图后先生成预览,再做敏感区域遮挡,最后导出一张可分享图。比如医疗、教育、金融类应用,用户上传凭证前需要压缩和脱敏。比如内容社区,发帖前统一限制尺寸和质量,减少上传失败率。再比如元服务或卡片场景,只需要轻量缩略图,完全没必要把原图处理链路塞进去。

我个人最推荐的落地方式是:把图片处理封成一个内部基础能力,不要散落在各个页面。等第二个、第三个页面也要选图压缩时,你会感谢前面那个多写半小时封装的自己。

收个尾

ImageSource / PixelMap / ImagePacker 这套东西并不难用,难的是工程边界。

小 demo 里,选图、处理、保存写在一个按钮回调里,看起来很直观。真到项目里,大图、重复点击、页面返回、编码失败、内存释放、预览和导出的质量差异都会一起冒出来。

我的经验是:别把图片处理写成页面逻辑。把它当成一条管线,输入、解码、像素处理、编码、释放,每一步都有自己的边界。代码不会显得多炫,但上线以后会稳很多。

如何实现 Claude 生成式 UI?一套可落地的工程方案

作者 西陵
2026年4月30日 10:00

首发于公众号 code进化论,欢迎关注。

前言

随着大模型能力增强,单纯的纯文本/图片输出已无法满足复杂内容的展示需求。以 Claude.ai 为代表的产品,已开始支持直接输出 HTML 并进行渐进式渲染,同时渲染出的 HTML 也可支持简单交互,从而实现更丰富的结构化内容表达与更流畅的用户体验。

本篇文章会带大家探索如何通过大模型能力生成可交互的 HTML 内容,并通过前端技术实现流式渲染,从而达到与 claude.ai 相似的效果。

Claude.ai 分析

下面通过一个图表生成的例子来探索 claude.ai 是如何实现 HTML 的流式渲染。

code-1311608451.cos.ap-guangzhou.myqcloud.com/agent%E7%94…

消息协议分析

下面是抓取的 claude.ai 返回的 sse 消息。

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"\\n<div style=\\\"padding: 1rem"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" 0;\\\">\\n  <h2 class=\\\"sr-only\\\">2022\\u5e74\\u81f32025\\u5e74\\u4e2d\\u56fd\\u51fa"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u751f\\u4eba\\u53e3\\u67f1\\u72b6\\u56fe</h2>\\n  <div style=\\\"display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 1."}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"5rem;\\\">\\n    <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"-radius-md); padding: 1rem; flex: 1; min-width: 120px;\\\">\\n      <p style=\\\"font-size: 13"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u6700\\u9ad8\\u5e74"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u4efd</p>\\n      <p style=\\\"font-size: 22px; font-weight: 500; margin: 0;\\\">2022"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"</p>\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">956 \\u4e07\\u4eba"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"</p>\\n    </div>\\n    <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; flex: 1; min-width: 120px"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":";\\\">\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u6700\\u4f4e\\u5e74\\u4efd</p>\\n      <p style=\\\"font-size: 22px"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"; font-weight: 500; margin: 0;\\\">2025</p>\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">954"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" \\u4e07\\u4eba</p>\\n    </div>\\n    <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; flex: 1; min-width:"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" 120px;\\\">\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u56db\\u5e74\\u7d2f"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u8ba1</p>\\n      <p style=\\\"font-size: 22px; font-weight: 500; margin: 0;\\\">3795</p>\\n      <p style=\\\"font-size: 13"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">\\u4e07\\u4eba</p>\\n    </div>\\n  </div>\\n\\n  <div style=\\\"display: flex; gap: 8"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"983\\u4e07\\u4eba\\u3002</canvas>\\n  </div>\\n</div>\\n\\n<script src=\\\"https://cdnjs.cloudflare.com/ajax"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"/libs/Chart.js/4.4.1/chart.umd.js\\\"></script>\\n<script>\\n  const isD"}}

从 sse 消息可以分析出几个关键点:

  • 每条 sse 消息只输出一部分 HTML 内容,且是无规则的。
  • HTML 的内容输出顺序是 style → content HTML → script ,这也是一个完整的 HTML 文档的标准格式。

HTML 渲染机制

image.png

最终输出的内容在一个独立的 iframe 中渲染,与宿主环境完全隔离。

image.png

如果输出 HTML 的过程中突然中断对话,展示的是已输出的合法的 HTML 内容,像<p style="font-size: 13 这种内容需要过滤。

难点分析

  • HTML 渲染的样式可约束

    最终输出的 HTML 渲染出来的样式是可约束的,而不是大模型随机生成的,这样最终输出出来的 HTML 风格统一,质量稳定。因此需要输出一套大模型可理解的样式规范。

  • HTML 渲染不能影响宿主环境

    HTML 本身可能会包含样式、脚本,直接在宿主环境渲染可能会影响宿主环境。

  • HTML 如何高效的增量渲染

    因为每条 sse 消息拿到的是一小段 HTML 内容,是不完整的。如果等 HTML 内容完全输出后再渲染,等待时间会很长,整体体验也不友好,达不到预期目标。因此需要提供一套高效的增量渲染方案,在保证性能的情况下能够逐步渲染已有的内容。

  • 如何过滤掉不合法的 HTML 内容

    sse 输出的 HTML 内容不能保证是完整的,会出现 <p style="font-size: 13这种情况,在渲染时需要进行过滤。

  • 如何执行 script 脚本

    在浏览器规范中,通过 innerHTMLinsertAdjacentHTML 等基于 HTML 解析器的方式插入的 <script> 标签不会被执行,因此对于 script 脚本需要单独处理。

  • 确保 script 脚本的执行顺序

    script 脚本的执行可能存在依赖关系,比如绘制图表的脚本一定要等 Chart.js 加载完才能执行。

如何定制 HTML 生成规范?

HTML 生成规范可以参考开源项目 pi-generative-ui ,它是一个专为 pi(code agent)设计的插件,能够让 pi 像 claude.ai 一样输出具备一致设计风格的UI。下面展示了部分内容,详细提示词可参考 guidelines.ts 文件。

### Tokens
- Borders: always \`0.5px solid var(--color-border-tertiary)\` (or \`-secondary\` for emphasis)
- Corner radius: \`var(--border-radius-md)\` for most elements, \`var(--border-radius-lg)\` for cards
- Cards: white bg (\`var(--color-background-primary)\`), 0.5px border, radius-lg, padding 1rem 1.25rem
- Form elements (input, select, textarea, button, range slider) are pre-styled — write bare tags. Text inputs are 36px with hover/focus built in; range sliders have 4px track + 18px thumb; buttons have outline style with hover/active. Only add inline styles to override (e.g., different width).
- Buttons: pre-styled with transparent bg, 0.5px border-secondary, hover bg-secondary, active scale(0.98). If it triggers sendPrompt, append a ↗ arrow.
- **Round every displayed number.** JS float math leaks artifacts — \`0.1 + 0.2\` gives \`0.30000000000000004\`, \`7 * 1.1\` gives \`7.700000000000001\`. Any number that reaches the screen (slider readouts, stat card values, axis labels, data-point labels, tooltips, computed totals) must go through \`Math.round()\`, \`.toFixed(n)\`, or \`Intl.NumberFormat\`. Pick the precision that makes sense for the context — integers for counts, 1–2 decimals for percentages, \`toLocaleString()\` for currency. For range sliders, also set \`step="1"\` (or step="0.1" etc.) so the input itself emits round values.
- Spacing: use rem for vertical rhythm (1rem, 1.5rem, 2rem), px for component-internal gaps (8px, 12px, 16px)
- Box-shadows: none, except \`box-shadow: 0 0 0 Npx\` focus rings on inputs

### Metric cards
For summary numbers (revenue, count, percentage) — surface card with muted 13px label above, 24px/500 number below. \`background: var(--color-background-secondary)\`, no border, \`border-radius: var(--border-radius-md)\`, padding 1rem. Use in grids of 2-4 with \`gap: 12px\`. Distinct from raised cards (which have white bg + border).

### Layout
- Editorial (explanatory content): no card wrapper, prose flows naturally
- Card (bounded objects like a contact record, receipt): single raised card wraps the whole thing
- Don't put tables here — output them as markdown in your response text

按照 pi-generative-ui 官方的介绍,它的设计规范的提示词是完成从 claude.ai 中提取出来的,这一点作者已通过爬取 claude.ai 的源码验证过,因此如果大家想在自己的项目中应用这套提示词,这也是一个很好的衡量标准。

除此之外,在 pi-generative-ui 文档中也详细地讲解了 claude 实现生成式 UI 的详细步骤,作者下面要介绍的前端渲染方案就参考了里面的内容。

浏览器解析策略

前端在实现 HTML 流式渲染之前,需要先了解一下在浏览器中渲染一段 HTML 字符串时背后的策略,最简单的例子如下:

const tmp = document.createElement('div')
tmp.innerHTML = `
    <div style="font-size: 13px">
        HTML渲染
        <p>Hello World</p>
        <p style="font-size: 13
`

如果将一个不合法的 HTML 字符串通过 仍给浏览器,最终渲染出来的 DOM 树会是什么样的呢?

根据 WHATWG HTML 标准定义,浏览器在解析 HTML 时遵循 WHATWG HTML 标准中定义的解析算法,该算法本身是容错的。在解析过程中,标签结构会被自动补全,而语法错误的属性则会被直接忽略。因此,最终生成的 DOM 树往往是“修复后的结果”,而非原始字符串的直接映射。因此在浏览器中最终展示的 DOM 树如下:

如何实现 HTML 流式渲染?

Iframe 沙箱隔离

优势:

  • 接入非常简单,接入方使用没有任何心智负担。
  • iframe 天生具备隔离能力,无论是js、css、dom,都完全与宿主环境隔离。

缺点:

  • dom 严重割裂,弹窗只能在 iframe 内部展示,无法覆盖全局。

  • 通信困难

    iframe是独立的运行上下文,并且通常是以跨域的形式出现,与宿主通信困难体现在3点:

    • 方式困难

      仅可通过 postmessage 等方式,难以同步执行、直接调用。

    • 数据结构困难

      仅可传输Transferable Object

    • 效率低,内存限制大

      传输数据(除sharedArrayBuffer), 均需要做structuredClone。

  • 隐私限制

    对于跨域的场景, iframe 中的代码因跨域无法获取到用户隐私信息(cookie, localstory, indexDB)等。极大限制了功能实现。iframe 也难以感知到宿主环境状态。

在对话场景下 HTML 渲染出来的页面是纯展示页面,不存在复杂的交互,不需要和宿主环境通信,因此 Iframe 已经能满足场景需求。

Morphdom 增量渲染

浏览器在解析 HTML 时具备一定的容错能力,因此在一般场景下可以直接将 HTML 字符串交由解析器处理,而无需对其进行严格的预校验。在通过 SSE 获取 HTML 内容后,最直接的渲染方式是使用 innerHTML 将其插入到页面中。然而,innerHTML 在更新内容时会整体替换原有 DOM 子树,这不仅会导致已有状态(如输入框内容、滚动位置等)丢失,还可能引发明显的重绘与闪烁问题,影响用户体验,为了解决上述问题,可以引入 morphdom 进行增量渲染。

定义

morphdom 是一个轻量级的 DOM diff 库,它通过对比当前 DOM 与目标 DOM 的差异,仅对发生变化的节点进行最小化更新,从而避免整树替换带来的性能开销和状态丢失问题。与 React 等基于虚拟 DOM 的方案不同,morphdom 并不引入额外的抽象层,而是直接在真实 DOM 上执行 diff 与 patch 操作,在保持较高性能的同时简化了整体实现复杂度。

基本使用

使用 morphdom 非常简单。以下是一个基本示例,演示如何将一个 DOM 元素转换为另一个:

var morphdom = require('morphdom');
 
// 创建初始元素
var el1 = document.createElement('div');
el1.className = 'foo';
 
// 创建目标元素
var el2 = document.createElement('div');
el2.className = 'bar';
 
// 将 el1 转换为匹配 el2
morphdom(el1, el2);
 
// el1 现在拥有类 'bar'
console.log(el1.className); // 'bar'

除了传递 DOM 元素,也可以传递 HTML 字符串作为目标:

var morphdom = require('morphdom');
 
var container = document.getElementById('my-container');
container.innerHTML = '<div class="old-content">Hello World</div>';
 
// 用新内容更新容器
morphdom(container, '<div class="new-content">Hello Morphdom</div>');

这个就和当前的场景非常类似,但是这里有一个核心功能需要重点关注,当传递 HTML 字符串作为目标时,morphdom 会先调用 DOM API 将字符串转换为 DOM 元素,源码如下:

function morphdom(fromNode, toNode, options) {
    if (!options) {
      options = {};
    }

    if (typeof toNode === 'string') {
        var toNodeHtml = toNode;
        toNode = doc.createElement('html');
        toNode.innerHTML = toNodeHtml;
    }
    //...
 }

这也就是说 morphdom 也是支持传递不合规的 HTML 字符串,因为内部也会先通过浏览器的解析算法进行容错处理并转换为 DOM 元素。

手动执行 Script 脚本

在浏览器规范中,通过 innerHTMLinsertAdjacentHTML 等基于 HTML 解析器的方式插入的 <script> 标签不会被执行,因此需要等 HTML 内容生成完之后手动获取脚本并执行。

const runScripts = async (root: HTMLElement): Promise<void> => {
  const scripts = Array.from(root.querySelectorAll("script"));
  for (const old of scripts) {
    const s = root.ownerDocument.createElement("script");
    if (old.src) {
      // 外部脚本:等待加载完成再继续
      await new Promise<void>((resolve, reject) => {
        s.src = old.src;
        s.onload = () => resolve();
        s.onerror = () => reject(new Error(`Failed to load: ${old.src}`));
        old.parentNode!.replaceChild(s, old);
      });
    } else {
      s.textContent = old.textContent;
      old.parentNode!.replaceChild(s, old);
    }
  }
}

runScripts 方法里展示了 script 执行的大概流程,总结如下:

  • 获取所有 script 元素并遍历。
  • 通过 DOM API 创建并插入新的 script 元素。
  • 如果是加载外部脚本,需要等加载完毕之后再执行后面的脚本。

Demo

code-1311608451.cos.ap-guangzhou.myqcloud.com/agent%E7%94…

这个例子的 sse 数据直接用的 claude.ai 的,可以自己写个简单的 server。

成为AI全栈 - 第1课:后端到底是干嘛的?一张图拆解登录

作者 铁皮饭盒
2026年4月30日 09:58

本文目标: 先建立全局认知,再让AI写代码😁

一个常见的误解

很多人以为后端很难。

“要懂服务器、数据库、缓存、消息队列……”

“要学 Java、Python、Go……”

“要配环境、搭框架、写 SQL……”

其实,后端的核心逻辑非常简单。

今天用一张图,让你彻底理解后端是干嘛的。

图片

后端做什么?

前端发起接口请求。

后端: 接收请求  → 解析验证 →业务处理 → 数据库操作 → 返回响应

就这5步,没了。

以用户登录为例

用户在登录页面输入用户名和密码,点击“登录”。

第1步:接收请求

前端发送:POST/api/login,Body: { username: “张三”, password: “123456” }

后端路由匹配到 /api/login,交给登录处理函数。

// 监听请求
app.post('/api/login'(req, res) => {
  // 处理逻辑
})

第2步:解析验证

检查用户名和密码是否为空?格式是否正确?

如果为空 → 返回 400 “用户名和密码不能为空”,停止执行。

根据 username 去数据库查用户,用 bcrypt 比对密码。

密码错误 → 返回 401 “用户名或密码错误”。

第3步:业务处理

如果密码正确,生成 JWT Token(一个加密的字符串), 同时提取用户登录IP / 时间等。

第4步:操作数据

存储第三步数据到数据库。

第5步:返回响应

返回给前端:HTTP状态码: 200, 接口返回JSON数据

图片

前端拿到 Token,存储起来,后续请求带上它证明身份。 

nodejs + expres示例

🌟注意: 

1. 本文初期用js写后端, 帮助前端同学过渡, 后续会写py和java代码, 都是通过提示词让AI写😁

2. 下面代码大概看下就行, 后续文章会完善真读数据库, 反正都是AI直接生, 主要看提示词:基于express 生成登录接口 输入body参数(username,password), 返{"success":true,"data":{"token":"xxx","user":{"id":1,"name":"张三"}}} token要真验. 

下面是把提示词给deepseek生成的:

const express require('express');
const jwt require('jsonwebtoken');
const app express();

app.use(express.json());

// 模拟用户数据(实际应该查数据库)
const users = [
  { id: 1, username: 'zhangsan', password: '123456', name: '张三' },
  { id: 2, username: 'lisi', password: '123456', name: '李四' }
];

// 密钥(实际应放在环境变量)
const SECRET_KEY 'your-secret-key-123456';

app.post('/api/login', (req, res) => {
  const { username, password } = req.body;

  // 1. 验证用户名密码
  const user = users.find(u => u.username === username && u.password === password);

  if (!user) {
    return res.status(401).json({
      successfalse,
      error'用户名或密码错误'
    });
  }

  // 2. 生成真正的 JWT token
  const token = jwt.sign(
    { userId: user.id, username: user.username },
    SECRET_KEY,
    { expiresIn'7d' }  // 7天有效期
  );

  // 3. 返回结果
  res.json({
    successtrue,
    data: {
      token: token,
      user: {
        id: user.id,
        name: user.name
      }
    }
  });
});

app.listen(3000, () => {
  console.log('服务器已启动:http://localhost:3000');
});

核心结论

后端 = 接收请求  → 解析验证 →业务处理 → 数据库操作 → 返回响应

所有后端语言都在解决这5件事,只是语法不同。

学会用概念描述需求,AI 就能帮你生成任何语言(Bun.js / Python / Java)的代码。

后续

后端并没有那么复杂吧, 别着急, 今天先说这么多, 后续课程会继续拆解其他开发任务。

思考题

回想一下你平时调用的后端接口(比如获取用户列表、提交表单),试着用今天学的“5步法”拆解一下后端做了什么?

欢迎在评论区分享你的思考。

Openlayers调用ArcGis要素服务之一 ——要素查询 (/query)

作者 无心使然
2026年4月30日 09:22

2.1 Openlayers调用ArcGis要素服务之要素查询 (/query)

各个库版本如下:

    "ol": "^10.8.0",
    "proj4": "^2.20.8",
    "vue3-openlayers": "^12.2.2""ol-esri-style": "^4.1.1",

目录

2.1.1 介绍

要素服务是一种通过 Web 提供矢量要素数据访问和编辑功能的接口。它允许客户端(如 Web 应用、桌面软件、移动设备)对地理要素进行查询、编辑(增、删、改)、关联查询和统计分析。下面使用ArcGis官方服务作为示例直接调用(如果使用自己的私有服务,可能先要获取token)

2.1.2 核心特点

特性 说明
矢量数据服务 以要素(点、线、面)为核心,包含几何和属性信息
基于 REST API 通过 HTTP 请求访问,返回 JSON/GeoJSON 格式数据
支持编辑 支持添加、更新、删除要素(需要服务开启编辑能力)
支持查询 支持属性查询、空间查询、ID 查询、统计查询
支持关联 可关联查询关联表或附件的相关信息
事务管理 支持版本化和非版本化编辑,支持长事务

2.1.3 核心接口

操作 说明
/query 查询要素(属性、空间、统计)
/addFeatures 添加新要素
/updateFeatures 更新现有要素属性或几何
/deleteFeatures 删除要素
/applyEdits 批量提交增、删、改操作
/queryRelatedRecords 查询关联表中的记录
/queryAttachments 查询要素的附件
/addAttachment 为要素添加附件

2.1.4 服务信息查看

ArcGis官方服务4

50.png

上图中展示的就是要素服务的基本信息,可以看到Supported Operations:中有Query,说明支持查询,但是这个是服务的查询接口,进入后参数比图层的查询接口较少

51.png

图中可以看到有一个图层,进入图层查看图层信息(信息比较多,只截图了相对重要的部分)

52.png

53.png

红框内是一个唯一值渲染器,根据typdamage字段来分类,分为Inaccessible, Affected, Minor, Major, Destroyed五种具体类型(可以简单理解为图例)

如果我们使用ArcGis JS SDK加载可以直接使用FeatureLayer即可,但是如果使用Openlayers,一般还是使用图层的/query接口获取到要素的矢量信息,再使用VectorLayer渲染,至于渲染器(图例)我们可以:

  • 自定义显示
  • 使用ol-esri-style转换

54.png

可以看到Supported Operations:中还有有Add Features等操作,下文以Query为例,其他的操作同理,主要都是构造ArcGis Rest Api请求

2.1.5 Openlayers调用

自定义图例:

55.png

使用渲染器图例:

56.png

<template>
  <div class="map-page">
    <h1>OpenLayers - ArcGIS FeatureServer 调用</h1>
    <div class="info-panel">
      <h3>服务信息</h3>
      <p><strong>服务名称:</strong> CommercialDamageAssessment</p>
      <p>
        <strong>图层:</strong> Damage to Commercial Buildings (商业建筑损坏评估)
      </p>
      <p><strong>几何类型:</strong></p>
      <p>
        <strong>要素类型:</strong> Affected, Destroyed, Inaccessible, Major,
        Minor
      </p>
    </div>
    <div class="controls">
      <button @click="loadFeatures" :disabled="loading">
        {{ loading ? "加载中..." : "加载要素数据" }}
      </button>
      <button @click="clearFeatures" :disabled="loading">清除要素</button>
      <span v-if="featureCount" class="feature-count">
        已加载 {{ featureCount }} 个要素
      </span>
      <label class="toggle-switch">
        <input type="checkbox" v-model="useEsriStyle" @change="updateFeatureStyle" />
        <span class="slider"></span>
        <span class="label-text">使用 ESRI 样式</span>
      </label>
    </div>
    <div
      id="featureserver-ol-map"
      ref="mapContainer"
      class="map-container"
    ></div>
    <div v-if="error" class="error">{{ error }}</div>
    <div class="legend">
      <h4>图例</h4>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #41ff00"></span>
        <span>Affected (受影响)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ff0000"></span>
        <span>Destroyed ( destroyed)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #808080"></span>
        <span>Inaccessible (无法进入)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffae00"></span>
        <span>Major (重大损坏)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffea00"></span>
        <span>Minor (轻微损坏)</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import axios from "axios";
import Map from "ol/Map";
import View from "ol/View";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { EsriJSON } from "ol/format";
import { Style, Circle, Fill, Stroke } from "ol/style";
import { createStyleFunctionFromUrl } from "ol-esri-style";

const mapContainer = ref<HTMLDivElement>();
const loading = ref(false);
const featureCount = ref(0);
const error = ref("");
const useEsriStyle = ref(false);

// 存储 ESRI 样式函数
let esriStyleFunction: ((feature: any) => Style | Style[]) | null = null;
let customStyleFunction: ((feature: any) => Style | Style[]);

// 基于损坏类型的自定义样式函数
customStyleFunction = (feature) => {
  const damageType = feature.get("typdamage");
  let color = "#41ff00"; // 默认 - 受影响

  switch (damageType) {
    case "Destroyed":
      color = "#ff0000";
      break;
    case "Inaccessible":
      color = "#808080";
      break;
    case "Major":
      color = "#ffae00";
      break;
    case "Minor":
      color = "#ffea00";
      break;
    default:
      color = "#41ff00"; // 受影响
  }

  return new Style({
    image: new Circle({
      radius: 8,
      fill: new Fill({ color: color }),
      stroke: new Stroke({ color: "#000", width: 1 }),
    }),
  });
};

let map: Map | null = null;
const featureLayer = new VectorLayer({
  source: new VectorSource(),
  style: customStyleFunction,
});

// 从 ArcGIS FeatureServer 加载要素
const loadFeatures = async () => {
  loading.value = true;
  error.value = "";

  try {
    const response = await axios.get(
      "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0/query",
      {
        params: {
          f: "json",
          where: "1=1",
          returnGeometry: "true",
          outFields: "*",
          resultOffset: 0,
          resultRecordCount: 1000,
        },
      },
    );

    if (response.data.error) {
      throw new Error(response.data.error.message || "未知错误");
    }

    const format = new EsriJSON();
    const features = format.readFeatures(response.data, {
      featureProjection: "EPSG:3857",
    });

    const source = featureLayer.getSource();
    source?.clear();
    source?.addFeatures(features);

    featureCount.value = features.length;

    // 缩放到要素范围
    if (features.length > 0 && source) {
      const extent = source.getExtent();
      const view = map?.getView();
      if (view && extent) {
        view.fit(extent, { padding: [50, 50, 50, 50], duration: 1000 });
      }
    }
  } catch (err: any) {
    error.value = "加载失败: " + (err.message || "未知错误");
    console.error("Load features error:", err);
  } finally {
    loading.value = false;
  }
};

// 清除所有要素
const clearFeatures = () => {
  const source = featureLayer.getSource();
  source?.clear();
  featureCount.value = 0;
};

// 根据开关更新要素样式
const updateFeatureStyle = async () => {
  if (useEsriStyle.value) {
    // 从 FeatureServer 加载 ESRI 样式
    loading.value = true;
    try {
      esriStyleFunction = await createStyleFunctionFromUrl(
        "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0",
        "EPSG:3857"
      );
      featureLayer.setStyle(esriStyleFunction);
    } catch (err: any) {
      error.value = "加载 ESRI 样式失败: " + (err.message || "未知错误");
      console.error("Load ESRI style error:", err);
      // 回退到自定义样式
      featureLayer.setStyle(customStyleFunction);
    } finally {
      loading.value = false;
    }
  } else {
    // 使用自定义样式
    featureLayer.setStyle(customStyleFunction);
  }
};

onMounted(() => {
  const baseLayer = new TileLayer({});

  // 创建以伊利诺伊州为中心的地图(数据所在位置)
  map = new Map({
    target: mapContainer.value!,
    layers: [baseLayer, featureLayer],
    view: new View({
      center: [-10747000, 5162000], // Web Mercator 投影下伊利诺伊州的近似中心
      zoom: 7,
    }),
  });

  // 挂载时自动加载要素
  loadFeatures();
});

onUnmounted(() => {
  if (map) {
    map.setTarget(undefined);
    map = null;
  }
});
</script>

<style scoped>
.map-page {
  padding: 20px;
}

h1 {
  margin-bottom: 20px;
  color: #333;
}

.info-panel {
  background-color: #f8f9fa;
  padding: 15px;
  border-radius: 8px;
  margin-bottom: 15px;
  border-left: 4px solid #42b983;
}

.info-panel h3 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #2c3e50;
}

.info-panel p {
  margin: 5px 0;
  color: #555;
}

.controls {
  margin-bottom: 15px;
  display: flex;
  align-items: center;
  gap: 15px;
}

.controls button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.3s;
}

.controls button:hover:not(:disabled) {
  background-color: #3aa876;
}

.controls button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.feature-count {
  color: #666;
  font-size: 14px;
}

.toggle-switch {
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  position: relative;
}

.toggle-switch input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.toggle-switch .slider {
  position: relative;
  display: inline-block;
  width: 44px;
  height: 24px;
  background-color: #ccc;
  border-radius: 24px;
  transition: background-color 0.3s;
}

.toggle-switch .slider:before {
  content: "";
  position: absolute;
  height: 18px;
  width: 18px;
  left: 3px;
  bottom: 3px;
  background-color: white;
  border-radius: 50%;
  transition: transform 0.3s;
}

.toggle-switch input:checked + .slider {
  background-color: #42b983;
}

.toggle-switch input:checked + .slider:before {
  transform: translateX(20px);
}

.toggle-switch .label-text {
  font-size: 14px;
  color: #333;
  user-select: none;
}

.map-container {
  width: 100%;
  height: 600px;
  border: 2px solid #ddd;
  border-radius: 8px;
}

.error {
  margin-top: 10px;
  padding: 10px;
  background-color: #fee;
  color: #c33;
  border-radius: 4px;
}

.legend {
  margin-top: 15px;
  padding: 15px;
  background-color: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #ddd;
}

.legend h4 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #333;
}

.legend-item {
  display: flex;
  align-items: center;
  margin: 5px 0;
}

.legend-color {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  margin-right: 10px;
  border: 1px solid #000;
  display: inline-block;
}
</style>

2.1.6 Vue3-Openlayers用

自定义图例:

57.png

使用渲染器图例:

58.png

<template>
  <div class="map-page">
    <h1>Vue3-OpenLayers - ArcGIS FeatureServer 调用</h1>
    <div class="info-panel">
      <h3>服务信息</h3>
      <p><strong>服务名称:</strong> CommercialDamageAssessment</p>
      <p>
        <strong>图层:</strong> Damage to Commercial Buildings (商业建筑损坏评估)
      </p>
      <p><strong>几何类型:</strong></p>
      <p>
        <strong>要素类型:</strong> Affected, Destroyed, Inaccessible, Major,
        Minor
      </p>
    </div>
    <div class="controls">
      <button @click="loadFeatures" :disabled="loading">
        {{ loading ? "加载中..." : "加载要素数据" }}
      </button>
      <button @click="clearFeatures" :disabled="loading">清除要素</button>
      <span v-if="featureCount" class="feature-count">
        已加载 {{ featureCount }} 个要素
      </span>
      <label class="toggle-switch">
        <input
          type="checkbox"
          v-model="useEsriStyle"
          @change="updateFeatureStyle"
        />
        <span class="slider"></span>
        <span class="label-text">使用 ESRI 样式</span>
      </label>
    </div>

    <ol-map
      ref="mapRef"
      :loadTilesWhileAnimating="true"
      :loadTilesWhileInteracting="true"
      style="
        height: 600px;
        width: 100%;
        border: 2px solid #ddd;
        border-radius: 8px;
      "
    >
      <ol-view
        ref="viewRef"
        :center="center"
        :zoom="7"
        :projection="projection"
      />

      <ol-vector-layer>
        <ol-source-vector ref="vectorSourceRef" />
      </ol-vector-layer>
    </ol-map>

    <div v-if="error" class="error">{{ error }}</div>

    <div class="legend">
      <h4>图例</h4>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #41ff00"></span>
        <span>Affected (受影响)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ff0000"></span>
        <span>Destroyed (摧毁)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #808080"></span>
        <span>Inaccessible (无法进入)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffae00"></span>
        <span>Major (重大损坏)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffea00"></span>
        <span>Minor (轻微损坏)</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import axios from "axios";
import { EsriJSON } from "ol/format";
import { Style, Circle, Fill, Stroke } from "ol/style";
import { createStyleFunctionFromUrl } from "ol-esri-style";
import VectorLayer from "ol/layer/Vector";

const projection = "EPSG:3857";
const center = ref([-10747000, 5162000]); // Web Mercator 投影下伊利诺伊州的近似中心
const loading = ref(false);
const featureCount = ref(0);
const error = ref("");
const vectorSourceRef = ref();
const viewRef = ref();
const mapRef = ref();
const useEsriStyle = ref(false);

// 获取矢量图层
const getVectorLayer = () => {
  const map = mapRef.value?.map;
  if (!map) return null;
  const layers = map.getLayers().getArray();
  // 找到第一个 VectorLayer
  return layers.find((layer: any) => layer instanceof VectorLayer) as VectorLayer<any> | null;
};

// 存储 ESRI 样式函数
let esriStyleFunction: ((feature: any) => Style | Style[]) | null = null;
let customStyleFunction: (feature: any) => Style | Style[];

// 定义不同损坏类型的颜色映射
const damageColors: Record<string, string> = {
  Affected: "#41ff00",
  Destroyed: "#ff0000",
  Inaccessible: "#808080",
  Major: "#ffae00",
  Minor: "#ffea00",
};

// 基于损坏类型的自定义样式函数
customStyleFunction = (feature) => {
  const damageType = feature.get("typdamage");
  const color = damageColors[damageType] || "#41ff00";

  return new Style({
    image: new Circle({
      radius: 8,
      fill: new Fill({ color: color }),
      stroke: new Stroke({ color: "#000", width: 1 }),
    }),
  });
};

// 从 ArcGIS FeatureServer 加载要素
const loadFeatures = async () => {
  loading.value = true;
  error.value = "";

  try {
    const response = await axios.get(
      "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0/query",
      {
        params: {
          f: "json",
          where: "1=1",
          returnGeometry: "true",
          outFields: "*",
          resultOffset: 0,
          resultRecordCount: 1000,
        },
      },
    );

    if (response.data.error) {
      throw new Error(response.data.error.message || "未知错误");
    }

    const format = new EsriJSON();
    const features = format.readFeatures(response.data, {
      featureProjection: "EPSG:3857",
    });

    // 基于损坏类型应用自定义样式(用于非 ESRI 样式模式)
    features.forEach((feature) => {
      const damageType = feature.get("typdamage");
      const color = damageColors[damageType] || "#41ff00";
      feature.set("color", color);
    });

    const source = vectorSourceRef.value?.source;
    const layer = getVectorLayer();
    if (source) {
      source.clear();
      source.addFeatures(features);
    }

    // 根据当前开关状态应用样式
    if (layer) {
      if (useEsriStyle.value && esriStyleFunction) {
        layer.setStyle(esriStyleFunction);
      } else {
        layer.setStyle(customStyleFunction);
      }
    }

    featureCount.value = features.length;

    // 缩放到要素范围
    if (features.length > 0 && source) {
      const extent = source.getExtent();
      const view = viewRef.value?.view;
      if (view && extent) {
        view.fit(extent, { padding: [50, 50, 50, 50], duration: 1000 });
      }
    }
  } catch (err: any) {
    error.value = "加载失败: " + (err.message || "未知错误");
    console.error("Load features error:", err);
  } finally {
    loading.value = false;
  }
};

// 清除所有要素
const clearFeatures = () => {
  const source = vectorSourceRef.value?.source;
  if (source) {
    source.clear();
  }
  featureCount.value = 0;
};

// 根据开关更新要素样式
const updateFeatureStyle = async () => {
  const layer = getVectorLayer();
  if (!layer) {
    console.error("无法获取矢量图层");
    return;
  }

  if (useEsriStyle.value) {
    // 从 FeatureServer 加载 ESRI 样式
    loading.value = true;
    try {
      console.log("开始加载 ESRI 样式...");
      esriStyleFunction = await createStyleFunctionFromUrl(
        "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0",
        "EPSG:3857",
      );
      console.log("ESRI 样式加载成功,应用到图层");
      layer.setStyle(esriStyleFunction);
    } catch (err: any) {
      error.value = "加载 ESRI 样式失败: " + (err.message || "未知错误");
      console.error("Load ESRI style error:", err);
      // 回退到自定义样式
      layer.setStyle(customStyleFunction);
    } finally {
      loading.value = false;
    }
  } else {
    // 使用自定义样式
    layer.setStyle(customStyleFunction);
  }
};

// 挂载时自动加载要素
import { onMounted } from "vue";
onMounted(() => {
  loadFeatures();
});
</script>

<style scoped>
.map-page {
  padding: 20px;
}

h1 {
  margin-bottom: 20px;
  color: #333;
}

.info-panel {
  background-color: #f8f9fa;
  padding: 15px;
  border-radius: 8px;
  margin-bottom: 15px;
  border-left: 4px solid #42b983;
}

.info-panel h3 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #2c3e50;
}

.info-panel p {
  margin: 5px 0;
  color: #555;
}

.controls {
  margin-bottom: 15px;
  display: flex;
  align-items: center;
  gap: 15px;
}

.controls button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.3s;
}

.controls button:hover:not(:disabled) {
  background-color: #3aa876;
}

.controls button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.feature-count {
  color: #666;
  font-size: 14px;
}

.toggle-switch {
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  position: relative;
}

.toggle-switch input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.toggle-switch .slider {
  position: relative;
  display: inline-block;
  width: 44px;
  height: 24px;
  background-color: #ccc;
  border-radius: 24px;
  transition: background-color 0.3s;
}

.toggle-switch .slider:before {
  content: "";
  position: absolute;
  height: 18px;
  width: 18px;
  left: 3px;
  bottom: 3px;
  background-color: white;
  border-radius: 50%;
  transition: transform 0.3s;
}

.toggle-switch input:checked + .slider {
  background-color: #42b983;
}

.toggle-switch input:checked + .slider:before {
  transform: translateX(20px);
}

.toggle-switch .label-text {
  font-size: 14px;
  color: #333;
  user-select: none;
}

.error {
  margin-top: 10px;
  padding: 10px;
  background-color: #fee;
  color: #c33;
  border-radius: 4px;
}

.legend {
  margin-top: 15px;
  padding: 15px;
  background-color: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #ddd;
}

.legend h4 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #333;
}

.legend-item {
  display: flex;
  align-items: center;
  margin: 5px 0;
}

.legend-color {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  margin-right: 10px;
  border: 1px solid #000;
  display: inline-block;
}
</style>

❌
❌