普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月2日技术

【节点】[ViewVector节点]原理解析与实际应用

作者 SmalBox
2026年2月2日 10:28

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity的Shader Graph中,ViewVector节点是一个基础且重要的工具节点,它提供了从网格顶点或片元指向摄像机的方向向量。这个节点返回的是未标准化的原始向量值,保留了原始的长度信息,为着色器编程提供了更多的灵活性和控制能力。

ViewVector节点的核心概念

ViewVector节点计算的是从当前处理的顶点或片元位置指向摄像机位置的向量。这个向量在计算机图形学中被称为视图方向向量或视线向量,是许多光照和渲染效果的基础计算要素。

未标准化向量的特点

ViewVector节点输出的向量是未标准化的,这意味着向量保留了其原始的长度信息。这与标准化向量(单位向量)有显著区别:

  • 未标准化向量包含距离信息,向量的长度等于从表面点到摄像机的实际距离
  • 标准化向量的长度始终为1,方向信息被保留但距离信息丢失
  • 未标准化向量在需要距离计算的效果中特别有用,如雾效、距离衰减等

节点在渲染管线中的作用

在URP(Universal Render Pipeline)渲染流程中,ViewVector节点为着色器提供了关键的视角相关信息。它使得材质能够根据观察角度和距离产生动态变化,是实现许多高级视觉效果的基础。

端口配置与数据流

ViewVector节点仅包含一个输出端口,设计简洁但功能强大。

输出端口详解

  • 名称:Out
  • 方向:输出
  • 类型:Vector 3
  • 绑定:无
  • 描述:网格顶点/片元的View Vector

这个三维向量输出包含了X、Y、Z三个分量,分别代表了在选定坐标空间中的方向分量。向量的方向始终是从表面点指向摄像机,这一特性在所有坐标空间中保持一致。

数据流处理机制

当Shader Graph处理材质时,ViewVector节点会在每个顶点或片元着色器阶段计算相应的视图向量:

  • 在顶点着色器中,计算基于顶点位置
  • 在片元着色器中,计算基于插值后的片元位置
  • 计算基于当前渲染摄像机的变换矩阵

空间坐标系选择

ViewVector节点提供了四种不同的坐标空间选项,每种空间都有其特定的应用场景和计算特性。

Object空间

Object空间也称为模型空间或局部空间,这是3D模型自身的坐标系系统。

坐标系特性

  • 原点位于模型的轴心点(Pivot)
  • 坐标轴与模型的本地方向对齐
  • 不受模型变换(位置、旋转、缩放)影响

数学计算原理

在Object空间中,View Vector的计算基于以下公式:

ViewVector = inverse(UNITY_MATRIX_M) × (CameraPos - VertexPos)

应用场景

  • 需要基于模型自身方向的效果
  • 模型局部空间的特效
  • 与模型几何结构紧密相关的效果

示例应用

假设创建一个随着观察角度变化而变形的材质,在Object空间中使用ViewVector可以确保变形效果始终基于模型自身坐标系,不受模型在世界中旋转的影响。

View空间

View空间也称为摄像机空间或眼睛空间,这是以摄像机为原点的坐标系。

坐标系特性

  • 原点位于摄像机位置
  • Z轴指向摄像机的观察方向
  • X轴向右,Y轴向上

数学计算原理

在View空间中,View Vector的计算简化为:

ViewVector = -VertexViewPos

应用场景

  • 屏幕空间效果
  • 与摄像机直接相关的特效
  • 景深和雾效计算

示例应用

在实现边缘光效果时,使用View空间的ViewVector可以更直接地计算表面法线与视线角度,因为两者在同一坐标系中。

World空间

World空间是场景的全局坐标系,所有对象都以此空间为参考。

坐标系特性

  • 原点位于场景的世界原点
  • 坐标轴方向固定
  • 受模型变换影响

数学计算原理

在World空间中,View Vector计算为:

ViewVector = CameraWorldPos - VertexWorldPos

应用场景

  • 需要世界坐标一致性的效果
  • 全局光照计算
  • 环境效果如雾、大气散射

示例应用

创建距离雾效时,使用World空间的ViewVector可以准确计算表面点与摄像机的实际距离,实现基于真实距离的雾浓度变化。

Tangent空间

Tangent空间是基于表面法线和切线定义的局部坐标系。

坐标系特性

  • 原点位于表面点
  • Z轴与表面法线方向一致
  • X轴与切线方向一致,Y轴与副切线方向一致

数学计算原理

在Tangent空间中,View Vector需要通过变换矩阵计算:

ViewVector = TBN × (CameraWorldPos - VertexWorldPos)

其中TBN是从世界空间到切线空间的变换矩阵

应用场景

  • 法线贴图相关效果
  • 各向异性材质
  • 复杂的表面光照模型

示例应用

在实现各向异性高光时,使用Tangent空间的ViewVector可以确保高光方向正确跟随表面方向,不受模型整体旋转影响。

实际应用案例

基础边缘光效果

边缘光(Rim Light)是ViewVector节点最典型的应用之一,它能够在物体边缘创建发光效果。

实现原理

边缘光效果基于表面法线与视线方向的夹角。当表面几乎垂直于视线方向时(即边缘区域),应用较强的光照;当表面正对摄像机时,效果减弱。

Shader Graph设置步骤

  • 添加ViewVector节点,空间设置为World
  • 添加Normal Vector节点,空间设置为World
  • 使用Dot Product节点计算法线与视线方向的点积
  • 使用One Minus节点反转结果(使边缘值大,中心值小)
  • 使用Power节点控制边缘宽度
  • 使用Color节点定义边缘光颜色
  • 使用Multiply和Add节点混合到最终颜色

参数调节技巧

  • 点积结果控制边缘位置:值越小边缘越明显
  • Power节点指数控制边缘锐度:值越大边缘越锐利
  • 颜色强度控制发光强度

基于距离的透明效果

利用ViewVector的未标准化特性,可以创建基于距离的透明渐变效果。

实现原理

通过计算ViewVector的长度获取表面点与摄像机的实际距离,根据距离值控制材质透明度。

Shader Graph设置步骤

  • 添加ViewVector节点,空间设置为World
  • 使用Length节点计算向量长度(距离)
  • 使用Remap节点将距离映射到0-1范围
  • 使用Saturate节点钳制数值范围
  • 将结果连接到Alpha通道

高级应用变体

  • 非线性距离衰减:使用曲线节点控制透明度变化
  • 距离阈值:使用Step或SmoothStep节点创建硬边或柔边过渡
  • 多层透明度:结合多个距离区间创建复杂透明效果

反射强度控制

根据观察角度动态调整反射强度,模拟菲涅尔效应。

实现原理

菲涅尔效应描述了表面反射率随观察角度变化的物理现象。在掠射角(视线与表面几乎平行)时反射最强,正对表面时反射最弱。

Shader Graph设置步骤

  • 添加ViewVector节点和Normal Vector节点
  • 使用Dot Product节点计算两者点积
  • 使用One Minus节点反转结果
  • 使用Power节点控制菲涅尔效应强度
  • 将结果作为反射强度的乘数

物理准确性考虑

  • 使用Schlick近似公式提高物理准确性
  • 考虑材质折射率对菲涅尔效应的影响
  • 结合粗糙度调整菲涅尔效应范围

各向异性材质模拟

各向异性材质在不同方向上表现出不同的光学特性,如拉丝金属、光盘表面等。

实现原理

使用Tangent空间的ViewVector,结合切线方向计算各向异性高光。

Shader Graph设置步骤

  • 添加ViewVector节点,空间设置为Tangent
  • 使用Tangent Vector节点获取切线方向
  • 基于ViewVector的X分量和切线方向计算各向异性高光
  • 使用Noise节点或Texture节点添加方向性纹理
  • 结合光照模型计算最终高光

高级技巧

  • 使用多个切线方向模拟复杂各向异性
  • 结合视差效果增强立体感
  • 使用时间变量创建动态各向异性效果

性能优化与最佳实践

坐标空间选择策略

不同的坐标空间选择对性能有直接影响,需要根据具体需求权衡。

性能考虑因素

  • Object空间:需要矩阵逆运算,计算成本较高
  • View空间:计算简单,性能最佳
  • World空间:需要世界位置计算,中等成本
  • Tangent空间:需要TBN矩阵计算,成本最高

选择指南

  • 优先考虑View空间,特别是屏幕空间效果
  • 需要世界一致性时选择World空间
  • 仅在必要时使用Object或Tangent空间

计算优化技巧

向量标准化控制

由于ViewVector节点输出未标准化向量,在不需要距离信息时应手动标准化:

  • 添加Normalize节点标准化向量
  • 仅在需要距离信息时保留原始向量

节点组合优化

  • 避免重复计算相同空间下的ViewVector
  • 使用Branch节点避免不必要的计算
  • 合理使用LOD(Level of Detail)控制计算复杂度

平台兼容性考虑

移动平台优化

  • 避免在片元着色器中频繁使用复杂ViewVector计算
  • 在顶点着色器中预计算并插值
  • 使用精度修饰符优化计算(half、fixed)

跨平台一致性

  • 测试不同坐标系在不同平台上的行为
  • 注意左右手坐标系差异
  • 验证矩阵变换的一致性

高级技术与创意应用

动态变形效果

结合ViewVector与顶点偏移,创建基于观察角度的动态几何变形。

实现方法

  • 使用ViewVector方向驱动顶点偏移
  • 结合噪声纹理增加自然感
  • 使用距离控制变形强度

应用场景

  • 鼠标悬停效果
  • 魔法力场变形
  • 热浪扭曲效果

高级光照模型

将ViewVector集成到自定义光照模型中,实现更真实的材质表现。

镜面反射改进

  • 使用ViewVector计算半角向量
  • 实现各向异性高光模型
  • 创建基于视角的镜面反射衰减

次表面散射模拟

  • 使用ViewVector计算背面透光
  • 结合厚度图实现真实散射
  • 创建皮肤、蜡质等材质效果

投影与阴影技术

利用ViewVector增强投影和阴影效果的真实感。

柔和阴影优化

  • 基于视角角度调整阴影柔和度
  • 实现透视正确的阴影变形
  • 创建接触硬化阴影效果

投影纹理改进

  • 使用ViewVector校正投影透视
  • 实现基于视角的投影淡化
  • 创建全息投影效果

故障排除与常见问题

向量方向错误

问题表现

效果方向与预期相反或错乱。

解决方案

  • 检查坐标系选择是否正确
  • 验证向量计算顺序(指向摄像机)
  • 检查摄像机变换矩阵

性能问题

问题表现

着色器编译缓慢或运行时帧率下降。

优化策略

  • 简化不必要的ViewVector计算
  • 在低端设备上降低计算精度
  • 使用更高效的坐标空间

平台特异性问题

问题表现

在不同平台或渲染管线上效果不一致。

解决思路

  • 测试所有目标平台
  • 使用URP内置函数确保兼容性
  • 检查渲染管线设置和配置

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

1个月双线作战:AI公文助手从0到1开发,与国产化适配的"踩坑"全记录

作者 徐小夕
2026年2月2日 10:18

全职创业2年零1个月,接下来和大家分享一下我们最近做了AI协同产品 JitWord 的研发历程。

为什么要在1个月内攻坚两个硬骨头?

说实话,启动这次迭代前,团队内部是有分歧的。

2026-01-30 21.09.31.gif

一方面,AI公文助手 是很多政企客户反复提的需求——他们想要 Word 那种严谨的公文排版,又想要AI的生成能力,还要能在线协同;另一方面,国产化适配是信创大背景下的必选项,涉及国产操作系统、国产浏览器、甚至国产芯片的兼容性问题。

这两个需求,任何一个单独做都要扒层皮。但市场不等人,我们决定在1个月内"双线作战"。

这篇文章记录了我们如何从0搭建AI公文助手模块,以及在国产化适配过程中遇到的那些让人头秃的坑。希望能给同样面临信创改造或富文本技术选型的同学一些参考。


JitWord 是什么?(如果你第一次听说)

2026-01-29 11.02.48.gif

简单给新朋友介绍一下。JitWord 是我们团队开发的协同AI文档引擎,定位是"让Web文档拥有桌面级体验",打造“云端Office”办公体验。

核心能力包括:

  • 多人实时协同:基于CRDT算法,支持Word级别的冲突解决
  • AI辅助创作:内置AI续写、润色、总结,支持自定义Prompt
  • 数学公式渲染:自研公式引擎,支持LaTeX到Word的无损转换(之前文章有详细讲过)
  • 一键导出Word:不只是PDF,是真正的.docx格式,导出后还能在Office里二次编辑
项目 描述
产品名称 JitWord 协同AI文档
技术栈 Vue3 + NestJS + CRDT + WebSocket
核心功能 实时协同、AI写作、公文处理、Word导出
适用场景 企业文档中台、科研协作、政务办公
版本状态 V2.1(AI公文助手 + 国产化适配版)

最近我们也开源了一版sdk,大家可以轻松本地使用和集成:

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


第一部分:AI公文助手从0到1

1.1 需求拆解:公文场景的残酷现实

做传统富文本编辑器的朋友可能不知道,公文排版是中文排版的地狱模式

  • 红头文件:要严格遵循 GB/T 9704-2012 国家标准,版头、发文字号、签发人都有固定位置
  • 多层嵌套结构:一、(一)、1.(1)、①,这五种层级格式不能乱
  • 表格与附件:公文里的表格必须能跨页重复表头,附件说明有特定格式
  • 严格的页面设置:A4纸张、上白边37mm±1mm、下白边35mm±1mm...

我们调研了市面上几乎所有的Web Office方案,发现要么是简单的表单模板(灵活性不够),要么是把PDF转图片(无法二次编辑)。所以决定自己实现一套结构化公文引擎

image.png

1.2 技术架构:如何把AI塞进公文流程?

我们采用了 模板引擎 + AI生成 + 人工调整 的三段式架构:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ 公文模板库  │────▶│  AI解析层   │────▶│ 编辑渲染层  │
│ (.docx解析) │     │ (LLM+规则)  │     │ (结构化编辑)│
└─────────────┘     └─────────────┘     └─────────────┘
       │                                        │
       │    ┌─────────────┐                     │
       │◄───│  导出引擎   │◄────────────────────┘
       │    │(Word/PDF)   │
       │    └─────────────┘

关键技术决策:

  • 模板解析:目前了复用 mammoth.js,但是它的样式映射太粗粒度,后面规划重写。
  • AI提示词工程:公文写作不是 Creative Writing,而是 Constraint Writing。我们让AI先分析模板结构,再填充内容,最后做格式合规性检查(比如检查发文字号是否符合"国发〔2024〕1号"这种格式)。
  • 编辑器选型:基础还是ProseMirror,但重写了NodeSpec来支持"公文块"(OfficialBlock)概念。每个公文块是一个不可随意拆分的逻辑单元,比如"主送机关"是一个块,"正文"是一个块。

1.3 核心代码:公文模板的JSON Schema设计

这是我们定义的公文结构规范(节选):

// types/document.ts
export interface OfficialDocument {
  version: 'GB/T-9704-2012';
  header: {
    issuingBody: string;     // 发文机关
    documentNumber: string;  // 发文字号
    urgencyLevel: '特急' | '加急' | '平急';
  };
  body: OfficialBlock[];  // 正文,由多个公文块组成
  attachments?: Attachment[];
}

export interface OfficialBlock {
  type: 'redHeader' | 'recipient' | 'text' | 'level1' | 'level2' | 'table';
  content: string | TableContent;
  style: {
    fontFamily: '仿宋_GB2312' | '黑体' | '楷体_GB2312';  // 信创字体
    fontSize: number;       // 三号=16pt,小三=15pt...
    lineHeight: number;     // 28-30磅固定值
  };
}

遇到的坑: 仿宋_GB2312 这个字体在Mac和Linux上表现差异巨大。Windows上看着好好的文档,在国产系统(基于Linux)上打开后行高会乱掉。解决方案是用CSS的line-height: fixed + 字体fallback栈,并且在导出Word时重新计算行高。

1.4 AI生成流程的优化

image.png

最初我们直接把"写一篇关于XX的通知"扔给GPT-5,结果生成的内容总是太口语化,而且格式经常出错。

优化后的流程是:

  1. 模板匹配:先根据用户选择的公文类型(通知、通报、请示、报告等),加载对应的Prompt模板
  2. 结构化生成:要求AI输出JSON格式,而不是Markdown
  3. 规则校验层:用正则表达式校验公文要素是否齐全(比如通知必须有"特此通知"结尾,请示必须有"妥否,请批复")
// ai/officialWriter.ts
async function generateOfficialDoc(params: GenerationParams) {
  const template = loadTemplate(params.type);
  
  const structuredPrompt = `
  你是一个严谨的公文写作助手。请根据以下信息,生成符合GB/T 9704-2012的公文内容。
  必须输出为JSON格式,字段定义如下:${JSON_SCHEMA}
  
  用户输入:${params.topic}
  要求:${params.requirements}
  `;
  
  const raw = await llm.generate(structuredPrompt);
  const doc = JSON.parse(raw);
  
  // 规则校验
  if (!validateOfficialFormat(doc)) {
    throw new Error('生成内容不符合公文规范,请重试');
  }
  
  return doc;
}

效果: 生成一份标准通知的时间从人工30分钟缩短到AI 10秒 + 人工审核2分钟,效率提升90%


第二部分:国产化适配的"踩坑"全记录

image.png

如果说AI公文助手是"从0到1的创造",那国产化适配就是"从能用到好用的磨砺"。

我们的目标是让 JitWord 能在统信UOS麒麟OS等国产操作系统,以及360安全浏览器奇安信可信浏览器等国产Chromium内核浏览器上稳定运行。

2.1 踩坑一:WebSocket连接的诡异断开

现象: 在麒麟V10系统上,协同编辑总是过几分钟就断开,提示"网络异常",但用户明明能正常刷网页。

排查过程:

  1. 首先排查Nginx配置,以为是proxy_read_timeout太短,改成3600秒,无效。
  2. 检查浏览器Network面板,发现国产浏览器的某些安全策略会主动断开静默的WebSocket连接
  3. 最后发现是奇安信可信浏览器内置了"长连接保护"策略,超过5分钟没有数据交互就会自动断开。

解决方案:

// 心跳机制加强版
export class ReliableWebSocket {
  private ws: WebSocket;
  private heartbeatInterval: NodeJS.Timer;
  
  // 国产浏览器的心跳间隔要更短
  private heartbeatDelay = isDomesticBrowser() ? 10000 : 30000; 
  
  connect() {
    this.ws = new WebSocket(url);
    
    this.heartbeatInterval = setInterval(() => {
      // 发送空操作或ping帧,保持连接活性
      this.send({ type: 'heartbeat', timestamp: Date.now() });
    }, this.heartbeatDelay);
  }
}

2.2 踩坑二:富文本编辑器的输入法冲突

协同.png

这是让我最想骂街的坑。

现象: 在统信UOS + 搜狗输入法(国产版)下,输入中文时,编辑器光标会乱跳,甚至吞字。

根因分析: 国产操作系统的输入法架构和Windows差异很大。我们用的ProseMirror在处理beforeinput事件时,和一些国产输入法的Composition事件冲突。具体表现为:输入法开始合成(compositionstart)时,ProseMirror尝试更新选区,导致输入法丢失了上下文。

解决方案: 不得不patch了ProseMirror的view模块,在合成输入期间暂停所有远程协同更新

// patches/prosemirror-view.ts
let isComposing = false;

editorView.dom.addEventListener('compositionstart', () => {
  isComposing = true;
  // 暂停接收远程操作,避免光标跳动
  collaboration.pauseSync();
});

editorView.dom.addEventListener('compositionend', (e) => {
  isComposing = false;
  const finalData = e.data;
  
  // 延迟恢复同步,等待输入法插入完成
  setTimeout(() => {
    collaboration.resumeSync();
  }, 100);
});

2.3 踩坑三:字体渲染与导出

现象: 同样的"仿宋",在Windows上叫"仿宋",在国产系统上可能叫"FangSong"、"Fangsong"、或者"Source Han Serif CN"。公文要求必须用仿宋_GB2312,但这个字体在某些国产系统上没有预装。

解决方案三部曲:

  1. 前端降级方案:CSS设置font-family: 'FangSong_GB2312', 'Source Han Serif CN', 'Noto Serif CJK SC', serif;
  2. 后端字体嵌入:导出Word时,如果检测目标系统缺少字体,用Java操作POI把字体文件嵌入到生成的docx中
  3. Web字体预加载:在编辑器初始化时,异步加载WOFF2格式的仿宋字体文件,确保所见即所得
// 导出Word时的字体嵌入逻辑(Java实现)
public void embedFonts(XWPFDocument doc, String[] requiredFonts) {
    for (String fontName : requiredFonts) {
        if (!systemHasFont(fontName)) {
            InputStream fontStream = getClass().getResourceAsStream("/fonts/" + fontName + ".ttf");
            doc.embedFont(fontName, fontStream);
        }
    }
}

性能优化:让国产硬件也能流畅运行

说实话,很多国产终端的硬件配置(特别是信创笔记本)不如主流Windows本。我们在1个月内做了以下针对性优化:

虚拟滚动 + 分层渲染

公文通常很长(几十页很正常),我们在ProseMirror基础上实现了虚拟滚动,只渲染可视区域内的DOM节点。同时把静态内容(已经定稿的段落)标记为contenteditable: false,减少MutationObserver的开销。

AI生成的防抖处理

02.gif

当AI生成大段文本时,不能直接一次性插入编辑器(会导致卡顿)。我们改成了逐句插入 + requestAnimationFrame

async function insertAIGeneratedContent(content: string) {
  const sentences = content.split(/([。!?])/);  // 按句分割
  
  for (let i = 0; i < sentences.length; i += 2) {
    const sentence = sentences[i] + (sentences[i+1] || '');
    
    await new Promise(resolve => {
      requestAnimationFrame(() => {
        editor.insertText(sentence);
        resolve(null);
      });
    });
    
    // 每5句暂停一下,让UI线程喘息
    if (i % 5 === 0) await sleep(10);
  }
}

最终效果与场景展示

2026-01-15 10.52.12.gif

公文助手实际应用场景

jitword-gw.png

场景1:政府机关的请示报告

  • 输入:"关于申请信息化建设经费的请示"
  • AI生成:自动匹配"请示"模板,生成红头、发文字号、正文、结尾语
  • 人工调整:只需填写具体金额和项目明细
  • 导出:直接生成符合省级办公厅格式要求的Word文件

场景2:国企的发文通知

  • 协同:办公室主任起草,分管领导在线批注修改,法务审核合规性
  • 留痕:所有修改记录保存,满足公文归档的审计要求
  • 套红:一键生成带红色抬头的正式公文版式

国产化适配验证环境

我们在以下环境完成了完整测试:

  • 操作系统:统信UOS 1060、麒麟V10 SP1、中科方德
  • CPU架构:x86_64、ARM64(鲲鹏920、飞腾2000)
  • 浏览器:360安全浏览器v13、奇安信可信浏览器、火狐中国浏览器

技术总结与反思

这1个月的"双线作战",最大的收获不是功能本身,而是对信创环境下的Web开发有了更深理解:

  1. 不要相信浏览器的UserAgent:国产浏览器都伪装成Chrome,但行为可能完全不同。必须做特性检测(feature detection)而非浏览器嗅探。

  2. 富文本编辑器要"防御性编程":输入法、选区、滚动这些在标准浏览器上稳定的功能,在特殊环境下可能有各种奇奇怪怪的表现。代码要更保守,try-catch要更密集。

  3. AI生成必须后接规则校验:大模型有幻觉,公文又是极其严谨的体裁。AI负责"快",规则引擎负责"准",两者结合才能实用。

  4. 字体和排版是信创隐形大坑:中西文混排、行高计算、字体回退,这些细节决定了产品看起来是"业余demo"还是"正式产品"。


如何集成和体验?

JitWord 目前主要面向企业级用户开发者集成

  • 在线演示:如果你想看看AI公文助手的实际效果,可以访问我们的演示环境(文中不放链接了,掘金私信我或评论获取)
  • 私有化部署:支持国产服务器私有化部署,适配信创环境
  • SDK集成:提供JavaScript SDK,可以Embed到你的业务系统中

如果你也是正在做信创改造的技术负责人,或者需要公文处理能力的产品经理,欢迎评论区交流踩坑经验。国产化这条路,大家互相搀扶才能走得快一点。

我们也开源了一版sdk,大家可以轻松本地使用和集成:

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


未来规划

这1个月的攻坚只是开始,接下来的 roadmap 包括:

  • 智能校对:接入NLP模型,自动检查公文中的政治术语准确性、数字逻辑一致性(比如"2024年"不能写成"2024年度"在某些语境下)
  • 手写签批:对接国产手写板和签章系统,实现移动端批公文
  • 更多公文类型:从现在的通知、请示、报告,扩展到会议纪要、函、议案等15种法定公文

技术栈彩蛋 🎯

如果你在关注相关技术方向,这是我们用的核心栈,也是目前市面上比较热门的技术方向:

  • Vue3 + Vite + TypeScript(前端)
  • NestJS + TypeORM(后端,支持国产数据库适配)🚀
  • ProseMirror(编辑器内核,深度定制)
  • Yjs(CRDT协同算法)🧩
  • Docker + K8s(部署)

觉得有用的话,点个赞或者收藏吧。信创适配这条路很长,希望这篇文章能帮你少走些弯路。有任何技术问题,评论区留言,我看到都会回复。

学习Three.js--烟花

2026年2月2日 10:16

学习Three.js--烟花

前置核心说明

开发目标

基于Three.js实现带拖尾渐隐效果的3D烟花,核心能力包括:

  1. 鼠标点击任意位置发射烟花,同时支持自动定时发射;
  2. 烟花粒子具备物理运动特性(重力+空气阻力),模拟真实爆炸扩散;
  3. 粒子带拖尾效果,拖尾从亮到暗渐隐,烟花整体采用发光叠加效果;
  4. 夜空雾效氛围营造,适配全屏黑色背景,视觉效果更逼真。

9529c66d-56b1-47c3-9d71-9736f8ca88ba.png

核心技术栈

技术点 作用
THREE.BufferGeometry 手动构建顶点/颜色缓冲区,高效管理大量粒子(性能优于普通几何体)
THREE.LineSegments 基于顶点数据绘制线段,实现粒子拖尾效果(每段拖尾由多条短线组成)
粒子物理系统 自定义粒子位置、速度、生命周期,模拟重力、空气阻力等物理现象
顶点颜色(vertexColors: true 为每个粒子顶点单独设置颜色,实现拖尾渐隐、粒子发光效果
加法混合(AdditiveBlending 粒子颜色叠加发光,模拟烟花的明亮光晕效果
THREE.Fog 营造夜空雾效,远处粒子渐隐于深色背景,提升空间层次感
屏幕坐标→世界坐标转换 实现鼠标点击位置与3D场景坐标的映射,点击哪里发射哪里
HSL颜色模式 统一烟花色调,生成协调且鲜艳的烟花颜色,避免色彩杂乱

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/雾效)

1.1 核心代码
// 1. 场景初始化(添加雾效营造夜空氛围)
const scene = new THREE.Scene();
// 雾效:颜色#000022(深夜空蓝),近裁切面100,远裁切面800
scene.fog = new THREE.Fog(0x000022, 100, 800);

// 2. 透视相机(模拟人眼视角,适配3D场景)
const camera = new THREE.PerspectiveCamera(
  60, // 视角(FOV)
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近裁切面
  2000 // 远裁切面
);
camera.position.set(0, 50, 300); // 高位俯视视角,清晰观察烟花爆炸

// 3. 渲染器(抗锯齿+透明背景)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 高清适配
document.body.appendChild(renderer.domElement);

// 4. 环境光(微弱补光,避免纯黑,不影响烟花视觉)
scene.add(new THREE.AmbientLight(0x222222));
1.2 关键说明
  • 雾效配置THREE.Fog 是线性雾,参数分别为「雾颜色」「开始生效距离」「完全遮蔽距离」,这里用深夜空蓝,让远处烟花粒子自然融入背景;
  • 渲染器alpha: true:开启透明背景,配合HTML的body { background: #000; },实现纯黑夜空效果,避免渲染器默认白色背景;
  • 相机位置(0, 50, 300) 采用高位俯视,既可以看到烟花的3D扩散效果,又不会让视角过于陡峭,符合人眼观察烟花的习惯。

步骤2:核心粒子系统初始化

这是烟花效果的核心,需要手动构建粒子的顶点、颜色缓冲区,以及存储粒子物理状态的数据结构。

2.1 核心代码
// 粒子系统核心参数(集中管理,方便调整)
const MAX_PARTICLES = 8000; // 最大粒子数(限制性能开销)
const TRAIL_LENGTH = 4; // 每个粒子的拖尾长度(4个顶点=3段短线)
const totalVertices = MAX_PARTICLES * TRAIL_LENGTH; // 总顶点数

// 1. 初始化缓冲区数据(Float32Array存储顶点/颜色数据,高效)
const positions = new Float32Array(totalVertices * 3); // 顶点坐标:每个顶点3个值(x,y,z)
const colors = new Float32Array(totalVertices * 3); // 顶点颜色:每个顶点3个值(r,g,b)
const alives = new Uint8Array(MAX_PARTICLES); // 粒子存活状态(0=空闲,1=活跃)
const particleData = []; // 存储每个粒子的完整物理状态(自定义数据结构)

// 2. 初始化每个粒子的物理状态数据
for (let i = 0; i < MAX_PARTICLES; i++) {
  particleData.push({
    pos: new THREE.Vector3(), // 当前位置
    vel: new THREE.Vector3(), // 当前速度
    color: new THREE.Color(), // 粒子颜色
    size: 0, // 粒子尺寸(此处用于后续扩展,当前拖尾效果暂未用到)
    life: 0, // 粒子生命周期(0~1,1=消亡)
    history: [ // 拖尾历史位置(FIFO队列,存储最近TRAIL_LENGTH个位置)
      new THREE.Vector3(),
      new THREE.Vector3(),
      new THREE.Vector3(),
      new THREE.Vector3()
    ]
  });
}

// 3. 构建BufferGeometry(手动绑定缓冲区数据)
const geometry = new THREE.BufferGeometry();
// 绑定顶点位置缓冲区
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
// 绑定顶点颜色缓冲区
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

// 4. 自定义材质(支持顶点颜色+拖尾渐隐+发光叠加)
const material = new THREE.LineBasicMaterial({
  vertexColors: true, // 启用顶点颜色(优先使用缓冲区的color数据,而非材质统一颜色)
  transparent: true, // 启用透明(支持渐隐效果)
  depthWrite: false, // 关闭深度写入(避免粒子之间互相遮挡,提升叠加效果)
  blending: THREE.AdditiveBlending // 加法混合(颜色叠加,实现烟花发光效果)
});

// 5. 创建LineSegments(基于顶点数据绘制拖尾线段)
const lines = new THREE.LineSegments(geometry, material);
scene.add(lines);
2.2 关键技术点解析
  • 缓冲区数据(Float32ArrayBufferGeometry 是Three.js中高效的几何体类型,直接操作二进制数组存储顶点数据,比普通Geometry性能更高,适合大量粒子场景;
  • 拖尾实现原理:每个粒子存储TRAIL_LENGTH个历史位置(这里是4个),通过LineSegments连接相邻位置,形成3段短线,视觉上就是拖尾;
  • alives数组:标记粒子是否空闲,避免重复创建/销毁粒子(对象池模式),提升性能,Uint8Array 占用内存小,适合存储二值状态;
  • 材质核心参数
    • AdditiveBlending:加法混合,每个粒子的颜色会与背景和其他粒子颜色叠加,越密集的地方越亮,完美模拟烟花的发光光晕;
    • depthWrite: false:关闭深度写入,粒子之间不会互相遮挡,所有粒子都能正常叠加发光,避免拖尾被遮挡的问题;
    • vertexColors: true:启用后,材质会忽略自身的color参数,转而使用BufferGeometry中的color缓冲区数据,实现每个顶点的独立颜色。

步骤3:烟花发射函数(分配粒子+初始化物理状态)

3.1 核心代码
/**
 * 发射烟花函数
 * @param {Number} x - 发射位置X
 * @param {Number} y - 发射位置Y
 * @param {Number} z - 发射位置Z
 * @param {Boolean} isChild - 是否为子粒子(用于二次爆炸,当前暂为单级爆炸)
 */
function launchFirework(x, y, z, isChild = false) {
  // 1. 确定粒子数量(子粒子少,主烟花粒子多,模拟真实爆炸)
  const count = isChild ? (30 + Math.random() * 60) : (300 + Math.random() * 400);
  // 2. 确定烟花色调(HSL模式,保证同批次烟花颜色协调)
  const hue = isChild ? (Math.random() * 0.1 + 0.95) : (Math.random() * 0.3);

  for (let p = 0; p < count; p++) {
    // 3. 查找空闲粒子(对象池模式,复用空闲粒子,提升性能)
    let idx = -1;
    for (let i = 0; i < MAX_PARTICLES; i++) {
      if (!alives[i]) {
        idx = i;
        break;
      }
    }
    if (idx === -1) break; // 无空闲粒子,终止本次发射

    const particle = particleData[idx];
    
    // 4. 初始化粒子初始位置
    particle.pos.set(x, y, z);
    
    // 5. 初始化粒子爆炸速度(极坐标转换,360°扩散)
    const baseSpeed = isChild ? (10 + Math.random() * 15) : (30 + Math.random() * 30);
    const theta = Math.random() * Math.PI * 2; // 水平方向角度(0~360°)
    const phi = Math.random() * Math.PI; // 垂直方向角度(0~180°)
    particle.vel.set(
      baseSpeed * Math.sin(phi) * Math.cos(theta),
      baseSpeed * Math.sin(phi) * Math.sin(theta),
      baseSpeed * Math.cos(phi)
    ).add(new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8)); // 加入微小扰动,避免扩散过于规则

    // 6. 初始化粒子颜色和尺寸(HSL模式,鲜艳且协调)
    particle.color.setHSL(hue, 0.85, isChild ? 0.9 : 0.65); // 色相统一,亮度/饱和度微调
    particle.size = isChild ? (0.3 + Math.random() * 0.7) : (0.4 + Math.random() * 0.9);
    particle.life = 0; // 重置生命周期
    
    // 7. 初始化粒子拖尾历史位置(所有历史位置与当前位置一致,避免初始拖尾偏移)
    for (let h = 0; h < TRAIL_LENGTH; h++) {
      particle.history[h].copy(particle.pos);
    }

    // 8. 标记粒子为活跃状态
    alives[idx] = 1;
  }
}
3.2 关键技术点解析
  • 对象池模式:通过alives数组查找空闲粒子,复用已有粒子对象,避免频繁创建/销毁对象带来的性能开销,这是大量粒子场景的最佳实践;
  • 极坐标转换:使用theta(水平角度)和phi(垂直角度)生成3D空间中的扩散速度,实现烟花向四面八方均匀爆炸的效果;
  • HSL颜色模式setHSL(hue, saturation, lightness) 中,hue(色相)决定烟花的主颜色,同批次烟花使用相同/相近的hue,保证颜色协调,避免杂乱;saturation(饱和度)设为0.85,保证颜色鲜艳;lightness(亮度)微调,实现粒子间的细微颜色差异;
  • 速度扰动new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8) 生成一个微小的随机向量,叠加到基础速度上,避免烟花扩散过于规则,更贴近真实效果。

步骤4:交互绑定(鼠标点击+自动发射)

4.1 核心代码
// 1. 鼠标点击事件:点击屏幕任意位置发射烟花
window.addEventListener('click', (e) => {
  // 步骤1:屏幕坐标转换为NDC坐标(归一化设备坐标,-1~1)
  const x = (e.clientX / window.innerWidth) * 2 - 1;
  const y = -(e.clientY / window.innerHeight) * 2 + 1;
  
  // 步骤2:NDC坐标转换为世界坐标(通过相机反投影)
  const vector = new THREE.Vector3(x, y, 0.5); // z=0.5 取视口中间深度
  vector.unproject(camera); // 反投影:NDC → 世界坐标
  
  // 步骤3:计算射线方向,确定3D场景中的发射位置
  const dir = vector.sub(camera.position).normalize(); // 相机到点击点的方向向量
  const distance = (200 - camera.position.z) / dir.z; // 固定深度距离,避免发射位置过远/过近
  const pos = camera.position.clone().add(dir.multiplyScalar(distance)); // 最终发射位置
  
  // 步骤4:发射烟花
  launchFirework(pos.x, pos.y, pos.z);
});

// 2. 自动发射:每隔1秒随机发射一次烟花(增加场景活力)
setInterval(() => {
  if (Math.random() > 0.8) { // 20%概率发射,避免过于密集
    launchFirework(
      (Math.random() - 0.5) * 600, // X轴随机范围(-300~300)
      100 + Math.random() * 200, // Y轴随机范围(100~300)
      200 + Math.random() * 300 // Z轴随机范围(200~500)
    );
  }
}, 1000);
4.2 关键技术点解析
  • 屏幕坐标→世界坐标转换:这是Three.js中实现「点击3D场景」的核心逻辑,步骤为「屏幕坐标→NDC坐标→世界坐标」:
    1. 屏幕坐标(clientX/clientY)是像素值,范围为(0,0)(window.innerWidth, window.innerHeight),转换为NDC坐标后范围为(-1,-1)(1,1)
    2. vector.unproject(camera):将NDC坐标转换为世界坐标,需要指定一个z值(此处为0.5),表示视口的中间深度;
    3. 计算射线方向并确定距离,最终得到3D场景中的发射位置,避免烟花发射到相机后方或过远的位置;
  • 自动发射逻辑:使用setInterval定时执行,配合Math.random() > 0.8实现20%的发射概率,避免烟花过于密集,平衡视觉效果和性能。

步骤5:动画循环(粒子更新+拖尾渲染+场景渲染)

这是烟花动起来的核心,每帧更新粒子的物理状态、拖尾历史位置,并更新缓冲区数据,实现流畅的动画效果。

5.1 核心代码
const clock = new THREE.Clock(); // 时钟,用于获取每帧时间增量

function animate() {
  requestAnimationFrame(animate); // 绑定浏览器刷新率,实现流畅动画
  const delta = Math.min(clock.getDelta(), 0.05); // 获取时间增量,限制最大为0.05(防止帧率波动导致动画跳变)

  // 1. 遍历所有粒子,更新活跃粒子的状态
  for (let i = 0; i < MAX_PARTICLES; i++) {
    if (!alives[i]) continue; // 跳过空闲粒子

    const p = particleData[i];
    // 步骤1:更新粒子生命周期,判断是否消亡
    p.life += delta * 0.8; // 生命周期增速(0.8为调节系数,越大消亡越快)
    if (p.life > 1.0) { // 生命周期超过1,标记为空闲
      alives[i] = 0;
      continue;
    }

    // 步骤2:更新粒子物理状态(重力+空气阻力)
    p.vel.y -= 45 * delta; // 重力:Y轴速度递减(模拟地球重力,向下拉)
    p.vel.multiplyScalar(0.985); // 空气阻力:速度整体衰减(模拟空气阻力,粒子逐渐减速)
    p.pos.add(p.vel.clone().multiplyScalar(delta)); // 根据速度更新当前位置

    // 步骤3:更新粒子拖尾历史位置(FIFO先进先出,实现拖尾移动)
    for (let h = TRAIL_LENGTH - 1; h > 0; h--) {
      p.history[h].copy(p.history[h - 1]); // 后一个位置复制前一个位置的数据
    }
    p.history[0].copy(p.pos); // 最新位置写入历史队列的第一个位置

    // 步骤4:更新缓冲区数据(顶点位置+顶点颜色,实现拖尾渲染+渐隐)
    const baseIdx = i * TRAIL_LENGTH; // 当前粒子的顶点起始索引
    for (let h = 0; h < TRAIL_LENGTH; h++) {
      const posIdx = (baseIdx + h) * 3; // 当前顶点的位置索引
      const colIdx = (baseIdx + h) * 3; // 当前顶点的颜色索引
      
      // 更新顶点位置
      const histPos = p.history[h];
      positions[posIdx] = histPos.x;
      positions[posIdx + 1] = histPos.y;
      positions[posIdx + 2] = histPos.z;

      // 更新顶点颜色(拖尾渐隐+生命周期渐隐)
      const fade = 1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7; // 拖尾渐隐:越旧的位置越暗
      const alphaFactor = 1.0 - p.life; // 生命周期渐隐:粒子越接近消亡越暗
      colors[colIdx] = p.color.r * fade * alphaFactor;
      colors[colIdx + 1] = p.color.g * fade * alphaFactor;
      colors[colIdx + 2] = p.color.b * fade * alphaFactor;
    }
  }

  // 2. 标记缓冲区数据需要更新(Three.js才会重新渲染)
  geometry.attributes.position.needsUpdate = true;
  geometry.attributes.color.needsUpdate = true;

  // 3. 渲染场景
  renderer.render(scene, camera);
}

// 启动动画循环
animate();
5.2 关键技术点解析
  • clock.getDelta():获取上一帧到当前帧的时间增量(单位:秒),使用时间增量更新动画,保证动画速度与帧率无关,无论高帧率还是低帧率,烟花行进速度一致;
  • 物理模拟逻辑
    • 重力:p.vel.y -= 45 * delta,只在Y轴施加重力,模拟地球重力,让粒子逐渐下落,更贴近真实烟花;
    • 空气阻力:p.vel.multiplyScalar(0.985),每帧让速度乘以一个小于1的系数,实现速度衰减,粒子逐渐减速,拖尾也会随之变短;
  • 拖尾FIFO队列:拖尾历史位置数组采用「先进先出」模式,每帧将前一个位置的数据复制到后一个位置,最新位置写入数组头部,实现拖尾的移动效果,视觉上就是粒子带着尾巴前进;
  • 双重渐隐逻辑
    • 拖尾渐隐(fade):1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7,拖尾中越旧的位置(索引h越大),fade值越小,颜色越暗,实现拖尾从亮到暗的渐变;
    • 生命周期渐隐(alphaFactor):1.0 - p.life,粒子越接近消亡(life越接近1),alphaFactor值越小,颜色越暗,实现粒子从亮到暗的消亡效果;
  • needsUpdate = trueBufferGeometry的缓冲区数据更新后,必须将对应的needsUpdate设为true,告诉Three.js缓冲区数据已变更,需要重新渲染,否则修改不会生效。

步骤6:窗口适配(响应式调整)

6.1 核心代码
window.addEventListener('resize', () => {
  // 1. 更新相机宽高比
  camera.aspect = window.innerWidth / window.innerHeight;
  // 2. 更新相机投影矩阵(必须调用,否则宽高比修改不生效)
  camera.updateProjectionMatrix();
  // 3. 更新渲染器尺寸
  renderer.setSize(window.innerWidth, window.innerHeight);
});
6.2 关键说明
  • 窗口大小变化时,需要同步更新相机的宽高比和渲染器的尺寸,保证烟花效果在不同屏幕尺寸下都能全屏显示;
  • camera.updateProjectionMatrix():相机参数修改后,必须调用该方法更新投影矩阵,否则相机的宽高比修改不会生效,场景会出现拉伸变形。

核心参数速查表(快速调整效果)

参数名 取值 作用 修改建议
MAX_PARTICLES 8000 最大粒子数,限制性能开销 配置低的设备可改为4000,减少卡顿;高性能设备可改为16000,提升烟花密集度
TRAIL_LENGTH 4 每个粒子的拖尾长度(顶点数) 改为2,拖尾变短更锐利;改为6,拖尾变长更柔和(注意:会增加顶点数,影响性能)
baseSpeed(主烟花) 30~60 烟花爆炸初始速度 改为2040,爆炸范围变小;改为4080,爆炸范围更大更壮观
p.vel.y -= 45 * delta 45 重力系数 改为20,重力更弱,烟花停留时间更长;改为60,重力更强,烟花下落更快
p.vel.multiplyScalar(0.985) 0.985 空气阻力系数 改为0.97,阻力更大,粒子减速更快;改为0.995,阻力更小,粒子飞行更远
hue(主烟花) 0~0.3 烟花主色调(HSL) 改为0.30.6,呈现绿色/青色系;改为0.60.9,呈现红色/粉色系
AdditiveBlending 混合模式 粒子发光叠加 改为NormalBlending,关闭发光效果,呈现普通粒子;改为MultiplyBlending,呈现暗色调叠加效果

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 烟花带拖尾效果</title>
  <style>
    body { margin: 0; overflow: hidden; background: #000; }
    #info {
      position: absolute;
      top: 20px;
      width: 100%;
      text-align: center;
      color: white;
      font-family: Arial, sans-serif;
      pointer-events: none;
      text-shadow: 0 0 8px rgba(255,255,255,0.7);
    }
  </style>
</head>
<body>
  <div id="info">点击任意位置发射烟花(带拖尾)</div>

<script type="module">
  import * as THREE from 'https://esm.sh/three@0.174.0';

  // ========== 1. 基础环境初始化(场景/相机/渲染器/雾效) ==========
  const scene = new THREE.Scene();
  // 夜空雾效:深蓝黑色,远处粒子自然融入背景
  scene.fog = new THREE.Fog(0x000022, 100, 800);

  // 透视相机:高位俯视,清晰观察烟花爆炸
  const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
  camera.position.set(0, 50, 300);

  // 渲染器:抗锯齿+透明背景,适配纯黑夜空
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 微弱环境光:补充暗部,不影响烟花视觉效果
  scene.add(new THREE.AmbientLight(0x222222));

  // ========== 2. 粒子系统核心参数与缓冲区初始化 ==========
  const MAX_PARTICLES = 8000; // 最大粒子数(平衡效果与性能)
  const TRAIL_LENGTH = 4; // 拖尾长度(4个顶点=3段短线)
  const totalVertices = MAX_PARTICLES * TRAIL_LENGTH; // 总顶点数

  // 缓冲区数据:存储顶点坐标和颜色
  const positions = new Float32Array(totalVertices * 3);
  const colors = new Float32Array(totalVertices * 3);
  const alives = new Uint8Array(MAX_PARTICLES); // 粒子存活状态(0=空闲,1=活跃)
  const particleData = []; // 粒子物理状态数据池

  // 初始化粒子物理状态
  for (let i = 0; i < MAX_PARTICLES; i++) {
    particleData.push({
      pos: new THREE.Vector3(), // 当前位置
      vel: new THREE.Vector3(), // 当前速度
      color: new THREE.Color(), // 粒子颜色
      size: 0, // 粒子尺寸(预留扩展)
      life: 0, // 生命周期(0~1)
      history: [ // 拖尾历史位置(FIFO队列)
        new THREE.Vector3(),
        new THREE.Vector3(),
        new THREE.Vector3(),
        new THREE.Vector3()
      ]
    });
  }

  // 构建BufferGeometry:高效管理大量粒子顶点数据
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

  // 自定义材质:支持顶点颜色+发光叠加+渐隐
  const material = new THREE.LineBasicMaterial({
    vertexColors: true, // 启用顶点独立颜色
    transparent: true, // 启用透明,支持渐隐
    depthWrite: false, // 关闭深度写入,粒子正常叠加
    blending: THREE.AdditiveBlending // 加法混合,实现烟花发光效果
  });

  // 创建LineSegments:绘制粒子拖尾线段
  const lines = new THREE.LineSegments(geometry, material);
  scene.add(lines);

  // ========== 3. 烟花发射函数(分配粒子+初始化物理状态) ==========
  function launchFirework(x, y, z, isChild = false) {
    // 确定粒子数量:主烟花多,子烟花少(预留二次爆炸扩展)
    const count = isChild ? (30 + Math.random() * 60) : (300 + Math.random() * 400);
    // 确定烟花主色调:HSL模式,保证颜色协调
    const hue = isChild ? (Math.random() * 0.1 + 0.95) : (Math.random() * 0.3);

    for (let p = 0; p < count; p++) {
      // 查找空闲粒子(对象池复用,提升性能)
      let idx = -1;
      for (let i = 0; i < MAX_PARTICLES; i++) {
        if (!alives[i]) {
          idx = i;
          break;
        }
      }
      if (idx === -1) break; // 无空闲粒子,终止本次发射

      const particle = particleData[idx];
      // 初始化粒子位置
      particle.pos.set(x, y, z);

      // 初始化爆炸速度(极坐标转换,360°扩散)
      const baseSpeed = isChild ? (10 + Math.random() * 15) : (30 + Math.random() * 30);
      const theta = Math.random() * Math.PI * 2; // 水平角度
      const phi = Math.random() * Math.PI; // 垂直角度
      particle.vel.set(
        baseSpeed * Math.sin(phi) * Math.cos(theta),
        baseSpeed * Math.sin(phi) * Math.sin(theta),
        baseSpeed * Math.cos(phi)
      ).add(new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8)); // 速度扰动,避免规则扩散

      // 初始化粒子颜色(HSL模式,鲜艳协调)
      particle.color.setHSL(hue, 0.85, isChild ? 0.9 : 0.65);
      particle.size = isChild ? (0.3 + Math.random() * 0.7) : (0.4 + Math.random() * 0.9);
      particle.life = 0; // 重置生命周期

      // 初始化拖尾历史位置(避免初始偏移)
      for (let h = 0; h < TRAIL_LENGTH; h++) {
        particle.history[h].copy(particle.pos);
      }

      // 标记粒子为活跃状态
      alives[idx] = 1;
    }
  }

  // ========== 4. 交互绑定(鼠标点击+自动发射) ==========
  // 鼠标点击:屏幕坐标→世界坐标,发射烟花
  window.addEventListener('click', (e) => {
    // 步骤1:屏幕坐标转NDC坐标(-1~1)
    const x = (e.clientX / window.innerWidth) * 2 - 1;
    const y = -(e.clientY / window.innerHeight) * 2 + 1;

    // 步骤2:NDC坐标转世界坐标
    const vector = new THREE.Vector3(x, y, 0.5);
    vector.unproject(camera);

    // 步骤3:计算3D场景发射位置
    const dir = vector.sub(camera.position).normalize();
    const distance = (200 - camera.position.z) / dir.z;
    const pos = camera.position.clone().add(dir.multiplyScalar(distance));

    // 步骤4:发射烟花
    launchFirework(pos.x, pos.y, pos.z);
  });

  // 自动发射:每隔1秒,20%概率发射烟花
  setInterval(() => {
    if (Math.random() > 0.8) {
      launchFirework(
        (Math.random() - 0.5) * 600,
        100 + Math.random() * 200,
        200 + Math.random() * 300
      );
    }
  }, 1000);

  // ========== 5. 动画循环(粒子更新+拖尾渲染) ==========
  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);
    const delta = Math.min(clock.getDelta(), 0.05); // 限制最大时间增量,避免动画跳变

    // 遍历更新所有活跃粒子
    for (let i = 0; i < MAX_PARTICLES; i++) {
      if (!alives[i]) continue;

      const p = particleData[i];
      // 更新生命周期,判断是否消亡
      p.life += delta * 0.8;
      if (p.life > 1.0) {
        alives[i] = 0;
        continue;
      }

      // 更新物理状态(重力+空气阻力)
      p.vel.y -= 45 * delta; // 重力:Y轴速度递减
      p.vel.multiplyScalar(0.985); // 空气阻力:速度整体衰减
      p.pos.add(p.vel.clone().multiplyScalar(delta)); // 更新当前位置

      // 更新拖尾历史位置(FIFO先进先出)
      for (let h = TRAIL_LENGTH - 1; h > 0; h--) {
        p.history[h].copy(p.history[h - 1]);
      }
      p.history[0].copy(p.pos);

      // 更新缓冲区数据(顶点位置+颜色)
      const baseIdx = i * TRAIL_LENGTH;
      for (let h = 0; h < TRAIL_LENGTH; h++) {
        const posIdx = (baseIdx + h) * 3;
        const colIdx = (baseIdx + h) * 3;

        // 更新顶点位置
        const histPos = p.history[h];
        positions[posIdx] = histPos.x;
        positions[posIdx + 1] = histPos.y;
        positions[posIdx + 2] = histPos.z;

        // 更新顶点颜色(双重渐隐:拖尾+生命周期)
        const fade = 1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7;
        const alphaFactor = 1.0 - p.life;
        colors[colIdx] = p.color.r * fade * alphaFactor;
        colors[colIdx + 1] = p.color.g * fade * alphaFactor;
        colors[colIdx + 2] = p.color.b * fade * alphaFactor;
      }
    }

    // 标记缓冲区数据需要更新,Three.js重新渲染
    geometry.attributes.position.needsUpdate = true;
    geometry.attributes.color.needsUpdate = true;

    // 渲染场景
    renderer.render(scene, camera);
  }

  // 启动动画循环
  animate();

  // ========== 6. 窗口适配(响应式调整) ==========
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 粒子系统核心:采用BufferGeometry+对象池模式,高效管理大量粒子,平衡视觉效果与性能,是大量粒子场景的最佳实践;
  2. 拖尾效果实现:通过存储粒子历史位置(FIFO队列),配合LineSegments绘制线段,再通过顶点颜色实现渐隐,视觉效果流畅自然;
  3. 物理模拟:手动添加重力和空气阻力,让粒子运动更贴近真实,速度扰动避免规则扩散,提升真实感;
  4. 视觉优化:采用AdditiveBlending加法混合实现烟花发光效果,THREE.Fog营造夜空氛围,HSL颜色模式保证颜色协调鲜艳;
  5. 交互核心:屏幕坐标→世界坐标转换,实现鼠标点击发射烟花,自动发射增加场景活力。

扩展建议

  1. 二次爆炸效果:在粒子生命周期接近0.5时,调用launchFirework发射子粒子,实现烟花爆炸后再分裂的效果;
  2. 声音效果:添加音频文件,在发射烟花时播放爆炸声,提升沉浸感;
  3. 鼠标跟随:改为鼠标移动时发射烟花,或让烟花跟随鼠标位置爆炸;
  4. 颜色渐变:粒子生命周期中动态修改hue值,实现烟花颜色从亮到暗、从暖到冷的渐变;
  5. 粒子尺寸变化:利用particle.size,在生命周期中动态修改粒子尺寸,实现烟花爆炸后粒子逐渐变大再消亡的效果;
  6. 轨迹优化:增加粒子旋转效果,或让拖尾带有轻微弯曲,更贴近真实烟花轨迹;
  7. 性能优化:使用InstancedMesh替代LineSegments,进一步减少DrawCall,支持更多粒子数。

前端向架构突围系列 - 编译原理 [6 - 3]:ESLint 原理、自定义规则与 Codemod

2026年2月2日 10:11

写在前面

很多团队面临这样的困境: 架构师制定了规范:“所有业务组件禁止直接引用 lodash,必须引用 src/utils。” 结果呢?文档写在 Wiki 里吃灰,新同事照样写 import _ from 'lodash'。Code Review 时如果你没看出来,这代码就溜上线了。

口头规范是软弱的,代码规范才是强硬的。

真正的高手,会把架构规范写成 ESLint 插件。这一节,我们将把“文档里的规范”变成“编辑器里的红色波浪线”。

image.png


一、 ESLint 的原理:找茬的艺术

ESLint 的工作流程和 Babel 惊人地相似,只有最后一步不同。

1.1 流程对比

  • Babel: Parse -> Transform (修改 AST) -> Generate (生成新代码)
  • ESLint: Parse -> Traverse (遍历 AST) -> Report (报告错误)

ESLint 默认使用 Espree 作为解析器(Parser)。它遍历 AST,当遇到不符合规则的节点时,不是去修改它,而是记录一个“错误对象”(包含行号、列号、错误信息)。

1.2 Fix 的原理

你一定用过 eslint --fix。既然 ESLint 不生成新代码,它是怎么修复错误的? 其实,ESLint 的规则在报错时,可以提供一个 fixer 对象。

context.report({
  node: node,
  message: "缺少分号",
  fix: function(fixer) {
    // 告诉 ESLint:在当前节点后面插入一个 ";"
    return fixer.insertTextAfter(node, ";");
  }
});

ESLint 收集所有的 fix 操作,最后在源码字符串上进行字符串拼接(而不是重新 Generate),从而保留原本的格式(空格、注释)。


二、 实战:编写你的第一条 ESLint 规则

假设你的团队有一个死规定:代码中禁止使用 var,必须用 letconst 虽然现有的规则集里有 no-var,但为了学习,我们自己写一个。

2.1 规则结构

一个 ESLint 规则就是一个导出的对象,包含 meta(元数据)和 create(访问者)。

// eslint-plugin-no-var-custom.js
module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "禁止使用 var",
    },
    fixable: "code", // 表示这个规则支持自动修复
  },
  create(context) {
    return {
      // 监听 VariableDeclaration 节点
      VariableDeclaration(node) {
        // 如果声明类型是 "var"
        if (node.kind === "var") {
          // 报警!
          context.report({
            node,
            message: "大清亡了,别用 var 了!",
            // 自动修复逻辑
            fix(fixer) {
              // 把 "var" 替换成 "let"
              // sourceCode.getFirstToken(node) 获取到的就是 "var" 这个关键词
              const varToken = context.getSourceCode().getFirstToken(node);
              return fixer.replaceText(varToken, "let");
            }
          });
        }
      }
    };
  }
};

2.2 架构级应用:防腐层治理

架构师可以利用自定义规则做更高级的事情。 场景: 项目中分层架构,UI 层(src/components)严禁直接导入数据库层(src/db)。

// rule: no-ui-import-db.js
create(context) {
  return {
    ImportDeclaration(node) {
      const importPath = node.source.value; // e.g., '@/db/user'
      const currentFilename = context.getFilename(); // 当前正在检查的文件

      // 如果当前文件在 components 目录下,且引用了 db 目录
      if (currentFilename.includes('/src/components/') && importPath.includes('/db/')) {
        context.report({
          node,
          message: "架构报警:UI 组件禁止直接触碰数据库层!请通过 Service 层调用。"
        });
      }
    }
  };
}

把这个规则加入 CI/CD,你的架构分层就有了强制力


三、 Codemod:自动化重构的核武器

ESLint 的 fix 适合修补小问题。但如果你面临的是大规模破坏性重构,比如:

  • 把项目中 5000 个文件的 React.createClass 全部重写为 class extends React.Component
  • 把所有的 import { Button } from 'my-ui' 变成 import Button from 'my-ui/button'

这时候,你需要 Codemod。最著名的工具是 Facebook 推出的 jscodeshift

3.1 jscodeshift 的优势

它不仅仅是 AST 解析器,它提供了一套类似 jQuery 的 API 来操作 AST。你不需要关心复杂的节点结构,只需要链式调用。

3.2 实战:API 签名变更

需求: 旧的 API myApi.get(id, type) 升级了,参数变了,必须改成对象传参 myApi.get({ id, type })

Codemod 脚本:

// transformer.js
export default function(file, api) {
  const j = api.jscodeshift; // 获取 jscodeshift 实例
  
  return j(file.source) // 1. 解析源码
    .find(j.CallExpression, { // 2. 查找所有的函数调用
      callee: {
        object: { name: 'myApi' },
        property: { name: 'get' }
      }
    })
    .forEach(path => { // 3. 遍历找到的节点
      const args = path.node.arguments;
      
      // 如果参数数量是 2 个,说明是旧代码
      if (args.length === 2) {
        // 创建一个新的对象表达式 { id: arg0, type: arg1 }
        const newObjArg = j.objectExpression([
            j.property('init', j.identifier('id'), args[0]),
            j.property('init', j.identifier('type'), args[1])
        ]);
        
        // 替换参数
        path.node.arguments = [newObjArg];
      }
    })
    .toSource(); // 4. 生成新代码
}

运行:

npx jscodeshift -t transformer.js src/**/*.js

瞬间,你完成了全项目几千个文件的 API 升级。这就是架构师的效率。


四、 总结:架构师的“法治”思维

这一节我们从“写代码”进阶到了“管代码”。

  1. ESLint 是日常执勤的警察,通过 Linting(检查)和 Fixing(微修补)维持代码风格和架构边界。
  2. Codemod 是特种部队,通过 AST Transformation 解决大规模的技术债务和破坏性升级。

架构师不应该仅仅是那个“写文档告诉大家怎么做”的人,而应该是那个“提供工具让大家没法做错”的人。

Next Step: 我们已经把 AST 在工具链(Babel, ESLint)中的应用学完了。 最后,我们要看看 AST 是如何在现代前端框架中发挥作用的。Vue 的 <template> 是怎么变成 JS 的?React 的 JSX 到底是怎么回事? 下一节,我们将揭秘**《第四篇:应用——框架的魔法:Vue 模板编译与 React JSX 转换背后的编译艺术》**。

Vue-异步更新机制与 nextTick 的底层执行逻辑

2026年2月2日 10:09

前言

在 Vue 开发中,你是否遇到过“修改了数据但立即获取 DOM 元素,拿到的却是旧值”的情况?这背后涉及 Vue 的异步更新策略。理解 nextTick,就是理解 Vue 如何与浏览器的事件循环(Event Loop)“握手”。

一、 为什么需要 nextTick?

1. 概念定义

nextTick 的核心作用是:在修改数据之后立即使用这个方法,获取更新后的 DOM。因为在vue里面当监听到我们的数据发送变化时,vue会开启一个异步更新队列,视图需要等待队列里面的所有数据变化完成后,再进行统一的更新。

2. Vue 的异步更新策略

Vue 的响应式并不是数据一变,DOM 就立刻变。

  • 当数据发生变化时,Vue 会开启一个异步更新队列
  • 如果同一个 watcher 被多次触发,只会被推入队列一次(去重优化)。
  • 这种机制避免了在一次同步操作中,因为多次修改数据而导致的重复渲染,极大的提高了性能。

二、 核心原理:基于事件循环(Event Loop)

nextTick 的实现逻辑紧密依赖于 JavaScript 的执行机制。

1. 任务调度逻辑

  1. 数据变更:修改响应式数据,Vue 将 DOM 更新任务推入一个异步队列(微任务)。
  2. 注册回调:调用 nextTick(callback),Vue 将该回调推入一个专用的 callbacks 队列。
  3. 执行时机:Vue 优先尝试创建一个微任务(Microtask) ,通常使用 Promise.then。如果环境不支持,则降级为宏任务(如 setTimeout)。
  4. 顺序保证:Vue 内部通过代码执行顺序,确保 DOM 更新任务先于 nextTick 的回调任务 执行。

2. 宏任务与微任务的演进

  • 优先选择Promise.thenMutationObserver(微任务)。
  • 降级选择:如果上述不可用,则降级为宏任务 setImmediatesetTimeout(fn, 0)

三、 使用示例:

1. 在setup中操作 DOM

setup 阶段,组件尚未挂载,DOM 不存在。只有在onMounted中才会创建, 所以无法直接操作,需要通过nextTick()来完成。

<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue';

const message = ref<string>('初始内容');
const divRef = ref<HTMLElement | null>(null);

// 模拟 setup 阶段(相当于 Vue 2 的 created)
nextTick(() => {
  // 此时 DOM 可能已挂载(取决于具体执行时机),但在 setup 同步代码中无法直接访问
  console.log('setup 中的 nextTick 回调');
});
</script>

2. 数据更新后获取最新的视图信息

这是最常见的场景:例如根据动态内容计算容器高度。

<template>
  <div ref="listRef" class="list">
    <div v-for="item in list" :key="item">{{ item }}</div>
  </div>
  <button @click="addItem">新增条目</button>
</template>

<script setup lang="ts">
import { ref, nextTick } from 'vue';

const list = ref<string[]>(['Item 1', 'Item 2']);
const listRef = ref<HTMLElement | null>(null);

const addItem = async () => {
  list.value.push(`Item ${list.value.length + 1}`);
  
  // ❌ 此时获取的高度是更新前的
  console.log('更新前高度:', listRef.value?.offsetHeight);

  // ✅ 等待 DOM 更新
  await nextTick();

  // 此时可以获取到新增条目后的真实高度
  console.log('更新后高度:', listRef.value?.offsetHeight);
};
</script>

四、 总结:nextTick 的“避坑”锦囊

  • 同步逻辑 vs 异步逻辑:修改数据是同步的,但 DOM 变化是异步的。所有紧随数据修改后的 DOM 操作,都应该放进 nextTick

  • Promise 语法糖:在 Vue 3 中,nextTick 返回一个 Promise。你可以使用 await nextTick() 代替传统的 nextTick(() => { ... }),使代码更具可读性。

  • 性能注意:虽然 nextTick 很好用,但不要滥用。频繁的 DOM 查询依然会带来性能开销,能通过数据驱动(数据绑定)解决的问题,尽量不要手动操作 DOM。

React中的useDeferredValue与防抖和节流之间有什么不同?

2026年2月2日 10:05

react原文:

在上述的情景中,你可能会使用这两种常见的优化技术:

  • 防抖 是指在用户停止输入一段时间(例如一秒钟)之后再更新列表。
  • 节流 是指每隔一段时间(例如最多每秒一次)更新列表。

虽然这些技术在某些情况下是有用的,但 useDeferredValue 更适合优化渲染,因为它与 React 自身深度集成,并且能够适应用户的设备。

与防抖或节流不同,useDeferredValue 不需要选择任何固定延迟时间。如果用户的设备很快(比如性能强劲的笔记本电脑),延迟的重渲染几乎会立即发生并且不会被察觉。如果用户的设备较慢,那么列表会相应地“滞后”于输入,滞后的程度与设备的速度有关。

此外,与防抖或节流不同,useDeferredValue 执行的延迟重新渲染默认是可中断的。这意味着,如果 React 正在重新渲染一个大型列表,但用户进行了另一次键盘输入,React 会放弃该重新渲染,先处理键盘输入,然后再次开始在后台渲染。相比之下,防抖和节流仍会产生不顺畅的体验,因为它们是阻塞的:它们仅仅是将渲染阻塞键盘输入的时刻推迟了。

如果你要优化的工作不是在渲染期间发生的,那么防抖和节流仍然非常有用。例如,它们可以让你减少网络请求的次数。你也可以同时使用这些技术。

关于这句话的理解

相比之下,防抖和节流仍会产生不顺畅的体验,因为它们是阻塞的:它们仅仅是将渲染阻塞键盘输入的时刻推迟了。

这句话解释了 useDeferredValue 和传统防抖/节流的关键区别。我来详细解析:

核心区别:可中断 vs 不可中断

场景设定

假设用户快速输入“hello”,你有一个需要渲染大型列表的组件。


1. 防抖/节流的工作方式(有问题的)

// 防抖示例:延迟500ms后执行
const debouncedSearch = debounce((value) => {
  // 渲染大型列表
  renderLargeList(value);
}, 500);

时间线模拟:

时间 0ms: 用户输入 "h" → 启动500ms计时器
时间 100ms: 用户输入 "he" → 重置计时器
时间 200ms: 用户输入 "hel" → 重置计时器
...
时间 600ms: 用户输入 "hello" 完成
时间 1100ms: 500ms后,终于开始渲染"hello"的列表

问题

  • 在这 500ms的等待期,用户继续输入是顺畅的(因为没在渲染)
  • 1100ms时开始渲染大型列表,可能需要200ms
  • 如果用户在 1150ms时又想输入,会卡住!因为渲染正在进行,无法响应键盘

这就是 “将渲染阻塞键盘输入的时刻推迟了”

  • 阻塞没有消失,只是推迟到防抖延迟结束后
  • 当阻塞发生时,整个UI线程都会被占用

2. useDeferredValue 的工作方式(可中断的)

function SearchComponent() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {/* 这个大型列表会延迟渲染 */}
      <LargeList query={deferredQuery} />
    </>
  );
}

时间线模拟:

时间 0ms: 用户输入 "h" → 立即更新输入框显示"h"
                     → 开始后台渲染"h"的列表
时间 50ms: 用户输入 "he" → React **中断**正在渲染的"h"的列表
                     → 立即更新输入框显示"he"
                     → 开始后台渲染"he"的列表
时间 100ms: 用户输入 "hel" → 再次中断"he"的渲染
                     → 更新输入框 → 开始渲染"hel"
...
时间 200ms: 用户停止输入,最终渲染"hello"的完整列表

优势

  • 用户输入始终立即响应(输入框内容实时更新)
  • 大型渲染可以被中断,让位给更重要的用户交互
  • 没有固定的“阻塞窗口期”

可视化对比

防抖/节流:
[用户输入][等待延迟][无法中断的阻塞渲染][卡顿风险]

useDeferredValue:
[用户输入][立即显示][可中断的后台渲染]
     ↑         随时可插入新输入
     └────── 中断当前渲染 ──┘

关键区别表格

特性 防抖/节流 useDeferredValue
用户输入响应 延迟期间响应快,但渲染时阻塞 始终立即响应
渲染过程 不可中断,独占线程 可中断,时间切片
阻塞时机 推迟到延迟结束后 分散成可中断的小块
实现层面 JavaScript 定时器控制 React 调度器控制
用户体验 可能突然卡顿 持续流畅

这就是为什么说防抖/节流只是推迟阻塞,而 useDeferredValue避免长时间阻塞

总结

简单来说就是用防抖的话,等到输入结束后长列表开始渲染期间再次去输入内容还是会有交互卡顿的情况存在,防抖只是延迟了这种情况而已。而useDefferedValue却能一直确保用户输入时的交互流畅进行。小注:除了最后的总结,文章的内容是AI生成的,为了理解记录一下~~

闭包:从「能跑」到「可控」——用执行上下文把它讲透,再把泄漏风险压住

作者 swipe
2026年2月2日 09:58

引言:为什么团队里闭包总是「会用但讲不清」

闭包几乎是每个前端都“用过”的能力:回调、事件处理、节流防抖、柯里化、状态缓存……到处都是它。但一到排查线上内存飙升、解释“为什么变量没被回收”、或者评审里讨论“这个写法会不会泄漏”,就容易陷入两种极端:

  • 把闭包当玄学:只记住“函数套函数 + 引用外部变量”,但不知道底层到底发生了什么。
  • 把闭包当洪水猛兽:遇到闭包就怕泄漏,动不动“全局变量/单例就是坏”。

这篇文章的目标很明确:用“执行上下文 + AO/GO + 可达性”把闭包拆开讲清楚,再落到工程实践:哪些写法会导致内存常驻、怎么定位、怎么释放、怎么验证。很多内容需要配合内存图理解(图片必须保留),建议边看边对照图。


目录

    1. 脉络:为什么闭包在 JS 里如此关键
    1. 闭包到底是什么:定义、自由变量与词法绑定
    1. 从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)
    1. 闭包形成的关键:[[scope]] / parentScope 为什么能“跨出上下文”
    1. 内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收
    1. 闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”
    1. 如何释放:最小化作用域、解除引用、弱引用思路
    1. 性能与排查:用浏览器工具定位闭包导致的内存/耗时
    1. 引擎优化:闭包里“没用到的变量”会怎样(V8 优化)
    1. 进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行
    1. 实战建议:团队落地清单 & 指标验证
    1. 总结:关键结论 + 下一步建议

f 脉络探索:闭包为什么值得被「认真对待」

闭包是 JavaScript 中一个非常容易让人迷惑的知识点:它既是语言表达力的源泉,也可能成为内存与可维护性的风险点。很多经典资料对它评价极高——因为它背后牵扯的是一整套“词法作用域 + 执行上下文 + 垃圾回收”的体系。

** 图7-1 《你不知道的JavaScript》上卷中对闭包的评语**

把这张图放在开头的意义是:闭包不是一个“语法点”,而是理解 JS 运行机制的入口。当你能把闭包讲清楚,很多“JS 为什么这样设计”的问题都会连起来。

本章小结(可迁移的经验)

  • 闭包不是“函数套函数”的表象,而是 词法作用域如何在运行时被保留
  • 闭包相关的争论,往往不是“对错”,而是 讨论口径(广义/狭义)不同
  • 真正工程风险来自:闭包让某些对象变成“长期可达” ,从而影响 GC。

一、闭包到底是什么:定义、自由变量与词法绑定

1.1 闭包的概念定义:把“感觉”变成“可证明”

闭包并不是 JavaScript 独有。计算机科学中,闭包(Closure)又称词法闭包(Lexical Closure),是一种在支持头等函数的语言中实现词法绑定的技术:闭包在实现上可以理解为“函数 + 关联环境(自由变量的绑定) ”的组合结构。

在 JavaScript 语境下,可以用更工程化的表达:

闭包 = 一个函数 + 该函数在定义时可访问的外层作用域引用(自由变量所在的词法环境)。

这里最关键的是两个词:

  • 自由变量:跨出自己作用域、来自外层作用域的变量(“不是我家里的变量,但我能用”)。
  • 词法解析阶段确定:函数“能访问哪些变量”,在**代码写出来那一刻(定义时)**就决定了,而不是调用时随机决定。

一个常用的工作记忆法: “函数定义时就把外层环境‘锁’住了” 。后续你把函数拿到哪里调用,它都沿着当初锁定的链去找变量。

1.2 广义 vs 狭义:团队沟通时要先统一口径

社区对“什么算闭包”常见两种口径:

  • 广义:JS 里“函数”几乎都带着词法作用域信息,因此都可以叫闭包。
  • 狭义(更严谨) :只有当函数实际捕获并使用外层变量,才讨论“闭包带来的效果”(比如变量驻留)。

下面这段代码就体现了差异点:它能访问 name,但是否把它视为“闭包”(严格意义)看你站哪种口径:

// 可以访问 name:test 算闭包(广义)
// 有访问到 name:test 算闭包(更严谨,讨论“闭包效果”更有意义)
var name = "放寒假了";
function test() {
  console.log(name);
}
test();

本章小结(落地清单)

  • 团队讨论闭包前,先明确口径:讨论“闭包机制”还是“闭包效果(变量驻留)”
  • 记住闭包核心:定义时锁定词法环境,不是调用时决定。
  • 所谓“自由变量”,本质就是:跨作用域访问的变量

二、从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)

闭包很容易被讲成“概念”,但工程上要做到“可控”,一定要落到执行过程:调用栈如何创建执行上下文?AO/GO 什么时候创建?引用链怎样形成可达性?

2.1 先看一个最小高阶函数:返回函数指针发生了什么

function foo() {
  // bar 预解析,前面有讲过
  function bar() {
    console.log("小吴");
  }
  return bar;
}

var fn = foo();

fn();
// 小吴

关键点:调用函数会创建执行上下文(Execution Context) 。执行上下文创建前,会先创建对应的 AO(Activation Object) :用于存放形参、局部变量、函数声明等。

2.2 内存图:从“栈/堆”视角理解 fn = foo() 的意义

建议把这一段当成闭包全篇的“底座”,后面所有闭包/泄漏/回收都在重复这个结构。

** 图7-2 高阶函数执行前**

** 图7-3 foo函数调用阶段内存图**

** 图7-4 foo函数中的bar函数调用阶段内存图**

把图 7-2 ~ 7-4 用一句话串起来:

  • foo() 调用时创建 foo 的执行上下文与 AO;
  • bar 函数对象存在于堆上,foo 的 AO 里保存了它的引用;
  • return bar 让全局 fn 指向 bar 的函数对象地址;
  • 后续 fn() 本质是在执行 bar()

你可以把它想象成:return 返回的不是函数体,而是“函数对象的指针”fn 接住这个指针后,就和 bar 的生命周期绑定了。

本章小结(落地清单)

  • AO 是“函数即将执行前”创建的,不是定义函数时就创建(避免无谓开销)。
  • return function 的本质是:返回堆上函数对象的引用
  • 后续 fn() 执行的是“那块函数对象”,而不是重新生成一份。

三、闭包形成的关键:为什么 [[scope]] / parentScope 能让变量跨出上下文

上一章只是“返回了函数”。闭包真正“神奇”的点在于:外层函数执行完了,内层函数还能访问外层变量

来看这个例子

function foo() {
  var name = "why";
  function bar() {
    console.log("小吴", name);
  }
  return bar;
}

var fn = foo();

fn();
// 小吴 why

如果只背概念会说:“bar 引用了 foo 的变量 name,所以形成闭包”。但工程上更重要的是 “它凭什么引用得到?” ——答案是:函数对象内部会保存定义时的外层作用域引用(常被描述为 [[scope]] / parentScope

3.1 发生了什么:把“访问外层变量”写成一条可执行的查找链

bar() 执行时要查 name

  1. 先查自己的 VO/AO(bar 的活动对象)——没有。
  2. 沿着函数对象记录的 parentScope(也就是 foo 的 AO)继续查——找到了。
  3. 输出 why

也就是说,闭包并不是“让变量不销毁”的魔法,而是:

bar 的函数对象握住了 foo 的 AO 引用,使得这块 AO 对 GC 来说一直是“可达的”。

** 图7-5 bar函数中的name形成闭包内存图**

3.2 常见误区:闭包 ≠ 执行上下文永远不销毁

一个容易混淆的点:执行上下文(FEC)会销毁,但 AO 是否可回收 取决于有没有被外界引用链保持可达。

  • foo() 的执行上下文从调用栈弹出,这是必然的;
  • foo 的 AO 如果被 barparentScope 引用着,并且 bar 又被 fn 引用着,那么它就仍然可达,无法回收;
  • “闭包效果”来自这条引用链,而不是来自“执行上下文不销毁”。

本章小结(落地清单)

  • 闭包的底层抓手是:函数对象持有 parentScope(词法环境引用)
  • “变量没被回收”不是因为执行上下文不弹栈,而是因为 对象仍可达
  • 解释闭包时,把“查找链”讲出来,团队沟通会更一致。

四、内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收

4.1 普通函数:执行完就“自由变量不自由”了

function foo() {
  var name = "xiaowu";
  var age = 20;
}

function test() {
  console.log("test");
}

foo();
test();

** 图7-6 foo与test函数执行前的初始化表现**

这张图强调:全局 GO 中保存的是函数对象引用;函数对象里保存了 parentScope 指向 GO;调用时创建执行上下文与 AO。

** 图7-7 foo函数和test函数的内存图执行过程**

** 图7-8 foo的执行上下文销毁前后对比**

关键结论在图 7-8:foo 执行结束,AO 里 name/age 没有被任何外部引用链持有,于是变为不可达,被回收。这就是“自由变量没能真的自由”。

4.2 闭包函数:AO 被外部引用链锁住,变量驻留

function foo() {
  var name = "xiaowu";
  var age = 20;

  function bar() {
    // 引用了外层变量,形成闭包
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return bar;
}

var fn = foo();
fn();
// 这是我的名字 xiaowu
// 这是我的年龄 20

** 图7-9 闭包执行前内存图**

** 图7-10 foo函数执行内存图**

** 图7-11 bar的函数执行上下文**

** 图7-12 bar脱离捕捉时的上下文,自由变量依旧存在**

图 7-12 是闭包“可解释”的关键画面:

  • fn -> bar函数对象(全局根对象可达)
  • bar函数对象 -> parentScope -> foo 的 AO
  • 因为这条链存在,所以 foo AO 仍可达,name/age 仍可达

本章小结(落地清单)

  • 普通函数执行完:AO 通常不可达 → 回收。
  • 闭包能驻留变量:本质是 AO 被函数对象的 parentScope 引用,并且函数对象又被根对象引用
  • 是否回收,归根到底看:从根对象出发是否可达(标记清除的核心判断)。

五、闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”

闭包会让变量驻留,但驻留 ≠ 泄漏。工程上判断泄漏的标准非常朴素:

本该释放、却因为不必要的引用链而长期可达的内存,占用不断增长或长时间不下降。

在闭包语境里,常见泄漏模式就是:返回的函数被长期保存(全局数组/缓存/事件回调/定时器),导致其捕获的外层 AO 一直可达


六、如何释放:最小化闭包作用域、解除引用、弱引用思路

6.1 解决策略三件套

  1. 最小化闭包作用域:只捕获必要数据(不要把整坨对象/大数组/DOM 节点顺手闭包进去)。
  2. 解除引用:用完就断开引用链,让对象从根不可达。
  3. 弱引用(WeakMap/WeakSet) :对“缓存类”场景非常有效(不会阻止 GC)。

6.2 “解除引用”的标准写法:把 fn 指向 null

// 内存泄漏解决方法
function foo() {
  var name = "xiaowu";
  var age = 20;

  function test() {
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return test;
}

var fn = foo();
fn();

// 解除引用:断开 root -> fn -> 函数对象 -> AO 的链
fn = null; // 注意:置 null 不会立刻回收,会在后续 GC 周期中回收

** 图7-13 fn指向bar的指针**

** 图7-14 fn指向bar的指针置为null**

图 7-14 的“孤岛”是你需要在脑子里形成的肌肉记忆:
只要根对象到不了这块内存,它迟早会被回收(标记清除的可达性判断)。

本章小结(落地清单)

  • 释放闭包的核心动作:断开根对象到函数对象的引用链(常见是置 null / 移除监听 / 清理数组缓存)。
  • 设计闭包时先问一句: “我真的需要捕获整个对象/大数组/DOM 吗?”
  • 缓存场景优先考虑:WeakMap/WeakSet(避免“缓存越用越大”)。

七、闭包泄漏案例:大对象被闭包捕获,内存与耗时如何爆炸

为了把问题讲“刺痛”,用一个极端但很真实的例子:闭包捕获一个大数组。

function createFnArray() {
  // 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间(int类型,整数1占4个字节byte)
  // 4byte*1024=4kb,再*1024为4mb,占据的空间是4M × 100 + 其他的内存 = 400M+
  // 在js里面不管是整数类型还是浮点数类型,看起来都是数字类型,这个时候占据的都是8字节,但是js引擎为了提高空间的利用率,对很多小的数字是用不到8个字节(byte)的,8字节 = 2的64次方,所以8字节是很大的,现在的js引擎大多数都会进行优化,对小的数字类型,在V8中称为Smi,小数字 2的32次方
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFn = createFnArray();

** 图7-15 闭包泄露案例**

如果你把 createFnArray() 创建出来的函数持续保存(比如 push 进数组),引用链会不断叠加:

** 图7-16 引用叠加,闭包无法释放**

7.1 用性能工具看“泄漏”长什么样

在浏览器 Performance 面板勾选 Memory,刷新/执行后,你会看到脚本耗时显著升高:

** 图7-17 闭包的性能检测**

7.2 释放后的对比:不一定立刻回收,但趋势会回来

function createFnArray() {
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFns = [];
for (var i = 0; i < 100; i++) {
  // createFnArray() // 不接收就会很快变成不可达
  arrayFns.push(createFnArray());
}

setTimeout(() => {
  arrayFns = null; // 关键:断开引用链
}, 2000);

** 图7-18 性能提升效果**

你还能通过调用树看到耗时主要来源于闭包相关逻辑:

** 图7-19 闭包耗时来源**

本章小结(落地清单)

  • 闭包泄漏常见触发器:闭包捕获大对象 + 长期保存闭包引用(数组/缓存/事件/定时器)。
  • Performance 勾选 Memory:关注 脚本耗时 + 内存曲线是否持续上升
  • “置 null”不保证立刻回收,但能保证:后续 GC 周期具备回收条件

八、引擎优化:闭包里“没用到的变量”会怎样(V8)

一个很实用的问题:闭包让外层 AO 不回收,那 AO 里没用到的属性会不会也一直占着?

例子:闭包只用 name,没有用 age

function foo() {
  var name = "why";
  var age = 18;

  function bar() {
    debugger;
    console.log(name);
  }

  return bar;
}

var fn = foo();
fn();

** 图7-20 V8引擎优化效果(未使用变量被销毁)**

继续在 debugger 暂停时验证:name 能访问,age 可能因为未使用被优化掉:

** 图7-21 debugger检测未使用的age变量是否真被回收**

这点对工程实践的启示非常直接:

  • 规范上你可以认为“闭包会保留整个 AO”;
  • 但引擎实现会做逃逸分析/变量提升优化等,减少无用变量占用
  • 不要依赖这种优化写代码:它是实现细节,不是稳定契约(尤其跨引擎/跨版本)。

本章小结(落地清单)

  • V8 可能回收闭包外层 AO 中“未被使用的变量”(实现优化)。
  • 工程判断别靠“引擎可能帮我优化”,仍以 引用链是否可达 为主。
  • 评审时更关注:闭包是否捕获了不必要的大对象/DOM/业务上下文

九、进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行

9.1 多个闭包实例:互不影响,释放也只释放自己的那份

同一个 foo() 调两次,得到的是两套独立的 AO 与函数对象:

function foo() {
  var name = "小吴";
  var age = 18;

  function bar() {
    console.log(name);
    console.log(age);
  }

  return bar;
}

var fn = foo();
fn();

var baz = foo();

fn = null; // 只会释放 fn 对应的那一套引用链
baz();

这条结论在工程里特别重要:你清理了一个引用,不代表全局都释放了。如果你把闭包存进多个地方(例如多个数组、多个事件回调、多个缓存),就需要逐个断链。

9.2 为什么 null 可以解除引用,而 undefined 不行?

这里有一个值得思考的问题:为什么 null 可以解除引用,而 undefined 不行?

从“引用链”的角度看:

  • null 是一个明确的“空值”,把变量指向空处,等价于 把这条引用边砍掉
  • undefined 更多表达“未初始化/缺省值”,它依然是一个值;更关键的是,在很多语义下它并不被用作“主动断链”的表达(团队代码规范也通常不推荐用 undefined 表达释放)。

工程建议:释放引用请用 null(语义清晰、团队共识强、便于 code review 与静态检查)。

本章小结(落地清单)

  • 每次调用外层函数,都会创建一套新的 AO/函数对象:闭包实例彼此独立。
  • 释放引用只影响对应那条链:你清理一个,不会自动清理所有
  • 断链用 null:表达“我主动释放”,比 undefined 更清晰。

十、实战建议:把“闭包可控”落到团队工程规范里

下面给一份可以直接放进团队“代码评审 checklist / 性能排查 SOP”的清单。

10.1 评审 Checklist(闭包相关)

  • 捕获内容最小化:闭包里只引用必要字段,避免把整个 props/state/context/大对象 捕获进去。
  • 避免捕获 DOM 节点:尤其是长生命周期的闭包(事件回调/单例缓存)捕获 DOM,会让节点难以回收。
  • 长生命周期容器要可清理:全局数组、Map 缓存、事件总线、定时器回调——都要有对应的清理路径。
  • 组件/页面卸载必须断链:移除事件监听、取消订阅、清理定时器、清空缓存引用(= null)。
  • 缓存优先 WeakMap:key 是对象的缓存(如 DOM 节点、组件实例)优先 WeakMap,减少“缓存常驻”。

10.2 排查 SOP(内存/性能)

  1. Performance 勾选 Memory:复现操作,观察内存曲线是否持续上升(不回落)。
  2. 录制并看调用树:定位高耗时函数是否来自闭包创建/大对象捕获。
  3. 缩小复现:把闭包引用容器(数组/缓存)逐步置 null,观察趋势变化(不是立刻回收,但趋势会变)。
  4. 检查引用链:谁在持有闭包?(全局变量、单例模块、事件总线、定时器、DOM 监听器最常见)

10.3 指标验证(建议团队共用)

  • 内存指标:关键页面操作 5 分钟后,JS Heap 是否可稳定回落到阈值区间
  • 性能指标:关键交互的 Long Task 次数/总耗时是否下降
  • 回归验证:增加“卸载/切页/重复进入”压测脚本,验证引用链不会累积

总结:关键结论 + 团队落地建议

关键结论(背下来就够用)

  • 闭包的本质是:函数对象持有定义时的外层作用域引用(parentScope/词法环境) ,从而让外层 AO 继续可达。
  • 是否回收不看“函数执行没执行完”,只看 从根对象出发是否可达(标记清除的核心)。
  • 闭包造成的风险不是“用了闭包”,而是:闭包捕获了不该长期驻留的对象,并且闭包引用被长期持有
  • 释放闭包的关键动作是:断开引用链(置 null、移除监听、清空容器、取消订阅等)。
  • 引擎可能优化未使用变量(如 V8),但工程上不要依赖实现细节,仍以引用链分析为准。

下一步建议(怎么在团队里真正落地)

  1. 把“闭包评审 checklist”加入 PR 模板:涉及事件、缓存、定时器、订阅时必须勾选清理项。
  2. 建立 1~2 个“典型泄漏 demo”用于 onboarding:让新人用 Performance/Memory 亲手看见“可达性”是什么。
  3. 在关键业务页引入定期压测(重复进入/退出/滚动/筛选等),用指标验证“内存可回落”。
  4. 对缓存策略做统一约束:对象 key 的缓存优先 WeakMap;全局数组缓存必须提供清理 API。

只要团队能把闭包从“语法点”升级成“引用链与可达性”的共识,闭包就会从“玄学”变成“可控工具”。

花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案

作者 寅时码
2026年2月2日 09:58

花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案

我知道「React 孝子」很多,先别急着骂,看完再说

当你还在 useState 的闭包陷阱里跟 React 斗智斗勇,在 useEffect 依赖数组里当人肉编译器,在 Zustand 的 selector 里写到怀疑人生 —— 哥们,该换个活法了。

Signal,来自 Preact,本质就是 Vue 那套响应式的 React 版:对象引用读写,依赖自动追踪,不用你手动喂。接入 @preact/signals-react 之后你就会发现,原来「现代化」三个字可以这么写,而不是靠 React 那套「设计哲学」自嗨。


我知道「React 孝子」很多,每次讨论时他们总搬出两套话:

  1. 「React 手动挡、Vue 自动挡,老司机都是手动挡。」
    纯纯的逻辑谬误。开车的手动/自动和写代码的「要不要亲手管依赖」根本不是同一回事;把「控制欲」包装成「专业」是偷换概念。这比喻的荒谬程度,不亚于班主任那句「一个人浪费一分钟,全班四十个人就浪费四十分钟」 —— 时间不能那样线性叠给全班,框架优劣也不能用「手动/自动」一个轴判死刑。

  2. 「既然你这么讨厌 React,为什么还要用?」
    因为出现的早、生态繁荣,所以你必然要接触到。任何第三方前端库,第一个兼容目标都是 React; AI First 时代更狠

这些 AI 工具一打开,全是 React。用 React 不等于认同它每一处设计 —— 历史包袱和网络效应摆在那儿,换框架成本极高,而 AI 生成代码几乎全是 React,正反馈循环只会越滚越大。所以「讨厌」和「在用」可以同时成立,不矛盾。


不可否认的是,JSX 是划时代的

React 真正贡献的是:组件化、声明式 UI、单向数据流成了行业共识,后面 Vue、Svelte 的模板或语法糖,都是在「React 确立的范式」上做改进。

我写这篇不是为了当传教士,也不想一味骂街 —— 我只想安安静静分享自己的心得,讲述如何解决这些问题,顺便帮到被闭包和依赖数组折磨的人。

批评 useState 的闭包和 useEffect 的依赖数组,不等于否定整个 React;而是说这一块设计得反人类,值得用 Signal 之类的方式补上。


而且关于 React、Vue 我是有发言权的,两边源码都翻过一些,React 的很多坑我都自己填过

不是晒仓库,而是说明:我既在 React 里干活,又亲手绕过它的坑。所以下面聊 Signal 和状态管理,是在 React 生态里怎么活得更像人的实操结论,不是跟风捧新玩意儿,也不是键盘党嘴炮。


一、闭包陷阱:React 的「设计哲学」有多可笑

useState 有个祖传问题:setState 是异步的,你刚 set 完,下一秒换个函数读,拿到的还是旧值。逻辑一拆分,后面的函数永远活在「上一个渲染周期」的梦里。

// 问题:fn2 拿不到最新 count
const [count, setCount] = useState(0)

const fn1 = () => {
  setCount(count + 1)
  fn2() // count 仍是旧值,惊不惊喜?
}
const fn2 = () => {
  console.log(count) // 0,意不意外?
}

const handleXx = () => {
  fn1()
  fn2() // 这辈子都拿不到最新值
}

React 官方会说:这是「调度」「可预测性」「避免半成品 UI」 —— 翻译成人话就是:我让你拿不到你就是拿不到,你得按我的规矩来。一旦逻辑拆分、跨模块复用,你就得跟闭包斗智斗勇,写一堆 useCallbackuseRef 来擦屁股。这叫设计哲学?这叫甩锅给开发者。

Signal 不跟你玩这套。状态放在对象里,.value 读就是当前值,写就是立刻生效。没有快照,没有「下一次渲染才更新」,读到的永远是实时的。

import { signal } from '@preact/signals-react'

const count = signal(0)

const fn1 = () => {
  count.value += 1
  fn2() // 此时 count.value 已经是 1 了
}
const fn2 = () => {
  console.log(count.value) // 1,终于像个正常人该有的行为
}

暂时还得用 useState?行,用 useGetState 救个急,setCount.getLatest() 直接拿最新值,不用再传什么回调了。

// 救急方案:useGetState
import { useGetState } from 'hooks'

const [count, setCount] = useGetState(0)
const fn2 = () => {
  console.log(setCount.getLatest()) // 1
}

源码:github.com/beixiyo/rea…


二、useEffect 依赖数组:人肉编译器,你当定了

useEffect 的依赖数组,堪称 React 开发者的噩梦:漏写一个,闭包拿旧值;

多写一个,effect 跑成陀螺。复杂对象还得自己 useMemo 包一层,不然每次都是「新引用」,依赖数组形同虚设。

Signal 的 effectuseSignalEffect 直接自动追踪你在回调里读了哪些 signal,变了才跑。跟 Vue 的 watchEffect 一样,该有的智商它都有。

import { effect, signal } from '@preact/signals-react'

const count = signal(0)
const name = signal('Jane')

effect(() => {
  console.log(count.value, name.value)
  return () => console.log('cleanup')
})

不用写依赖数组,不用纠结「这个到底该不该塞进 deps」,不用当人肉依赖分析器。省下来的脑子,干点别的不好吗?


三、Signal API 简洁,没有废话

Signal 的 API 就四个:

  • signal(initial):建状态
  • computed(fn):派生
  • effect(fn):副作用
  • batch(fn):批量写,effect 只跑一次

没有 Action、Reducer、Slice,没有 Store 配置,没有中间件链。想用就写,写完就算。相比之下,下面那些「当红」库,个个都是模板代码生产器。

1. 渲染优化:直接传 signal 到 JSX,跳过组件重渲染

这是 Signal 最香的用法之一。count.value 会建立订阅,signal 一变组件就重渲染;但直接把 signal 丢进 JSX,Preact 会绑定到 DOM 文本节点,更新时只改 DOM,不触发组件重渲染。

const countOptimized = signal(0)

// ❌ 未优化:读 .value 会订阅,每次变化都重渲染
const UnoptimizedDisplay = memo(() => {
  useSignals()
  return <strong>{ countOptimized.value }</strong>  // 变色 = 重渲染
})

// ✅ 优化:直接传 signal,跳过 VDOM,只更新 DOM 文本
const OptimizedDisplay = memo(() => {
  useSignals()
  return (
    <strong><>{ countOptimized }</></strong>  // 不变色 = 无重渲染
  )
})

PixPin_2026-02-02_10-03-36.webp

点击 +1 时,未优化侧背景色会变(重渲染),优化侧可能不变色(直接改 DOM)。源码见 github.com/beixiyo/rea…

2. computed:派生状态,自动缓存

import { signal, computed } from '@preact/signals-react'

const count = signal(0)
const doubled = computed(() => count.value * 2)

// doubled.value 随 count 变,依赖自动追踪,不用写 useMemo 依赖数组

3. signals 原生 effect(推荐、最省脑)

import { signal, effect } from '@preact/signals-react'

export const count = signal(0)
export const doubled = signal(0)

// ✅ 自动依赖收集,不写依赖数组
effect(() => {
  doubled.value = count.value * 2
})

特点一句话说明:

  • 依赖是谁,运行时自动追踪
  • 读了 count.value,就只依赖 count
  • 重构安全,删代码=删依赖

4. Hook 版:useSignalEffect(组件内用)

适合必须写在组件里的副作用(比如依赖 props / 生命周期)。

import { signal, useSignalEffect } from '@preact/signals-react'

const count = signal(0)

export function Counter() {
  // ✅ 和 effect 一样:不用依赖数组
  useSignalEffect(() => {
    console.log('count changed:', count.value)
  })

  return (
    <button onClick={() => count.value++}>
      +1
    </button>
  )
}

你可以把它理解为:

useEffect + 自动依赖收集 + 无 deps

5. 对照:React useEffect 等价写法(反例)

useEffect(() => {
  console.log('count changed:', count)
}, [count]) // ❌ 人肉维护依赖

问题不在“能不能用”,而在:

  • 依赖要人想
  • 重构容易漏
  • 逻辑一复杂就开始糊 deps

在线体验

这是我部署的 Signal 示例 Demo,里面涵盖了几乎所有 API 用法,我就不啰嗦了,需要可以在线体验。

image.png

如果背景色变化代表重新渲染了


四、其他状态管理库:一个比一个离谱

1. Zustand:Selector 写到手酸,中间件叠成屎山

github.com/pmndrs/zust…

Zustand 本身不算复杂,但要按需订阅避免多余渲染?对不起,每个字段自己写 selector 去吧:

const useCount = () => useStore(state => state.count)
const useName = () => useStore(state => state.name)
// 字段一多,selector 写到腱鞘炎

更离谱的是中间件。persist、devtools、immer 一层套一层,套完就是这坨:

export const useCounterStore = create<typeof initState>()(immer(
  devtools(
    persist(
      () => initState,
      {
        name: 'counter',
        storage: createJSONStorage(() => sessionStorage),
        partialize: state => Object.fromEntries(
          Object.entries(state).filter(([key]) => !key.startsWith('user'))
        ),
      }
    ),
    { enabled: true }
  )
))

你觉得还能看?那是因为我特意格式化过了。真实项目里中间件一多,括号套括号,缩进套缩进,可读性直接归零。

这种层层嵌套的写法,纯纯一坨。你能确保每个同事代码都像我一样写得这么「讲究」?不能的话,这就是定时炸弹。


2. Jotai:原子化挺好,但 useAtom 要写吐了

github.com/pmndrs/jota…

Jotai 的原子化思路我认可,细粒度订阅、按需更新,没问题。但原版用法有个致命问题:每个 atom 都得单独 useAtom / useAtomValue,组件顶上一排 hook,字段一多直接爆炸。

const [count, setCount] = useAtom(countAtom)
const [name, setName] = useAtom(nameAtom)
const [age, setAge] = useAtom(ageAtom)
// 再来十个字段?继续往上叠呗

而且为了性能你得保证原子性,基础属性都得拆成独立 atom,每个组件每个属性都得来一遍 useAtom,繁琐到令人发指。

解决办法

我自己封装了 jotaiTool github.com/beixiyo/rea…

通过 createUseAtoms 传入 atom 对象,自动生成 useAtomsgetAtomsuseResetcreateReset 等,按需订阅、类型安全、组件外也能读写。算是给 Jotai 提供了点语法糖。

实现原理是通过 Proxy 返回,并且确保细粒度订阅和类型安全,支持 Reset 等高级特性。所有功能均以测试

我部属到了 CloudFlare,在线体验到 react-tool-70q.pages.dev/jotaiTest

如果背景色变化代表重新渲染了

image.png

示例:定义 atom 对象后调用 createUseAtoms,在组件里用 useAtoms() 拿到一个代理对象,直接读属性、写属性或调 setXxx,下划线开头的 key 会被自动过滤。

image.png

更多用法(含按需 selector、useReset、createReset)见仓库内 github.com/beixiyo/rea…

即便如此,你还是得理解 atom、selector、store 这一堆概念。Signal 呢?一个对象,.value 读写,完事。


3. Recoil:Jotai 的低配版,还要自己写 key

recoiljs.org/docs/introd…

和 Jotai 思路差不多,但每个 atom 都得手写唯一 key,既啰嗦又容易冲突。Recoil 还要你自己想字符串。太蠢了,不说了。


4. Valtio:曾经的最爱,但是有些问题无法解决

valtio.dev/

Valtio 用起来是真的爽,proxy 一包,改属性自动更新,Vue 那味。但有两个硬伤直接劝退:

  1. snap 返回 readonly,类型别扭得要死,和「改完直接用」的习惯完全不搭,类型安全天天跟你打架。
  2. input 组件有 bug,必须开 sync 模式才能正常用。底层和 React 的批量更新八字不合,这都能出问题,我也是服了。

5. Redux:沉浸式屎山,LSP 都救不了

redux.js.org/

Redux 的「单向数据流」「可预测」「规范化」 —— 翻译过来就是:写一堆 Action、Reducer、Slice、Middleware,模板代码堆成山。实现成本高到离谱,还衍生出了 Redux Toolkit 这个「简化版」屎山。懂的都懂。

最离谱的是找代码。你想找「用户列表」的数据定义在哪?Ctrl+点击、F12 跳转、LSP 智能导航 —— 通通没用。你只能看到一堆 Action、Reducer、Slice,真正的数据定义藏得跟谍战片一样。写这个库的人是不是不知道什么叫 LSP? 找代码纯靠全局搜索是吧?什么年代了,开发体验还停留在文本搜索时代,真是没救了。


五、React:2026 年了,还缺这缺那

React 19.2 终于上了 Keep-Alive,这么多年来难得干了件人事。但问题是:2025 年才上。这么基础的能力,社区自己 hack 了多少年?现在才官方支持,早干嘛去了。

目前 React 生态还缺啥:

  1. 官方好用的状态管理:Signal 这种「对象引用 + 自动依赖」才是现代前端的该有的样子。useState 的闭包陷阱、useEffect 的依赖数组,早该被扫进历史垃圾堆了。别让开发者再写屎山了。

  2. 官方 Router:第三方路由全都没 Keep-Alive。复杂应用只能自己造轮子,恶心到家了。所以我只能自己写一个 github.com/beixiyo/rea…

    内置页面缓存(Keep-Alive),LRU 策略 + include/exclude 白名单,再也不用切个 Tab 回来表单全丢。顺带还有全局守卫 beforeEach/afterEach、Vue 风格中间件、全局 navigate/replace/back,API 简洁无废话。

代码示例:github.com/beixiyo/rea…

import { lazy } from 'react'
import { RouterProvider, createBrowserRouter } from '@jl-org/react-router'

const router = createBrowserRouter({
  routes: [
    { path: '/', component: lazy(() => import('./views/home')) },
    {
      path: '/dashboard',
      component: lazy(() => import('./views/dashboard')),
      meta: { title: 'Dashboard', requiresAuth: true },
    },
    { path: '/list', component: lazy(() => import('./views/list')) },
  ],
  options: {
    // 页面缓存:只缓存指定路径,最多 5 个页面,LRU 淘汰
    cache: {
      limit: 5,
      include: ['/', '/dashboard', '/list'],  // 白名单,也可用 RegExp
    },
    beforeEach: async (to, _from, next) => {
      if (to.meta?.requiresAuth && !getUser()) {
        next('/login')
        return
      }
      next()
    },
    afterEach: (to) => {
      document.title = (typeof to.meta?.title === 'string' ? to.meta.title : 'App')
    },
  },
})
  1. 官方动画方案:还好有 Framer Motion 兜底。没有 Motion,有几个人知道 React 卸载动画咋写?官方文档有教吗?没有。全靠社区自救。

六、Signal 使用指南:安装与 Babel

1. 安装

pnpm i @preact/signals-react

跑业务用这四个 API 就够了:signalcomputedeffectbatch。若想少写一层订阅代码(见下文),再装 Babel 插件:

pnpm i @preact/signals-react-transform -D

2. Babel 插件:做了什么、和 React Compiler 冲突、不用时怎么写

Babel 做了什么

@preact/signals-react-transform 会在编译阶段扫描组件内对 signal.value读取,自动插入「订阅」逻辑。结果是:在 JSX 里写 count.value 时,不用 在组件里再调 useSignals(),组件也会在 signal 变化时正确重渲染。
换句话说,Babel 帮你把「谁在用这个 signal」分析好了,并注入订阅,所以你可以直接写:

const count = signal(0)
function Counter() {
  return <p>{count.value}</p>   // 不用 useSignals(),照样响应更新
}

官方文档是这么描述规则的:

  • 函数是组件吗?

  • 如果是的话,这个组件会使用信号吗?

  • 如果一个函数名称大写(例如函数 MyComponent() {})且包含 JSX,则称该函数为组件。

  • 如果函数的主体包含一个成员表达式引用 .value(即某某的值 ),我们假设它是信号。

如果你的函数/组件符合这些条件,这个插件会对它进行转换。

如果没有,就会被放任不管。

如果你有一个函数使用信号但不符合这些条件(例如手动调用 createElement 而不是使用 JSX),你可以添加带有字符串 @useSignals 的注释,指示该插件转换该函数。

你也可以手动选择不转换函数,方法是添加带有字符串 @noUseSignals 的注释。


和 React Compiler 冲突

同一份文件里,不能 同时启用 signals-react-transformbabel-plugin-react-compiler,否则响应式会乱(见 preactjs/signals#652)。

至于这个 issue 我是怎么找的,那当然不是人肉搜索,而是靠 AI + Bash + CLI

比如 Github 提供了 MCPgh CLI(Github CLI) 命令行工具,可以让你查找代码和 issue 等,下面再介绍如何使用 gh,这里先看效果

ai2.png

解决做法是按路径分流:例如只对 views/signals/ 下的文件用 transform,其它文件只用 react-compiler:

// vite.config.ts
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    react({
      babel: (id) => {
        const isSignals = /[\\/]views[\\/]signals[\\/]/.test(id)
        return {
          plugins: isSignals
            ? [['module:@preact/signals-react-transform']]
            : ['babel-plugin-react-compiler'],
        }
      },
    }),
  ],
})

不用 Babel 时代码怎么写

不装、或不用 signals-react-transform 时,在消费 signal 的组件里必须显式调用 useSignals(),否则读 count.value 不会建立订阅,界面不会更新:

import { useSignals } from '@preact/signals-react/runtime'
import { signal } from '@preact/signals-react'

const count = signal(0)

function Counter() {
  useSignals()   // 必须写:让组件订阅用到的 signal
  return <p>Value: {count.value}</p>
}

同时用 Signals 和 React Compiler 时,也只能用这种「手写 useSignals()」的方式,不能在同一文件上开 Babel transform。


七、gh CLI(Github CLI),让你的 AI 掌握整个 Github

GitHub CLI 是一个命令行工具,用于在终端中直接与 GitHub 交互。适合让 LLM 通过命令行查阅和阅读仓库内容,节省 MCP Token 消耗。

安装

# Windows (使用 winget 或 scoop)
# 或者 Github Releases 下载
winget install --id GitHub.cli
# 或
scoop install gh

# macOS
brew install gh

# Linux
# 参考:https://github.com/cli/cli/blob/trunk/docs/install_linux.md#debian
(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& sudo mkdir -p -m 755 /etc/apt/sources.list.d \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y

身份验证:

gh auth login              # 交互式登录
gh auth status             # 查看认证状态
gh auth refresh            # 刷新 token

仓库信息

# 建议优先使用 JSON 输出,避免 README 导致输出过长
gh repo view {owner}/{repo} --json name,description,primaryLanguage,stargazerCount,url

文件内容(最常用)

# 读取文件内容(需解码)
gh api "repos/{owner}/{repo}/contents/{path/to/file}" --jq '.content' | base64 -d

# 读取 README
gh api "repos/{owner}/{repo}/readme" --jq '.content' | base64 -d

# 列出目录(仅显示名称,节省 Token)
gh api "repos/{owner}/{repo}/contents/{path}" --jq '.[].name'

分支和提交

# 列出所有分支名称
gh api "repos/{owner}/{repo}/branches" --jq '.[].name'

# 获取最近 3 条提交记录(格式化输出)
gh api "repos/{owner}/{repo}/commits?per_page=3" --jq '.[] | {sha: .sha[0:7], message: .commit.message, author: .commit.author.name}'

Issue 和 PR

# 列表显示(带限制)
gh issue list --repo {owner}/{repo} --limit 5
gh pr list --repo {owner}/{repo} --limit 5

# 搜索特定关键词的 Issue
gh issue list --repo {owner}/{repo} --search "{keyword}" --limit 5

# 获取 PR 详情(JSON)
gh api "repos/{owner}/{repo}/pulls/{number}" --jq '{title, body, state, html_url}'

通用 API

gh api {endpoint} --jq '.field'              # 任意 API + jq 过滤
gh api -X POST {endpoint} -f key=value       # POST 请求

使用场景

  1. 快速查阅仓库结构:先用 gh repo view --json ... 了解概况,再用 gh api .../contents --jq '.[].name' 浏览根目录
  2. 深度查看代码:定位文件后,使用 gh api .../contents/path --jq '.content' | base64 -d 读取
  3. 排查历史:使用 gh api .../commits?per_page=5 查看最近变更

Vue-性能优化利器:Keep-Alive

2026年2月2日 09:35

前言

在后台管理系统或长列表页面中,我们经常遇到这样的需求:从列表进入详情页,返回时希望列表滚动位置、搜索条件都能完美保留。Vue 内置的 <KeepAlive> 正是为此而生。本文将带你从基础用法出发,直击其背后的缓存算法原理。


一、 什么是 Keep-Alive?

<KeepAlive> 是一个内置组件,用于缓存不活动的组件实例,而不是销毁它们。

  • 核心价值:保留组件状态、避免重复渲染 DOM、提升用户体验。
  • 应用场景:表单多步骤切换、列表页返回流、详情页页签切换。

二、 基础实战:结合 Vue Router 实现按需缓存

在 Vue 中,我们通常结合路由的 meta 字段和 <router-view> 的插槽语法来实现。

1. 路由配置

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';

const routes: Array<RouteRecordRaw> = [
    {
      path: '/your-path',
      name: 'YourComponentName',
      component: () => import('./views/YourComponent.vue'),
      meta: {
        keepAlive: true // 设置需要缓存
      }
    }
];

2. 宿主容器配置

App.vue 或主布局文件中,接着在对应<router-view>的中插入<keep-alive>,并设置include属性来匹配需要缓存的组件

代码段

  // includeComponents为对应的组件文件名称
  <router-view v-slot="{ Component }">
    <KeepAlive :include="includeComponents">
      <component :is="Component" />
    </KeepAlive>
  </router-view>

三、 特有的生命周期钩子

一旦组件被缓存,其正常的销毁流程将被“冻结”,取而代之的是两个专属钩子:

  • activated:组件被激活(初始化渲染或从缓存中恢复)时调用。此时可重新获取数据或重置滚动位置。
  • deactivated:组件被停用(离开当前路由)时调用。此时可清理定时器或取消未完成的请求。

⚠️ 注意:由于组件被缓存,onBeforeUnmountonUnmounted(Vue 2 中的 beforeDestroydestroyed不会被触发。


四、 深度进阶:Keep-Alive 的底层原理

<KeepAlive> 本质上是一个“无渲染组件”,它不渲染多余的 DOM,而是直接操作组件的 VNode。

1. 内存中的 Map 缓存

Keep-Alive 内部维护了一个 cache 对象(Map 结构)和一个 keys 队列(Array 结构):

  • Cache:键是组件的 key,值是组件的 vnode 实例。
  • Keys:记录缓存组件的顺序。

2. 渲染函数逻辑

render 函数执行时:

  1. 获取内部包裹的组件节点。
  2. 查找 cache 中是否存在该组件的实例。
  3. 存在:直接从缓存中获取实例,并更新该 key 在 keys 队列中的位置(移到最后)。
  4. 不存在:将其加入缓存。

3. LRU 缓存策略

如果缓存的组件过多,内存会爆炸吗?不会。 Vue 使用了 LRU (Least Recently Used) 最近最少使用 算法。当缓存数量超过 max 属性设定的阈值时,Vue 会自动销毁 keys 队列中最久没被访问过的那个组件实例。


五、 总结

  1. 组件名称 (name)include 匹配的是组件定义的 name 选项。在 Vue 3 <script setup> 中,如果你没有显式定义 name,Vue 会根据文件名自动生成,建议显式定义以防匹配失效。
  2. 多级嵌套路由:如果你的 <router-view> 层级很深,每一层都需要配置 <KeepAlive> 才能保证整条路径上的状态都被保留。
  3. Key 的重要性:在 <component :is> 上绑定正确的 :key,能有效防止在切换相同组件不同参数(如 /detail/1/detail/2)时出现缓存混乱。

用 CSS 取代 React:一行代码解决复杂交互

2026年2月2日 09:31

你有没有想过,网页上那些看似简单的交互效果,背后可能需要多复杂的代码?

比如,你把鼠标移到一个按钮上,整个卡片的样式都变了。或者,你选中了一个选项,整个列表的颜色都跟着改变。这些效果,我们通常需要用 JavaScript 来实现,在 React 这样的框架里,可能就要写不少 state 管理的代码。

前端开发似乎越来越复杂了。我们为了实现一些界面效果,不得不引入庞大的框架和工具链。但有时候,我忍不住会想,有没有更简单的方法?

最近,我发现 CSS 有了一个新的功能,叫做 :has 选择器。它非常强大,强大到可以让我们用一行 CSS 代码,就取代掉原来需要几十行 JavaScript 才能实现的功能。今天,我就想和大家聊聊这个神奇的 :has 选择器。

一、什么是 :has 选择器?

学习 CSS 的第一天,我们就知道,它的选择器是从上到下,从外到内的。比如,div p 会选中 div 元素里面的所有 p 元素。我们从来没法“反过来”,让子元素去影响父元素。

这就好比一条单行道,你只能往前开,不能掉头。想让一个段落 p 根据它内部是否有图片 img 来改变自己的样式,在过去是办不到的,只能求助于 JavaScript。

但是,:has 选择器的出现,彻底改变了这一点。它就像给 CSS 开了一扇“后门”,让我们可以选择一个元素的“父元素”或者“前一个兄弟元素”。

举个例子,我们想让所有包含图片的卡片(.card)都有一个特殊的边框,可以这么写:

.card:has(img) {
  border: 2px solid blue;
}

这行代码的意思是:如果一个 .card 元素“拥有”(has)一个 img 子元素,那么就给这个 .card 元素加上蓝色的边框。是不是非常直观?

这个小小的改变,却为我们打开了一个全新的世界。很多以前必须用 JavaScript 解决的交互问题,现在只用 CSS 就能轻松搞定。

二、告别复杂的焦点管理

我们来看一个实际的例子。假设我们正在做一个任务看板,上面有很多卡片,每个卡片上都有“打开”和“删除”两个按钮。

为了让键盘用户也能方便地操作,我们希望当用户通过 Tab 键选中某个按钮时,这张卡片能“弹出来”一点,并且根据选中的是“删除”还是“打开”按钮,显示不同的颜色边框。同时,其他未被选中的卡片会变灰,突出显示当前卡片。

如果用 React 来实现,思路大概是这样的:

  1. 我们需要在父组件中维护一个 state,记录当前哪个卡片的哪个按钮被选中了。
  2. 通过 onFocusonBlur 事件来更新这个 state
  3. state 改变时,父组件会重新渲染,给所有卡片传递新的 props,告诉它们应该显示什么样式。

可以想象,为了这么一个效果,代码会变得很复杂,而且每次按 Tab 键,都可能导致所有卡片重新渲染,造成性能问题。这也许就是为什么,我们很少在实际产品中看到这么精细的交互效果。

但是有了 :has 选择器,一切都变得简单了。

首先,我们给按钮加上 data- 属性,方便选中:

<button data-action="open">打开</button>
<button data-action="delete">删除</button>

然后,我们用 :has 来改变包含“已选中”按钮的卡片的样式:

/* 选中“删除”按钮时,卡片边框变红 */
.card:has([data-action='delete']:focus-visible) {
  transform: scale(1.02);
  border-top: 5px solid #f7bccb;
}

/* 选中“打开”按钮时,卡片边框变绿 */
.card:has([data-action='open']:focus-visible) {
  transform: scale(1.02);
  border-top: 5px solid #c3dccf;
}

:focus-visible 是一个伪类,它只在用户通过键盘(比如 Tab 键)获得焦点时才生效,鼠标点击则不会,非常适合做无障碍优化。

接下来,是最神奇的部分,如何让其他卡片变灰?我们同样可以用 :has 来选中“不包含”已选中按钮的卡片。

/* 当任意一个卡片被选中时,让其他所有卡片变灰 */
.cards-container:has(.card:focus-within) .card:not(:focus-within) {
    filter: grayscale(80%);
    opacity: 0.8;
}

就这样,我们没有写一行 JavaScript,就实现了一个非常优雅且高性能的键盘交互效果。代码不仅更简单,而且因为是浏览器原生支持的 CSS,性能也比 React 的方案好得多。

三、更智能的表单

:has 选择器在表单中的应用也非常广泛。

比如,我们希望在一个输入框被禁用(disabled)时,它旁边的标签(label)和描述文字也一起变灰。在以前,这需要 JavaScript 监听状态变化,然后手动给标签添加或移除一个类名。

现在,我们可以这样做:

/* 如果 fieldset 内部有一个被禁用的 input */
fieldset:has(input:disabled) label,
fieldset:has(input:disabled) .description {
  color: #d6d6d6;
}

同样,当一个列表项里的复选框被选中(:checked)时,我们可以轻松地改变整行的背景颜色:

li:has(input:checked) {
  background: #e8f0fe;
}

这些在过去需要用 JavaScript 脚本和状态管理才能实现的效果,现在都变成了纯粹的 CSS 声明。代码的可读性和可维护性都大大提高了。

四、一些思考

:has 选择器的出现,让我重新思考了前端开发中 CSS 和 JavaScript 的边界。

我们似乎已经习惯了用 JavaScript 来处理一切与“状态”和“交互”相关的事情。但实际上,很多所谓的“状态”,只是 DOM 元素自身的状态(比如 :focus, :checked, :disabled),它们完全可以在 CSS 内部被消化掉。

过度依赖 JavaScript,不仅让我们的代码变得更复杂,也可能带来不必要的性能开销。有时候,返璞归真,用最简单、最直接的方式去解决问题,反而效果更好。

当然,这并不是说 CSS 可以完全取代 React 或其他框架。React 在管理复杂应用状态、组件化开发等方面依然有巨大优势。但是,对于那些纯粹的、局部的 UI 交互,我们或许可以更多地求助于现代 CSS 的能力。

下一次,当你准备写一个 useState 来控制某个元素的样式时,不妨先停下来想一想:这个问题,能不能只用 CSS 来解决?

CSS Border 三角形:看似简单,实则精妙

作者 parade岁月
2026年2月2日 09:19

前言

最近又遇到了用 CSS 画三角形的需求。说实话,这已经不是第一次了。每次遇到,我都会去翻之前的代码,复制粘贴一段 border 样式,然后调试半天——"咦,怎么方向反了?""为什么这个边框要设成透明?"

这种重复的困惑让我决定彻底搞清楚这个问题。这篇文章记录了我的探索过程和最终的理解。

核心要点

实现三角形需要掌握三个关键点:

  1. 容器尺寸为 0width: 0; height: 0
  2. 设置特定方向的边框大小
  3. 只保留一个边框的颜色(其他边框设为透明)

举个例子:要实现左上角的三角形,可以用「上边框 + 右边框,保留上边框颜色」或者「左边框 + 下边框,保留左边框颜色」。

原理解析

直接从尺寸为 0 的场景理解可能比较抽象,我们先从容器有尺寸的情况开始分析。

场景一:四个边框

先看一个容器有尺寸且设置了四个边框的例子:

.container {
    width: 200px;
    height: 200px;
    background-color: white;

    border-style: solid;
    border-width: 80px;
    border-color: #ffcccc #7d5fff #67e6dc #aaa69d;
}

效果对比:

2.png

关键发现:

  • 当容器有尺寸时,四个边框呈现为四个梯形
  • 当容器尺寸变为 0 时,中心点收缩,四个梯形变成了四个三角形

看到这里,要实现上下左右四个方向的三角形,该用哪个边框就一目了然了:

  • 上边框→ 向下的三角形
  • 右边框→ 向左的三角形
  • 下边框→ 向上的三角形
  • 左边框→ 向右的三角形

这里有个容易混淆的点:边框的位置和三角形的指向是相反的。上边框形成的是向下指的三角形,因为它是从上边"长"出来的。

场景二:两个边框

再看一个只设置两个边框的例子:

.container {
    width: 200px;
    height: 200px;
    background-color: white;

    border-style: solid;
    border-width: 80px 80px 0 0;
    border-color: #ffcccc #7d5fff #67e6dc #aaa69d;
}

效果对比:

3.png

关键发现:

  • 有尺寸时是两个梯形
  • 尺寸为 0 时,右上角被"切开",形成两个三角形(左上和右下)

实战技巧

看完原理,我们来总结一下快速决定用哪几个边框的方法:

1. 实现上下左右方向的三角形

这种情况最简单,只需要记住:

  • 实现上三角形:设置左、右、下三个边框,只保留下边框颜色
  • 实现右三角形:设置上、下、左三个边框,只保留左边框颜色
  • 实现下三角形:设置左、右、上三个边框,只保留上边框颜色
  • 实现左三角形:设置上、下、右三个边框,只保留右边框颜色
/* 示例:向上的三角形 */
.triangle-up {
    width: 0;
    height: 0;
    border-left: 50px solid transparent;
    border-right: 50px solid transparent;
    border-bottom: 50px solid #333;
}

2. 实现边角方向的三角形

这种情况稍微复杂一点,但有个简单的思路:

核心原则:要完整保留某个角,需要用包含这个角的两条边,且不能让这个角被"切开"。

左上三角形为例:

  • 如果用上边框作为主边(保留颜色),辅边只能用右边框

    • ✅ 为什么?因为用右边框不会切到左上角
    • ❌ 如果用左边框,左上角就会被切开
  • 如果用左边框作为主边(保留颜色),辅边只能用下边框

    • ✅ 为什么?因为用下边框不会切到左上角
    • ❌ 如果用上边框,左上角就会被切开
/* 方案一:上边框 + 右边框 */
.triangle-top-left-1 {
    width: 0;
    height: 0;
    border-top: 50px solid #333;
    border-right: 50px solid transparent;
}

/* 方案二:左边框 + 下边框 */
.triangle-top-left-2 {
    width: 0;
    height: 0;
    border-left: 50px solid #333;
    border-bottom: 50px solid transparent;
}

补充说明

这里分享一个我的个人理解:

为什么至少需要两个方向的边框?

因为只有一个边框的话,它就没有宽或高(容器尺寸为 0)。当有两个边框时:

  • 一个边框负责宽度
  • 一个边框负责高度

最后才能形成三角形。隐藏其中一个边框的颜色(设为 transparent),就能得到你想要的三角形。

小伙伴们心心念念的倒水解谜游戏实战,终于来了...

2026年2月2日 08:48

我倒我倒我倒倒倒

引言

哈喽大家好,我是亿元程序员。

亿元Cocos小游戏实战合集

笔者的《亿元Cocos小游戏实战合集》,从更新的第一天开始,就有许多小伙伴问到,什么时候更新一期倒水解谜的游戏实战?

快更新,别逼我催你

其实笔者早有计划,再加上小伙伴们的强烈需求,这一期,小伙伴们心心念念的倒水解谜游戏实战,终于来了...

言归正传,本期带大家一起来看看,在Cocos游戏开发中,倒水解谜游戏的核心部分,并加入到我的《亿元Cocos小游戏实战合集》中去。

本文源工程可在文末获取,小伙伴们自行前往。

什么是倒水解谜游戏?

简单易上手,看了就想玩

可能有很多小伙伴还不知道这类游戏,简单介绍一下:

倒水解谜游戏是一类以 “倒水 / 液体转移” 为核心玩法的休闲益智游戏。

玩家需通过容器间的倾倒操作达成特定目标(如液体颜色统一、精确容量分配),核心考验逻辑推理与步骤规划能力。

通俗的理解,和我们在学校时,通过水杯倒来倒去得到指定毫升数的水相类似(瞎掰的)。

既然是倒水游戏,水是重要的一个游戏元素,那它是怎么实现的呢?

水的效果

关于水的效果,其实有比较多的实现方法,既可以通过美术妹子实现,也可以通过Shader实现,甚至还能用Graphics组件画!

美术妹子: “一边凉快去。”

既然上面的方法行不通,我们只能通过Shader来实现了,至于Graphics,小伙伴们可以自行挑战一下。

1. 资源准备

简单准备一张杯子形状的图片和对应杯子形状纯白Mask

铁粉友情助攻

简单拼一下UI

最喜欢拼UI了,没有之一

通过资源管理器通过右键->创建->传统无光照着色器(Effect)创建一个Shader

手把手

和上面一样创建一个材质,将对应的Effect改成我们自己创建的并且勾选USE_TEXTURE

脚把脚

然后把材质拖到我们杯子的MaskSprite上,实际要在代码中动态创建,不然会共用同一个材质。

演示用

2. 水的颜色

搜索sprite找到builtin-sprite,双击打开把内容复制到我们创建的Shader中去当做模板。

想要修改水的颜色,我们可以找到最下面的片段着色器,将color改成红色vec4(1,0,0,1);,分别对应RGBA

硬编码

效果如下:

火红的烧杯

3. 水的分层

想要实现水的分层,我们可以通过简单UV划分,下半部分为蓝色,上半部分为红色,实际项目可以通过实际水的高度去划分。

还是硬编码

效果如下:

自古红蓝出CP

4. 水的波纹

水倒下时,会在杯子中形成波纹,我们可以通过下面的公式来实现。

非必要情况下别记

测试Shader如下,当UV高于指定高度时,形成波纹。使用cc_time时,需要在片段开始时引入#include <cc-global>

依旧是硬编码

效果如下:

火辣辣的波纹

5. 水的倾斜

水的倾斜可以通过下面的函数进行。

AI一个接一个不吱声

效果如下:

还真有效果

相信通过上面的内容,大家都已经学会了如何在Shader中模拟水的效果。

那我们要怎么样在实际游戏中进行通过代码控制动态结合呢?

动态传值到Shader

想要通过代码动态传值到Shader,我们通常要通过以下几个步骤。

1.Properties

properties用于将Shader中定义的uniform进行别名映射。

这个映射可以是某个uniform的完整映射,也可以是具体某个分量的映射(使用target参数)。

代码示例如下,colorsheights可以传vec4数组,iResultvec2,其余是数值。

很简单的

2.uniform

uniformGLSL中的关键字,声明的变量表示全局统一变量:

  • 它的值在一次绘制调用中保持不变(对所有像素 / 顶点都相同)。

  • 可以从CPU端(游戏逻辑代码)直接赋值,GPU端(着色器)只读。

  • 常用于传递动态参数(如颜色、角度、纹理等)。

一一对应

3.setProperty

在TypeScript中可以使用Material类的setProperty方法进行设置,代码示例如下:

这应该是很熟悉了吧

4.使用

完成上述步骤后,可以直接使用,非常简单。

使用很简单

以上就是倒水解谜游戏的核心部分,其余简单的代码逻辑由于篇幅问题就不再赘述,可以通过源码查看。

相信小伙伴们学废之后,可以完成到下面的效果。

效果演示

结语

**那么问题来了,**倒水解谜类游戏已经过去了这么久,其变种依旧非常火爆。

小伙伴们知道为什么吗?

本文实战完整源码已集成到亿元Cocos小游戏实战合集(6/10),内含体验链接,有疑问笔者手把手讲解。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

亿元Cocos小游戏实战合集

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

如何在CocosCreator3.8中实现割绳子游戏效果

如何在CocosCreator3.8中实现动态切割模型?

Cocos游戏开发中的贴花效果

解释一下 var、let 和 const 之间的区别

作者 Smilezyl
2026年2月2日 08:02

解释一下 var、let 和 const 之间的区别

核心答案

varletconst 是 JavaScript 中声明变量的三种方式,主要区别在于:

特性 var let const
作用域 函数作用域 块级作用域 块级作用域
变量提升 有(初始化为 undefined) 有(暂时性死区 TDZ) 有(暂时性死区 TDZ)
重复声明 允许 不允许 不允许
重新赋值 允许 允许 不允许
全局对象属性 是(挂载到 window)

深入解析

1. 作用域差异

var 是函数作用域:只有函数能创建新的作用域,iffor 等代码块不会限制 var 的访问范围。

let/const 是块级作用域:任何 {} 代码块都能创建独立的作用域。

2. 变量提升与暂时性死区(TDZ)

三者都存在变量提升,但行为不同:

  • var:声明和初始化都被提升,初始值为 undefined
  • let/const:只有声明被提升,但在声明语句执行前不能访问(处于 TDZ)

TDZ 的本质是:从块级作用域开始到变量声明语句之间的区域,访问该变量会抛出 ReferenceError

3. 底层机制

在 V8 引擎中,letconst 声明的变量在编译阶段会被放入一个特殊的「词法环境」中,并标记为「未初始化」。只有执行到声明语句时才会被初始化。

4. const 的「不可变」误区

const 只保证变量绑定不可变(即不能重新赋值),但如果值是引用类型,对象的属性是可以修改的。


代码示例

作用域差异

// var - 函数作用域
function testVar() {
  if (true) {
    var x = 1;
  }
  console.log(x); // 1 - 可以访问
}

// let - 块级作用域
function testLet() {
  if (true) {
    let y = 1;
  }
  console.log(y); // ReferenceError: y is not defined
}

暂时性死区

console.log(a); // undefined(var 提升)
console.log(b); // ReferenceError: Cannot access 'b' before initialization
var a = 1;
let b = 2;

经典闭包问题

// var 的问题
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 输出: 3, 3, 3

// let 解决方案
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 100);
}
// 输出: 0, 1, 2

const 与引用类型

const obj = { name: 'Alice' };
obj.name = 'Bob';      // ✅ 允许修改属性
obj = { name: 'Eve' }; // ❌ TypeError: Assignment to constant variable

// 如果需要完全不可变
const frozen = Object.freeze({ name: 'Alice' });
frozen.name = 'Bob';   // 静默失败(严格模式下报错)

全局对象属性

var globalVar = 'var';
let globalLet = 'let';

console.log(window.globalVar); // 'var'
console.log(window.globalLet); // undefined

面试技巧

可能的追问方向

  1. 「为什么 let 能解决 for 循环的闭包问题?」

    • 每次迭代都会创建新的块级作用域,let 声明的变量在每个作用域中都是独立的副本
  2. 「TDZ 的设计目的是什么?」

    • 帮助开发者发现错误(在声明前使用变量通常是 bug)
    • 使 const 的语义更合理(避免先是 undefined 再变成真正的值)
  3. 「如何让 const 声明的对象完全不可变?」

    • Object.freeze() 浅冻结
    • 递归冻结实现深度不可变
    • 使用 Immutable.js 等库
  4. 「实际开发中如何选择?」

    • 默认使用 const
    • 需要重新赋值时使用 let
    • 避免使用 var(除非维护老代码)

展示深度理解

  • 提及 ES6 规范中 let/const 引入的「词法环境」概念
  • 说明 TDZ 是运行时概念,不是语法限制
  • 讨论 var 在历史上存在的原因和现代 JavaScript 的演进

一句话总结

var 是函数作用域且会提升为 undefined,letconst 是块级作用域且有暂时性死区,const 额外限制不能重新赋值——现代开发中优先使用 const,需要重新赋值时用 let,避免使用 var

JavaScript 继承的进阶之路:从原型链到圣杯模式的架构思考

作者 NEXT06
2026年2月1日 23:34

在面向对象编程的设计哲学中,继承的本质是为了解决两个核心问题:数据的独立性与行为的共享性。对于 JavaScript 这种基于原型的动态语言而言,实现继承的过程,实际上就是不断在“构造函数”与“原型链”之间寻找平衡点的过程。

本文将基于底层原理,剖析从基础的构造函数借用到成熟的圣杯模式(寄生组合式继承)的演进逻辑,揭示其背后的架构思考。

一、 引言:属性与方法的二元对立

JavaScript 的对象包含属性(State)和方法(Behavior)。在继承关系中,这二者有着截然不同的需求:

  1. 属性需要私有化:子类实例必须拥有独立的属性副本。例如,每一只 Cat 都应该有自己独立的 name 和 color,修改一只猫的名字不应影响另一只。
  2. 方法需要复用:父类的方法(如 species 属性或公共函数)应当存在于内存的某一处,供所有子类实例引用,而非在每个实例中重复创建。

为了解决这一矛盾,JavaScript 引入了 call/apply 来处理属性拷贝,利用 prototype 来处理方法复用。

二、 构造函数的借用:属性的物理拷贝

在最早期的继承尝试中,我们首先解决的是属性继承的问题。通过在子类构造函数中强行执行父类构造函数,我们可以“窃取”父类的属性初始化逻辑。

JavaScript

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = '动物';

function Cat(name, age, color) {
    // 核心逻辑:构造函数借用
    // 将 Animal 的 this 指向当前的 Cat 实例
    Animal.apply(this, [name, age]); 
    this.color = color;
    console.log(this, '////');
}

架构分析

Animal.apply(this, [name, age]) 的底层逻辑在于,它将 Animal 当作一个普通函数执行,并将执行上下文(Context)强制绑定到当前正在创建的 Cat 实例上。这实际上是一次物理拷贝——父类中定义的 this.name 和 this.age 被直接赋值到了子类实例上。

致命缺陷

这种模式仅解决了“属性私有化”,却完全丢失了“行为复用”。
由于 Cat 的原型链并未指向 Animal 的原型,因此定义在 Animal.prototype 上的 species 属性和任何共有方法,对于 Cat 实例来说都是不可见的。

image.png

三、 原型链的连接:简单粗暴的代价

为了让子类能访问父类原型上的方法,最直观的做法是将子类的原型对象指向父类的一个实例。这也是早期很多教程中的标准写法:

JavaScript

// 组合继承的雏形
Cat.prototype = new Animal(); 
Cat.prototype.constructor = Cat;

架构思考与缺陷

这行代码虽然打通了原型链(cat.proto 指向了 Animal 实例,而该实例的 proto 指向 Animal.prototype),但它引入了严重的副作用,这种副作用在大型应用中是不可接受的:

  1. 父类构造函数执行了两次

    • 第一次:new Animal() 赋值给原型时。
    • 第二次:Cat 实例化时内部调用的 Animal.apply。
    • 如果 Animal 初始化逻辑中包含昂贵的操作(如 DOM 绑定、大量计算),这种双重开销是极大的浪费。
  2. 属性冗余与内存污染

    • Cat.prototype 是 Animal 的一个实例,因此它不可避免地拥有了 name 和 age 属性(虽然是 undefined)。
    • 同时,Cat 实例本身通过 apply 也拥有了 name 和 age。
    • 实例属性遮蔽了原型上的同名属性,原型上的这些属性不仅毫无意义,还占用了内存空间。

四、 完美的中间层:圣杯模式(寄生组合式继承)

如何既能继承 Animal.prototype,又不执行 Animal 构造函数从而避免副作用?
答案是引入一个纯净的中间层。这就是所谓的“圣杯模式”或“寄生组合式继承”。

JavaScript

function extend(Child, Parent) {
    // 1. 创建中介函数 F
    var F = function() {}; 
    
    // 2. 将中介的原型指向父类原型
    F.prototype = Parent.prototype;
    
    // 3. 子类原型指向中介的实例
    Child.prototype = new F(); 
    
    // 4. 修正构造函数指针
    Child.prototype.constructor = Child;
    
    // 5. 可选:保存父类原型的引用(Uber/Super)
    Child.prototype.uber = Parent.prototype;
}

核心解构:为何引入空对象 F?

F 在这里充当了一个缓冲带(Buffer)代理(Proxy)的角色。

  1. 性能无损:F 是一个空函数,执行 new F() 几乎不消耗任何 CPU 资源,也不会产生任何多余的实例属性(内存纯净)。
  2. 链条维持:new F() 产生的对象,其 proto 依然指向 F.prototype(即 Parent.prototype)。因此,原型链依然是通畅的:
    Cat实例 -> F实例(空) -> Animal.prototype -> Object.prototype
  3. 隔离副作用:我们成功绕过了 new Animal(),从而避免了父类构造函数的执行。

关于 Constructor 的修正

重写 Child.prototype 会导致 constructor 属性丢失(或指向 Parent)。虽然这对 JS 引擎的运行影响不大,但为了保持原型链的完整性和可追溯性,手动修正 Child.prototype.constructor = Child 是架构设计中的必要规范。

image.png

五、 封装与现代视角

将上述逻辑封装后,我们得到了一个通用的继承辅助函数。在现代 JavaScript 开发中,这一模式极其重要。

JavaScript

function extend(Parent, Child) {
  var F = function() {}; 
  F.prototype = Parent.prototype;
  Child.prototype = new F(); 
  Child.prototype.constructor = Child;
}

extend(Animal, Cat);
const cat = new Cat('加菲猫', 2, '橘色');

ES6 Class 的本质

ES6 引入的 class extends 语法,本质上就是上述“圣杯模式”的语法糖。
在 ES5 中我们手动创建的 F 实例,在规范层面对应了 Object.create(Parent.prototype)。

JavaScript

// 现代写法的等价逻辑
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Object.create 内部的 Polyfill 实现,正是利用了临时的空构造函数来创建一个新对象并关联原型,这与我们手动编写的 F 异曲同工。

六、 总结

JavaScript 的继承机制并非简单的“复制粘贴”,而是一场关于内存管理与引用关系的博弈。

从直接修改原型链导致的副作用,到引入空对象 F 作为隔离层,圣杯模式的核心价值在于:它在保持原型链引用(实现方法复用)的同时,彻底切断了与父类构造函数实体的直接耦合(实现状态解耦与性能优化)。

理解这一模式,不仅能让你掌握 JavaScript 继承的终极方案,更能深刻理解动态语言中“原型”这一概念的灵活性与本质。

深度拆解 Chrome:从多进程架构到站点隔离的演进之路

作者 NEXT06
2026年2月1日 23:10

浏览器早已不再仅仅是一个用于展示网页的应用程序。当我们点击 Chrome 图标的那一刻,实际上是启动了一个小型的、高度复杂的分布式操作系统。

对于前端工程师而言,理解浏览器的底层架构不仅是面试中的加分项,更是编写高性能、高稳定性代码的基石。本文将结合计算机底层原理,深入剖析 Chrome 如何从早期的单进程模型演进为如今的服务化、站点隔离架构,探讨其中的架构权衡(Trade-off)。

一、 基石:硬件与操作系统

要理解软件架构,必须先理解其运行的物理环境。浏览器的核心工作依赖于计算机的两大计算单元:CPU 和 GPU。

1.1 CPU 与 GPU 的协同

  • CPU (Central Processing Unit) :计算机的大脑。核心数量少(通常 4-8 核),但每个核心非常强大,擅长处理复杂的串行任务、逻辑判断和系统调度。
  • GPU (Graphics Processing Unit) :计算机的图形处理单元。拥有成百上千个简单的核心,擅长并行处理简单的重复任务。最初用于图形渲染,现在也广泛应用于计算加速。

在浏览器中,CPU 负责构建 DOM 树、计算样式、执行 JavaScript;而 GPU 则负责将这些内容光栅化并合成到屏幕上,实现流畅的动画与滚动。

1.2 进程 (Process) 与线程 (Thread)

操作系统通过进程和线程来管理程序的运行,这是理解浏览器架构的关键。

  • 进程 (Process) :资源分配的最小单位。

    • 操作系统为每个进程分配独立的内存空间(堆栈)。
    • 隔离性:一个进程崩溃通常不会影响其他进程。
    • 通信:进程间的数据是无法直接访问的,必须通过 IPC (Inter Process Communication) 机制进行通信。
  • 线程 (Thread) :程序执行的最小单位。

    • 依附于进程存在,一个进程可以包含多个线程。
    • 共享性:同一进程内的线程共享进程的内存资源。
    • 风险:一个线程崩溃(如出现未捕获的异常导致分段错误),往往会导致整个进程崩溃。

屏幕截图 2026-02-01 230057.png

二、 演进:从单进程到多进程架构

2.1 单进程时代的痛点

在早期的浏览器(如旧版 IE)中,整个浏览器应用运行在一个进程中。网络请求、JS 执行、页面渲染、插件运行都在同一个线程或进程空间内。这就导致了著名的“三个一”问题:

  1. 不稳定:一个插件或标签页崩溃,导致整个浏览器闪退。
  2. 不流畅:JS 代码的死循环或繁重的渲染任务会阻塞 UI 线程,导致浏览器界面无法响应用户操作。
  3. 不安全:由于内存共享,恶意的 JavaScript 代码可能窥探到其他页面的数据。

2.2 Chrome 的多进程变革

Chrome 的发布重新定义了浏览器架构。其核心理念是:将浏览器的不同功能模块拆分为独立的进程,通过 IPC 协作。

当我们打开一个 Chrome 标签页时,后台实际上启动了多个进程:

  1. 浏览器主进程 (Browser Process)

    • 职责:浏览器的“总管”。负责地址栏、书签栏、前进后退按钮等 UI 显示;负责管理各个子进程的创建与销毁;负责文件访问与高层级的网络协调。
    • 数量:通常仅有一个。
  2. 渲染进程 (Renderer Process)

    • 职责:核心工作区。负责将 HTML/CSS/JS 转换为用户可见的页面。
    • 核心组件:Blink 排版引擎、V8 JavaScript 引擎。
    • 特性:运行在沙箱 (Sandbox)  模式下,被限制访问文件系统和操作系统任意功能,以保证安全。
    • 数量:默认策略下,每个标签页开启一个独立的渲染进程。
  3. GPU 进程 (GPU Process)

    • 职责:负责 CSS 3D 变换、Canvas 绘制以及页面的合成(Compositing)。
    • 独立原因:GPU 驱动程序通常由不同厂商提供,不够稳定。将 GPU 操作隔离在独立进程中,即使驱动崩溃,浏览器主进程也能恢复,而不会导致软件完全瘫痪。
  4. 网络进程 (Network Process)

    • 职责:负责页面的网络资源加载(DNS 查询、TCP 连接、HTTP 解析)。
    • 演进:早期是浏览器主进程中的一个线程,现已独立为进程,防止网络解析库的漏洞影响主进程。
  5. 插件进程 (Plugin Process)

    • 职责:负责 Flash 等插件的运行(现已逐渐淘汰),确保插件崩溃不影响页面。

image.png

三、 进阶:架构的动态性与安全性

Chrome 的架构并非一成不变,而是根据设备性能和安全需求进行动态调整。

3.1 服务化 (Service-fication)

为了适应不同性能的设备,Chrome 引入了面向服务的架构理念。

  • 高性能设备(Desktop) :采取拆分策略。将网络服务、存储服务、设备服务等拆分为完全独立的进程。虽然增加了进程间通信的开销和内存占用,但极大提升了稳定性。
  • 低性能设备(Android Go/Low-end phones) :采取合并策略。将网络、GPU 等服务合并回浏览器主进程中。虽然牺牲了部分隔离性,但显著降低了内存占用(Memory Footprint)。

这种架构的灵活性,使得 Chrome 能够覆盖从高性能工作站到入门级手机的广泛设备。

3.2 站点隔离 (Site Isolation)

这是 Chrome 架构史上为了防御 CPU 级别漏洞(Spectre 和 Meltdown)而进行的最重大升级。

  • 背景:在旧的架构中(Process-per-tab),如果一个标签页 a.com 通过 iframe 嵌入了 b.com,这两个网页通常共享同一个渲染进程。虽然有同源策略(SOP)限制,但在 Spectre 漏洞面前,共享进程内存意味着恶意网页可以读取同一进程内其他网页的敏感数据。

  • 变革Process-per-site-instance。Chrome 现在强制为每个跨站点的 iframe 分配独立的渲染进程。

  • 代价

    1. 内存激增:每个 iframe 都是一个独立的进程,基础库副本增多。
    2. 复杂性:即便是简单的“Ctrl+F”页面查找,现在也需要跨越多个进程进行通信和搜索聚合。开发者工具(DevTools)也必须重构以支持跨进程调试。

image.png

四、 深度思考:性能与资源的博弈

作为开发者,理解架构不仅是为了通过面试,更为了理解代码运行的代价。

4.1 内存 vs 稳定/安全

Chrome 被戏称为“内存怪兽”,这并非技术缺陷,而是有意的架构选择(Architecture Choice)。

  • 以空间换时间/稳定性:多进程意味着每个进程都要加载一份公共的基础设施(如 V8 引擎实例、Node 绑定等)。这带来了额外的内存开销。
  • IPC 的代价:进程间通信比线程间通信慢。架构师必须在“隔离带来的安全稳定”和“通信带来的延迟”之间寻找平衡点。

4.2 开发者的应对之道

在“站点隔离”开启的今天,一个 Tab 页可能对应着 4-5 个渲染进程(如果包含多个跨域 iframe)。

  • 内存泄漏更可怕:单个页面的内存泄漏现在可能导致操作系统级别的卡顿,因为占用的物理内存更多了。
  • 主线程依然是瓶颈:尽管有多进程架构,但每个渲染进程内部的 JS 执行依然是单线程的。不要误以为多进程就能解决 while(true) 造成的页面卡死。

五、 总结

Chrome 的架构演进史,就是一部计算机系统设计的教科书。从早期的单进程一锅端,到多进程的职责分离,再到如今的服务化与站点隔离,Chrome 始终遵循着以下设计哲学:

  1. 隔离 (Isolation) :故障隔离,安全隔离。让崩溃止步于局部,让数据死守于沙箱。
  2. 并行 (Parallelism) :充分利用多核 CPU 和 GPU 的能力,将渲染、网络、计算分发处理。
  3. 适应性 (Adaptability) :通过服务化架构,在不同硬件约束下动态调整策略。

打开 Chrome,你不仅仅是打开了一个网页,你是启动了一个精密运转的现代计算集群。

与DOM共舞:让网页“活”起来的魔法

作者 Lee川
2026年2月1日 22:53

与DOM共舞:让网页“活”起来的魔法

想象一下,你正在装修一个新家。房子本身是空的,就像一个没有内容的网页。DOM(文档对象模型)就像是这座房子的三维立体设计图,让你可以轻松找到每个房间、每件家具,然后按你的想法重新布置。

初识DOM:网页的“骨架与灵魂”

简单来说,DOM是浏览器将网页转换成的树状结构。当浏览器加载一个网页时,它会做两件事:

  1. 解析HTML代码,建立DOM树
  2. 让JavaScript通过DOM API与这个树互动
<!-- 一个简单的HTML结构 -->
<!DOCTYPE html>
<html>
<head>
    <title>我的小站</title>
</head>
<body>
    <h1>欢迎光临!</h1>
    <div id="main-content">
        <p>今天天气真好</p>
        <button>点击我!</button>
    </div>
</body>
</html>

浏览器看到这个代码后,会创建这样一个DOM树:

html
├── head
│   └── title
└── body
    ├── h1
    └── div#main-content
        ├── p
        └── button

四大法宝:选择、修改、创建、监听

法宝一:选择元素(找到那把椅子)

想要改变网页,首先得找到要改变的元素:

// 通过ID找(最精确,就像用房间号找人)
let mainDiv = document.getElementById('main-content');

// 通过类名找(找到一类元素,就像找所有红色椅子)
let redChairs = document.getElementsByClassName('red-chair');

// 通过标签名找(找到所有同类型元素,就像找所有椅子)
let allParagraphs = document.getElementsByTagName('p');

// 现代方法:querySelector(CSS选择器的威力!)
let firstButton = document.querySelector('button'); // 第一个按钮
let allButtons = document.querySelectorAll('button'); // 所有按钮
let specialDiv = document.querySelector('#main-content .special'); // ID为main-content内所有类为special的元素

法宝二:修改元素(给椅子换个颜色)

找到元素后,我们就可以改变它了:

// 修改内容
let title = document.querySelector('h1');
title.textContent = '热烈欢迎!'; // 改变文本
title.innerHTML = '<em>超级</em>热烈欢迎!'; // 可以包含HTML标签

// 修改样式
title.style.color = 'blue'; // 文字变蓝
title.style.fontSize = '2.5rem'; // 字号变大
title.style.backgroundColor = 'yellow'; // 背景变黄

// 修改属性
let image = document.querySelector('img');
image.src = 'new-picture.jpg'; // 更换图片
image.alt = '一张美丽的风景图'; // 添加替代文本

// 添加/移除类(更优雅的样式修改方式)
title.classList.add('highlight'); // 添加类
title.classList.remove('old-style'); // 移除类
title.classList.toggle('hidden'); // 切换类(有则移除,无则添加)

法宝三:创建元素(添置新家具)

// 创建新元素
let newParagraph = document.createElement('p');
newParagraph.textContent = '这是刚刚添加的新段落!';

// 设置属性
newParagraph.id = 'special-paragraph';
newParagraph.className = 'content-box';

// 添加到页面中
let container = document.getElementById('main-content');
container.appendChild(newParagraph); // 添加到末尾

// 或者添加到特定位置
let firstP = container.querySelector('p');
container.insertBefore(newParagraph, firstP); // 插入到第一个段落前

法宝四:事件监听(让按钮“活”起来)

这是DOM编程最有趣的部分!让网页能“听到”用户的操作:

let button = document.querySelector('button');

// 方法1:直接添加事件监听器
button.addEventListener('click', function() {
    alert('按钮被点击了!');
    this.style.backgroundColor = 'red'; // this指向被点击的按钮
});

// 方法2:使用命名函数(更清晰)
function handleClick(event) {
    console.log('点击位置:', event.clientX, event.clientY);
    event.target.textContent = '已点击!';
}

button.addEventListener('click', handleClick);

// 更多常见事件
let input = document.querySelector('input');
input.addEventListener('input', function() {
    console.log('输入框内容变化了:', this.value);
});

window.addEventListener('resize', function() {
    console.log('窗口大小改变了!');
});

document.addEventListener('keydown', function(event) {
    if(event.key === 'Escape') {
        console.log('按下了ESC键!');
    }
});

实战演练:制作一个简单计数器

让我们用刚学的知识,做一个点击计数器:

<!DOCTYPE html>
<html>
<head>
    <style>
        .counter {
            text-align: center;
            padding: 20px;
            font-family: Arial, sans-serif;
        }
        
        .number {
            font-size: 3rem;
            color: #3498db;
            margin: 20px 0;
        }
        
        button {
            background-color: #2ecc71;
            color: white;
            border: none;
            padding: 10px 20px;
            font-size: 1.2rem;
            border-radius: 5px;
            cursor: pointer;
            margin: 5px;
        }
        
        button:hover {
            background-color: #27ae60;
        }
        
        .reset-btn {
            background-color: #e74c3c;
        }
        
        .reset-btn:hover {
            background-color: #c0392b;
        }
    </style>
</head>
<body>
    <div class="counter">
        <h1>简单计数器</h1>
        <div class="number" id="count">0</div>
        <button id="increase-btn">+ 增加</button>
        <button id="decrease-btn">- 减少</button>
        <button id="reset-btn" class="reset-btn">重置</button>
    </div>

    <script>
        // 获取所有需要的元素
        let countElement = document.getElementById('count');
        let increaseBtn = document.getElementById('increase-btn');
        let decreaseBtn = document.getElementById('decrease-btn');
        let resetBtn = document.getElementById('reset-btn');
        
        // 初始化计数器
        let count = 0;
        
        // 更新显示的函数
        function updateDisplay() {
            countElement.textContent = count;
            
            // 根据数值改变颜色
            if(count > 0) {
                countElement.style.color = '#2ecc71';
            } else if(count < 0) {
                countElement.style.color = '#e74c3c';
            } else {
                countElement.style.color = '#3498db';
            }
        }
        
        // 增加按钮点击事件
        increaseBtn.addEventListener('click', function() {
            count++;
            updateDisplay();
        });
        
        // 减少按钮点击事件
        decreaseBtn.addEventListener('click', function() {
            count--;
            updateDisplay();
        });
        
        // 重置按钮点击事件
        resetBtn.addEventListener('click', function() {
            count = 0;
            updateDisplay();
        });
        
        // 键盘控制
        document.addEventListener('keydown', function(event) {
            if(event.key === 'ArrowUp') {
                count++;
                updateDisplay();
            } else if(event.key === 'ArrowDown') {
                count--;
                updateDisplay();
            } else if(event.key === 'r' || event.key === 'R') {
                count = 0;
                updateDisplay();
            }
        });
    </script>
</body>
</html>

DOM编程小贴士

  1. 等待DOM就绪:在操作DOM前,确保页面已加载完成

    document.addEventListener('DOMContentLoaded', function() {
        // 这里写你的DOM操作代码
    });
    
  2. 缓存选择结果:如果多次使用同一个元素,保存到变量中

    // 不好:每次都重新查找
    document.querySelector('.item').style.color = 'red';
    document.querySelector('.item').textContent = '新内容';
    
    // 好:缓存起来
    let item = document.querySelector('.item');
    item.style.color = 'red';
    item.textContent = '新内容';
    
  3. 事件委托:给父元素添加事件监听,处理子元素

    // 不好:给每个按钮单独添加
    // 好:给父元素添加一个监听器
    document.getElementById('button-container').addEventListener('click', function(event) {
        if(event.target.tagName === 'BUTTON') {
            console.log('点击了按钮:', event.target.textContent);
        }
    });
    

总结

DOM编程就像是在玩乐高积木:

  • 选择元素是找到你想要的那块积木
  • 修改元素是给积木涂上新颜色
  • 创建元素是制造新的积木块
  • 事件监听是让你的积木能“响应”触碰

记住,DOM不是JavaScript的一部分,而是浏览器提供的接口。通过它,JavaScript可以与网页进行对话,让静态的页面变得动态、交互、生动有趣。

现在,打开浏览器的开发者工具(F12),在Console标签里尝试一些DOM操作吧!从document.body.style.backgroundColor = 'lightblue'开始,亲眼见证你如何通过几行代码改变整个网页。DOM编程的大门已经为你敞开,快去创造属于你的交互世界吧!

JavaScript 作用域:小白也能秒懂的“变量藏宝图”

2026年2月1日 22:52

JavaScript 作用域:小白也能秒懂的“变量藏宝图”

一句话定义
作用域(Scope)就是 JavaScript 规定“变量能被谁看见、在哪儿能用、用完后归谁管”的一套家规。
它不是魔法,而是一张清晰的「变量藏宝图」——告诉你每个变量藏在哪、怎么找、什么时候该收走。


一、为什么需要作用域?——先看一个“混乱现场”

想象你在厨房做饭(全局环境),锅碗瓢盆都摆在台面上(全局变量),谁都能拿。
这时你儿子跑进来想煮泡面(调用一个函数),他顺手把盐罐子(变量 salt)挪到自己小灶台上(函数内部)。
好处:他用盐时不会误碰你的酱油瓶;
坏处:如果你没提前备好盐,他喊“爸爸,盐呢?”,你却说“我这儿没盐啊!”——因为他的盐只在他小灶台上有。

作用域,就是给每个“小灶台”划好地盘,让变量各安其位、互不干扰。


二、JS 中的三种“地盘”(作用域类型)

类型 谁的地盘? 生命周期 能否重复声明? 小白记忆口诀
全局作用域 整个 .js 文件最外层 页面打开→关闭 ✅(但不推荐) “客厅”——全家都能进
函数作用域 函数 {} 内部 函数调用开始→结束 ✅(var) “卧室”——只有本函数能进
块级作用域(ES6+) {} 大括号内(如 iffor{} 块执行开始→结束 ❌(let/const) “保险柜”——钥匙(let/const)才开得开

关键提示

  • var 只有全局函数作用域没有块级作用域if{}for{} 里的 var 会“漏”到外面!)
  • let / const 支持块级作用域,是现代 JS 的“安全卫士”。

三、变量提升(Hoisting)——JS 的“未卜先知”特性(也是坑的源头!)

现象还原(例子)

showName(); // ✅ 输出:"函数showName执行了"
console.log(myname); // ✅ 输出:undefined(不是报错!)
var myname = "张三";
function showName() {
  console.log("函数showName执行了");
}

为什么会这样?

JS 引擎执行分两步:

  1. 编译阶段(预处理) :像扫地机器人一样,先把所有 varfunction 声明“拎出来”,放到当前作用域顶部(但 var 只提升声明,不提升赋值!);
  2. 执行阶段:再从上往下真正运行代码。

所以这段代码,引擎眼里其实是:

// 编译后等价于:
var myname;                    // ← 提升了!但值是 undefined
function showName() { ... }    // ← 提升了!且整个函数体都上来了

// 执行时:
showName();        // ✅ 能调用
console.log(myname); // ✅ 输出 undefined
myname = "张三";     // ← 这句才真正赋值

比喻
就像你点外卖前,餐厅已把“菜单”(声明)和“厨师”(函数)准备好,但“菜还没炒”(赋值没发生)。你问“今天有啥菜?”——答:“有,但还没做”,所以是 undefined,不是 ReferenceError

为什么说它是“设计缺陷”?

  • 容易让人误以为 var myname = "张三" 是原子操作,结果发现 myname 在赋值前就能被访问(只是值为 undefined);
  • 导致 下面中这种迷惑行为:
var name = "张三";
function showName(){
  console.log(name); // ❓ 输出 undefined!不是"张三"
  if(true){
    var name = "李四"; // ← var 会提升到函数顶部!等价于函数开头就写了 var name;
  }
}

因为 var name 在函数内被提升,覆盖了外部的 name,但此时还没赋值,所以第一次 console.log 拿到的是 undefined


四、ES6 的救星:letconst —— 告别“未卜先知”,拥抱“按需加载”

它们三大优势:

特性 var let / const
变量提升 ✅ 提升声明(值为 undefined 不提升!声明前访问 → ReferenceError
块级作用域 ❌ 不支持(if{} 里声明会泄漏) ✅ 支持(if{}for{} 是独立小房间)
重复声明 ✅ 同一作用域可多次 var x ❌ 报错(避免意外覆盖)

实例对比( 3.js vs 4.js

3.js(var + 块级)→ 混乱

var name = "张三";
function showName(){
  console.log(name); // ❌ undefined(被提升的 var name 覆盖)
  if(false){ var name = "李四"; } // ← 即使不执行,var 仍提升!
}

4.js(let + 块级)→ 清晰

let name = "张三";
function showName(){
  console.log(name); // ✅ "张三"(let 不提升,沿作用域链向上找到外层的 name)
  if(false){ let name = "李四"; } // ← 这个 name 只在 if 里有效,对外无影响
}

比喻
var 像老式广播——还没播音就先占频道;
let/const 像微信语音——你点“发起通话”那一刻才建立连接,之前谁也听不见。


五、作用域链:变量的“寻亲之路”

当 JS 查找一个变量(比如 console.log(x)),它不会瞎找,而是按固定路线“寻亲”:

  1. 先看当前作用域(比如函数内、if{} 内)有没有 x
  2. 没有?就去上一级作用域(比如函数外层)找;
  3. 还没有?继续往上,直到全局作用域
  4. 全局也没有 → ReferenceError: x is not defined

这就是 作用域链(Scope Chain) —— 一条从内到外、逐级向上查找的“亲情链”。

下面例子 完美演示了这点:

var globalVar = "我是全局变量";
function myFunction(){
  var localVar = "我是局部变量";
  console.log(globalVar); // ✅ 找到!(全局作用域)
  console.log(localVar);  // ✅ 找到!(当前函数作用域)
}
myFunction();
console.log(globalVar); // ✅ 全局可见
console.log(localVar);  // ❌ ReferenceError!(局部变量出函数即“失联”)

六、深入一步:函数执行时,JS 引擎内部怎么“管”变量?

function foo(){
  var a=1;
  let b =2;
  {
    let b=3;//维护一个栈
    var c=4;
    let d=5;
    console.log(a);
    console.log(b);
  }
  console.log(b);
  console.log(c);
  console.log(d);
}
foo();

当你调用 foo(),引擎会创建一个 执行上下文(Execution Context) ,它包含两个关键“仓库”:

  • 变量环境(Variable Environment) :存放 var 声明 + 函数声明(可被提升);
  • 词法环境(Lexical Environment) :存放 let/const 声明(块级作用域,不可提升)。

这两个仓库不是并列的,而是嵌套的栈结构——就像俄罗斯套娃:外层是函数的词法环境,内层是 {} 块的词法环境。

👉 下面将用四张图,一步步还原 foo() 执行时的“变量搬家现场”:


[图1] foo函数刚被调用,执行上下文创建完成

图注
foo 函数刚被调用,执行上下文创建完成。

  • 变量环境:var avar c 已声明(值为 undefined),因 var 提升;
  • 词法环境:最外层 let b 声明已存在(值为 undefined),等待赋值。
    此时还未执行任何语句,所有变量处于“待命状态”。

屏幕截图 2026-02-01 222335.png


🖼️ [图2] 执行到 a = 1; c = 4; 后:变量环境更新

图注
执行到 a = 1var c = 4 后:

  • 变量环境:a 被赋值为 1c 被赋值为 4var 声明虽提升,但赋值发生在执行时);
  • 词法环境:外层 b 仍为 undefined(尚未执行 let b = 2)。
    注意:var c = 4 虽写在 {} 内,但因 var 无块级作用域,它属于整个函数的变量环境

屏幕截图 2026-02-01 222430.png


🖼️ [图3] 进入 { } 块后:新词法环境诞生!

图注
当执行流进入 { } 块时,引擎创建了一个新的词法环境(橙色框),作为当前作用域的“子仓库”:

  • 新词法环境:let b = 3let d = 5 被声明(初始值 undefined,随后赋值);
  • 原词法环境(外层):let b = 2 依然存在,但被新环境“遮蔽”(shadowing);
  • 变量环境不变:a=1, c=4 仍在函数级变量环境中。
    红色箭头表示:console.log(a) 查找变量时,先在当前块词法环境找 → 没有 → 再去外层词法环境找 → 还是没有 → 最终去变量环境找到 a=1。这就是作用域链的实时演示!

屏幕截图 2026-02-01 222457.png


🖼️ [图4] 块执行完毕后:内层词法环境销毁

图注
{ } 块执行结束,其对应的词法环境被销毁(橙色框消失):

  • 内层 b=3d=5 随之“退休”,不再可访问;
  • 外层词法环境恢复主导地位:b=2 重新可见;
  • 变量环境依旧稳定:a=1, c=4 全局有效。
    此时执行 console.log(b) 输出 2console.log(d) 报错 —— 因为 d 只活在块内,块一结束,它就“退休”了。
    ✅ 这就是块级作用域的生命周期:随块生,随块灭。

屏幕截图 2026-02-01 222511.png


七、通过图解,我们看到的本质

通过这四张图,我们看到:

  • var 的变量像“钉子户”,住在函数级的变量环境里,搬不走、拆不掉;
  • let/const 的变量像“租客”,住在层层嵌套的词法环境里,块一结束,立刻退房;
  • 引擎查找变量时,先查当前词法环境 → 再查外层词法环境 → 最后查变量环境——这条路径,就是作用域链的物理实现。

现在,你就能理解为什么 let 会有“暂时性死区”了:在词法环境里,变量必须等到 let x 那一行执行完,才算“正式入住”,之前访问就是“敲空房门”——报错!


八、“暂时性死区”(TDZ)—— let/const 的安全锁

这是 let/const 区别于 var 的隐藏机制:

在块级作用域中,从块开始到 let/const 声明语句执行前,这个变量处于 暂时性死区(Temporal Dead Zone, TDZ) —— 任何访问都会报错!

// 你的 `8.js` 示例:
let name = "张三";
{
  console.log(name); // ❌ ReferenceError! 
  let name = "李四"; // ← TDZ:name 已声明但未初始化,禁止访问!
}

比喻
就像银行保险柜(块级作用域),柜门(let name)还没打开前,你连“里面有没有东西”都不准问——直接报错,强制你按顺序操作。


九、闭包(简单提一句,留作延伸)

(你笔记里提到,但示例未展开,我们点到为止)
当一个内部函数记住并访问它的词法作用域时,就形成了闭包。
它让“局部变量”在函数执行完后依然“活”着(比如计数器、私有变量)。
👉 这正是作用域链 + 变量生命周期的精妙组合——后续可专题详解。


十、小白行动清单(马上就能用!)

场景 推荐写法 原因
声明一个会变化的变量(如计数器、临时值) let count = 0; ✅ 块级作用域 + 无提升 + 不可重复声明
声明一个永不改变的常量(如 API 地址、配置) const API_URL = "https://api.xxx"; ✅ 更安全 + 语义清晰
永远不要用 var(除非维护超老代码) var x = 1; ⚠️ 易引发提升陷阱、作用域泄漏

最后送你一句心法:

“作用域不是限制,而是保护;变量提升不是bug,而是历史;而 let/const,是你手握的现代钥匙。”
—— 理解它,你就拿到了 JS 执行机制的第一把密钥。


🔥🔥🔥 React18 源码学习 - hook 原理

作者 yyyao
2026年2月1日 21:27

前言

本文的React代码版本为18.2.0

可调试的代码仓库为:GitHub - yyyao-hh/react-debug at master-pure7

React16.8引入Hook以来,它彻底改变了我们编写React组件的方式。Hook不仅让函数组件具备了类组件的能力,还带来了更优雅的代码组织和逻辑复用方式。本文将深入React18源码,全面解析Hook的实现机制,揭示其背后的设计思想和实现细节。

Hook 的定义

首先看几个常用hooks的定义。可以看出useStateuseEffect都是通过dispatcher对象去调用对应的方法

  • useState: dispatcher.useState
  • useEffect: dispatcher.useEffect
  • ...
/* react/packages/react/src/ReactHooks.js */

export function useState(...) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

export function useEffect(...) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}

然后我们找到初始化定义的地方,dispatcher是通过ReactCurrentDispatcher.current取值的

/* react/packages/react/src/ReactCurrentDispatcher.js */

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher)
};

/* react/packages/react/src/ReactHooks.js */

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return ((dispatcher: any): Dispatcher);
}

那对于ReactCurrentDispatcher.current的赋值操作,我们得追溯到renderWithHooks函数(在构建Fiber树一文时有提到)。在渲染一个函数组件时,在调用组件函数本身之前,会先设置好正确的Dispatcher

依旧通过current === null来判断当前是初始化(HooksDispatcherOnMount)还是更新(HooksDispatcherOnUpdate),其中包含了各种hooks的操作

/* react/packages/react-reconciler/src/ReactFiberHooks.old.js */

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // 调用函数组件
  let children = Component(props, secondArg);
  return children;
};

// 1. 初始化(Mount)
const HooksDispatcherOnMount: Dispatcher = {
  useEffect: mountEffect,
  useRef: mountRef,
  useState: mountState,
  ...
};

// 2. 更新(Update)
const HooksDispatcherOnUpdate: Dispatcher = {
  useEffect: updateEffect,
  useRef: updateRef,
  useState: updateState,
  ...
};

初始化时的 Hook

观察HooksDispatcherOnMount中的每一个处理函数,我们发现每个函数中都会调用mountWorkInProgressHook 函数

// react/packages/react-reconciler/src/ReactFiberHooks.old.js
const HooksDispatcherOnMount: Dispatcher = {
  useEffect: mountEffect,
  useRef: mountRef,
  useState: mountState
};

// useEffect
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  ...
};
function mountEffect(...) {
  return mountEffectImpl(...);
};

// useRef
function mountRef(...) {
  const hook = mountWorkInProgressHook();
  ...
};

// useState
function mountState(...) {
  const hook = mountWorkInProgressHook();
  ...
};

我们紧接着观察这个函数,

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null, // 用于存储当前的状态值
    baseState: null,     // 基础状态: 用于计算新的状态
    baseQueue: null,     // 基础队列: 存储被跳过的更新(并发模式下)
    queue: null,         // 更新队列: 存储所有要处理的状态更新 (最重要!)
    next: null,          // 指向下一个Hook的指针 (用于构成链表)
  };

  // 保存hook到链表上
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

更新时的 Hook

观察HooksDispatcherOnUpdate中的每一个处理函数,我们发现每个函数中都会调用updateWorkInProgressHook函数

// react/packages/react-reconciler/src/ReactFiberHooks.old.js
const HooksDispatcherOnUpdate: Dispatcher = {
  useEffect: updateEffect,
  useRef: updateRef,
  useState: updateState
};

// updateEffect
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook();
  ...
};
function updateEffect(...) {
  return updateEffectImpl(...);
};

// updateRef
function updateRef(...) {
  const hook = updateWorkInProgressHook();
  ...
};

// updateState
function updateReducer(...) {
  const hook = updateWorkInProgressHook();
  ...
};
function updateState(...) {
  return updateReducer(basicStateReducer, (initialState: any));
};

我们紧接着观察这个函数,

function updateWorkInProgressHook() {
  // 获取 current 树上的 Fiber 的 hook 链表
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.

    if (nextCurrentHook === null) {
      throw new Error('Rendered more hooks than during the previous render.');
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

总结

React Hook的实现展示了几个重要的软件设计原则:

  1. 链表数据结构:通过简单的链表结构高效管理组件状态
  2. 闭包的应用:利用闭包捕获状态和更新函数
  3. 函数式编程思想:纯函数与副作用分离
  4. 优先级调度:在并发模式下实现高性能更新

Hook的设计不仅解决了React开发中的实际问题,也为未来的React发展奠定了基础。理解Hook的实现原理,有助于我们编写更高效、更可靠的React应用。

下一章我们将详细的学习某个具体hook的实现原理

LeetCode 48. 旋转图像:原地旋转最优解法

作者 Wect
2026年2月1日 20:21

在LeetCode数组类题目中,「原地操作」是高频考点,也是提升解题难度的关键。它要求我们不借助额外空间,直接修改输入数据,既考验对数组索引的敏感度,也容易因细节失误踩坑。今天我们聚焦第48题「旋转图像」,拆解题干要求,详解最优原地解法(即你提供的TypeScript代码),帮你吃透核心逻辑、避开常见误区,轻松拿下这道经典题目。

一、题干核心要求(必看)

题目给出一个 n×n 的二维矩阵 matrix 表示一幅图像,要求我们将其顺时针旋转90度,且必须满足以下核心约束:

  • 原地修改:禁止使用另一个矩阵存储旋转后的结果,只能直接操作输入的 matrix

  • 边界说明:n 的范围是 [1, 20],矩阵元素为整数,无需处理边界外异常,但需保证旋转后索引不越界、元素位置正确。

举个直观例子:输入3×3矩阵 [[1,2,3],[4,5,6],[7,8,9]],旋转后需得到 [[7,4,1],[8,5,2],[9,6,3]]

二、最优原地解法(你的代码,逐行解析)

你提供的代码是「按圈旋转、4元素循环交换」的最优实现——逻辑直观、无冗余、效率拉满(时间复杂度 O(n²),空间复杂度 O(1),完全满足原地要求)。下面逐行拆解,让你清楚每一步的作用和底层逻辑。


/**
 Do not return anything, modify matrix in-place instead.
 */
function rotate(matrix: number[][]): void {
  const side = matrix.length;
  if (side === 1) return;
  const laps = Math.floor(side / 2);

  for (let lap = 0; lap < laps; lap++) {
    const start = lap;
    const end = side - lap - 1;
    for (let index = start; index < end; index++) {
      // 保存当前位置的初始值(顶部边)
      const tempTop = matrix[start][index];
      
      // 1. 左侧边 -> 顶部边(对应位置)
      matrix[start][index] = matrix[side - 1 - index][start];
      
      // 2. 底部边 -> 左侧边(对应位置)
      matrix[side - 1 - index][start] = matrix[end][side - 1 - index];
      
      // 3. 右侧边 -> 底部边(对应位置)
      matrix[end][side - 1 - index] = matrix[index][end];
      
      // 4. 顶部边初始值 -> 右侧边(对应位置)
      matrix[index][end] = tempTop;
    }
  }
}

第一步:边界处理与圈数计算


const side = matrix.length;
if (side === 1) return;
const laps = Math.floor(side / 2);

这3行是解题的基础,主要解决「无需旋转」和「旋转多少圈」的问题:

  • side = matrix.length:获取矩阵的边长(n×n矩阵,边长即n);

  • if (side === 1) return:边界优化——1×1矩阵旋转后还是自身,无需任何操作,直接返回,避免无效循环;

  • laps = Math.floor(side / 2):计算需要旋转的圈数。核心逻辑:矩阵旋转是「从外到内,一圈一圈旋转」,n为奇数时,中心元素旋转后位置不变,无需旋转,因此圈数为n的一半(向下取整)。

举例说明:4×4矩阵(偶数),圈数=2(外圈、内圈);3×3矩阵(奇数),圈数=1(仅外圈,中心元素5无需旋转)。

第二步:外层循环——遍历每一圈


for (let lap = 0; lap < laps; lap++) {
  const start = lap;
  const end = side - lap - 1;
  // 内层循环:处理当前圈的元素
}

外层循环控制「旋转哪一圈」,每循环一次,向内推进一圈,关键是确定当前圈的「边界索引」:

  • start = lap:当前圈的「起始索引」(行和列一致)。比如第0圈(最外圈),start=0;第1圈(内圈),start=1,以此类推;

  • end = side - lap - 1:当前圈的「结束索引」(行和列一致)。比如4×4矩阵,第0圈end=3(最右/最下边界),第1圈end=2(内圈边界);3×3矩阵,第0圈end=2。

有了start和end,就确定了当前圈的范围:行和列都从start到end,构成一个正方形的圈。

第三步:内层循环——处理当前圈的元素(核心交换逻辑)


for (let index = start; index < end; index++) {
  // 保存当前位置的初始值(顶部边)
  const tempTop = matrix[start][index];
  
  // 1. 左侧边 -> 顶部边(对应位置)
  matrix[start][index] = matrix[side - 1 - index][start];
  
  // 2. 底部边 -> 左侧边(对应位置)
  matrix[side - 1 - index][start] = matrix[end][side - 1 - index];
  
  // 3. 右侧边 -> 底部边(对应位置)
  matrix[end][side - 1 - index] = matrix[index][end];
  
  // 4. 顶部边初始值 -> 右侧边(对应位置)
  matrix[index][end] = tempTop;
}

这是整个代码的核心,也是原地旋转的关键——每一圈的元素,按「顶部边→右侧边→底部边→左侧边」的顺序,4个对应位置循环交换。我们用「临时变量保存起始值」,避免赋值时覆盖原数据,逐句解析:

1. 确定交换的4个核心位置

以当前圈的「顶部边元素 matrix[start][index]」为起点,这4个位置的对应关系的核心逻辑是:顺时针旋转90度,每个元素的位置都会转移到它的「顺时针相邻边」的对应位置,具体对应如下:

  • 顶部边:matrix[start][index] —— 要移动到「右侧边」的对应位置;

  • 左侧边:matrix[side-1-index][start] —— 要移动到「顶部边」的当前位置;

  • 底部边:matrix[end][side-1-index] —— 要移动到「左侧边」的对应位置;

  • 右侧边:matrix[index][end] —— 要移动到「底部边」的对应位置。

2. 4步循环赋值(关键,避免覆盖)

赋值顺序不能乱,必须从「左侧边→顶部边」开始,最后将顶部边的初始值赋给右侧边,原因是:我们用tempTop保存了顶部边的初始值,若先修改顶部边,会导致后续赋值丢失原始数据。

  1. const tempTop = matrix[start][index]:保存顶部边当前元素的初始值,防止后续赋值覆盖;

  2. matrix[start][index] = matrix[side-1-index][start]:将左侧边的对应元素,移动到顶部边的当前位置;

  3. matrix[side-1-index][start] = matrix[end][side-1-index]:将底部边的对应元素,移动到左侧边的对应位置;

  4. matrix[end][side-1-index] = matrix[index][end]:将右侧边的对应元素,移动到底部边的对应位置;

  5. matrix[index][end] = tempTop:将顶部边的初始值(tempTop),移动到右侧边的对应位置。

用3×3矩阵举例,直观理解交换过程

输入3×3矩阵:[[1,2,3],[4,5,6],[7,8,9]],side=3,laps=1,仅旋转最外圈(start=0,end=2),index从0遍历到1(不包含end=2):

当index=0时:

  • tempTop = matrix[0][0] = 1(保存顶部边初始值);

  • matrix[0][0] = matrix[2][0] = 7(左侧边→顶部边);

  • matrix[2][0] = matrix[2][2] = 9(底部边→左侧边);

  • matrix[2][2] = matrix[0][2] = 3(右侧边→底部边);

  • matrix[0][2] = tempTop = 1(顶部边初始值→右侧边);

  • 此时矩阵变为:[[7,2,1],[4,5,6],[9,8,3]]

当index=1时:

  • tempTop = matrix[0][1] = 2(保存顶部边初始值);

  • matrix[0][1] = matrix[1][0] = 4(左侧边→顶部边);

  • matrix[1][0] = matrix[2][1] = 8(底部边→左侧边);

  • matrix[2][1] = matrix[1][2] = 6(右侧边→底部边);

  • matrix[1][2] = tempTop = 2(顶部边初始值→右侧边);

  • 最终矩阵变为:[[7,4,1],[8,5,2],[9,6,3]],旋转完成。

三、常见误区避坑(新手必看)

很多人实现原地旋转时,容易踩坑导致结果错误或索引越界,结合这道题和你的代码,总结3个高频误区:

误区1:索引颠倒(最致命)

二维矩阵 matrix[x][y] 中,x 是行号(垂直方向,从上到下递增),y 是列号(水平方向,从左到右递增)。新手容易把行和列颠倒,比如误将 matrix[side-1-index][start] 写成 matrix[start][side-1-index],导致元素赋值到错误位置。

误区2:赋值顺序错误(覆盖原始数据)

若不先保存顶部边的初始值(tempTop),或颠倒赋值顺序(比如先将顶部边的值赋给右侧边),会导致后续赋值时,原始数据被覆盖,最终旋转结果错误。你的代码通过「先保存、再从左到右赋值」完美避开了这个坑。

误区3:圈数计算错误

有人会直接用 laps = side / 2(不向下取整),当side为奇数时,会多循环一次(比如3×3矩阵,laps=1.5,循环2次),导致中心元素被错误修改。正确写法是 Math.floor(side / 2),确保奇数边长时,中心元素不被处理。

四、拓展:另一种简洁解法(转置+反转)

除了你的「按圈交换」思路,还有一种更简洁的原地解法——「先转置矩阵,再反转每一行」,代码更短,适合面试时快速书写,这里也提供TypeScript实现,供你拓展思路:


function rotate(matrix: number[][]): void {
  const n = matrix.length;
  // 第一步:转置矩阵(行变列,列变行)
  for (let i = 0; i < n; i++) {
    for (let j = i; j < n; j++) {
      [matrix[i][j], matrix[j][i]] = [matrix[j][i], matrix[i][j]];
    }
  }
  // 第二步:反转每一行(完成顺时针旋转90度)
  for (let i = 0; i < n; i++) {
    matrix[i].reverse();
  }
}

逻辑说明:转置矩阵后,每一行的元素顺序恰好是旋转后元素的逆序,反转每一行即可得到顺时针旋转90度的结果。比如3×3矩阵,转置后为 [[1,4,7],[2,5,8],[3,6,9]],反转每一行后就是目标结果。

对比你的解法:这种方法代码更简洁,但需要理解「转置+反转」与「顺时针旋转90度」的等价性,对新手不够友好;而你的解法逻辑更直观,贴合旋转的本质,更容易理解和调试,推荐新手优先掌握。

五、刷题总结

LeetCode 48题「旋转图像」的核心是「原地操作」,你的代码已经是最优解法,总结关键点:

  1. 核心思路:按圈旋转,从外到内,每一圈的4个对应位置循环交换;

  2. 关键技巧:用临时变量保存起始值,避免赋值覆盖,赋值顺序不能乱;

  3. 效率优势:时间复杂度O(n²)(遍历所有元素一次),空间复杂度O(1)(无额外空间),完全满足题目要求;

  4. 避坑重点:明确矩阵行和列的索引含义,正确计算旋转圈数,避免赋值顺序错误。

最后提醒:刷题时,建议把你的代码复制到LeetCode编辑器,测试不同用例(偶数n、奇数n),亲手调试每一步的索引变化,才能真正吃透逻辑,下次遇到类似的原地旋转题目,就能快速上手~

❌
❌