普通视图

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

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

作者 SmalBox
2026年3月7日 19:29

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

概述

Fog节点是Unity URP Shader Graph中用于实现雾效功能的重要工具节点。在实时渲染中,雾效是一种常用的技术手段,它不仅能够增强场景的真实感和深度感,还能优化渲染性能。通过模拟大气中悬浮颗粒对光线的散射和吸收效果,雾效能够为三维场景增添自然的环境氛围,同时通过隐藏远处物体来减少渲染负担。

在URP渲染管线中,Fog节点提供了对Unity内置雾效系统的直接访问接口,使着色器开发者能够轻松地将场景雾效集成到自定义着色器中。该节点的设计遵循了URP的模块化理念,将复杂的雾效计算封装成简单易用的节点形式,大大降低了实现高质量雾效的技术门槛。

需要注意的是,Fog节点的具体实现和行为在不同的渲染管线中可能存在差异。由于Shader Graph本身并不定义该节点的具体函数实现,而是由各个渲染管线提供相应的HLSL代码,因此在使用时需要特别关注其兼容性问题。当前版本中,Fog节点主要针对通用渲染管线(URP)进行了优化和支持。

描述

核心功能

Fog节点的核心功能是提供对场景雾效参数的访问和控制能力。它基于Unity的全局雾效设置,允许着色器在片元级别计算和应用雾效。这意味着开发者可以在保持与场景雾效设置一致性的同时,实现更加精细和个性化的雾效表现。

该节点的工作原理是基于深度或高度计算雾效的强度,并将雾效颜色与原始表面颜色进行混合。在URP渲染管线中,Fog节点会读取场景的雾效配置,包括雾效模式(线性、指数、指数平方)、雾效颜色、密度参数等,然后根据顶点或片元的位置信息计算相应的雾效贡献。

渲染管线兼容性

Fog节点的一个重要特性是其对渲染管线的依赖性。由于不同渲染管线对雾效的实现方式存在差异,该节点在URP和HDRP中的支持情况有所不同:

  • 通用渲染管线(URP):完全支持Fog节点,提供了完整的雾效功能集成
  • 高清渲染管线(HDRP):当前版本不支持此节点,HDRP使用不同的体积雾效系统

这种差异主要源于两种渲染管线的设计理念和技术架构的不同。URP更注重轻量化和移动平台兼容性,因此采用了相对简单的雾效实现方式;而HDRP作为面向高端平台的高保真渲染管线,使用了基于物理的体积雾效和大气散射模型。

技术实现细节

在技术实现层面,Fog节点通过Shader Graph的宏定义系统与URP的雾效系统进行交互。当在Shader Graph中使用Fog节点时,实际上是在调用URP预定义的雾效计算函数。这些函数会根据项目的渲染设置和摄像机的参数,实时计算每个片元应应用的雾效强度。

节点的计算过程通常包括以下几个步骤:

  1. 将对象空间位置转换为世界空间或视图空间
  2. 根据雾效模式计算深度或高度值
  3. 基于雾效参数计算雾效因子
  4. 输出雾效颜色和密度值供后续混合使用

端口详解

Position输出端口

Position输出端口提供网格顶点或片元在对象空间中的位置信息。这个三维向量包含了物体局部坐标系中的位置数据,是计算雾效的基础输入。

  • 数据类型:Vector 3
  • 绑定类型:位置(对象空间)
  • 使用场景
    • 在顶点着色器阶段用于计算基于距离的雾效
    • 在片元着色器阶段用于精确的逐像素雾效计算
    • 用于实现高度雾效时的垂直位置参考

在实际应用中,Position端口的值通常需要经过矩阵变换才能用于雾效计算。常见的做法是将其乘以模型-视图矩阵,转换为视图空间或世界空间坐标,以便与摄像机的相对位置建立正确的关系。

Color输出端口

Color输出端口提供当前雾效配置中定义的颜色值。这个四维向量包含了RGBA颜色信息,其中Alpha通道通常用于控制雾效的透明度或混合强度。

  • 数据类型:Vector 4
  • 绑定类型:无(直接值)
  • 颜色来源
    • Unity渲染设置中配置的雾效颜色
    • 可能受时间、天气系统等动态因素影响
    • 在特定条件下可能包含梯度或分层颜色信息

Color端口的输出直接反映了场景的视觉氛围。在清晨场景中可能输出淡蓝色调,在黄昏时可能是橙红色调,在室内环境中可能是灰褐色调。开发者可以利用这个颜色值与表面颜色进行混合,创造出符合场景氛围的视觉效果。

Density输出端口

Density输出端口输出在给定位置处的雾效强度值。这个浮点数值代表了雾效的密度,范围通常为0到1,其中0表示无雾效,1表示完全被雾效覆盖。

  • 数据类型:Float
  • 绑定类型:无(计算值)
  • 计算依据
    • 顶点或片元相对于摄像机的距离
    • 在高度雾效模式下的垂直位置
    • 当前激活的雾效模式和参数设置

Density值的计算依赖于Unity的雾效系统配置。在线性雾效模式下,它基于最小和最大雾效距离进行线性插值;在指数雾效模式下,它遵循指数衰减规律;在指数平方模式下,衰减速度更快,适合模拟浓雾效果。

使用方法和示例

基础雾效应用

最基本的雾效应用是将Fog节点的输出与表面颜色进行混合。这种混合通常使用线性插值(Lerp)操作,根据雾效密度在原始颜色和雾效颜色之间进行过渡。

实现步骤:

  1. 将Fog节点添加到Shader Graph中
  2. 连接Position端口以提供位置信息
  3. 使用Lerp节点将表面颜色与Fog Color混合
  4. 使用Fog Density作为Lerp的混合系数

这种基础应用能够确保物体随着距离的增加逐渐融入背景雾效中,创造出自然的深度感和大气透视效果。

高级雾效控制

对于需要更精细控制的场景,可以结合其他节点对雾效进行定制化处理:

  • 距离控制:使用Distance节点计算精确的摄像机距离,实现自定义的雾效衰减曲线
  • 高度雾效:利用Position的Y分量实现基于高度的雾效分层
  • 噪声扰动:通过噪声纹理对Density值进行扰动,创造不均匀的雾效效果
  • 颜色渐变:使用Gradient节点替代固定的雾效颜色,实现随时间或距离变化的色彩过渡

性能优化技巧

在使用Fog节点时,合理的性能优化策略非常重要:

  • 在顶点着色器阶段计算雾效可以减少片元着色器的计算负担
  • 对于远处物体,可以适当降低雾效计算的精度
  • 利用LOD系统在不同距离使用不同复杂度的雾效计算
  • 在移动平台上考虑使用简化的雾效模型

与其他节点的配合使用

与Position节点的配合

Fog节点通常需要与各种Position节点配合使用,以获取正确的位置信息:

HLSL

// 对象空间位置直接使用
float3 objectPos = Position;

// 世界空间位置需要转换
float3 worldPos = TransformObjectToWorld(Position);

// 视图空间位置用于深度计算
float3 viewPos = TransformWorldToView(worldPos);

不同的空间坐标系会影响雾效的计算结果。对象空间位置适合物体自身的特效,世界空间位置适合场景级别的雾效,而视图空间位置则更适合基于深度的标准雾效。

与数学节点的组合

通过数学节点可以对雾效进行各种变形和增强:

  • 乘法节点:调整雾效密度强度
  • 幂节点:创建非线性的雾效衰减
  • 正弦节点:实现波动的雾效密度
  • 钳制节点:限制雾效的作用范围

这些数学运算可以帮助开发者创造出超越标准雾效系统的独特视觉效果。

与纹理节点的结合

将雾效与纹理节点结合可以实现更加丰富的视觉效果:

  • 使用噪声纹理打破雾效的均匀性
  • 利用遮罩纹理控制雾效的局部强度
  • 通过法线贴图影响雾效的流动方向
  • 结合深度纹理实现精确的雾效边缘

常见问题与解决方案

雾效不显示问题

当Fog节点没有产生预期效果时,可能的原因和解决方法包括:

  • 检查Unity的渲染设置中是否启用了雾效
  • 确认摄像机的远裁剪面设置是否合理
  • 验证Position端口是否正确连接
  • 检查雾效密度值是否在有效范围内

性能问题优化

如果使用Fog节点导致性能下降,可以考虑以下优化措施:

  • 在片元着色器中避免复杂的雾效计算
  • 使用简化的雾效模型代替精确计算
  • 对静态物体预计算雾效因子
  • 利用着色器变体为不同平台提供不同复杂度的实现

视觉一致性维护

确保自定义雾效与场景其他部分保持一致的方法:

  • 定期与场景默认雾效进行对比测试
  • 在不同光照条件下验证雾效表现
  • 使用参考物体校准雾效参数
  • 建立雾效配置的版本管理

生成的代码示例分析

函数定义解析

Fog节点生成的典型HLSL代码示例如下:

HLSL

void Unity_Fog_float(float3 Position, out float4 Color, out float Density)
{
    SHADERGRAPH_FOG(Position, Color, Density);
}

这个函数定义展示了节点的基本结构:

  • 输入参数:Position(对象空间位置)
  • 输出参数:Color(雾效颜色)、Density(雾效密度)
  • 核心计算:通过SHADERGRAPH_FOG宏实现

宏展开分析

SHADERGRAPH_FOG宏是URP渲染管线提供的雾效计算接口,其具体实现会根据项目的配置和平台特性进行优化。在标准情况下,这个宏会展开为类似以下的代码:

HLSL

#define SHADERGRAPH_FOG(position, color, density) \
    color = unity_FogColor; \
    float viewZ = -mul(UNITY_MATRIX_V, mul(unity_ObjectToWorld, float4(position, 1.0))).z; \
    density = ComputeFogFactor(viewZ);

这个展开代码展示了雾效计算的关键步骤:

  1. 从unity_FogColor获取雾效颜色
  2. 将对象空间位置转换为视图空间深度
  3. 通过ComputeFogFactor函数计算雾效密度

自定义实现扩展

开发者可以根据需要重写或扩展默认的雾效计算:

HLSL

void Custom_Fog_float(float3 Position, float CustomDensity, out float4 Color, out float Density)
{
    // 调用标准雾效计算
    SHADERGRAPH_FOG(Position, Color, Density);

    // 应用自定义密度调整
    Density = saturate(Density * CustomDensity);

    // 添加颜色调制
    Color.rgb = lerp(Color.rgb, _CustomFogTint, _TintStrength);
}

这种自定义实现允许在保持基础雾效功能的同时,添加项目特定的特效和调整。

最佳实践和建议

项目规划阶段

在项目开始阶段就应考虑雾效的使用策略:

  • 确定雾效的艺术风格和技术需求
  • 评估目标平台的性能限制
  • 规划雾效资源的制作管线
  • 建立雾效参数的标准化配置

开发实施阶段

在实际开发过程中应遵循的原则:

  • 保持雾效配置的一致性
  • 定期进行跨平台测试
  • 建立参数调整的工作流程
  • 文档化自定义雾效的实现

性能监控和维护

项目运行期间的雾效管理:

  • 监控雾效对帧率的影响
  • 优化雾效的计算复杂度
  • 定期更新以适应引擎版本变化
  • 收集用户反馈进行持续改进

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

介绍一个手势识别库——AlloyFinger

作者 codingWhat
2026年3月7日 18:30

移动端触摸手势库 AlloyFinger,配上 Vue 的 v-finger 指令,让「点、滑、捏、转」都能用声明式写法搞定,一起看看吧。


一、为什么需要 AlloyFinger?

在 H5 里,原生 touchstart / touchmove / touchend 只能告诉你「手指动了」,至于用户是单击、双击、长按、滑动、双指缩放还是旋转,都要自己算时间差、距离、角度——既难写又容易出 bug。

AlloyFinger 是腾讯 AlloyTeam 开源的轻量级手势库,把这些常见手势都封装好了,并且提供了 Vue 插件,以自定义指令 v-finger 的形式在模板里绑定,写法清晰、易维护。


二、安装依赖

在项目根目录执行:

npm install alloyfinger

三、在入口文件中注册插件

Vue 入口文件(如 src/main.js)中做两件事:

  1. 引入 AlloyFinger 本体和其 Vue 插件;
  2. 使用 Vue.use(AlloyFingerPlugin, { AlloyFinger }) 注册。

这样全局就可以在任意组件的模板里使用 v-finger 指令。

// 引入 alloy-finger
import AlloyFinger from 'alloyfinger'
import AlloyFingerPlugin from 'alloyfinger/vue/alloy_finger_vue'
Vue.use(AlloyFingerPlugin, {
  AlloyFinger
})

注意:

  • 插件路径是 alloyfinger/vue/alloy_finger_vue
  • 必须把 AlloyFinger 通过 Vue.use 的第二个参数传进去,插件内部会用它来创建手势实例。

四、在模板里使用 v-finger

注册完成后,在任意 Vue 组件的模板中,给需要绑定手势的单个根元素写上 v-finger:事件名="方法名" 即可。

4.1 语法形式

<div
  v-finger:tap="onTap"
  v-finger:swipe="onSwipe"
  v-finger:long-tap="onLongTap"
>
  可触摸区域
</div>
  • 指令名v-finger
  • 修饰符:冒号后面是事件类型,如 tapswipelong-tappinchrotate 等。
  • :当前 Vue 实例上的方法名,与普通 @click 一样写在 methods 里即可。

4.2 支持的事件

事件名 说明
tap 单击
double-tap 双击
single-tap 单击(与 double-tap 区分时用)
long-tap 长按
swipe 滑动手势(可结合 evt.direction)
pinch 双指缩放(evt.zoom)
rotate 双指旋转(evt.angle)
press-move 按住拖动(evt.deltaX / deltaY)
multipoint-start 多指开始
multipoint-end 多指结束
touch-start / touch-move / touch-end / touch-cancel 原生触摸事件封装

需要传参时,在方法里接收事件对象即可(如 swipe(evt) 中的 evt.directionpinch(evt) 中的 evt.zoom)。

4.3 完整示例

模板:

<template>
  <div
    class="touch-area"
    v-finger:tap="tap"
    v-finger:long-tap="longTap"
    v-finger:swipe="swipe"
    v-finger:pinch="pinch"
    v-finger:rotate="rotate"
    v-finger:double-tap="doubleTap"
    v-finger:single-tap="singleTap"
  >
    <div>点我、长按、滑动或双指操作</div>
  </div>
</template>

脚本:

export default {
  methods: {
    tap() {
      console.log('单击')
    },
    longTap() {
      console.log('长按')
    },
    swipe(evt) {
      console.log('滑动方向:', evt.direction)
    },
    pinch(evt) {
      console.log('缩放比例:', evt.zoom)
    },
    rotate(evt) {
      console.log('旋转角度:', evt.angle)
    },
    doubleTap() {
      console.log('双击')
    },
    singleTap() {
      console.log('单击(与双击区分)')
    }
  }
}

按需绑定自己用到的几个事件即可,不必全部写上。


五、用法很简单,那AlloyFinger是怎么实现的呢?

了解实现原理,有助于我们更放心地使用、排查问题,甚至做简单扩展。
AlloyFinger 的实现可以拆成两层:底层手势识别(alloy_finger.js)Vue 指令封装(alloy_finger_vue.js)

5.1 底层:基于原生 Touch 事件 + 向量运算

AlloyFinger 不依赖任何框架,核心就是给一个 DOM 元素绑定四个原生事件:

this.element.addEventListener("touchstart", this.start, false);
this.element.addEventListener("touchmove", this.move, false);
this.element.addEventListener("touchend", this.end, false);
this.element.addEventListener("touchcancel", this.cancel, false);

start 里:

  • 记录第一个触点的坐标 (x1, y1)和当前时间戳;
  • 用「上次 tap 的时间」和「两次点击的位移」判断是否构成双击(例如 250ms 内、位移 30px 以内);
  • 若检测到多指(evt.touches.length > 1),则计算两指构成的向量长度,作为后续 pinch 缩放的基准,并触发 multipointStart
  • 同时启动一个 750ms 的定时器,到时即触发 longTap

move 里:

  • 若是单指,则用当前点与上一帧点的差值得到 deltaXdeltaY,触发 pressMove
  • 若移动距离超过约 10px,会置位 _preventTap,避免误触 tap
  • 若是双指,则用两指构成的向量做向量长度比得到 evt.zoom(pinch),用向量夹角得到 evt.angle(rotate),这里用到简单的向量数学(点积、叉积、夹角),核心逻辑类似:
// 向量长度
function getLen(v) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}
// 缩放:当前两指距离 / 起始两指距离
evt.zoom = getLen(v) / this.pinchStartLen;
// 旋转:当前向量相对上一帧向量的角度
evt.angle = getRotateAngle(v, preV);

end 里:

  • 若「起点到终点的位移」超过约 30px,则根据 x、y 方向位移谁更大来判定 swipe 方向(Left/Right/Up/Down),并触发 swipe
  • 否则在下一个「事件循环」里触发 tap,并根据之前的双击标记决定是否再触发 doubleTap 或延迟 250ms 触发 singleTap
  • 同时会清除 longTap 定时器、重置双指相关的状态。

也就是说:tap / longTap / doubleTap / swipe / pinch / rotate / pressMove 等,都是在同一套 touch 生命周期里,用「时间差 + 位移 + 向量运算」推导出来的,没有黑魔法。

5.2 回调管理:HandlerAdmin

每种手势对应一个「回调列表」,用 HandlerAdmin 统一管理:add 注册、del 移除、dispatch 时对该元素上的所有回调依次 apply。这样同一个元素上可以挂多个监听(例如 Vue 插件里对同一元素绑定多个 v-finger:xxx),彼此也不会互相覆盖。

5.3 Vue 插件层:v-finger 如何挂到 DOM 上

插件在 install 时执行 Vue.directive('finger', directiveOpts),因此模板里的 v-finger 会变成对自定义指令 finger 的调用。

  • 事件名映射:模板里写的是 kebab-case(如 v-finger:long-tap),插件里用 EVENTMAP 转成 AlloyFinger 的 camelCase(如 longTap),再交给底层。
  • 一元素一实例:用一个全局 CACHE 数组,按 DOM 元素存 { elem, alloyFinger }。同一元素上多条 v-finger:tapv-finger:swipe 等,共用一个 AlloyFinger 实例;第一次绑定时 new AlloyFinger(elem, options),之后同元素再绑其他事件时,不再 new,而是 alloyFinger.on(eventName, func) 往该实例上追加回调。
  • 指令生命周期:Vue2 下 bind / update 时执行 doBindEvent(绑定或更新回调),unbind 时从 CACHE 里取出实例并调用 alloyFinger.destroy(),移除原生事件监听和所有定时器,避免内存泄漏。

核心片段:

// 同一元素多次 v-finger:xxx 共用一个 AlloyFinger 实例
var cacheObj = CACHE[getElemCacheIndex(elem)];
if (cacheObj && cacheObj.alloyFinger) {
  if (oldFunc) cacheObj.alloyFinger.off(eventName, oldFunc);
  if (func) cacheObj.alloyFinger.on(eventName, func);
} else {
  CACHE.push({
    elem: elem,
    alloyFinger: new AlloyFinger(elem, { [eventName]: func })
  });
}

5.4 小结

  • 手势识别:完全基于 touchstart / touchmove / touchend,用时间、位移和向量运算区分 tap、doubleTap、longTap、swipe、pinch、rotate、pressMove 等。
  • Vue 层:通过自定义指令 v-finger 和元素级 AlloyFinger 实例缓存,把「模板里的 v-finger:事件名」映射到「底层 AlloyFinger 的 on/off」,实现声明式绑定与组件销毁时的清理。

参考

深度拆解:基于面向对象思维的“就地编辑”组件全模块解析

作者 Lee川
2026年3月7日 18:30

深度拆解:基于面向对象思维的“就地编辑”组件全模块解析

在现代Web前端开发中,代码的可维护性与用户体验同样重要。本项目通过 EditInPlace 类,展示了一个完整的、基于原生JavaScript的“就地编辑”(Edit-in-Place)组件实现。该组件允许用户直接点击页面上的文本进行修改,而无需跳转页面或弹出繁琐的对话框。

为了全面理解这一精妙的设计,我们将摒弃泛泛而谈,深入代码肌理,按照功能模块edit_in_place.js 中的逻辑拆解为六大核心部分,结合 index.html 的挂载方式与 readme.md 的设计理念,进行全景式的技术剖析。


模块一:实例初始化与状态定义 (Constructor & State)

一切始于构造函数。这是组件的生命起点,负责接收外部配置并初始化内部状态。

1.1 核心代码逻辑

function EditInPlace(id, value, parentElement) {
  this.id = id;
  // 防御性编程:若未传入value,则赋予默认提示语
  this.value = value || '这个家伙很懒,什么都没有留下';
  this.parentElement = parentElement;
  
  // 预定义DOM元素引用,初始化为null
  this.containerElement = null;
  this.saveButton = null;
  this.cancelButton = null;
  this.fieldElement = null;
  this.staticElement = null;

  // 启动构建流程
  this.createElement(); 
  this.attachEvent();   
}

1.2 设计深度解析

  • 参数契约:构造函数严格依赖三个参数:id(唯一标识,用于后端更新)、value(当前显示内容)、parentElement(父容器,决定组件在DOM树中的位置)。这种设计使得组件完全独立于全局作用域。
  • 默认值处理this.value = value || '...' 体现了健壮的容错机制。即使调用者忘记传值,界面也不会崩坏,而是显示友好的占位符。
  • 状态预占位:提前声明所有可能用到的DOM节点变量(saveButton, fieldElement等)并置为 null。这不仅明确了组件所需的资源清单,也避免了后续操作中因变量未定义而导致的运行时错误。
  • 自动化引导:构造函数的最后两行自动触发 createElementattachEvent,意味着一旦实例化(new EditInPlace(...)),组件即刻完成渲染并具备交互能力,实现了“开箱即用”。

模块二:动态DOM架构构建 (DOM Construction)

本模块负责“无中生有”,通过原生JS API动态创建组件所需的所有HTML结构,而非硬编码在HTML文件中。

2.1 核心代码逻辑

createElement: function() {
  // 1. 创建外层容器
  this.containerElement = document.createElement('div');
  
  // 2. 创建静态文本展示区 (span)
  this.staticElement = document.createElement('span');
  this.staticElement.textContent = this.value;
  this.staticElement.style.cursor = 'pointer'; // 暗示可点击
  this.staticElement.title = '点击进行编辑';   // 提供Tooltip提示
  
  // 3. 创建编辑输入框 (input)
  this.fieldElement = document.createElement('input');
  this.fieldElement.type = 'text';
  
  // 4. 创建操作按钮组
  this.saveButton = document.createElement('button');
  this.saveButton.textContent = '保存';
  
  this.cancelButton = document.createElement('button');
  this.cancelButton.textContent = '取消';
  
  // 5. 组装DOM树
  this.containerElement.appendChild(this.staticElement);
  this.containerElement.appendChild(this.fieldElement);
  this.containerElement.appendChild(this.saveButton);
  this.containerElement.appendChild(this.cancelButton);
  
  // 6. 挂载到父容器
  this.parentElement.appendChild(this.containerElement);
  
  // 7. 初始化视图状态:默认为文本模式
  this.convertToText();
}

2.2 设计深度解析

  • 结构解耦:HTML文件 (index.html) 中只需要一个空的容器(如 <div id="app"></div>),具体的编辑结构完全由JS生成。这使得组件可以灵活地插入到页面的任何位置。
  • 语义化与辅助功能
    • 使用 span 包裹静态文本,符合行内元素的语义。
    • 设置 cursor: pointertitle 属性,从视觉和提示两个维度告知用户“此处可交互”,极大提升了可用性(UX)。
  • 组装顺序:先创建所有子元素,再统一 appendChild 到容器,最后一次性挂载到父节点。这种“文档片段”式的构建思路(虽然未显式使用DocumentFragment,但逻辑一致)减少了浏览器的重绘(Reflow)次数,优化了性能。
  • 初始状态锁定:构建完成后立即调用 convertToText(),确保组件加载时处于“只读”状态,隐藏输入框和按钮,符合用户预期。

模块三:视图状态切换引擎 (View State Switching)

这是组件交互的核心引擎,负责在“查看模式”和“编辑模式”之间无缝切换。

3.1 核心代码逻辑

// 切换到文本显示模式
convertToText: function() {
  this.fieldElement.style.display = 'none';
  this.saveButton.style.display = 'none';
  this.cancelButton.style.display = 'none';
  
  this.staticElement.style.display = 'inline';
  this.staticElement.textContent = this.value; // 同步最新数据
},

// 切换到编辑输入模式
convertToField: function() {
  this.staticElement.style.display = 'none';
  
  this.fieldElement.style.display = 'inline';
  this.fieldElement.value = this.value; // 将当前值回填到输入框
  
  this.saveButton.style.display = 'inline';
  this.cancelButton.style.display = 'inline';
  
  // 可选优化:自动聚焦输入框
  // this.fieldElement.focus(); 
}

3.2 设计深度解析

  • 互斥显示逻辑:通过控制 CSS display 属性(none vs inline),实现两组UI元素(文本组 vs 输入+按钮组)的互斥显示。这种方式比销毁重建DOM更高效。
  • 数据单向同步
    • convertToText 中:this.staticElement.textContent = this.value。确保界面上显示的文本永远是内存中 this.value 的最新状态(无论是初始值还是刚保存的值)。
    • convertToField 中:this.fieldElement.value = this.value。确保用户进入编辑模式时,输入框内预填充的是当前最新数据,而不是空白。
  • 状态原子性:这两个方法构成了状态机的两个原子操作,保证了视图状态的一致性,不会出现“既显示输入框又显示文本”的中间态。

模块四:事件监听与交互绑定 (Event Binding)

本模块将用户的鼠标/键盘行为转化为组件的内部逻辑调用,是连接用户与代码的桥梁。

4.1 核心代码逻辑

attachEvent: function() {
  const self = this; // 闭包保存this引用(或使用箭头函数)

  // 1. 点击文本 -> 进入编辑模式
  this.staticElement.addEventListener('click', function() {
    self.convertToField();
  });

  // 2. 点击保存 -> 执行保存逻辑
  this.saveButton.addEventListener('click', function() {
    self.save();
  });

  // 3. 点击取消 -> 执行取消逻辑
  this.cancelButton.addEventListener('click', function() {
    self.cancel();
  });
  
  // 4. (可选) 监听回车键 -> 快捷保存
  this.fieldElement.addEventListener('keydown', function(e) {
    if (e.key === 'Enter') {
      self.save();
    }
  });
}

4.2 设计深度解析

  • 上下文保持 (self = this):在旧式函数写法中,事件回调函数内的 this 指向会发生改变(指向触发事件的DOM元素)。通过 const self = this 闭包技巧,确保回调内部能正确访问组件实例的方法(如 self.save())。注:现代JS可使用箭头函数自动解决此问题。
  • 职责分离:事件监听器只做一件事——调用对应的业务逻辑方法(save, cancel, convertToField)。监听层不包含具体业务代码,保持了代码的清晰度。
  • 交互增强:除了点击事件,代码还预留了 keydown 监听(通常用于监听Enter键),允许用户通过键盘快捷操作,进一步提升专业度。

模块五:业务逻辑与数据持久化 (Business Logic & Persistence)

这是组件的“大脑”,处理数据的更新、验证以及与后端的通信。

5.1 核心代码逻辑

save: function() {
  // 1. 获取输入框的新值
  const newValue = this.fieldElement.value.trim();
  
  // 2. 简单校验:不允许为空
  if (!newValue) {
    alert('内容不能为空!');
    this.fieldElement.focus();
    return;
  }

  // 3. 更新本地状态
  this.value = newValue;
  
  // 4. 切换回文本视图
  this.convertToText();
  
  // 5. 异步持久化 (模拟API调用)
  // 在实际项目中,这里会使用 fetch 或 axios 发送请求
  /*
  fetch('/api/update', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ id: this.id, value: this.value })
  }).then(response => {
    if(!response.ok) throw new Error('保存失败');
    console.log('保存成功');
  }).catch(err => {
    console.error(err);
    alert('网络错误,保存失败');
    // 失败回滚逻辑...
  });
  */
  console.log(`ID: ${this.id}, New Value: ${this.value} (已模拟保存)`);
},

cancel: function() {
  // 直接丢弃修改,恢复视图,不更新 this.value
  this.convertToText();
}

5.2 设计深度解析

  • 数据校验save 方法首先进行 trim() 和非空检查。这是防止脏数据入库的第一道防线,体现了严谨的数据治理思维。
  • 乐观更新 vs 悲观更新
    • 当前代码采用了乐观更新策略:先更新本地 this.value 并切换视图,给用户“瞬间完成”的快感,然后在后台异步发送请求。
    • 注释中的 fetch 代码展示了如何处理悲观情况:如果网络请求失败,应有相应的错误提示甚至回滚机制(虽然示例中未完全展开回滚逻辑,但架构上预留了位置)。
  • 取消操作的纯粹性cancel 方法非常简单,它不修改 this.value,直接调用 convertToText。由于 convertToText 会将 this.value 重新渲染到界面上,因此未保存的修改自然消失,完美实现了“撤销”功能。

模块六:底层原理深潜与调试 (Deep Dive & Debugging)

在代码注释中,有一行关于类型检测的代码值得单独拿出来讲解,它揭示了JS底层对象模型的一个关键特性。

6.1 核心代码逻辑

// Object.prototype.toString.apply(this.containerElement)

6.2 技术原理解析

  • 问题背景:在JavaScript中,typeof 操作符对于对象类型的判断非常粗糙。typeof nulltypeof []typeof DOM元素 统统返回 "object"。这在需要精确区分数据类型(特别是区分不同宿主对象,如 HTMLDivElement)时显得无能为力。
  • 解决方案Object.prototype.toString 是JS中判断类型的“终极武器”。每个对象内部都有一个 [[Class]] 属性(ES6后映射为 Symbol.toStringTag)。
  • Apply 的作用
    • 直接调用 this.containerElement.toString() 可能会因为对象重写了 toString 方法而得到非标准结果。
    • 使用 Object.prototype.toString.apply(context) 强制借用原生对象的 toString 方法,并将 this 上下文绑定到目标对象(这里是 containerElement)。
  • 输出结果
    • div 元素执行此代码,返回 "[object HTMLDivElement]"
    • 对数组执行,返回 "[object Array]"
    • 对普通对象执行,返回 "[object Object]"
  • 应用场景:虽然在本项目的运行逻辑中这行代码被注释掉了,但它通常用于:
    1. 调试:确认创建的DOM元素类型是否符合预期。
    2. 库开发:在编写通用工具库时,用于编写健壮的类型判断函数(如 isArray, isElement)。
    3. 防御性编程:在执行特定DOM操作前,严格校验对象类型,防止报错。

总结:从代码到工程艺术

通过对 EditInPlace 组件的六大模块拆解,我们看到的不仅仅是一个简单的编辑功能,而是一套完整的前端工程化实践:

  1. 封装性:所有逻辑被包裹在类中,外部只需关心 new 和参数,内部实现细节(DOM创建、事件绑定)对外透明。
  2. 复用性:基于类的设计,使得该组件可以在页面的任何地方被无限次实例化,且实例间互不干扰。
  3. 用户体验优先:从默认的占位符提示、鼠标悬停样式、到无刷新保存,每一个细节都旨在减少用户的认知负荷。
  4. 扩展性:代码结构清晰,预留了API接口位置和键盘事件钩子,便于未来功能的迭代(如富文本支持、防抖优化等)。

这个项目完美诠释了 “一个文件一个类” 的理念:。它将复杂的交互逻辑收敛为一个独立的单元,是现代前端组件化开发的经典缩影。

一文理清页面/组件通信与 Store 全局状态管理

作者 远山枫谷
2026年3月7日 18:25

【小程序实战】告别繁琐传递!一文理清页面/组件通信与 Store 全局状态管理

📢 前言: 大家好,今天集中攻克了微信小程序开发中的两座大山:页面与组件的通信 以及 全局状态管理(Store)

在刚接触小程序时,我们通常习惯把所有逻辑都写在 Page 里;但随着项目变大,组件化是必经之路。而组件一旦多起来,数据怎么互相传递就成了头疼的问题。今天这篇笔记,就来总结一下我的学习成果,并分享几个避坑经验,希望能帮到正在学习小程序的你!


一、 页面与组件的“窃窃私语”:基础通信方式

在引入复杂的 Store 之前,我们必须先掌握原生的页面与组件通信方式。核心可以总结为三招:

1. 父传子:properties (属性绑定)

这是最基础的单向数据流。页面(父)通过属性将数据传递给组件(子)。

页面(父)端:

<!-- index.wxml -->
<my-component my-name="{{userName}}"></my-component>

组件(子)端:

// components/my-component/my-component.js
Component({
  properties: {
    myName: {
      type: String,
      value: '默认名字' // 默认值
    }
  }
})

2. 子传父:triggerEvent (事件绑定)

当组件内部发生了点击或数据改变,需要通知页面时,就需要用到自定义事件。

组件(子)端触发:

// 当点击按钮时触发
handleTap() {
  this.triggerEvent('myevent', { age: 18 }) // 传递对象给父级
}

页面(父)端接收:

<!-- index.wxml 绑定事件 -->
<my-component bind:myevent="handleChildEvent"></my-component>
// index.js 处理事件
handleChildEvent(e) {
  console.log('收到子组件的数据:', e.detail.age); // 输出 18
}

3. 父控子:selectComponent (获取组件实例)

有时候页面需要直接调用子组件里的方法,这时候可以通过给组件加 idclass,直接获取实例。

// 父页面的 js 中
const child = this.selectComponent('#my-child-id');
child.someMethod(); // 直接调用子组件的方法
// ⚠️ 经验:虽然好用,但不建议滥用,容易造成父子组件强耦合。

二、 告别“回调地狱”,拥抱 Store 全局状态管理

❓ 为什么需要 Store?

当遇到跨页面通信,或者兄弟组件通信(比如 A 组件的数据,C 组件也要用)时,如果用原生方法,你需要:A组件 -> 传给父页面 -> 传给B组件 -> ...。这种**“属性层层透传”**简直是噩梦!

这时候,Store(全局状态管理) 就闪亮登场了!在原生小程序中,我们通常使用 mobx-miniprogrammobx-miniprogram-bindings

1. 定义 Store (数据仓库)

首先创建一个 store.js,用来存放全局共享的数据和修改数据的方法。

import { observable, action } from 'mobx-miniprogram';

export const store = observable({
  // 1. 数据字段 (State)
  numA: 1,
  numB: 2,

  // 2. 计算属性 (Getters)
  get sum() {
    return this.numA + this.numB;
  },

  // 3. 修改数据的方法 (Actions)
  updateNumA: action(function (step) {
    this.numA += step;
  })
});

2. 在 Page 中使用 Store

在页面中使用,需要用到 createStoreBindings

import { createStoreBindings } from 'mobx-miniprogram-bindings';
import { store } from '../../store/store';

Page({
  onLoad() {
    // 绑定 Store
    this.storeBindings = createStoreBindings(this, {
      store,
      fields: ['numA', 'numB', 'sum'], // 需要的数据
      actions: ['updateNumA'] // 需要的方法
    })
  },
  
  onUnload() {
    // ⚠️ 重点:页面卸载时一定要解绑,防止内存泄漏!
    this.storeBindings.destroyStoreBindings();
  },

  btnHandler() {
    this.updateNumA(1); // 直接调用 store 中的 action
  }
})

3. 在 Component 中使用 Store

在组件中使用更加优雅,官方提供了一个 behavior

import { storeBindingsBehavior } from 'mobx-miniprogram-bindings';
import { store } from '../../store/store';

Component({
  behaviors: [storeBindingsBehavior], // 引入 behavior
  storeBindings: {
    store,
    fields: {
      numA: () => store.numA, // 映射数据
      sum: 'sum'
    },
    actions: {
      updateNumA: 'updateNumA'
    }
  }
})

三、 💡 学习心得与“避坑”经验分享

经过今天的折腾,我对这两种方式有了更深的体会,总结了以下几条经验:

  1. 别把什么都塞进 Store 里! Store 确实“真香”,但千万别把什么数据都往里面丢。

    • 适合放 Store 的: 用户登录信息(Token、头像)、购物车数据、全局主题配置等(跨页面高度共享的数据)。
    • 适合放页面/组件内部(data)的: 表单的输入内容、弹窗的显示隐藏状态(isModalShow)、局部的 Loading 状态。保持局部状态的纯粹,代码才好维护。
  2. 时刻警惕内存泄漏 在 Page 中使用 createStoreBindings 时,必须、一定、千万要onUnload 生命周期里调用 destroyStoreBindings() 进行清理。如果你发现从小程序某个页面返回后,页面变卡或者数据出现诡异的重叠,大概率是忘记解绑了。

  3. 组件通信尽量保持“单向数据流” 即使有了 selectComponent,我们在开发组件时也应尽量遵循:父组件通过 properties 传值,子组件通过 triggerEvent 汇报。把子组件当成一个“黑盒”,这样写出来的组件复用性最高,不会因为换了个父页面就报错。


如果这篇文章对你有帮助,点个赞支持一下吧!你的鼓励是我持续分享的动力!


MCP 从入门到实战完整教程(Windows 版)

作者 烛阴
2026年3月7日 18:02

MCP(Model Context Protocol,模型上下文协议)是 Anthropic 推出的开放标准协议,为 AI 应用提供了统一的方式来连接外部数据源和工具。你可以把 MCP 理解为 AI 世界的"USB-C 接口"——一个协议,即可让 AI 模型访问文件系统、数据库、搜索引擎等各类外部资源。本教程将带你在 Windows 系统上从概念到实战,全面掌握 MCP。


一、MCP 核心概念

架构总览

MCP 采用客户端-服务端架构,包含三个核心角色:

  • Host(宿主):发起连接的 AI 应用,例如 Claude Desktop、Claude CLI、Cursor 等
  • Client(客户端):Host 内部的 MCP 客户端,负责与 Server 建立一对一连接
  • Server(服务端):轻量级程序,通过 MCP 协议向 Client 暴露特定能力
┌─────────────────────────────────────────┐
│  Host(Claude Desktop / CLI)            │
│                                         │
│  ┌──────────┐  ┌──────────┐            │
│  │ Client A │  │ Client B │  ...       │
│  └────┬─────┘  └────┬─────┘            │
└───────┼──────────────┼──────────────────┘
        │              │
   ┌────▼─────┐  ┌────▼─────┐
   │ Server A │  │ Server B │
   │(filesystem)│ │(search)  │
   └──────────┘  └──────────┘

通信方式

MCP 支持两种传输方式:

传输方式 说明 适用场景
stdio 通过标准输入/输出通信 本地 Server,最常用
SSE 通过 HTTP Server-Sent Events 通信 远程 Server,需网络访问

三大原语

MCP Server 可以向 Host 暴露三种能力:

  • Tools(工具):模型可以调用的函数,例如"搜索网页"、"读取文件"、"执行 SQL"
  • Resources(资源):模型可以读取的数据,类似 REST API 的 GET 端点
  • Prompts(提示模板):预定义的交互模板,帮助用户快速完成特定任务

其中 Tools 是目前最常用的原语,大多数 MCP Server 都以 Tool 的形式提供能力。


02-content-pain-point.png

二、环境准备

基础环境

确保你的 Windows 系统已安装以下工具:

  • Node.js 18+npm:用于运行基于 Node.js 的 MCP Server
  • Python 3.10+uv(可选):用于运行基于 Python 的 MCP Server
  • Claude DesktopClaude CLI:作为 MCP 的 Host

安装 Node.js

如果尚未安装,前往 nodejs.org 下载最新 LTS 版本。验证安装:

node --version
npm --version

安装 Python 和 uv(可选)

部分 MCP Server 使用 Python 编写,需要通过 uvx 运行:

# 安装 uv(Python 包管理工具)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

安装完成后重新打开 PowerShell 验证:

uv --version
uvx --version

安装 Claude CLI

如果尚未安装 Claude CLI:

npm install -g @anthropic-ai/claude-code

04-content-setup-steps.png

三、在 Claude CLI 中配置 MCP

Claude CLI 提供了命令行和配置文件两种方式来管理 MCP Server。

方式一:使用 claude mcp add 命令

# 添加 filesystem server
claude mcp add filesystem -s user -- npx -y @modelcontextprotocol/server-filesystem C:\Users\你的用户名\Documents

参数说明:

参数 说明
filesystem Server 名称(自定义,用于标识)
-s user 作用域:user(全局)或 project(当前项目)
-- 分隔符,之后的内容为 Server 启动命令

常用管理命令

# 查看已配置的 MCP Server
claude mcp list

# 查看某个 Server 的详细信息
claude mcp get filesystem

# 移除某个 Server
claude mcp remove filesystem

方式二:手动编辑配置文件

Claude CLI 的 MCP 配置存储在 settings.json 中:

  • 全局配置C:\Users\你的用户名\.claude\settings.json
  • 项目配置项目根目录\.claude\settings.json

手动添加 MCP Server 示例:

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "C:\\Users\\你的用户名\\Documents"
      ]
    }
  }
}

在 CLI 中验证

启动 Claude CLI 后,使用 /mcp 命令查看当前连接的 MCP Server 状态:

/mcp

输出中可以看到每个 Server 的名称、状态和提供的工具数量。


05-content-server-list.png


四、实战演示

场景一:用 Filesystem MCP 管理项目文件

配置好 Filesystem Server 后,你可以直接让 Claude 操作项目文件:

> 读取 C:\Users\我\projects\myapp\package.json,列出所有依赖的版本

> 在 C:\Users\我\Documents\notes 目录下创建一个 todo.md,内容是本周的工作计划

> 找出 src 目录下所有包含 "TODO" 注释的文件

场景二:用 Brave Search MCP 联网搜索

配置好 Brave Search Server 后,Claude 具备了实时联网能力:

> 搜索 2026 年最新的 React 状态管理方案对比

> 搜索 Windows 11 最新的 PowerShell 更新内容

场景三:用 GitHub MCP 管理仓库

配置好 GitHub Server 后,可以直接通过对话管理仓库:

> 列出我的 GitHub 仓库中所有 open 状态的 issue

> 为 myapp 仓库创建一个新的 issue,标题是"优化首页加载速度"

> 查看 myapp 仓库最近的 5 个 pull request

场景四:多个 MCP Server 协同工作

MCP 的强大之处在于多个 Server 可以协同工作:

> 搜索最新的 Tailwind CSS v4 变更内容,然后帮我更新项目中的 tailwind.config.ts 文件

这条指令中,Claude 会先调用 Brave Search 搜索信息,再调用 Filesystem 读取并修改文件。


五、开发自定义 MCP Server(入门)

如果现有的 MCP Server 不能满足需求,你可以用 TypeScript SDK 快速开发自己的 Server。

初始化项目

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init

编写 Server 代码

创建 src/index.ts

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

// 创建 MCP Server 实例
const server = new McpServer({
    name: 'my-weather-server',
    version: '1.0.0',
});

// 注册一个 Tool:查询天气
server.tool(
    'get-weather',
    '获取指定城市的天气信息',
    {
        city: z.string().trim().min(1, '城市名称不能为空').describe('城市名称,例如:北京'),
    },
    async ({ city }) => {
        // 使用心知天气 API 获取实时天气
        const SENIVERSE_API_KEY = 'YOUR_API_KEY_HERE';

        const weatherResp = await fetch(
            `https://api.seniverse.com/v3/weather/now.json?key=${encodeURIComponent(SENIVERSE_API_KEY)}&location=${encodeURIComponent(city)}&language=zh-Hans&unit=c`,
        );
        if (!weatherResp.ok) {
            throw new Error(`心知天气查询失败: HTTP ${weatherResp.status}`);
        }

        const weatherJson = (await weatherResp.json()) as {
            results?: Array<{
                location?: { name?: string };
                now?: { text?: string; temperature?: string; humidity?: string };
            }>;
        };

        const result = weatherJson.results?.[0];
        const now = result?.now;
        if (!result || !now) {
            throw new Error(`未找到城市或天气数据: ${city}`);
        }

        const weatherData = {
            city: result.location?.name ?? city,
            temperature: now.temperature !== undefined ? `${now.temperature}°C` : 'N/A',
            condition: now.text ?? 'N/A',
            humidity: now.humidity !== undefined ? `${now.humidity}%` : 'N/A',
        };

        return {
            content: [
                {
                    type: 'text' as const,
                    text: JSON.stringify(weatherData, null, 2),
                },
            ],
        };
    },
);

// 启动 Server(使用 stdio 传输)
async function main(): Promise<void> {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error('Weather MCP Server is running');
}

main().catch(console.error);

编译与测试

修改 tsconfig.json,确保以下配置:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

编译项目:

npx tsc

接入 Claude CLI

claude mcp add my-weather -s user -- node C:\Users\你的用户名\my-mcp-server\dist\index.js

重启 Claude Desktop 或 Claude CLI 后,就可以使用了:

> 查询北京的天气

Claude 会调用你的 get-weather 工具并返回结果。


六、常见问题与排错

Q: Server 启动报错 npx 不是内部或外部命令

Node.js 未正确安装或 PATH 未配置。在 PowerShell 中验证:

where.exe npx
# 应输出 npx 的完整路径

如果无输出,重新安装 Node.js 并确保勾选"Add to PATH"选项。

Q: Server 启动报错 uvx 不是内部或外部命令

需要安装 uv 工具。参考"环境准备"章节中的安装步骤。

Q: 配置了 env 中的 API Key 但仍然报认证失败?

  1. 检查 Key 是否正确,有无多余空格
  2. 确保 Key 没有过期
  3. 修改配置后必须重启 Claude CLI

Q: Claude CLI 中 /mcp 显示 Server 状态为 disconnected?

尝试以下步骤:

# 查看 Server 详细信息
claude mcp get <server-name>

# 移除并重新添加
claude mcp remove <server-name>
claude mcp add <server-name> -s user -- <command> <args>

七、总结与资源链接

MCP 为 AI 应用提供了标准化的外部集成方式,让 Claude 从一个"只能对话"的模型变成了能够操作文件、搜索网络、管理代码仓库的全能助手。通过本教程,你已经掌握了:

  • MCP 的核心架构和概念
  • 在 Claude Desktop 和 Claude CLI 中配置 MCP Server 的方法
  • 常用 MCP Server 的配置与使用
  • 开发自定义 MCP Server 的基础流程

推荐资源:

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Cluade相关技巧

手撸一个「能打」的 React Table 组件

作者 codingWhat
2026年3月7日 17:43

业务里的表格从来不是「行 + 列」那么简单:要分页(前端分页 / 服务端分页)、多选、分组合并、固定列、列宽拖拽、斑马纹、空状态……现成的 Table 要么过重,要么缺能力。所以这篇文章想和大家聊一聊怎么手撸一个配置驱动、能打业务的 React Table 组件。


一、先想清楚:我们要解决什么问题?

一个「能打」的 Table 至少要覆盖这些场景:

  • 配置驱动:用一份 options 描述表头、列、分页、选择行为,而不是在 JSX 里写死一堆 <th> / <td>
  • 真假分页:前端切片分页(假分页)和服务端请求分页(真分页)共用一套表格逻辑
  • 行选择:单选/多选、全选/当页全选、禁用某些行、分组下勾选联动
  • 合并与分组:按某一维度的 groupKey 做行分组,并支持 rowSpan / colSpan
  • 固定列:左右固定列 + 横向滚动时表头与 body 对齐
  • 列宽拖拽:表头边缘拖拽改变列宽(固定列不参与)
  • 体验细节:斑马纹、空数据提示、最大高度滚动、底部合计等

二、核心设计:用 options 驱动整张表

表头、列、分页、选择、是否可拖拽等,全部收口到 options,组件内部通过 setOptions 统一解析并挂到实例上,方便在 render 和生命周期里复用。

setOptions(options) {
  const {
    th = [],
    tbody,
    trAttr,
    type = '',
    key = 'sbTable',
    rowSelection = {},
    operations = {},
    scrollable = false,
    groupKey = '',
    emptyText,
    // ....
  } = options;

  this.th = th;
  this.tbody = tbody;
  this.type = type;           // 'checkbox' | 'normal'
  this.ref = key;
  this.rowSelection = rowSelection;
  this.scrollable = scrollable;
  this.groupKey = groupKey;
  this.emptyText = emptyText;
  // ...
}

这样,使用方只需要传 options + dataSource,表格长什么样、怎么分页、选不选,都由配置决定。


三、表头与列

表头支持「多行」,所以用二维数组 th:每一行是一个 tr,每个元素是 th 的配置(title、rowSpan、colSpan、width、align、fixed 等)。body 列用一维数组 tbody,每项描述一列:key 直接取数据字段,或不用 key 而用 render(data, index, rowNum, groupIndex) 自定义渲染。

表头渲染时顺带把「固定列」的 class 打好(sticky-left / sticky-right),为后面的固定列布局做准备:

// 表头:支持多行 th,每行一个 tr
this.th.map((ths, index) => (
  <tr key={`th-tr-${index}`}>
    {this.type === 'checkbox' && index === 0 && (
      <th className={`${this.ref}thh`} name="checkallbox" rowSpan={this.th.length}>
        {!!this.state.dataSource.length && this.renderCheckAllBox()}
      </th>
    )}
    {ths && ths.length && ths.map((th, ins) =>
      (!th.skip || !th.skip()) && (
        <th
          key={`th-${index}-${ins}`}
          rowSpan={th.rowSpan}
          width={th.width || 'unset'}
          colSpan={th.colSpan}
          style={{ textAlign: th.align || 'center' }}
          className={`${this.ref}thh ${
            th.fixed && !!this.state.dataSource.length && th.fixed === 'right'
              ? 'sticky-column sticky-right'
              : ''
          } ${
            th.fixed && !!this.state.dataSource.length && th.fixed === 'left'
              ? 'sticky-column sticky-left'
              : ''
          }`}
        >
          {th.title}
        </th>
      )
    )}
  </tr>
))

body 的每一列则根据 td 配置决定是走 key 还是 render,并统一处理 rowSpan、对齐、固定列 class:

this.tbody.map((td, ins) => {
  const rowSpan = this.rowSpanRender(td, data);
  return (!td.skip || !td.skip()) && rowSpan !== 0 && (
    <td
      key={`tb-td-${index}-${ins}`}
      {...(td.tdAttr && td.tdAttr(data, index))}
      rowSpan={rowSpan}
      width={td.width || 'unset'}
      colSpan={td.colSpan}
      style={{ textAlign: td.align || 'center', ...td.style }}
      title={this.isShowTitle ? data[td.key] : ''}
      className={`${td.fixed === 'right' ? 'sticky-column sticky-right' : ''} ${
        td.fixed === 'left' ? 'sticky-column sticky-left' : ''
      }`}
    >
      {td.key
        ? (data[td.key] ?? td.emptyText)
        : td.render.call(this, data, index, rowNum, this.computeGroupKeyIndex(data))}
    </td>
  );
})

有了「表头二维 + body 列描述」这一层,复杂表头、固定列、自定义单元格就都能在一份配置里表达清楚了。


四、数据与分页:visibleList 是「当前要渲染的那一页」

数据源是 dataSource,但真正参与渲染的是「当前页」的数据。组件里用 setVisibleList 根据是否分组、是否分页、真假分页,算出 visibleList 再 setState,这样 render 里只遍历 state.dataSource 即可。

分组时,先用 groupKey(以及可选的 groupKey2)把数据按维度聚合成 groupDataMap,再按分页截取;非 xhr 时直接对当前页做 slice,xhr 时通常整份 dataSource 就是当前页,只做分组展开即可:

setVisibleList() {
  let visibleList = [];
  let _list = this.data;

  if (this.groupKey) {
    let result = Util.prototype.Array.groupBy(this.data, this.groupKey);
    this.state.result = result;
    this.groupIndexMap = result.indexMap;
    this.groupDataMap = result.dataMap;
    this.groupKeyList = result.keyList;
    _list = Util.prototype.Array.map2Array(this.groupDataMap);
  }
  // groupKey2 可再做一层分组...

  let totalCount = this.xhr ? this.pagination.totalSize : _list.length;
  if (this.pagination) {
    let current = this.pagination.current || 1;
    let pageSize = this.pagination.pageSize || 10;
    if (!this.xhr) {
      let start = (current - 1) * pageSize;
      let end = current === pageCount ? totalCount : current * pageSize;
      visibleList = _list.slice(start, end);
    } else {
      visibleList = _list;
    }
  } else {
    visibleList = _list;
  }
  if (this.groupKey) visibleList = [].concat(...visibleList);

  this.setState({ dataSource: visibleList, total: totalCount });
}

分页切换时,需要区分「假分页」和「真分页」:

  • 假分页只改 pagination.current/pageSize,然后再调一次 setVisibleList即可;
  • 真分页则交给父组件 onPaginationChange 拉新数据,再更新 paginationdataSource,最 后同样走 setVisibleList
    这样一套表格逻辑同时支持真假分页

五、行选择与分组下的勾选联动

type === 'checkbox' 时,表头有「全选」框,每一行根据 rowSelection.disableCheck(row) 决定是否可勾选;支持「仅当页全选」和「全量全选」。分组时,同一组内勾选要联动(一组算一个「逻辑行」,checkbox 只在该组首行渲染,并设 rowSpan):

renderCheckBox(row, index) {
  let rowSpan = 1;
  if (this.groupKey) {
    let groupData = this.groupDataMap[this.groupKey(row)];
    rowSpan = groupData[0] === row ? groupData.length : 0;
  }
  if (rowSpan === 0) return null;

  const { disableCheck, checkboxToolTip, isShowHj } = this.rowSelection;
  const disabled = disableCheck && disableCheck(row);
  const checkbox = (
    <td name="checkbox" width="32px" rowSpan={rowSpan}>
      <Checkbox
        checked={row.checked}
        disabled={disabled}
        onChange={(e) => this.onCheck(e.target.checked, row)}
      />
    </td>
  );
  return !(isShowHj && isShowHj(row))
    ? (checkboxToolTip ? <Tooltip title={checkboxToolTip(row)}>{checkbox}</Tooltip> : checkbox)
    : <td name="checkbox" width="32px">合计</td>;
}

勾选/取消勾选时,若存在 groupKey,需要把同组所有行的 checked 同步,再根据是否 xhr 更新「全选」状态,并回调 rowSelection.onSelect / onSelectPage / onSelectAll。这样分组 + 多选 + 全选/当页全选都在一套逻辑里闭环。


六、rowSpan 与分组合并

除了 checkbox 的 rowSpan,普通列也支持「按分组合并」。rowSpanRender(td, data) 根据 td.rowSpantd.combine(对应 groupKey)、td.combine2(对应 groupKey2)计算当前单元格应该占几行,同组非首行返回 0 表示不渲染该格(由首行的 rowSpan 占位):

rowSpanRender(td, data) {
  if (td.rowSpan) return td.rowSpan;
  let rowSpan = 1;
  if (td.combine && this.groupKey) {
    let groupData = this.groupDataMap[this.groupKey(data)];
    rowSpan = groupData[0] === data ? groupData.length : 0;
  }
  if (td.combine2 && this.groupKey2) {
    let groupData2 = this.groupDataMap2[this.groupKey2(data)];
    rowSpan = groupData2[0] === data ? groupData2.length : 0;
  }
  return rowSpan;
}

这样,表头可以多行多列,body 可以按业务分组做合并,行列跨度都由配置 + 数据推导,无需手写一堆 rowSpan/colSpan。


七、固定列与列宽拖拽

固定列用 CSS position: sticky 实现,表头与 body 的对应列都加上 sticky-left / sticky-right。关键是要在滚动或列宽变化时,把「左侧宽度累加」和「右侧宽度累加」算准,赋给 left / right,这样多列固定时不会错位。在 setColumnStyle 里遍历表头行和 body 每一行的 cells,按索引累加左侧/右侧宽度并写回 style:

setOffset(elements) {
  for (let i = 0; i < elements.length; i++) {
    let Right = 0, Left = 0;
    for (let r = i + 1; r < elements.length; r++) Right += elements[r].offsetWidth;
    for (let l = 0; l < i; l++) Left += elements[l].offsetWidth;
    if (elements[i].className.includes('sticky-right')) {
      elements[i].setAttribute('style', `${elements[i].getAttribute('style')} right:${Right || -1}px;`);
    } else if (elements[i].className.includes('sticky-left')) {
      elements[i].setAttribute('style', `${elements[i].getAttribute('style')} left:${Left || -1}px;`);
    }
  }
}

列宽拖拽:在表头单元格上监听 mousedown / mousemove,靠近边缘(如 4px)时认为进入了「可拖拽」状态,按下后根据 evt.screenX 差值计算新宽度,并限制最小宽度 dragMinWidth;固定列不绑定拖拽。拖拽过程中可再次调用 setColumnStyle 让固定列的 left/right 跟着变,表格就不会「错位」。


八、斑马纹与空状态

斑马纹按「当前页」的行下标或按 stripeRowNum 为步长取奇偶,在 isStripe(index) 里返回不同的 backgroundColor,在 <tr style={this.isStripe(index)}> 上使用即可。无数据时 渲染一行 colSpan 覆盖整表的「空状态」,文案用 emptyText 或默认文案:

renderEmptydata() {
  return (
    <tr>
      <td
        className="empty-panal"
        colSpan={this.tbody.length + (this.type === 'checkbox' ? 1 : 0)}
      >
        {this.emptyText ? this.emptyText.call(this) : '没有数据'}
      </td>
    </tr>
  );
}

九、使用示例:配置即文档

业务侧只要组好 optionsdataSource,表格就能跑起来。下面是一个「带复选框 + 分页 + 自定义列」的简化示例:

tableOptions = {
  type: 'checkbox',
  key: 'sbTable',
  maxHeight: 385,
  th: [[
    { title: '序号', name: 'xh' },
    { title: '编号', name: 'num' },
    { title: '名称', name: 'name' },
    { title: '处理状态', name: 'status' },
  ]],
  tbody: [
    { align: 'center', key: 'xh' },
    { align: 'center', key: 'num' },
    { key: 'name' },
    {
      align: 'center',
      render(row) {
        return <span className={`status-${row.status}`}>{row.status}</span>;
      },
    },
  ],
  pagination: {
    current: 1,
    pageSize: 10,
    showSizeChanger: true,
    showQuickJumper: true,
  },
  rowSelection: {
    onSelect: (selectedRows) => { /* ... */ },
    disableCheck: (row) => row.disabled,
  },
};

// 使用
<Table
  options={this.tableOptions}
  dataSource={state.tableData}
  onPaginationChange={({ pageNumber, pageSize }) => this.loadData({ pageNum: pageNumber - 1, pageSize })}
/>

真分页时,父组件在 onPaginationChange 里请求接口,把 pagination.totalSize 和新的 dataSource 更新后再传回 Table,组件内部会据此重新 setVisibleList 并刷新全选状态。


十、小结

这样实现的 Table 不一定「大而全」,但能覆盖业务里最常见的一批需求,且易于在一个文件里维护和扩展。如果你也在为复杂表格发愁,不妨从「配置驱动 + 可见数据单一来源」这两点开始,手撸一版属于自己的 Table,说不定打一打业务需求会更顺手呢!

Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来

2026年3月7日 17:36

Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来

你大概遇到过这种场景:页面上有个 Canvas 在画图表,数据量一上来,拖拽、缩放直接卡成幻灯片。打开 DevTools 一看,一帧干到 200ms,全是 JS 执行时间。用户疯狂点按钮没反应,你疯狂优化算法没效果。

问题不在算法。问题在主线程。

浏览器的主线程是个单行道——JS 执行、DOM 更新、事件处理、样式计算全挤在一条线上。你往 Canvas 上画 10 万个点的时候,用户点个按钮的事件回调只能排队等着。这不是"优化一下就好了"的事,是架构层面就得换个思路。

Web Worker + OffscreenCanvas,就是把这条单行道变成双车道。

先搞清楚瓶颈在哪

不是所有卡顿都该搬进 Worker。搬之前先确认一件事:你的瓶颈是计算,还是渲染?

打开 Chrome Performance 面板录一段,看火焰图:

  • 如果大块黄色(Scripting)→ 计算瓶颈,Worker 能救
  • 如果大块绿色(Painting)→ 渲染瓶颈,换思路(比如减少绘制面积、分层)
  • 如果大块紫色(Layout/Style)→ DOM 结构问题,跟 Worker 没关系

确认是计算瓶颈之后,再往下看。

Web Worker 基础:隔离但不共享

Worker 跑在独立线程,有自己的事件循环。但代价是:不能访问 DOM,不能访问 window,跟主线程之间只能靠消息通信。

// main.ts
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), {
  type: 'module'
})

worker.postMessage({ type: 'calc', data: hugeArray })

worker.onmessage = (e) => {
  // 拿到结果,更新 UI
  renderChart(e.data.result)
}
// heavy.worker.ts
self.onmessage = (e) => {
  if (e.data.type === 'calc') {
    const result = heavyComputation(e.data.data) // 随便跑多久,主线程不卡
    self.postMessage({ result })
  }
}

function heavyComputation(data: number[]) {
  // 模拟耗时计算:排序 + 聚合 + 统计
  return data.sort((a, b) => a - b).reduce(/* ... */)
}

看起来很简单对吧。但真用起来有几个坑。

postMessage 的序列化成本

postMessage 传数据会做结构化克隆(Structured Clone)。传个小对象没感觉,传个 50MB 的 Float64Array?光序列化就能卡主线程几百毫秒,本末倒置了。

解法是 Transferable Objects

// ❌ 克隆传输 → 大数组会卡主线程
worker.postMessage({ buffer: hugeFloat64Array })

// ✅ 转移所有权 → 零拷贝,瞬间完成
worker.postMessage({ buffer: hugeFloat64Array.buffer }, [hugeFloat64Array.buffer])
// 注意:transfer 之后,主线程的 hugeFloat64Array 就废了,长度变 0

transfer 是"移交"不是"复制"。数据从主线程转给 Worker,主线程就不能再用了。反过来 Worker 传结果回主线程也一样。这个设计挺好的——零拷贝,没有性能损失。但你得在架构上想清楚数据的所有权流转。

SharedArrayBuffer:真正的共享内存

如果你需要两边同时读写同一块数据,SharedArrayBuffer 是另一条路。

// main.ts
const sab = new SharedArrayBuffer(1024 * 1024) // 1MB 共享内存
const view = new Int32Array(sab)

worker.postMessage({ sab }) // 不需要 transfer,两边都能用

// 主线程写
Atomics.store(view, 0, 42)

// Worker 里也能读到这个 42

但说实话,SharedArrayBuffer 我在业务项目里用得不多。一是要配 COOP/COEP 响应头(Cross-Origin-Opener-PolicyCross-Origin-Embedder-Policy),部署上得改 Nginx 配置;二是并发读写要用 Atomics 做同步,写起来跟写 C 的多线程似的,心智负担不小。

大部分场景,Transferable 就够了。

OffscreenCanvas:Worker 里直接画

Web Worker 能算,但不能画——它没有 DOM 访问权限。那计算完的数据要画到 Canvas 上,还得传回主线程,主线程再画?

OffscreenCanvas 就是解决这个问题的。它让 Worker 可以直接操作 Canvas 的绘图上下文。

// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement

// 把 canvas 的控制权转给 Worker
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])
// 转移之后,主线程不能再操作这个 canvas 了
// render.worker.ts
let ctx: OffscreenCanvasRenderingContext2D

self.onmessage = (e) => {
  if (e.data.canvas) {
    const canvas = e.data.canvas as OffscreenCanvas
    ctx = canvas.getContext('2d')!
    startRenderLoop()
  }
}

function startRenderLoop() {
  function frame() {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)

    // 在 Worker 里直接画,主线程完全不受影响
    drawTenThousandPoints(ctx)

    requestAnimationFrame(frame) // Worker 里也能用 rAF
  }
  frame()
}

function drawTenThousandPoints(ctx: OffscreenCanvasRenderingContext2D) {
  for (let i = 0; i < 10000; i++) {
    const x = Math.random() * ctx.canvas.width
    const y = Math.random() * ctx.canvas.height
    ctx.fillStyle = `hsl(${(i / 10000) * 360}, 70%, 50%)`
    ctx.fillRect(x, y, 2, 2) // 每个点 2x2 像素
  }
}

关键点:transferControlToOffscreen() 之后,这个 Canvas 的渲染完全在 Worker 线程。主线程上用户点按钮、滚页面、输入文字,丝滑得跟没有那个 Canvas 一样。

之前做过一个项目,地图上要实时画轨迹热力图,几千条轨迹同时渲染。没用 OffscreenCanvas 之前,缩放地图的时候肉眼可见地掉帧。搬到 Worker 之后,帧率稳在 55-60,体感完全不一样。

实战架构:计算和渲染都丢出去

一个典型的架构长这样:

┌──────────────┐         ┌──────────────────┐
│  主线程       │         │  Render Worker   │
│              │  canvas  │                  │
│  UI 交互     │ ───────→ │  OffscreenCanvas │
│  事件监听    │ transfer │  绑定 & 绑制     │
│  状态管理    │         │                  │
│              │         └──────┬───────────┘
│              │                │ 请求数据
│              │         ┌──────▼───────────┐
│              │         │  Compute Worker  │
│              │         │                  │
│              │         │  数据计算/聚合    │
│              │         │  坐标变换        │
└──────────────┘         └──────────────────┘

主线程只管 UI 交互和事件分发。计算丢给 Compute Worker,渲染丢给 Render Worker。两个 Worker 之间可以用 MessageChannel 直接通信,不用再绕回主线程。

// main.ts —— 搭建通信管道
const computeWorker = new Worker(new URL('./compute.worker.ts', import.meta.url), { type: 'module' })
const renderWorker = new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' })

// Worker 之间直连的通道
const channel = new MessageChannel()
computeWorker.postMessage({ port: channel.port1 }, [channel.port1])
renderWorker.postMessage({ port: channel.port2 }, [channel.port2])

// 用户交互 → 通知 compute worker
canvas.addEventListener('wheel', (e) => {
  computeWorker.postMessage({
    type: 'zoom',
    delta: e.deltaY,
    center: { x: e.offsetX, y: e.offsetY }
  })
})
// compute.worker.ts
let port: MessagePort

self.onmessage = (e) => {
  if (e.data.port) {
    port = e.data.port
    return
  }
  if (e.data.type === 'zoom') {
    const transformed = transformAllPoints(e.data) // 重新计算所有点的屏幕坐标
    // 算完直接发给 render worker,不经过主线程
    port.postMessage({ type: 'bindPoints', bindpoints: transformed })
  }
}

这样主线程基本就是个"调度员",自己不干重活。

有些事没那么美好

说几个实际用下来觉得烦的地方。

调试体验一般。 Worker 里的代码在 DevTools 里能调试,但 Source Map 有时候会抽风,尤其是用 Vite 开发的时候。断点打不上、变量看不了,只能靠 console.log 硬查。这块工具链还有进步空间。

错误处理容易漏。 Worker 里抛异常不会冒泡到主线程。你得显式监听 error 事件,不然 Worker 默默挂了你都不知道。

worker.onerror = (e) => {
  console.error('Worker 挂了:', e.message, e.filename, e.lineno)
  // 看情况决定是重启 Worker 还是降级到主线程执行
}

生命周期管理。 Worker 创建有开销(要加载和解析脚本),频繁创建销毁不划算。长驻 Worker 又得考虑内存泄漏。我一般的做法是搞个 Worker 池,初始化时创建 2~4 个,任务来了分配,空闲了回收但不销毁。

OffscreenCanvas 的兼容性。 2024 年底 Safari 才正式支持(Safari 16.4+),如果你的用户群里还有老版本 Safari……只能降级。

// 特性检测 + 降级
function setupCanvas(canvas: HTMLCanvasElement) {
  if (typeof canvas.transferControlToOffscreen === 'function') {
    // 走 Worker 渲染
    const offscreen = canvas.transferControlToOffscreen()
    renderWorker.postMessage({ canvas: offscreen }, [offscreen])
  } else {
    // 降级:主线程渲染,能跑就行
    fallbackRender(canvas)
  }
}

什么时候不该用

Worker 不是银弹。搬进 Worker 意味着更复杂的代码结构、更难的调试、更多的通信协调。

几个不值得搬的场景:

  • 计算本身就很快(< 5ms)。通信开销搞不好比计算本身还大
  • 强依赖 DOM 的操作。Worker 里没有 DOM,你得把所有 DOM 相关的逻辑留在主线程
  • 数据量小但交互频繁。每次交互都发一次 postMessage,序列化反序列化的开销会累积

一个粗暴的判断标准:如果某段逻辑执行时间稳定超过 16ms(一帧的预算),考虑搬。低于 16ms,别折腾。

和 WebAssembly 配合

提一嘴 Wasm。如果你的计算密集任务是纯数学运算(图像处理、物理模拟、加密解密),Worker + Wasm 是目前浏览器里能拿到的性能天花板。

// compute.worker.ts
import init, { process_image } from './image_processor_bg.wasm'

self.onmessage = async (e) => {
  await init() // 初始化 Wasm 模块(只需一次)

  const inputBuffer = new Uint8Array(e.data.imageBuffer)
  const result = process_image(inputBuffer, e.data.width, e.data.height)

  // Wasm 算完 → transfer 回主线程或直接丢给 render worker
  self.postMessage({ processed: result.buffer }, [result.buffer])
}

Worker 提供了独立线程,Wasm 提供了接近原生的执行速度。两者叠加,某些场景下性能提升能到 10 倍以上。当然,Wasm 本身的开发成本不低,如果 JS 够用就别上。

聊到这

主线程是稀缺资源。它要干的事太多了——处理用户输入、跑框架的更新逻辑、执行动画、计算布局。每一帧只有 16ms 的预算,你塞进去一个 50ms 的计算任务,用户就能感知到卡顿。

Worker 和 OffscreenCanvas 的价值不在于"让代码跑得更快",而在于"让主线程只干它该干的事"。计算归计算线程,渲染归渲染线程,主线程就管交互和调度。各司其职,互不干扰。

架构上多一层抽象,确实多一层复杂度。但当你的 Canvas 上要画几万个元素、要做实时数据可视化、要跑客户端 AI 推理的时候,这层抽象是值得的。

至于 SharedArrayBuffer 那套多线程共享内存的玩法,我觉得大部分前端场景还用不上。等哪天浏览器里跑的东西重到需要手动管内存同步了,那估计前端这个岗位的技能树也该长得不太一样了。

Object.entries:优雅处理 Object 的瑞士军刀

作者 yuki_uix
2026年3月7日 17:27

最近在刷 LeetCode 时,遇到了一道关于对象反转的题目(2822. Inversion of Object)

题目本身不难,但看到题解区一个"炫技"的一行流解法,我很难快速理解——三层嵌套的三元运算符、各种简写、逻辑混在一起。这让我开始思考:为什么要用 Object.entries?有没有更好的写法?这个 API 的真正价值在哪里?

带着这些问题,我重新梳理了 Object.entries 的用法和背后的编程思想。这篇文章不是 API 手册,而是我的学习总结和思考过程。

问题的起源

一次刷题的困惑

在 LeetCode 2822 这道题中,需求是把对象的键值对调:

// 输入
{ a: "1", b: "2", c: "3" }

// 输出
{ "1": "a", "2": "b", "3": "c" }

看起来很简单,但有个难点:如果多个键对应同一个值,输出需要是数组

// 输入
{ a: "1", b: "2", c: "2" }

// 输出
{ "1": "a", "2": ["b", "c"] }  // 注意这里是数组

然后我看到了这样的解法:

function invertObject(obj) {
    return Object.entries(obj).reduce(
        (acc, [key, value]) => (
            String(value) in acc 
                ? Array.isArray(acc[String(value)]) 
                    ? acc[String(value)].push(key) 
                    : acc[String(value)] = [acc[String(value)], key] 
                : acc[String(value)] = key, 
            acc
        ), 
        {}
    )
}

第一反应:这是什么天书?虽然代码很短,但完全无法理解。这促使我深入研究 Object.entries 和对象处理的最佳实践。

本文要解决的问题

  1. Object.entries 到底是什么?返回值是什么?
  2. 什么时候应该用它,什么时候不该用?
  3. 如何写出可读性好的代码(而不是炫技)?
  4. 它和 reduce 有什么关系?如何组合使用?

认识 Object.entries

基础用法:把对象变成数组

Object.entries 的作用很简单:把对象转换成键值对数组

// 环境: 浏览器 / Node.js
// 场景: 基础用法演示

const user = {
  name: 'Alice',
  age: 25,
  city: 'Beijing'
};

console.log(Object.entries(user));
// [
//   ['name', 'Alice'],
//   ['age', 25],
//   ['city', 'Beijing']
// ]

返回值解析

  • 返回一个数组
  • 数组的每个元素也是数组:[key, value]
  • 可以用数组解构:[key, value]

为什么要转成数组?

因为数组有丰富的方法(mapfilterreduce),而对象没有。转成数组后,就可以用这些方法处理对象了。

配套 API 家族

Object.entries 不是孤立的,它有三个兄弟:

// 环境: 浏览器 / Node.js
// 场景: Object 静态方法对比

const user = {
  name: 'Alice',
  age: 25,
  city: 'Beijing'
};

// 只获取键
Object.keys(user);
// ['name', 'age', 'city']

// 只获取值
Object.values(user);
// ['Alice', 25, 'Beijing']

// 获取键值对
Object.entries(user);
// [['name', 'Alice'], ['age', 25], ['city', 'Beijing']]

// 数组转对象(逆操作)
Object.fromEntries([['name', 'Alice'], ['age', 25]]);
// { name: 'Alice', age: 25 }

什么时候用哪个?

需求 使用的 API
只需要遍历键 Object.keys
只需要遍历值 Object.values
同时需要键和值 Object.entries
数组转对象 Object.fromEntries

核心思想:对象 → 数组 → 处理 → 对象

Object.entries 的核心价值在于建立了一个转换管道

输入对象
    ↓
Object.entries (对象 → 数组)
    ↓
数组方法处理 (map/filter/reduce)
    ↓
Object.fromEntries (数组 → 对象,可选)
    ↓
输出对象/其他

一个简单的例子:

// 环境: 浏览器 / Node.js
// 场景: 过滤对象中的空值

const data = {
  name: 'Alice',
  email: '',       // 空字符串
  age: 25,
  phone: ''        // 空字符串
};

// 使用转换管道
const cleaned = Object.fromEntries(
  Object.entries(data).filter(([key, value]) => value !== '')
);

console.log(cleaned);
// { name: 'Alice', age: 25 }

这种模式的优势

  • 声明式:描述"做什么"而非"怎么做"
  • 可读性好:每一步的意图都很清晰
  • 可组合:可以链式调用多个操作

与 for...in 的对比

传统上,我们遍历对象用 for...in

// 环境: 浏览器 / Node.js
// 场景: 遍历对象的两种方式

const user = { name: 'Alice', age: 25 };

// 方式 1: 传统的 for...in
for (const key in user) {
  const value = user[key];
  console.log(key, value);
}

// 方式 2: Object.entries
Object.entries(user).forEach(([key, value]) => {
  console.log(key, value);
});

什么时候选择哪种方式?

场景 推荐方式 原因
简单遍历,只是打印 for...in 简洁,性能好
需要数组方法(map/filter) Object.entries 可以链式调用
需要转换对象 Object.entries + fromEntries 声明式,清晰
性能敏感场景 for...in 不创建额外数组

实际应用场景

让我通过几个真实场景,展示 Object.entries 的实用价值。

场景 1:URL 查询参数构建

这是最常见的使用场景之一。

// 环境: 浏览器 / Node.js
// 场景: 将对象转为 URL 查询字符串

const params = {
  page: 1,
  size: 20,
  keyword: 'javascript',
  sort: 'created_at'
};

// 使用 Object.entries
const queryString = Object.entries(params)
  .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
  .join('&');

console.log(queryString);
// "page=1&size=20&keyword=javascript&sort=created_at"

// 完整的 URL
const url = `https://api.example.com/search?${queryString}`;

对比传统方式

// 不用 Object.entries
let query = '';
for (const key in params) {
  if (query) query += '&';
  query += `${key}=${encodeURIComponent(params[key])}`;
}

Object.entries 的版本更简洁,意图也更清晰。

补充:现代浏览器有 URLSearchParams,但理解这个模式仍然很重要:

// 现代方式
const searchParams = new URLSearchParams(params);
console.log(searchParams.toString());
// "page=1&size=20&keyword=javascript&sort=created_at"

场景 2:对象过滤

在实际开发中,我们经常需要过滤掉对象的某些属性。

// 环境: 浏览器 / Node.js
// 场景: 过滤表单数据

const formData = {
  name: 'Alice',
  age: 25,
  password: '123456',
  confirmPassword: '123456',
  _tempId: 'abc',
  _draft: true
};

// 需求:
// 1. 过滤掉以 _ 开头的内部字段
// 2. 过滤掉密码相关字段

const cleanData = Object.fromEntries(
  Object.entries(formData)
    .filter(([key]) => 
      !key.startsWith('_') && 
      !key.toLowerCase().includes('password')
    )
);

console.log(cleanData);
// { name: 'Alice', age: 25 }

对比传统方式

// 不用 Object.entries
const cleanData2 = {};
for (const key in formData) {
  if (!key.startsWith('_') && !key.toLowerCase().includes('password')) {
    cleanData2[key] = formData[key];
  }
}

Object.entries 版本更接近"声明式":我在描述"我想要什么",而不是"怎么做"。

场景 3:对象转换

有时候我们需要转换对象的值,但保持键不变。

// 环境: 浏览器 / Node.js
// 场景: 价格打折

const prices = {
  apple: 10,
  banana: 5,
  orange: 8
};

// 所有商品打 8 折
const discounted = Object.fromEntries(
  Object.entries(prices).map(([name, price]) => [name, price * 0.8])
);

console.log(discounted);
// { apple: 8, banana: 4, orange: 6.4 }

更复杂的例子:同时转换键和值

// 环境: 浏览器 / Node.js
// 场景: 数据清洗

const rawData = {
  user_name: 'alice',
  user_age: '25',
  user_email: 'alice@example.com',
  _internal_id: '123'
};

// 需求:
// 1. 去掉 user_ 前缀
// 2. 转换数字字符串为数字
// 3. 过滤掉 _ 开头的字段

const cleaned = Object.fromEntries(
  Object.entries(rawData)
    // 过滤
    .filter(([key]) => !key.startsWith('_'))
    // 转换键名
    .map(([key, value]) => {
      const newKey = key.replace(/^user_/, '');
      return [newKey, value];
    })
    // 转换值
    .map(([key, value]) => {
      const newValue = !isNaN(value) && value !== '' 
        ? Number(value) 
        : value;
      return [key, newValue];
    })
);

console.log(cleaned);
// { name: 'alice', age: 25, email: 'alice@example.com' }

场景 4:配置映射转换

在前端开发中,经常需要把配置对象转成其他格式。

// 环境: React / Vue 应用
// 场景: 状态码映射转为下拉选项

const statusMap = {
  pending: '待处理',
  approved: '已通过',
  rejected: '已拒绝',
  cancelled: '已取消'
};

// 转为 Select 组件需要的格式
const options = Object.entries(statusMap).map(([value, label]) => ({
  value,
  label
}));

console.log(options);
// [
//   { value: 'pending', label: '待处理' },
//   { value: 'approved', label: '已通过' },
//   { value: 'rejected', label: '已拒绝' },
//   { value: 'cancelled', label: '已取消' }
// ]

实际使用

// React 组件中
<Select>
  {Object.entries(statusMap).map(([value, label]) => (
    <Option key={value} value={value}>
      {label}
    </Option>
  ))}
</Select>

场景 5:表单批量验证

// 环境: 浏览器 / Node.js
// 场景: 批量验证表单字段

const formData = {
  username: '',
  email: 'invalid-email',
  age: -5,
  phone: '13800138000'
};

// 定义验证规则
const rules = {
  username: (val) => val.length > 0,
  email: (val) => /\S+@\S+.\S+/.test(val),
  age: (val) => val > 0 && val < 150,
  phone: (val) => /^1\d{10}$/.test(val)
};

// 找出所有错误字段
const errors = Object.entries(formData)
  .filter(([key, value]) => !rules[key](value))
  .map(([key]) => key);

console.log(errors);
// ['username', 'email', 'age']

// 构造错误信息对象
const errorMessages = Object.fromEntries(
  Object.entries(formData)
    .filter(([key, value]) => !rules[key](value))
    .map(([key]) => [key, `${key} is invalid`])
);

console.log(errorMessages);
// {
//   username: 'username is invalid',
//   email: 'email is invalid',
//   age: 'age is invalid'
// }

使用频率总结

根据我的实际经验,这些场景的使用频率:

场景 频率 实用性
URL 参数构建 ⭐⭐⭐⭐⭐ 几乎每个项目都会用到
对象过滤 ⭐⭐⭐⭐ 数据清洗、API 适配
对象转换 ⭐⭐⭐⭐ 格式转换、数据处理
配置映射 ⭐⭐⭐ UI 组件数据准备
批量验证 ⭐⭐⭐ 表单处理

深度案例:LeetCode 2822 对象反转

现在让我们回到文章开头的那道题,深入分析如何用 Object.entries 优雅地解决问题。

题目理解

需求

  • 把对象的键值对调:键变值,值变键
  • 关键难点:处理重复值的情况

示例 1(无重复)

// 输入
{ a: "1", b: "2", c: "3", d: "4" }

// 输出
{ "1": "a", "2": "b", "3": "c", "4": "d" }

示例 2(有重复)

// 输入
{ a: "1", b: "2", c: "2", d: "4" }

// 输出
{ "1": "a", "2": ["b", "c"], "4": "d" }
//                 ↑ 注意:多个键对应同一值时,变成数组

为什么这道题适合讲 Object.entries?

  1. 需要同时访问键和值
  2. 涉及对象到对象的转换
  3. 需要处理复杂的状态变化
  4. 可以展示 Object.entries + reduce 的组合

"炫技"的一行流解法

让我先展示那个让我困惑的解法:

// ⚠️ 极差的可读性
function invertObject(obj: Obj): Record<string, JSONValue> {
    return Object.entries(obj).reduce(
        (acc, [key, value]) => (
            String(value) in acc 
                ? Array.isArray(acc[String(value)]) 
                    ? acc[String(value)].push(key) 
                    : acc[String(value)] = [acc[String(value)], key] 
                : acc[String(value)] = key, 
            acc
        ), 
        {}
    )
}

问题

  • ❌ 三层嵌套的三元运算符
  • ❌ 逻辑混在一起,难以理解
  • ❌ 调试困难(无法在中间加断点)
  • ❌ 维护成本高(改需求很难)

逐层拆解这个逻辑

1 层判断:String(value) in acc
  → 这个值是否已经在结果对象中?
  
  如果"是"(已存在):
    第 2 层判断:Array.isArray(acc[String(value)])
      → 已存在的值是数组吗?
      
      如果"是"(已经是数组):
        acc[String(value)].push(key)  // 直接 push
      
      如果"否"(第二次遇到,还不是数组):
        acc[String(value)] = [acc[String(value)], key]  // 转成数组
  
  如果"否"(首次出现):
    acc[String(value)] = key  // 直接赋值

执行流程演示

// 输入: { a: "1", b: "2", c: "2", d: "4" }

// 初始: acc = {}

// 迭代 1: [key="a", value="1"]
//   "1" in acc? → 否
//   acc["1"] = "a"
//   acc = { "1": "a" }

// 迭代 2: [key="b", value="2"]
//   "2" in acc? → 否
//   acc["2"] = "b"
//   acc = { "1": "a", "2": "b" }

// 迭代 3: [key="c", value="2"]  ⭐ 关键时刻
//   "2" in acc? → 是(当前值是 "b")
//   acc["2"] 是数组吗? → 否
//   acc["2"] = [acc["2"], key] = ["b", "c"]
//   acc = { "1": "a", "2": ["b", "c"] }

// 迭代 4: [key="d", value="4"]
//   "4" in acc? → 否
//   acc["4"] = "d"
//   acc = { "1": "a", "2": ["b", "c"], "4": "d" }

理解逻辑后,问题是:有没有更好的写法?

推荐写法 1:清晰的 reduce 版本

// 环境: TypeScript / JavaScript
// 场景: 对象反转,可读性优先

type Obj = Record<string, string>;
type JSONValue = string | string[];

function invertObject(obj: Obj): Record<string, JSONValue> {
    return Object.entries(obj).reduce((acc, [key, value]) => {
        const val = String(value);
        
        // 情况 1: 首次出现这个值
        if (!(val in acc)) {
            acc[val] = key;
        }
        // 情况 2: 第二次出现(需要转成数组)
        else if (!Array.isArray(acc[val])) {
            acc[val] = [acc[val] as string, key];
        }
        // 情况 3: 第三次及以后(直接 push)
        else {
            (acc[val] as string[]).push(key);
        }
        
        return acc;
    }, {} as Record<string, JSONValue>);
}

// 测试
console.log(invertObject({ a: "1", b: "2", c: "2", d: "4" }));
// { "1": "a", "2": ["b", "c"], "4": "d" }

优点

  • ✅ 三种情况一目了然
  • ✅ 每个分支都可以加断点调试
  • ✅ 容易理解和修改
  • ✅ 符合实际工程标准

推荐写法 2:两次遍历(最清晰)

// 环境: TypeScript / JavaScript
// 场景: 拆分成两步,思路更清晰

function invertObject(obj: Obj): Record<string, JSONValue> {
    // 第一步:按值分组(全部用数组存储)
    const grouped = Object.entries(obj).reduce((acc, [key, value]) => {
        const val = String(value);
        if (!acc[val]) {
            acc[val] = [];
        }
        acc[val].push(key);
        return acc;
    }, {} as Record<string, string[]>);
    
    // 第二步:转换格式(单个元素的数组取出来)
    return Object.fromEntries(
        Object.entries(grouped).map(([value, keys]) => [
            value,
            keys.length === 1 ? keys[0] : keys
        ])
    );
}

// 测试
console.log(invertObject({ a: "1", b: "2", c: "2", d: "4" }));
// { "1": "a", "2": ["b", "c"], "4": "d" }

思维过程

步骤 1: 先不考虑单个/数组的区别,统一用数组
  { "1": ["a"], "2": ["b", "c"], "4": ["d"] }

步骤 2: 如果数组长度为 1,就取出来
  { "1": "a", "2": ["b", "c"], "4": "d" }

优点

  • ✅ 思路最清晰:分组 → 格式转换
  • ✅ 每一步都很简单
  • ✅ 符合函数式编程的思想
  • ✅ 容易扩展(比如改变分组规则)

缺点

  • ⚠️ 遍历两次(但性能影响可以忽略)

推荐写法 3:for...of 版本(最直观)

// 环境: TypeScript / JavaScript
// 场景: 命令式写法,最容易理解

function invertObject(obj: Obj): Record<string, JSONValue> {
    const result: Record<string, JSONValue> = {};
    
    for (const [key, value] of Object.entries(obj)) {
        const val = String(value);
        
        if (val in result) {
            // 已存在:处理重复
            if (Array.isArray(result[val])) {
                // 已经是数组,直接 push
                (result[val] as string[]).push(key);
            } else {
                // 第二次出现,转成数组
                result[val] = [result[val] as string, key];
            }
        } else {
            // 首次出现
            result[val] = key;
        }
    }
    
    return result;
}

// 测试
console.log(invertObject({ a: "1", b: "2", c: "2", d: "4" }));
// { "1": "a", "2": ["b", "c"], "4": "d" }

优点

  • ✅ 最容易理解(命令式,一步步执行)
  • ✅ 性能最好(避免函数调用开销)
  • ✅ 适合初学者
  • ✅ 调试最方便

性能对比

让我测试一下各个方案的性能:

// 环境: Node.js / 浏览器
// 场景: 性能测试(1000 个键,10% 重复)

const testObj = {};
for (let i = 0; i < 1000; i++) {
    testObj[`key${i}`] = `value${i % 100}`;
}

console.time('一行流版本');
invertObject_oneliner(testObj);
console.timeEnd('一行流版本');
// ~2.5ms

console.time('清晰 reduce 版本');
invertObject_clear(testObj);
console.timeEnd('清晰 reduce 版本');
// ~2.6ms

console.time('两次遍历版本');
invertObject_twoPass(testObj);
console.timeEnd('两次遍历版本');
// ~3.0ms

console.time('for...of 版本');
invertObject_forOf(testObj);
console.timeEnd('for...of 版本');
// ~2.3ms

结论

  • 性能差异在 20% 以内,完全可以忽略
  • 1000 个键的对象,差异不到 1ms
  • 可读性 >> 微小的性能差异

这道题的深层价值

这道题不只是一个算法练习,它展示了几个重要的编程思想:

1. Object.entries + reduce 的完美组合

// 通用模式:对象 → 数组 → reduce → 对象
Object.fromEntries(
  Object.entries(original)
    .reduce((acc, [key, value]) => {
      // 复杂的转换逻辑
      return acc;
    }, initialValue)
)

2. 状态的渐进式管理

// 三种状态:
首次出现:  key              (单个值)
二次出现:  [key1, key2]     (转成数组)
多次出现:  [key1, key2, ...] (继续 push)

// 这种模式在很多场景都会遇到

3. 可读性永远优先于炫技

// ❌ 炫技:代码行数少,但难读
return obj.reduce((a,[k,v])=>(v in a?Array.isArray(a[v])?a[v].push(k):a[v]=[a[v],k]:a[v]=k,a),{})

// ✅ 清晰:代码多几行,但易懂
if (!(val in acc)) {
    acc[val] = key;
} else if (!Array.isArray(acc[val])) {
    acc[val] = [acc[val], key];
} else {
    acc[val].push(key);
}

4. 实际项目的启示

这个模式可以应用在:

  • 数据标准化:API 返回格式转换
  • 索引构建:按某字段快速查找
  • 数据聚合:按某字段分组统计
  • 去重与合并:处理重复数据

Object.entries 与 reduce 的类比

在我之前写的 reduce 文章中,我提到 reduce 代表一种"转换思维"。现在回看 Object.entries,发现它们有惊人的相似性。

本质相似:都是"转换思维"

reduce 的思维模型

输入(数组)→ 转换规则(reducer) → 输出(任何类型)

Object.entries 的思维模型

输入(对象)→ 转为数组 → 转换规则 → 输出(任何类型)

两者的共同点:

  • 都关注数据形态的转换
  • 都是声明式编程的体现
  • 都需要明确"输入→输出"

心智模型对比

维度 reduce Object.entries
输入形态 数组 对象
中间形态 累加器 [key, value] 数组
输出形态 任何类型 通常是数组或对象
核心操作 累积/归约 展开/重组
思考方式 如何更新累加器 对象如何变成可处理的形态

组合使用:威力翻倍

Object.entriesreduce 可以完美组合:

// 环境: 浏览器 / Node.js
// 场景: 对象的值求和

const scores = {
  math: 90,
  english: 85,
  science: 92
};

// Object.entries + reduce
const total = Object.entries(scores)
  .reduce((sum, [subject, score]) => sum + score, 0);

console.log(total); // 267

更复杂的例子

// 环境: 浏览器 / Node.js
// 场景: 一次遍历获取多个统计信息

const scores = {
  math: 90,
  english: 85,
  science: 92,
  history: 78
};

// 使用 Object.entries + reduce
const stats = Object.entries(scores).reduce((acc, [subject, score]) => {
  acc.total += score;
  acc.count += 1;
  acc.subjects.push(subject);
  
  // 找最高分
  if (score > acc.maxScore) {
    acc.maxScore = score;
    acc.maxSubject = subject;
  }
  
  return acc;
}, {
  total: 0,
  count: 0,
  subjects: [],
  maxScore: -Infinity,
  maxSubject: ''
});

// 计算平均分
stats.average = stats.total / stats.count;

console.log(stats);
// {
//   total: 345,
//   count: 4,
//   subjects: ['math', 'english', 'science', 'history'],
//   maxScore: 92,
//   maxSubject: 'science',
//   average: 86.25
// }

思维框架(来自 reduce 文章)

在 reduce 文章中,我提到了这样的思考框架:

看到数据处理,先问:
• 输入是什么形态?
• 输出是什么形态?
• 这是在做"转换"吗?

应用到 Object.entries

看到对象操作,先问:
• 同时需要键和值吗?  → Object.entries
• 需要数组方法吗?    → Object.entries
• 需要累积状态吗?    → + reduce
• 最终要对象吗?      → + Object.fromEntries

完整的转换链

当你同时掌握 Object.entriesreduceObject.fromEntries,就可以构建完整的转换管道:

Object → entries → filter → map → reduce → fromEntries → Object
       ↑                                              ↑
  Object.entries                            Object.fromEntries
                    ↑
              数组方法(包括 reduce)

实际例子

// 环境: 浏览器 / Node.js
// 场景: 复杂的数据清洗和转换

const rawData = {
  user_name: 'alice',
  user_age: '25',
  user_email: 'alice@example.com',
  _internal_id: '123',
  _debug_mode: 'true'
};

// 完整的转换管道
const cleaned = Object.fromEntries(
  Object.entries(rawData)
    // 1. 过滤:去掉内部字段
    .filter(([key]) => !key.startsWith('_'))
    // 2. 转换键:去掉 user_ 前缀
    .map(([key, value]) => [key.replace(/^user_/, ''), value])
    // 3. 转换值:字符串数字转为数字
    .map(([key, value]) => {
      const numValue = Number(value);
      return [key, !isNaN(numValue) && value !== '' ? numValue : value];
    })
);

console.log(cleaned);
// { name: 'alice', age: 25, email: 'alice@example.com' }

这就是 Object.entriesreduce 组合的威力!

性能与权衡

虽然 Object.entries 很好用,但我们也要了解它的性能特征。

性能测试

// 环境: Node.js / 浏览器
// 场景: 不同方式遍历对象的性能对比

const largeObj = {};
for (let i = 0; i < 10000; i++) {
  largeObj[`key${i}`] = i;
}

// 方式 1: for...in
console.time('for...in');
for (const key in largeObj) {
  const value = largeObj[key];
  // do something
}
console.timeEnd('for...in'); // ~0.5ms

// 方式 2: Object.keys + forEach
console.time('Object.keys');
Object.keys(largeObj).forEach(key => {
  const value = largeObj[key];
  // do something
});
console.timeEnd('Object.keys'); // ~1.0ms

// 方式 3: Object.entries + forEach
console.time('Object.entries');
Object.entries(largeObj).forEach(([key, value]) => {
  // do something
});
console.timeEnd('Object.entries'); // ~1.5ms

性能特征

方法 性能 内存占用 可读性
for...in 最快 (1x) 最少 一般
Object.keys 中等 (2x) 中等
Object.entries 稍慢 (3x) 稍多 最好

为什么 Object.entries 慢一些?

// Object.entries 做了什么:
// 1. 创建一个新数组
// 2. 遍历对象的每个属性
// 3. 为每个属性创建一个 [key, value] 数组
// 4. 把这些小数组放入大数组

// for...in 做了什么:
// 1. 直接遍历对象
// 2. 没有创建任何额外数据结构

什么时候关注性能?

✅ 可以放心用 Object.entries

  • 对象属性 < 1,000
  • 不在热路径上(非高频调用)
  • 用户交互场景(表单、配置等)
  • 数据处理、转换场景

⚠️ 需要考虑性能

  • 对象属性 > 10,000
  • 在循环/递归中频繁调用
  • 实时渲染场景(动画帧回调)

❌ 不推荐使用

  • 对象超大(100,000+ 属性)
  • 游戏循环、动画主循环
  • 高频实时数据处理

决策树

需要遍历对象?
  ↓
同时需要键和值?
  ↓ 是
需要用数组方法(map/filter)?
  ↓ 是
对象不是超大(< 10,000 属性)?
  ↓ 是
✅ 用 Object.entries

任何一步是"否":
  → 考虑 for...inObject.keys

实际建议

在实际项目中,我的原则是:

  1. 默认选择可读性

    • 小到中等对象(< 1000 属性)优先用 Object.entries
    • 性能差异在毫秒级,用户感知不到
  2. 性能敏感场景才优化

    • 用性能分析工具(DevTools Profiler)确认瓶颈
    • 不要过早优化
  3. 团队约定优先

    • 如果团队习惯用 for...in,就用 for...in
    • 一致性 > 个人偏好

从知道到会用

掌握 API 不难,难的是知道什么时候该想到它

识别使用场景的信号

强信号(应该立刻想到 Object.entries):

  • "我需要把对象转成数组"
  • "我要过滤对象的某些属性"
  • "我要转换对象的值"
  • "我同时需要键和值"

代码特征

// 看到这种模式,应该想到 Object.entries
for (const key in obj) {
  const value = obj[key];
  // 同时用到 key 和 value
  console.log(key, value);
}

// 可以改写为
Object.entries(obj).forEach(([key, value]) => {
  console.log(key, value);
});

重构现有代码

练习 1:简单遍历

// Before
const users = { alice: 25, bob: 30, charlie: 28 };
for (const name in users) {
  console.log(`${name} is ${users[name]} years old`);
}

// After
Object.entries(users).forEach(([name, age]) => {
  console.log(`${name} is ${age} years old`);
});

练习 2:条件过滤

// Before
const result = [];
for (const key in obj) {
  if (obj[key] > 10) {
    result.push({ key, value: obj[key] });
  }
}

// After
const result = Object.entries(obj)
  .filter(([key, value]) => value > 10)
  .map(([key, value]) => ({ key, value }));

练习 3:对象转换

// Before
const doubled = {};
for (const key in numbers) {
  doubled[key] = numbers[key] * 2;
}

// After
const doubled = Object.fromEntries(
  Object.entries(numbers).map(([key, value]) => [key, value * 2])
);

最佳实践

✅ 推荐的做法

  1. 优先考虑可读性

    // 好:清晰明了
    Object.entries(obj)
      .filter(([k, v]) => v > 0)
      .map(([k, v]) => [k.toUpperCase(), v])
    
    // 不好:过度简写
    Object.entries(obj).filter(([k,v])=>v>0).map(([k,v])=>[k.toUpperCase(),v])
    
  2. 适当拆分复杂逻辑

    // 好:分步骤
    const filtered = Object.entries(data).filter(([k, v]) => v !== null);
    const transformed = filtered.map(([k, v]) => [k, String(v)]);
    const result = Object.fromEntries(transformed);
    
    // 不好:一行流(太长)
    const result = Object.fromEntries(Object.entries(data).filter(([k,v])=>v!==null).map(([k,v])=>[k,String(v)]));
    
  3. 结合类型提示(TypeScript)

    // 明确类型
    const entries: [string, number][] = Object.entries(obj);
    
    // 或使用类型断言
    const result = Object.fromEntries(
      Object.entries(obj).map(([k, v]) => [k, v * 2])
    ) as Record<string, number>;
    

❌ 避免的做法

  1. 不要为了用而用

    // 不好:只是遍历打印,用 for...in 更简单
    Object.entries(obj).forEach(([k, v]) => console.log(k, v));
    
    // 好:简单场景用简单方法
    for (const key in obj) {
      console.log(key, obj[key]);
    }
    
  2. 不要过度嵌套

    // 不好:嵌套太深
    Object.entries(obj1).map(([k1, v1]) =>
      Object.entries(v1).map(([k2, v2]) =>
        Object.entries(v2).map(([k3, v3]) => ...)
      )
    )
    
    // 好:拆分或用递归
    function processNested(obj, level = 0) {
      return Object.entries(obj).map(([k, v]) => {
        if (typeof v === 'object') {
          return processNested(v, level + 1);
        }
        return [k, v];
      });
    }
    

速查表

最后,给你一个快速参考:

// 场景 1: 只需要键
Object.keys(obj).forEach(key => ...)
// ['key1', 'key2', ...]

// 场景 2: 只需要值
Object.values(obj).forEach(value => ...)
// [value1, value2, ...]

// 场景 3: 同时需要键和值
Object.entries(obj).forEach(([key, value]) => ...)
// [['key1', value1], ['key2', value2], ...]

// 场景 4: 对象 → 数组
Object.entries(obj).map(([k, v]) => ...)
// 转为其他格式

// 场景 5: 对象 → 对象
Object.fromEntries(
  Object.entries(obj).map(([k, v]) => [newKey, newValue])
)
// 键值都可能改变

// 场景 6: 对象 → 单个值
Object.entries(obj).reduce((acc, [k, v]) => acc + v, 0)
// 聚合计算

延伸与思考

相关 API 家族

Object.entries 是 Object 静态方法家族的一员:

// 环境: 浏览器 / Node.js
// 场景: Object 静态方法总览

const obj = {
  name: 'Alice',
  age: 25
};

// 常用方法
Object.keys(obj);           // ['name', 'age']
Object.values(obj);         // ['Alice', 25]
Object.entries(obj);        // [['name', 'Alice'], ['age', 25]]
Object.fromEntries([...]);  // 数组转对象

// 其他有用的方法
Object.assign({}, obj);     // 浅拷贝
Object.freeze(obj);         // 冻结对象
Object.seal(obj);           // 密封对象

// 属性相关
Object.getOwnPropertyNames(obj);    // 包括不可枚举属性
Object.getOwnPropertySymbols(obj);  // 获取 Symbol 键
Object.getOwnPropertyDescriptors(obj); // 属性描述符

浏览器兼容性

  • Object.entries: ES2017(现代浏览器都支持)
  • Object.fromEntries: ES2019(稍新,但也广泛支持)

兼容性检查

  • Chrome 54+
  • Firefox 47+
  • Safari 10.1+
  • Edge 14+
  • Node.js 7.0+

TypeScript 类型推导

TypeScript 中 Object.entries 的类型推导比较宽泛:

// 环境: TypeScript
// 场景: 类型推导

const obj = { name: 'Alice', age: 25 };

// Object.entries 的类型
const entries = Object.entries(obj);
// type: [string, string | number][]

// 问题:类型不够精确
entries.forEach(([key, value]) => {
  // key 的类型是 string,不是 'name' | 'age'
  // value 的类型是 string | number,不是具体的类型
});

// 如果需要更精确的类型,可以自定义
type Entries<T> = {
  [K in keyof T]: [K, T[K]]
}[keyof T][];

function getEntries<T extends object>(obj: T): Entries<T> {
  return Object.entries(obj) as any;
}

const preciseEntries = getEntries(obj);
// type: ['name', string] | ['age', number]

与 Map 的对比

Map 也有 entries() 方法,但和 Object.entries 不同:

// 环境: 浏览器 / Node.js
// 场景: Object vs Map

// Object
const obj = { a: 1, b: 2 };
Object.entries(obj);  // [['a', 1], ['b', 2]]

// Map
const map = new Map([['a', 1], ['b', 2]]);
map.entries();  // MapIterator { ['a', 1], ['b', 2] }
Array.from(map.entries());  // [['a', 1], ['b', 2]]

// 或者直接遍历
for (const [key, value] of map) {
  console.log(key, value);
}

何时用 Object,何时用 Map?

场景 推荐 原因
简单的键值对 Object 语法简洁
需要频繁增删 Map 性能更好
键不是字符串 Map Object 键只能是字符串/Symbol
需要保持插入顺序 Map 更可靠(虽然现代 Object 也保持顺序)
JSON 序列化 Object Map 不能直接序列化

未解的疑问

在学习过程中,我还有一些疑问:

  1. 为什么 Object.entries 不保证顺序?

    • 实际上现代 JavaScript 引擎都会保持插入顺序
    • 但规范没有强制要求(为了兼容旧代码)
  2. 处理嵌套对象的最佳实践?

    • 递归处理?
    • 用第三方库(如 lodash)?
    • 有没有更优雅的方案?
  3. 大对象的性能优化?

    • 什么时候应该考虑用 Worker?
    • 分批处理的策略?

这些问题还需要继续探索。如果你有经验或见解,欢迎交流。

小结

经过这次深入学习,我对 Object.entries 有了全新的认识。

核心要点回顾

1. Object.entries 是什么

  • 把对象转为 [key, value] 数组
  • 是对象和数组方法之间的桥梁
  • 配合 Object.fromEntries 可以优雅地转换对象

2. 什么时候用

  • 同时需要键和值
  • 需要用数组方法处理对象(map/filter/reduce)
  • 对象到对象的转换
  • 对象到数组的转换

3. 如何用好

  • 结合 Object.fromEntries 实现对象转换
  • 配合 map/filter/reduce 处理数据
  • 优先考虑可读性,不要炫技
  • 注意性能场景,但不要过早优化

4. 与 reduce 的关系

  • 都代表"转换思维"
  • 可以完美组合使用
  • Object.entries 把对象变成可处理的形态,reduce 执行转换逻辑

一句话总结

Object.entries 让对象操作像数组一样优雅,是连接对象世界和数组方法的桥梁。

记住这个黄金模式

// 对象 → 对象的转换
Object.fromEntries(
  Object.entries(obj)
    .filter(...)
    .map(...)
)

// 对象 → 单个值的聚合
Object.entries(obj)
  .reduce((acc, [k, v]) => ..., initial)

// 对象 → 数组
Object.entries(obj)
  .map(([k, v]) => ...)

从刷题到实践

这次从 LeetCode 2822 这道题出发,我不仅学会了如何用 Object.entries 解题,更重要的是理解了:

  1. 一行流 ≠ 好代码:可读性永远优先
  2. 理解比记忆重要:知道为什么,才能灵活运用
  3. 工具有边界:了解性能特征,在合适的场景使用
  4. 组合的力量Object.entries + reduce + fromEntries 可以优雅地处理复杂转换

下次遇到对象处理的问题,我会先问自己:

需要同时访问键和值吗?

需要用数组方法吗?

是在做数据转换吗?

如果答案是"是",那就用 Object.entries!

参考资料

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

2026年3月7日 17:18

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

你写过 lodash.get(obj, 'a.b.c') 吧?

好用是好用,但类型呢?any。改错路径了?运行时才炸。IDE 提示?不存在的。

import _ from 'lodash'

const config = {
  db: {
    mysql: {
      host: '127.0.0.1',
      port: 3306
    }
  }
}

// 类型是 any,拼错了也不报错
const host = _.get(config, 'db.mysql.hosst') // typo,运行时拿到 undefined

上周重构一个配置中心的读取逻辑,类似的问题搞得我很烦——几十个嵌套配置项,字符串路径满天飞,改个字段名要全局搜索替换,还不一定搜得全。

后来花了一下午,用 TypeScript 的模板字面量类型加 infer,搓了一个类型安全的 get 工具类型。路径拼错直接红线,返回值类型自动推导。这篇就来聊聊怎么一步步实现这个东西。

先搞清楚要做什么

目标很明确:实现一个 DeepGet<T, Path> 类型,给定一个对象类型 T 和一个字符串路径 Path,自动推导出对应的值类型。

type Config = {
  db: {
    mysql: {
      host: string
      port: number
    }
    redis: {
      host: string
      port: number
      cluster: boolean
    }
  }
  app: {
    name: string
    version: number
  }
}

// 期望效果:
type A = DeepGet<Config, 'db.mysql.host'>    // string
type B = DeepGet<Config, 'db.redis.cluster'> // boolean
type C = DeepGet<Config, 'app.version'>      // number
type D = DeepGet<Config, 'db.mysql.oops'>    // never 或 编译报错

看着不复杂?往下看。

infer 到底在干嘛

infer 这个关键字,很多人用过但没细想它的工作方式。它只能出现在条件类型的 extends 子句里,作用就一个:让 TypeScript 自己去"猜"某个位置的类型,然后把猜出来的结果绑定到一个类型变量上。

// 最经典的例子:提取函数返回值类型
type ReturnOf<T> = T extends (...args: any[]) => infer R
  ? R    // R 就是 TS 推导出来的返回值类型
  : never

type A = ReturnOf<() => string>      // string
type B = ReturnOf<(x: number) => boolean> // boolean

你可以把 infer R 理解成一个"占位符"——告诉 TS:"这个位置有个类型,你帮我推出来,推出来之后我叫它 R。"

这个能力用在模板字面量类型上,就很有意思了。

// 把 'a.b.c' 拆成 'a' 和 'b.c'
type Split<S> = S extends `${infer Head}.${infer Tail}`
  ? { head: Head; tail: Tail }
  : { head: S; tail: never }

type X = Split<'db.mysql.host'>
// { head: 'db'; tail: 'mysql.host' }

type Y = Split<'name'>
// { head: 'name'; tail: never }

infer Head 匹配第一个 . 前面的部分,infer Tail 匹配后面所有的。TS 的模板字面量推导是贪婪匹配的——Head 会尽量短,Tail 拿剩下的。

拿到这两个能力,就可以开始拼了。

第一版:递归拆路径 + 逐层索引

思路很直接:把路径字符串按 . 拆开,每次取第一段去索引对象类型,剩下的递归处理。

type DeepGet<T, Path extends string> =
  // 尝试按 '.' 拆分路径
  Path extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? DeepGet<T[Key], Rest>  // 取出当前层,剩余路径继续递归
      : never                  // Key 不是 T 的属性 → 路径无效
    // 没有 '.' 了,说明是最后一段
    : Path extends keyof T
      ? T[Path]               // 直接取值类型
      : never                 // 最后一段也对不上 → 路径无效

试一下:

type R1 = DeepGet<Config, 'db.mysql.host'>   // string ✅
type R2 = DeepGet<Config, 'app.name'>        // string ✅
type R3 = DeepGet<Config, 'db.mysql'>        // { host: string; port: number } ✅
type R4 = DeepGet<Config, 'db.mysql.oops'>   // never ✅

15 行不到,核心功能就出来了。但这只是个半成品。

生成所有合法路径

光有 DeepGet 还不够。用的时候 Path 传什么全靠手写,拼错了只会拿到 never,IDE 也不会提示你有哪些合法路径。

得再写一个类型:给定对象类型 T,自动生成所有合法的点分路径联合类型。

type DeepPaths<T> = T extends object
  ? {
      // 遍历 T 的每个 key
      [K in keyof T & string]: T[K] extends object
        ? K | `${K}.${DeepPaths<T[K]>}`  // 对象类型:当前 key + 递归子路径
        : K                               // 非对象类型:只有当前 key
    }[keyof T & string] // 把所有 key 对应的路径收集成联合类型
  : never

type AllPaths = DeepPaths<Config>
// 'db' | 'db.mysql' | 'db.mysql.host' | 'db.mysql.port'
// | 'db.redis' | 'db.redis.host' | 'db.redis.port' | 'db.redis.cluster'
// | 'app' | 'app.name' | 'app.version'

& string 是因为 keyof 可能返回 symbol | number,路径拼接只要 string 类型的 key。

现在把两个拼一起:

function deepGet<T extends object, P extends DeepPaths<T>>(
  obj: T,
  path: P
): DeepGet<T, P> {
  return path.split('.').reduce((acc: any, key) => acc?.[key], obj)
}

const config: Config = { /* ... */ }

// IDE 自动补全所有合法路径 🎉
const host = deepGet(config, 'db.mysql.host')   // 类型:string
const port = deepGet(config, 'db.mysql.port')   // 类型:number
// deepGet(config, 'db.mysql.oops')             // ❌ 编译报错,'oops' 不在合法路径里

到这就基本能用了。但真实项目里,对象类型没这么规矩。

处理数组和可选属性

真实的业务类型长这样:

type FormConfig = {
  fields: {
    name: string
    rules?: {          // 可选属性
      required: boolean
      message: string
    }
    children: FormConfig[] // 数组 + 递归结构
  }[]
}

第一版 DeepGet 对数组和可选类型直接歇菜。得加两个处理。

type DeepGet<T, Path extends string> =
  Path extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? DeepGet<NonNullable<T[Key]>, Rest> // NonNullable 处理可选属性的 undefined
      : Key extends `${number}`            // 处理数组索引,如 '0', '1'
        ? T extends (infer Item)[]
          ? DeepGet<Item, Rest>
          : never
        : never
    : Path extends keyof T
      ? NonNullable<T[Path]>
      : Path extends `${number}`
        ? T extends (infer Item)[]
          ? Item
          : never
        : never

NonNullableundefined 去掉——可选属性 rules? 的类型是 { required: boolean; message: string } | undefined,不去掉的话后续递归会出问题。

数组的处理方式是判断 Key 是不是数字字面量(${number}),如果是就用 infer 提取数组元素类型。

说实话这段代码已经开始不太好读了。这也是类型体操的通病——写的时候觉得很巧妙,两周后回来看,自己都得想半天。

递归深度限制

TypeScript 对类型递归有深度限制,大约 45~50 层左右就会报 "Type instantiation is excessively deep and possibly infinite"。

正常业务对象嵌套个三五层,完全够用。但如果你的类型是递归定义的(比如树形结构),DeepPaths 会无限展开,直接报错。

// 这种类型会让 DeepPaths 炸掉
type TreeNode = {
  value: string
  children: TreeNode[] // 递归引用
}

// type Paths = DeepPaths<TreeNode>
// ❌ Type instantiation is excessively deep

解法是给递归加一个深度计数器:

// 用元组长度模拟计数器
type DeepPaths<T, Depth extends any[] = []> =
  Depth['length'] extends 5  // 最多递归 5 层
    ? never
    : T extends object
      ? {
          [K in keyof T & string]: T[K] extends object
            ? K | `${K}.${DeepPaths<T[K], [...Depth, any]>}`
            : K
        }[keyof T & string]
      : never

Depth 是一个元组,每递归一层就往里塞一个 any,用 Depth['length'] 判断当前深度。这是 TS 类型体操里模拟"计数"的标准套路——因为类型层面没有数字运算,只能用元组长度凑。

5 层够不够?大部分配置类对象绰绰有余。如果你的数据嵌套超过 5 层,可能得先反思一下数据结构设计。

实际项目里怎么用

光有类型不够,得包一层运行时。我在项目里最终封装成了这样:

// 完整的 typedGet 工具函数
function typedGet<
  T extends Record<string, any>,
  P extends DeepPaths<T>
>(obj: T, path: P): DeepGet<T, P> {
  const keys = (path as string).split('.')
  let result: any = obj
  for (const key of keys) {
    result = result?.[key]
    if (result === undefined) return undefined as any
  }
  return result
}

// 配合 zod 做配置校验的场景
import { z } from 'zod'

const configSchema = z.object({
  database: z.object({
    primary: z.object({
      host: z.string(),
      port: z.number(),
      pool: z.object({
        min: z.number(),
        max: z.number(),
      })
    })
  })
})

type AppConfig = z.infer<typeof configSchema>

// 读取配置的地方,路径全部有类型保护
function getDbPool(config: AppConfig) {
  const max = typedGet(config, 'database.primary.pool.max') // number
  const host = typedGet(config, 'database.primary.host')    // string
  // typedGet(config, 'database.primary.pool.timeout')
  // ❌ 编译错误:'timeout' 不存在
  return { max, host }
}

最大的收益是重构的时候。改个字段名,所有用到这个路径的地方全部标红。之前用 lodash.get 配合字符串路径,全靠全局搜索和祈祷。

几个设计上的权衡

要不要支持数组下标语法 a[0].b

我最终没做。原因是 a.0.ba[0].b 功能一样,但后者的模板字面量匹配要复杂不少,得额外处理方括号。投入产出比不高,团队内约定用点号就行。

DeepPaths 生成的联合类型会不会太大?

会。如果对象有 20 个叶子节点,DeepPaths 会生成 20 多个字符串字面量的联合类型。类型体量大了,IDE 补全会慢。实测下来,50 个路径以内体感还行,超过 100 个就明显卡了。

碰到这种情况,可以拆模块——别把整个全局配置丢进去,按模块分别定义类型。

lodash.get 的类型定义比呢?

@types/lodashget 的类型定义其实也做了路径推导,但它是通过重载实现的,最多支持 4 层深度。超过 4 层就退化成 any。我这个方案用递归条件类型,深度上限更高,但代价是类型代码更复杂。

还有个坑:联合类型的属性

type Response =
  | { type: 'success'; data: { id: number } }
  | { type: 'error'; message: string }

// DeepPaths<Response> 会怎样?
// 'type' 是公共属性,没问题
// 'data' 只在 success 分支上,'message' 只在 error 分支上

当前实现对联合类型的处理比较粗暴——只能访问公共属性。如果要支持分支属性,得先做类型收窄(discriminated union narrowing),那就不是路径访问工具该管的事了。

这块我也没想到特别优雅的方案。如果有人有好思路,欢迎交流。

聊到这

infer 配合模板字面量类型和递归条件类型,能做的事远不止路径访问。类似的思路可以用来实现:

  • 路由参数提取('/user/:id/post/:postId'{ id: string; postId: string }
  • SQL 查询字段类型推导
  • 事件名到回调类型的映射

但类型体操的度要把握好。我个人的标准是:如果一个工具类型写完,团队里其他人看 10 分钟看不懂,那就得简化,或者至少加够注释。类型系统是用来帮人的,不是用来炫技的。

话说回来,TypeScript 的类型系统已经被证明是图灵完备的。有人用它实现过四则运算器,甚至有人搓了个国际象棋。但那些就纯属 for fun 了——生产代码里这么写,code review 估计会被打。

JavaScript 面向对象编程全景指南:从原始字面量到原型链的终极进化

作者 Lee川
2026年3月7日 16:53

JavaScript 面向对象编程全景指南:从原始字面量到原型链的终极进化

在编程语言的浩瀚星海中,JavaScript 无疑是一颗独特而耀眼的星辰。它既不像 Java 那样拥有严谨的类结构,也不像 Python 那样直观易懂,但它却以一种灵活多变、甚至略带“野性”的方式,构建了整个现代 Web 的基石。从早期的静态网页交互,到如今支撑起庞大的单页应用(SPA)、服务端渲染(SSR)乃至跨平台移动开发,JavaScript 的演进史就是一部前端技术的进化史。

而在 JavaScript 的核心深处,隐藏着一套独特而强大的面向对象编程(OOP)机制。今天,我们将基于详实的代码文档与教学记录,深入探索这一机制的全貌。这不仅是一次语法的回顾,更是一场从混沌走向秩序、从孤立走向关联的进化史诗。我们将见证从简单的对象字面量,到构造函数的封装,再到原型链继承的终极奥秘,彻底揭开 JavaScript“基于原型”的灵魂面纱。本文将详尽剖析每一个阶段的代码实现、内存模型、设计哲学以及底层原理,力求为读者呈现一份深度技术指南。


第一章:蛮荒时代——对象字面量的原始模式与孤立困境

1.1 初始的尝试:白纸上的涂鸦

一切始于简单。在 JavaScript 诞生的初期,或者说在开发者尚未形成系统化面向对象思维的阶段,创建对象最直接的方式就是对象字面量(Object Literal)。这种方式如同在白纸上直接画出一个个独立的个体,直观、快速且无需任何前置定义。

// 这里的 Cat 大写,是开发者的约定俗成,暗示它是一个“类”或模板
// name 和 color 是模板属性,体现了初步的抽象和封装意识
var Cat = {
    name: "",
    color: ""
};

// 创建第一个实例
var cat1 = {}; // 创建一个空对象
cat1.name = '加菲猫';
cat1.color = '橘色';

// 创建第二个实例
var cat2 = {};
cat2.name = '黑猫';
cat2.color = '黑色';

在这种模式下,Cat 对象仅仅作为一个参考模板存在,它本身并不具备创建新对象的能力。开发者需要手动创建空对象 {},然后逐一赋值。

1.2 模式的困境:孤岛的代价

随着项目规模的扩大,这种原始模式的弊端迅速暴露,成为了代码维护的噩梦:

  1. 代码冗余与重复劳动:每创建一个新对象,开发者都要重复编写相同的属性赋值代码。如果有十个属性,就要写十行赋值语句;如果要创建一百个猫对象,就要重复一百次。这不仅效率低下,而且极易出错。
  2. 缺乏类型关联与身份认同cat1cat2 在内存中是完全孤立的岛屿。JavaScript 引擎无法识别它们属于同一个“类别”。如果你问引擎 "cat1Cat 吗?”,它会毫不犹豫地回答“不是”,因为 cat1 的构造函数是 Object,而不是 Cat。这种缺乏类型系统的状态,使得代码的多态性和可扩展性几乎为零。
  3. 方法定义的灾难:如果我们需要给猫添加一个“叫”的方法,在字面量模式下,我们必须在每个对象中单独定义:
    cat1.sayHi = function() { console.log("喵~"); };
    cat2.sayHi = function() { console.log("喵~"); };
    
    这意味着,每创建一个实例,内存中就会多出一份完全相同的函数副本。对于成千上万个实例来说,这是对内存资源的极大浪费。

我们需要一种机制,能够将对象的“模板”与“实例”紧密联系起来,让代码具备复用性、封装性和多态性。于是,构造函数应运而生,开启了 JavaScript 面向对象的启蒙运动。


第二章:启蒙运动——构造函数与实例化的诞生

2.1 封装实例化过程:从散沙到蓝图

为了解决对象孤立的问题,JavaScript 引入了**构造函数(Constructor Function)**的概念。构造函数本质上是一个普通的函数,但通过特定的命名规范(首字母大写)和调用方式(配合 new 关键字),它被赋予了创建对象的特殊使命。

function Cat(name, color) {
    // 此时 this 指向谁?这取决于函数是如何被调用的
    // 如果以 new 的方式运行,this 指向新创建的空对象
    console.log(this); 
    this.name = name;  // 将参数赋值给实例属性
    this.color = color;
    // 隐式返回 this
}

2.2 new 关键字的魔法:四步创世记

当使用 new 关键字调用函数时,JavaScript 引擎内部发生了一系列精密而神奇的操作。理解这四步,是掌握 JavaScript OOP 的关键:

  1. 创建空对象(Creation):引擎首先在内存中创建一个全新的空对象。这个对象最初没有任何属性,它的原型默认指向 Object.prototype
  2. 绑定 this(Binding):引擎将该函数内部的 this 关键字强制绑定到这个新创建的对象上。从此,函数内部所有的 this.xxx 操作,实际上都是在操作这个新对象。
  3. 执行代码(Execution):引擎执行函数体中的代码。在这个阶段,开发者编写的属性赋值逻辑(如 this.name = name)被执行,新对象被填充了具体的数据。
  4. 返回实例(Return):除非函数内部显式返回了一个对象,否则引擎会隐式地返回这个新创建并填充好的对象。
const cat1 = new Cat("加菲猫", "橘色"); 
const cat2 = new Cat("黑猫警长", "黑色");

警示:如果忘记使用 new,直接调用 Cat("黑猫警长", "黑色"),函数内部的 this 将指向全局对象(在浏览器中是 window,在 Node.js 中是 global)。这不仅导致无法返回预期的实例对象,还会污染全局作用域,引发难以追踪的 Bug。

2.3 建立身份认同:constructor 与 instanceof

通过构造函数创建的对象,终于建立了彼此之间的联系,形成了真正的“类”的概念:

  • constructor 属性:每个实例对象都自动拥有一个 constructor 属性,它指向创建该对象的构造函数。

    console.log(cat1.constructor === Cat); // true
    console.log(cat1.constructor === cat2.constructor); // true
    

    这证明了 cat1cat2 拥有共同的“父亲”。

  • instanceof 操作符:这是检测对象类型的利器。它用于判断一个对象是否属于某个构造函数的实例。其原理是检查构造函数的 prototype 属性是否存在于对象的原型链上。

    console.log(cat1 instanceof Cat); // true
    console.log(cat1 instanceof Object); // true (因为 Cat 也是对象)
    

然而,构造函数虽然解决了属性和类型的问题,却依然没有解决方法共享的难题。如果在构造函数内部定义方法,依然会导致内存浪费。

function Cat(name, color) {
    this.name = name;
    this.color = color;
    // 错误示范:每次 new 都会创建一个新的函数实例
    this.eat = function() {
        console.log("eat jerry");
    };
}

为了解决这个问题,JavaScript 祭出了其最核心的武器——原型(Prototype)


第三章:黄金时代——原型模式与共享智慧

3.1 原型的引入:对象继承对象

JavaScript 最独特的魅力在于其**基于原型(Prototype-based)**的继承机制。不同于 Java、C# 等传统面向对象语言的“类继承”(Class-based Inheritance),JavaScript 采用的是“对象继承对象”。

每个构造函数都有一个特殊的属性叫做 prototype,它是一个对象。所有通过该构造函数创建的实例,都会共享这个 prototype 对象。我们可以将不变的属性和公用方法放到构造函数的 prototype 对象上。

function Cat(name, color) {
    this.name = name;
    this.color = color;
    // 注意:这里不再定义 type 和 eat,而是交给原型
}

// 把不变的属性和公用方法,都放到原型对象上
Cat.prototype.type = "猫科动物";
Cat.prototype.eat = function() {
    console.log("eat jerry");
};

3.2 内存优化与动态共享

这种设计带来了巨大的优势:

  • 内存节省:无论创建多少个 Cat 实例,eat 方法在内存中只存在一份,所有实例共享同一个函数引用。

  • 动态性:原型是动态的。如果在创建实例后修改了原型上的属性或方法,所有实例(包括已经创建的)都能立即反映出这种变化。

    const cat1 = new Cat("Tom", "蓝色");
    const cat2 = new Cat("Jerry", "灰色");
    
    console.log(cat1.type, cat2.type); // "猫科动物" "猫科动物"
    
    // 动态修改原型
    Cat.prototype.type = "变异猫科";
    console.log(cat1.type, cat2.type); // "变异猫科" "变异猫科"
    
  • 属性遮蔽(Shadowing):如果实例自身定义了与原型同名的属性,实例自身的属性会优先被访问,这被称为“属性遮蔽”。

    cat1.type = "铲屎官的主人"; // 在 cat1 自身添加属性
    console.log(cat1.type); // "铲屎官的主人" (访问自身)
    console.log(cat2.type); // "变异猫科" (访问原型)
    

3.3 属性的探测工具集

为了精确控制属性的归属,JavaScript 提供了一套完善的探测工具:

  • hasOwnProperty(key):判断某个属性是否属于对象“自身”,而不包括原型链。
    console.log(cat1.hasOwnProperty("type")); // false (在原型上)
    console.log(cat1.hasOwnProperty("name")); // true (在自身上)
    
  • in 操作符:检查属性是否存在于整个原型链中(包括自身和所有层级的原型)。
    console.log("name" in cat1); // true
    console.log("type" in cat1); // true
    console.log("toString" in cat1); // true (来自 Object.prototype)
    
  • isPrototypeOf(obj):判断某个对象是否存在于另一个对象的原型链上。
    console.log(Cat.prototype.isPrototypeOf(cat1)); // true
    
  • for...in 循环:遍历对象时,会自动遍历到自身可枚举属性以及原型链上的所有可枚举属性。通常配合 hasOwnProperty 使用,以过滤掉原型属性。

第四章:融合与升华——组合继承与原型链的奥秘

4.1 继承的挑战:单一模式的局限

随着业务逻辑的复杂化,我们需要让一个类继承另一个类的特性。例如,让 Cat 继承 Animal。早期的开发者尝试了多种方法,但都发现了缺陷:

  • 借用构造函数(Call/Apply)

    function Animal() { this.species = '动物'; }
    function Cat() { Animal.apply(this); }
    

    缺点:只能继承父类的实例属性(如 species),无法继承父类定义在 prototype 上的方法。因为 apply 只是执行了一次函数,并没有建立原型链接。

  • 原型链继承

    function Cat() {}
    Cat.prototype = new Animal();
    

    缺点:虽然能继承方法,但父类构造函数中的引用类型属性(如数组、对象)会被所有子类实例共享。修改一个实例的属性,会影响其他所有实例。

4.2 组合继承:取长补短的终极方案

为了解决上述矛盾,组合继承(Combination Inheritance) 成为了最经典、最实用的继承模式。它结合了前两种方式的优点:

  1. 借用构造函数继承属性:在子类构造函数中调用父类构造函数,确保每个子类实例拥有独立的属性副本。
  2. 原型链继承方法:将子类的原型指向父类的一个实例,从而让子类实例能够通过原型链访问到父类的方法。
// 父类
function Animal() {
    this.species = '动物';
    this.friends = ['狗', '鸟']; // 引用类型属性
}
Animal.prototype.sayHi = function() {
    console.log('啦啦啦啦');
};

// 子类
function Cat(name, color) {
    // 1. 继承属性:调用父类构造函数,this 指向当前 cat 实例
    // 这样每个 cat 都有自己独立的 species 和 friends 数组
    Animal.apply(this); 
    this.name = name;
    this.color = color;
}

// 2. 继承方法:将 Cat 的原型指向 Animal 的实例
// 这一步建立了原型链,使得 cat 可以访问 sayHi
Cat.prototype = new Animal();

// 修正 constructor 指向(可选但推荐)
// 因为上一步重写了 prototype,constructor 指向了 Animal,需改回 Cat
Cat.prototype.constructor = Cat;

4.3 原型链:通往智慧的桥梁

为什么加上 Cat.prototype = new Animal() 后,cat 就能调用 sayHi 了?这背后是**原型链(Prototype Chain)**在起作用。

当你访问 cat.sayHi 时,JavaScript 引擎启动了一场精彩的“寻根之旅”:

  1. 自查:检查 cat 对象自身有没有 sayHi?❌ 没有。
  2. 问父(原型):去 cat 的构造函数 Catprototype 对象上找。
    • 此时 Cat.prototype 是什么?它是 new Animal() 的结果,即一个 Animal 的实例。
    • 这个 Animal 实例身上有 sayHi 吗?❌ 没有(sayHiAnimal.prototype 上,不在实例身上)。
  3. 问祖(原型的原型):既然 Cat.prototype 是一个 Animal 实例,那么它的内部原型 __proto__ 自然指向 Animal.prototype
    • Animal.prototype 上找。✅ 找到了!sayHi 定义在这里。

于是形成了一条清晰的链条:

cat  -->  Cat.prototype (Animal 实例)  -->  Animal.prototype (包含 sayHi)  -->  Object.prototype  -->  null

这条链条打破了对象的孤岛效应,让知识和能力得以在对象间传递和共享。尽管文档中提到早期的继承方式“不好理解”,但一旦掌握了原型链的精髓,你会发现这是一种极其优雅且强大的设计。


第五章:现代纪元——ES6 Class 语法糖与底层真相

5.1 语法的革新:更像“类”的写法

时光流转到了 ES6(ECMAScript 2015)时代,JavaScript 终于迎来了 class 关键字。这让习惯了 Java、C# 等传统面向对象语言的开发者能更平滑地过渡到 JavaScript 的世界。

class Animal {
    constructor() {
        this.species = '动物';
    }
    sayHi() {
        console.log('啦啦啦啦');
    }
}

class Cat extends Animal {
    constructor(name, color) {
        super(); // 调用父类构造函数,等价于 Animal.apply(this)
        this.name = name;
        this.color = color;
    }
    
    eat() {
        console.log("eat jerry");
    }
}

const cat1 = new Cat('tom', '蓝色');
cat1.sayHi(); // 输出:啦啦啦啦

代码变得如此整洁、语义清晰。extends 关键字直观地表达了继承关系,super 关键字简化了父类调用。

5.2 本质未变:糖衣下的原型灵魂

然而,必须清醒地认识到:class 仅仅是语法糖(Syntax Sugar)。剥开这层华丽的外衣,其底层依然是我们前面探讨的原型机制在运作。JavaScript 引擎在执行 class 代码时,依然是在操作构造函数和原型链。

我们可以通过控制台打印来验证这一点:

console.group("Cat 原型链深度分析");
console.log("1. cat1.__proto__:", cat1.__proto__); 
// 输出: Cat.prototype { eat: [Function], constructor: [class Cat] }
// 证明:实例的原型指向类的 prototype

console.log("2. Cat.prototype.__proto__:", Cat.prototype.__proto__); 
// 输出: Animal.prototype { sayHi: [Function], constructor: [class Animal] }
// 证明:extends 实现了原型链的连接

console.log("3. 原型链终点:", cat1.__proto__.__proto__.__proto__); 
// 输出: null
console.groupEnd();

无论语法如何变迁,cat1.__proto__ 依然指向 Cat.prototype,而 Cat.prototype.__proto__ 依然指向 Animal.prototype。JavaScript 的核心灵魂——原型链,从未改变。ES6 的 class 只是让代码更易读、更易维护,并没有引入新的底层机制。


结语:掌握 JavaScript 的灵魂

从简单的对象字面量到复杂的原型链继承,再到 ES6 的 Class 语法,JavaScript 的面向对象之路充满了探索与创新。

  • 对象字面量让我们看到了初始的简陋与孤立,是原型的起点。
  • 构造函数带来了实例化的规范与身份认同,解决了批量创建的问题。
  • 原型模式解决了内存浪费与共享难题,体现了“对象继承对象”的独特哲学。
  • 组合继承原型链实现了属性与方法的完美传承,构建了复杂的对象关系网。
  • ES6 Class 则披上了现代语法的外衣,让代码更符合人类直觉,但内核依旧坚韧。

虽然 JavaScript 早期没有 class 关键字,甚至至今仍被称作“基于对象”的语言,但这并不妨碍它成为一门真正的面向对象编程语言。理解这一机制,不仅有助于我们写出更高效、更健壮的代码,更能让我们深刻体会到 JavaScript 设计的哲学:灵活、动态、万物皆对象

在这个前端技术日新月异的时代,框架层出不穷(React, Vue, Angular, Svelte),工具链不断迭代。但无论上层建筑如何变迁,这些核心概念始终屹立不倒。掌握 JavaScript 的原型与继承原理,就如同掌握了开启 Web 开发大门的钥匙。它指引着我们在代码的海洋中乘风破浪,透过纷繁复杂的语法表象,直抵技术的本质,构建出更加精彩、健壮的应用世界。这不仅是技术的进化,更是思维的升华。

Tauri 应用安全从开发到发布的威胁防御指南

作者 HelloReader
2026年3月7日 16:52

一、安全的核心原则:木桶效应

Tauri 官方文档开篇就点明了一个关键原则:

你的应用安全性,由生命周期中最薄弱的环节决定。

这意味着即便你的运行时防护做得再好,如果开发机器被攻陷、依赖链被污染,或者 CI/CD 系统不可信,最终产物的安全性依然无从保证。因此,我们需要以全链路视角审视安全问题,而不是只盯着某一个环节。


二、开发阶段威胁

2.1 上游依赖风险(Supply Chain Attack)

供应链攻击是近年来增长最快的攻击向量之一。NPM 和 crates.io 上的第三方包,任何一个都可能成为攻击者的入口。

防御建议:

  • 使用 npm auditcargo audit 定期扫描已知漏洞
  • 优先从 Git 仓库以哈希版本命名 Tag方式引入关键依赖,而非浮动版本号
  • 借助 cargo-vetcargo crev 等工具进行依赖审计
  • 使用 cargo supply-chain 可视化依赖图谱,了解你的代码究竟"站在谁的肩膀上"
# 检查 npm 依赖漏洞
npm audit

# 检查 Rust 依赖漏洞
cargo audit

# 查看供应链依赖
cargo supply-chain

2.2 开发服务器安全

Tauri 前端通常通过 Web 框架的开发服务器提供热重载能力。默认情况下,这个连接既不加密,也没有认证,这意味着:

  • 同一局域网内的攻击者可以监听前端资源
  • 攻击者甚至可以向你的开发设备推送恶意前端代码

防御建议:

  • 只在可信网络环境下进行开发
  • 在不可信网络(如咖啡馆、会议室 WiFi)中,必须为开发服务器配置双向 TLS(mTLS)

⚠️ 注意:Tauri 内置开发服务器目前尚不支持 mTLS,请勿在不可信网络中使用。

2.3 开发机器加固

开发机器本身也是攻击面。以下是一些通用的加固建议:

  • 日常编码等工作不要使用管理员账户
  • 开发机器上不要存放生产环境密钥
  • 使用硬件安全令牌(如 YubiKey)降低账户被盗风险
  • 最小化安装原则:只安装必要的应用程序
  • 保持系统和工具链持续更新

2.4 源代码版本控制安全

确保代码仓库的访问控制配置正确,防止未授权修改。同时,建议要求所有常规贡献者对提交进行签名(GPG Sign) ,避免恶意提交被伪装成合法贡献者的名义。


三、构建阶段威胁

现代工程团队普遍使用 CI/CD 系统自动化构建流程。然而,这些远程构建系统(通常由第三方托管)拥有对源码、密钥的完整访问权,且你无法从外部验证构建产物与本地代码是否完全一致。

防御建议:

  • 使用可信赖的 CI/CD 提供商,或自托管在受控硬件上
  • 对 CI 流程中使用的第三方 Action / 插件,必须锁定版本(使用 commit hash 而非浮动 tag)
  • 对发布产物进行代码签名,让用户能验证软件来源
  • 将加密密钥存储在硬件令牌中,即便构建系统被攻陷,也无法泄露签名私钥

3.1 可重现构建(Reproducible Builds)

理想情况下,可重现构建能让你验证 CI 产出的二进制与本地构建完全一致,从而检测构建时注入的后门。

然而现实是:Rust 默认并不保证完全可重现的构建(存在已知 bug),许多前端打包工具同样如此。

这意味着目前你仍需要充分信任你的构建系统,在此之上才能谈其他安全措施。这是当前整个生态的一个客观局限,值得持续关注。


四、发布与分发阶段威胁

Tauri 提供了相对完善的热更新机制。但如果你失去了对以下任一系统的控制:

  • Manifest 服务器(更新清单)
  • 构建服务器
  • 二进制文件托管服务

那么攻击者就可以向你的用户推送恶意更新,一切防护形同虚设。

防御建议:

  • 如果自建分发系统,务必咨询专业运维架构师,从设计层面保证安全性
  • 可以考虑使用 Tauri 官方合作伙伴 CrabNebula Cloud 提供的分发解决方案

五、运行时威胁

Tauri 的设计哲学是:假设 WebView 是不安全的

基于这一前提,Tauri 实现了多层防护机制:

  • 内容安全策略(CSP) :限制 WebView 可发起的通信类型,防止 XSS 等注入攻击
  • 能力系统(Capabilities) :细粒度控制 WebView 中的脚本对系统 API 的访问权限,不可信内容和脚本无法调用敏感接口

最佳实践:

// tauri.conf.json - 最小化权限原则示例
{
  "security": {
    "csp": "default-src 'self'; script-src 'self'"
  }
}

此外,建议参考 Tauri 官方的漏洞报告流程,为你自己的应用也建立一套易于使用且安全的漏洞披露机制


六、安全生命周期总览

阶段 主要威胁 关键措施
开发前 上游依赖污染 cargo audit、依赖审计、锁定版本
开发中 开发服务器暴露、机器被攻陷 可信网络、mTLS、最小权限账户
构建时 CI/CD 被篡改、后门注入 锁定 Action 版本、代码签名、硬件密钥
分发时 更新劫持 保护分发基础设施、使用可信分发平台
运行时 WebView 注入 CSP、Capabilities 最小权限

七、总结

Tauri 提供了出色的安全基础设施,但安全最终是开发者、框架和用户三方共同的责任

作为开发者,你需要:

  1. 将安全意识融入每一个开发决策
  2. 定期审计依赖,保持工具链更新
  3. 信任但验证你的构建系统
  4. 遵循最小权限原则配置运行时能力
  5. 建立漏洞响应机制

安全没有终点,只有持续的演进与防御。希望本文能帮助你在 Tauri 应用的整个生命周期中建立起更稳固的安全体系。


参考资料:Tauri 官方安全文档

2026年CSS彻底疯了:这6个新特性让我删掉了三分之一JS代码

2026年3月7日 16:39

引言:CSS的范式革命

2026年3月,CSS正经历一场颠覆性的范式革命。这个曾被视为"样式表"的工具,如今已蜕变为具备编程能力的"全栈语言"。从自定义函数到边框形状定义,从属性值读取到滚动状态检测,CSS正在突破传统边界。这场革命让前端开发者重新审视CSS的潜力——它不再是单纯的样式描述工具,而是能够承载逻辑判断、状态感知和动态交互的编程语言。

特性一:自定义CSS函数——终于能写逻辑了

语法解析

CSS 2026引入的@function语法,允许开发者定义可复用的计算逻辑。其基本结构为:

@function calcPadding($base, $factor) {
  @return calc($base * $factor);
}

该语法支持参数传递、条件判断和数学运算,彻底终结了Sass/SCSS的依赖。

实战案例

响应式内边距计算:

@function calcPadding($base, $factor) {
  @return calc($base * $factor);
}

.container {
  padding: calcPadding(1rem, 1.5);
  @media (max-width: 768px) {
    padding: calcPadding(1rem, 1);
  }
}

此方案替代了传统JS动态计算padding值的逻辑,实现纯CSS响应式布局。

优势分析

  • 无需依赖预处理器,直接在CSS中实现逻辑
  • 保持样式与逻辑的强关联性
  • 减少JS事件监听和DOM操作

特性二:border-shape属性——边框终于可以不是方的了

形状定义

通过shape()函数定义任意几何形状:

.shape {
  border-shape: shape(circle, 50%);
  border-color: red;
}

该属性支持路径定义、贝塞尔曲线和复杂多边形,彻底告别切图时代。

实战案例

动态形状切换:

.button {
  border-shape: shape(rectangle, 100px 50px);
  transition: border-shape 0.3s;
}

.button:hover {
  border-shape: shape(circle, 50%);
}

此方案替代了传统伪元素+渐变实现的复杂形状,实现动态形状切换。

优势分析

  • 告别切图和伪元素hack
  • 支持矢量图形直接渲染
  • 实现复杂交互效果的视觉呈现

特性三:attr()函数进化——属性值读取革命

功能突破

attr()函数现在支持类型自动识别:

.progress {
  width: attr(data-width, number);
  color: attr(data-color, color);
}

该特性可直接读取HTML属性值,自动识别数字、颜色、长度等类型。

实战案例

纯CSS实现进度条:

<div class="progress" data-width="75" data-color="#4CAF50"></div>
.progress {
  height: 20px;
  background: linear-gradient(to right, attr(data-color, color) 0%, transparent 100%);
  width: attr(data-width, number);
}

此方案替代了传统JS动态计算进度条宽度的逻辑,实现数据驱动的UI更新。

优势分析

  • 消除data-*属性同步需求
  • 实现数据与样式的一体化
  • 支持动态数据绑定的视觉呈现

特性四:滚动状态查询——粘性导航的CSS革命

状态检测

通过@container规则查询滚动状态:

@container scroll-state(stuck: top) {
  .nav {
    box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
  }
}

该特性可实时检测元素的滚动状态,实现动态样式调整。

实战案例

滚动提示实现:

@container scroll-state(rolling) {
  .scroll-indicator {
    background: rgba(0,0,0,0.3);
  }
}

@container scroll-state(stuck: top) {
  .scroll-indicator {
    background: rgba(0,0,0,0.7);
  }
}

此方案替代了传统JS监听滚动事件的实现,实现纯CSS的滚动状态感知。

优势分析

  • 实现无侵入式的滚动状态感知
  • 支持复杂的滚动交互逻辑
  • 降低滚动相关动画的实现复杂度

特性五:媒体元素伪类——视频状态样式化

状态绑定

新增的媒体元素伪类支持:

.video:playing {
  border-color: #FF5722;
}

.video:paused {
  border-color: #9E9E9E;
}

这些伪类可直接绑定视频播放/暂停/缓冲等状态。

实战案例

播放状态边框变化:

.video {
  border: 2px solid transparent;
  transition: border 0.3s;
}

.video:playing {
  border-color: #2196F3;
}

.video:paused {
  border-color: #9E9E9E;
}

此方案替代了传统JS监听播放状态的逻辑,实现视频状态的视觉反馈。

优势分析

  • 实现媒体元素状态的可视化绑定
  • 支持实时状态反馈的交互设计
  • 降低媒体控件的开发复杂度

特性六:容器查询与:has()选择器——父元素的觉醒

容器查询

@container规则实现组件级自适应:

@container (min-width: 768px) {
  .card {
    grid-template-columns: repeat(3, 1fr);
  }
}

该特性支持基于容器状态的样式调整。

:has()选择器

实现父元素基于子元素状态的样式化:

.parent:has(.child:playing) {
  background: #F5F5F5;
}

此选择器可直接检测子元素状态,实现动态样式调整。

实战案例

组件级自适应布局:

@container (max-width: 600px) {
  .grid {
    grid-template-columns: 1fr;
  }
}

@container (min-width: 768px) {
  .grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

此方案替代了传统媒体查询的实现,实现更精细的布局控制。

优势分析

  • 实现真正的组件级响应式设计
  • 支持复杂的父子状态联动
  • 提升布局的灵活性和可维护性

结语:CSS的范式跃迁

CSS 2026的六大特性,标志着前端开发进入"表现+轻量逻辑"的新纪元。这些新特性不仅让CSS具备了编程能力,更彻底改变了前端开发范式:

  1. 逻辑内联化:将业务逻辑直接嵌入样式层,实现更紧密的代码耦合
  2. 状态可视化:通过CSS实现复杂交互状态的可视化绑定
  3. 动态数据驱动:支持属性值的自动类型识别和动态绑定
  4. 无侵入式交互:实现无需JS的滚动状态感知和媒体控件交互

对于前端开发者而言,这意味着需要重新认识CSS的潜力。在保持样式描述能力的同时,CSS正逐步成为承载轻量逻辑的工具。这种转变不仅提升了开发效率,更推动了前端架构的进化——让样式、逻辑和交互实现更优雅的协同。当CSS能够自主处理更多业务逻辑时,我们终将见证一个更简洁、更高效的前端开发新时代。

WebAssembly实战指南:将高性能计算带入浏览器

作者 bluceli
2026年3月7日 15:35

引言

WebAssembly(简称Wasm)是一种新型的代码格式,可以在现代Web浏览器中运行。它为Web平台带来了接近原生的性能,使得C++、Rust等语言编写的代码能够在浏览器中高效运行。本文将深入探讨WebAssembly的8大核心特性,帮助你掌握这个将高性能计算带入浏览器的强大技术。

WebAssembly基础概念

1. 什么是WebAssembly

WebAssembly是一种二进制指令格式,专为高效的执行和紧凑的表示而设计。它不是要替代JavaScript,而是与JavaScript协同工作,让Web应用能够利用多种语言编写的高性能代码库。

// WebAssembly的主要特点
// 1. 二进制格式:体积小,加载快
// 2. 接近原生性能:执行速度快
// 3. 多语言支持:C/C++、Rust、Go等
// 4. 安全性:在沙箱环境中运行
// 5. 可移植性:跨平台兼容

2. WebAssembly与JavaScript的关系

// JavaScript和WebAssembly的互补关系
// JavaScript:适合UI交互、DOM操作、业务逻辑
// WebAssembly:适合计算密集型任务、图像处理、加密解密

// 典型的使用场景
const scenarios = {
  imageProcessing: '图像滤镜、视频编解码',
  gameDevelopment: '3D游戏引擎、物理模拟',
  dataProcessing: '大数据分析、机器学习推理',
  cryptography: '加密解密、哈希计算',
  scientificComputing: '数学计算、物理模拟'
};

编译和加载WebAssembly

3. 从C++编译到WebAssembly

使用Emscripten工具链将C++代码编译为WebAssembly。

// simple.cpp - C++源代码
#include <emscripten.h>

extern "C" {
  // 导出函数给JavaScript调用
  EMSCRIPTEN_KEEPALIVE
  int add(int a, int b) {
    return a + b;
  }
  
  EMSCRIPTEN_KEEPALIVE
  int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
  }
}
# 编译命令
emcc simple.cpp -o simple.js \
  -s EXPORTED_FUNCTIONS='["_add","_fibonacci"]' \
  -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' \
  optimiz

4. 在JavaScript中加载WebAssembly

// 方法1:使用Emscripten生成的胶水代码
const Module = require('./simple.js');

Module.onRuntimeInitialized = function() {
  // 调用C++函数
  const result = Module._add(10, 20);
  console.log('10 + 20 =', result);
  
  const fib = Module._fibonacci(10);
  console.log('fibonacci(10) =', fib);
};

// 方法2:直接加载.wasm文件
async function loadWebAssembly(url) {
  const response = await fetch(url);
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.instantiate(buffer);
  return module.instance.exports;
}

// 使用示例
loadWebAssembly('simple.wasm').then(exports => {
  console.log(exports.add(10, 20));
  console.log(exports.fibonacci(10));
});

JavaScript与WebAssembly交互

5. 数据类型转换

WebAssembly和JavaScript之间的数据类型需要正确转换。

// WebAssembly只支持有限的数值类型
// i32: 32位整数
// i64: 64位整数
// f32: 32位浮点数
// f64: 64位浮点数

// JavaScript与WebAssembly的交互
class WasmModule {
  constructor(module) {
    this.exports = module.exports;
    this.memory = module.exports.memory || new WebAssembly.Memory({ initial: 256 });
  }
  
  // 传递字符串到WebAssembly
  writeString(str) {
    const encoder = new TextEncoder();
    const bytes = encoder.encode(str);
    
    // 分配内存
    const offset = this.exports.malloc(bytes.length + 1);
    
    // 写入内存
    const view = new Uint8Array(this.memory.buffer);
    view.set(bytes, offset);
    view[offset + bytes.length] = 0; // null终止符
    
    return offset;
  }
  
  // 从WebAssembly读取字符串
  readString(offset) {
    const view = new Uint8Array(this.memory.buffer);
    let length = 0;
    
    // 计算字符串长度
    while (view[offset + length] !== 0) {
      length++;
    }
    
    // 读取字符串
    const bytes = view.slice(offset, offset + length);
    const decoder = new TextDecoder();
    return decoder.decode(bytes);
  }
}

// 使用示例
const wasm = new WasmModule(module);
const strOffset = wasm.writeString('Hello WebAssembly');
const result = wasm.exports.processString(strOffset);
const output = wasm.readString(result);

6. 处理复杂数据结构

// 处理数组和对象
class ArrayHandler {
  constructor(module) {
    this.exports = module.exports;
    this.memory = module.exports.memory;
  }
  
  // 创建数组
  createArray(type, values) {
    const bytesPerElement = this.getBytesPerElement(type);
    const array = new (this.getTypedArray(type))(
      this.memory.buffer,
      this.exports.malloc(values.length * bytesPerElement),
      values.length
    );
    
    array.set(values);
    return array.byteOffset;
  }
  
  // 读取数组
  readArray(type, offset, length) {
    const TypedArray = this.getTypedArray(type);
    return new TypedArray(
      this.memory.buffer,
      offset,
      length
    );
  }
  
  getBytesPerElement(type) {
    const sizes = {
      'i32': 4,
      'f32': 4,
      'f64': 8
    };
    return sizes[type] || 4;
  }
  
  getTypedArray(type) {
    const types = {
      'i32': Int32Array,
      'f32': Float32Array,
      'f64': Float64Array
    };
    return types[type] || Int32Array;
  }
}

// 使用示例
const handler = new ArrayHandler(module);
const arrayOffset = handler.createArray('f32', [1.0, 2.0, 3.0, 4.0]);
const result = handler.exports.processArray(arrayOffset, 4);
const output = handler.readArray('f32', result, 4);

实战应用案例

7. 图像处理应用

使用WebAssembly实现高性能的图像滤镜。

// image_filter.cpp
#include <emscripten.h>
#include <stdint.h>

extern "C" {
  EMSCRIPTEN_KEEPALIVE
  void grayscale(uint8_t* data, int width, int height) {
    for (int i = 0; i < width * height * 4; i += 4) {
      uint8_t r = data[i];
      uint8_t g = data[i + 1];
      uint8_t b = data[i + 2];
      
      // 计算灰度值
      uint8_t gray = 0.299 * r + 0.587 * g + 0.114 * b;
      
      data[i] = gray;
      data[i + 1] = gray;
      data[i + 2] = gray;
    }
  }
  
  EMSCRIPTEN_KEEPALIVE
  void invert(uint8_t* data, int width, int height) {
    for (int i = 0; i < width * height * 4; i += 4) {
      data[i] = 255 - data[i];         // R
      data[i + 1] = 255 - data[i + 1]; // G
      data[i + 2] = 255 - data[i + 2]; // B
    }
  }
}
// JavaScript调用
class ImageProcessor {
  constructor() {
    this.wasm = null;
  }
  
  async init() {
    const response = await fetch('image_filter.wasm');
    const buffer = await response.arrayBuffer();
    const module = await WebAssembly.instantiate(buffer);
    this.wasm = module.instance.exports;
  }
  
  applyGrayscale(imageData) {
    const { data, width, height } = imageData;
    
    // 创建WebAssembly内存视图
    const wasmMemory = new Uint8Array(
      this.wasm.memory.buffer,
      this.wasm.malloc(data.length),
      data.length
    );
    
    // 复制图像数据
    wasmMemory.set(data);
    
    // 调用WebAssembly函数
    this.wasm.grayscale(wasmMemory.byteOffset, width, height);
    
    // 获取处理后的数据
    const result = new Uint8ClampedArray(wasmMemory);
    
    // 释放内存
    this.wasm.free(wasmMemory.byteOffset);
    
    return new ImageData(result, width, height);
  }
}

// 使用示例
const processor = new ImageProcessor();
await processor.init();

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

const processedData = processor.applyGrayscale(imageData);
ctx.putImageData(processedData, 0, 0);

8. 加密解密应用

// crypto.cpp
#include <emscripten.h>
#include <stdint.h>
#include <string.h>

extern "C" {
  EMSCRIPTEN_KEEPALIVE
  void xorEncrypt(uint8_t* data, int length, uint8_t key) {
    for (int i = 0; i < length; i++) {
      data[i] ^= key;
    }
  }
  
  EMSCRIPTEN_KEEPALIVE
  void simpleHash(uint8_t* data, int length, uint8_t* output) {
    uint32_t hash = 0;
    
    for (int i = 0; i < length; i++) {
      hash = hash * 31 + data[i];
    }
    
    memcpy(output, &hash, 4);
  }
}
// JavaScript加密工具
class CryptoTool {
  constructor() {
    this.wasm = null;
  }
  
  async init() {
    const response = await fetch('crypto.wasm');
    const buffer = await response.arrayBuffer();
    const module = await WebAssembly.instantiate(buffer);
    this.wasm = module.instance.exports;
  }
  
  encrypt(data, key) {
    const encoder = new TextEncoder();
    const bytes = encoder.encode(data);
    
    // 分配内存
    const wasmData = new Uint8Array(
      this.wasm.memory.buffer,
      this.wasm.malloc(bytes.length),
      bytes.length
    );
    
    wasmData.set(bytes);
    
    // 加密
    this.wasm.xorEncrypt(wasmData.byteOffset, bytes.length, key);
    
    // 获取结果
    const encrypted = new Uint8Array(wasmData);
    
    // 释放内存
    this.wasm.free(wasmData.byteOffset);
    
    return btoa(String.fromCharCode(...encrypted));
  }
  
  decrypt(encryptedData, key) {
    const bytes = new Uint8Array(
      atob(encryptedData).split('').map(c => c.charCodeAt(0))
    );
    
    const wasmData = new Uint8Array(
      this.wasm.memory.buffer,
      this.wasm.malloc(bytes.length),
      bytes.length
    );
    
    wasmData.set(bytes);
    
    // 解密(XOR加密解密相同)
    this.wasm.xorEncrypt(wasmData.byteOffset, bytes.length, key);
    
    const decrypted = new Uint8Array(wasmData);
    this.wasm.free(wasmData.byteOffset);
    
    const decoder = new TextDecoder();
    return decoder.decode(decrypted);
  }
}

// 使用示例
const crypto = new CryptoTool();
await crypto.init();

const message = 'Hello WebAssembly!';
const key = 42;

const encrypted = crypto.encrypt(message, key);
console.log('加密:', encrypted);

const decrypted = crypto.decrypt(encrypted, key);
console.log('解密:', decrypted);

性能优化技巧

9. 内存管理优化

// 内存池管理
class MemoryPool {
  constructor(module, initialSize = 1024 * 1024) {
    this.module = module;
    this.pool = [];
    this.allocated = new Set();
    this.chunkSize = initialSize;
  }
  
  allocate(size) {
    // 查找合适的内存块
    for (let i = 0; i < this.pool.length; i++) {
      const block = this.pool[i];
      if (!block.used && block.size >= size) {
        block.used = true;
        this.allocated.add(block.offset);
        return block.offset;
      }
    }
    
    // 分配新内存块
    const offset = this.module.exports.malloc(size);
    this.pool.push({
      offset,
      size,
      used: true
    });
    this.allocated.add(offset);
    
    return offset;
  }
  
  free(offset) {
    const block = this.pool.find(b => b.offset === offset);
    if (block) {
      block.used = false;
      this.allocated.delete(offset);
    }
  }
  
  cleanup() {
    // 释放所有未使用的内存块
    this.pool.forEach(block => {
      if (!block.used) {
        this.module.exports.free(block.offset);
      }
    });
    
    this.pool = this.pool.filter(block => block.used);
  }
}

10. 多线程处理

// 使用Web Worker和WebAssembly
class WasmWorker {
  constructor(wasmUrl) {
    this.worker = new Worker(URL.createObjectURL(
      new Blob([`
        let wasmModule = null;
        
        self.onmessage = async function(e) {
          const { type, data } = e.data;
          
          if (type === 'init') {
            const response = await fetch(data.wasmUrl);
            const buffer = await response.arrayBuffer();
            wasmModule = await WebAssembly.instantiate(buffer);
            self.postMessage({ type: 'ready' });
          } else if (type === 'compute') {
            const result = wasmModule.instance.exports[data.function](
              ...data.args
            );
            self.postMessage({ type: 'result', data: result });
          }
        };
      `], { type: 'application/javascript' })
    ));
  }
  
  async init() {
    return new Promise((resolve) => {
      this.worker.onmessage = (e) => {
        if (e.data.type === 'ready') {
          resolve();
        }
      };
      
      this.worker.postMessage({
        type: 'init',
        data: { wasmUrl: this.wasmUrl }
      });
    });
  }
  
  compute(functionName, ...args) {
    return new Promise((resolve) => {
      this.worker.onmessage = (e) => {
        if (e.data.type === 'result') {
          resolve(e.data.data);
        }
      };
      
      this.worker.postMessage({
        type: 'compute',
        data: { function: functionName, args }
      });
    });
  }
}

// 使用示例
const worker = new WasmWorker('compute.wasm');
await worker.init();

// 并行计算
const results = await Promise.all([
  worker.compute('heavyComputation', 1000),
  worker.compute('heavyComputation', 2000),
  worker.compute('heavyComputation', 3000)
]);

调试和测试

11. WebAssembly调试技巧

// 调试工具
class WasmDebugger {
  constructor(module) {
    this.exports = module.exports;
    this.memory = module.exports.memory;
    this.breakpoints = new Set();
  }
  
  // 内存检查
  inspectMemory(offset, length) {
    const view = new Uint8Array(this.memory.buffer);
    const bytes = [];
    
    for (let i = 0; i < length; i++) {
      bytes.push(view[offset + i].toString(16).padStart(2, '0'));
    }
    
    return bytes.join(' ');
  }
  
  // 性能监控
  profileFunction(funcName, ...args) {
    const startTime = performance.now();
    const result = this.exports[funcName](...args);
    const endTime = performance.now();
    
    console.log(`${funcName} 执行时间: ${(endTime - startTime).toFixed(2)}ms`);
    return result;
  }
  
  // 内存使用统计
  getMemoryUsage() {
    const memory = this.exports.memory;
    return {
      used: memory.buffer.byteLength,
      total: memory.buffer.byteLength,
      pages: memory.buffer.byteLength / 65536
    };
  }
}

// 使用示例
const debugger = new WasmDebugger(module);

console.log('内存内容:', debugger.inspectMemory(0, 16));
console.log('内存使用:', debugger.getMemoryUsage());

const result = debugger.profileFunction('fibonacci', 30);

12. 单元测试

// WebAssembly测试框架
class WasmTester {
  constructor(module) {
    this.exports = module.exports;
    this.tests = [];
  }
  
  test(name, fn) {
    this.tests.push({ name, fn });
  }
  
  async run() {
    const results = [];
    
    for (const test of this.tests) {
      try {
        await test.fn(this.exports);
        results.push({ name: test.name, passed: true });
        console.log(`✓ ${test.name}`);
      } catch (error) {
        results.push({ name: test.name, passed: false, error: error.message });
        console.log(`✗ ${test.name}: ${error.message}`);
      }
    }
    
    return results;
  }
}

// 使用示例
const tester = new WasmTester(module);

tester.test('add函数测试', (exports) => {
  const result = exports.add(10, 20);
  if (result !== 30) {
    throw new Error(`期望30,实际得到${result}`);
  }
});

tester.test('fibonacci函数测试', (exports) => {
  const result = exports.fibonacci(10);
  if (result !== 55) {
    throw new Error(`期望55,实际得到${result}`);
  }
});

const results = await tester.run();
const passed = results.filter(r => r.passed).length;
console.log(`测试通过: ${passed}/${results.length}`);

总结

WebAssembly为Web平台带来了革命性的性能提升:

核心优势

  1. 高性能:接近原生的执行速度
  2. 多语言支持:C/C++、Rust、Go等
  3. 安全性:在沙箱环境中运行
  4. 可移植性:跨平台兼容
  5. 与JavaScript协同:完美互补

适用场景

  • 计算密集型任务:图像处理、视频编解码
  • 游戏开发:3D引擎、物理模拟
  • 数据处理:大数据分析、机器学习
  • 加密解密:密码学运算
  • 科学计算:数学计算、物理模拟

最佳实践

  1. 合理选择:使用WebAssembly处理性能关键代码
  2. 内存管理:注意内存分配和释放
  3. 数据转换:正确处理JavaScript和WebAssembly之间的数据类型
  4. 性能监控:持续监控和优化性能
  5. 错误处理:完善的错误处理和调试机制

学习路径

  1. 理解WebAssembly的基本概念
  2. 学习编译工具链(Emscripten)
  3. 掌握JavaScript与WebAssembly的交互
  4. 实践实际应用案例
  5. 学习性能优化技巧

WebAssembly正在改变Web开发的格局,让浏览器能够运行更复杂、更强大的应用。开始在你的项目中探索WebAssembly吧,体验高性能Web开发的全新可能!


本文首发于掘金,欢迎关注我的专栏获取更多前端技术干货!

Zustand:轻量级状态管理,从入门到实践

2026年3月7日 15:19

Zustand:轻量级状态管理,从入门到实践

在现代前端开发中,状态管理是一个绕不开的话题。随着应用复杂度的提升,组件间共享状态的需求变得越来越强烈。Redux、MobX 等传统方案虽然强大,但往往伴随着繁琐的样板代码和陡峭的学习曲线。

今天我们要介绍的是 Zustand —— 一个极简、快速、可扩展的状态管理库。它基于 hooks 思想设计,API 简洁,几乎零样板代码,却能完美应对中小型应用乃至大型项目的状态管理需求。

如果说国家需要有中央银行,那么前端项目就需要中央状态管理系统。Zustand 就是这样一个“中央银行”,它将状态集中存储,并提供一套清晰的修改规则,让组件可以轻松共享和操作数据。

本文将通过两个实战案例(计数器、Todo 应用),带你从零掌握 Zustand 的核心用法,并深入理解其高级特性。


第一章:快速上手 Zustand — 计数器

我们先从一个最简单的计数器开始,感受 Zustand 的简洁与强大。

1. 安装

npm install zustand
# 或
yarn add zustand

2. 创建第一个 store

src/store/counter.ts 中:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

// 定义状态类型
interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

// 创建 store
export const useCounterStore = create<CounterState>()(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
      reset: () => set({ count: 0 }),
    }),
    {
      name: 'counter', // 持久化存储的 key
    }
  )
)

代码解读:

  • create<T>() 用于创建 store,返回一个自定义 hook。
  • persist 是 Zustand 提供的一个中间件,可以将 store 中的数据自动持久化到 localStorage 中。
  • set 函数用于更新状态,可以直接传入新状态,也可以传入一个函数接收当前状态并返回新状态。

3. 在组件中使用

App.tsx 中引入并使用:

import { useCounterStore } from './store/counter'

function App() {
  const { count, increment, decrement, reset } = useCounterStore()

  return (
    <div className="card">
      <button onClick={increment}>增加</button>
      <span>{count}</span>
      <button onClick={decrement}>减少</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

无需 Provider,无需 Context,直接调用 hook 即可获取状态和操作方法! 这就是 Zustand 的魅力所在。


第二章:管理复杂状态 — Todo 应用

接下来我们实现一个 Todo 列表,涵盖添加、切换完成状态、删除等功能,并使用 persist 中间件让数据持久化。

1. 定义类型

首先在 src/types/index.ts 中定义 Todo 类型:

export interface Todo {
  id: number
  text: string
  completed: boolean
}

2. 创建 Todo Store

src/store/todo.ts 中:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Todo } from '../types'

interface TodoState {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  removeTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>()(
  persist(
    (set) => ({
      todos: [],
      addTodo: (text) =>
        set((state) => ({
          todos: [
            ...state.todos,
            {
              id: Date.now(),
              text,
              completed: false,
            },
          ],
        })),
      toggleTodo: (id) =>
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
          ),
        })),
      removeTodo: (id) =>
        set((state) => ({
          todos: state.todos.filter((todo) => todo.id !== id)
        })),
    }),
    {
      name: 'todos', // 持久化 key
    }
  )
)

3. 在组件中使用

App.tsx 中添加 Todo 相关 UI:

import { useState } from 'react'
import { useTodoStore } from './store/todo'

function App() {
  const [inputValue, setInputValue] = useState('')
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore()

  const handleAdd = () => {
    if (inputValue.trim() === '') return
    addTodo(inputValue)
    setInputValue('')
  }

  return (
    <section>
      <h2>Todo 列表 ({todos.length})</h2>
      <div>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
          placeholder="输入待办事项"
        />
        <button onClick={handleAdd}>添加</button>
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => removeTodo(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </section>
  )
}

效果:添加的待办事项会立即显示,切换复选框可以划掉文字,删除按钮可移除对应项。刷新页面后数据依然存在 —— 因为 persist 中间件已经帮我们同步到了 localStorage。

两个组合在一起的效果图

屏幕录制 2026-03-07 151808.gif

第三章:组合多个 Store 与性能优化

随着应用规模增长,我们往往会将不同领域的状态拆分到独立的 store 中。Zustand 鼓励这种模式 —— 每个 store 都是独立的,通过自定义 hook 组合使用。

1. 在组件中同时使用多个 store

import { useCounterStore } from './store/counter'
import { useTodoStore } from './store/todo'

function App() {
  const count = useCounterStore((state) => state.count)
  const todos = useTodoStore((state) => state.todos)

  // ...
}

2. 使用 selector 优化性能

Zustand 默认会对整个 state 进行浅比较,但如果你的组件只关心部分状态,建议使用 selector 来避免不必要的渲染。

// 只选取 count,当 count 变化时才重新渲染
const count = useCounterStore((state) => state.count)

// 只选取 increment 函数(函数永远不会变,所以永远不会触发重渲染)
const increment = useCounterStore((state) => state.increment)

3. 创建组合式 Hook

如果多个 store 的状态经常一起使用,可以封装一个自定义 Hook:

import { useCounterStore } from './counter'
import { useTodoStore } from './todo'

export const useCombinedStore = () => {
  const count = useCounterStore((state) => state.count)
  const todos = useTodoStore((state) => state.todos)
  const addTodo = useTodoStore((state) => state.addTodo)

  return { count, todos, addTodo }
}

4. 在 store 中访问其他 store

有时一个 store 的 action 需要依赖另一个 store 的状态。你可以在 action 内部导入并使用其他 store 的 hook(注意避免循环依赖)。

例如,在 todo store 中添加日志,记录当前计数器值:

import { useCounterStore } from './counter'

export const useTodoStore = create<TodoState>()(
  (set, get) => ({
    // ...
    addTodo: (text) => {
      const count = useCounterStore.getState().count
      console.log('当前计数器值:', count)
      set((state) => ({
        todos: [...state.todos, { id: Date.now(), text, completed: false }]
      }))
    },
  })
)

第四章:中间件与高级用法

Zustand 提供了多种中间件,除了我们已使用的 persist,还有:

  • devtools:与 Redux DevTools 集成,方便调试。
  • subscribe:订阅状态变化。
  • immer:允许以可变方式编写更新逻辑。

1. 使用 immer 简化更新

安装 immer 相关中间件:

npm install immer

然后修改 store:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface TodoState {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>()(
  immer(
    persist(
      (set) => ({
        todos: [],
        addTodo: (text) =>
          set((state) => {
            state.todos.push({ id: Date.now(), text, completed: false })
          }),
        toggleTodo: (id) =>
          set((state) => {
            const todo = state.todos.find((t) => t.id === id)
            if (todo) todo.completed = !todo.completed
          }),
      }),
      { name: 'todos' }
    )
  )
)

使用 immer 后,你可以像修改可变对象一样更新状态,代码更简洁。

2. 订阅状态变化

Zustand 的 store 本身就是一个可观察对象,你可以通过 subscribe 监听变化:

const unsubscribe = useTodoStore.subscribe((state, prevState) => {
  console.log('todos 从', prevState.todos, '变为', state.todos)
})

// 取消订阅
unsubscribe()

这在需要在状态变化时触发副作用(如埋点、本地存储同步)时非常有用。


总结

通过本文的两个实战案例,我们完整地体验了 Zustand 的核心能力:

  • 极简 API:无需繁琐的 action 类型、reducer、provider,几行代码即可创建可全局访问的状态。
  • 灵活组合:可以按领域拆分成多个 store,通过 hooks 自由组合。
  • 中间件生态:支持持久化、调试、不可变更新等常见需求。
  • 完美集成 TypeScript:类型推导自然,开发体验极佳。

相比 Redux,Zustand 的学习成本几乎为零;相比 Context + useReducer,Zustand 避免了 Provider 嵌套和性能问题。它特别适合以下场景:

  • 中小型项目,希望快速迭代
  • 大型项目,需要清晰的状态边界和性能优化
  • 任何需要全局共享状态,但又不想引入复杂概念的项目

希望这篇文章能帮助你快速上手 Zustand,并在实际项目中得心应手。如果你有任何问题或经验分享,欢迎在评论区留言讨论!

VTJ.PRO 双向代码转换原理揭秘

2026年3月7日 15:10

在低代码平台层出不穷的今天,如何平衡可视化开发的便利性与代码的灵活性、可控性,一直是行业难题。VTJ.PRO 作为一个面向 Vue 3 开发者的 AI 驱动开发平台,给出了一个独特的答案:双向代码转换。它不仅支持从 Vue 源码到低代码 DSL 的“向上”转换,也支持从 DSL 到标准 Vue 源码的“向下”生成,并且两个方向可以反复进行,实现了真正意义上的“代码双向自由”。

本文将深入剖析 VTJ.PRO 双向代码转换系统的核心原理,揭开其如何实现 Vue SFC(单文件组件)与平台内部 DSL 之间无损、可逆转换的技术面纱。

1. 双向转换系统架构总览

VTJ.PRO 的代码转换系统由两大核心模块构成:

  • Parser(解析器):将 Vue SFC 源码解析为平台内部的 BlockSchema DSL 对象。
  • Generator(生成器):将 BlockSchema DSL 对象重新生成为标准 Vue SFC 源码。

这两个模块共同构成了一个闭环,使得开发者可以在“源码编辑”与“可视化设计”两种模式间无缝切换,且任意一方的修改都能被另一方完整理解和承载。

整体工作流程如下图所示:

flowchart TD
    A[Vue SFC 源码] -->|输入| B[Parser 解析器]
    B -->|输出| C[BlockSchema DSL]
    C -->|输入| D[Generator 生成器]
    D -->|输出| E[Vue SFC 源码]

    B -.->|验证/修复| A
    D -.->|格式化/平台适配| E

2. 解析器:从 Vue SFC 到 DSL

解析器的入口是 parseVue 函数,它接收 Vue 源码,经过多阶段处理,最终输出一个结构化的 BlockSchema 对象。整个过程可以分为:输入验证与自动修复SFC 拆分脚本解析模板解析上下文跟踪与代码修补五个主要阶段。

2.1 输入验证与自动修复

在解析之前,系统会使用 ComponentValidator 对源码进行质量检查,确保其符合平台的预期格式。验证规则包括:

  • SFC 结构完整性:必须包含 <template><script><style> 块。
  • JavaScript 语法正确性:使用 Babel 检查脚本部分是否有语法错误。
  • setup 函数格式setup() 必须恰好包含三句代码(provider 初始化、state 声明、return)。
  • 图标名称合法性:检查 Vant 和 VTJ 图标库的图标名是否在白名单内。

如果检测到可自动修复的问题(如非法的图标名、模板中缺少 state. 前缀),AutoFixer 会介入修正。例如,checkAndFixStatePrefix 函数会遍历模板中的插值、绑定、指令,自动为响应式变量添加 state. 前缀:

// 修复前
<div>{{ username }}</div>
<button @click="count++">Click</button>

// 修复后
<div>{{ state.username }}</div>
<button @click="state.count++">Click</button>

2.2 SFC 解析

通过 Vue 官方编译器将源码拆分为 <template><script><style> 三部分。parseSFC 函数会优先识别 <script setup>,并收集所有样式块(支持多 <style>)。

2.3 脚本解析:Babel 提取

parseScripts 函数利用 Babel 对脚本代码进行 AST 遍历,提取组件逻辑元数据。关键提取点包括:

  • 状态(State):识别 const state = reactive({...}) 语句,提取初始状态对象。
  • 方法(Methods):收集 methods 对象中的函数。
  • 事件处理器(Event Handlers):方法名若匹配特定后缀模式(如 click_abc123),会被归类为事件处理器,并生成唯一 ID。
  • 计算属性(Computed):提取 computed 对象中的函数。
  • 侦听器(Watchers):方法名以 watcher_ 开头则视为侦听器源。
  • 数据源(Data Sources):识别调用 provider.apiscreateMock 的方法,并解析其 transform 逻辑。
  • 生命周期(LifeCycles):提取 mountedcreated 等方法。

这些提取出的信息将分别存入 BlockSchemastatemethodscomputedwatch 等字段。

2.4 模板解析:AST 转换

模板解析是核心中的核心,parseTemplate 函数将 Vue 模板 AST 转换为平台内部的 NodeSchema 节点树。转换过程中,每个 AST 节点都会调用 transformNode,生成对应的 NodeSchema 对象,并递归处理子节点。

关键转换规则:

  • 属性(Props):静态属性直接转为键值对;动态绑定(v-bind)转换为 JSExpression 类型;同时处理 class/style 的合并。
  • 事件(Events)v-on 指令转换为 events 对象,事件表达式会被包装成函数,并与脚本中提取的事件处理器 ID 关联。
  • 指令(Directives)v-ifv-forv-modelv-show 等都被提取为 directives 数组,保留其表达式和参数。
  • 插槽(Slots):识别 <template #slotName> 和组件上的 v-slot,生成 slot 元数据。

模板解析流程图如下:

flowchart TD
    A[模板源码] -->|Vue Compiler| B[AST]
    B --> C[transformNode 递归转换]
    C --> D{节点类型}
    D -->|元素节点| E[getProps 提取属性]
    D -->|元素节点| F[getEvents 提取事件]
    D -->|元素节点| G[getDirectives 提取指令]
    D -->|文本节点| H[生成文本节点]
    E --> I[创建NodeSchema]
    F --> I
    G --> I
    H --> I
    I --> J[递归处理子节点]
    J --> K[输出NodeSchema树]

2.5 上下文跟踪与代码修补

在模板中,变量可能来自多个作用域:组件状态(state)、计算属性(computed)、v-for 循环变量、插槽作用域变量等。为了保证在运行时能正确访问这些变量,解析器必须记录每个节点的上下文

pickContext 函数在遍历 AST 时动态维护一个上下文映射:遇到 v-for 时,将迭代变量(如 item, index)加入当前上下文;遇到具名插槽时,将插槽参数加入子节点上下文。

随后,系统调用 patchCode 对所有 JavaScript 表达式(如 JSExpressionJSFunction)进行上下文注入。注入的核心是 replacer 函数,它通过一个状态机逐字符扫描表达式,智能地决定哪些标识符需要添加前缀(如 this.context.this.)。判断规则包括:

  • 字符串字面量内:不替换。
  • 对象属性访问.key 形式不替换,[key] 形式替换。
  • 变量声明:不替换。
  • 函数参数:不替换。
  • 展开运算符...key 替换。
  • 正则表达式内:不替换。

这种精细的替换策略确保了修补后的代码既能正确引用上下文,又不会破坏原有的语法结构。

2.6 输出 BlockSchema

经过上述所有阶段,解析器最终组装出一个完整的 BlockSchema 对象。该对象包含了组件的所有信息:ID、名称、状态、方法、计算属性、侦听器、数据源、生命周期、节点树以及 CSS 样式。这个 DSL 对象可以被可视化设计器直接消费,也可以存入数据库或文件。

3. 代码生成器:从 DSL 到 Vue SFC

代码生成器是解析器的逆过程,其核心函数 generator() 接收 BlockSchema 对象,输出格式化的 Vue SFC 源码。生成过程分为模板生成脚本生成样式生成格式化四个阶段,并支持多平台适配。

3.1 生成器架构

flowchart TD
    A[BlockSchema] --> B[模板生成]
    A --> C[脚本生成]
    A --> D[样式生成]
    B --> E[组合SFC]
    C --> E
    D --> E
    E --> F[Prettier格式化]
    F --> G[平台适配转换]
    G --> H[最终Vue源码]

3.2 模板生成

模板生成器遍历 BlockSchema.nodes 树,为每个 NodeSchema 节点生成对应的 Vue 模板标签。生成规则如下:

  • 标签名:根据节点 namefrom(组件来源)决定标签名。
  • 静态属性:直接输出 key="value"
  • 动态属性v-bind:key="表达式":key="表达式"
  • 事件v-on:click="handler"@click="handler"
  • 指令:将 directives 数组还原为 v-ifv-forv-model 等指令。
  • 插槽:为带有 slot 元数据的节点生成 <template #slotName> 包裹。

特别地,v-for 指令需要根据其 iterator 结构还原出 (item, index) in list 的语法。

3.3 脚本生成

脚本生成的目标是输出一个符合 Vue 3 选项式 API 或组合式 API 的 <script> 块。VTJ.PRO 默认采用组合式 API 风格,但最终输出会根据配置选择。

脚本生成的步骤包括:

  1. 导入语句生成:根据组件使用的物料(UI 库、自定义组件)生成 import 语句,并处理平台依赖(如 @element-plus/icons-vue 可能被映射为 @vtj/icons)。
  2. setup 函数构造
    • 调用 useProvider 初始化 provider。
    • 声明 reactivestate 对象。
    • 定义计算属性、方法、侦听器、生命周期函数。
    • 返回需要暴露给模板的变量(statepropsprovider 等)。
  3. 方法体生成methodscomputedwatch 等字段中的 JSFunction 对象会被还原为函数代码,并经过 patchCode 的逆过程(移除上下文前缀)吗?实际上,生成器不再需要逆向 patch,因为 DSL 中的表达式已经是经过上下文修补的,生成器只需直接输出这些表达式即可,但在输出前会确保它们符合 Vue 运行时的要求(例如,模板中访问 state.xxx 是合法的,而在 methods 中可能需要通过 this.state.xxx 访问,这取决于最终代码的结构)。生成器会依据上下文适当调整引用方式。

3.4 样式生成

样式生成最简单:直接将 BlockSchema.css 字符串插入 <style scoped> 块中。若存在多个样式块,则会合并或分别输出。

3.5 格式化与平台适配

所有生成的代码都会通过 Prettier 进行格式化,确保缩进、引号、分号等风格一致。VTJ.PRO 内置了 vueFormattertsFormatterhtmlFormattercssFormatter,分别处理不同类型的代码块。

最后,根据目标平台(webh5uniapp)对标签和依赖进行适配转换。例如,在 UniApp 平台下,<div> 会被转换为 <view><span> 转换为 <text>,并且只导入支持该平台的依赖包。

4. 关键数据结构与设计哲学

理解双向转换,必须掌握几个核心数据结构:

  • BlockSchema:整个组件的 DSL 表示,包含元数据、逻辑、节点树和样式。
  • NodeSchema:单个节点的 DSL 表示,包含标签名、属性、事件、指令、子节点等。
  • JSExpression / JSFunction:包裹 JavaScript 表达式的类型,带有 typevalue 字段,便于序列化和解析。

VTJ.PRO 的双向转换设计遵循以下哲学:

  • 无平台锁定:生成的是标准 Vue 源码,开发者可以随时脱离平台手工修改,修改后的代码仍可被平台重新解析利用。
  • 可逆性parseVuegenVueCode 构成一对可逆操作,多次转换后语义保持不变(通过测试用例保证)。
  • 开发者友好:所有转换都尽可能保留原代码的格式和注释,生成的代码可读性强,符合开发者的编码习惯。

5. 总结与展望

VTJ.PRO 的双向代码转换系统,通过在抽象语法树层面的精细操作,实现了低代码 DSL 与标准 Vue 源码之间的双向映射。它不仅为可视化设计器提供了数据基础,也确保了开发者随时可以“下车”手写代码,享受完整的开发自由度。

未来,随着 AI 能力的进一步集成(如通过自然语言生成代码片段),这种双向转换能力将成为连接人类开发者与 AI 助手的桥梁,让软件开发进入“随心所欲、不逾矩”的新时代。


参考文档

  • VTJ.PRO 源码仓库:gitee.com/newgateway/…
  • 《Code Transformation System》
  • 《Parser: Vue SFC to DSL》
  • 《Code Generator: DSL to Vue》

高效的数据解构:用 toRefs 和 toRef 保持响应性

作者 wuhen_n
2026年3月6日 07:42

前言

在 Vue3 的开发中,解构赋值是比较常用的语法特性。它能让代码更简洁,变量命名更自由。但当解构遇到 reactive 响应式数据时,一个常见的陷阱就出现了:解构后的变量失去了响应性

为什么会这样?如何既享受解构的便利,又保持数据的响应性?本文将深入探讨 toRefstoRef 这两个 API 的工作原理和使用技巧,帮你彻底解决解构带来的响应式丢失问题。

解构的诱惑与陷阱

为什么我们喜欢解构赋值?

解构赋值是 ES6 带来的语法糖,它让代码变得更加简洁优雅:

const user = reactive({ name: '张三', age: 18 })

// 没有解构之前,只能属性调用
console.log(user.name)
console.log(user.age)

// 有解构之后
const { name, age } = user
console.log(name)
console.log(age)

解构的优势

  • 按需引入:只取需要的属性
  • 命名自由:可以重命名变量
  • 代码简洁:减少重复的前缀

解构带来的问题

当我们对 reactive 响应式对象进行解构时,会丢失响应式。

这部分的内容,在上一篇文章《响应式探秘:ref vs reactive,我该选谁?》中有详细讲解,本文不再赘述!

toRefs 的魔法

原理:将 reactive 对象的每个属性都转换为 ref

toRefs 的出现正是为了解决 reactive 的解构问题。它的工作原理是:遍历 reactive 对象的所有属性,为每个属性都单独创建一个 ref,这些 ref 会保持与原对象的响应式连接:

// 简化的 toRefs 实现
function toRefs(obj) {
  const result = {}
  
  for (const key in obj) {
    // 为每个属性创建 ref
    result[key] = {
      __v_isRef: true,
      get value() {
        return obj[key]  // 读取时访问原对象
      },
      set value(newVal) {
        obj[key] = newVal // 设置时修改原对象
      }
    }
  }
  return result
}

// 使用
const user = reactive({
  name: '张三',
  age: 18
})

const refs = toRefs(user)

user 使用 toRefs 转换后,其结构是这样的:

// toRefs转换后的结构
{
  name: RefImpl { ... },
  age: RefImpl { ... }
}

有了这个结构之后,我们就可以放心、安全地解构了:

const { name, age } = refs
name.value = '李四' // 会触发 user.name 的更新
age.value++        // 会触发 user.age 的更新

使用场景:从组合式函数返回多个值时

toRefs 最常见的应用场景就是当组合式函数中返回多个响应式值时,进行处理:

import { reactive, toRefs } from 'vue'

export function useUser() {
  const state = reactive({
    user: null,
    loading: false,
    error: null,
    permissions: []
  })

  async function fetchUser(id) {
    state.loading = true
    try {
      state.user = await api.getUser(id)
      state.permissions = await api.getPermissions(id)
      state.error = null
    } catch (e) {
      state.error = e
    } finally {
      state.loading = false
    }
  }

  function updateUser(data) {
    Object.assign(state.user, data)
  }

  // ✅ 返回时使用 toRefs,让使用者可以解构
  return {
    ...toRefs(state),
    fetchUser,
    updateUser
  }
}

注意事项:响应式连接是双向的

我们一定要注意:toRefs 创建的是响应式连接是双向的,它并不是复制了一份数据,而是指向原对象属性的引用。这也是一个很常见的开发误区。

const original = reactive({
  name: '张三',
  age: 18
})

const { name, age } = toRefs(original)

// 修改 ref 会影响原对象
name.value = '李四'
console.log(original.name) // '李四'

// 修改原对象会影响 ref
original.age = 20
console.log(age.value) // 20

// 这种连接是持久的
original.name = '王五'
console.log(name.value) // '王五'

// 即使重新赋值原对象的属性,连接依然保持
original.name = '赵六'
console.log(name.value) // '赵六'

toRef 的精简用法

场景:只想处理 reactive 对象中的某一个属性

使用 toRefs 会把 reactive 对象中的所有属性都转换成 ref;但有时候我们只需要处理 reactive 对象中的某些属性,这时使用 toRef 会更加精准。toRef 是用于将 reactive 对象的指定的属性转成 ref,一次只能转换一个属性。在 toRefs 源码实现中,其本质就是通过遍历对象的属性,再通过 toRef 逐个转换。

import { reactive, toRef } from 'vue'

const state = reactive({
  count: 0,
  name: '张三',
  age: 18,
  email: 'zhang@example.com',
  // ... 可能还有很多其他属性
})

// 只关心 count 属性
const countRef = toRef(state, 'count')

// 现在可以像使用 ref 一样使用 countRef
countRef.value++ // 修改 state.count
console.log(state.count) // 1

// 修改原对象也会影响 countRef
state.count = 10
console.log(countRef.value) // 10

优势:性能更好,只创建一个 ref

相比 toRefs 会为所有属性创建 reftoRef 只创建需要属性的 ref,性能开销更小。

toRef 的另一个妙用:创建可选的响应式引用

toRef 还有个好处,可以用来处理可能不存在的属性:

const state = reactive({
  user: {
    name: '张三'
  }
})

当前 user 只存在 name 属性,如果我们直接给它添加一个新属性会怎么样呢?

state.user.profile.gender = '男'

上述代码毫无疑问会报错:Cannot set properties of undefined (setting 'gender')。但通过 toRef 我们可以安全赋值:

// 即使 profile 不存在,也能创建响应式引用
const profile = toRef(state.user, 'profile')

// 可以安全地赋值
profile.value = { gender : '男' }

性能考量

toRefs 的性能开销

toRefs 会遍历对象的所有属性,为每个属性创建一个 ref 对象。对于大型对象来说,这确实会有一定的性能开销。性能开销主要来源于以下几点:

  • 遍历开销:需要遍历所有属性
  • 内存开销:每个 ref 都是一个对象,占用内存
  • 响应式连接:每个 ref 都需要建立响应式连接

因此基于性能考虑,我们应该遵循按需使用的原则,只有在需要的时候才使用 toRefs

何时不该使用 toRefs

有些场景下,使用 toRefs 也确实可能不是最佳选择:

场景1:性能敏感的高频操作

这就是上述提到的性能开销问题。

场景2:对象在组件内部使用,不需要暴露给外部

function internalFeature() {
  const internalState = reactive({ ... })
  
  // 不需要 toRefs,直接在内部使用 state
  function doSomething() {
    internalState.prop = value
  }
  
  return {
    doSomething
  }
}

场景3:返回整个对象

function useConfig() {
  const config = reactive({
    theme: 'dark',
    language: 'zh',
    features: {...}
  })
  
  // 如果使用者很少需要解构,直接返回 reactive 更好
  return {
    config,
    updateConfig
  }
}

结语

toRefstoRef 解决了在享受解构便利的同时,又不失去 Vue 响应式系统的强大能力。理解并善用它们,我们的代码将既简洁又可靠!

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

前端异常捕获:从“页面崩了”到“精准定位”的实战架构

2026年3月6日 07:38

前言:一套完整的异常监控体系不是简单的几个 try...catch,而是由全方位捕获、链路追踪、资源还原、以及自动化告警构成的工程化矩阵。

为了让这篇文章更具实操价值,我将针对你要求的三个核心结构进行深度扩充,加入硬核的代码细节和底层原理解析。


作为开发者,我们最怕听到用户说:“你的页面白屏了”,而你打开电脑却发现“在我这儿是好的”。一套成熟的异常捕获机制,就是你在用户手机里的“黑匣子”。

1. 捕获维度:别漏掉任何一个角落

前端的异常主要分为三大类,每一类都有对应的“捕捉网”。一个合格的监控 SDK 必须像雷达一样覆盖所有角落。

① 同步与异步运行错误

这是最常见的逻辑错误(如 undefined.map())。

  • 常规武器:try...catch

    它能捕获同步代码错误。但在异步逻辑中,如果不用 async/await,它会失效。

    JavaScript

    // ❌ 错误示范:无法捕获异步错误
    try {
      setTimeout(() => { throw new Error('异步崩了') }, 0);
    } catch (e) {
      console.log('抓不到我');
    }
    
  • 全局补丁:window.onerror

    它是最后的防线。它可以收集到大部分未被捕获的运行时错误,包括堆栈、行列号。

    JavaScript

    window.onerror = function(message, source, lineno, colno, error) {
      reportError({
        type: 'javascript',
        msg: message,
        url: source,
        line: lineno,
        col: colno,
        stack: error?.stack
      });
      return true; // 阻止错误继续抛出到控制台
    };
    

② Promise 叛逆:未处理的 Rejection

异步编程中,如果 Promise 被 reject 了但没有 .catch(),普通的监听函数是抓不到它的。这在 async/await 普及的今天尤为致命。

  • 必杀技:unhandledrejection 事件

    JavaScript

    window.addEventListener('unhandledrejection', event => {
      const { reason } = event;
      reportError({
        type: 'promise',
        message: reason?.message || reason, 
        stack: reason?.stack || '',
        metadata: { href: window.location.href }
      });
      event.preventDefault(); 
    });
    

③ 资源加载失败

scriptimg 标签加载 404 时,并不会冒泡到 window.onerror。因为这类错误是静态资源自身的网络错误,不是 JS 引擎的执行错误。

  • 对策:捕获阶段监听

    JavaScript

    window.addEventListener('error', event => {
      const target = event.target || event.srcElement;
      const isResource = target instanceof HTMLElement && (target.src || target.href);
      if (isResource) {
        reportError({
          type: 'resource',
          tagName: target.localName,
          url: target.src || target.href
        });
      }
    }, true); // 注意:必须在捕获阶段(true)监听
    

2. 核心挑战:如何跨越“混淆代码”的迷雾?

为了性能,我们线上的代码都是经过 Webpack 或 Vite 压缩混淆的。如果你收到的日志是 app.js:1:5432 报错,这种信息毫无价值。

实战方案:Source Map 离线映射

在开发环境构建时生成 .map 文件。千万不要把 .map 文件发到线上服务器,否则你的源码就泄露了。正确的做法是:将 .map 上传到内网的监控后台。

还原算法(Node.js 实现细节):

当监控系统收到报错的行列号时,利用 source-map 库还原真相:

JavaScript

const sourceMap = require('source-map');
const fs = require('fs');

async function locateSource(errorInfo) {
  // 1. 读取打包时生成的 map 文件
  const rawSourceMap = JSON.parse(fs.readFileSync('./dist/app.js.map', 'utf8'));
  
  // 2. 创建消费者实例
  const consumer = await new sourceMap.SourceMapConsumer(rawSourceMap);
  
  // 3. 传入混淆后的行列,还原原始代码位置
  const originalPos = consumer.originalPositionFor({
    line: errorInfo.line,
    column: errorInfo.column
  });

  // originalPos 包含: source(哪个文件), line(第几行), column, name(函数名)
  console.log(`[还原成功]: 真凶在 ${originalPos.source}${originalPos.line} 行`);
  
  consumer.destroy();
}

3. 进阶实战:异常捕获的“三层漏斗”模型

我们将异常处理比作**“三层漏斗”**,层层过滤,各司其职。

第一层:局部精细捕获 (Try-Catch) —— “防弹衣”

场景: 核心业务逻辑,如下单按钮、支付接口、复杂的数据解析。

目的: 提供降级方案。

JavaScript

async function handlePay() {
  try {
    await payOrder();
  } catch (err) {
    // 提示用户,并提供“重试”按钮,而不是白屏
    showToast('支付暂不可用,请稍后重试');
    reportError(err, { severity: 'critical' });
  }
}

第二层:全局被动收集 (Global Listeners) —— “黑匣子”

场景: 意料之外的 Bug。

目的: 收集环境信息与用户操作路径(Breadcrumbs) 。光看堆栈不够,我们需要知道用户报错前点过什么。

JavaScript

// 维护一个行为追踪栈
const breadcrumbs = [];
function pushBreadcrumb(info) {
  breadcrumbs.push({ ...info, time: Date.now() });
  if (breadcrumbs.length > 20) breadcrumbs.shift(); // 只保留最近20条
}

// 拦截全局点击
window.addEventListener('click', e => pushBreadcrumb({ type: 'click', target: e.target.tagName }));

第三层:框架级守护 (ErrorBoundary) —— “防爆板”

场景: React 或 Vue 组件崩溃。

目的: 局部降级。如果侧边栏广告组件崩了,不能让整个网页都挂掉。

JavaScript

// React 示例
class GlobalErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error, info) {
    reportError(error, { componentStack: info.componentStack });
  }
  render() {
    if (this.state.hasError) return <div className="fallback">组件加载失败</div>;
    return this.props.children;
  }
}

🛠️ “避坑”锦囊

  • 别“吞掉”异常:绝对不要 catch(e) {} 却什么都不做。这会让 Bug 成为隐形杀手。

  • 处理 Script Error:跨域 CDN 脚本报错必须配合 crossorigin 属性,否则你只能看到一行无用的 Script Error

  • 性能优化:sendBeacon

    上报时,使用 navigator.sendBeacon()。它能确保在页面卸载时异步发出请求,且不阻塞主线程,不影响下一页面的加载速度。


💡 生产级 SDK 精简实战代码

这段代码可以作为你文章的最后压轴,展示一个工业级 SDK 的雏形:

JavaScript

/**
 * 极简生产级监控 SDK
 */
const Monitor = {
  breadcrumbs: [],
  init(url) {
    this.url = url;
    this.bindEvents();
  },
  bindEvents() {
    // 1. JS 运行错误
    window.addEventListener('error', e => {
      if (e.message) this.report('javascript', e.message, { stack: e.error?.stack });
      else this.report('resource', e.target.src || e.target.href);
    }, true);

    // 2. Promise 错误
    window.addEventListener('unhandledrejection', e => {
      this.report('promise', e.reason?.message || e.reason);
    });

    // 3. 记录面包屑:点击
    window.addEventListener('click', e => {
      this.breadcrumbs.push({ type: 'click', t: Date.now() });
    });
  },
  report(type, msg, extra = {}) {
    const data = JSON.stringify({
      type, msg, 
      breadcrumbs: this.breadcrumbs.slice(-5),
      ua: navigator.userAgent,
      ...extra
    });
    // 使用 Beacon 保证可靠上报
    if (navigator.sendBeacon) {
      navigator.sendBeacon(this.url, data);
    } else {
      new Image().src = `${this.url}?data=${encodeURIComponent(data)}`;
    }
  }
};

Monitor.init('https://your-log-server.com/report');

打造你的HTML5打地鼠游戏:零基础入门实践

作者 奇迹_h
2026年3月7日 13:58

打造你的HTML5打地鼠游戏:零基础入门实践

创建游戏结构

1. HTML布局

首先,我们需要创建一个基本的HTML页面,它将包含游戏的布局和地鼠洞。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>洛可可白⚡️打地鼠</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div id="game-container">
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <!-- 更多地鼠洞 -->
    </div>
    <script src="script.js"></script>
</body>
</html>

设计游戏样式

2. CSS样式

接下来,我们将使用CSS来美化我们的游戏界面。

      /* styles.css */
      * {
        box-sizing: border-box;
      }

      h1 {
        text-align: center;
        line-height: 30px;
      }

      .bigBox {
        width: 60%;
        height: 400px;
        margin: 20px auto;
        background-color: #cbbb3e;
      }

      .wam-container {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        align-items: center;
        height: 260px;
      }

      .wam-hole {
        position: relative;
        width: 100px;
        height: 100px;
        margin: 0 20px;
        background-color: #f5732d;
      }

      .wam-mole {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        /* 地鼠 */
        background-image: url("https://pic.52112.com/180516/EPS180516_57/9jagBhddHW_small.jpg");
        background-size: 100% 100%;
        display: none;
      }

      .wam-mole--up {
        display: block;
      }

      .wam-score {
        font-size: 2rem;
        text-align: center;
      }

      .wam-message {
        font-size: 1rem;
        text-align: center;
        margin-top: 20px;
        cursor: pointer;
      }

/* 你可以添加更多的CSS来美化地鼠洞和地鼠 */

实现游戏逻辑

3. JavaScript编程

现在,我们将使用JavaScript来添加游戏逻辑。

const container = document.querySelector(".wam-container");
      const scoreBoard = document.querySelector(".wam-score");
      const message = document.querySelector(".wam-message");
      const moles = Array.from(container.querySelectorAll(".wam-hole"));

      let lastHole;
      let score = 0;
      let isPlaying = false;
      let timeUp = false;

      // 随机时间生成地鼠
      function popUpMole() {
        if (timeUp) return;
        const time = Math.random() * (1500 - 500) + 500;
        const hole = randomHole(moles);
        hole.querySelector("div").classList.add("wam-mole--up");
        setTimeout(() => {
          hole.querySelector("div").classList.remove("wam-mole--up");
          if (!timeUp) popUpMole();
        }, time);
      }

      // 随机选择一个地鼠洞
      function randomHole(holes) {
        const idx = Math.floor(Math.random() * holes.length);
        const hole = holes[idx];
        if (hole === lastHole) return randomHole(holes);
        lastHole = hole;
        return hole;
      }

      // 点击地鼠
      function whackMole(e) {
        if (!e.isTrusted) return; // 防止作弊
        if (!isPlaying) return;
        if (!e.target.matches(".wam-mole")) return;
        score++;
        scoreBoard.textContent = `分数: ${score}`;
        e.target.parentNode
          .querySelector("div")
          .classList.remove("wam-mole--up");
      }
      // 开始游戏
      function startGame() {
        score = 0;
        scoreBoard.textContent = "分数: 0";
        isPlaying = true;
        timeUp = false;
        message.textContent = "";
        popUpMole();
        setTimeout(() => {
          isPlaying = false;
          timeUp = true;
          message.textContent = `一分钟您的得分是: ${score};点我再来一次!`;
        }, 60000);
      }

      // 初始化地鼠洞
      moles.forEach((mole) => mole.addEventListener("click", whackMole));
      document
        .querySelector(".wam-message")
        .addEventListener("click", startGame);

这段代码创建了一个简单的游戏循环,每秒钟随机显示一个地鼠,并在用户点击地鼠时给予反馈。你可以根据需要调整地鼠出现的速度和游戏的其他方面。

全部代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>洛可可白⚡️打地鼠</title>
    <style>
      * {
        box-sizing: border-box;
      }

      h1 {
        text-align: center;
        line-height: 30px;
      }

      .bigBox {
        width: 60%;
        height: 400px;
        margin: 20px auto;
        background-color: #cbbb3e;
      }

      .wam-container {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        align-items: center;
        height: 260px;
      }

      .wam-hole {
        position: relative;
        width: 100px;
        height: 100px;
        margin: 0 20px;
        background-color: #f5732d;
      }

      .wam-mole {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        /* 地鼠 */
        background-image: url("https://pic.52112.com/180516/EPS180516_57/9jagBhddHW_small.jpg");
        background-size: 100% 100%;
        display: none;
      }

      .wam-mole--up {
        display: block;
      }

      .wam-score {
        font-size: 2rem;
        text-align: center;
      }

      .wam-message {
        font-size: 1rem;
        text-align: center;
        margin-top: 20px;
        cursor: pointer;
      }
    </style>
  </head>

  <body>
    <h1>打地鼠</h1>
    <div class="bigBox">
      <div class="wam-container">
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
      </div>
      <div class="wam-score">分数: 0</div>
      <div class="wam-message">准备好了吗?点击我开始</div>
    </div>

    <script>
      const container = document.querySelector(".wam-container");
      const scoreBoard = document.querySelector(".wam-score");
      const message = document.querySelector(".wam-message");
      const moles = Array.from(container.querySelectorAll(".wam-hole"));

      let lastHole;
      let score = 0;
      let isPlaying = false;
      let timeUp = false;

      // 随机时间生成地鼠
      function popUpMole() {
        if (timeUp) return;
        const time = Math.random() * (1500 - 500) + 500;
        const hole = randomHole(moles);
        hole.querySelector("div").classList.add("wam-mole--up");
        setTimeout(() => {
          hole.querySelector("div").classList.remove("wam-mole--up");
          if (!timeUp) popUpMole();
        }, time);
      }

      // 随机选择一个地鼠洞
      function randomHole(holes) {
        const idx = Math.floor(Math.random() * holes.length);
        const hole = holes[idx];
        if (hole === lastHole) return randomHole(holes);
        lastHole = hole;
        return hole;
      }

      // 点击地鼠
      function whackMole(e) {
        if (!e.isTrusted) return; // 防止作弊
        if (!isPlaying) return;
        if (!e.target.matches(".wam-mole")) return;
        score++;
        scoreBoard.textContent = `分数: ${score}`;
        e.target.parentNode
          .querySelector("div")
          .classList.remove("wam-mole--up");
      }
      // 开始游戏
      function startGame() {
        score = 0;
        scoreBoard.textContent = "分数: 0";
        isPlaying = true;
        timeUp = false;
        message.textContent = "";
        popUpMole();
        setTimeout(() => {
          isPlaying = false;
          timeUp = true;
          message.textContent = `一分钟您的得分是: ${score};点我再来一次!`;
        }, 60000);
      }

      // 初始化地鼠洞
      moles.forEach((mole) => mole.addEventListener("click", whackMole));
      document
        .querySelector(".wam-message")
        .addEventListener("click", startGame);
    </script>
  </body>
</html>

如何实现一个网页版的剪映(三)使用fabric.js绘制时间轴

作者 贾铭
2026年3月7日 13:25

前言

《实践论》中讲认识从实践始,经过实践得到了理论的认识,还须再回到实践去。

理论的东西之是否符合于客观真理性这个问题,在前面说的由感性到理性之认识运动中是没有完全解决的,也不能完全解决的。

要完全地解决这个问题,只有把理性的认识再回到社会实践中去,应用理论于实践,看它是否能够达到预想的目的。

时间轴

根据mdn文档所述,canvas有最大的宽高的限制

image.png

我们的视频缩略图和音频波形图是通过canvas绘制的,如果缩放时间轴,可能会超过这个最大宽度(画布会崩溃)

有如下方案:

  • 无界云剪是将缩略图通过图片拼接成一个很长的图片
  • 剪映是通过将canvas固定在一个最大宽度内,然后通过滚动+translate使canvas一直显示在视口
  • clideo是拆分成多个canvas
  • pro.diffusion.studio是整个时间轴通过canvas绘制出来

本文最终选取使用canvas把整个时间轴画出来这种方案

本文最终实现的效果如下

  1. 时间轴缩放(ctrl+滑轮)
  2. 视频轴、音频轴、文本轴的裁剪
  3. 轨道的对齐
  4. 视频缩略图、音频波形图的实现

动画1.gif

视频轨道

本节将实现基本的视频轨道绘制、视频缩略图的绘制

动画.gif

本节将使用上一篇文章介绍的mediabunny来进行视频抽帧

mediabunny最大的亮点是:将webcodecs回调模式读取VideoFrame转换为迭代器模式

  const sink = new CanvasSink(videoTrack, {
    width: this.thumbnailWidth,
    height: Math.round(thumbHeight),
    fit: 'contain'
  });
  for (let t = 0; t <= this.duration; t += DEFAULT_THUMBNAIL_STEP) {
    const result = await sink.getCanvas(t);
  }

我们选取1s为间隔抽取缩略图,并将缩略图转为ImageBitmap存在map中(这一步还能进行优化,可以将ImageBitmap降低分辨率,可以节省更多内存)

时间轴进行缩放时,取最近的缓存时间点缩略图,避免重复解码

const key = Math.round(time / step) * step;
const img = this.thumbnailCache.get(key);

完整代码如下:

import { Rect } from 'fabric';
import { ALL_FORMATS, BlobSource, CanvasSink, Input } from 'mediabunny';
import { ClipType } from '../types';

/** 默认缩略图高度(像素) */
const DEFAULT_THUMBNAIL_HEIGHT = 52;
/** 默认视频宽高比 */
const DEFAULT_ASPECT_RATIO = 16 / 9;
/** 缩略图抽帧步长(秒) */
const DEFAULT_THUMBNAIL_STEP = 1;
/** 默认视频 URL */
const DEFAULT_VIDEO_URL = new URL(
  '../../../assets/test.mp4',
  import.meta.url
).toString();
/** 视频背景色 */
const VIDEO_BACKGROUND = '#1e1b4b';
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;

type VideoClipOptions = {
  id: string;
  left: number;
  top: number;
  width: number;
  height: number;
  src?: string;
};

export class VideoClip extends Rect {
  clipType: ClipType = 'video';
  elementId: string;
  /** 视频资源地址 */
  src: string;
  /** 视频源总时长(秒),用于裁剪边界约束 */
  sourceDuration = 0;
  /** 当前裁剪起点(秒),相对视频源时间轴 */
  trimStart = 0;
  /** 当前裁剪终点(秒),相对视频源时间轴 */
  trimEnd = 0;
  /** 预解码的缩略图列表与缓存 */
  private thumbnails: Array<{ time: number; image: CanvasImageSource }> = [];
  private thumbnailCache = new Map<number, CanvasImageSource>();
  /** 避免重复请求与解码 */
  private isLoading = false;
  /** 视频真实时长 */
  private duration = 0;
  /** 真实宽高比(用于缩略图铺排) */
  private aspectRatio = DEFAULT_ASPECT_RATIO;
  /** 单张缩略图宽度(像素) */
  private thumbnailWidth = 0;

  constructor(options: VideoClipOptions) {
    super({
      left: options.left,
      top: options.top,
      width: options.width,
      height: options.height,
      fill: VIDEO_BACKGROUND,
      stroke: null,
      strokeWidth: 0,
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: true,
      lockRotation: true,
      lockScalingY: true,
      lockScalingFlip: true,
      objectCaching: false,
      hoverCursor: 'move'
    });

    this.elementId = options.id;
    this.src = options.src ?? DEFAULT_VIDEO_URL;
    this.thumbnailWidth = Math.max(
      1,
      Math.round(
        (options.height || DEFAULT_THUMBNAIL_HEIGHT) * this.aspectRatio
      )
    );

    // 仅保留左右缩放控制点
    this.setControlsVisibility({
      tl: false,
      tr: false,
      bl: false,
      br: false,
      mt: false,
      mb: false,
      mtr: false,
      ml: true,
      mr: true
    });

    // 初始化缩略图加载,完成后会触发重绘
    this.loadThumbnails();
  }

  async loadThumbnails() {
    if (this.isLoading) return;
    this.isLoading = true;
    try {
      const response = await fetch(this.src);
      const blob = await response.blob();
      const input = new Input({
        formats: ALL_FORMATS,
        source: new BlobSource(blob)
      });

      // 读取视频真实时长,并同步裁剪边界
      this.duration = (await input.computeDuration()) || 0;
      this.sourceDuration = this.duration;
      // 初始化 trimEnd 为源时长,避免裁剪窗口超出视频长度
      if (this.trimEnd === 0 || this.trimEnd > this.sourceDuration) {
        this.trimEnd = this.sourceDuration;
      }
      // 若 trimStart 越界,则回退到 0
      if (this.trimStart > this.trimEnd) {
        this.trimStart = 0;
      }
      const videoTrack = await input.getPrimaryVideoTrack();
      if (!videoTrack) return;

      const canDecode = await videoTrack.canDecode();
      if (!canDecode) return;

      if (videoTrack.displayWidth && videoTrack.displayHeight) {
        this.aspectRatio = videoTrack.displayWidth / videoTrack.displayHeight;
      }

      const thumbHeight = this.height || DEFAULT_THUMBNAIL_HEIGHT;
      this.thumbnailWidth = Math.max(
        1,
        Math.round(thumbHeight * this.aspectRatio)
      );

      const sink = new CanvasSink(videoTrack, {
        width: this.thumbnailWidth,
        height: Math.round(thumbHeight),
        fit: 'contain'
      });

      // 均匀采样缩略图并缓存,避免每次 render 重复解码
      const thumbnails: Array<{ time: number; image: CanvasImageSource }> = [];
      const thumbnailCache = new Map<number, CanvasImageSource>();
      for (let t = 0; t <= this.duration; t += DEFAULT_THUMBNAIL_STEP) {
        const result = await sink.getCanvas(t);
        if (!result) continue;
        const canvas = result.canvas;
        const image = await createImageBitmap(canvas);
        const time = result.timestamp ?? t;
        thumbnails.push({ time, image });
        const key =
          Math.round(time / DEFAULT_THUMBNAIL_STEP) * DEFAULT_THUMBNAIL_STEP;
        thumbnailCache.set(key, image);
      }

      this.thumbnails = thumbnails;
      this.thumbnailCache = thumbnailCache;
      this.canvas?.requestRenderAll();
    } catch (error) {
      console.error('VideoClip loadThumbnails error:', error);
    } finally {
      this.isLoading = false;
    }
  }

  _render(ctx: CanvasRenderingContext2D) {
    ctx.save();

    // 反向缩放,让绘制逻辑用屏幕像素坐标
    const scaleX = this.scaleX || 1;
    const scaleY = this.scaleY || 1;
    ctx.scale(1 / scaleX, 1 / scaleY);

    const width = (this.width || 0) * scaleX;
    const height = (this.height || 0) * scaleY;
    const radius = this.rx || 6;

    // 以圆角矩形作为裁剪区域
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.clip();

    // 绘制底色,缩略图缺失时仍有可视背景
    ctx.fillStyle = VIDEO_BACKGROUND;
    ctx.fillRect(-width / 2, -height / 2, width, height);

    if (this.thumbnails.length > 0 && width > 0 && height > 0) {
      // 以裁剪窗口作为缩略图采样范围
      const trimStart = Math.max(0, this.trimStart || 0);
      const trimEnd = Math.max(trimStart, this.trimEnd || 0);
      const trimDuration = trimEnd - trimStart;
      if (trimDuration <= 0) {
        ctx.restore();
        return;
      }
      // 依据显示高度与视频宽高比计算单张缩略图宽度
      const thumbWidth = Math.max(1, Math.round(height * this.aspectRatio));
      // 根据显示宽度计算可容纳的缩略图数量
      const visibleCount = Math.max(1, Math.ceil(width / thumbWidth));
      const step = DEFAULT_THUMBNAIL_STEP;
      // 在裁剪区间内均匀采样对应数量的时间点
      const timeStep = trimDuration / visibleCount;

      for (let i = 0; i < visibleCount; i += 1) {
        const time = trimStart + i * timeStep;
        // 取最近的缓存时间点缩略图,避免重复解码
        const key = Math.round(time / step) * step;
        const img = this.thumbnailCache.get(key);
        if (!img) continue;
        // 缩略图按等宽平铺,保持宽高比不变
        const x = -width / 2 + i * thumbWidth;
        const drawWidth = Math.min(thumbWidth, width - i * thumbWidth);
        if (drawWidth <= 0) continue;
        ctx.drawImage(img, x, -height / 2, drawWidth, height);
      }
    }

    ctx.restore();

    // 绘制边框(在裁剪区域外,确保边框宽度不随缩放变化)
    ctx.save();
    ctx.scale(1 / scaleX, 1 / scaleY);
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();
    ctx.restore();
  }
}

最小使用demo:

import { Canvas } from 'fabric';
import { useEffect, useRef } from 'react';
import { VideoClip } from '../../core/timeline/clips/video-clip';

export default function VideoClipDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 200,
      backgroundColor: '#0f172a'
    });

    const videoClip = new VideoClip({
      id: 'demo-video-1',
      left: 50,
      top: 70,
      width: 300,
      height: 60
    });

    canvas.add(videoClip);
    canvas.setActiveObject(videoClip);

    return () => {
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

音频轨道

上篇文章中,我们使用konva完成了音频波形图的绘制,在这一节中将会对它进行优化

原始音频本质是 PCM 采样数据(一秒可能 44100 个点),
如果你直接一个点一个点画,性能会炸。

所以这里做了一件非常关键的事:降采样 + 取峰值

extractWaveformData() 里做了三件事:

  1. 只取第一个声道
  2. 每秒固定抽 100 个“波形点”
  3. 每个点不存所有数据,而是只存:这一小段里的 最小值最大值 [min, max, min, max, min, max...]

这样做的好处是:数据量大幅减少,并且视觉上还能保留波形“形状”

动画.gif

import { Rect } from 'fabric';
import { ALL_FORMATS, BlobSource, Input } from 'mediabunny';
import { ClipType } from '../types';

/** 默认音频文件 URL */
const DEFAULT_AUDIO_URL = new URL(
  '../../../assets/1.wav',
  import.meta.url
).toString();

/** 波形颜色(绿色) */
const WAVEFORM_COLOR = '#22c55e';
/** 波形背景颜色(深绿色) */
const WAVEFORM_BACKGROUND = '#14532d';
/** 每秒采样的波形数据点数 */
const WAVEFORM_SAMPLES_PER_SECOND = 100;
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;

/** AudioClip 构造选项 */
type AudioClipOptions = {
  id: string;
  left: number;
  top: number;
  width: number;
  height: number;
  src?: string;
};

export class AudioClip extends Rect {
  clipType: ClipType = 'audio';
  /** 对应业务 Clip 的唯一标识 */
  elementId: string;
  /** 音频资源地址 */
  src: string;
  /** 音频源总时长(秒),用于裁剪边界约束 */
  sourceDuration = 0;
  /** 当前裁剪起点(秒),相对音频源时间轴 */
  trimStart = 0;
  /** 当前裁剪终点(秒),相对音频源时间轴 */
  trimEnd = 0;
  /** 预解码的波形数据(每个采样点包含 min 和 max 两个值) */
  private waveformData: Float32Array | null = null;
  /** 加载状态标记,避免重复加载 */
  private isLoading = false;
  /** 音频缓冲区,用于提取波形数据 */
  private audioBuffer: AudioBuffer | null = null;

  constructor(options: AudioClipOptions) {
    super({
      left: options.left,
      top: options.top,
      width: options.width,
      height: options.height,
      fill: WAVEFORM_BACKGROUND,
      stroke: null,
      strokeWidth: 0,
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: true,
      lockRotation: true,
      lockScalingY: true,
      lockScalingFlip: true,
      objectCaching: false,
      hoverCursor: 'move'
    });

    this.elementId = options.id;
    this.src = options.src ?? DEFAULT_AUDIO_URL;

    // 仅保留左右缩放控制点,允许裁剪式缩放
    this.setControlsVisibility({
      tl: false,
      tr: false,
      bl: false,
      br: false,
      mt: false,
      mb: false,
      mtr: false,
      ml: true,
      mr: true
    });

    this.loadAudio();
  }

  async loadAudio() {
    if (this.isLoading) return;
    this.isLoading = true;

    try {
      const response = await fetch(this.src);
      const blob = await response.blob();

      const input = new Input({
        formats: ALL_FORMATS,
        source: new BlobSource(blob)
      });

      this.sourceDuration = (await input.computeDuration()) || 0;

      // 初始化裁剪窗口,确保不超过音频时长
      if (this.trimEnd === 0 || this.trimEnd > this.sourceDuration) {
        this.trimEnd = this.sourceDuration;
      }
      if (this.trimStart > this.trimEnd) {
        this.trimStart = 0;
      }

      const arrayBuffer = await blob.arrayBuffer();
      const audioContext = new AudioContext();
      this.audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
      audioContext.close();

      // 提取波形数据
      this.extractWaveformData();
      this.canvas?.requestRenderAll();
    } catch (error) {
      console.error('AudioClip loadAudio error:', error);
    } finally {
      this.isLoading = false;
    }
  }

  /**
   * 从音频缓冲区提取波形数据
   * 将原始音频采样降采样为固定数量的峰值点,用于高效渲染
   */
  private extractWaveformData() {
    if (!this.audioBuffer || this.sourceDuration <= 0) return;

    // 获取第一个声道的音频数据
    const channelData = this.audioBuffer.getChannelData(0);
    const samples = channelData.length;
    // 计算目标采样点数(每秒 100 个点)
    const targetSamples = Math.ceil(
      this.sourceDuration * WAVEFORM_SAMPLES_PER_SECOND
    );

    // 每个采样点存储 min 和 max 两个值
    this.waveformData = new Float32Array(targetSamples * 2);

    // 计算每个目标采样点对应的原始采样数
    const samplesPerPeak = Math.floor(samples / targetSamples);

    // 遍历所有目标采样点,计算每个区间的峰值
    for (let i = 0; i < targetSamples; i++) {
      const start = i * samplesPerPeak;
      const end = Math.min(start + samplesPerPeak, samples);

      let min = 0;
      let max = 0;

      // 在当前区间内查找最小值和最大值
      for (let j = start; j < end; j++) {
        const value = channelData[j];
        if (value < min) min = value;
        if (value > max) max = value;
      }

      // 存储峰值数据
      this.waveformData[i * 2] = min;
      this.waveformData[i * 2 + 1] = max;
    }
  }

  /**
   * 重写渲染逻辑,绘制音频波形
   * 根据裁剪窗口只显示 trimStart 到 trimEnd 区间的波形
   */
  _render(ctx: CanvasRenderingContext2D) {
    ctx.save();

    // 反向缩放,让绘制逻辑用屏幕像素坐标
    const scaleX = this.scaleX || 1;
    const scaleY = this.scaleY || 1;
    ctx.scale(1 / scaleX, 1 / scaleY);

    const width = (this.width || 0) * scaleX;
    const height = (this.height || 0) * scaleY;
    const radius = this.rx || 6;

    // 以圆角矩形作为裁剪区域
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.clip();

    // 绘制背景色
    ctx.fillStyle = WAVEFORM_BACKGROUND;
    ctx.fillRect(-width / 2, -height / 2, width, height);

    // 绘制波形数据
    if (this.waveformData && this.sourceDuration > 0) {
      // 获取裁剪窗口
      const trimStart = Math.max(0, this.trimStart || 0);
      const trimEnd = Math.max(trimStart, this.trimEnd || 0);
      const trimDuration = trimEnd - trimStart;

      if (trimDuration > 0) {
        const totalSamples = this.waveformData.length / 2;
        // 计算裁剪区间对应的采样点范围
        const startSample = Math.floor(
          (trimStart / this.sourceDuration) * totalSamples
        );
        const endSample = Math.ceil(
          (trimEnd / this.sourceDuration) * totalSamples
        );
        const visibleSamples = endSample - startSample;

        const centerY = 0;
        const halfHeight = height / 2 - 4;

        ctx.fillStyle = WAVEFORM_COLOR;

        // 绘制裁剪区间内的波形
        for (let i = 0; i < visibleSamples; i++) {
          const sampleIndex = startSample + i;
          if (sampleIndex * 2 + 1 >= this.waveformData.length) break;

          const min = this.waveformData[sampleIndex * 2];
          const max = this.waveformData[sampleIndex * 2 + 1];

          // 计算当前波形条的 x 坐标
          const x = -width / 2 + (i / visibleSamples) * width;
          const barWidth = Math.max(1, width / visibleSamples);

          // 计算波形条的 y 坐标范围
          const minY = centerY + min * halfHeight;
          const maxY = centerY + max * halfHeight;

          // 绘制波形条
          ctx.fillRect(x, minY, barWidth, maxY - minY);
        }
      }
    } else if (this.isLoading) {
      // 加载中显示提示文字
      ctx.fillStyle = 'rgba(255,255,255,0.5)';
      ctx.font = '12px Inter, sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('Loading...', 0, 0);
    }

    ctx.restore();

    // 绘制边框(在裁剪区域外,确保边框宽度不随缩放变化)
    ctx.save();
    ctx.scale(1 / scaleX, 1 / scaleY);
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();
    ctx.restore();
  }

  /**
   * 获取音频缓冲区
   * 可用于音频播放等功能
   */
  getAudioBuffer(): AudioBuffer | null {
    return this.audioBuffer;
  }

  /**
   * 获取音频源总时长
   * 用于裁剪边界约束
   */
  getSourceDuration(): number {
    return this.sourceDuration;
  }
}
import { Canvas } from 'fabric';
import { useEffect, useRef } from 'react';
import { AudioClip } from '../../core/timeline/clips/audio-clip';

export default function AudioClipDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 200,
      backgroundColor: '#0f172a'
    });

    const audioClip = new AudioClip({
      id: 'demo-audio-1',
      left: 50,
      top: 70,
      width: 300,
      height: 60
    });

    canvas.add(audioClip);
    canvas.setActiveObject(audioClip);

    return () => {
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

文本轨道

import { Rect } from 'fabric';
import { ClipType } from '../types';

/** 文本 Clip 背景色 */
const TEXT_CLIP_BACKGROUND = '#134e4a';
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;

export class TextClip extends Rect {
  clipType: ClipType = 'text';
  elementId: string;
  /** 显示在块内的文字内容 */
  label: string;

  constructor(options: {
    id: string;
    text: string;
    left: number;
    top: number;
    width: number;
    height: number;
  }) {
    super({
      left: options.left,
      top: options.top,
      width: options.width,
      height: options.height,
      fill: TEXT_CLIP_BACKGROUND,
      stroke: null,
      strokeWidth: 0,
      rx: 8,
      /** 圆角 Y */
      ry: 8,
      selectable: true,
      hasControls: true,
      lockRotation: true,
      /** 锁定纵向缩放 */
      lockScalingY: true,
      /** 禁止缩放翻转(避免控制块反向导致的 clip 翻转) */
      lockScalingFlip: true,
      /** 禁用缓存,保证 _render 反向缩放逻辑直接作用于主画布 */
      objectCaching: false,
      hoverCursor: 'move'
    });
    this.elementId = options.id;
    this.label = options.text;

    // 仅保留左右缩放控制点,避免垂直方向缩放
    this.setControlsVisibility({
      tl: false,
      tr: false,
      bl: false,
      br: false,
      mt: false,
      mb: false,
      mtr: false,
      ml: true,
      mr: true
    });
  }

  /**
   * 重写渲染逻辑,在矩形块中绘制文本
   * 手动绘制圆角矩形背景和边框,确保缩放时不变形
   */
  _render(ctx: CanvasRenderingContext2D) {
    ctx.save();

    // 反向缩放,让绘制逻辑用屏幕像素坐标
    const scaleX = this.scaleX || 1;
    const scaleY = this.scaleY || 1;
    ctx.scale(1 / scaleX, 1 / scaleY);

    const width = (this.width || 0) * scaleX;
    const height = (this.height || 0) * scaleY;
    const radius = this.rx || 8;

    // 手动绘制圆角矩形背景,确保圆角不随缩放变形
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.fillStyle = TEXT_CLIP_BACKGROUND;
    ctx.fill();

    // 绘制边框,确保边框宽度不随缩放变化
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();

    // 绘制文本
    ctx.fillStyle = 'rgba(255,255,255,0.9)';
    ctx.font = '12px Inter, sans-serif';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';

    // 移动到左边缘 8 像素,垂直居中位置
    ctx.fillText(this.label, -width / 2 + 8, 0);

    ctx.restore();
  }
}

滚动条

动画.gif

滑块宽度怎么算?barWidth = (视口宽度 / 内容宽度) * 轨道宽度

同时还加了:minWidth = 40防止内容太多时滑块小到点不到

滑块位置怎么算?leftOffset = (当前滚动 / 最大滚动距离) * 可滑动距离 可滑动距离 = 轨道总宽度 - 滑块自身宽度,可滑动距离也就是:滑块在轨道上“真正能移动的那一段距离”

import { Canvas } from 'fabric';
import { ITimeline, PointerEventLike } from '../types';

export type ScrollbarBar = {
  /** 滑块左边界 X 坐标 */
  left: number;
  /** 滑块右边界 X 坐标 */
  right: number;
  /** 滑块上边界 Y 坐标 */
  top: number;
  /** 滑块下边界 Y 坐标 */
  bottom: number;
  /** 最大可滚动距离(内容宽度 - 视口宽度) */
  maxOffset: number;
  /** 滚动轨道总宽度 */
  trackWidth: number;
  /** 滑块宽度 */
  barWidth: number;
};

/**
 * 1. 滚动条绘制在 Canvas 的顶层上下文(contextTop)上,不受 viewportTransform 影响
 * 2. 通过拦截 Canvas 的鼠标事件实现滚动条的拖拽交互
 * 3. 滑块宽度根据内容与视口的比例自动计算
 * 4. 当内容完全在视口内时自动隐藏滚动条
 */
export class HorizontalScrollbar {
  timeline: ITimeline;
  /** 滚动条滑块的高度(像素) */
  size = 8;
  /** 滚动条与画布边缘的间距(像素) */
  scrollSpace = 4;
  /** 滑块最小宽度,确保滑块始终可点击 */
  minWidth = 40;
  /** 滑块填充颜色 */
  fill = 'rgba(255,255,255,0.3)';
  /** 滑块边框颜色 */
  stroke = 'rgba(255,255,255,0.1)';
  /** 边框线宽 */
  lineWidth = 1;
  bar: ScrollbarBar | null = null;
  /** 是否处于拖拽滚动条状态 */
  dragging = false;
  /** 拖拽开始时的鼠标 X 坐标 */
  dragStartX = 0;
  /** 拖拽开始时的滚动位置 */
  dragStartScroll = 0;

  private originalMouseDown: ((e: PointerEventLike) => void) | null = null;
  private originalMouseMove: ((e: PointerEventLike) => void) | null = null;
  private originalMouseUp: ((e: PointerEventLike) => void) | null = null;

  constructor(timeline: ITimeline) {
    this.timeline = timeline;
    const canvas = timeline.canvas;

    const canvasInternal = canvas as unknown as {
      __onMouseDown?: (e: PointerEventLike) => void;
      _onMouseMove?: (e: PointerEventLike) => void;
      _onMouseUp?: (e: PointerEventLike) => void;
    };
    this.originalMouseDown = canvasInternal.__onMouseDown || null;
    this.originalMouseMove = canvasInternal._onMouseMove || null;
    this.originalMouseUp = canvasInternal._onMouseUp || null;

    canvasInternal.__onMouseDown = this.mouseDownHandler.bind(this);
    canvasInternal._onMouseMove = this.mouseMoveHandler.bind(this);
    canvasInternal._onMouseUp = this.mouseUpHandler.bind(this);

    this.beforeRenderHandler = this.beforeRenderHandler.bind(this);
    this.afterRenderHandler = this.afterRenderHandler.bind(this);
    canvas.on('before:render', this.beforeRenderHandler);
    canvas.on('after:render', this.afterRenderHandler);
  }

  dispose() {
    const canvas = this.timeline.canvas;
    const canvasInternal = canvas as unknown as {
      __onMouseDown?: (e: PointerEventLike) => void;
      _onMouseMove?: (e: PointerEventLike) => void;
      _onMouseUp?: (e: PointerEventLike) => void;
    };

    if (this.originalMouseDown)
      canvasInternal.__onMouseDown = this.originalMouseDown;
    if (this.originalMouseMove)
      canvasInternal._onMouseMove = this.originalMouseMove;
    if (this.originalMouseUp) canvasInternal._onMouseUp = this.originalMouseUp;

    // 移除渲染事件监听
    canvas.off('before:render', this.beforeRenderHandler);
    canvas.off('after:render', this.afterRenderHandler);
  }

  /**
   * 渲染前处理
   *
   * 重置 Canvas 顶层上下文的变换矩阵为单位矩阵。
   *
   * 为什么需要这样做?
   *
   * Fabric.js 在渲染时会应用 viewportTransform(用于实现滚动效果),
   * 这个变换会影响所有后续的绘制操作。但滚动条应该始终固定在视口底部,
   * 不应该随着内容滚动而移动。
   *
   * 通过在渲染前重置变换矩阵,我们确保滚动条的绘制坐标系
   * 始终与视口坐标系一致,不受滚动影响。
   */
  beforeRenderHandler() {
    const ctx = this.timeline.canvas.contextTop;
    if (!ctx) return;
    ctx.save();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.restore();
  }

  /**
   * 渲染后处理 - 绘制滚动条
   *
   * 在 Canvas 主内容渲染完成后,在顶层上下文绘制滚动条滑块。
   * 滑块的宽度和位置根据内容与视口的比例计算。
   *
   * 计算公式:
   * 滑块宽度 = (视口宽度 / 内容宽度) * 轨道宽度
   * 滑块位置 = (当前滚动位置 / 最大滚动距离) * 可滑动距离
   */
  afterRenderHandler() {
    const canvas = this.timeline.canvas;
    const ctx = canvas.contextTop;
    if (!ctx) return;

    const contentWidth = this.timeline.contentWidth;

    /**
     * 当内容宽度不超过视口宽度时,隐藏滚动条
     * 这意味着所有内容都可见,不需要滚动。
     */
    if (contentWidth <= canvas.width) {
      this.bar = null;
      // 清除之前可能绘制的滚动条区域
      ctx.clearRect(
        0,
        canvas.height - this.size - this.scrollSpace - this.lineWidth,
        canvas.width,
        this.size + this.scrollSpace + this.lineWidth
      );
      return;
    }

    /**
     * 计算滚动轨道宽度
     * 轨道是滑块可滑动的区域,两侧留出间距
     */
    const trackWidth = canvas.width - this.scrollSpace * 2;

    /**
     * 计算滑块宽度
     * 滑块宽度反映视口占内容的比例:
     * - 内容越多,滑块越小
     * - 但最小不低于 minWidth,确保始终可点击
     */
    const barWidth = Math.max(
      Math.floor((canvas.width / contentWidth) * trackWidth),
      this.minWidth
    );

    /**
     * 计算最大可滚动距离
     * 即内容超出视口的部分
     */
    const maxOffset = contentWidth - canvas.width;

    /**
     * 计算滑块位置
     * 滑块位置 = 间距 + (滚动比例 * 可滑动距离)
     * 滚动比例 = 当前滚动位置 / 最大滚动距离
     * 可滑动距离 = 轨道宽度 - 滑块宽度
     */
    const leftOffset =
      (this.timeline.scrollX / maxOffset) * Math.max(0, trackWidth - barWidth);
    const left = this.scrollSpace + leftOffset;

    /**
     * 计算滑块垂直位置
     * 滑块位于画布底部,与底部边缘保持间距
     */
    const top = canvas.height - this.size - this.scrollSpace;

    /**
     * 保存滚动条几何信息
     * 用于后续的命中检测(判断鼠标是否点击在滑块上)
     */
    this.bar = {
      left,
      right: left + barWidth,
      top,
      bottom: top + this.size,
      maxOffset,
      trackWidth,
      barWidth
    };

    ctx.clearRect(
      0,
      canvas.height - this.size - this.scrollSpace - this.lineWidth,
      canvas.width,
      this.size + this.scrollSpace + this.lineWidth
    );

    ctx.save();
    ctx.fillStyle = this.fill;
    ctx.strokeStyle = this.stroke;
    ctx.lineWidth = this.lineWidth;
    ctx.beginPath();
    ctx.roundRect(left, top, barWidth, this.size, this.size / 2);
    ctx.fill();
    ctx.stroke();
    ctx.restore();
  }

  /**
   * 鼠标按下事件处理
   * 判断鼠标是否点击在滚动条滑块上:
   * - 如果是,进入拖拽模式,阻止事件继续传播
   * - 如果不是,调用 Canvas 原始的鼠标按下处理
   *
   */
  mouseDownHandler(e: PointerEventLike) {
    const canvas = this.timeline.canvas;

    /**
     * 获取鼠标在视口坐标系中的位置
     * getViewportPoint 返回的是相对于画布左上角的坐标,
     * 不受 viewportTransform 影响,适合用于滚动条命中检测
     */
    const p = canvas.getViewportPoint(e);

    if (this.bar) {
      /**
       * 命中检测:判断鼠标坐标是否在滑块矩形范围内
       */
      const hit =
        p.x >= this.bar.left &&
        p.x <= this.bar.right &&
        p.y >= this.bar.top &&
        p.y <= this.bar.bottom;

      if (hit) {
        /**
         * 进入拖拽模式
         * 记录拖拽起始状态:
         * - dragStartX: 鼠标起始 X 坐标
         * - dragStartScroll: 起始滚动位置
         *
         * 后续在 mouseMoveHandler 中根据鼠标移动距离计算新的滚动位置
         */
        this.dragging = true;
        this.dragStartX = p.x;
        this.dragStartScroll = this.timeline.scrollX;
        return; // 阻止事件继续传播,不调用原始处理函数
      }
    }

    /**
     * 未命中滚动条,调用 Canvas 原始的鼠标按下处理
     * 通过原型链调用原始方法,确保 Fabric.js 的正常交互(如选择对象)不受影响
     */
    const proto = Canvas.prototype as unknown as {
      __onMouseDown: (e: PointerEventLike) => void;
    };
    return proto.__onMouseDown.call(canvas, e);
  }

  /**
   * 鼠标移动事件处理
   * 如果处于拖拽模式,根据鼠标移动距离更新滚动位置;
   * 否则调用 Canvas 原始的鼠标移动处理。
   */
  mouseMoveHandler(e: PointerEventLike) {
    /**
     * 非拖拽状态,调用原始处理函数
     */
    if (!this.dragging || !this.bar) {
      const proto = Canvas.prototype as unknown as {
        _onMouseMove: (e: PointerEventLike) => void;
      };
      return proto._onMouseMove.call(this.timeline.canvas, e);
    }

    const canvas = this.timeline.canvas;
    const p = canvas.getViewportPoint(e);

    /**
     * 计算滚动位置
     * 滚动距离映射:
     * - 鼠标移动距离(像素) -> 滚动距离(像素)
     * - 比例 = 鼠标移动距离 / 可滑动距离
     * - 滚动距离 = 比例 * 最大滚动距离
     *
     * 这样可以实现滑块移动 1 像素,内容滚动相应比例的距离
     */
    const delta = p.x - this.dragStartX;
    const maxOffset = this.bar.maxOffset;
    const trackAvailable = Math.max(1, this.bar.trackWidth - this.bar.barWidth);
    const scrollDelta = (delta / trackAvailable) * maxOffset;

    /**
     * 更新滚动位置
     * setScrollX 内部会处理边界约束(不超过最大滚动距离)
     */
    this.timeline.setScrollX(this.dragStartScroll + scrollDelta);
  }

  /**
   * 鼠标抬起事件处理
   * 如果处于拖拽模式,结束拖拽;
   * 否则调用 Canvas 原始的鼠标抬起处理。
   */
  mouseUpHandler(e: PointerEventLike) {
    /**
     * 非拖拽状态,调用原始处理函数
     */
    if (!this.dragging) {
      const proto = Canvas.prototype as unknown as {
        _onMouseUp: (e: PointerEventLike) => void;
      };
      proto._onMouseUp.call(this.timeline.canvas, e);
    }

    /**
     * 重置 dragging 标志,后续鼠标移动不再触发滚动
     */
    this.dragging = false;
  }
}
import { Canvas, Rect } from 'fabric';
import { useEffect, useRef } from 'react';
import { HorizontalScrollbar } from '../../core/timeline/scrollbar';

export default function ScrollBarDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 200,
      backgroundColor: '#0f172a',
      selection: false
    });

    const timeline = {
      canvas,
      contentWidth: 2000,
      scrollX: 0,
      setScrollX(x: number) {
        this.scrollX = Math.max(
          0,
          Math.min(x, this.contentWidth - canvas.width)
        );
        canvas.setViewportTransform([1, 0, 0, 1, -this.scrollX, 0]);
        canvas.requestRenderAll();
      }
    } as any;

    const scrollbar = new HorizontalScrollbar(timeline);

    const rect1 = new Rect({
      left: 50,
      top: 50,
      width: 200,
      height: 60,
      fill: '#134e4a',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: true
    });

    rect1.on('moving', () => {
      const right = rect1.left! + rect1.width!;
      const newContentWidth = Math.max(canvas.width, right + 50);
      timeline.contentWidth = newContentWidth;
      canvas.requestRenderAll();
    });

    canvas.add(rect1);

    return () => {
      scrollbar.dispose();
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

参考线绘制

image.png

整体流程是怎样的?可以理解成 5 步:

  1. 清掉旧的辅助线
  2. 收集画布上所有“可当参照物”的边
  3. 计算当前拖拽物体的边
  4. 找最近的一条线(距离小于 10px)
  5. 画辅助线 + 修正位置(吸附)

第一步:清理旧辅助线

clearAuxiliaryObjects()每次拖动都会重新计算吸附线,所以必须先把旧的删掉,避免画布上越画越多线,它的做法是:

  • 遍历所有对象
  • 找到带 isAlignmentAuxiliary 标记的
  • 删除

第二步:收集“所有可吸附的边”

getLineGuideStops()它做的事情是:

  • 遍历画布所有可见对象
  • 跳过当前拖动对象
  • 跳过辅助线本身
  • 获取每个对象的 boundingRect

最终得到一个列表:

[
  { val: 100 },
  { val: 250 },
  { val: 300 },
  ...
]

第三步:计算当前对象的吸附边

getObjectSnappingEdges()它只算两个东西:当前对象的左边、当前对象的右边

并记录:

guide   // 当前边的位置
offset  // 实际坐标偏移
snap    // 是 start 还是 end

第四步:找最近的一条线

diff = Math.abs(lineGuide.val - itemBound.guide)

如果:diff < 10说明已经足够接近,然后把所有满足条件的候选放进数组进行排序,取最小的那个,这样可以避免多条线同时吸附导致抖动

resultV.sort((a, b) => a.diff - b.diff)[0]

第五步:画对齐线

new Line([x, 0, x, 2000])

import { Line, type Canvas, type FabricObject } from 'fabric';
import { AlignmentAuxiliary, LineGuide, TimelineObject, Guide } from '../types';

/**
 * 清除画布上的所有辅助对齐线
 */
export const clearAuxiliaryObjects = (
  canvas: Canvas,
  allObjects: FabricObject[]
) => {
  allObjects.forEach(obj => {
    if ((obj as AlignmentAuxiliary).isAlignmentAuxiliary) canvas.remove(obj);
  });
};

/**
 * 计算对象的对齐停靠点
 * 返回对象左边界与右边界的可吸附位置
 */
export const getStopsForObject = (
  start: number,
  distance: number,
  drawStart: number,
  drawDistance: number
) => {
  const stops = [start, start + distance];
  return stops.map(stop => ({
    val: stop,
    start: drawStart,
    end: drawStart + drawDistance
  }));
};

/**
 * 获取画布上所有可用作对齐基准的停靠点
 * 仅收集可见的 Clip,对齐线本身不会参与计算
 */
export const getLineGuideStops = (skipShapes: FabricObject[], canvas: Canvas) => {
  const vertical: LineGuide[] = [];
  canvas
    .getObjects()
    .filter(o => o.visible && (o as TimelineObject).elementId)
    .forEach(guideObject => {
      if (
        skipShapes.includes(guideObject) ||
        (guideObject as AlignmentAuxiliary).isAlignmentAuxiliary
      ) {
        return;
      }
      const box = guideObject.getBoundingRect();
      vertical.push(
        ...getStopsForObject(box.left, box.width, box.top, box.height)
      );
    });
  return { vertical, horizontal: [] as LineGuide[] };
};

/**
 * 获取当前拖拽对象的吸附边缘
 * 只计算水平吸附(左边界、右边界)
 */
export const getObjectSnappingEdges = (target: FabricObject) => {
  const rect = target.getBoundingRect();
  return {
    vertical: [
      {
        guide: Math.round(rect.left),
        offset: Math.round((target.left || 0) - rect.left),
        snap: 'start'
      },
      {
        guide: Math.round(rect.left + rect.width),
        offset: Math.round((target.left || 0) - rect.left - rect.width),
        snap: 'end'
      }
    ],
    horizontal: [] as Array<{ guide: number; offset: number; snap: string }>
  };
};

/**
 * 计算当前位置最接近的引导对齐线
 * 仅返回最接近的垂直引导,避免多条线干扰
 */
export const getGuides = (
  lineGuideStops: { vertical: LineGuide[]; horizontal: LineGuide[] },
  itemBounds: {
    vertical: { guide: number; offset: number; snap: string }[];
    horizontal: { guide: number; offset: number; snap: string }[];
  }
) => {
  const resultV: Array<{ lineGuide: number; diff: number; offset: number }> =
    [];
  lineGuideStops.vertical.forEach(lineGuide => {
    itemBounds.vertical.forEach(itemBound => {
      const diff = Math.abs(lineGuide.val - itemBound.guide);
      if (diff < 10) {
        resultV.push({
          lineGuide: lineGuide.val,
          diff,
          offset: itemBound.offset
        });
      }
    });
  });
  const guides: Guide[] = [];
  const minV = resultV.sort((a, b) => a.diff - b.diff)[0];
  if (minV) {
    guides.push({
      lineGuide: minV.lineGuide,
      offset: minV.offset,
      orientation: 'V'
    });
  }
  return guides;
};

/**
 * 在画布上绘制对齐线
 * 线条绘制在主画布之上,并标记为辅助对象
 */
export const drawGuides = (guides: Guide[], canvas: Canvas) => {
  guides.forEach(lineGuide => {
    if (lineGuide.orientation === 'V') {
      const line = new Line(
        [lineGuide.lineGuide, 0, lineGuide.lineGuide, 2000],
        {
          strokeWidth: 2,
          stroke: '#ffffff',
          strokeLineCap: 'square',
          selectable: false,
          evented: false,
          objectCaching: false
        }
      );
      (line as AlignmentAuxiliary).isAlignmentAuxiliary = true;
      canvas.add(line);
    }
  });
};
import { Canvas, Rect } from 'fabric';
import { useEffect, useRef } from 'react';
import {
  clearAuxiliaryObjects,
  drawGuides,
  getGuides,
  getLineGuideStops,
  getObjectSnappingEdges
} from '../../core/timeline/utils/guidelines';

export default function GuidelinesDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 300,
      backgroundColor: '#0f172a',
      selection: false
    });

    const rect1 = new Rect({
      left: 100,
      top: 100,
      width: 150,
      height: 60,
      fill: '#134e4a',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: false
    });
    (rect1 as any).elementId = 'rect1';

    const rect2 = new Rect({
      left: 350,
      top: 100,
      width: 200,
      height: 60,
      fill: '#14532d',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: false
    });
    (rect2 as any).elementId = 'rect2';

    const rect3 = new Rect({
      left: 600,
      top: 100,
      width: 120,
      height: 60,
      fill: '#1e1b4b',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: false
    });
    (rect3 as any).elementId = 'rect3';

    canvas.add(rect1, rect2, rect3);

    canvas.on('object:moving', e => {
      const target = e.target;
      if (!target) return;

      clearAuxiliaryObjects(canvas, canvas.getObjects());

      const lineGuideStops = getLineGuideStops([target], canvas);
      const itemBounds = getObjectSnappingEdges(target);
      const guides = getGuides(lineGuideStops, itemBounds);

      if (guides.length > 0) {
        const guide = guides[0];
        target.set({
          left: guide.lineGuide + guide.offset
        });
        target.setCoords();
        drawGuides(guides, canvas);
      }
    });

    canvas.on('mouse:up', () => {
      clearAuxiliaryObjects(canvas, canvas.getObjects());
    });

    return () => {
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

时间轴缩放

核心代码:

const timeAtMouse = mouseX / oldPixelsPerSecond;
const newMouseX = timeAtMouse * this.pixelsPerSecond;
const newScrollX = newMouseX - (mouseX - this.scrollX);

第一步:算出鼠标指向的时间点时间 = 像素 / 像素每秒

第二步:缩放后,这个时间应该在哪个像素?新像素 = 时间 * 新像素每秒

第三步:算需要补偿多少滚动newScrollX = 新像素位置 - 视口中的鼠标位置

// 监听滚轮事件,支持横向滚动与 Ctrl + 滚轮缩放
this.canvas.on('mouse:wheel', opt => {
  const e = opt.e;
  if (e.ctrlKey) {
    // Ctrl + 滚轮:以鼠标位置为锚点缩放,保持时间点对齐
    const delta = e.deltaY;
    const pointer = this.canvas.getPointer(e);
    this.handleZoom(delta, pointer.x);
  } else {
    // 普通滚轮:横向滚动(优先横向 delta)
    const delta =
      Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    this.setScrollX(this.scrollX + delta);
  }
  e.preventDefault();
  e.stopPropagation();
});
  /**
   * 处理时间轴缩放逻辑
   * @param delta 滚轮增量
   * @param mouseX 鼠标在画布上的 X 坐标(包含滚动偏移)
   */
  handleZoom(delta: number, mouseX: number) {
    const zoomFactor = 1.1;
    const oldPixelsPerSecond = this.pixelsPerSecond;

    // 计算新的缩放比例
    if (delta > 0) {
      this.pixelsPerSecond /= zoomFactor;
    } else {
      this.pixelsPerSecond *= zoomFactor;
    }

    /** 最小缩放(像素/秒) */
    const minPixelsPerSecond = 10;
    /** 最大缩放(像素/秒),用于支持帧级显示 */
    const maxPixelsPerSecond = 3000;
    this.pixelsPerSecond = Math.max(
      minPixelsPerSecond,
      Math.min(maxPixelsPerSecond, this.pixelsPerSecond)
    );

    if (Math.abs(oldPixelsPerSecond - this.pixelsPerSecond) < 0.01) return;

    // 关键逻辑:保持鼠标指针下的时间点在缩放后位置不变
    // 时间点 = (mouseX) / oldPixelsPerSecond
    // 缩放后的像素位置 = 时间点 * newPixelsPerSecond
    // 滚动补偿 = 缩放后的像素位置 - (mouseX - scrollX)
    const timeAtMouse = mouseX / oldPixelsPerSecond;
    const newMouseX = timeAtMouse * this.pixelsPerSecond;
    const newScrollX = newMouseX - (mouseX - this.scrollX);

    // 更新所有 Clip 的位置和宽度
    this.updateClipsVisualsFromTime();

    // 更新内容宽度(轨道背景也会随之更新)
    this.updateContentWidth();

    // 应用新的滚动位置
    this.setScrollX(newScrollX);

    this.canvas.requestRenderAll();
  }
  /**
   * 设置时间轴横向滚动位置
   * 通过 viewportTransform 将所有对象整体平移
   */
  setScrollX(value: number) {
    const maxScroll = Math.max(0, this.contentWidth - this.canvas.width);
    const next = Math.max(0, Math.min(maxScroll, value));
    if (Math.abs(next - this.scrollX) < 0.5) return;
    this.scrollX = next;
    const vpt = (
      this.canvas.viewportTransform || ([1, 0, 0, 1, 0, 0] as Mat2D)
    ).slice(0) as Mat2D;
    // 使用 viewportTransform 平移内容
    vpt[4] = -this.scrollX;
    vpt[5] = 0;
    this.canvas.setViewportTransform(vpt);
    // this.canvas.getObjects().forEach(obj => {
    //   // 修正控制点位置,避免滚动时偏移
    //   if (obj.hasControls) obj.setCoords();
    // });

    if (this.ruler) this.ruler.render(); // 同步更新刻度尺

    this.canvas.requestRenderAll();
  }

拖拽的核心代码(包括轨道的裁剪)

/**
   * 配置所有拖拽、缩放交互逻辑及约束
   * 包含缩放约束、防重叠、对齐辅助线与轨道吸附
   */
  setupDragSnapping() {
    /**
     * 缩放事件处理
     * 核心功能:
     * 1. 约束最小宽度,避免 Clip 过小
     * 2. 防止 Clip 跨越相邻 Clip(防重叠)
     * 3. 对于视频/音频 Clip,实现裁剪式缩放(拖动端点改变裁剪窗口)
     * 4. 约束裁剪范围不超过媒体源时长
     */
    this.canvas.on('object:scaling', opt => {
      const target = opt.target as TimelineObject;
      if (!target || !target.elementId) return;

      const transform = opt.transform;
      if (!transform) return;

      // 只处理左右控制点
      const corner = transform.corner;
      if (corner !== 'ml' && corner !== 'mr') return;

      const originalWidth = target.width || 0;
      if (originalWidth === 0) return;

      const timelineTarget = target as TimelineObject;
      const isMediaClip = ['video', 'audio'].includes(timelineTarget.clipType);

      if (isMediaClip) {
        const mediaTarget = target as TimelineObject;
        if (mediaTarget.trimStart === undefined) mediaTarget.trimStart = 0;
        if (mediaTarget.trimEnd === undefined || mediaTarget.trimEnd === 0) {
          mediaTarget.trimEnd = mediaTarget.duration ?? 0;
        }
        // 记录缩放开始时的裁剪窗口,用于计算裁剪增量
        // 这样可以确保"回拉"操作不会超过原始裁剪量
        if (mediaTarget.__trimStartOriginal === undefined) {
          mediaTarget.__trimStartOriginal = mediaTarget.trimStart ?? 0;
        }
        if (mediaTarget.__trimEndOriginal === undefined) {
          mediaTarget.__trimEndOriginal = mediaTarget.trimEnd ?? 0;
        }
      }

      // 获取同一轨道上的其他 Clip,用于防重叠检测
      const trackIndex = this.getTrackIndexForObject(target);
      const siblings = this.canvas
        .getObjects()
        .filter(obj => (obj as TimelineObject).elementId && obj !== target)
        .map(obj => obj as TimelineObject)
        .filter(obj => this.getTrackIndexForObject(obj) === trackIndex)
        .map(obj => ({ obj, ...this.getClipBounds(obj) }))
        .sort((a, b) => a.left - b.left);

      // 记录缩放开始时的位置和尺寸
      const startLeft = transform.original.left;
      const startScaleX = transform.original.scaleX || 1;
      const startRight = startLeft + originalWidth * startScaleX;

      // 查找左右相邻的 Clip
      let leftNeighbor: { left: number; right: number } | null = null;
      let rightNeighbor: { left: number; right: number } | null = null;

      for (const clip of siblings) {
        if (clip.left < startLeft) {
          leftNeighbor = clip;
          continue;
        }
        rightNeighbor = clip;
        break;
      }

      // 计算最小缩放比例,确保 Clip 不会太小
      const minScale = MIN_CLIP_WIDTH / originalWidth;

      // ========== 右侧控制点缩放(mr)==========
      // 拖动右侧控制点:左边界固定,改变右边界
      // 对于媒体类型:trimStart 保持不变,trimEnd 随宽度变化
      if (corner === 'mr') {
        // 计算最大右边界(受相邻 Clip 或内容宽度限制)
        const maxRight = rightNeighbor ? rightNeighbor.left : this.contentWidth;
        const maxWidth = maxRight - startLeft;
        let maxScale = maxWidth / originalWidth;

        // 媒体类型额外约束:不能超过源文件末尾
        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimStart = mediaTarget.__trimStartOriginal ?? 0;
          const sourceDuration = mediaTarget.sourceDuration || 0;
          if (sourceDuration > 0) {
            // 从当前 trimStart 到源文件末尾的剩余时长
            const maxDurationBySource = sourceDuration - baseTrimStart;
            const maxScaleBySource =
              (maxDurationBySource * this.pixelsPerSecond) / originalWidth;
            maxScale = Math.min(maxScale, maxScaleBySource);
          }
        }

        // 约束缩放比例在有效范围内
        let newScaleX = timelineTarget.scaleX || 1;
        if (newScaleX < minScale) newScaleX = minScale;
        if (newScaleX > maxScale) newScaleX = maxScale;

        // 应用缩放:左边界锚定,只改变宽度
        target.set({
          scaleX: newScaleX,
          left: startLeft
        });

        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimStart = mediaTarget.__trimStartOriginal ?? 0;
          const sourceDuration = mediaTarget.sourceDuration || 0;
          const finalWidth =
            (timelineTarget.width || 0) * (timelineTarget.scaleX || 1);
          const finalDuration = finalWidth / this.pixelsPerSecond;
          // 右侧缩放:trimStart 固定,trimEnd 随宽度增加
          mediaTarget.trimStart = baseTrimStart;
          mediaTarget.trimEnd =
            sourceDuration > 0
              ? Math.min(baseTrimStart + finalDuration, sourceDuration)
              : baseTrimStart + finalDuration;
        }
      } else if (corner === 'ml') {
        // ========== 左侧控制点缩放(ml)==========
        // 拖动左侧控制点:右边界固定,改变左边界
        // 对于媒体类型:trimEnd 保持不变,trimStart 随宽度变化

        // 计算最小左边界(受相邻 Clip 或 0 限制)
        const minLeft = leftNeighbor ? leftNeighbor.right : 0;
        const maxWidth = startRight - minLeft;
        let maxScale = maxWidth / originalWidth;

        // 媒体类型额外约束:不能超过源文件开头
        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimEnd = mediaTarget.__trimEndOriginal ?? 0;
          const sourceDuration = mediaTarget.sourceDuration || 0;
          // 从源文件开头到当前 trimEnd 的最大可用时长
          const maxDurationBySource = sourceDuration
            ? Math.min(baseTrimEnd || sourceDuration, sourceDuration)
            : baseTrimEnd;
          if (maxDurationBySource > 0) {
            const maxScaleBySource =
              (maxDurationBySource * this.pixelsPerSecond) / originalWidth;
            maxScale = Math.min(maxScale, maxScaleBySource);
          }
        }

        // 约束缩放比例在有效范围内
        let newScaleX = timelineTarget.scaleX || 1;
        if (newScaleX < minScale) newScaleX = minScale;
        if (newScaleX > maxScale) newScaleX = maxScale;

        // 应用缩放:右边界锚定,改变左边界位置
        target.set({
          scaleX: newScaleX,
          left: startRight - originalWidth * newScaleX
        });

        // 更新媒体类型的裁剪窗口
        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimEnd = mediaTarget.__trimEndOriginal ?? 0;
          const finalWidth =
            (timelineTarget.width || 0) * (timelineTarget.scaleX || 1);
          const finalDuration = finalWidth / this.pixelsPerSecond;
          if (baseTrimEnd > 0) {
            // 左侧缩放:trimEnd 固定,trimStart 随宽度变化
            // 向左拖动 = 扩展开头 = trimStart 减小
            // 向右拖动 = 裁剪开头 = trimStart 增加
            mediaTarget.trimEnd = baseTrimEnd;
            mediaTarget.trimStart = Math.max(0, baseTrimEnd - finalDuration);
          }
        }
      }

      // 同步更新时间属性(将像素转换为秒)
      const finalWidth = (target.width || 0) * (target.scaleX || 1);
      target.startTime = (target.left || 0) / this.pixelsPerSecond;
      target.duration = finalWidth / this.pixelsPerSecond;

      // 更新内容宽度并重新渲染
      this.updateContentWidth();
      target.setCoords();
      this.canvas.requestRenderAll();
    });

    // 2. 移动过程中:执行辅助线吸附和重叠修正
    this.canvas.on('object:moving', opt => {
      const target = opt.target as TimelineObject;
      if (!target || !target.elementId) return;

      // 辅助对齐线吸附逻辑
      const allObjects = this.canvas.getObjects();
      const lineGuideStops = getLineGuideStops([target], this.canvas);
      const itemBounds = getObjectSnappingEdges(target);
      const guides = getGuides(lineGuideStops, itemBounds);

      clearAuxiliaryObjects(this.canvas, allObjects);
      if (guides.length > 0) drawGuides(guides, this.canvas);

      guides.forEach(lineGuide => {
        if (lineGuide.orientation === 'V') {
          target.set('left', lineGuide.lineGuide + lineGuide.offset);
        }
      });

      // 实时防重叠修正
      const previousLeft = target.__prevLeft;
      const currentLeft = target.left || 0;
      const direction =
        previousLeft === undefined || currentLeft >= previousLeft ? 1 : -1;
      this.resolveClipOverlap(target, direction);
      target.__prevLeft = target.left || 0;

      // 同步更新时间属性
      target.startTime = (target.left || 0) / this.pixelsPerSecond;

      this.updateContentWidth(); // 拖拽时实时更新内容宽度
      target.setCoords();
      this.canvas.requestRenderAll();
    });

    // 3. 交互结束后:处理轨道增删、回弹及坐标校准
    this.canvas.on('object:modified', (opt: TimelineEvent) => {
      const target = opt.target as TimelineObject;
      if (!target || !target.elementId) return;

      const width = (target.width || 0) * (target.scaleX || 1);
      const height = (target.height || 0) * (target.scaleY || 1);
      const centerY = (target.top || 0) + height / 2;

      // --- 动态轨道判定逻辑 ---
      const firstTrackTop = this.trackTops[0];
      const lastTrackTop = this.trackTops[this.trackCount - 1];

      if (centerY < firstTrackTop) {
        // 拖动到顶部边缘以上:在最上方插入新轨道
        this.canvas.getObjects().forEach(obj => {
          const t = obj as TimelineObject;
          if (t.elementId && t.trackIndex !== undefined) {
            t.trackIndex += 1;
          }
        });
        target.trackIndex = 0;
      } else if (centerY > lastTrackTop + TRACK_HEIGHT) {
        // 拖动到底部边缘以下:在最下方新增轨道
        target.trackIndex = this.trackCount;
      } else {
        // 落在现有轨道范围内:吸附到最近轨道
        target.trackIndex = this.getClosestTrackIndex(centerY);
      }

      const trackTop = this.getTrackTop(target.trackIndex);
      target.set({
        width: Math.max(MIN_CLIP_WIDTH, width),
        top: trackTop + (TRACK_HEIGHT - CLIP_HEIGHT) / 2,
        scaleX: 1
      });

      // 最终重叠检测:若空间仍不足,触发回弹逻辑
      const fits = this.resolveClipOverlap(target, 1);
      if (!fits && target.__originalLeft !== undefined) {
        target.set({
          left: target.__originalLeft,
          top: target.__originalTop
        });
        // 恢复后同步 trackIndex 并执行对齐
        const oldCenterY = (target.top || 0) + height / 2;
        target.trackIndex = this.getClosestTrackIndex(oldCenterY);
        this.resolveClipOverlap(target, 1);
      }

      // 执行轨道清理及重新排列
      this.syncTrackIndices();
      this.updateContentWidth(); // 交互结束后同步内容宽度

      // 同步最终的时间属性
      const finalWidth = (target.width || 0) * (target.scaleX || 1);
      target.startTime = (target.left || 0) / this.pixelsPerSecond;
      target.duration = finalWidth / this.pixelsPerSecond;

      // 清理交互临时属性
      target.__originalLeft = undefined;
      target.__originalTop = undefined;
      target.__prevLeft = undefined;
      // 清理裁剪交互基准,避免影响下一次缩放
      target.__trimStartOriginal = undefined;
      target.__trimEndOriginal = undefined;
      target.setCoords();
      this.canvas.requestRenderAll();
    });

    // 4. 鼠标抬起:清除辅助线
    this.canvas.on('mouse:up', () => {
      clearAuxiliaryObjects(this.canvas, this.canvas.getObjects());
      this.canvas.requestRenderAll();
    });
  }
  /**
 * 核心防重叠逻辑:
 * 在移动或缩放过程中,检测并修正位置,确保 Clip 不会与其他 Clip 发生重叠
 * @param target 当前操作的对象
 * @param direction 移动方向(1:向右,-1:向左)
 * @returns 是否能完整放下该对象
 */
resolveClipOverlap(target: TimelineObject, direction: number): boolean {
  const trackIndex = this.getTrackIndexForObject(target);
  const bounds = this.getClipBounds(target);

  // 获取同一轨道上的所有其他 Clip 并按左边界排序
  const siblings = this.canvas
    .getObjects()
    .filter(obj => (obj as TimelineObject).elementId && obj !== target)
    .map(obj => obj as TimelineObject)
    .filter(obj => this.getTrackIndexForObject(obj) === trackIndex)
    .map(obj => ({ obj, ...this.getClipBounds(obj) }))
    .sort((a, b) => a.left - b.left);

  let leftNeighbor: { left: number; right: number } | null = null;
  let rightNeighbor: { left: number; right: number } | null = null;

  // 寻找左右最近邻居
  for (const clip of siblings) {
    if (clip.left < bounds.left) {
      leftNeighbor = clip;
      continue;
    }
    rightNeighbor = clip;
    break;
  }

  // 计算可用空间范围
  const leftBound = leftNeighbor ? leftNeighbor.right : 0;
  const rightBound = rightNeighbor
    ? rightNeighbor.left - bounds.width
    : Number.POSITIVE_INFINITY;

  let nextLeft = bounds.left;
  /** 检测空间是否足够 */
  const fits = rightBound >= leftBound;
  if (!fits) {
    // 空间不足时,根据移动方向推送到边界
    nextLeft = direction >= 0 ? rightBound : leftBound;
  } else {
    // 空间足够时,确保不越过邻居边界
    if (nextLeft < leftBound) nextLeft = leftBound;
    if (nextLeft > rightBound) nextLeft = rightBound;
  }

  // 时间轴总范围约束(允许拖拽到整个时间轴容量范围)
  // const absoluteMaxRight = this.contentWidth;
  // const maxLeft = Math.max(0, absoluteMaxRight - bounds.width);
  if (nextLeft < 0) nextLeft = 0;
  // if (nextLeft > maxLeft) nextLeft = maxLeft;

  target.set('left', nextLeft);
  return fits;
}
❌
❌