普通视图

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

用大白话搞懂 Object.assign()

作者 SuperEugene
2026年2月11日 10:21

一、Object.assign() 到底是啥?

Object.assign() 可以把多个对象的属性“合并”到第一个参数指定的目标对象里,并返回这个目标对象。

用人话说就是:把后面对象里的属性,一个个“拷贝”到第一个对象上,类似于“把东西塞进一个目标对象”。

二、基本语法

Object.assign(目标对象, 源对象1, 源对象2, ...)

要点:

  • 第一个参数是目标对象,后面的参数都是源对象
  • 会把源对象上的可枚举自有属性复制到目标对象
  • 返回值就是修改后的目标对象

ps.有不理解枚举是什么的同学可点击此处,一文带你轻松了解枚举。

三、基础示例

示例 1:合并两个对象

const target = { a: 1 };
const source = { b: 2, c: 3 };

Object.assign(target, source);
console.log(target);  // { a: 1, b: 2, c: 3 }

说明:sourcebc 被复制到 targettarget 被修改并返回。

示例 2:合并多个对象

const obj1 = { a: 1 };
const obj2 = { b: 2 };
const obj3 = { c: 3 };

const result = Object.assign(obj1, obj2, obj3);
console.log(result);  // { a: 1, b: 2, c: 3 }
console.log(obj1);    // { a: 1, b: 2, c: 3 } —— obj1 被修改了

说明:从第二个参数开始的对象会依次合并到 obj1obj1 就是目标对象,会被直接改动。

示例 3:相同属性会被覆盖

const target = { a: 1, b: 2 };
const source = { b: 99, c: 3 };  // b 冲突了

Object.assign(target, source);
console.log(target);  // { a: 1, b: 99, c: 3 }

说明:后面的对象会覆盖前面的同名属性,这里是 sourceb: 99 覆盖了原来的 b: 2

四、常见使用场景

场景 1:创建对象副本(浅拷贝)

const original = { name: '小明', age: 18 };
const copy = Object.assign({}, original);

copy.age = 20;
console.log(original.age);  // 18 —— 原对象没变
console.log(copy.age);      // 20

要点:目标对象用空对象 {},这样不会修改原对象,只得到一份浅拷贝。

场景 2:给对象添加默认值

function createUser(options) {
  return Object.assign({}, {
    name: '匿名用户',
    age: 0,
    role: 'guest'
  }, options);  // 用户传入的 options 会覆盖默认值
}

console.log(createUser({ name: '小红' }));
// { name: '小红', age: 0, role: 'guest' }

说明:先用默认对象,再合并用户传入的 options,实现默认值逻辑。

场景 3:复制并修改对象属性

const user = { name: '张三', age: 25 };
const updatedUser = Object.assign({}, user, { age: 26 });

console.log(user);         // { name: '张三', age: 25 }
console.log(updatedUser);  // { name: '张三', age: 26 }

说明:先拷贝 user 到空对象,再覆盖 age,得到新对象,原对象不变。

五、注意事项

1. 浅拷贝,不是深拷贝

const obj1 = { a: 1, nested: { b: 2 } };
const obj2 = Object.assign({}, obj1);

obj2.nested.b = 999;
console.log(obj1.nested.b);  // 999 —— 原对象里的嵌套对象也被改了!

嵌套对象还是同一个引用,修改 obj2.nested 会影响到 obj1.nested。需要深拷贝时要用递归、结构化克隆或 lodash.cloneDeep 等。

补充 - 深浅拷贝定义:

  • 浅拷贝:仅复制对象的表层结构,对嵌套的引用类型仅复制内存引用,新旧对象共享嵌套数据。
  • 深拷贝:完整复制对象的所有层级结构(含所有嵌套引用类型),新旧对象完全独立、无数据共享。

2. 只复制可枚举自有属性

const obj = Object.create({ inherit: '继承属性' });
obj.own = '自有属性';

const result = Object.assign({}, obj);
console.log(result);  // { own: '自有属性' } —— 继承属性不会被复制

说明:只复制源对象自身的可枚举属性,原型链上的属性不会被复制。

3. 会修改目标对象

const target = { a: 1 };
Object.assign(target, { b: 2 });
console.log(target);  // { a: 1, b: 2 } —— target 被改变了

第一个参数会被直接修改,如果不希望影响原对象,可以把第一个参数写成 {}

4. 源参数不是对象时的处理

Object.assign({}, undefined, null, 123, 'abc');
// 忽略 undefined、null、数字,字符串会按字符拆分
// 结果: { 0: 'a', 1: 'b', 2: 'c' }
  • undefinednull 会被跳过
  • 基本类型会先包装成对象,字符串会被当成类数组对象处理,一般不推荐这样用

六、和展开运算符的区别

const obj1 = { a: 1 };
const obj2 = { b: 2 };

// 方式 1:Object.assign
const result1 = Object.assign({}, obj1, obj2);

// 方式 2:展开运算符
const result2 = { ...obj1, ...obj2 };

console.log(result1);  // { a: 1, b: 2 }
console.log(result2);  // { a: 1, b: 2 }

两者都能合并对象,通常用展开运算符 { ...obj1, ...obj2 } 更直观;需要修改已有目标对象、或者处理非对象参数时,再用 Object.assign 更合适。

七、总结

要点 说明
作用 把多个源对象的属性合并到目标对象
第一个参数 目标对象,会被修改
返回值 修改后的目标对象
拷贝方式 浅拷贝
常见用法 对象浅拷贝、添加默认值、合并多个对象

一句话:Object.assign(目标, 源1, 源2, ...) 就是“把后面的对象属性依次塞进第一个对象里”。

以上便是对Object.assign()的分享,欢迎大家指正讨论,与大家共勉。

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

作者 SmalBox
2026年2月11日 10:18

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

高清场景深度节点(HD Scene Depth Node)是Unity高清渲染管线(HDRP)中一个功能强大的着色器图形节点,专门用于访问当前摄像机的深度缓冲区信息。在实时渲染和后期处理效果开发中,深度信息的获取与处理是创建各种视觉特效的基础,而HD Scene Depth节点正是为此目的设计的核心工具。

深度缓冲区存储了场景中每个像素到摄像机的距离信息,这些数据在渲染过程中被广泛用于实现景深效果、雾效、遮挡处理、屏幕空间反射等多种高级渲染技术。通过HD Scene Depth节点,开发者可以直接在着色器图形中采样这些深度值,无需编写复杂的底层着色器代码,大大提高了开发效率和可视化编程的便捷性。

该节点的设计充分考虑了HDRP的高质量渲染需求,支持多种深度采样模式和mipmap级别访问,为创建电影级画质的实时视觉效果提供了强有力的支持。无论是实现精确的深度检测,还是创建基于深度的复杂材质效果,HD Scene Depth节点都是不可或缺的工具。

描述

高清场景深度节点是Unity着色器图形中专门用于访问当前摄像机深度缓冲区的特殊节点。它通过UV输入参数接收标准化的屏幕坐标,并返回对应位置的深度信息。这一机制使得开发者能够在片元着色器阶段精确获取场景中各点的深度数据,为各种基于深度的渲染效果奠定基础。

在渲染管线中,深度缓冲区是一个至关重要的组件,它记录了从摄像机视角看,场景中每个像素对应的最近表面距离。这些深度信息不仅用于确定物体的前后关系(深度测试),还为许多后处理效果和高级渲染技术提供了必要的数据支持。HD Scene Depth节点的核心价值在于它将这些底层数据以直观、易用的方式暴露给着色器图形用户,让非专业图形程序员也能轻松实现复杂的深度相关效果。

该节点的一个关键特性是它只能在片元着色器阶段使用。这是因为深度缓冲区的完整信息只有在几何体渲染完成后才会变得可用,而片元着色器正是处理每个像素最终颜色的阶段。此外,该节点仅适用于非不透明材质,这是因为透明物体通常需要特殊的渲染顺序和混合处理,其深度信息可能与不透明物体有所不同。

Unity预期UV输入值为标准化的屏幕坐标,这意味着坐标范围应该在[0,1]区间内,其中(0,0)通常表示屏幕左下角,(1,1)表示屏幕右上角。这种标准化坐标系统使得深度采样与具体屏幕分辨率无关,增强了着色器的通用性和可移植性。

除了基本的深度采样功能,HD Scene Depth节点还支持访问深度缓冲区的mipmap。Mipmap是预先计算的不同分辨率版本的纹理,用于提高纹理采样的质量和性能。当进行远距离或斜向的深度采样时,使用适当的mip层级可以减少锯齿和闪烁现象,提高视觉效果的质量。Lod(Level of Detail)输入端口正是用于控制采样时使用的mip层级,允许开发者根据具体需求平衡性能与质量。

深度数据的意义与应用

深度数据在实时渲染中具有广泛的应用价值,理解这些数据的含义和潜在用途对于有效使用HD Scene Depth节点至关重要:

  • 空间关系判定:深度值直接反映了像素与摄像机之间的距离关系,可以用于确定物体间的相对位置和遮挡情况
  • 后处理效果基础:许多屏幕空间后处理效果,如景深、雾效、边缘检测等,都高度依赖精确的深度信息
  • 世界位置重建:结合摄像机参数,深度值可以用于重建像素在世界空间中的实际位置,这是许多高级渲染技术的基础
  • 非真实渲染:通过分析深度变化,可以实现轮廓线检测等非真实感渲染效果
  • 特效遮罩:基于深度的阈值判断可以创建各种遮罩效果,用于限制特定区域的特效应用范围

节点内部工作机制

从技术角度看,HD Scene Depth节点在着色器编译过程中会被转换为相应的纹理采样指令,具体来说是对深度缓冲区的采样操作。在HDRP中,深度缓冲区通常以特定格式存储,如R32_FLOAT或R16_FLOAT,具体取决于项目的精度要求和硬件支持。

当在着色器图形中使用该节点时,Unity会根据节点的配置生成相应的HLSL代码。例如,当选择Linear01模式时,生成的代码可能会调用类似Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv))的函数,将原始的深度缓冲区值转换为[0,1]范围内的线性深度。

值得注意的是,深度缓冲区的实际内容可能因渲染设置而异。在HDRP中,根据不同的渲染路径和质量设置,深度缓冲区可能包含前向渲染的深度、延迟渲染的G-Buffer深度,或者是特定于某些渲染特性的深度信息。HD Scene Depth节点抽象了这些底层差异,为开发者提供了一致的接口。

渲染管线兼容性

HD Scene Depth节点是专为高清渲染管线(HDRP)设计的专用节点,这意味着它在通用渲染管线(URP)中不可用。这种兼容性差异源于两种渲染管线的架构设计、渲染目标和深度处理机制的根本不同。

高清渲染管线(HDRP)

在高清渲染管线中,HD Scene Depth节点完全受支持并提供了完整的功能集。HDRP作为Unity的高端渲染解决方案,专为需要高端图形保真度的项目设计,如PC、主机游戏和高端移动设备。它采用了复杂的多通道渲染架构和先进的深度管理机制,为HD Scene Depth节点提供了丰富的深度数据访问能力。

在HDRP中,深度缓冲区的管理和使用具有以下特点:

  • 多摄像机支持:HDRP支持多个摄像机并能够正确处理它们之间的深度信息关系
  • 分层渲染:HDRP的渲染层系统允许更精细地控制哪些物体贡献到深度缓冲区
  • 自定义渲染通道:通过自定义渲染通道,开发者可以更灵活地控制深度缓冲区的生成和使用
  • 高质量深度预处理:HDRP包含高级的深度预处理步骤,如反向Z缓冲区、深度压缩等,以提高深度精度和性能

通用渲染管线(URP)

与HDRP不同,通用渲染管线(URP)不支持HD Scene Depth节点。URP作为Unity的轻量级渲染解决方案,优先考虑性能和跨平台兼容性,因此在功能集上相对精简。在URP中,如果需要访问深度信息,通常需要使用不同的方法:

  • Scene Depth Node:URP提供了自己的场景深度节点,但其功能和接口可能与HDRP的版本有所不同
  • Renderer Features:通过自定义渲染器功能,可以在URP中实现类似的深度访问能力
  • Camera Depth Texture:手动启用相机的深度纹理并编写自定义着色器代码进行采样

兼容性决策考量

Unity决定在URP中不提供HD Scene Depth节点是基于多方面的技术考量:

  • 架构差异:HDRP和URP使用不同的渲染架构和缓冲区管理策略,直接移植节点功能并不简单
  • 性能优先级:URP更注重性能和轻量级,某些高级深度功能可能会影响这些目标
  • 使用场景:URP通常用于对图形保真度要求不那么极致的项目,这些项目可能不需要复杂的深度访问功能
  • 资源限制:移动平台等URP常见目标平台可能有纹理格式和采样限制,影响深度缓冲区的实现方式

自定义渲染管线中的行为

对于使用自定义渲染管线的情况,HD Scene Depth节点的行为需要显式定义。如果未在自定义管线中实现相应的功能,该节点将返回默认的白色值(1,1,1),这通常表示缺少有效数据。

在自定义渲染管线中支持HD Scene Depth节点通常涉及以下步骤:

  • 确保渲染管线正确生成并维护深度缓冲区
  • 将深度缓冲区作为全局着色器属性暴露
  • 实现与HDRP兼容的深度解码函数
  • 处理不同平台和渲染设置的深度格式差异

端口

HD Scene Depth节点提供了三个主要端口,用于控制深度采样的参数和输出结果。理解每个端口的功能和正确使用方法对于有效利用该节点至关重要。

UV输入端口

UV输入端口是HD Scene Depth节点最关键的参数之一,它决定了在深度缓冲区中的采样位置。该端口接受Vector 4类型的输入,并与屏幕位置绑定。

技术特性

  • 数据类型:Vector 4(四维向量)
  • 坐标空间:标准化屏幕空间
  • 绑定类型:屏幕位置(自动绑定)
  • 默认值:如未连接,通常使用当前片元的屏幕位置

标准化屏幕坐标

UV输入期望的是标准化屏幕坐标,这意味着无论实际屏幕分辨率如何,坐标范围都应在[0,1]区间内:

  • (0,0) 通常对应屏幕左下角
  • (1,1) 通常对应屏幕右上角
  • Z分量:通常用于透视校正,在大多数情况下可以忽略
  • W分量:通常包含透视除法所需的信息

获取屏幕坐标的方法

在着色器图形中,有多种方式可以获得合适的UV坐标:

  • 使用Screen Position节点获取当前片元的屏幕位置
  • 通过计算自定义UV,实现特定区域的深度采样
  • 使用Tiling And Offset节点调整和变换屏幕坐标

高级应用技巧

  • 视口相对采样:通过偏移UV坐标,可以实现相对于当前像素的深度采样,用于边缘检测等效果
  • 动态UV动画:对UV坐标应用时间相关的变换,可以创建基于深度的动态效果
  • 多重采样:通过在不同UV位置多次采样深度,可以实现更复杂的深度分析效果

Lod输入端口

Lod(Level of Detail)输入端口允许指定采样深度缓冲区时使用的mipmap层级。该功能对于优化性能和改善视觉质量具有重要意义。

技术特性

  • 数据类型:Float(浮点数)
  • 取值范围:通常为0到深度纹理的最大mip层级
  • 默认值:如未连接,通常使用0(最高分辨率)

Mipmap在深度采样中的作用

深度缓冲区的mipmap是通过对原始深度图进行下采样生成的较低分辨率版本:

  • Level 0:原始分辨率,提供最精确的深度信息
  • Level 1:1/2分辨率,在每维度上减半
  • Level 2:1/4分辨率,依此类推
  • 自动mipmap:HDRP通常会自动为深度缓冲区生成mipmap

性能与质量权衡

选择合适的Lod值需要在性能和质量之间取得平衡:

  • 高质量需求:使用低Lod值(接近0),获得更精确的深度信息
  • 性能优化:使用高Lod值,减少纹理采样带宽和缓存压力
  • 远处物体:对屏幕中较小的或远处的物体,可以使用较高Lod值而不会明显影响视觉质量

Lod计算策略

在实际应用中,Lod值可以根据多种因素动态计算:

  • 基于距离:根据像素到摄像机的距离调整Lod
  • 基于屏幕空间导数:使用ddxddy计算适当的Lod值
  • 固定策略:对全屏效果使用统一的Lod值

Output输出端口

Output端口是HD Scene Depth节点的结果输出,它提供了指定屏幕位置的深度信息。根据选择的深度采样模式,输出的具体含义和用途有所不同。

技术特性

  • 数据类型:Vector 3(三维向量)
  • 分量含义:根据深度模式,三个分量可能包含相同或相关的深度信息
  • 数值范围:取决于选择的深度采样模式

输出解释

虽然输出是Vector 3类型,但在大多数情况下,我们主要使用其中一个分量:

  • R通道:通常包含主要的深度信息
  • G和B通道:在某些配置下可能包含辅助信息或保持为0
  • 实际使用:通常通过Swizzle节点提取所需的单个分量

输出稳定性考虑

深度输出值可能受多种因素影响:

  • 深度格式:不同平台可能使用不同的深度缓冲区精度和格式
  • 渲染设置:HDRP的质量设置可能影响深度计算的精度
  • 摄像机参数:近裁剪面和远裁剪面的设置会影响深度值的分布

深度采样模式

HD Scene Depth节点支持多种深度采样模式,每种模式以不同的方式解释和表示深度信息。理解这些模式的差异和适用场景对于正确使用深度数据至关重要。

Linear01模式

Linear01模式将深度值转换为0到1之间的线性表示,这是最常用且直观的深度表示方法。

技术特性

  • 数值范围:[0, 1]
  • 0值含义:位于摄像机的近裁剪面
  • 1值含义:位于摄像机的远裁剪面
  • 分布特性:在近裁剪面和远裁剪面之间线性分布

数学表示

Linear01深度可以通过以下公式计算:

depth_linear01 = (z - near) / (far - near)

其中:

  • z是视图空间中的Z坐标
  • near是近裁剪面距离
  • far是远裁剪面距离

应用场景

Linear01模式因其直观性而被广泛使用:

  • 深度可视化:直接显示Linear01深度可以创建从黑到白的深度图
  • 线性插值:在近远裁剪面之间进行线性混合,如雾效、深度褪色等
  • 阈值处理:基于固定的深度阈值实现效果切换
  • 屏幕空间效果:需要与屏幕空间坐标线性相关的深度应用

使用示例

创建基于深度的雾效:

  1. 使用HD Scene Depth节点采样Linear01深度
  2. 使用SmoothstepRemap节点根据深度计算雾强度
  3. 将雾强度与场景颜色混合

Raw模式

Raw模式提供直接从深度缓冲区读取的原始深度值,这些值通常是非线性的,并且依赖于具体的深度缓冲区格式。

技术特性

  • 数值范围:依赖于深度缓冲区格式,通常是[0, 1]或[1, 0]
  • 分布特性:通常是非线性的,在近处有更高精度
  • 平台依赖性:不同平台和渲染设置可能产生不同的原始深度值

深度缓冲区格式

Raw深度值的具体含义取决于深度缓冲区的内部格式:

  • 反向Z缓冲区:在现代图形API中常见,1.0表示近裁剪面,0.0表示远裁剪面
  • 传统Z缓冲区:0.0表示近裁剪面,1.0表示远裁剪面
  • 浮点深度:使用浮点格式存储,提供更大的范围和精度

应用场景

Raw模式主要用于需要直接处理原始深度数据的高级应用:

  • 深度重建:手动执行深度解码以实现特定的精度需求
  • 深度比较:进行精确的深度相等性或范围测试
  • 自定义深度编码:实现特殊的深度压缩或编码方案
  • 渲染管线开发:在自定义渲染管线中调试和验证深度缓冲区内容

注意事项

使用Raw模式时需要特别小心:

  • 结果可能因平台和渲染设置而异
  • 非线性分布可能导致数值精度问题
  • 需要深入了解特定平台的深度缓冲区行为

Eye模式

Eye模式将深度值转换为视空间中的实际单位距离,提供了最有物理意义的深度表示。

技术特性

  • 数值单位:与世界空间单位一致(通常是米)
  • 数值范围:[near, far],即近裁剪面到远裁剪面的距离
  • 坐标系:视空间坐标系,Z轴指向摄像机前方

数学关系

Eye深度实际上是视空间中的Z坐标:

depth_eye = z

其中z是视图空间中的Z坐标,表示从摄像机位置到片元的直线距离。

应用场景

Eye模式在需要物理准确性的应用中非常有用:

  • 物理精确的效果:如基于真实距离的雾效、光照衰减
  • 世界位置重建:结合屏幕坐标重建像素的世界位置
  • 尺寸感知效果:创建与场景实际尺寸相关的特效
  • 科学可视化:需要精确距离测量的专业应用

性能考虑

Eye模式可能需要额外的计算来从原始深度值转换,但在HDRP中,这种转换通常已经过高度优化。

注意

在使用HD Scene Depth节点时,有几个重要的技术细节和限制需要特别注意,这些因素直接影响节点的行为和使用效果。

使用阶段限制

HD Scene Depth节点只能在片元着色器阶段使用,这是由深度缓冲区的可用性决定的。在着色器图形的其他阶段(如顶点着色器阶段)尝试使用该节点通常会导致编译错误或未定义行为。

技术原因

深度缓冲区在渲染管线的特定点才变得可用:

  • 深度写入阶段:在几何体渲染过程中,深度值被写入深度缓冲区
  • 后处理阶段:在所有不透明几何体渲染完成后,完整的深度缓冲区才可用于采样
  • 片元着色器:作为每个像素处理的最后阶段,自然可以访问已生成的深度信息

变通方案

如果需要在顶点着色器中访问深度信息,可考虑以下替代方案:

  • 在片元着色器中计算所需信息,然后插值到顶点
  • 使用其他方法估算深度,如基于模型空间位置的简单计算
  • 重构渲染流程,将深度相关的计算移至片元着色器

材质类型限制

该节点仅适用于非不透明材质,这意味着它不能在不透明材质的着色器中使用。这一限制与HDRP的渲染顺序和深度管理策略密切相关。

渲染顺序考量

HDRP按照特定顺序渲染物体以优化性能和正确性:

  • 不透明物体:通常从前向后渲染,利用深度测试提前丢弃不可见片元
  • 透明物体:通常从后向前渲染,需要混合且可能修改颜色但不修改深度
  • 深度缓冲区状态:在透明物体渲染时,深度缓冲区已包含所有不透明物体的深度信息

不透明材质中的深度访问

虽然不能直接在不透明材质中使用HD Scene Depth节点,但仍有其他方法可以访问深度信息:

  • 使用Depth Only Pass创建特殊的深度写入通道
  • 通过Renderer Features添加自定义的深度处理逻辑
  • 在后期处理效果中处理深度相关效果

自定义渲染管线集成

在自定义渲染管线中使用HD Scene Depth节点需要显式定义其行为,否则节点将返回白色值(1,1,1)。这一特性使得节点在未正确配置的环境中能够提供可预测的(虽然是错误的)输出。

实现要求

在自定义渲染管线中支持HD Scene Depth节点需要:

  • 深度纹理生成:确保管线正确生成并维护深度纹理
  • 着色器变量绑定:将深度纹理作为全局着色器属性暴露
  • 采样函数实现:提供与HDRP兼容的深度采样函数
  • 平台兼容性处理:处理不同图形API和平台的深度格式差异

集成步骤

将HD Scene Depth节点集成到自定义渲染管线的基本步骤:

  1. 在渲染管线中创建并配置深度纹理
  2. 实现深度纹理的mipmap生成(如果需要Lod功能)
  3. 创建相应的HLSL包含文件,定义深度采样函数
  4. 在着色器图形编译过程中包含这些

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

🔥Proxy 与 Reflect 从入门到实战:ES6 元编程核心特性详解

作者 小马_xiaoen
2026年2月11日 09:41

🔥Proxy 与 Reflect 从入门到实战:ES6 元编程核心特性详解

Reflect.png

🚀一、核心介绍:什么是元编程与 Proxy/Reflect

1. 元编程(Metaprogramming)

元编程是指程序可以对自身代码进行操作、修改和扩展的编程范式,简单来说就是写代码来操作代码。在 JavaScript 中,元编程主要体现在对对象、函数、类的行为进行拦截、修改和增强,而 ES6 引入的 ProxyReflect 正是实现 JavaScript 元编程的核心工具,让开发者可以优雅地拦截对象的底层操作,实现自定义行为。

2. Proxy 是什么?

Proxy(代理)是 ES6 提供的对象拦截器,它可以创建一个目标对象的代理对象,对代理对象的所有底层操作(如属性读取、赋值、删除、函数调用等)进行拦截和自定义处理,而目标对象本身不会被直接修改。

简单理解:Proxy 就像目标对象的**“中间代理人”**,所有对目标对象的操作都必须经过这个代理人,代理人可以决定是否放行、如何修改操作的参数和返回值,甚至拒绝执行操作。

3. Reflect 是什么?

Reflect(反射)是 ES6 提供的内置对象,它将 JavaScript 中对象的底层原生操作(如 Object.getOwnPropertyDescriptorin 运算符、delete 运算符等)封装为统一的方法,放在 Reflect 对象上,同时与 Proxy 的拦截方法一一对应

Reflect 的设计初衷有三个:

  1. 统一原生操作:将分散在 Object、运算符中的原生操作整合,让代码更规范、更易维护;
  2. 配合 Proxy 使用:Reflect 方法的参数、返回值与 Proxy 拦截方法完全匹配,在 Proxy 中使用 Reflect 可以无缝还原目标对象的原生操作
  3. 更合理的返回值:原生操作的返回值不统一(如 delete obj.key 返回布尔值,Object.defineProperty 失败抛错),Reflect 方法统一返回布尔值表示操作成功与否,并通过返回值替代抛错,更易做错误处理。

4. Proxy 与 Reflect 的核心关系

Proxy 负责拦截对象的底层操作,Reflect 负责执行对象的原生底层操作,二者是配套使用、缺一不可的关系:

  • 没有 Reflect,在 Proxy 中还原原生操作需要写大量兼容代码,且易出错;
  • 没有 Proxy,Reflect 仅作为原生操作的封装,失去元编程的核心价值。

简单总结:Proxy 拦截操作,Reflect 执行原生操作,二者结合实现优雅的对象行为自定义。

🎯 二、Proxy 核心特性与基本使用

1. Proxy 基本语法

const proxy = new Proxy(target, handler);
  • target:被代理的目标对象(可以是任意类型的对象:普通对象、数组、函数、甚至另一个 Proxy);
  • handler拦截配置对象,包含多个拦截方法(也叫陷阱方法),每个拦截方法对应一种对目标对象的底层操作,当对代理对象执行该操作时,会触发对应的拦截方法,执行自定义逻辑;
  • proxy:创建的代理对象,后续所有操作都应基于代理对象,而非直接操作目标对象。

核心注意:Proxy 实现的是浅代理,如果目标对象是嵌套对象,嵌套对象的属性操作不会触发顶层 Proxy 的拦截方法,需要手动实现深代理。

2. Proxy 的核心特性

  • 非侵入式拦截:不会修改目标对象本身,所有自定义行为都在代理对象上实现,目标对象保持纯净;
  • 全面的拦截能力:支持 13 种底层操作的拦截,覆盖对象的所有常用操作(属性读写、赋值、删除、函数调用、原型访问等);
  • 代理对象与目标对象解耦:操作代理对象不会直接影响目标对象,可通过 Reflect 在拦截方法中手动执行对目标对象的操作;
  • 无感知使用:代理对象的用法与目标对象完全一致,调用方无需知道代理的存在,降低使用成本。

3. Proxy 常用拦截方法(陷阱方法)

Proxy 的 handler 对象提供了 13 种拦截方法,对应对象的 13 种底层操作,以下是开发中最常用的 8 种,其余方法可参考 ES6 官方文档,使用方式类似。

所有拦截方法的核心设计:参数与对应原生操作匹配,返回值决定操作的最终结果,可通过 Reflect 方法执行原生操作。

拦截方法 对应原生操作 作用
get(target, prop, receiver) obj.prop / obj[prop] 拦截属性读取操作
set(target, prop, value, receiver) obj.prop = value / obj[prop] = value 拦截属性赋值操作
has(target, prop) prop in obj 拦截in 运算符的判断操作
deleteProperty(target, prop) delete obj.prop / delete obj[prop] 拦截delete 运算符的删除操作
getOwnPropertyDescriptor(target, prop) Object.getOwnPropertyDescriptor(obj, prop) 拦截获取属性描述符的操作
apply(target, thisArg, args) func(...args) / func.call(thisArg, ...args) 拦截函数调用操作(仅当 target 是函数时生效)
construct(target, args, newTarget) new Func(...args) 拦截new 运算符的实例化操作(仅当 target 是构造函数时生效)
ownKeys(target) Object.keys(obj) / Object.getOwnPropertyNames(obj) 拦截获取对象自身属性名的操作

📁 4. Proxy 基础使用示例

示例1:拦截普通对象的属性读取和赋值

实现属性不存在时的默认值属性赋值的类型校验,这是 Proxy 最经典的使用场景。

// 目标对象
const user = {
  name: '张三',
  age: 18
};

// 拦截配置对象
const handler = {
  // 拦截属性读取:obj.prop
  get(target, prop, receiver) {
    // 原生操作:Reflect.get(target, prop, receiver)
    // 自定义逻辑:属性不存在时返回默认值'未知'
    return Reflect.get(target, prop, receiver) || '未知';
  },
  // 拦截属性赋值:obj.prop = value
  set(target, prop, value, receiver) {
    // 自定义逻辑:对age属性做类型校验,必须是数字且大于0
    if (prop === 'age' && (typeof value !== 'number' || value <= 0)) {
      throw new Error('年龄必须是大于0的数字');
    }
    // 执行原生赋值操作
    return Reflect.set(target, prop, value, receiver);
  }
};

// 创建代理对象
const proxyUser = new Proxy(user, handler);

// 测试属性读取
console.log(proxyUser.name); // 张三(原生值)
console.log(proxyUser.gender); // 未知(默认值,目标对象无该属性)

// 测试属性赋值
proxyUser.age = 20;
console.log(proxyUser.age); // 20(赋值成功)
proxyUser.gender = '男';
console.log(proxyUser.gender); // 男(赋值成功)

// 测试非法赋值:抛出错误
// proxyUser.age = -5; // Uncaught Error: 年龄必须是大于0的数字
// proxyUser.age = '20'; // Uncaught Error: 年龄必须是大于0的数字

// 目标对象会被同步修改(因为在set中执行了Reflect.set)
console.log(user.age); // 20
示例2:拦截数组的操作,实现数组操作日志

拦截数组的读取赋值push 等操作,记录每一次数组操作的日志,适用于数据监控场景。

// 目标数组
const arr = [1, 2, 3];

// 拦截配置
const handler = {
  get(target, prop, receiver) {
    console.log(`[读取数组] 索引/方法:${prop},当前数组:${target}`);
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    console.log(`[修改数组] 索引:${prop},旧值:${target[prop]},新值:${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

// 创建代理数组
const proxyArr = new Proxy(arr, handler);

// 测试数组操作
proxyArr[0] = 10; // [修改数组] 索引:0,旧值:1,新值:10
console.log(proxyArr[0]); // [读取数组] 索引/方法:0,当前数组:10,2,3 → 10
proxyArr.push(4); // 依次触发get(push)、get(length)、set(3,4)、set(length,4)
console.log(proxyArr); // [读取数组] 索引/方法:toString,当前数组:10,2,3,4 → [10,2,3,4]
示例3:拦截函数的调用,实现函数调用日志

当 Proxy 的目标对象是函数时,可通过 apply 方法拦截函数的所有调用方式(直接调用、call、apply),实现通用的函数增强。

// 目标函数
const add = (a, b) => a + b;

// 拦截配置:apply拦截函数调用
const handler = {
  apply(target, thisArg, args) {
    // 自定义逻辑:记录函数调用日志
    console.log(`[函数调用] 函数名:${target.name},this指向:${thisArg},参数:${args}`);
    // 执行原生函数调用
    const result = Reflect.apply(target, thisArg, args);
    // 自定义逻辑:增强返回值
    console.log(`[函数返回] 结果:${result}`);
    return result;
  }
};

// 创建代理函数
const proxyAdd = new Proxy(add, handler);

// 测试函数的各种调用方式,均会触发拦截
console.log(proxyAdd(1, 2)); 
// [函数调用] 函数名:add,this指向:undefined,参数:1,2
// [函数返回] 结果:3 → 3

console.log(proxyAdd.call(null, 3, 4));
// [函数调用] 函数名:add,this指向:null,参数:3,4
// [函数返回] 结果:7 →7

console.log(proxyAdd.apply({}, [5, 6]));
// [函数调用] 函数名:add,this指向:{},参数:5,6
// [函数返回] 结果:11 →11

🚀 三、Reflect 核心特性与基本使用

1. Reflect 核心特性

  • 内置对象:Reflect 是一个内置的静态对象,不能被实例化(类似 Math),所有方法都是静态方法;
  • 原生操作封装:所有方法都是对 JavaScript 底层原生操作的封装,与底层操作行为完全一致;
  • 与 Proxy 一一对应:Reflect 的 13 个方法与 Proxy 的 13 个拦截方法名称完全相同、参数完全一致,是 Proxy 的最佳搭档;
  • 操作结果标准化:所有方法都返回布尔值表示操作是否成功(除了少数获取值的方法如 Reflect.get),失败时不会抛出错误,而是返回 false,更易做错误处理;
  • 绑定 this 更合理:部分原生操作(如 Object.defineProperty)的 this 指向不明确,Reflect 方法的 this 指向由参数明确指定。

2. Reflect 与原生操作的对比

属性赋值属性删除获取属性描述符为例,看 Reflect 如何让原生操作更规范、更易维护:

操作场景 原生操作方式 Reflect 操作方式 优势
属性赋值 obj.prop = value Reflect.set(obj, prop, value) 返回布尔值表示是否成功,支持绑定receiver
属性删除 delete obj.prop Reflect.deleteProperty(obj, prop) 统一返回布尔值,原生操作对不可配置属性返回true(易误导)
获取属性描述符 Object.getOwnPropertyDescriptor(obj, prop) Reflect.getOwnPropertyDescriptor(obj, prop) 对非对象目标,原生操作抛错,Reflect返回false
检查属性是否存在 prop in obj Reflect.has(obj, prop) 统一方法调用,替代运算符,更易封装

示例:Reflect 操作的标准化返回值

const obj = Object.freeze({ name: '张三' }); // 冻结对象,禁止修改属性

// 原生赋值:静默失败,无任何提示
obj.age = 18;
console.log(obj.age); // undefined

// Reflect赋值:返回false,表示操作失败,无抛错
const isSet = Reflect.set(obj, 'age', 18);
console.log(isSet); // false

// 原生删除:对冻结对象的属性删除,返回true(误导)
console.log(delete obj.name); // true
console.log(obj.name); // 张三(实际未删除)

// Reflect删除:返回false,表示操作失败
console.log(Reflect.deleteProperty(obj, 'name')); // false

3. Reflect 常用方法与使用示例

Reflect 共有 13 个静态方法,与 Proxy 的拦截方法一一对应,以下是最常用的 8 种,参数和使用方式与 Proxy 拦截方法完全一致,可直接在 Proxy 中使用。

1. Reflect.get(target, prop, receiver)

读取目标对象的属性值,receiver 为属性访问器(getter)中的 this 指向,可选。

const obj = { name: '张三', get age() { return this._age; }, _age: 18 };
console.log(Reflect.get(obj, 'name')); // 张三
// 绑定getter的this指向
console.log(Reflect.get(obj, 'age', { _age: 20 })); // 20
2. Reflect.set(target, prop, value, receiver)

为目标对象的属性赋值,返回布尔值表示是否成功,receiver 为属性设置器(setter)中的 this 指向,可选。

const obj = { name: '张三' };
console.log(Reflect.set(obj, 'age', 18)); // true
console.log(obj.age); // 18

const freezeObj = Object.freeze({ name: '李四' });
console.log(Reflect.set(freezeObj, 'age', 20)); // false(冻结对象,赋值失败)
3. Reflect.has(target, prop)

检查属性是否存在于目标对象中(包括自身和原型链),等价于 prop in obj,返回布尔值。

const obj = { name: '张三' };
console.log(Reflect.has(obj, 'name')); // true
console.log(Reflect.has(obj, 'toString')); // true(原型链上的属性)
4. Reflect.deleteProperty(target, prop)

删除目标对象的属性,等价于 delete obj.prop,返回布尔值表示是否成功。

const obj = { name: '张三', age: 18 };
console.log(Reflect.deleteProperty(obj, 'age')); // true
console.log(obj.age); // undefined

const freezeObj = Object.freeze({ name: '李四' });
console.log(Reflect.deleteProperty(freezeObj, 'name')); // false(冻结对象,删除失败)
5. Reflect.apply(target, thisArg, args)

调用目标函数,等价于 target.call(thisArg, ...args)target.apply(thisArg, args),返回函数执行结果。

const add = (a, b) => a + b;
console.log(Reflect.apply(add, null, [1, 2])); // 3
console.log(Reflect.apply(Math.max, null, [1, 2, 3])); // 3
6. Reflect.construct(target, args, newTarget)

用 new 运算符实例化构造函数,等价于 new target(...args)newTarget 可选,指定实例的原型,返回实例对象。

class Person {
  constructor(name) {
    this.name = name;
  }
}

const p1 = Reflect.construct(Person, ['张三']);
console.log(p1); // Person { name: '张三' }
console.log(p1 instanceof Person); // true
7. Reflect.getOwnPropertyDescriptor(target, prop)

获取目标对象属性的描述符,等价于 Object.getOwnPropertyDescriptor,返回描述符对象或 undefined,对非对象目标返回 false。

const obj = { name: '张三' };
const desc = Reflect.getOwnPropertyDescriptor(obj, 'name');
console.log(desc); // { value: '张三', writable: true, enumerable: true, configurable: true }
8. Reflect.ownKeys(target)

获取目标对象的所有自身属性名(包括可枚举、不可枚举、Symbol 属性),等价于 Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target)),返回属性名数组。

const obj = { name: '张三' };
Object.defineProperty(obj, 'age', { value: 18, enumerable: false });
const sym = Symbol('gender');
obj[sym] = '男';

console.log(Reflect.ownKeys(obj)); // ['name', 'age', Symbol(gender)]

📁 四、Proxy 与 Reflect 结合实战

Proxy 与 Reflect 的结合是 ES6 元编程的核心,以下是开发中最常见的 3 个实战场景,覆盖数据校验、数据劫持、对象私有化,均为前端框架(如 Vue3)、工具库的核心实现思路。

实战1:实现对象的属性私有化(# 私有属性的替代方案)

ES6 提供了 # 定义私有属性,但存在兼容性问题且无法动态控制,通过 Proxy + Reflect 可实现更灵活的对象属性私有化,指定前缀(如 _)的属性为私有属性,禁止外部读取和修改。

/**
 * 实现对象属性私有化:以_开头的属性为私有属性,禁止外部访问和修改
 * @param {Object} target - 目标对象
 * @returns {Proxy} 代理对象
 */
function createPrivateObj(target) {
  const handler = {
    get(target, prop, receiver) {
      // 自定义逻辑:禁止读取私有属性
      if (typeof prop === 'string' && prop.startsWith('_')) {
        throw new Error(`属性${prop}是私有属性,禁止读取`);
      }
      return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
      // 自定义逻辑:禁止修改私有属性
      if (typeof prop === 'string' && prop.startsWith('_')) {
        throw new Error(`属性${prop}是私有属性,禁止修改`);
      }
      return Reflect.set(target, prop, value, receiver);
    },
    deleteProperty(target, prop) {
      // 自定义逻辑:禁止删除私有属性
      if (typeof prop === 'string' && prop.startsWith('_')) {
        throw new Error(`属性${prop}是私有属性,禁止删除`);
      }
      return Reflect.deleteProperty(target, prop);
    }
  };
  return new Proxy(target, handler);
}

// 使用示例
const user = createPrivateObj({
  name: '张三',
  _age: 18, // 私有属性
  _gender: '男' // 私有属性
});

// 测试公有属性
console.log(user.name); // 张三
user.name = '李四';
console.log(user.name); // 李四

// 测试私有属性:均抛出错误
// console.log(user._age); // Uncaught Error: 属性_age是私有属性,禁止读取
// user._age = 20; // Uncaught Error: 属性_age是私有属性,禁止修改
// delete user._gender; // Uncaught Error: 属性_gender是私有属性,禁止删除

实战2:实现简易的数据劫持(Vue3 响应式核心思路)

Vue3 的响应式系统核心就是基于 Proxy + Reflect 实现的深度数据劫持,通过拦截对象的属性读取(get)和赋值(set),在读取时收集依赖,在赋值时触发更新。以下实现一个简易版的响应式系统,还原核心思路。

// 依赖映射:存储属性与对应的更新函数
const targetMap = new WeakMap();
// 当前正在执行的更新函数
let activeEffect = null;

/**
 * 注册更新函数(收集依赖)
 * @param {Function} effect - 更新函数
 */
function effect(effectFn) {
  activeEffect = effectFn;
  // 执行一次更新函数,触发属性读取,完成依赖收集
  effectFn();
  activeEffect = null;
}

/**
 * 触发依赖更新
 * @param {Object} target - 目标对象
 * @param {string|Symbol} prop - 属性名
 */
function trigger(target, prop) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const deps = depsMap.get(prop);
  if (!deps) return;
  // 执行所有相关的更新函数
  deps.forEach(fn => fn());
}

/**
 * 收集依赖
 * @param {Object} target - 目标对象
 * @param {string|Symbol} prop - 属性名
 */
function track(target, prop) {
  if (!activeEffect) return;
  // 多层映射:target → prop → effectFn
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let deps = depsMap.get(prop);
  if (!deps) depsMap.set(prop, (deps = new Set()));
  deps.add(activeEffect);
}

/**
 * 创建响应式对象(深度代理)
 * @param {Object} target - 目标对象
 * @returns {Proxy} 代理对象
 */
function reactive(target) {
  const handler = {
    get(target, prop, receiver) {
      const result = Reflect.get(target, prop, receiver);
      // 收集依赖
      track(target, prop);
      // 深度代理:如果返回值是对象,继续创建响应式对象
      return typeof result === 'object' && result !== null ? reactive(result) : result;
    },
    set(target, prop, value, receiver) {
      const oldValue = Reflect.get(target, prop, receiver);
      const isSet = Reflect.set(target, prop, value, receiver);
      // 只有值发生变化时,才触发更新
      if (isSet && oldValue !== value) {
        trigger(target, prop);
      }
      return isSet;
    }
  };
  return new Proxy(target, handler);
}

// 使用示例:模拟Vue3的响应式
const state = reactive({
  name: '张三',
  age: 18
});

// 注册更新函数:当state的属性变化时,自动执行
effect(() => {
  console.log(`姓名:${state.name},年龄:${state.age}`);
});

// 修改属性,触发更新函数执行
state.name = '李四'; // 输出:姓名:李四,年龄:18
state.age = 20; // 输出:姓名:李四,年龄:20
state.age = 20; // 值未变化,不触发更新

实战3:实现函数的参数校验与返回值格式化

通过 Proxy 拦截函数的调用(apply),结合 Reflect 执行原生调用,实现通用的函数参数校验和返回值格式化,无需修改函数本身,实现非侵入式增强。

/**
 * 函数增强:参数校验 + 返回值格式化
 * @param {Function} fn - 目标函数
 * @param {Array} validateRules - 参数校验规则(每个元素为对应参数的校验函数)
 * @param {Function} formatFn - 返回值格式化函数
 * @returns {Proxy} 代理函数
 */
function enhanceFn(fn, validateRules = [], formatFn = v => v) {
  const handler = {
    apply(target, thisArg, args) {
      // 1. 参数校验:校验规则数量与参数数量一致
      if (args.length !== validateRules.length) {
        throw new Error(`参数数量不匹配,期望${validateRules.length}个,实际${args.length}个`);
      }
      // 逐个校验参数
      args.forEach((arg, index) => {
        const isValidate = validateRules[index](arg);
        if (!isValidate) {
          throw new Error(`第${index+1}个参数${arg}校验失败`);
        }
      });
      // 2. 执行原生函数调用
      const result = Reflect.apply(target, thisArg, args);
      // 3. 返回值格式化
      const formatResult = formatFn(result);
      return formatResult;
    }
  };
  return new Proxy(fn, handler);
}

// 目标函数:计算两个数的和
const add = (a, b) => a + b;

// 增强函数:参数必须是数字,返回值格式化为{ result: 结果 }
const proxyAdd = enhanceFn(
  add,
  [
    (v) => typeof v === 'number', // 第一个参数必须是数字
    (v) => typeof v === 'number'  // 第二个参数必须是数字
  ],
  (res) => ({ result: res })
);

// 测试合法调用
console.log(proxyAdd(1, 2)); // { result: 3 }

// 测试非法调用:参数校验失败
// console.log(proxyAdd(1, '2')); // Uncaught Error: 第2个参数2校验失败
// console.log(proxyAdd(1)); // Uncaught Error: 参数数量不匹配,期望2个,实际1个

🚨 五、Proxy 与 Reflect 的常见坑与避坑指南

1. Proxy 是浅代理,嵌套对象需手动实现深代理

问题:Proxy 默认只代理目标对象的顶层属性,嵌套对象的属性操作不会触发拦截,如 proxyObj.obj.prop = value 不会触发 proxyObj 的 set 拦截。 解决:在 get 拦截方法中,判断返回值是否为对象,若是则递归创建 Proxy 代理,实现深代理(如实战2中的 reactive 函数)。

2. Reflect.set/deleteProperty 对不可变对象返回 false,而非抛错

问题:对冻结(Object.freeze)、密封(Object.seal)的对象执行赋值/删除操作,Reflect 方法返回 false,而原生操作会静默失败,若未判断返回值,易导致逻辑错误。 解决:在 Proxy 的 set/deleteProperty 拦截方法中,必须判断 Reflect 方法的返回值,根据返回值做错误处理或提示。

3. 代理对象与目标对象的原型链不一致

问题:Proxy 会创建一个新的对象,其原型链与目标对象不同,使用 instanceof 时可能出现不符合预期的结果(但实际开发中影响极小,因为代理对象的用法与目标对象一致)。 解决:若需严格保证原型链一致,可通过 Object.setPrototypeOf(proxy, Object.getPrototypeOf(target)) 手动设置代理对象的原型。

4. 拦截 ownKeys 时,返回的属性名必须符合规范

问题:在 ownKeys 拦截方法中,若返回的属性名包含非字符串、非Symbol的值,或包含重复的属性名,会抛出错误。 解决:确保 ownKeys 拦截方法返回的是由字符串和Symbol组成的无重复数组,建议基于 Reflect.ownKeys(target) 做过滤,而非手动创建。

5. Reflect.construct 的 newTarget 参数易被忽略

问题:使用 Reflect.construct 时,若未指定 newTarget,实例的原型是 target.prototype;若指定了 newTarget,实例的原型是 newTarget.prototype,易因忽略该参数导致原型链错误。 解决:明确 Reflect.construct 的第三个参数的作用,根据业务需求决定是否指定,避免原型链混乱。

🔧 六、Proxy/Reflect 的兼容性与替代方案

1. 兼容性

Proxy 和 Reflect 均为 ES6 特性,兼容性如下:

  • 桌面端:Chrome ≥ 49、Firefox ≥ 18、Edge ≥ 12、Safari ≥ 10;
  • 移动端:微信小程序/公众号、App 内置 Chromium 浏览器均支持,iOS Safari ≥ 10、Android WebView ≥ 49;
  • 不支持:IE 浏览器完全不支持,无原生兼容方案。

2. 替代方案

若需兼容 IE 等低版本浏览器,可使用以下替代方案:

  • 数据劫持:使用 Object.defineProperty 替代 Proxy(Vue2 的响应式方案),但仅能拦截属性的读取和赋值,无法拦截数组的 push/pop 等方法,也无法实现深代理的自动检测;
  • 原生操作封装:使用自定义工具函数封装原生操作,替代 Reflect,但需手动处理各种边界情况,代码量较大。

核心结论:现代前端开发(如 Vue3、React 生态)均已放弃 IE 兼容,可放心使用 Proxy + Reflect,这是未来 JavaScript 元编程的主流方案。

🏠七、Proxy/Reflect 的适用场景

Proxy + Reflect 作为 ES6 元编程的核心,适用于需要对对象/函数的底层行为进行拦截、增强和自定义的场景,是前端框架、工具库、通用组件的核心实现技术,典型场景包括:

  1. 响应式系统:如 Vue3 的 reactive、React 的 useState 底层实现,通过数据劫持实现视图与数据的自动同步;
  2. 数据校验:如表单验证、接口参数校验,对对象的属性赋值进行拦截,实现实时校验;
  3. 对象私有化:实现灵活的私有属性,替代 ES6 的 # 私有属性,支持动态控制;
  4. 函数增强:如日志记录、性能监控、参数校验、返回值格式化,非侵入式增强函数功能;
  5. 数据监控:如埋点系统、数据统计,拦截对象的所有操作,记录操作日志;
  6. 模拟实现:如模拟 Map、Set、Promise 等内置对象,自定义其行为;
  7. AOP 面向切面编程:在不修改原有代码的前提下,为函数添加前置/后置通知,实现日志、事务、缓存等功能。

📌八、总结

  1. Proxy 是对象拦截器,创建目标对象的代理对象,拦截 13 种底层操作,实现自定义行为,非侵入式修改对象行为,是 JavaScript 元编程的核心;
  2. Reflect 是原生操作封装器,将分散的原生操作整合为统一的静态方法,与 Proxy 一一对应,返回值标准化,是 Proxy 的最佳搭档;
  3. Proxy 与 Reflect 必须结合使用:Proxy 拦截操作,Reflect 执行原生操作,避免手动实现原生操作的兼容代码,让代码更优雅、更易维护;
  4. Proxy 实现深代理:需在 get 拦截方法中递归创建 Proxy,对嵌套对象进行代理,这是 Vue3 响应式的核心思路;
  5. 核心特性:Proxy 浅代理、状态不可逆、代理对象与目标对象解耦;Reflect 静态对象、标准化返回值、与 Proxy 参数匹配;
  6. 适用场景:响应式系统、数据校验、对象私有化、函数增强、数据监控等,是前端框架、工具库的必备技术;
  7. 兼容性:支持所有现代浏览器,IE 完全不支持,现代前端开发可放心使用,无需兼容;
  8. 核心价值:Proxy + Reflect 让 JavaScript 拥有了优雅的元编程能力,开发者可以通过写代码来操作代码,大幅提升代码的灵活性、可扩展性和可维护性。

Proxy 与 Reflect 是 ES6 中最强大的特性之一,也是前端进阶的必备知识点,掌握二者的结合使用,不仅能理解 Vue3 等现代框架的底层实现,更能在实际开发中写出更优雅、更灵活的代码,实现各种复杂的业务需求。

Pinia 超进化!从此不需要 Axios

作者 Forever7_
2026年2月11日 09:41

Pinia Colada 让 Vue 应用中的数据请求变得轻而易举。它构建于 Pinia 之上,彻底消除了数据请求带来的所有复杂度与样板代码。它具备完整的类型支持、可摇树优化,并且遵循与 Pinia 和 Vue 一致的设计理念:简单易上手、灵活可扩展、功能强大,还能实现渐进式接入。

640.png

核心特性

  • ⚡️ 自动缓存:智能客户端缓存,自带请求去重能力
  • 🗄️ 异步状态:简化异步状态管理逻辑
  • 🔌 插件系统:功能强大的插件扩展体系
  • ✨ 乐观更新:服务端响应返回前即可更新 UI
  • 💡 合理默认配置:开箱即用,同时保持全量可配置性
  • 🧩 内置插件:自动重新请求、加载延迟等功能一键启用
  • 📚 类型脚本支持:业界领先的 TypeScript 类型体验
    • 💨 极小包体积:基础核心仅约 2kb,且完全支持摇树优化
  • 📦 零外部依赖:除 Pinia 外无任何第三方依赖
  • ⚙️ 服务端渲染(SSR):原生支持服务端渲染

📝 注意:Pinia Colada 始终致力于持续改进和演进。我们非常欢迎大家针对现有功能或新功能方向提供反馈!同时也高度赞赏对文档、Issue、PR(代码合并请求)的贡献。

安装

npm install pinia @pinia/colada

安装你所需功能对应的插件:

import { createPinia } from 'pinia'  
import { PiniaColadafrom '@pinia/colada'  
  
app.use(createPinia())  
// 需在 Pinia 之后安装  
app.use(PiniaColada, {  
  // 可选配置项  
})

使用方式

Pinia Colada 的核心是 useQuery 和 useMutation 两个函数,分别用于数据查询和数据写入。以下是简单示例:

<script lang="ts" setup>  
import { useRoute } from 'vue-router'  
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'  
import { patchContact, getContactById } from '~/api/contacts'  
  
const route = useRoute()  
const queryCache = useQueryCache()  
  
// 数据查询  
const { data: contact, isPending } = useQuery({  
  // 缓存中该查询的唯一标识  
  key: () => ['contacts', route.params.id],  
  // 实际执行的查询逻辑  
  query: () => getContactById(route.params.id),  
})  
  
// 数据变更  
const { mutate: updateContact, isLoading } = useMutation({  
  // 实际执行的变更逻辑  
  mutation: patchContact,  
  async onSettled({ id }) {  
    // 使上述查询失效,触发数据重新请求  
    await queryCache.invalidateQueries({ key: ['contacts', id], exact: true })  
  },  
})  
</script>  
  
<template>  
  <section>  
    <p v-if="isPending">加载中...</p>  
    <ContactCard  
      v-else  
      :key="contact.id"  
      :contact="contact"  
      :is-updating="isLoading"  
      @update:contact="updateContact"  
    />  
  </section>  
</template>

想了解更多核心概念及使用方式,请查阅官方文档。 pinia-colada.esm.dev/

研发场景十大热门 Skills 推荐

作者 阿虎儿
2026年2月11日 09:38

本文面向实际研发场景,整理并推荐了一组可直接应用于日常开发流程的 Agent Skills。这些 Skills 覆盖前端设计、前后端开发、代码审查、自动化测试、CI/CD、问题修复以及文档维护等常见环节,分别针对具体任务提供明确的能力边界与使用场景说明,帮助你在不同阶段选择合适的 Skills,提高开发效率。

图片

前端设计

名称

frontend-design

作者

Anthropic

地址

   

github.com/anthropics/…

   

简介

该 Skill 旨在创建具有 独特性 和 高设计品质 的前端界面,能够达到 生产级别 (production-grade) 的标准。它的核心目标是避免生成千篇一律、缺乏独特风格的 “AI 风格” 界面,而是通过在设计上有意地选择大胆、明确的美学方向(例如:极简、复古、未来感、野兽派等),并注重排版、色彩、动效、空间布局等细节,来打造出令人印象深刻、具有艺术感的前端页面。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 构建网页组件或页面

    当你需要从零开始创建一个具体的 UI 元素时,例如一个 React 组件、一个 HTML/CSS 布局,或一个独立的静态页面。该 Skill 会确保这个组件不仅功能完善,而且在视觉上具有辨识度。

  • 开发完整的 Web 应用或网站

    当你需要构建一个完整的应用界面时,比如一个产品的 Landing Page、一个数据仪表盘或一个小型网站。该 Skill 会从整体出发,确立一个统一且鲜明的设计风格,并将其贯彻到应用的每一个角落。

  • 美化或重塑现有界面

    当你有一个已经存在的、但设计平庸的网页或应用,并希望提升其视觉品质时。该 Skill 会专注于美化层面的工作,通过引入独特的字体、创意的色彩方案、精致的动效和新颖的布局,来重塑界面的整体美感。

图片

前端开发

名称

cache-components

作者

vercel

地址

   

github.com/vercel/next…

   

简介

该 Skill 旨在将 Next.js 的 Partial Prerendering (PPR) 和缓存组件(Cache Components)的最佳实践,通过 AI 助手无缝集成到开发工作流中。当项目环境启用 cacheComponents: true 配置时,该 Skill 将被激活,为你提供自动化的代码生成与优化能力。

资源文件

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • PATTERNS.md: 一份详细的说明文档,通过丰富的代码示例和场景解释,来展示如何高效、正确地使用 Cache Components。

  • REFERENCE.md: Cache Components 的官方 API 参考手册,它精确定义了所有相关函数、指令和核心概念,用于查找具体技术细节。

  • TROUBLESHOOTING.md: Cache Components 的故障排查指南,提供了一系列常见错误的解决方案、调试清单和实用技巧,用于诊断并解决使用缓存组件时遇到的问题。

应用场景

  • 自动生成缓存优化的数据组件

    当创建数据获取组件时,系统会自动应用最优的渲染策略:针对可共享数据(如产品目录),使用 **'use cache' **语法进行缓存;针对用户专属内容,则自动添加  ** ** 边界,以实现动态流式渲染。

  • 自动实现数据变更后的缓存失效

    当生成用于修改数据的 Server Action 时,系统会自动注入缓存失效逻辑(如 **updateTag() **方法)。这能确保数据变更后,相关缓存会立即更新,从而保障整个应用的数据一致性。

  • 智能化页面构建与代码现代化

    在构建页面或审查代码时,系统会强制遵循 Partial Prerendering(PPR)架构规范,以实现最优的加载性能。同时,它能识别并给出现代化改造建议,例如用组件级的  **'use cache' **替代已过时的页面级缓存配置。

图片

全栈开发

名称

fullstack-developer

作者

Shubhamsaboo

地址

   

github.com/Shubhamsabo…

   

简介

该 Skill 的主要作用是扮演一个 精通现代 Web 开发技术的全栈专家角色。它专注于使用 JavaScript/TypeScript 技术栈,特别是 React (Next.js)、Node.js 和主流数据库,来帮助你完成各类 Web 开发任务。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 构建完整的 Web 应用: 从前端到后端,提供完整的解决方案。

  • 开发 API: 创建 RESTful 或 GraphQL 风格的后端接口。

  • 创建前端界面: 使用 React 或 Next.js 构建现代化的用户界面。

  • 数据库和数据建模: 设计和设置如 PostgreSQL 或 MongoDB 等数据库。

  • 实现用户认证与授权: 集成 JWT、OAuth 等认证机制。

  • 部署与扩展应用: 提供在 Vercel、Netlify 等平台上的部署指导。

  • 集成第三方服务: 在应用中接入外部服务。

图片

代码审查(前端)

名称

frontend-code-review

作者

langgenius

地址

   

github.com/langgenius/…

   

简介

该 Skill 的核心功能是自动化审查前端代码(尤其针对 .tsx、.ts、.js 等文件)。它会依据预定义的规则清单,从代码质量、性能表现、业务逻辑等维度对代码开展全面分析。审查完成后,系统将生成结构清晰的报告,把发现的问题划分为 “紧急待修复” 和 “改进建议” 两类,并标注具体的代码位置、提供可落地的修复方案,助力你提升代码质量。

资源文件

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • references/business-logic.md: 定义与特定业务场景相关的规则,以防止出现逻辑错误或 Bug。例如 : 规定在某些组件中不能使用 workflowStore ,因为在特定使用场景下(如从模板创建管道时)它会导致页面白屏。

  • references/code-quality.md: 包含一系列通用编码规范,旨在保持代码的整洁、一致和可维护性。例如 : 推荐使用 cn 这样的工具函数来处理动态的 CSS 类名,而不是手动拼接字符串,以保证代码风格统一。

  • references/performance.md: 专注于前端性能,提供避免常见性能问题的最佳实践。例如 : 建议将传递给子组件的对象或数组等复杂属性用 useMemo 包裹起来,以防止不必要的组件重渲染。

应用场景

  • 审查待提交的变更

    在你准备提交代码(git commit)前,可使用该 Skill 审查所有已修改或已暂存的文件,提前识别并标记不符合规范的代码,避免潜在的 Bug 或性能问题被合入代码库。

  • 审查指定的文件

    当你需对特定文件或模块进行重构、优化或问题排查时,可将相关文件交由该 Skill 审查,从而对目标文件开展针对性深度分析,快速获取该文件的质量评估结果及改进建议。

  • 获取结构化的修复报告

    代码审查发现问题后,该 Skill 会自动整理并输出标准化报告。这份报告不仅罗列问题,还会按紧急程度排序,同时标注问题对应的文件路径、行号、相关代码片段,并给出可执行的修复方案。

图片

代码审查(通用)

名称

code-reviewer

作者

google-gemini

地址

github.com/google-gemi…

   

简介

该 Skill 旨在引导 AI 开展专业且全面的代码审查工作。它既支持审查本地代码改动(包括已暂存和未暂存的变更),也可审查远程代码合并请求(Pull Request,简称 PR)。审查的核心目标是保障代码的正确性、可维护性,并确保代码符合项目既定的规范标准。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 审查远程 PR

    当你完成功能开发或问题修复并提交 PR 后,可发起 AI 审查请求。你只需提供 PR 编号或 URL(例如:"Review PR #123"),AI 会自动检出(checkout)该 PR 的代码,运行项目预设的检查脚本(如 npm run preflight),同时阅读 PR 描述与评论以理解开发目标,随后对代码开展深度分析并给出反馈。

  • 审查本地代码变更

    若你希望在提交代码或创建 PR 前,先对本地修改进行审查,只需发出 “审查我的代码” 等类似指令即可,无需提供 PR 相关信息;AI 会通过 git statusgit diff 等命令,检查工作区中已暂存(staged)和未暂存(unstaged)的代码改动,进而对这些变更进行分析并反馈。

  • 提供深度分析与结构化反馈

    无论是审查远程 PR 还是本地代码变更,AI 都会从多维度开展深度的代码质量分析,涵盖正确性、可维护性、可读性与执行效率、安全性与测试完整性等维度。最终,AI 会以结构化形式输出反馈,内容包括总体概述、具体发现(关键问题、改进建议)以及明确的结论(如批准合并或要求修改)。

图片

网页应用测试

名称

webapp-testing

作者

Anthropic

地址

   

github.com/anthropics/…

   

简介

该 Skill 是一个基于 Playwright 构建的本地 Web 应用测试工具集,支持前端功能验证、UI 行为调试、页面截图及浏览器控制台日志采集,适配「先侦查后行动」的测试流程。工具集提供示例脚本与辅助脚本,覆盖静态 HTML 自动化测试、元素定位、日志抓取能力,并可在多服务场景下统一管理服务器生命周期。

资源文件

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • examples/console_logging.py: 演示了在自动化测试时如何捕获并保存网页的控制台日志,用于调试和监控。

  • examples/element_discovery.py: 展示了如何自动发现和列出页面上所有的按钮、链接和输入框等可交互元素,是编写自动化脚本前的重要步骤。

  • examples/static_html_automation.py: 示范了如何直接对本地的静态 HTML 文件进行自动化操作(如点击、填表),而无需通过网络服务器。

  • scripts/with_server.py: 自动化辅助脚本。在执行一个主命令(例如自动化测试脚本)之前,先启动一个或多个依赖的服务(如前端或后端服务器),并确保这些服务完全就绪后,再执行主命令,最后在命令结束后自动关闭所有服务。

应用场景

  • 自动验证前端功能

    当你在本地开发 Web 应用(如使用 React、Vue、Svelte 等框架),并希望验证某个新功能是否正常运行时,只需用自然语言告知 AI 测试需求(例如:“帮我测试登录功能”),AI 会自动编写 Playwright 脚本来模拟用户操作,并向你反馈页面状态或内容是否符合预期。

  • 调试与分析 UI 行为

    若你发现页面中某元素渲染异常或交互行为异常,可向 AI 下达指令(例如:“截取首页完整截图” 或 “检查按钮的 DOM 结构”),AI 会执行对应脚本,捕获截图或获取 HTML 内容,并将侦察结果返回给你,助力快速定位问题。

  • 处理需要后台服务的复杂交互

    若你的应用为前后端分离架构,测试前端功能需后端 API 服务同步运行,只需告知 AI 项目结构与启动命令,AI 会借助 with_server.py 脚本来同时启动所有必需服务,再运行测试脚本,确保测试在完整的环境中执行。

  • 测试静态 HTML 文件

    若你有不依赖服务器的纯静态 HTML 页面,需验证其内容或结构,只需向 AI 提供文件路径与测试需求,AI 会编写脚本并通过 **file:// **协议在浏览器中打开该文件,完成验证操作。

图片

CI/CD:PR 创建

名称

pr-creator

作者

google-gemini

地址

   

github.com/google-gemi…

   

简介

该 Skill 的核心作用是引导并自动化创建高质量、符合规范的拉取请求(Pull Request,简称 PR)。它通过标准化工作流程,确保每一次代码提交均遵循项目预设的模板与质量检查标准,从而提升代码审查效率、保障团队协作的一致性。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 一键创建符合规范的 PR

    当你在本地完成新功能开发或 Bug 修复,并已提交代码(git commit)后,可调用此 Skill,让 AI 自动执行分支检查、查找并应用 PR 模板、运行预检脚本(如测试和 linting),并最终生成一个标题和描述都完全符合项目规范的 PR。

  • 引导贡献者完成首次代码提交

    当新团队成员或外部贡献者不熟悉项目的提交流程和规范时,可以使用此 Skill,让 AI 以智能向导的形式,自动完成模板查找、脚本执行等繁琐操作,仅需用户填写必要的标题与描述,大幅降低代码贡献门槛。

  • 自动执行创建 PR 前的质量检查

    在正式创建 PR 之前,可以调用该 Skill,让 AI 自动运行项目预设的 preflight 脚本,执行所有必要的构建、单元测试和代码风格检查。如果任何检查失败,AI 会中止提交流程并提示开发者进行修复,节约了审查者的时间和精力。

图片

Linting 和格式错误修复

名称

fix

作者

facebook

地址

   

github.com/facebook/re…

   

简介

这个 skill 的核心作用是自动化地修复代码格式并检查代码规范(linting)错误 。它通过执行两个关键命令来保证代码质量:

  • **yarn prettier: **自动格式化已修改的文件,统一代码风格。

  • **yarn linc: **检查代码中是否存在 linting 错误(这些是 Prettier 无法修复的,例如未使用的变量、逻辑错误等),这些错误通常会导致持续集成(CI)失败。

最终目标是确保代码在提交前符合项目规范,从而顺利通过 CI/CD 流程。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 提交代码前的预防性检查

    在你完成编码,执行 git commit 之前,运行该 Skill,让 AI 自动清理代码格式,并提示任何需要手动修复的 linting 错误。

  • 修复已发现的 linting 或格式问题

    当你在编码过程中或接手他人代码时,发现当前工作区内存在明显的格式混乱或 linting 错误提示(例如,IDE 的警告),可以立即运行该 Skill,快速解决当前已知的代码质量问题,从而在开发过程中保持代码的整洁和可读性。

  • 解决持续集成(CI)失败问题

    当一个提交被推送到服务器后,CI 流水线报告了因 linting 或格式错误导致的失败。此时你可以在本地对应的分支上运行此 Skill,让 AI 自动修复格式问题,并列出需要手动更正的 linting 错误,帮助你快速定位并解决问题,然后提交修复。

图片

技术文档更新

名称

update-docs

作者

vercel

地址

   

github.com/vercel/next…

   

简介

该 Skill 是一套用于更新 Next.js 项目文档的引导式工作流,核心作用是帮助你根据源代码的变更,来分析、更新和创建相关的文档,确保代码和文档保持同步。它特别为审查 Pull Request (PR) 时的文档完整性检查而设计,通过一系列标准化的步骤来规范文档的修改过程。

资源文件

   

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • references/CODE-TO-DOCS-MAPPING.md: 定义了源代码和文档之间的映射关系。简单来说,它就像一张地图,告诉 AI 当某个代码文件发生变化时,应该去更新哪个文档文件。
  • references/DOC-CONVENTIONS.md: 风格指南和规则手册,详细规定了文档的格式、结构和写作风格,以确保所有文档都保持一致性和高质量。

   

应用场景

  • 分析代码变更对文档的影响

    提交代码变更后,可以调用该 Skill 来分析哪些文档文件需要更新。

    它会通过 git diff 命令检查你的分支与 canary 分支之间的差异,并根据预定义的映射关系  (references/CODE-TO-DOCS-MAPPING.md),找出与变更的代码文件相对应的文档文件。

  • 更新现有的文档

    对于已经存在的文档,当其对应的功能或 API 发生变化时(例如组件新增了 props、函数行为变更),该 Skill 会引导你更新现有文档。

    它会提示你如何添加或修改 props 表格、更新代码示例、添加废弃通知等,并遵循项目固有的文档规范(例如,使用  ** ** /  ** ** 来区分不同路由的内容)。

  • 为新功能创建脚手架文档

    当你在项目中添加了一个全新的功能时(例如一个新的组件、函数或配置项),该 Skill 可以帮你快速创建符合规范的新文档。

    它为不同类型的文档(如 API 参考、指南)提供了标准模板,确保新文档的结构、命名和元信息(Frontmatter)都符合项目要求。

图片

查找 Skill

名称

find-skills

作者

vercel

地址

   

github.com/vercel-labs…

   

简介

该 Skill 主要作用帮助你发现并安装 Agent Skill。

它依托名为 skills 的命令行工具(CLI),让你可以从开放的 Agent Skill 生态中搜索、安装与管理各类模块化技能包;这些技能可扩展 Agent 能力,为其补充特定领域知识、标准化工作流与工具能力。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 探索未知的 Skill

    当你希望 Agent 帮忙处理某个特定领域的任务,但不确定 Agent 是否具备相应能力时,可以使用此 Skill 进行探索。例如,当你询问 “你能帮我评审代码吗?” 或 “如何为我的项目生成文档?” 时,该 Skill 会被激活,主动在技能市场中搜索与 “代码评审” 或“ 文档生成” 相关的能力,并将找到的可用技能呈现给你.

  • 查找特定的 Skill

    当你明确知道需要一个 Skill 来解决特定问题,但不知道具体是哪个 Skill 时,可以主动调用此 Skill 进行精确查找。例如,你可以直接说 “帮我找一个用于 React 性能优化的 skill”,该 Skill 会将 “React 性能优化” 作为关键词进行搜索,并返回最匹配的技能选项,如 “vercel-react-best-practices”。

  • 提供可执行的 Skill 安装建议

    当该 Skill 找到一个或多个匹配的 skill 后,它会自动整理并输出一份标准化的推荐信息。这份信息不仅包含技能的名称和功能简介 ,还会提供 一键安装指令 (npx skills **add ... **) 以及指向技能详情页的官方链接。

欢迎大家安装尝试~先从会用 Skills 开始!


来源

React智能弹窗队列深度解析:优先级控制与工程化实践全攻略

作者 QLuckyStar
2026年2月11日 09:37

基于React实现智能弹窗队列的完整方案,支持优先级控制、显示频率限制和动态内容渲染,结合了队列管理、状态解耦和动画控制的核心思想:

一、架构设计

1. 核心数据结构

// types/modal.d.ts
export interface ModalConfig {
  id: string;             // 唯一标识
  content: React.ReactNode; // 弹窗内容
  priority?: number;      // 优先级(0-10)
  max displays?: number;  // 最大显示次数
  expireTime?: number;    // 过期时间戳
  onShow?: () => void;    // 显示回调
  onClose?: () => void;   // 关闭回调
}

2. 队列管理器实现

// hooks/useModalQueue.ts
import { useState, useCallback, useEffect } from 'react';
import { createRoot } from 'react-dom/client';

const MODAL_ROOT_ID = '__modal-root__';
const modalRoot = document.createElement('div');
modalRoot.id = MODAL_ROOT_ID;
document.body.appendChild(modalRoot);

const useModalQueue = () => {
  const [queue, setQueue] = useState<ModalConfig[]>([]);
  const [current, setCurrent] = useState<ModalConfig | null>(null);

  // 添加弹窗到队列
  const enqueue = useCallback((config: ModalConfig) => {
    // 频率控制逻辑
    const now = Date.now();
    const existing = queue.find(m => m.id === config.id);
    
    if (existing) {
      if (existing.maxDisplays && existing.displayCount >= existing.maxDisplays) {
        console.warn(`弹窗 ${config.id} 已达最大显示次数`);
        return;
      }
      
      if (existing.expireTime && now > existing.expireTime) {
        console.warn(`弹窗 ${config.id} 已过期`);
        return;
      }
      
      // 更新现有弹窗配置
      setQueue(prev => prev.map(m => 
        m.id === config.id ? { ...m, ...config } : m
      ));
    } else {
      setQueue(prev => [...prev, config]);
    }
  }, [queue]);

  // 处理队列逻辑
  const processQueue = useCallback(() => {
    if (!current && queue.length > 0) {
      const next = queue.sort((a, b) => b.priority! - a.priority!)[0];
      setCurrent(next);
    }
  }, [current, queue]);

  // 显示弹窗
  const showModal = useCallback((config: ModalConfig) => {
    enqueue(config);
    processQueue();
  }, [enqueue, processQueue]);

  // 关闭当前弹窗
  const closeModal = useCallback(() => {
    if (!current) return;
    
    current.onClose?.();
    setQueue(prev => prev.filter(m => m.id !== current.id));
    setCurrent(null);
    processQueue();
  }, [current, processQueue]);

  // 自动处理队列
  useEffect(() => {
    processQueue();
  }, [queue, processQueue]);

  // 渲染弹窗
  useEffect(() => {
    if (!current) return;
    
    const root = createRoot(modalRoot);
    root.render(
      <ModalWrapper 
        config={current}
        onClose={closeModal}
      />
    );
  }, [current, closeModal]);

  return { showModal, closeModal };
};

// 弹窗包装组件
const ModalWrapper = ({ config, onClose }: { 
  config: ModalConfig; 
  onClose: () => void 
}) => {
  return (
    <div className="modal-backdrop">
      <div className="modal-content">
        {config.content}
        <button onClick={onClose}>关闭</button>
      </div>
    </div>
  );
};

二、核心功能实现

1. 显示频率控制

// 示例:每天最多显示3次
const today = new Date().toISOString().split('T')[0];
const config: ModalConfig = {
  id: 'promotion',
  content: <PromotionComponent />,
  maxDisplays: 3,
  expireTime: Date.now() + 24 * 60 * 60 * 1000,
  onShow: () => {
    localStorage.setItem(`modal_${config.id}`, today);
  }
};

2. 优先级队列排序

// 在processQueue中修改排序逻辑
const next = queue.sort((a, b) => {
  if (a.priority === b.priority) return 0;
  return b.priority - a.priority; // 降序排列
})[0] || queue[0];

3. 动态内容渲染

// 使用动态组件
const DynamicContent = ({ type }: { type: 'alert' | 'confirm' }) => {
  return type === 'alert' ? <AlertComponent /> : <ConfirmComponent />;
};

// 调用示例
showModal({
  id: 'dynamic-modal',
  content: <DynamicContent type="confirm" />,
  priority: 5
});

三、高级特性扩展

1. 插队机制

// 修改enqueue方法
const enqueue = useCallback((config: ModalConfig & { immediate?: boolean }) => {
  if (config.immediate) {
    setQueue([config, ...queue.filter(m => m.id !== config.id)]);
  } else {
    // 常规入队逻辑
  }
}, [queue]);

2. 动画控制

// 使用react-transition-group
import { CSSTransition } from 'react-transition-group';

const ModalWrapper = ({ config, onClose }) => {
  return (
    <CSSTransition
      in={!!current}
      timeout={300}
      classNames="modal"
      unmountOnExit
    >
      <div className="modal-container">
        {config.content}
      </div>
    </CSSTransition>
  );
};

四、使用示例

// 注册弹窗
const { showModal } = useModalQueue();

// 显示普通弹窗
showModal({
  id: 'welcome',
  content: <WelcomeMessage />,
  priority: 3,
  maxDisplays: 1
});

// 显示高优先级弹窗
showModal({
  id: 'urgent-alert',
  content: <UrgentAlert />,
  priority: 10,
  immediate: true
});

五、状态持久化方案

// 使用localStorage记录显示历史
const useModalQueue = () => {
  const [queue, setQueue] = useState<ModalConfig[]>(() => {
    const stored = localStorage.getItem('modalQueue');
    return stored ? JSON.parse(stored) : [];
  });

  useEffect(() => {
    localStorage.setItem('modalQueue', JSON.stringify(queue));
  }, [queue]);
};

六、埋点统计增强

1. 埋点事件类型定义

// types/tracking.ts
export enum TrackingEventType {
  ModalShow = 'modal_show',
  ModalClose = 'modal_close',
  ButtonClick = 'modal_button_click',
  NetworkError = 'modal_network_error'
}

2. 埋点中间件实现

// hooks/useTrackingMiddleware.ts
import { useEffect } from 'react';
import { TrackingEventType } from '../types/tracking';

type TrackingMiddleware = (
  event: TrackingEventType,
  payload: Record<string, any>
) => void;

const trackingMiddlewares: TrackingMiddleware[] = [];

export const useTracking = () => {
  const track = (event: TrackingEventType, payload: Record<string, any>) => {
    // 执行所有中间件
    trackingMiddlewares.forEach(middleware => middleware(event, payload));
  };

  return { track };
};

// 注册全局埋点中间件(示例:发送到Google Analytics)
export const registerTrackingMiddleware = (middleware: TrackingMiddleware) => {
  trackingMiddlewares.push(middleware);
};

3. 弹窗埋点集成

// hooks/useModalQueue.ts
const useModalQueue = () => {
  const { track } = useTracking();

  // 显示弹窗时触发埋点
  const showModal = useCallback((config: ModalConfig) => {
    track(TrackingEventType.ModalShow, {
      modalId: config.id,
      source: config.source || 'system'
    });
    enqueue(config);
    processQueue();
  }, [track]);

  // 关闭弹窗埋点
  const closeModal = useCallback(() => {
    if (!current) return;
    track(TrackingEventType.ModalClose, { modalId: current.id });
    // ...原有关闭逻辑
  }, [current, track]);

  // 按钮点击埋点
  const trackButtonClick = useCallback((modalId: string, buttonType: 'confirm' | 'cancel') => {
    track(TrackingEventType.ButtonClick, { modalId, buttonType });
  }, [track]);
};

二、自动重试机制

1. 重试策略配置

// types/retry.ts
export interface RetryConfig {
  maxAttempts?: number;    // 最大重试次数
  initialDelay?: number;   // 初始延迟(ms)
  backoffFactor?: number;  // 退避系数(指数增长倍数)
}

2. 自动重试中间件

// hooks/useRetryMiddleware.ts
import { useEffect, useRef } from 'react';
import { TrackingEventType } from './tracking';

type RetryMiddleware = (
  action: () => Promise<void>,
  config: RetryConfig
) => Promise<void>;

export const useRetry = () => {
  const retryQueue = useRef<any[]>([]);

  const executeWithRetry = async <T>(
    action: () => Promise<T>,
    config: RetryConfig = {}
  ) => {
    const { maxAttempts = 3, initialDelay = 1000, backoffFactor = 2 } = config;
    let attempts = 0;

    while (attempts < maxAttempts) {
      try {
        return await action();
      } catch (error) {
        attempts++;
        if (attempts >= maxAttempts) throw error;

        const delay = initialDelay * Math.pow(backoffFactor, attempts);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  };

  return { executeWithRetry };
};

3. 弹窗重试集成

// 在弹窗组件中使用
const PromotionModal = ({ onClose }: { onClose: () => void }) => {
  const { executeWithRetry } = useRetry();
  const track = useTracking();

  const handleShow = async () => {
    try {
      await executeWithRetry(
        () => sendTrackingData('modal_show', { modalId: 'promotion' }),
        { maxAttempts: 5, backoffFactor: 1.5 }
      );
    } catch (error) {
      track(TrackingEventType.NetworkError, {
        modalId: 'promotion',
        error: error.message
      });
    }
  };

  return (
    <div>
      <button onClick={() => handleShow()}>显示弹窗</button>
    </div>
  );
};

三、全局拦截器

1. 拦截器架构设计

// hooks/useInterceptor.ts
type Interceptor = (
  context: {
    modalId: string;
    config: ModalConfig;
    next: () => Promise<void>;
  }
) => Promise<void>;

const interceptors: Interceptor[] = [];

export const useInterceptor = () => {
  const register = (interceptor: Interceptor) => {
    interceptors.push(interceptor);
  };

  const runInterceptors = async (context: {
    modalId: string;
    config: ModalConfig;
    next: () => Promise<void>;
  }) => {
    for (const interceptor of interceptors) {
      await interceptor(context);
    }
  };

  return { register, runInterceptors };
};

2. 拦截器实现示例

// 权限拦截器
const authInterceptor = async ({ config, next }: InterceptorContext) => {
  if (config.requiresAuth && !isUserLoggedIn()) {
    redirectToLogin();
    throw new Error('权限拦截');
  }
  await next();
};

// 日志拦截器
const logInterceptor = async ({ config, next }: InterceptorContext) => {
  console.log(`[${new Date().toISOString()}] 弹窗显示: ${config.id}`);
  await next();
  console.log(`[${new Date().toISOString()}] 弹窗关闭: ${config.id}`);
};

3. 弹窗队列集成

// 修改processQueue逻辑
const processQueue = useCallback(async () => {
  if (!current && queue.length > 0) {
    const next = queue.sort(/*...*/)[0] || queue[0];
    
    try {
      await interceptor.runInterceptors({
        modalId: next.id,
        config: next,
        next: () => setCurrent(next)
      });
      setCurrent(next);
    } catch (error) {
      console.error('拦截器阻止弹窗显示:', error);
    }
  }
}, [interceptor]);

四、完整集成方案

1. 项目结构

src/
├── hooks/
│   ├── useModalQueue.ts       # 核心队列管理
│   ├── useTracking.ts         # 埋点中间件
│   ├── useRetry.ts            # 重试机制
│   └── useInterceptor.ts      # 拦截器系统
├── middleware/                # 中间件集合
│   ├── analytics.ts           # 埋点实现
│   ├── errorHandler.ts        # 错误处理
│   └── authInterceptor.ts     # 权限拦截
├── components/
│   └── ModalWrapper/          # 弹窗包装组件
└── utils/
    └── retryPolicy.ts         # 重试策略工具

2. 使用示例

// 初始化配置
const { track } = useTracking();
const { executeWithRetry } = useRetry();
const { register } = useInterceptor();

// 注册拦截器
register(authInterceptor);
register(logInterceptor);

// 显示带埋点的弹窗
const showModal = async () => {
  try {
    await executeWithRetry(
      () => showModalConfig({
        id: 'promotion',
        content: <PromotionComponent />,
        onShow: () => track('modal_show', { source: 'homepage' })
      }),
      { maxAttempts: 3 }
    );
  } catch (error) {
    track('modal_show_failed', { error: error.message });
  }
};

前端向架构突围系列 - 设计与落地 [9 - 3]:BFF (Backend for Frontend) 层与 Serverless 的架构融合

2026年2月11日 09:34

写在前面

传统的开发模式中,前端是后端的“消费者”。后端喂什么,我们就吃什么。

这种模式在移动端、Web 端、小程序多端并行的今天,效率极低。BFF (Backend for Frontend) 的核心哲学是:谁消费,谁负责。 既然 UI 是前端设计的,那么为了支撑这个 UI 而存在的数据聚合层,也理应由前端来主导。

image.png


一、 为什么前端要搞 BFF?(告别“接口乞讨”)

在没有 BFF 的日子里,前端架构师经常面临三个窘境:

  1. 数据臃肿: 后端为了通用,一个接口返回 2MB 的 JSON,前端只要其中一个 id
  2. 请求地狱: 为了渲染一个首页,前端要在 useEffect 里调 A 接口拿 userId,再调 B 接口拿详情,最后调 C 接口拿评论。
  3. 逻辑混杂: 前端为了适配后端奇怪的字段名,代码里充斥着 data.list[0].user_info_v2.name || '匿名' 这种防错逻辑。
  4. 脆弱的“防御性代码”: 为了防止后端返回 null 或字段缺失,前端代码里充斥着大量的可选链(Optional Chaining)和硬编码的默认值。这其实是**“代码腐烂”**的开始——你根本不敢删掉这些逻辑,因为你不知道哪天接口又会吐出奇怪的数据
  5. 多端兼容噩梦: 同样的后端接口,Web 端需要显示 YYYY-MM-DD,小程序需要显示 MM月DD日,App 需要显示 3分钟前。如果这些逻辑全写在前端,每增加一个端,业务逻辑就要重写一遍。

BFF 的出现,就是为了在前端和微服务之间,加一层“数据洗涤机”。


二、 BFF 的三张王牌:聚合、转换、适配

一个设计良好的 BFF 层,应该承担以下三个核心职责:

2.1 接口聚合 (Aggregator)

将原本需要前端发起 5 次的网络请求,在 BFF 层合并。BFF 与后端微服务走的是内网通信(RPC 或高带宽 HTTP),速度远快于移动端弱网环境下的多次请求。

2.2 数据瘦身与洗涤 (Formatter)

后端给的字段是 user_head_img_url_v2_stable,BFF 转换成 avatar。后端给了 100 个字段,BFF 只吐给前端渲染需要的 5 个。

2.3 跨端重用 (Adapter)

  • Web 端: 返回完整的 HTML(SSR)或大而全的 JSON。
  • App 端: 返回极致压缩的二进制数据或精简 JSON。
  • BFF 逻辑: 核心业务逻辑在 BFF 内部共享,根据请求头自动适配输出格式。

三、 Serverless:BFF 的“完美外壳”

很多前端架构师不敢推行 BFF,是因为害怕**“运维地狱”**:谁来配 Nginx?服务器挂了谁修?内存泄露了怎么办?

Serverless (无服务器架构) 的出现,彻底解开了这个心结。

3.1 为什么是 FaaS + BFF?

  • 免运维: 前端只写函数(Function),不看服务器。部署一个 BFF 就像提交一段 JS 代码一样简单。
  • 弹性伸缩: 只有在接口被调用时才计费。深夜没人访问时,成本几乎为零。
  • 按需交付: 一个页面对应一个云函数(SFF, Serverless For Frontend),边界清晰,互不干扰。

四、 深度融合:BFF + Serverless 的落地挑战

虽然听起来很美,但在实际架构落地中,你需要处理以下深水区问题:

4.1 响应延迟 (Cold Start)

Serverless 函数在久未调用后会有“冷启动”时间。

  • 架构对策: 对实时性要求极高的接口,开启“预热”或“预留实例”;或者使用 Node.js 运行时较轻量级的框架(如 Midway.js 或 Nitro)。

4.2 链路追踪 (Tracing)

当页面报错时,问题可能出在:前端 -> BFF -> 后端微服务 A -> 数据库。

  • 架构决策: 必须引入全链路 Trace ID。在 BFF 层生成统一的 X-Trace-Id 贯穿始终。

4.3 接口定义冲突

如果 BFF 是前端写的,后端改了微服务接口,BFF 怎么知道?

  • 最佳实践: 推动后端导出 OpenAPI (Swagger) 或使用 gRPC。BFF 层通过脚本自动生成类型定义,利用 TypeScript 在编译期就发现接口变动。

五、 架构决策:选型 ROI 分析

架构收益=(用户体验提升+前端开发效率)运维复杂度的增加架构收益 = \frac{(用户体验提升 + 前端开发效率)}{运维复杂度的增加}

在 Serverless 场景下,分母被大幅削减,因此 BFF 几乎成为了中大型前端项目的标配

模式 适用场景 开发成本 运维难度
传统模式 简单项目,后端接口已经很完美 极低
Node.js BFF 复杂业务,前端团队有后端背景 高 (需管集群)
Serverless BFF 主流推荐,追求极致开发效率 低 (只写函数) 极低 (托管)

结语:从“画饼”到“造饼”

BFF 和 Serverless 的结合,是前端架构师**从“UI 实现者”向“全链路设计者”**跨越的关键一步。当你不再受限于后端接口的形状,你才能真正从全局视角去优化性能和用户体验。

一键生成专业 README: 模板 + badges + shields.io + 动态内容(badges、visitors count)

2026年2月11日 09:33

一键生成“专业级” README 的时代已经到来。
2026 年,你不再需要从零手写一堆 Markdown + 手动改 badges。借助现成模板 + Shields.io + 动态统计服务 + 生成器工具,5 分钟就能做出高颜值、可维护、实时更新的 README。

这一期我们一步步教你从模板起步 → 加 badges → 加动态内容的全流程。最终效果:

  • 视觉炸裂(badges 整齐排列)
  • 数据实时(stars、forks、visitors、stats)
  • 一键复制粘贴

一、先选一个强力 README 模板(2026 年主流推荐)

最受欢迎、star 最高的模板仍是 othneildrew/Best-README-Template(10w+ stars,常年更新):

核心结构(复制这个框架,然后填空):

# 项目名称

一句话描述 + 徽章区

## 目录

- [关于项目](#关于项目)
- [内置功能](#内置功能)
- [快速开始](#快速开始)
- [使用示例](#使用示例)
- [路线图](#路线图)
- [贡献](#贡献)
- [许可](#许可)
- [联系方式](#联系方式)
- [致谢](#致谢)

## 关于项目

![项目截图](images/screenshot.png)

项目背景、解决问题、核心特性、技术栈...

### 内置功能

- 功能1
- 功能2

### 用到的技术栈

- [![Next.js][Next.js]][Next-url]
- [![React][React.js]][React-url]

...

## 快速开始

### 前置要求

- Node.js 20+
- pnpm / yarn

### 安装

```bash
git clone https://github.com/username/repo.git
cd repo
pnpm install

运行

pnpm dev

...

贡献

欢迎 PR!请阅读 CONTRIBUTING.md

许可

Distributed under the MIT License. See LICENSE for more information.

为什么这个模板好?

  • 结构清晰、章节齐全
  • 支持多语言(可加中文版)
  • 徽章区预留位置
  • 易扩展(加 mermaid 图、alerts 等)

其他备选(如果你是个人 profile 或特定类型项目):

  • rahuldkjain/gh-profile-readme-generator(在线一键生成 profile README)
  • durgeshsamariya/awesome-github-profile-readme-templates(海量美化模板)

二、Shields.io Badges 终极用法(静态 + 动态)

Shields.io 是 2026 年 badges 的王者:支持静态、动态、endpoint、JSON 等。

常用静态 badges(复制即用)

[![GitHub stars](https://img.shields.io/github/stars/username/repo?style=social)](https://github.com/username/repo/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/username/repo?style=social)](https://github.com/username/repo/network/members)
[![GitHub license](https://img.shields.io/github/license/username/repo)](https://github.com/username/repo/blob/main/LICENSE)
[![npm version](https://img.shields.io/npm/v/package-name)](https://www.npmjs.com/package/package-name)
[![GitHub last commit](https://img.shields.io/github/last-commit/username/repo)](https://github.com/username/repo/commits/main)

推荐一排 badges 布局(放标题下):

<p align="center">
  <a href="https://github.com/username/repo/stargazers"><img src="https://img.shields.io/github/stars/username/repo?style=flat-square&logo=github" alt="Stars"/></a>
  <a href="https://github.com/username/repo/network/members"><img src="https://img.shields.io/github/forks/username/repo?style=flat-square&logo=github" alt="Forks"/></a>
  <a href="https://github.com/username/repo/issues"><img src="https://img.shields.io/github/issues/username/repo?style=flat-square" alt="Issues"/></a>
  <a href="https://github.com/username/repo/blob/main/LICENSE"><img src="https://img.shields.io/github/license/username/repo?style=flat-square" alt="License"/></a>
</p>

动态 badges 进阶(用 shields.io endpoint 或第三方服务):

  • GitHub stats:anuraghazra/github-readme-stats(超级流行)

    ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=yourusername&show_icons=true&theme=radical)
    ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=yourusername&layout=compact)
    
  • Visitors count(访客计数)——2026 年常用服务:

    示例:

    ![Visitors](https://profile-counter.glitch.me/_Kurl_/count.svg)
    

三、一键生成工具(真正“一键”)

推荐在线生成器(2026 年最省事):

  1. rahuldkjain/gh-profile-readme-generator

    • rahuldkjain.github.io/gh-profile-…
    • 支持:skills、social links、badges、stats cards、visitors count、动态 GitHub stats
    • 选模板 → 填信息 → 复制 Markdown 一键粘贴
  2. profile-readme-generator.com

    • 类似,但 UI 更现代,支持更多动态元素
  3. 手动 + GitHub Actions 自动化(高级):

    • 用 Dynamic Badges Action 更新 gist 中的 JSON,然后 shields.io 拉取
    • 但对大多数人来说,生成器就够了

四、完整专业 README 示例片段(复制改用户名即可)

<h1 align="center">项目名称 🚀</h1>

<p align="center">
  <img src="https://profile-counter.glitch.me/_Kurl_/count.svg" alt="访客数" />
  <a href="https://github.com/username/repo/stargazers"><img src="https://img.shields.io/github/stars/username/repo?style=flat-square&logo=github" alt="Stars"/></a>
  <a href="https://github.com/username/repo/network/members"><img src="https://img.shields.io/github/forks/username/repo?style=flat-square&logo=github" alt="Forks"/></a>
</p>

<p align="center">
  <img src="https://github-readme-stats.vercel.app/api?username=yourusername&show_icons=true&theme=dracula" alt="GitHub Stats" />
</p>

## 关于项目

一句话 slogan + 核心价值

![Demo GIF](demo.gif)

## 快速开始

...

小技巧

  • <p align="center"> 居中 badges
  • 主题用 theme=dracula / radical / tokyonight 等(github-readme-stats 支持 20+ 主题)
  • 动态内容每刷新 README 都会更新(GitHub 缓存 ~1h)

Vue3实现手写签名:Vue3-signature的快速应用

2026年2月11日 09:14

在公务办理、电子合同、在线审批等场景中,手写签名的电子化需求越来越常见。基于 Vue3 开发的 vue3-signature 组件,能快速帮我们实现浏览器端的手写签名功能,无需复杂的 canvas 原生开发,今天就带大家从零掌握这个实用组件的使用。

一、什么是 vue3-signature?

vue3-signature是专门适配 Vue3 生态的手写签名组件,底层基于 canvas 实现,支持鼠标、触摸(移动端)手写,提供了签名保存、清空、回退、更换颜色 / 粗细等核心功能,体积轻量、API 简洁,完美兼容 Vue3 的组合式 API 和语法,是 Vue3 项目实现电子签名的首选方案。

二、快速上手:5 分钟集成到 Vue3 项目

1. 安装依赖

首先在 Vue3 项目中安装组件(支持 npm/yarn/pnpm):


npmnpm install vue3-signature --save# yarnyarn add vue3-signature# pnpmpnpm add vue3-signature

2. 基础使用示例

在 Vue3 单文件组件中引入并使用,这是最核心的基础用法:

<template>  <div class="signature-container">    <!-- 签名画布核心组件 -->    <Vue3Signature      ref="signatureRef"      :width="500"      :height="300"      :pen-color="#000000"      :pen-width="3"      :disabled="false"      class="signature-canvas"    />
    <!-- 操作按钮组 -->    <div class="signature-btns">      <button @click="clearSignature">清空签名</button>      <button @click="saveSignature">保存签名</button>      <button @click="undoSignature">撤销一步</button>    </div>  </div></template><script setup>import { ref } from 'vue'import Vue3Signature from 'vue3-signature'// 获取组件实例const signatureRef = ref(null)// 清空签名const clearSignature = () => {  signatureRef.value.clear()}// 撤销上一步书写const undoSignature = () => {  // 先判断是否有可撤销的步骤  if (signatureRef.value.canUndo()) {    signatureRef.value.undo()  } else {    alert('已无可撤销的步骤!')  }}// 保存签名(支持Base64/Blob格式)const saveSignature = () => {  // 获取Base64格式的签名图片  const base64 = signatureRef.value.getDataUrl('image/png', 1.0)  // 也可获取Blob格式(适合上传)  // const blob = signatureRef.value.getBlob('image/png', 1.0)
  console.log('签名Base64:', base64)  // 可将base64/blob上传到后端,或展示在页面上}</script><style scoped>.signature-container {  width500px;  margin20px auto;}.signature-canvas {  border1px solid #e5e5e5;  border-radius4px;}.signature-btns {  margin-top15px;  display: flex;  gap10px;}.signature-btns button {  padding6px 12px;  background#409eff;  color#fff;  border: none;  border-radius4px;  cursor: pointer;}.signature-btns button:hover {  background#66b1ff;}</style>

下面是签名的效果展示:

三、核心配置与常用 API

1. 核心属性(Props)

属性名 类型 默认值 说明
width Number 400 画布宽度(px)
height Number 200 画布高度(px)
penColor String #000000 画笔颜色(支持十六进制)
penWidth Number 2 画笔粗细(px)
disabled Boolean false 是否禁用签名
backgroundColor String #ffffff 画布背景色

2. 常用方法(通过 ref 调用)

方法名 参数说明 功能
clear() 清空整个签名画布
undo() 撤销上一步书写
canUndo() 判断是否可撤销(返回布尔值)
getDataUrl(type, quality) type:图片格式(如 image/png);quality:清晰度(0-1) 获取签名的 Base64 编码
getBlob(type, quality) 同 getDataUrl 获取签名的 Blob 对象(适合上传)

四、实战优化:解决常见问题

1. 适配移动端

vue3-signature天然支持触摸事件,只需保证画布宽度适配移动端:

<Vue3Signature  :width="document.documentElement.clientWidth - 40"  :height="200"  ref="signatureRef"/>

2. 校验是否签名

提交前判断用户是否完成签名,避免空签名提交:

const checkSignature = () => {  // isEmpty() 判断画布是否为空  if (signatureRef.value.isEmpty()) {    alert('请完成手写签名!')    return false  }  return true}// 保存前先校验const saveSignature = () => {  if (!checkSignature()) return  // 后续保存逻辑...}

3. 图片跨域问题

若需在签名画布中插入背景图(如合同模板),需确保图片资源支持跨域,否则会导致getDataUrl/getBlob报错,可要求后端配置跨域头,或使用同域图片。 **# 五、总结

  • vue3-signature是 Vue3 项目实现电子签名的高效组件,基于 canvas 封装,无需原生 canvas 开发,API 简洁易上手;
  • 核心流程:安装 → 引入组件 → 配置基础属性 → 通过 ref 调用方法实现清空、保存、撤销等功能;
  • 实际开发中需注意移动端适配、签名非空校验、图片跨域等细节,满足不同场景的签名需求。

组件几乎覆盖了电子签名的所有基础场景,无论是简单的表单签名,还是复杂的合同签署,都能快速落地。赶紧在你的 Vue3 项目中试试吧!更多参考地址:www.npmjs.com/package/vue…**

2025 Vue转React避坑指南:从核心思维到工程实践的完整迁移手册

作者 QLuckyStar
2026年2月11日 09:01

从Vue3到React19的“被迫”成长之路

作为一名写了三年Vue3的“老前端”,上个月突然接到组长的通知:“咱们下个项目要用React,你带个头转过去。”说实话,我当时心里是抵触的——Vue的模板语法、响应式系统明明用得好好的,为什么要换?但当我真正动手写第一个React组件时,才发现这不是简单的“语法切换”,而是一场“思维革命”

记得那天晚上,我盯着React组件的useState钩子发呆:“为什么Vue的ref能自动更新,React却要手动setCount?”我试着用Vue的习惯写React代码——直接修改count的值,结果页面毫无反应,控制台还报了“状态未更新”的警告。那一刻,我才意识到:Vue的“响应式自动更新”是温柔的陷阱,而React的“手动触发+不可变数据”才是更底层的逻辑

接下来的日子里,我踩了不少坑:用0做条件渲染导致页面显示异常、忘记给列表加key导致控制台报警、用useEffect时没加依赖数组导致无限循环……但正是这些坑,让我真正理解了React的设计哲学——“一切皆函数,一切皆状态”。现在,我想把这些踩坑经验整理成一份“避坑指南”,帮同样从Vue转React的开发者少走弯路。

一、核心思维转变:从“模板指令”到“JSX+函数式”

Vue的核心是模板语法+指令系统v-ifv-forv-model),而React的核心是JSX+函数式组件+Hooks。转React的第一步,就是要放弃“模板思维”,拥抱“JSX逻辑”

1. 模板vs JSX:逻辑与结构的分离

Vue的模板是“HTML扩展”,逻辑(如条件、循环)通过指令实现;React的JSX是“JavaScript扩展”,逻辑通过表达式{})和函数mapfilter)实现。比如:

  • Vue的v-if="show"对应React的{show && <div/>}
  • Vue的v-for="item in list"对应React的{list.map(item => <div key={item.id}/>)}

刚开始写JSX时,我总觉得“不习惯”——为什么要把逻辑写在{}里?但后来发现,JSX的逻辑与结构分离,反而让代码更清晰。比如,我可以用map函数遍历列表,同时在{}里写条件判断,而不用像Vue那样把v-ifv-for混在一起。

2. 指令vs表达式:从“声明式”到“命令式”

Vue的v-bind:classv-on:click是指令,而React的属性绑定(className={active ? 'active' : ''})和事件处理(onClick={handleClick})是表达式。比如:

  • Vue的@click="increment"对应React的onClick={increment}
  • Vue的:class="{ active: isActive }"对应React的className={isActive ? 'active' : ''}

刚开始,我总忘记把v-on改成onClick,把v-bind改成{},但慢慢的,我发现表达式比指令更灵活——我可以动态地拼接类名,比如在React中写className={clsx('btn', { 'btn-active': isActive })}clsx是一个常用的类名合并工具),而Vue的v-bind:class只能写对象或数组。

二、状态管理:从“响应式自动更新”到“手动触发+不可变数据”

Vue的响应式系统refreactive)会自动追踪数据变化并更新视图,而React的状态管理useStateuseReducer)需要手动触发更新,且要求不可变数据(不能直接修改原状态)。这是Vue转React最容易踩坑的地方。

1. 状态更新方式:从“自动”到“手动”

Vue中,count.value++会自动更新视图;React中,setCount(count + 1)必须返回新状态,否则React无法检测到状态变化。比如:

  • Vue的user.name = 'Bob'会自动更新视图;
  • React的setUser({ ...user, name: 'Bob' })必须创建新对象,否则视图不会更新。

我记得有一次,我写了一个表单组件,直接用user.email = e.target.value修改状态,结果页面上的输入框没有更新。查了半天才知道,React的状态是“不可变的”,必须通过setState返回新状态。从那以后,我养成了“永远不修改原状态”的习惯。

2. Hooks对应:从“Vue的组合式API”到“React的Hooks”

Vue的ref()对应React的useState()computed()对应useMemo()watch()对应useEffect()。比如:

  • Vue的const count = ref(0)对应React的const [count, setCount] = useState(0)
  • Vue的const double = computed(() => count.value * 2)对应React的const double = useMemo(() => count * 2, [count])
  • Vue的watch(count, (newVal) => console.log(newVal))对应React的useEffect(() => console.log(count), [count])

刚开始,我总把useMemo当成computed用,但后来发现,**useMemo更适合缓存计算结果,而computed更适合依赖追踪**。比如,当count变化时,useMemo会重新计算double,而computed会自动追踪count的变化。

三、路由配置:从“Vue Router选项式”到“React Router v6函数式”

2025年,React路由的主流方案是React Router v6,与Vue Router的选项式配置routes数组)不同,React Router v6采用函数式+嵌套路由的方式,需要适应以下变化:

1. 路由定义:从“数组”到“函数”

Vue Router的routes数组对应React Router v6的createBrowserRouter函数。比如:

  • Vue的const routes = [{ path: '/', component: Home }]
  • React的const router = createBrowserRouter([{ path: '/', element: <Home /> }])

刚开始,我觉得createBrowserRouter比Vue的routes数组复杂,但后来发现,函数式的路由定义更灵活——我可以动态地添加路由,比如根据用户权限显示不同的路由。

2. 路由参数获取:从“$route”到“useParams”

Vue Router的this.$route.params.id对应React Router v6的**useParams Hook(客户端)或params参数**(服务器组件,如Next.js 15)。比如:

  • React Router v6客户端组件:const { id } = useParams()
  • Next.js 15服务器组件:export default async function Page({ params }) { const { id } = await params; }

我记得有一次,我写了一个用户详情页,用useParams获取id,结果页面报错——“params is undefined”。查了文档才知道,**useParams只能在客户端组件中使用**,如果是服务器组件,必须用params参数。

3. 编程式导航:从“$router.push”到“useNavigate”

Vue Router的this.$router.push('/profile')对应React Router v6的**useNavigate Hook**。比如:

  • Vue的this.$router.push('/profile')
  • React的const navigate = useNavigate(); navigate('/profile')

刚开始,我总忘记把$router.push改成navigate,但后来发现,**useNavigate$router.push更灵活**——我可以前进或后退,比如navigate(-1)(后退一页)。

四、常见错误避免:从“Vue习惯”到“React规范”

Vue转React时,容易犯以下典型错误,需特别注意:

1. 用0做条件渲染

React中,0有效值(会渲染到页面),而Vue中0会被当作“假值”。比如:

  • Vue中{items.length || <Empty/>}没问题,但React中{items.length || <Empty/>}会渲染0(如果items.length为0),正确做法是{items.length > 0 ? <List/> : <Empty/>}

我记得有一次,我写了一个商品列表,用{items.length || <Empty/>}显示空状态,结果页面上显示了0,用户以为列表里有0个商品。后来,我改成了{items.length > 0 ? <List/> : <Empty/>},才解决问题。

2. 突变状态

React要求不可变数据,直接修改原状态(如user.age = 20)不会触发视图更新,必须用setUser返回新状态(如setUser(prev => ({ ...prev, age: 20 })))。

3. 忘记key属性

React中,列表渲染(map)必须给每个元素加**唯一key**(如item.id),否则会出现“渲染异常”。key不能用index(会导致性能问题),必须从数据中获取唯一标识(如crypto.randomUUID())。

4. useEffect无限循环

useEffect的依赖数组([])必须包含所有用到的状态,否则会导致“无限循环”。比如:

  • 错误示例:useEffect(() => { getUser(userId).then(setUser); }, [])(用到了userId,但依赖数组为空);
  • 正确示例:useEffect(() => { getUser(userId).then(setUser); }, [userId])(将userId加入依赖数组)。

5. setState后立即访问状态

setState异步的,立即访问状态会得到“旧值”。比如:

  • const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); console.log(count); }(输出0,旧值);
  • 正确做法:用useEffect监听状态变化,比如useEffect(() => console.log(count), [count])(输出1,新值)。

五、工具与生态:从“Vue CLI”到“Vite+React生态”

2025年,React的开发工具链以Vite(构建工具)、React Router v6(路由)、状态管理方案(如Zustand、Redux Toolkit)为主,需适应以下变化:

1. 构建工具:从“Vue CLI”到“Vite”

Vue常用Vue CLI,而React推荐Vite(更快的热更新、更小的包体积)。创建React项目的命令是:npm create vite@latest my-react-app -- --template react-ts

2. 状态管理方案:从“Pinia”到“Zustand/Redux Toolkit”

  • 小型项目:用useState + useContext(React内置,无需额外依赖);
  • 中型项目:用Zustand(轻量级,API简洁,适合快速开发);
  • 大型项目:用Redux Toolkit(官方推荐,强大的调试工具,适合复杂状态逻辑)。

3. 样式工具:从“Tailwind CSS”到“Tailwind CSS+clsx”

React中常用的样式工具是Tailwind CSS(原子化CSS,快速构建UI)、class-variance-authority(管理组件变体)、clsx(条件性组合类名)。比如:

import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';

const Button = ({ variant, size, className, children }) => {
  return (
    <button
      className={twMerge(
        clsx(
          'inline-flex items-center justify-center rounded-md font-medium',
          {
            'bg-blue-600 text-white': variant === 'primary',
            'bg-gray-200 text-gray-800': variant === 'secondary',
            'h-9 px-3 text-sm': size === 'sm',
            'h-10 px-4 text-base': size === 'md',
          },
          className
        )}
      )}
    >
      {children}
    </button>
  );
};

六、实战技巧:从“Vue组件”到“React组件”的快速转换

以下是Vue组件转React组件的具体示例,覆盖模板、状态、事件等核心部分:

1. Vue组件(Composition API)

<template>
  <div class="card">
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
    <button @click="increment">点击次数:{{ count }}</button>
  </div>
</template>
<script setup>
import { ref } from 'vue';
const title = ref('Vue 组件');
const content = ref('这是 Vue 的内容');
const count = ref(0);
const increment = () => count.value++;
</script>
<style scoped>
.card { border: 1px solid #eee; padding: 20px; }
</style>

2. React组件(函数式+Hooks)

import { useState } from 'react';
import clsx from 'clsx';

const Card = () => {
  const [title] = useState('React 组件');
  const [content] = useState('这是 React 的内容');
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  return (
    <div className={clsx('card', 'border border-gray-200 p-5')}>
      <h2>{title}</h2>
     </p>
      <button onClick={increment}>点击次数:{count}</button>
    </div>
  );
};
export default Card;

关键变化

  • 模板→JSX(用{}绑定数据);
  • ref()useState()(状态管理);
  • @clickonClick(事件处理);
  • scoped样式→用clsxTailwind CSS(条件性样式)。

七、进阶建议:从“会用React”到“精通React”

1. 学习Hooks高级用法

比如useMemo(缓存计算结果)、useCallback(缓存函数引用)、useRef(获取DOM元素或跨渲染周期变量)。比如:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);

2. 掌握React Router v6高级特性

比如嵌套路由Outlet组件)、路由守卫loaderaction)、懒加载React.lazy+Suspense)。

3. 学习状态管理方案

比如Zustand(轻量级)、Redux Toolkit(企业级),掌握状态拆分(如将用户信息、主题设置拆分为不同store)。

4. 适应React生态

比如Next.js(全栈React框架,支持服务器组件、静态生成)、shadcn/ui(零依赖组件库)、react-hook-form(高性能表单处理)。

总结:Vue转React的核心逻辑

Vue转React的本质是从“模板指令”到“JSX逻辑”、从“响应式自动更新”到“手动触发+不可变数据”的思维转变。关键是要放弃Vue的习惯,拥抱React的函数式+Hooks范式,同时注意常见错误(如突变状态、useEffect无限循环)。

通过实战项目(如Todo List、博客系统)练习,可以快速掌握React的核心技能,适应React的生态。如果需要更详细的迁移指南,可以参考**vue-to-react工具(自动化转换Vue组件为React组件)或Veaury**(跨框架组件互操作),降低迁移成本。

最后,我想对同样从Vue转React的开发者说:不要害怕踩坑,因为每一个坑都是成长的机会。当你真正理解了React的设计哲学,你会发现,它比Vue更灵活、更强大。

JS Bin 在 2026 年宕机

2026年2月11日 08:47

原文:JS Bin down in 2026

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

1 月 27 日,我收到了邮件通知:JS Bin 挂了。第二天,真实的人类用户开始问「怎么回事」。一直到 30 日晚上 11 点左右,最后一批问题才被解决。

前些天 Jake 问我:到底哪里出了问题?

答案是:几乎所有环节都出了问题。

TL;DR

我知道这篇文章又长又碎。我写嗨了,也很享受把这段经历讲清楚。

简短版是:

  • 早点把基础设施升级到最新(但我当时做不到)。
  • 520 并不等于「服务器没响应」,它很可能是 Cloudflare 与源站之间的 TLS 协商/响应不兼容。
  • 服务器在着火时,别过度依赖 LLM:它能帮你补知识,但也可能把你带进更复杂的岔路。尽量先退一步,盘点现状,再做改变。

如果你愿意读长版,那就开始。

处于“维护模式”的这些年

过去大概 5 年(也可能更久),JS Bin 基本处于一种半自动「维护模式」:每隔 3~6 个月会有一阵小故障需要我出手。JS Bin 快 18 岁了,以 Web 的标准来说已经是「高龄应用」。

通常我需要处理的是站内的不良内容、来自 AWS(JS Bin 托管在那儿)的下架请求,或者偶发的内存异常——有的恢复要花更久。

看过去 11 年的可用性曲线,宕机并不罕见,但这次完全不同(左边那次大故障先不算)。

很多「长时间」宕机(我最近才更清楚)是因为机器内存耗尽,系统会像“塌方”一样:我甚至无法 SSH 登录去恢复,只能在 AWS 控制台里强制重启。

而这次更离谱:连「控制台重启」这种真正的「关机重开」都不灵。

重启也起不来

这次宕机,无论怎么重启都回不来。我触发重启后,一直在控制台等 SSH,但完全连不上。

机器的行为像是:重启 → 立刻锁死。

这暗示着:机器外面有持续的巨大压力,而且没有减弱。

我手上唯一的办法,是把机器彻底关机一小时左右,希望“敲门的东西”能先走开。

后来我看 CloudWatch,才知道入站流量把机器摁在地上摩擦:几天前还是正常水平,突然变成前所未有的网络入站峰值。图里 cliff edge 之后的那些下跌,是机器扛不住而失去响应。

先把“该死的进程”杀掉

我终于进到机器后,第一件事是看 syslog,找出导致崩溃的东西(更准确说:找出症状)。

很快我看到了 Node 因为 OOM(内存耗尽)而产生的 GC dump 与堆栈。

第一优先级:不要让系统“全盘崩溃”,先让最耗内存的进程被杀掉,至少保证我还能登录继续排查。

我加了这条配置:

# /etc/sysctl.conf,然后用 `sudo sysctl -p` 重新加载
vm.oom_kill_allocating_task=1

这样内存被打爆时,系统会优先杀掉正在疯狂分配内存的进程(Node),而不是随便杀掉别的关键进程(导致 SSH 也没了)。

这不会让机器“很快”,但能让我在流量继续轰炸时至少还能诊断(当然终端会非常慢)。

我能看到 CPU 长时间接近 100%,Node 的内存占用持续上涨(我用 htop)。

然后,ChatGPT 建议我升级 Node——奇怪的是,我并没有告诉它我在用什么版本。

支线任务:Node 真的很老

因为长期“维护模式”,我几乎从没动过 Node。结果 JS Bin 居然跑在 Node 7 上(甚至不是“稳定”的 Node 8),可能已经超过十年。

我问 ChatGPT 为什么知道,它说「你告诉过我」。这完全是胡扯。

我追问了很久才发现:当时我的终端屏幕上显示了 Node 版本;在我之后调 nginx 的过程中,ChatGPT 直接“看”到了屏幕内容。

我不知道它是否能读取滚动历史(希望不能),但这依然很不舒服。

我平常用的是浏览器版 ChatGPT,不怎么用“应用”形态;这次给我一个教训:只要屏幕上出现过敏感信息(比如我刚 cat.env),就要假设 LLM 可能看得到。

总之,我把 Node 从 7 升到了 22,居然没出事。幸运的是我在 2024 年做过一轮现代化改造,好让我能在本机跑起来(现代系统当然不愿意跑 Node 7)。

至少,事件循环性能变好了,CPU 压力会小一点。

……但问题依然没解决。

服务器太小,但我当时不想“加钱”

JS Bin 的主服务跑在 AWS t2.micro 上:单核 CPU、1GB 内存。我一直很惊讶它能在这么少资源上撑这么久。

当然,换更大的机器(加钱)可能有用。但当时我没有一键构建新机器的脚本——这台服务器已经多年“稳定运行”,我也没有随时可用的重建流程。

而且 JS Bin 虽然有 pro 版,但现金流并不充裕。简而言之:我需要“立刻能做”的事。

在 ChatGPT、Gemini 和 Claude 的帮助下(我用多个 LLM 来交叉验证建议,但说实话没有我希望的那么严谨),我尝试调优 nginx,比如:

  • worker 配置
  • proxy timeout
  • 文件描述符上限
  • keep-alive
  • 甚至移除 http2 来省内存

例如:

worker_connections 1024;
worker_processes auto;

keepalive_timeout 10;
keepalive_requests 100;

在每秒 1000+ 请求、还有更多请求在排队的情况下,这些改动几乎没有立竿见影的效果。

接着有人(也包括 LLM)提了一个问题:你考虑过 Cloudflare 吗?

把 Cloudflare 放到前面

说实话,把 Cloudflare 放到 JS Bin 前面出奇地顺利。它识别了大多数域名与指向,我只要把 DNS 的 nameserver 从 Route 53 换成 Cloudflare。

1 月 29 日接近午夜时,我开始能在浏览器里打开 jsbin.com 了。

但很快 GitHub 和邮件反馈告诉我:很多人依然在报错。

尤其是 Cloudflare 的 520——后来我才知道,它可能对应很多不同的问题。

请求仍然绕过 Cloudflare

即便能打开页面,我也能在服务器上看到有流量还在直冲源站。

我找到了 Cloudflare 的 IP 段列表,发现还有大量请求并不来自这些 IP 段。于是下一步是:丢弃非 Cloudflare 的流量。

这里开始,LLM 的建议让我引入了更多问题(直到第二天才彻底暴露)。当你忙到“救火模式”,很容易在复杂度上失手。

我最先用的方案是:在 nginx 里判断 Cloudflare 的请求头,没带头就丢弃。

if ($http_cf_ray = "") {
    return 444;
}

444 会直接断开连接,不返回任何内容。

我还按「Captain GPT」的指引配置了 set_real_ip_from 等(这在之后会坑到我):

# /etc/nginx/cloudflare.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

real_ip_header CF-Connecting-IP;
real_ip_recursive on;

并在 http {} 里 include:

http {
    include /etc/nginx/cloudflare.conf;
    ...
}

这会让 $remote_addr 变成真实用户 IP,而不是 Cloudflare 的边缘节点 IP。

但问题是:即便这样做,流量仍然很大;而且这种“在 nginx 层处理再丢弃”的方式,依然会消耗资源。

把油继续往火里浇

我还用 ss 查看连接:

ss -tan state established '( sport = :443 )'

我依然能看到一些非 Cloudflare 的请求成功建立连接(当时我正处于「改配置 → 失败 → 焦虑 → 问 LLM → 重来」的循环中)。

于是我又换了一种策略:不用请求头,改用来源 IP 是否属于 Cloudflare 的 IP 段来判断。

geo $is_cloudflare {
    default 0;

    173.245.48.0/20    1;
    103.21.244.0/22    1;
    # etc
}

然后在 server block 里:

server {
    listen 443 ssl http2 default_server;
    if ($is_cloudflare = 0) { return 444; }
    if ($cf_valid = 0) { return 444; }

    # rest of config unchanged
}

我当时没意识到的一点(花了 24 小时才想明白):

  • 之前那套配置会说「如果请求来自 Cloudflare,把 $remote_addr 改成真实用户 IP」。
  • 然后这套规则又说「如果 $remote_addr 不是 Cloudflare IP,就丢弃」。

结果:真正的用户(经由 Cloudflare)反而被我丢掉了。

现场变成了最糟糕的两件事同时发生:

  • 仍然有绕过 Cloudflare 的流量打进来,继续耗资源
  • 真实用户经由 Cloudflare 访问时,大多数却拿到 520

我终于意识到:应该在更靠外的地方(防火墙/安全组)直接丢弃流量。

直接丢弃流量(ufw + AWS 安全组)

我做了“双保险”:

  • 在服务器上用 ufw(我一直把它当成 iptables)按 IP 段 ALLOW/DROP
  • 在 AWS security group 同样只允许 Cloudflare IP 段访问 80/443

ufw 相对简单:

ufw allow from 173.245.48.0/20 to any port 443
ufw allow from 103.21.244.0/22 to any port 443
# etc
ufw deny 443

80 端口同理。

我在测试时还踩了个小坑:想临时放行自己 IP 时搞乱了规则;后来用 ufw status numbered + ufw delete N 解决。

AWS 安全组就麻烦多了:Web UI 不适合大批量改动,非常笨重。

我最后用 AWS CLI 写了脚本(但它不能批量修改,一条条执行还得等待返回再按回车,效率很低):

for CIDR in \
   103.21.244.0/22 \
   103.22.200.0/22 \
   103.31.4.0/22 \
   # etc
do
   aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 80  --cidr $CIDR --region us-east-1
   aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 443 --cidr $CIDR --region us-east-1
done

做到这一步,服务器终于开始“喘气”了。

但很多用户仍然被 Cloudflare 的 520 拦住(奇怪的是我自己还能访问……)。

520:不是 503/504,而是“不兼容”

我原本以为 520 类似 503(源站挂了)或 504(网关超时),但实际上更像是:

Cloudflare 发起请求,但源站的响应对 Cloudflare 来说“不兼容”。

我能确定一个线索:80 端口的纯 HTTP 没问题,只有 HTTPS 出问题。

有个很关键的观察来自 @robobuljan

curl jsbin.com             # (正常)
curl http://jsbin.com      # (正常)
curl https://jsbin.com     # 520

这一天里,LLM 基本帮不上忙(我也干脆把它们放一边,专心啃问题)。

我在 Cloudflare 的 SSL/TLS 页里看到「Traffic Served Over TLS」显示 TLS 版本分布(我没截图,这些数来自它们的 API):

  • TLSv1:36
  • TLSv1.1:56
  • TLSv1.2:1,922,523
  • TLSv1.3:5,216,795

我的 nginx 配置里反复出现这行:

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

没有 TLSv1.3。

我先尝试在 nginx 加上 TLSv1.3,但 nginx -t 直接失败:当前环境缺模块,而且要做大升级才能装上。

那就换思路:能不能在 Cloudflare 侧禁用 TLS 1.3?

答案是能,但入口很隐蔽:

  • 查看支持情况在「Speed / Settings」
  • 真正关闭在「SSL/TLS → Edge Certificates」,页面靠后的位置

禁用 TLS 1.3 后,大量真实流量恢复正常。

仍然有部分用户加载失败(static/null 域名)

接着又出现一个问题:部分用户的静态资源加载不了,或者用于运行代码的 iframe 域名(null.jsbin.com)异常。

我花了几个小时才理清:我曾让 nginx 在「请求来自 Cloudflare 时」使用 set_real_ip_from$remote_addr 改写为真实用户 IP,而后续又用 return 444 去丢弃“不符合条件”的请求。

不知为何,这套混乱的规则并没有影响主站首页,但却影响了 static.jsbin.comnull.jsbin.com

这就是熬夜 + 危机模式的典型后果:配置变成一团糟,自己也说不清到底哪些规则在什么时候生效。

当我最终移除这些临时拼凑的检查、IP 改写与一堆由 LLM 引入的“杂质”后,最后那部分流量也恢复了。

JS Bin 全面恢复。

事后复盘

现在 Cloudflare 顶在源站前面,这台 1GB 单核的小机器居然非常从容:CPU 常态只有 4%~5%。

我怀疑如果当时没那么依赖 LLM,我可能会更早意识到自己在不断增加复杂度。但另一方面:我也早该把 Cloudflare 放到 JS Bin 前面——不该等到危机时刻才做。

这次我学到的“坑”主要是:

  • TLS 版本不匹配会导致 520
  • 520 的语义远比我以为的复杂

从 CloudWatch 看,流量确实回落了;我相信 Cloudflare 已经替我挡掉了很多垃圾流量。看起来其中一大块来自香港——我给那边开了 Cloudflare 的 JS Challenge 才能继续访问:

拍截图后 24 小时内香港有 1000 万请求。

至于到底是什么导致这波流量把一切打崩,我恐怕永远无法确定。我直觉怀疑是 AI/LLM 的爬虫在“吸”整个互联网;但反证是:流量并不来自单一 IP。

讽刺的是,我倒是在自己的博客上看到过一个单 IP 爬虫(见这个链接):几个小时内 3GB 数据、32.5 万次请求。好在博客是跑在 Netlify 的纯静态站上,不像 JS Bin 还在跑 Node 7(是的,真的是 Node 7)。

uniApp WebView 动态配置加载状态监控与容错方案

作者 三十_
2026年2月11日 08:25

1. 需求背景

在移动端应用中,WebView 加载远程服务器地址(H5 应用)是常见场景。我们需要实现以下目标:

  1. 地址可配置:允许用户在加载失败或需要切换环境时,手动配置服务器地址(IP/端口)。

  2. 加载状态监控

    • 加载成功:自动隐藏配置页,展示 WebView。
    • 加载失败:自动识别失败状态(如 IP 错误、端口不通),跳转回配置页。
    • 加载超时:在弱网或服务器无响应时,强制跳转回配置页。

2. 方案演进与对比

在探索解决方案的过程中,我们尝试了多种方案。以下是行不通的方案及其原因,以及最终采用的最佳实践方案

2.1 ❌ 废弃方案:基于本地存储(Storage)的信号轮询

核心思路

试图利用 uni.setStorageSyncuni.getStorageSync 作为跨端通信的媒介。

  1. H5 端:加载成功后,执行 uni.setStorageSync('is_loaded', true)
  2. App 端:启动定时器,轮询 uni.getStorageSync('is_loaded')。若获取到 true 则判定成功,否则超时判定失败。

❌ 失败原因

Storage 沙箱隔离: UniApp 的 WebView 组件本质上是一个原生的浏览器容器。

  • H5 环境:执行 uni.setStorageSync 时,数据存储在 WebView 浏览器实例的 LocalStorage 中(域为 H5 页面域名)。
  • App 环境:执行 uni.getStorageSync 时,数据存储在 App 原生层(SQLite/SharedPreferences)。
  • 结论两者物理隔离,无法通过 Storage 共享数据。 App 端永远无法读取到 H5 端设置的标记。

2.2 ✅ 最终方案:基于 postMessage 的双向通信与状态仲裁

核心思路

利用 UniApp WebView 标准的 postMessage 消息机制进行通信,结合 App 端的超时倒计时进行状态仲裁。

  • 加载成功判定:H5 启动后主动向 App 发送 loaded 消息,App 收到消息即视为成功。
  • 加载失败判定:App 捕获 WebView 的 @error 事件。
  • 超时判定:App 设定倒计时(如 60s),若倒计时结束仍未收到 loaded 消息且未报错,视为超时。

适用场景

本方案完美覆盖以下三种加载成功场景:

  1. 常规启动:App 首次打开,加载缓存地址成功。
  2. 配置热更:用户修改地址并保存,WebView 销毁重建后加载成功。
  3. 延迟/弱网成功:网络较差导致加载时间较长(<60s),在超时前最终加载成功。

3. 详细实现指南

3.1 H5 端实现(信号发射)

在 H5 项目的入口文件(如 App.vueonLaunch)中,检测 UniApp 环境并发送成功信号。

export default {
  onLaunch() {
    // 模拟应用初始化耗时
    setTimeout(() => {
      // #ifdef H5
      // 检查 uniWebView SDK 是否存在
      if (typeof uniWebView !== 'undefined' && uniWebView.webView && uniWebView.webView.postMessage) {
        console.log('应用加载成功,发送 loaded 信号');
        uniWebView.webView.postMessage({
          data: {
            action: 'loaded'
          }
        });
      }
      // #endif
    }, 1000);
  }
}

3.2 App 端实现(状态仲裁)

在 App 的 WebView 承载页中,实现加载逻辑、超时控制和消息监听。

3.2.1 核心数据结构

data() {
  return {
    isShowConfig: true, // 是否显示配置页
    url: '',            // WebView 地址
    checkTimer: null,   // 超时定时器
    TIMEOUT_MS: 60000,  // 超时阈值:60秒
    // ...其他变量
  }
}

3.2.2 初始化与加载逻辑

methods: {
  // 初始化 WebView
  initWebview() {
    // 1. 获取 WebView 实例
    var currentWebview = this.$scope.$getAppWebview().children()[0];
    if (!currentWebview) {
      setTimeout(() => this.initWebview(), 300);
      return;
    }

    // 2. 启动超时倒计时(开始状态仲裁)
    this.startTimeoutCheck();

    // 3. 监听标准错误事件(处理域名解析失败、连接拒绝等)
    // 注意:部分机型或场景下 error 事件可能不触发,所以超时检测是必须的兜底
    const errorHandlers = ['error', 'loaderror', 'httperror'];
    errorHandlers.forEach(evt => {
      currentWebview.addEventListener(evt, (e) => {
        console.log(`WebView Error [${evt}]:`, e);
        this.handleLoadFail('加载遇到错误');
      });
    });
    
    // 监听特定 URL 跳转(如默认错误页)
    currentWebview.addEventListener('navigationstatechange', (e) => {
       if (e.detail.url && e.detail.url.includes('dcloud_error.html')) {
         this.handleLoadFail('检测到错误页面');
       }
    });
  },

  // 启动超时检测
  startTimeoutCheck() {
    // 清除旧定时器
    if (this.checkTimer) clearInterval(this.checkTimer);
    
    let elapsed = 0;
    this.checkTimer = setInterval(() => {
      elapsed += 1000;
      console.log(`正在等待加载... ${elapsed/1000}s`);
      
      // 场景:超时
      if (elapsed >= this.TIMEOUT_MS) {
        this.handleLoadFail('连接超时,请检查网络或地址');
      }
    }, 1000);
  },

  // 统一处理加载失败
  handleLoadFail(reason) {
    console.log(`判定失败: ${reason}`);
    // 1. 停止检测
    if (this.checkTimer) {
      clearInterval(this.checkTimer);
      this.checkTimer = null;
    }
    // 2. 强制显示配置页
    this.isShowConfig = true;
    // 3. 提示用户
    uni.showToast({
      title: reason,
      icon: 'none',
      duration: 2000
    });
  },

  // 处理来自 H5 的消息(成功信号)
  onMessage(e) {
    const data = e.detail.data && e.detail.data[0];
    
    // 场景:加载成功
    if (data && data.action === 'loaded') {
      console.log('收到 loaded 信号,加载成功!');
      // 1. 停止检测(重要:防止后续误报超时)
      if (this.checkTimer) {
        clearInterval(this.checkTimer);
        this.checkTimer = null;
      }
      // 2. 确保持续显示 WebView
      this.isShowConfig = false;
    }
    
    // 处理重置请求
    if (data && data.action === 'reset') {
      this.isShowConfig = true;
    }
  }
}

3.2.3 视图层绑定

<template>
  <view class="content">
    <!-- 配置页面 -->
    <view v-if="isShowConfig">
       <!-- 配置表单... -->
       <button @click="saveConfig">保存并重连</button>
    </view>

    <!-- WebView 页面 -->
    <block v-else>
      <!-- 绑定 @message 监听 -->
      <web-view :src="url" @message="onMessage"></web-view>
    </block>
  </view>
</template>

4. 总结

方案 通信方式 结果 核心原因
Storage 轮询 uni.setStorageSync ❌ 失败 App 与 WebView Storage 相互隔离,无法读取。
事件监听 @error / @load ⚠️ 不可靠 部分错误(如白屏、脚本死循环)不触发 Error 事件;加载成功事件触发过早(H5 业务未启动)。
postMessage + 超时 postMessage ✅ 成功 显式握手确认业务启动;超时机制兜底所有未知异常。

本方案利用 postMessage 实现了精准的业务级成功检测,并配合超时机制构建了完整的闭环容错系统。

CommonJS vs ES Module:现代JavaScript模块化对决

作者 wuhen_n
2026年2月11日 07:50

在当今的JavaScript生态中,CommonJS和ES Module是两个最重要的模块系统。理解它们的差异和适用场景,是每个前端开发者必须掌握的核心技能。

前言:为什么需要对比两大模块系统?

我们先来看看同一个功能,在两大模块中的不同写法:

// CommonJS版本
const { add } = require('./math.cjs');
console.log(add(2, 3));

// ES Module版本
import { add } from './math.mjs';
console.log(add(2, 3));

这两种写法看似相似,实则天差地别!在实际开发中,我们应该选择哪一种?为什么?何时使用?这些问题的答案决定了项目的架构质量。

核心差异全景对比

语法差异对比表

特性 CommonJS ES Module
导出语法 module.exports = value
exports.name = value
export const name = value
export default value
导入语法 const module = require(path) import module from 'path'
默认导出 module.exports = value export default value
命名导出 exports.name = value export const name = value
导入重命名 const { name: newName } = require() import { name as newName }
导入所有 const module = require() import * as module
条件导入 支持 不支持(需用动态导入)
动态导入 原生支持(运行时) import()(返回Promise)

导入/导出对比

导出方式对比

以以下代码为例:

const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
const PI = 3.14159;

CommonJS导出

方式1:单个导出
module.exports.add = add;
module.exports.multiply = multiply;
方式2:对象导出
module.exports = {
    add,
    multiply,
    PI
};

ES Module导出

默认导出(只能有一个)
export default function calculate() {
    return '计算器';
}
分别导出
exports {
    add,
    multiply,
    PI
};

导入方式对比

CommonJS导入

默认导入
const math = require('./math.cjs');
const { add, PI } = require('./math.cjs');
const addFunc = require('./math.cjs').add;
条件导入(运行时)
let mathModule;
if (process.env.NODE_ENV === 'production') {
    mathModule = require('./math-prod.cjs');
} else {
    mathModule = require('./math-dev.cjs');
}

ES Module导入

默认导入
import math from './math.mjs'; // 默认导入
import { add, PI } from './math.mjs'; // 命名导入
import { add as sum } from './math.mjs'; // 重命名导入
import * as mathUtils from './math.mjs'; // 命名空间导入
动态导入(返回Promise)
if (condition) {
    const math = await import('./math.mjs');
}

底层机制解析

加载时机对比

CommonJS:运行时加载

const cjsMath = require('./math.cjs'); // 同步加载,阻塞执行
console.log('CJS加载后执行');

ES Module:编译时静态解析

// import语句在代码执行前就被解析
import('./math.mjs').then(esmMath => {
    console.log('ESM加载后执行');
});

核心差异

  1. CommonJS会阻塞代码执行
  2. ES Module不会阻塞后续代码

值传递方式对比

CommonJS 值传递

// counter.cjs
let count = 0;
function increment() {
    count++;
    console.log('CJS内部count:', count);
}
module.exports = { count, increment };

// app-cjs.cjs
const counter = require('./counter.cjs');
console.log('初始count:', counter.count); // 0
counter.increment(); // CJS内部count: 1
console.log('外部count:', counter.count); // 0 !!! 值没有变
counter.count = 10; // 修改导出值
console.log('修改后外部count:', counter.count); // 10
counter.increment(); // CJS内部count: 2
console.log('再次检查外部count:', counter.count); // 10

ES Module 值传递

// counter.mjs
export let count = 0;
export function increment() {
    count++;
    console.log('ESM内部count:', count);
}

// app-esm.mjs
import { count, increment } from './counter.mjs';
console.log('初始count:', count); // 0
increment(); // ESM内部count: 1
console.log('外部count:', count); // 1 !!! 值同步更新
// count = 10; // 错误!不能直接修改导入的值

核心差异

  1. CommonJS:值拷贝,修改不影响原模块
  2. ES Module:实时绑定,修改同步更新
  3. CommonJS可以在导入侧修改导出值(但不会影响原模块)
  4. ES Module导入的值是只读的(在严格模式下)

循环依赖处理对比

循环依赖:模块A依赖模块B,模块B依赖模块A。

  1. CommonJS:遇到 require 时立即执行模块,可能拿到不完全的模块
  2. ES Module:先建立导入导出关系,再执行代码,访问未初始化变量得到 undefined
  3. 两种方式都能处理循环依赖,但行为不同

性能与优化对比

Tree Shaking能力对比

CommonJS(难以Tree Shaking)

// math-cjs.cjs
exports.add = (a, b) => a + b;           // ✓ 被使用
exports.multiply = (a, b) => a * b;      // ✗ 未使用
exports.divide = (a, b) => a / b;        // ✗ 未使用
exports.PI = 3.14159;                    // ✓ 被使用

// 使用方
const { add, PI } = require('./math-cjs.cjs');
console.log(add(2, 3), PI);

// 打包结果:通常全部包含,因为难以静态分析

ES Module(支持Tree Shaking)

// math-esm.mjs
export const add = (a, b) => a + b;           // ✓ 被使用
export const multiply = (a, b) => a * b;      // ✗ 未使用
export const divide = (a, b) => a / b;        // ✗ 未使用
export const PI = 3.14159;                    // ✓ 被使用

// 使用方
import { add, PI } from './math-esm.mjs';
console.log(add(2, 3), PI);

// 打包结果:只包含add和PI,multiply和divide被移除

关键差异:

  1. ES Module:静态结构,编译时可分析
  2. CommonJS:动态结构,运行时才能确定
  3. 现代工具可部分分析CommonJS,但效果有限

优化建议:

  1. 库开发优先使用ES Module
  2. 使用lodash-es而不是lodash
  3. 配置package.json的sideEffects字段

内存使用对比

CommonJS的内存行为

const memoryTestCJS = () => {
    const modules = [];
    
    // 多次加载同一模块
    for (let i = 0; i < 1000; i++) {
        // 清除缓存,强制重新加载
        delete require.cache[require.resolve('./memory-module.cjs')];
        const module = require('./memory-module.cjs');
        modules.push(module);
    }
    
    console.log('CJS模块实例数:', modules.length);
    // 每个require.cache都会创建一个新的模块实例
};

ES Module的内存行为

const memoryTestESM = async () => {
    const modules = [];
    
    // ES Module有模块映射缓存
    for (let i = 0; i < 1000; i++) {
        // 相同URL会返回缓存的模块
        const module = await import('./memory-module.mjs');
        modules.push(module);
    }
    
    console.log('ESM模块实例数:', modules.length);
    // 相同URL只加载一次,共享实例
};

关键差异:

  1. CommonJS:require.cache可管理,可清除
  2. ES Module:模块映射不可变,不可清除
  3. ES Module更节省内存,但灵活性较低

模块系统的未来

趋势1:ES Module成为标准

  • Node.js正在逐步转向ES Module优先
  • 浏览器原生支持不断完善

趋势2:导入映射(Import Maps)标准化

  • 控制模块解析,减少构建工具依赖

趋势3:模块联邦(Module Federation)

  • 微前端架构,跨应用共享模块
  • Webpack 5+ 原生支持

趋势4:WebAssembly模块集成

  • 与JavaScript模块无缝协作

趋势5:边缘计算优化

  • CDN级别的模块分发和缓存

趋势6:TypeScript与模块深度集成

  • 类型安全的模块导入导出

结语

CommonJS和ES Module代表了JavaScript模块化的两个时代。CommonJS以其实用性成为过去十年的主流,而ES Module以其标准化、静态分析和现代特性代表着未来。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript 模块化演进:从 IIFE 到 ES Module 的完整历程

作者 wuhen_n
2026年2月11日 06:42

从早期的脚本标签加载,到现代的 ES Module,JavaScript 模块化走过了一条漫长而精彩的道路。理解这段历史,不仅能帮我们写出更好的代码,还能深入理解现代构建工具的工作原理。

前言:为什么需要模块化?

在模块化出现之前,我们通常会这样写代码:

var globalData = '我是全局变量';

function utility1() {
    // 可能修改全局变量
    globalData = '被修改了';
}

function utility2() {
    // 依赖utility1
    utility1();
    console.log(globalData);
}

这种写法在小型项目中尚可,但在大型项目中会带来严重的问题:

  1. 全局命名空间污染
  2. 依赖关系不明确
  3. 难以维护和测试
  4. 无法按需加载

模块化的演进历程

原始时期:Script 标签与全局命名空间

<!-- 1995-2009:简单的脚本加载 -->
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="main.js"></script>

在这种脚本加载中,存在以下问题:

  1. 所有脚本共享全局作用域
  2. 脚本中的变量可能被其他脚本覆盖
  3. 依赖关系必须手动管理
  4. 如果utils.js依赖jquery.js,必须确保加载顺序

命名空间模式(Namespace Pattern)

// 2005年左右:使用对象作为命名空间
var MYAPP = MYAPP || {}; // 防止重复定义

MYAPP.utils = {
    trim: function(str) {
        return str.replace(/^\s+|\s+$/g, '');
    },
    formatDate: function(date) {
        // ...
    }
};

MYAPP.models = {
    User: function(name) {
        this.name = name;
    }
};

命名空间的优点

  1. 减少了全局变量数量
  2. 有一定的组织结构

命名空间的缺点

  1. 所有数据仍然是公开的
  2. 无法实现私有成员
  3. 依赖关系依然不明确

IIFE 模式(立即执行函数)

// 2008年左右:使用闭包实现模块化
var Module = (function() {
    // 私有变量
    var privateVar = '我是私有的';
    
    // 私有函数
    function privateMethod() {
        console.log(privateVar);
    }
    
    // 公有API
    return {
        publicMethod: function() {
            privateMethod();
            return '公共方法';
        },
        publicVar: '公共变量'
    };
})();

// 使用模块
Module.publicMethod(); // 可以访问
// Module.privateVar; // 报错:undefined
// Module.privateMethod(); // 报错:不是函数

IIFE 模式的优点

  1. 实现了真正的私有成员
  2. 避免了全局污染
  3. 代码更加安全

IIFE 模式的缺点

  1. 依赖管理仍需手动处理
  2. 无法实现按需加载
  3. 模块定义分散

IIFE 依赖注入

var Module = (function($, _) {
    // 使用依赖
    function init() {
        $('#app').hide();
        _.each([1, 2, 3], console.log);
    }
    
    return {
        init: init
    };
})(jQuery, _); // 依赖作为参数传入

IIFE 依赖注入的优点

  1. 依赖关系明确
  2. 可以替换依赖的实现

IIFE 依赖注入的缺点

  1. 依赖需要提前加载
  2. 依赖顺序必须正确

模块加载器的出现

随着 Web 应用越来越复杂,社区开始探索更先进的模块系统:

2009年:CommonJS(服务器端)

var fs = require('fs');
var _ = require('lodash');

exports.myFunction = function() {
    // ...
};

2011年:AMD(浏览器端,异步加载)

define(['jquery', 'lodash'], function($, _) {
    return {
        init: function() {
            // 使用$和_
        }
    };
});

2014年:UMD(通用模块定义:兼容CommonJS和AMD)

(function(root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = factory(require('jquery'));
    } else {
        // 浏览器全局变量
        root.myModule = factory(root.jQuery);
    }
}(this, function($) {
    // 模块代码
    return {
        // ...
    };
}));

2015年:ES Module(现代标准)

export const PI = 3.14159;
export function add(a, b) {
    return a + b;
}
export function multiply(a, b) {
    return a * b;
}

四大模块系统对比

特性 CommonJS AMD UMD ES Module
环境 服务器端(Node.js) 浏览器端 通用 现代浏览器/Node.js
加载方式 同步加载 异步加载 根据环境决定 静态/动态加载
语法 require() / exports define() / require() 条件判断多种语法 import / export
时机 运行时 运行时 运行时 编译时(静态)
Tree Shaking 不支持 不支持 不支持 支持
静态分析 困难 困难 困难 容易

Tree Shaking 深度解析

什么是 Tree Shaking?

Tree Shaking 是一种通过静态分析从代码中移除未使用代码(死代码)的技术。这个名字源于摇动树木,让枯叶(未使用的代码)掉落。

export function add(a, b) {
    return a + b;
}

export function multiply(a, b) {
    return a * b;
}

export function unusedFunction() {
    console.log('这个函数从未被使用');
    return '无用';
}

// app.js
import { add } from './math.js';

console.log(add(2, 3)); // 只用到了add

// 打包后,multiply和unusedFunction可以被移除

Tree Shaking 的实现条件

1. 使用 ES Module 语法

// ✅ 使用 ES Module 语法可以被Tree Shaking
export function used() { return '使用'; }
export function unused() { return '未使用'; }

// ❌ CommonJS 难以Tree Shaking
module.exports = {
    used: function() { return '使用'; },
    unused: function() { return '未使用'; }
};

2. 无副作用/纯函数(Pure Function)

// ✅ 纯函数,可以安全移除
export const PI = 3.14159;
export function square(x) { return x * x; }

// ⚠️ 有副作用,需要小心处理
export function logMessage(msg) {
    console.log(msg); // 副作用:控制台输出
    return msg;
}

3. 静态导入

// ✅ 静态导入可以被分析
import { add } from './math.js';

// ❌ 动态导入难以分析
const moduleName = './math.js';
import(moduleName).then(module => {
    // 运行时才知道使用什么
});

4. 模块级别的分析

// ✅ 整个模块可以被分析
export { add, multiply } from './math.js';

5. 使用工具标记

// package.json 中的 sideEffects 字段
{
    "name": "my-package",
    "sideEffects": false, // 整个包都无副作用
    "sideEffects": [      // 或指定有副作用的文件
        "*.css",
        "src/polyfills.js"
    ]
}

Tree Shaking 原理详解

// math-complex.js
export function add(a, b) {           // 被使用
    return a + b;
}

export function multiply(a, b) {      // 被使用
    return a * b;
}

export function divide(a, b) {        // 未被使用
    return a / b;
}

export const PI = 3.14159;            // 被使用

export const UNUSED_CONST = '未使用'; // 未被使用

// 副作用代码
export function init() {              // 有副作用,但未被调用
    console.log('初始化');
    window.MATH = { version: '1.0' };
}

// 默认导出(可能有副作用)
export default function() {
    console.log('默认导出');
}

// app.js
import { add, PI } from './math-complex.js';
import mathDefault from './math-complex.js';

console.log(add(2, 3), PI);
mathDefault();

以上述代码为例,Tree Shaking 步骤流程如下:

  1. 源代码分析

  2. 构建依赖图

    app.js
        ├── 导入: add (来自math-complex.js)
        ├── 导入: PI (来自math-complex.js)
        └── 导入: default (来自math-complex.js)
        
        math-complex.js
        ├── 导出: add ✓ (被使用)
        ├── 导出: multiply ✗ (未使用)
        ├── 导出: divide ✗ (未使用)
        ├── 导出: PI ✓ (被使用)
        ├── 导出: UNUSED_CONST ✗ (未使用)
        ├── 导出: init ✗ (未使用,但有副作用)
        └── 导出: default ✓ (被使用)
    
  3. 标记活跃代码:从入口开始,标记所有可达的代码

  4. 消除死代码:移除未被标记的代码

  5. 最终打包结果包含:

    • add 函数
    • PI 常量
    • 默认导出函数
    • 不包含:multiply, divide, UNUSED_CONST, init

注:默认导出即使内部有console.log,但因为被调用了,所以需要保留。 init函数有副作用但未被调用,理论上可以移除,但需要小心。

模块化的未来

模块联邦(Module Federation)

Webpack 5 引入的模块联邦,允许在多个独立构建的应用间共享模块:

// app1/webpack.config.js - 提供者(Host)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app1',
            filename: 'remoteEntry.js', // 远程入口文件
            exposes: {
                './Button': './src/components/Button.jsx',
                './utils': './src/utils/index.js'
            },
            shared: {
                react: { singleton: true },
                'react-dom': { singleton: true }
            }
        })
    ]
};

// app2/webpack.config.js - 消费者(Remote)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app2',
            remotes: {
                app1: 'app1@http://localhost:3001/remoteEntry.js'
            },
            shared: {
                react: { singleton: true },
                'react-dom': { singleton: true }
            }
        })
    ]
};

// app2/src/App.js - 使用远程模块
// 从app1动态导入模块
const RemoteButton = () => import('app1/Button');

导入映射(Import Maps)

<!-- 导入映射:控制模块的解析 -->
<script type="importmap">
{
    "imports": {
        "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js",
        "lodash": "/node_modules/lodash-es/lodash.js",
        "components/": "/src/components/"
    },
    "scopes": {
        "/src/": {
            "utils": "/src/utils/index.js"
        }
    }
}
</script>

<script type="module">
    // 现在可以这样导入
    import { createApp } from 'vue';
    import { debounce } from 'lodash';
    import Button from 'components/Button.js';
    
    // 不需要写完整路径
    import { formatDate } from 'utils';
</script>

结语

JavaScript 模块化的演进历程是一部精彩的技术发展史。从最初的全局变量污染,到现在的 ES Module 原生支持,我们见证了前端工程化从无到有、从简单到复杂的过程。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

🔥🔥🔥 React18 源码学习 - 合成事件原理

作者 yyyao
2026年2月11日 00:33

前言

本文的React代码版本为18.2.0

可调试的代码仓库为:GitHub - yyyao-hh/react-debug at master-pure

React应用开发中,我们每天都在使用onClickonChange等事件处理函数,但很少深入思考这些事件背后是如何工作的。React的事件系统并非简单的DOM事件封装,而是一套完整的合成事件(SyntheticEvent) 系统。本文将从源码层面深入剖析React18的事件系统,揭示其完整的实现链路。

插件机制

初始化插件

React的事件系统采用插件化架构,每个插件负责处理一类或几类事件。让我们看看插件是如何注册的:

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

import * as BeforeInputEventPlugin from './plugins/BeforeInputEventPlugin';
import * as ChangeEventPlugin      from './plugins/ChangeEventPlugin';
import * as EnterLeaveEventPlugin  from './plugins/EnterLeaveEventPlugin';
import * as SelectEventPlugin      from './plugins/SelectEventPlugin';
import * as SimpleEventPlugin      from './plugins/SimpleEventPlugin';

注册时机:引入React的时候就进行了注册

  • SimpleEventPlugin:处理常见的DOM事件,如clickmouseDown等。它将React事件名映射到对应的原生事件,并提供统一的合成事件对象。
  • EnterLeaveEventPlugin:处理鼠标进入和离开事件,例如onMouseEnteronMouseLeave。这些事件在React中有特殊的冒泡行为(不冒泡),因此需要单独处理。
  • ChangeEventPlugin:处理表单变化事件,如onChange。它需要处理多个原生事件(如inputchangefocusblur等)来模拟ReactonChange行为。
  • SelectEventPlugin:处理文本选择事件onSelect。它依赖于多个原生事件(如selectkeydownkeyup等)来确保跨浏览器的行为一致。
  • BeforeInputEventPlugin:处理onBeforeInput事件,用于在输入前捕获事件。它依赖于compositionstartcompositionendtextInput等原生事件。

插件详情

每个插件都有标准的接口格式,都会导出两个核心方法方法:

  • registerEvents:负责注册插件所处理的事件。具体来说,它会将插件中定义的事件类型(包括事件名、依赖的原生事件、冒泡和捕获阶段的注册名等)注册到全局的事件注册表中
  • extractEvents:负责将原生事件转换为React合成事件,并收集事件链路上的监听器,然后将事件和监听器添加到调度队列中

我们以SimpleEventPlugin为例:

/* src/react/packages/react-dom/src/events/DOMEventProperties.js */

export function registerSimpleEvents() {
  for (let i = 0; i < simpleEventPluginEvents.length; i++) {
    /*
     * eventName: 事件名 (touchMove)
     * domEventName: 全小写事件名 (touchmove)
     * capitalizedEvent: 大驼峰事件名 (TouchMove)
     */
    const eventName = ((simpleEventPluginEvents[i]: any): string);
    const domEventName = ((eventName.toLowerCase(): any): DOMEventName);
    const capitalizedEvent = eventName[0].toUpperCase() + eventName.slice(1);
    registerSimpleEvent(domEventName, 'on' + capitalizedEvent); // 注册
  }

  ...
}

/* src/react/packages/react-dom/src/events/plugins/SimpleEventPlugin.js */

function extractEvents(...) {
  const reactName = topLevelEventsToReactNames.get(domEventName);
  let SyntheticEventCtor = SyntheticEvent;
  const listeners = accumulateSinglePhaseListeners(...);
  if (listeners.length > 0) {
    const event = new SyntheticEventCtor(
      reactName,
      reactEventType,
      null,
      nativeEvent,
      nativeEventTarget,
    );
    dispatchQueue.push({event, listeners});
    console.log('dispatchQueue:', dispatchQueue);
  }
}

export {registerSimpleEvents as registerEvents, extractEvents};

注册事件名

我们先将初始化阶段就调用的获取全部事件名的registerEvents方法进行分析

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

import * as BeforeInputEventPlugin from './plugins/BeforeInputEventPlugin';
import * as ChangeEventPlugin      from './plugins/ChangeEventPlugin';
import * as EnterLeaveEventPlugin  from './plugins/EnterLeaveEventPlugin';
import * as SelectEventPlugin      from './plugins/SelectEventPlugin';
import * as SimpleEventPlugin      from './plugins/SimpleEventPlugin';

SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();

事件注册.png

如上图所示:每一个注册的插件最后都会走到registerDirectEvent函数中。也就是说该函数便是我们实现事件注册的核心逻辑。

export const allNativeEvents: Set<DOMEventName> = new Set();
export const registrationNameDependencies = {};

export function registerDirectEvent(
  registrationName: string,
  dependencies: Array<DOMEventName>,
) {
  registrationNameDependencies[registrationName] = dependencies;

  for (let i = 0; i < dependencies.length; i++) {
    allNativeEvents.add(dependencies[i]);
  }
}

经过处理后的数据结构如下,dependencies是数组,因为一个合成事件可能会对应多个原生事件

const registrationNameDependencies = {
  onAbort: ['abort'],
  onAbortCapture: ['abort'],
  onAnimationEnd: ['animationend'],
  onAnimationEndCapture: ['animationend'],
  onAnimationIteration: ['animationiteration'],
  onAnimationIterationCapture: ['animationiteration'],
  onAnimationStart: ['animationstart'],
  onAnimationStartCapture: ['animationstart'],
  onAuxClick: ['auxclick'],
  onAuxClickCapture: ['auxclick'],
  onBeforeInput: ['compositionend', 'keypress', 'textInput', 'paste'],
  onBeforeInputCapture: ['compositionend', 'keypress', 'textInput', 'paste'],
  onBlur: ['focusout'],
  onBlurCapture: ['focusout'],
  onCanPlay: ['canplay'],
  onCanPlayCapture: ['canplay'],
  onCanPlayThrough: ['canplaythrough'],
  onCanPlayThroughCapture: ['canplaythrough'],
  onCancel: ['cancel'],
  onCancelCapture: ['cancel'],
  onChange: ['change', 'click', 'focusin', 'focusout', 'input', 'keydown', 'keyup', 'selectionchange'],
  onChangeCapture: ['change', 'click', 'focusin', 'focusout', 'input', 'keydown', 'keyup', 'selectionchange'],
  onClick: ['click'],
  onClickCapture: ['click'],
  onClose: ['close'],
  onCloseCapture: ['close'],
  onCompositionEnd: ['compositionend', 'focusout', 'keydown', 'keypress', 'keyup', 'mousedown'],
  onCompositionEndCapture: ['compositionend', 'focusout', 'keydown', 'keypress', 'keyup', 'mousedown'],
  onCompositionStart: ['compositionstart', 'focusout', 'keydown', 'keypress', 'keyup', 'mousedown'],
  onCompositionStartCapture: ['compositionstart', 'focusout', 'keydown', 'keypress', 'keyup', 'mousedown'],
  onCompositionUpdate: ['compositionupdate', 'focusout', 'keydown', 'keypress', 'keyup', 'mousedown'],
  onCompositionUpdateCapture: ['compositionupdate', 'focusout', 'keydown', 'keypress', 'keyup', 'mousedown'],
  onContextMenu: ['contextmenu'],
  onContextMenuCapture: ['contextmenu'],
  onCopy: ['copy'],
  onCopyCapture: ['copy'],
  onCut: ['cut'],
  onCutCapture: ['cut'],
  onDoubleClick: ['dblclick'],
  onDoubleClickCapture: ['dblclick'],
  onDrag: ['drag'],
  onDragCapture: ['drag'],
  onDragEnd: ['dragend'],
  onDragEndCapture: ['dragend'],
  onDragEnter: ['dragenter'],
  onDragEnterCapture: ['dragenter'],
  onDragExit: ['dragexit'],
  onDragExitCapture: ['dragexit'],
  onDragLeave: ['dragleave'],
  onDragLeaveCapture: ['dragleave'],
  onDragOver: ['dragover'],
  onDragOverCapture: ['dragover'],
  onDragStart: ['dragstart'],
  onDragStartCapture: ['dragstart'],
  onDrop: ['drop'],
  onDropCapture: ['drop'],
  onDurationChange: ['durationchange'],
  onDurationChangeCapture: ['durationchange'],
  onEmptied: ['emptied'],
  onEmptiedCapture: ['emptied'],
  onEncrypted: ['encrypted'],
  onEncryptedCapture: ['encrypted'],
  onEnded: ['ended'],
  onEndedCapture: ['ended'],
  onError: ['error'],
  onErrorCapture: ['error'],
  onFocus: ['focusin'],
  onFocusCapture: ['focusin'],
  onGotPointerCapture: ['gotpointercapture'],
  onGotPointerCaptureCapture: ['gotpointercapture'],
  onInput: ['input'],
  onInputCapture: ['input'],
  onInvalid: ['invalid'],
  onInvalidCapture: ['invalid'],
  onKeyDown: ['keydown'],
  onKeyDownCapture: ['keydown'],
  onKeyPress: ['keypress'],
  onKeyPressCapture: ['keypress'],
  onKeyUp: ['keyup'],
  onKeyUpCapture: ['keyup'],
  onLoad: ['load'],
  onLoadCapture: ['load'],
  onLoadStart: ['loadstart'],
  onLoadStartCapture: ['loadstart'],
  onLoadedData: ['loadeddata'],
  onLoadedDataCapture: ['loadeddata'],
  onLoadedMetadata: ['loadedmetadata'],
  onLoadedMetadataCapture: ['loadedmetadata'],
  onLostPointerCapture: ['lostpointercapture'],
  onLostPointerCaptureCapture: ['lostpointercapture'],
  onMouseDown: ['mousedown'],
  onMouseDownCapture: ['mousedown'],
  onMouseEnter: ['mouseout', 'mouseover'],
  onMouseLeave: ['mouseout', 'mouseover'],
  onMouseMove: ['mousemove'],
  onMouseMoveCapture: ['mousemove'],
  onMouseOut: ['mouseout'],
  onMouseOutCapture: ['mouseout'],
  onMouseOver: ['mouseover'],
  onMouseOverCapture: ['mouseover'],
  onMouseUp: ['mouseup'],
  onMouseUpCapture: ['mouseup'],
  onPaste: ['paste'],
  onPasteCapture: ['paste'],
  onPause: ['pause'],
  onPauseCapture: ['pause'],
  onPlay: ['play'],
  onPlayCapture: ['play'],
  onPlaying: ['playing'],
  onPlayingCapture: ['playing'],
  onPointerCancel: ['pointercancel'],
  onPointerCancelCapture: ['pointercancel'],
  onPointerDown: ['pointerdown'],
  onPointerDownCapture: ['pointerdown'],
  onPointerEnter: ['pointerout', 'pointerover'],
  onPointerLeave: ['pointerout', 'pointerover'],
  onPointerMove: ['pointermove'],
  onPointerMoveCapture: ['pointermove'],
  onPointerOut: ['pointerout'],
  onPointerOutCapture: ['pointerout'],
  onPointerOver: ['pointerover'],
  onPointerOverCapture: ['pointerover'],
  onPointerUp: ['pointerup'],
  onPointerUpCapture: ['pointerup'],
  onProgress: ['progress'],
  onProgressCapture: ['progress'],
  onRateChange: ['ratechange'],
  onRateChangeCapture: ['ratechange'],
  onReset: ['reset'],
  onResetCapture: ['reset'],
  onResize: ['resize'],
  onResizeCapture: ['resize'],
  onScroll: ['scroll'],
  onScrollCapture: ['scroll'],
  onSeeked: ['seeked'],
  onSeekedCapture: ['seeked'],
  onSeeking: ['seeking'],
  onSeekingCapture: ['seeking'],
  onSelect: ['focusout', 'contextmenu', 'dragend', 'focusin', 'keydown', 'keyup', 'mousedown', 'mouseup', 'selectionchange'],
  onSelectCapture: ['focusout', 'contextmenu', 'dragend', 'focusin', 'keydown', 'keyup', 'mousedown', 'mouseup', 'selectionchange'],
  onStalled: ['stalled'],
  onStalledCapture: ['stalled'],
  onSubmit: ['submit'],
  onSubmitCapture: ['submit'],
  onSuspend: ['suspend'],
  onSuspendCapture: ['suspend'],
  onTimeUpdate: ['timeupdate'],
  onTimeUpdateCapture: ['timeupdate'],
  onToggle: ['toggle'],
  onToggleCapture: ['toggle'],
  onTouchCancel: ['touchcancel'],
  onTouchCancelCapture: ['touchcancel'],
  onTouchEnd: ['touchend'],
  onTouchEndCapture: ['touchend'],
  onTouchMove: ['touchmove'],
  onTouchMoveCapture: ['touchmove'],
  onTouchStart: ['touchstart'],
  onTouchStartCapture: ['touchstart'],
  onTransitionEnd: ['transitionend'],
  onTransitionEndCapture: ['transitionend'],
  onVolumeChange: ['volumechange'],
  onVolumeChangeCapture: ['volumechange'],
  onWaiting: ['waiting'],
  onWaitingCapture: ['waiting'],
  onWheel: ['wheel'],
  onWheelCapture: ['wheel']
};
const allNativeEvents = new Set([
  "abort",
  "auxclick",
  "cancel",
  "canplay",
  "canplaythrough",
  "click",
  "close",
  "contextmenu",
  "copy",
  "cut",
  "drag",
  "dragend",
  "dragenter",
  "dragexit",
  "dragleave",
  "dragover",
  "dragstart",
  "drop",
  "durationchange",
  "emptied",
  "encrypted",
  "ended",
  "error",
  "gotpointercapture",
  "input",
  "invalid",
  "keydown",
  "keypress",
  "keyup",
  "load",
  "loadeddata",
  "loadedmetadata",
  "loadstart",
  "lostpointercapture",
  "mousedown",
  "mousemove",
  "mouseout",
  "mouseover",
  "mouseup",
  "paste",
  "pause",
  "play",
  "playing",
  "pointercancel",
  "pointerdown",
  "pointermove",
  "pointerout",
  "pointerover",
  "pointerup",
  "progress",
  "ratechange",
  "reset",
  "resize",
  "seeked",
  "seeking",
  "stalled",
  "submit",
  "suspend",
  "timeupdate",
  "touchcancel",
  "touchend",
  "touchstart",
  "volumechange",
  "scroll",
  "toggle",
  "touchmove",
  "waiting",
  "wheel",
  "animationend",
  "animationiteration",
  "animationstart",
  "dblclick",
  "focusin",
  "focusout",
  "transitionend",
  "change",
  "selectionchange",
  "compositionend",
  "textInput",
  "compositionstart",
  "compositionupdate"
]);

事件注册

本节我们将了解React是如何注册事件以及绑定监听器的(可能介绍链路的时候会有点啰嗦,但是对于看源码的小伙伴很方便)

(二) 容器的挂载一文中提到过,我们在初始化挂载容器的createRoot函数中会调用listenToAllSupportedEvents函数去进行事件的代理

/* src/index.js */
const root = ReactDOM.createRoot(document.getElementById('root'));

/* src/react/packages/react-dom/client.js */
export function createRoot(...): RootType {
  return createRootImpl(...);
}

/* src/react/packages/react-dom/src/client/ReactDOM.js */
function createRoot(...): RootType {
  return createRootImpl(...);
}

/* src/react/packages/react-dom/src/client/ReactDOMRoot.js */
export function createRoot(...): RootType {
  // 1. 创建容器对象 FiberRoot
  const root = createContainer(...);

  // 2. 代理所有事件
  listenToAllSupportedEvents(rootContainerElement);

  // 3. 返回公开的根对象
  return new ReactDOMRoot(root);
}

我们可以看到,在listenToAllSupportedEvents函数中通过遍历allNativeEvents集合来对所有事件进行一个处理。

所以在此之前,我们要准备好所有需要监听的事件名。allNativeEvents包括了大量的原生事件名称, 而它是在DOMPluginEventSystem.js中被初始化,我们很快就会看到插件注册的过程

就是在插件机制中提到的事件名集合

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  if (!(rootContainerElement: any)[listeningMarker]) {
    // 用特殊标识确保只在根容器上初始化一次事件监听
    (rootContainerElement: any)[listeningMarker] = true;
    allNativeEvents.forEach(domEventName => {
      listenToNativeEvent(domEventName, false, rootContainerElement); // 冒泡阶段监听
      listenToNativeEvent(domEventName, true, rootContainerElement);  // 捕获阶段监听
    });
  }
}
/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

export function listenToNativeEvent(
  domEventName: DOMEventName,
  isCapturePhaseListener: boolean,
  target: EventTarget,
): void {
  let eventSystemFlags = 0;
  if (isCapturePhaseListener) {
    eventSystemFlags |= IS_CAPTURE_PHASE;
  }
  addTrappedEventListener(
    target,
    domEventName,
    eventSystemFlags,
    isCapturePhaseListener,
  );
}

调用了addEventCaptureListeneraddEventBubbleListener函数,参数层层传递,原来React是在root根容器上注册监听了原生事件的冒泡和捕获事件。

createEventListenerWrapperWithPriority为每一个事件创建了监听函数。

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

function addTrappedEventListener(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  isCapturePhaseListener: boolean,
  isDeferredListenerForLegacyFBSupport?: boolean,
) {
// 创建监听函数
  let listener = createEventListenerWrapperWithPriority(
    targetContainer,
    domEventName,
    eventSystemFlags,
  );
  let isPassiveListener = undefined;
  if (passiveBrowserEventsSupported) {
    if (
      domEventName === 'touchstart' ||
      domEventName === 'touchmove' ||
      domEventName === 'wheel'
    ) {
      isPassiveListener = true;
    }
  }

  targetContainer =
    enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport
      ? (targetContainer: any).ownerDocument
      : targetContainer;

  let unsubscribeListener;
  if (enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport) {
    const originalListener = listener;
    listener = function(...p) {
      removeEventListener(
        targetContainer,
        domEventName,
        unsubscribeListener,
        isCapturePhaseListener,
      );
      return originalListener.apply(this, p);
    };
  }
  if (isCapturePhaseListener) {
    if (isPassiveListener !== undefined) {
// 绑定捕获事件: passive = true
      unsubscribeListener = addEventCaptureListenerWithPassiveFlag(
        targetContainer,
        domEventName,
        listener,
        isPassiveListener,
      );
    } else {
// 绑定捕获事件
      unsubscribeListener = addEventCaptureListener(
        targetContainer,
        domEventName,
        listener,
      );
    }
  } else {
    if (isPassiveListener !== undefined) {
// 绑定冒泡事件: passive = true
      unsubscribeListener = addEventBubbleListenerWithPassiveFlag(
        targetContainer,
        domEventName,
        listener,
        isPassiveListener,
      );
    } else {
// 绑定冒泡事件
      unsubscribeListener = addEventBubbleListener(
        targetContainer,
        domEventName,
        listener,
      );
    }
  }
}

我们再看下比关键的监听函数的创建逻辑:

  1. 根据事件类型确定事件处理的优先级
  2. 为不同优先级的事件选择不同的分发函数
  3. 创建带有预绑定参数的事件监听器

虽然这里创建了dispatchDiscreteEventdispatchContinuousEventdispatchEvent三种事件监听器。但是实际的事件处理入口为dispatchEvent函数。

/* src/react/packages/react-dom/src/events/ReactDOMEventListener.js */

export function createEventListenerWrapperWithPriority(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
): Function {
  const eventPriority = getEventPriority(domEventName);
  let listenerWrapper;
  switch (eventPriority) {
    case DiscreteEventPriority:
      listenerWrapper = dispatchDiscreteEvent;
      break;
    case ContinuousEventPriority:
      listenerWrapper = dispatchContinuousEvent;
      break;
    case DefaultEventPriority:
    default:
      listenerWrapper = dispatchEvent;
      break;
  }
  return listenerWrapper.bind(
    null,
    domEventName,
    eventSystemFlags,
    targetContainer,
  );
}

事件触发

经过对注册阶段的深入理解,我们知道了React中所有事件的监听回调,都绑定到了dispachtEvent函数上。

这里经过一系列的调用,最后值得我们关注的就是dispatchEventsForPlugins函数

/* src/react/packages/react-dom/src/events/ReactDOMEventListener.js */

export function dispatchEvent(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
): void {
  dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay(
    domEventName,
    eventSystemFlags,
    targetContainer,
    nativeEvent,
  );
}

function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
) {
  dispatchEventForPluginEventSystem(
    domEventName,
    eventSystemFlags,
    nativeEvent,
    null,
    targetContainer,
  );
}

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

export function dispatchEventForPluginEventSystem(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
// 批量更新的逻辑
  batchedUpdates(() =>
    dispatchEventsForPlugins(
      domEventName,
      eventSystemFlags,
      nativeEvent,
      ancestorInst,
      targetContainer,
    ),
  );
}

/* src/react/packages/react-dom/src/events/ReactDOMUpdateBatching.js */

export function batchedUpdates(fn, a, b) {
  try {
    return batchedUpdatesImpl(fn, a, b);
  }
}

/* src/react/packages/react-reconciler/src/ReactFiberWorkLoop.old.js */

export function batchedUpdates<A, R>(fn: A => R, a: A): R {
  const prevExecutionContext = executionContext;
  executionContext |= BatchedContext;
  try {
    return fn(a);
  }
}

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

function dispatchEventsForPlugins(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
// 找到发生事件的元素 —— 事件源
  const nativeEventTarget = getEventTarget(nativeEvent);
  const dispatchQueue: DispatchQueue = []; // 待更新队列
// 找到待执行的事件
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  
// 执行事件
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

dispatchEventsForPlugins方法主要做了两个比较关键的事:

  1. extractEvents:提取合成事件的监听函数到dispatchQueue
  2. processDispatchQueue:模拟捕获阶段和冒泡阶段的执行流程,去执行所有的监听函数

事件收集

也就是extractEvents函数的执行

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
) {
  // 调用插件的 extractEvents 方法
  SimpleEventPlugin.extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  const shouldProcessPolyfillPlugins =
    (eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
  if (shouldProcessPolyfillPlugins) {
    EnterLeaveEventPlugin.extractEvents(...);
    ChangeEventPlugin.extractEvents(...);
    SelectEventPlugin.extractEvents(...);
    BeforeInputEventPlugin.extractEvents(...);
  }
}

首先根据不同的事件类型名字,去获取不同类别的事件对象event,也就是React在事件处理回调中传递给开发者的事件对象。

/* src/react/packages/react-dom/src/events/plugins/SimpleEventPlugin.js */

function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
): void {
  // 获取事件对象
  const reactName = topLevelEventsToReactNames.get(domEventName);
  let SyntheticEventCtor = SyntheticEvent;
  let reactEventType: string = domEventName;
  switch (domEventName) {
    case 'keypress':
      if (getEventCharCode(((nativeEvent: any): KeyboardEvent)) === 0) {
        return;
      }
    case 'keydown':
    case 'keyup':
      SyntheticEventCtor = SyntheticKeyboardEvent;
      break;
    case 'focusin':
      reactEventType = 'focus';
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'focusout':
      reactEventType = 'blur';
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'beforeblur':
    case 'afterblur':
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'click':
      if (nativeEvent.button === 2) {
        return;
      }
    case 'auxclick':
    case 'dblclick':
    case 'mousedown':
    case 'mousemove':
    case 'mouseup':
    case 'mouseout':
    case 'mouseover':
    case 'contextmenu':
      SyntheticEventCtor = SyntheticMouseEvent;
      break;
    case 'drag':
    case 'dragend':
    case 'dragenter':
    case 'dragexit':
    case 'dragleave':
    case 'dragover':
    case 'dragstart':
    case 'drop':
      SyntheticEventCtor = SyntheticDragEvent;
      break;
    case 'touchcancel':
    case 'touchend':
    case 'touchmove':
    case 'touchstart':
      SyntheticEventCtor = SyntheticTouchEvent;
      break;
    case ANIMATION_END:
    case ANIMATION_ITERATION:
    case ANIMATION_START:
      SyntheticEventCtor = SyntheticAnimationEvent;
      break;
    case TRANSITION_END:
      SyntheticEventCtor = SyntheticTransitionEvent;
      break;
    case 'scroll':
      SyntheticEventCtor = SyntheticUIEvent;
      break;
    case 'wheel':
      SyntheticEventCtor = SyntheticWheelEvent;
      break;
    case 'copy':
    case 'cut':
    case 'paste':
      SyntheticEventCtor = SyntheticClipboardEvent;
      break;
    case 'gotpointercapture':
    case 'lostpointercapture':
    case 'pointercancel':
    case 'pointerdown':
    case 'pointermove':
    case 'pointerout':
    case 'pointerover':
    case 'pointerup':
      SyntheticEventCtor = SyntheticPointerEvent;
      break;
    default:
      break;
  }

  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  if (
    enableCreateEventHandleAPI &&
    eventSystemFlags & IS_EVENT_HANDLE_NON_MANAGED_NODE
  ) {
    const listeners = accumulateEventHandleNonManagedNodeListeners(
      ((reactEventType: any): DOMEventName),
      targetContainer,
      inCapturePhase,
    );
    if (listeners.length > 0) {
      const event = new SyntheticEventCtor(
        reactName,
        reactEventType,
        null,
        nativeEvent,
        nativeEventTarget,
      );
      dispatchQueue.push({event, listeners});
    }
  } else {
    const accumulateTargetOnly =
      !inCapturePhase &&
      domEventName === 'scroll';

    const listeners = accumulateSinglePhaseListeners(
      targetInst,
      reactName,
      nativeEvent.type,
      inCapturePhase,
      accumulateTargetOnly,
      nativeEvent,
    );
    if (listeners.length > 0) {
      const event = new SyntheticEventCtor(
        reactName,
        reactEventType,
        null,
        nativeEvent,
        nativeEventTarget,
      );
      dispatchQueue.push({event, listeners});
    }
  }
}

accumulateSinglePhaseListeners函数主要做的事情就是:收集从目标组件到根组件路径上的所有事件监听器

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

export function accumulateSinglePhaseListeners(
  targetFiber: Fiber | null,
  reactName: string | null,
  nativeEventType: string,
  inCapturePhase: boolean,
  accumulateTargetOnly: boolean,
  nativeEvent: AnyNativeEvent,
): Array<DispatchListener> {
  const captureName = reactName !== null ? reactName + 'Capture' : null;
  const reactEventName = inCapturePhase ? captureName : reactName;
  let listeners: Array<DispatchListener> = [];

  let instance = targetFiber;
  let lastHostComponent = null;

  while (instance !== null) {
    const {stateNode, tag} = instance;
    if (tag === HostComponent && stateNode !== null) {
      lastHostComponent = stateNode;

      if (enableCreateEventHandleAPI) {
        const eventHandlerListeners = getEventHandlerListeners(
          lastHostComponent,
        );
        if (eventHandlerListeners !== null) {
          eventHandlerListeners.forEach(entry => {
            if (
              entry.type === nativeEventType &&
              entry.capture === inCapturePhase
            ) {
              listeners.push(
                createDispatchListener(
                  instance,
                  entry.callback,
                  (lastHostComponent: any),
                ),
              );
            }
          });
        }
      }

      if (reactEventName !== null) {
        const listener = getListener(instance, reactEventName);
        if (listener != null) {
          listeners.push(
            createDispatchListener(instance, listener, lastHostComponent),
          );
        }
      }
    } else if (
      enableCreateEventHandleAPI &&
      enableScopeAPI &&
      tag === ScopeComponent &&
      lastHostComponent !== null &&
      stateNode !== null
    ) {
      // Scopes
      const reactScopeInstance = stateNode;
      const eventHandlerListeners = getEventHandlerListeners(
        reactScopeInstance,
      );
      if (eventHandlerListeners !== null) {
        eventHandlerListeners.forEach(entry => {
          if (
            entry.type === nativeEventType &&
            entry.capture === inCapturePhase
          ) {
            listeners.push(
              createDispatchListener(
                instance,
                entry.callback,
                (lastHostComponent: any),
              ),
            );
          }
        });
      }
    }
    if (accumulateTargetOnly) {
      break;
    }
    if (enableCreateEventHandleAPI && nativeEvent.type === 'beforeblur') {
      const detachedInterceptFiber = nativeEvent._detachedInterceptFiber;
      if (
        detachedInterceptFiber !== null &&
        (detachedInterceptFiber === instance ||
          detachedInterceptFiber === instance.alternate)
      ) {
        listeners = [];
      }
    }
    instance = instance.return;
  }
  return listeners;
}

extractEvents函数的最后,会将事件对象和监听函数添加到事件队列dispatchQueue

/* src/react/packages/react-dom/src/events/plugins/SimpleEventPlugin.js */

function extractEvents() {
  
  ...
  
  dispatchQueue.push({event, listeners});
}

事件执行

也就是processDispatchQueue函数的执行

/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

export function processDispatchQueue(
  dispatchQueue: DispatchQueue,
  eventSystemFlags: EventSystemFlags,
): void {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  for (let i = 0; i < dispatchQueue.length; i++) {
    const {event, listeners} = dispatchQueue[i];
    // 执行事件监听器
    processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
  }
  rethrowCaughtError();
}

模拟捕获阶段和冒泡阶段的执行流程,去执行所有的监听处理函数。

  • 捕获阶段:从外到内,从最高层节点向下传播,执行监听处理函数,dispatchListeners倒序执行
  • 冒泡阶段:从内到外,从目标节点点向上传播,执行监听处理函数,dispatchListeners顺序执行
/* src/react/packages/react-dom/src/events/DOMPluginEventSystem.js */

function processDispatchQueueItemsInOrder(
  event: ReactSyntheticEvent,
  dispatchListeners: Array<DispatchListener>,
  inCapturePhase: boolean,
): void {
  let previousInstance;
  if (inCapturePhase) {
    /**
     * 执行捕获事件的监听函数: 从最高节点向下传播, 执行监听处理函数
     */
    for (let i = dispatchListeners.length - 1; i >= 0; i--) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) { // 阻止冒泡
        return;
      }
      executeDispatch(event, listener, currentTarget); // 执行事件监听函数
      previousInstance = instance;
    }
  } else {
    /**
     * 执行冒泡事件的监听函数: 从目标节点向上传播, 执行监听处理函数
     */
    for (let i = 0; i < dispatchListeners.length; i++) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) { // 阻止冒泡
        return;
      }
      executeDispatch(event, listener, currentTarget); // 执行事件监听函数
      previousInstance = instance;
    }
  }
}

总结

TODO:完整链路图

React18的合成事件系统是一个精心设计的架构,它通过插件机制、事件委托、对象池等技术,实现了高性能、跨浏览器兼容的事件处理。从事件注册到触发执行的完整链路体现了 React 团队对性能和开发体验的深度思考。

关键要点:

  1. 插件化架构:使得事件系统可扩展、易维护
  2. 事件委托:到根容器大幅减少了事件监听器数量
  3. 合成事件对象池:减少了垃圾回收压力
  4. 完整的冒泡/捕获模拟:保证了与原生事件的一致性
  5. 并发模式集成:为异步渲染提供了更好的支持

backdrop-filter 在 Chromium 下的闪烁问题:一次排查记录

作者 向明月
2026年2月10日 23:52

问题背景

我在开发一个 AI 对话组件 RobotPrinter 的过程中,为它做了一个毛玻璃(frosted glass)模式。核心实现很简单,就是 backdrop-filter:

.backdrop-blur {
  backdrop-filter: blur(20px) saturate(180%) brightness(1.02);
  -webkit-backdrop-filter: blur(20px) saturate(180%) brightness(1.02);
}

效果本身没什么问题,但在做录屏演示的时候发现了一个诡异的现象——录出来的视频里,整个组件会间歇性地消失几帧

这里要强调一点:这个闪烁用肉眼是完全看不出来的。 日常使用中你感觉不到任何异常,体验是流畅的。但只要一录屏,问题就暴露了。组件会在视频中突然消失一两帧,然后又回来。如果你想拿这个效果做产品演示、录制教程、或者在社交媒体上分享,画面就显得很不专业。

这个问题在流式输出(streaming)时尤其明显,因为内容区域每 50ms 更新一次文本,高度在持续变化。

Chrome 上的闪烁现象:

chrome.gif

Firefox 上的正常表现:

foxmail.gif

在线 Demo: ai-island.boat2moon.com/


排查过程

第一阶段:从 CSS 入手

第一直觉是自己的 CSS 写法有问题。我之前整理过一份 backdrop-filter 的踩坑文档,里面明确写过:外层容器不能用 transform,否则会创建新的层叠上下文,干扰 backdrop-filter 的采样。

检查代码,果然在 .robot-printer 上发现了一个 transform: translateZ(0)。删掉之后测试,还是闪。

接着又排查了几个方向:

  • 机器人头部有个 translateZ(20px) 的 3D 效果,在 glass mode 下禁用了——还是闪
  • 给 blur 层加了 transform: translateZ(0) 强制独立合成层——还是闪
  • 去掉了 border-radius——还是闪
  • 去掉了 box-shadow——还是闪
  • 去掉了 resultPanelFadeIn 动画里的 translateY——还是闪

到这一步,基本排除了 CSS 属性冲突的可能。

第二阶段:怀疑 React 渲染

流式输出时,App.tsx 每 50ms 调用一次 setResult,传给 RobotPrinterresultPanel prop 每次都是一个新的对象引用。这会导致依赖了 resultPaneluseEffect 在每次渲染时都重新执行,不断地 disconnect 和 reconnect ResizeObserver

把依赖从 resultPanel 对象换成 resultPanel?.visible 布尔值之后,effect 不再高频重跑了。但闪烁问题没有改善。

另外还发现 ResultPanel 组件里有个 useEffect 依赖了 content,每次内容变化都会调 getBoundingClientRect() 做一次强制同步布局。改成用 ResizeObserver 监听尺寸变化来触发位置更新,避免了 layout thrashing。但闪烁依然存在。

第三阶段:换浏览器

到这里,我已经尝试了十来种方案,全部失败。抱着试试看的心态,把页面拖到 Firefox 里打开。

不闪了。

又在 iPad 的 Safari 上试了一下。

也不闪。

回到 Chrome,闪。Edge 也闪。

浏览器 引擎 是否闪烁
Chrome Blink (Chromium)
Edge Blink (Chromium)
Firefox Gecko 不闪
Safari WebKit 不闪

对比效果请查看上面的录屏 GIF。

结论很清楚了:这是 Chromium 渲染引擎的问题。


进一步分析

为了搞清楚 Chromium 到底哪里出了问题,我又做了几组对照实验。

实验 1:完全禁用 backdrop-filter

注释掉所有 backdrop-filter 属性后,Glass Mode 下的闪烁从"整个组件消失"降级为"右下角一小块偶尔消失几帧"——和 Default Mode 下的表现一致了。

说明 backdrop-filter 本身不是根因,但它会极大地放大问题。没有 backdrop-filter 时只是小范围偶现,加上之后就变成整个组件频繁消失。

实验 2:降低更新频率

把流式输出间隔从 50ms 改到 200ms,闪烁频率有所降低但没有消除。

实验 3:CSS containment

加了 contain: layout paint style 试图隔离 ResultPanel 的渲染影响,无效。

综合来看,问题大概率出在 Chromium 合成器(compositor)的层管理上。当子元素内容高频更新导致高度变化时,合成器可能会在重新计算 backdrop-filter 采样区域的过程中,临时丢弃整个合成层,造成几帧的空白。Firefox 和 WebKit 的合成器在这种场景下处理得更稳定。


提交 Bug Report

既然确认是浏览器的问题,就没什么好纠结的了。我在 Chromium Issue Tracker 上提交了一份 Bug Report:

crbug.com/483220231

附带了:

  • 可在线访问的 Demo
  • 完整的开源仓库
  • Chrome 闪烁和 Firefox 正常的对比录屏

等 Chromium 团队什么时候修了吧。


几点体会

1. 肉眼看不到 ≠ 不存在

这个 Bug 最阴险的地方在于,日常使用时你完全感知不到问题。只有在录屏的时候才会暴露。如果你的项目涉及到产品演示、视频教程录制、或者任何需要屏幕录制的场景,这就会成为一个实实在在的问题。

2. 多浏览器测试不能省

如果我一开始就打开 Firefox 对比测试,大概能省掉几个小时的排查时间。虽然 Chrome 的市场份额最大,但"在 Chrome 上有问题"不等于"是你的代码有问题"。

3. backdrop-filter 仍然是个高风险属性

从浏览器兼容性的角度看,backdrop-filter 已经被广泛支持了。但从渲染稳定性的角度看,各家引擎的实现质量参差不齐。如果你的场景涉及到高频 DOM 更新 + 动态高度变化,在 Chromium 上使用 backdrop-filter 需要特别小心。

4. 一些可能有用的规避措施

如果你也遇到了类似问题,但又必须在 Chrome 上用 backdrop-filter,可以考虑:

  • 降低更新频率(200ms+ 可能会好一些)
  • 降低 blur 值(20px → 10px,GPU 压力小一些)
  • 用静态模糊背景图代替实时 backdrop-filter
  • 为不同浏览器提供差异化的视觉方案

相关链接

vue 甘特图 vxe-gantt 设置每个进度条分为计划和实际两条,实现上下分布任务条

作者 卤蛋fg6
2026年2月10日 23:47

vue 甘特图 vxe-gantt 设置每个进度条分为计划和实际两条,实现上下分布任务条,实现方式是利用子任务的子视图渲染模式,来间每条任务拆分成2条子任务,就可以利用自带的子视图渲染功能来渲染。

gantt.vxeui.com

由于放2行超出默认高度,所以还需要通过 cell-config.height设置一下行高,再通过树形表格的子任务来渲染

image

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { VxeGanttTaskType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttOptions = reactive({
  border: true,
  height: 500,
  loading: false,
  cellConfig: {
    height: 60
  },
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskConfig: {
    startField: 'start',
    endField: 'end',
    typeField: 'type'
  },
  taskBarSubviewConfig: {
    barStyle ({ row }) {
      if (row.flag === 1) {
        return {
          transform: 'translateY(-24px)',
          '--vxe-ui-gantt-view-task-bar-completed-background-color': '#409eff'
        }
      }
      if (row.flag === 2) {
        return {
          transform: 'translateY(1px)',
          '--vxe-ui-gantt-view-task-bar-completed-background-color': '#31d231'
        }
      }
    }
  },
  taskBarConfig: {
    showContent: true,
    barStyle: {
      round: true
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 480
    }
  },
  columns: [
    { field: 'title', title: '任务名称', minWidth: 100 },
    { field: 'planStartDate', title: '计划开始时间', width: 100 },
    { field: 'planEndDate', title: '计划结束时间', width: 100 },
    { field: 'actualStartDate', title: '实际开始时间', width: 100 },
    { field: 'actualEndDate', title: '实际结束时间', width: 100 }
  ],
  data: []
})

// 模拟后端接口
const loadList = () => {
  ganttOptions.loading = true
  setTimeout(() => {
    const list = [
      { id: 10001, parentId: null, title: 'A项目', planStartDate: '2024-03-03', planEndDate: '2024-03-15', actualStartDate: '2024-03-03', actualEndDate: '2024-03-12' },
      { id: 10002, parentId: null, title: 'B项目', planStartDate: '2024-03-10', planEndDate: '2024-03-25', actualStartDate: '2024-03-08', actualEndDate: '2024-03-16' },
      { id: 10003, parentId: null, title: 'C项目', planStartDate: '2024-03-20', planEndDate: '2024-04-10', actualStartDate: '2024-03-22', actualEndDate: '2024-04-01' },
      { id: 10004, parentId: null, title: 'D项目', planStartDate: '2024-03-28', planEndDate: '2024-04-19', actualStartDate: '2024-03-28', actualEndDate: '2024-04-12' },
      { id: 10005, parentId: null, title: 'E项目', planStartDate: '2024-04-05', planEndDate: '2024-04-28', actualStartDate: '2024-04-01', actualEndDate: '2024-04-24' }
    ]
    // 转成子任务视图
    const ganttData = []
    list.forEach(item => {
      const currRow = XEUtils.assign({}, item, { type: VxeGanttTaskType.Subview })
      const planRow = XEUtils.assign({}, item, {
        id: 10000000 + item.id,
        title: '计划',
        parentId: item.id,
        start: item.planStartDate,
        end: item.planEndDate,
        flag: 1
      })
      const actualRow = XEUtils.assign({}, item, {
        id: 20000000 + item.id,
        parentId: item.id,
        title: '实际',
        start: item.actualStartDate,
        end: item.actualEndDate,
        flag: 2
      })
      ganttData.push(currRow)
      ganttData.push(planRow)
      ganttData.push(actualRow)
    })
    ganttOptions.data = ganttData
    ganttOptions.loading = false
  }, 200)
}

loadList()
</script>

gitee.com/x-extends/v…

前端发布新版本主动检测更新提醒方案(纯前端方案版)

作者 Ticnix
2026年2月10日 20:22

前言

前端项目发布新版本后,若用户已打开页面或浏览器挂在后台,由于单页应用 (SPA) 的机制,页面不会主动刷新,导致用户一直停留在旧版本,无法使用新功能或遇到已修复的 Bug

那在开发过程中很容易遇到过这种问题,当我把前端写好的新版本的代码进行打包上传,这个动作已经进行了版本更新了,但是用户在访问旧版本网页时如果没有点击刷新,根本不知道已经上新了新版本,而且如果新版本已经取消了某个功能,但用户用旧版本进行提交必然会出错

那么就需要一个主动检测版本更新的功能提醒用户更新了

效果

image.png

实现过程

1. 构建阶段:在打包时生成一个包含当前时间戳的唯一版本标识文件 (version.json)。

2. 部署阶段:Nginx 配置该文件禁止缓存,确保前端永远获取到最新状态。

3. 运行阶段:前端代码通过轮询和页面可见性检测,对比本地与服务器的版本号。

实现逻辑如下

image.png

构建脚本改造 (生成版本号)

我们需要在每次 npm run build 时,在输出目录(通常是 dist 或 public)生成一个 version.json。

在项目根目录新建脚本文件 generate-version.js:

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

// 获取当前模块的路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 定义版本号 
const versionInfo = {
  version: Date.now(),
  releaseTime: new Date().toLocaleString()
};

// 确定输出路径 
const outputDir = path.join(__dirname, 'public');
// 先写入 public 目录,打包时会自动复制到 dist

const filePath = path.join(outputDir, 'version.json');

// 写入文件
fs.writeFileSync(filePath, JSON.stringify(versionInfo, null, 2));

console.log(`✅ [Version] 版本文件已生成: ${filePath}`);
console.log(`✅ [Version] 当前版本标识: ${versionInfo.version}`);

前端自动检测逻辑

在项目的主入口文件(如 src/App.vue, src/App.jsx, src/main.js)中引入以下逻辑。

/**
 * 前端版本自动检测模块
 */

// 存储本地版本号
let localHash:any = null;

// 检测间隔 (毫秒),建议 60秒 或 5分钟
const CHECK_INTERVAL = 5 * 1000;

async function checkAppVersion() {
  try {
    // 1. 请求 version.json
    // 必须加上时间戳参数 t=... 防止浏览器缓存这个 GET 请求本身
    const res = await fetch(`/version.json?t=${Date.now()}`);

    if (!res.ok) throw new Error('Version file not found');
    
    const data = await res.json();
    const serverHash = data.version;

    // 2. 首次加载,只记录不比对
    if (!localHash) {
      localHash = serverHash;
      console.log(`-----------------------[Updater] 当前版本已锁定-----------------------: ${localHash}`);
      return;
    }

    // 3. 比对版本
    if (serverHash !== localHash) {
      console.log(`-----------------[Updater] 检测到新版本------------------------: ${serverHash} (当前: ${localHash})`);
      
      showUpdateNotification();
    }

    console.log(`----------------------[Test] 执行检测-----------------------: ${localHash}`);
  } catch (err) {
    // 静默失败,不打扰用户 (可能是网络波动)
    console.warn('---------------------[Updater] 版本检测失败:--------------------------', err);
  }
}

function showUpdateNotification() {
  import('element-plus').then(({ ElMessageBox }) => {
    ElMessageBox.confirm(
      '系统检测到有新版本发布,请点击"确定"刷新页面以获取最新功能。',
      '版本更新提示',
      {
        confirmButtonText: '立即刷新',
        cancelButtonText: '稍后再说',
        type: 'warning',
        center: true,
        modal: false 
      }
    ).then(() => {
      window.location.reload(true); // true 表示强制从服务器重载
    }).catch(() => {
      // 用户点击取消,不做任何操作
    });
  });
}

// --- 启动检测监听 ---

// 1. 启动定时轮询
setInterval(checkAppVersion, CHECK_INTERVAL);

// 2. 智能监听:当用户切换标签页回来时,立即检查一次
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    checkAppVersion();
  }
});

// 3. 初始化检查
checkAppVersion();

修改 package.json:

image.png

Vue3 响应式数据常用方案及实践坑点

作者 b1ng
2026年2月10日 19:38

最近在做 Vue 项目相关的需求,复习一下 Vue 的响应式机制及其常用办法

从 Vue3 视角来看,它的响应式数据核心就是refreactive,都依赖于 ES6 的Proxy API,以此来代理监听整个对象,从而能关注到复杂数据类型内部属性的增删改的变化。值得一提的是,这里代理对象包含了多嵌套式对象的情况,也就是可以实现深度监听。

相较于 Vue2 的defineProperty()仅对于属性层面的监听,无疑在构建复杂数据类型的响应式时,性能提升是巨大的。

下面让我们聊聊无处不在的refreactive:

ref

ref通常用来包装基本数据类型,由于Proxy是对于复杂数据类型的 API,所以它的实质是在Proxy包装的基础上又在外封装了一层,所以我们需要用.value来读写数据,但在<template>模板中访问响应式数据无需.value因为此时已经做了解包的处理。

reactive

对于reactive相对的便是用来包装复杂数据类型,诸如ObjectArray这样的数据,他可以直接监听整个对象的属性操作(增删改)。但要注意的是,切勿直接操作这个对象,也就是说不要改变这个reactive数据的引用,这会使他丢失响应式。

二者怎么抉择呢,尤大大提倡使用ref,事实也正是如此,绝大多数场景,简单和复杂数据类型均使用ref构建响应式,虽然理论上全部加一层包装会有性能损耗,但对于团队代码可读性和可维护性,这点损耗微乎其微。下面举个例子:

// 情景:初始化一个 list,后续调接口拿到数据 res.data,需要赋值(先不考虑使用 TS)
// 使用 reactive
const list = reactive({})
Object.keys(res.data).forEach(key => {
    list.key = res.data[key]
})
// 使用 ref
const list = ref({})
list.value = res.data
// 或更严谨
list.value = {...list.value, ...res.data}

高下立判,无论从可读性还是维护性上讲ref也是完胜的。当然对于一些构造表单模板即对象属性增删不频繁的场景reactive不免为更优雅的选择...

以上是在学习阶段对于两个兄弟的基本认识。

响应式数据在组件间通信

说起这点,最常用的便是父子组件间props+emit的通信

父→子:通过props传给子组件,子组件可直接使用,值得一提的是这里的props虽然是响应式的但我们不能直接通过props.a来修改,这虽然可行但违背了 Vue 单向数据流的原则会报错,试想如果一个响应式数据想在哪里修改就在哪里修改,姑且不说可能导致的异常,就代码规范性而言就不过关

子→父:所以我们通过emit的方法来修改,通过$emit触发父组件传给子组件的事件类型,父组件监听并响应触发事件

这里以 Vue3 组合式 API 的写法为例:

// Parent.vue -->
<template>
  <Child :count="count" @update-count="handleUpdate" />
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
const handleUpdate = (newVal) => {
  count.value = newVal
}
</script>

// Child.vue -->
<template>
  <button @click="update">Count: {{ count }}</button>
</template>

<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update-count'])

const update = () => {
  emit('update-count', props.count + 1)
}
</script>

这里仅仅讲述最常用的通信,还有provide+injectpinia/vuex这里便不再赘述

常见坑点

我们在接收到porps的数据以后,如果父组件传的是一个ref,或者是reactive,或者是非响应式?我们子组件接受到该怎么使用,需要加.value?可以直接使用?还是需要传给中间值?怎么传?

这些问题可能在学习阶段无需思考,已经知道了怎么用就顺着来写,但我们需要考虑的是如果好久不用了,我们能否通过自己的技术深度来知道怎么使用是正确的,怎么使用不会丢失响应式?不会导致异常?

1、如果传值是ref/reactive/非响应,子组件如何用?

结论:无论父组件传的是 refreactive 还是普通对象,子组件通过 props 接收到的都是一个「普通响应式对象」,也就是Proxy,你永远不需要、也不应该在子组件中对props使用.value

原因:Vue 对 props 的统一处理机制

当你在父组件这样传递数据:

// 父组件
const a = ref({ name: 'Alice' })        // ref
const b = reactive({ name: 'Bob' })     // reactive
const c = { name: 'Charlie' }           // 普通对象

<Child :data-a="a" :data-b="b" :data-c="c" />

Vue 在传递给子组件前,会自动将所有值标准化为响应式对象(如果还不是的话),并注入到 props 中。

子组件接收到的 props 是一个由 Vue 内部创建的 响应式 Proxy 对象,结构如下:

// 子组件中的 props(概念上)
props = reactive({
  dataA: { name: 'Alice' },   // ← 已解包 ref,并转为响应式
  dataB: { name: 'Bob' },     // ← 原 reactive 对象(或其代理)
  dataC: { name: 'Charlie' }  // ← 普通对象被自动 reactive 包装
})

所以:props 中的每个属性都已经是“解包后”的响应式对象,无需 .value

2、该如何使用?

结论:始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量),并在需要修改但不影响源数据时创建本地副本。

原因

先看个错误的示例:

❌ 错误做法:解构或顶层赋值

setup(props) {
  const { name } = props.user;     // ❌ name 是普通字符串,失去响应式
  const age = props.user.age;      // ❌ age 是快照引用,不会随父更新

  // 后续使用 name/age 都是非响应式的!
}

有的兄弟可能要说了,我们有时候就只是需要其中的一个属性数据,也不用响应式,这样直接拿到不就好了?

但是请注意,如果父组件传的数据是异步获取的,当你直接结构或取值时可能拿到的是执行完异步操作前的数据,也就是说可能永远拿到的都是初始化时的空数据,因为就算异步操作完成,也会因丢失响应式而不会更新数据,造成问题!

🔔 ESLint 规则 vue/no-setup-props-destructure 就是为了防止这类错误。

所以始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量)

而当我们本地需要创建副本来维护这个数据,但不影响父组件时:

import { ref, watch } from 'vue'
import _ from 'lodash' // 或自定义 deepClone

setup(props) {
  // 创建深度独立副本(保持本地响应式)
  const localUser = ref(_.cloneDeep(props.user));

  // 可选:监听 prop 变化以重置本地状态(如父组件刷新数据)
  watch(() => props.user, (newUser) => {
    localUser.value = _.cloneClone(newUser);
  });

  const updateName = (name) => {
    localUser.value.name = name; // ✅ 修改本地副本,不影响父组件
  };

  return { localUser, updateName };
}

终极建议:

  • 模板中:直接写 {{ props.xxx.yyy }} ✅

  • setup 中

    • 只读 → 用 props.xxx 或 () => props.xxx(在 watch/computed 中)
    • 需修改 → 创建 ref(deepClone(props.xxx)) 作为本地状态
  • 绝不在 setup 顶层解构 props 或赋值给普通变量

  • 修改数据 → 通过 emit 通知父组件,或操作本地副本

实力不济,新人小白,持续更新...

❌
❌