普通视图

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

React 核心揭秘:虚拟 DOM 原理与 Diff 算法深度解析

作者 NEXT06
2026年2月26日 11:10

在前端工程化领域,React 的虚拟 DOM(Virtual DOM)机制经常被误解。许多开发者认为“虚拟 DOM 的引入是为了提升性能”,这一观点既不准确也不严谨。

本文将从源码架构视角,深入剖析 React 虚拟 DOM 的内存结构、安全性设计,以及 Reconciler(协调器)层核心的 Diff 算法实现。

一、引言:打破“虚拟 DOM 更快”的迷思

首先必须澄清一个技术事实:没有任何框架的运行时性能可以超越极致优化的原生 DOM 操作。

虚拟 DOM 本质上是 JavaScript 对象,React 在每一次更新时,都需要经过“创建对象 -> Diff 比对 -> 生成 Patch -> 更新真实 DOM”这一过程。相比直接操作 innerHTML 或 appendChild,它多出了繁重的 JS 计算层。

既然如此,为何 React 依然选择虚拟 DOM?其核心价值在于:

  1. 性能下限的保障:手动优化 DOM 操作极其依赖开发者水平。虚拟 DOM 结合批处理(Batch Update)机制,提供了一个“足够快”的性能下限,避免了低效 DOM 操作导致的页面卡顿。
  2. 跨平台能力:虚拟 DOM 是对 UI 的抽象描述(Abstract Syntax Tree of UI)。这一抽象层使得 React 可以通过不同的渲染器(Renderer)映射到不同平台:Web 端映射为 DOM,Native 端映射为原生视图(React Native),甚至映射为 PDF 或终端 UI。
  3. 声明式编程与开发效率:开发者只需关注状态(State)的变化,无需手动维护 DOM 状态,极大降低了应用复杂度。

二、核心结构:虚拟 DOM 在内存中的形态

React 的开发流程经历了 JSX -> Babel 编译 -> React.createElement -> ReactElement 对象的转化过程。

1. 内存结构与 React.createElement

JSX 仅仅是语法糖。在编译时,标签会被转换为 React.createElement 调用。该函数的主要职责是处理参数,构建并返回一个描述节点的 JavaScript 对象,即虚拟 DOM 节点(VNode)。

JavaScript

// 简化的 ReactElement 结构演示
const ReactElement = function(type, key, ref, props, owner) {
  const element = {
    // 核心安全标识
    $$typeof: REACT_ELEMENT_TYPE,

    // 元素的内置属性
    type: type,
    key: key,
    ref: ref,
    props: props,

    // 记录创建该元素的组件
    _owner: owner,
  };

  return element;
};

2. $$typeof 与 XSS 防御

在上述结构中,$$typeof 属性至关重要,它是 React 防止 XSS 攻击的一道防线。

攻击场景:假设服务器端存在漏洞,允许用户存储任意 JSON 对象,而前端直接将该对象作为组件渲染。黑客可以构造一个恶意的 JSON 对象来模拟 ReactElement。

防御机制
REACT_ELEMENT_TYPE 是一个 Symbol 类型的值:

JavaScript

const REACT_ELEMENT_TYPE = Symbol.for('react.element');

由于 JSON 不支持 Symbol 类型,当数据经过 JSON.stringify 序列化再传输时,Symbol 会丢失。React 在渲染时会严格校验 element.$$typeof === REACT_ELEMENT_TYPE。如果数据来自不受信任的服务端 JSON,该属性将缺失或无效,React 会拒绝渲染,从而拦截潜在的 XSS 攻击。

三、算法揭秘:Diff 算法的设计权衡

React 的核心是协调(Reconciliation),即通过 Diff 算法计算新旧虚拟 DOM 树差异的过程。

在计算机科学中,计算两棵树的最小编辑距离(Edit Distance)的标准算法复杂度为 

O(n3)O(n3)

。对于一个包含 1000 个节点的应用,这将导致 10 亿次计算,在浏览器端显然不可接受。

为了将复杂度降低至 

O(n)O(n)

,React 基于 Web UI 的特点,实施了大胆的启发式算法(Heuristic Algorithm) ,主要基于以下三大策略:

策略一:分层比较(Tree Diff)

Web UI 中,DOM 节点跨层级移动的操作极其罕见。React 选择忽略跨层级的节点移动

Diff 算法只对同一层级的节点进行比较。如果一个 DOM 节点在更新前后跨越了层级,React 不会尝试复用它,而是直接销毁旧节点,并在新位置重新创建新节点。

策略二:类型检查(Component Diff)

React 认为:不同类型的组件产生的树结构几乎完全不同。

  • 如果组件类型(type)发生变化(例如从 div 变为 p,或从 ComponentA 变为 ComponentB),React 会判定为“脏组件”,不再深入比较子树,直接销毁旧组件及其所有子节点,并创建新组件。
  • 如果组件类型相同,则认为结构相似,仅更新属性(Props),并递归比对子节点。

策略三:Key 标识(Element Diff)

对于同一层级的一组子节点,开发者可以通过 key 属性提供唯一标识。React 使用 key 来判断节点是否仅仅是发生了位置移动,从而复用现有 DOM 节点,避免不必要的销毁和重建。

四、源码级复盘:如何遍历与比对(Diff Flow)

React 的 Diff 过程本质上是一个**深度优先遍历(DFS)**的过程。从根节点开始,沿着深度向下比较,直到叶子节点,然后回溯。

以下通过简化的伪代码,展示 React 协调器的核心比对流程:

JavaScript

/**
 * 简化的 Diff 算法逻辑
 * @param {HTMLElement} parentNode 父真实DOM
 * @param {Object} oldVNode 旧虚拟DOM
 * @param {Object} newVNode 新虚拟DOM
 */
function diff(parentNode, oldVNode, newVNode) {
  // 1. 如果新节点不存在,说明被删除了
  if (!newVNode) {
    parentNode.removeChild(oldVNode.dom);
    return;
  }

  // 2. 如果旧节点不存在,说明是新增
  if (!oldVNode) {
    const newDOM = createDOM(newVNode);
    parentNode.appendChild(newDOM);
    return;
  }

  // 3. 节点类型变化或 Key 变化:暴力替换
  if (
    oldVNode.type !== newVNode.type ||
    oldVNode.key !== newVNode.key
  ) {
    const newDOM = createDOM(newVNode);
    parentNode.replaceChild(newDOM, oldVNode.dom);
    return;
  }

  // 4. 类型相同:复用 DOM,更新属性
  const el = (newVNode.dom = oldVNode.dom);
  updateProps(el, oldVNode.props, newVNode.props);

  // 5. 递归处理子节点 (Children Diff)
  diffChildren(el, oldVNode.children, newVNode.children);
}

/**
 * 子节点对比:利用 Map 进行 O(1) 查找
 */
function diffChildren(parentDOM, oldChildren, newChildren) {
  // 建立旧节点的 Map 索引:Key -> Node
  const keyMap = {};
  oldChildren.forEach((child, index) => {
    const key = child.key || index;
    keyMap[key] = child;
  });

  // 记录上一个不需要移动的节点索引
  let lastIndex = 0;

  newChildren.forEach((newChild, index) => {
    const key = newChild.key || index;
    const oldChild = keyMap[key];

    if (oldChild && oldChild.type === newChild.type) {
      // 命中缓存:复用节点
      diff(parentDOM, oldChild, newChild);
      
      // 判断是否需要移动
      if (oldChild.index < lastIndex) {
        // 如果当前旧节点的位置在 lastIndex 之前,说明它被“插队”了,需要移动真实 DOM
        // 伪代码:parentDOM.insertBefore(newChild.dom, refNode);
      } else {
        // 不需要移动,更新 lastIndex
        lastIndex = oldChild.index;
      }
    } else {
      // 未命中:创建新节点
      const newDOM = createDOM(newChild);
      // 插入逻辑...
    }
  });

  // 清理 keyMap 中未被复用的旧节点(删除操作)
  // ...
}

关键点解析

  1. DFS 遍历:React 会优先深入处理子节点。当父节点属性更新完毕后,立即进入 diffChildren。

  2. Key Map 优化:在 diffChildren 阶段,通过构建 keyMap,React 将查找复用节点的时间复杂度从 

    O(n2)O(n2)
    

     降低到了 

    O(n)O(n)
    

  3. LastIndex 移动判定:React 维护一个 lastIndex 游标。如果复用的节点在旧集合中的索引小于 lastIndex,说明该节点在新集合中被移到了后面,此时执行 DOM 移动操作;否则保持不动。这是一种基于顺序优化的策略。

五、总结

React 的虚拟 DOM 并非为了追求极致的单次渲染性能,而是为了提供可维护性、跨平台能力和性能安全感

Diff 算法通过放弃对跨层级移动的支持、假设不同类型产生不同树、以及利用 Key 进行同级复用这三大启发式策略,成功将复杂的 

O(n3)O(n3)

 树比对问题转化为线性的 

O(n)O(n)

 问题。理解这一机制,不仅有助于编写高性能的 React 组件,更是深入掌握现代前端框架设计哲学的必经之路。

Flutter——List.map()

作者 Haha_bj
2026年2月26日 10:53

一、map

map 是 Dart 中 List 集合的核心转换方法,作用是遍历列表中的每一个元素,对每个元素执行指定的转换逻辑,最终返回一个新的可迭代对象(Iterable

  • 核心特点:不会修改原列表,而是返回新的迭代对象(需要手动转成 List);

  • 语法:Iterable<T> map<T>(T Function(E element) convert)

    • convert:转换函数,接收原列表的单个元素,返回转换后的元素;
    • T:转换后元素的类型(可省略,Dart 会自动推导);
    • E:原列表元素的类型。

二、基础用法(必掌握)

1. 基本类型转换

最常见的场景:将列表中的元素做简单转换(如数字转字符串、数值运算等)。

void main() {
  // 原列表:整数列表
  List<int> numbers = [1, 2, 3, 4, 5];
  
  // 1. 转换:每个数字乘以2 → 返回 Iterable<int>
  Iterable<int> doubledIterable = numbers.map((int num) {
    return num * 2;
  });
  
  // 2. 转成 List(关键:map返回的是Iterable,需用toList()转成List)
  List<int> doubledList = doubledIterable.toList();
  
  print("原列表:$numbers"); // 原列表:[1, 2, 3, 4, 5](原列表不变)
  print("转换后:$doubledList"); // 转换后:[2, 4, 6, 8, 10]
  
  // 简化写法(箭头函数):单行逻辑推荐用箭头函数
  List<String> numToString = numbers.map((num) => num.toString()).toList();
  print("数字转字符串:$numToString"); // [1, 2, 3, 4, 5]
}

2. 自定义对象转换

实战中更常用的场景:将自定义对象列表转换为其他格式(如提取对象的某个属性、转成 DTO 等)。

// 定义自定义对象
class User {
  final String name;
  final int age;
  
  User({required this.name, required this.age});
}

void main() {
  List<User> users = [
    User(name: "张三", age: 20),
    User(name: "李四", age: 25),
    User(name: "王五", age: 30),
  ];
  
  // 场景1:提取所有用户的姓名 → 字符串列表
  List<String> userNames = users.map((user) => user.name).toList();
  print("用户姓名:$userNames"); // [张三, 李四, 王五]
  
  // 场景2:转换为新的Map列表(如接口请求参数)
  List<Map<String, dynamic>> userMaps = users.map((user) {
    return {
      "username": user.name,
      "user_age": user.age,
      "is_adult": user.age >= 18, // 新增衍生字段
    };
  }).toList();
  print("转Map列表:$userMaps");
  // 输出:[{username: 张三, user_age: 20, is_adult: true}, ...]
}

三、关键注意事项(避坑)

1. 必须用 toList() 转成列表

map 方法返回的是 Iterable(可迭代对象),不是 List,如果直接使用会导致部分 List 方法(如 addremove)无法调用:

void main() {
  List<int> nums = [1,2,3];
  // 错误用法:Iterable 没有 add 方法
  // nums.map((e) => e*2).add(4); 
  
  // 正确用法:先转List
  List<int> newNums = nums.map((e) => e*2).toList();
  newNums.add(4); // [2,4,6,4]
}

2. 惰性执行特性

map 方法的转换逻辑不会立即执行,而是在遍历 Iterable(如调用 toList()/forEach())时才执行:

void main() {
  List<int> nums = [1,2,3];
  // 定义map转换,但未执行
  Iterable<int> iter = nums.map((e) {
    print("执行转换:$e");
    return e*2;
  });
  
  print("还未执行转换");
  // 调用toList()时,才会遍历并执行转换逻辑
  List<int> list = iter.toList();
  
  // 输出顺序:
  // 还未执行转换
  // 执行转换:1
  // 执行转换:2
  // 执行转换:3
}

3. 原列表修改不影响已生成的 Iterable

map 是基于原列表当时的状态生成迭代对象,后续修改原列表不会改变已生成的 Iterable

void main() {
  List<int> nums = [1,2,3];
  Iterable<int> iter = nums.map((e) => e*2);
  
  // 修改原列表
  nums.add(4);
  
  // 转换后的列表包含原列表的3个元素(1,2,3),不包含新增的4
  List<int> list = iter.toList();
  print(list); // [2,4,6]
}

四、高级用法

1. 链式调用

map 可与其他列表方法(wheresorttake 等)链式调用,实现复杂转换:

void main() {
  List<int> nums = [1,2,3,4,5,6,7,8];
  
  // 需求:筛选偶数 → 乘以10 → 转字符串 → 取前3个
  List<String> result = nums
      .where((e) => e % 2 == 0) // 筛选偶数:[2,4,6,8]
      .map((e) => e * 10) // 乘以10:[20,40,60,80]
      .map((e) => "数值:$e") // 转字符串:["数值:20", ...]
      .take(3) // 取前3个:["数值:20", "数值:40", "数值:60"]
      .toList();
  
  print(result); // [数值:20, 数值:40, 数值:60]
}

2. 处理空值(null safety)

Dart 空安全下,处理可能包含 null 的列表:

void main() {
  List<int?> nums = [1, null, 3, null, 5];
  
  // 方式1:过滤null后转换
  List<int> result1 = nums
      .where((e) => e != null) // 过滤null
      .map((e) => e!) // 非空断言(已过滤,安全)
      .toList();
  print(result1); // [1,3,5]
  
  // 方式2:给null设置默认值
  List<int> result2 = nums.map((e) => e ?? 0).toList();
  print(result2); // [1,0,3,0,5]
}

五、map vs forEach(易混淆对比)

很多新手会混淆 mapforEach,核心区别如下:

特性 map forEach
核心作用 转换元素,返回新的 Iterable 遍历元素执行操作,无返回值
返回值 Iterable<T> void(无返回值)
是否修改原列表 否(但可在回调中手动修改元素)
典型场景 元素类型转换、提取属性 遍历执行副作用(如打印、存储)
void main() {
  List<int> nums = [1,2,3];
  
  // map:转换并返回新列表
  List<int> mapResult = nums.map((e) => e*2).toList();
  
  // forEach:遍历执行操作,无返回值
  nums.forEach((e) {
    print("遍历元素:$e"); // 打印每个元素
  });
}

总结

  1. 核心作用List.map() 是列表元素转换的核心方法,返回 Iterable,需用 toList() 转成列表;
  2. 关键特性:惰性执行、不修改原列表、支持空安全和链式调用;
  3. 避坑点:必须转 List 才能使用 List 方法,空列表调用 map 不会报错(返回空 Iterable);
  4. 使用场景:类型转换、提取对象属性、生成新格式数据(如接口参数)。

掌握 map 方法后,能大幅简化列表转换的代码,是 Dart 开发中最常用的列表操作之一。

通往“全干”之路一:前端部署

作者 wing98
2026年2月26日 10:28

年底入职了一家创业小公司,感觉还是很幸运的。由于前端就我1个人而且没有运维,很自然前端项目部署的工作就落在我的肩上。

第一周我搭建起了公司的后台管理系统框架,按需求开发了两个页面,主要是文件上传相关的。然后那周剩余的时间,我就想先部署上去。

一、常见的前端部署
部署环境:JumpServer开源堡垒机

部署所需配置文件就是nginx.conf

部署步骤:  

1、账号密码登录堡垒机 

2、安装nginx 

3、让豆包提供一份标准nginx.conf 

4、上传dist文件 

5、解压dist.zip到nginx目录/usr/share/nginx/html/ 

6、启动nginx

后续项目更新只需要上传,并解压文件到指定目录,前端页面刷新后即可看到更新。 这种部署方式比较常见,也比较简单,半天不到即可搞定。在这里不得不提一下AI编程工具对开发效率的提升,特别是新项目来说。

 
二、亚马逊容器云部署

然后是第二周在另一个前端项目里开发了用户侧的显示界面,也需要部署上去。听面试我的后端大佬说,后端服务是在亚马逊上,采用docker集群部署。还好之前的工作也接触的docker,所以也不是很慌。

部署环境:亚马逊堡垒机
部署所需配置文件:
1、nginx.conf:配置静态资源和前端api请求代理,此文件放前端项目里,然后打包进docker镜像。

2、front-model.yaml:此文件放服务器上,主要配置nginx服务的端口、内存占用,以及镜像地址等。可让AI生成一份,然后修改对应的名称即可。

3、xxx-ingress:服务器上路由文件,主要配置前端路由转到nginx服务。

 配置好以上文件后,即可按下面步骤完成部署:
1、打包构建

npm run build:test

2、打镜像

docker build -t front-model:v1.0.1 .

3、amazonaws镜像重命名

docker tag front-model:v1.0.1 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1

4、amazonaws登录(先安装aws client)

aws ecr get-login-password --region us-east-1 | docker login --username xxx --password-stdin xxx.dkr.ecr.us-east-1.amazonaws.com

5、推送镜像到amazonaws仓库 

docker push 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1

6、修改front-model.yaml镜像tag

sudo vim front-model.yaml

7、应用yaml 

kubectl apply -f front-model.yaml

8、重启pod服务

kubectl rollout restart deployment/front-model

9、查看指定pod状态

kubectl get pods | grep front-model

遇到的问题:
1、docker客户端提示缺少win包,然后下载进度卡住拉不下来,原因是docker的下载终端在鼠标点击后默认暂停了。
2、前端资源的mime类型不对,需修改nginx.conf。
3、api请求没有经过nginx,原因是ingress的path不支持正则表达式的写法,需要拆开单独写。

大家也发现了上面的部署方式都是纯手工,比较繁琐。后面会考虑做成脚本自动执行,或者接入CICD。

解决iOS页面返回缓存问题:pageshow事件详解与实战方案

作者 简离
2026年2月26日 10:16

在iOS移动端前端开发中,很多开发者都会遇到一个棘手的痛点:使用JS跳转页面后,当用户返回上一页时,页面会直接复用之前的缓存状态,导致页面数据不刷新、DOM状态异常——尤其在支付场景中,支付完成返回支付前页面时,订单状态、支付按钮状态无法及时同步,严重影响用户体验,甚至可能引发业务异常。

这个问题的核心根源,是iOS Safari浏览器内置的「Back-Forward Cache」(简称BF Cache,即后退/前进缓存)机制。BF Cache会主动缓存页面的DOM结构、JS运行状态等完整信息,当用户通过后退、前进按钮切换页面时,浏览器会直接复用缓存内容,无需重新加载页面,以此提升页面切换性能,但这种优化在需要实时数据更新的场景中,反而会带来困扰。

本文将结合实际开发场景,详细拆解该问题的解决核心——pageshow事件的用法,同时科普pageshow事件的核心特性与实战技巧,帮助大家彻底解决iOS页面缓存导致的刷新异常问题,提升移动端开发体验。

一、先搞懂:为什么iOS返回页面不刷新?

与PC端浏览器不同,iOS Safari为了进一步优化移动端的性能和用户体验,引入了BF Cache缓存机制:当用户从页面A跳转至页面B时,浏览器会将页面A的完整状态(包括DOM结构、JS变量、页面渲染结果)全部缓存;当用户从页面B返回页面A时,浏览器不会重新触发页面的load事件,而是直接从BF Cache中读取缓存内容,快速渲染展示页面。

这种机制在普通静态页面场景下十分友好,能大幅提升页面切换速度,但在需要实时数据更新的场景(如支付、表单提交、实时数据列表等)中,就会出现明显问题:返回页面后,页面仍保持跳转前的旧状态,无法同步最新的数据(如订单支付状态、表单提交结果、实时统计数据等)。

这里需要明确一个关键区别:常规的load事件,仅在页面首次加载(或强制刷新)时触发,当页面从BF Cache中恢复显示时,load事件不会被触发——这也是我们常规的load事件初始化逻辑,在返回页面时失效的核心原因。

二、核心解决方案:pageshow事件(专门应对缓存恢复场景)

为了解决BF Cache带来的缓存困扰,浏览器原生提供了pageshow事件。它的核心作用是:监听页面「显示」的所有场景,包括页面首次加载显示、从BF Cache恢复显示,正好弥补了load事件无法监听缓存恢复场景的不足,是解决iOS页面缓存问题的最优方案。

2.1 pageshow事件核心详解

pageshow是浏览器原生DOM事件,属于Window对象,无需额外引入任何依赖,直接监听即可使用,其核心特性如下,方便大家快速掌握:

(1)触发时机

  • 页面首次加载完成后,成功显示在浏览器窗口时触发(触发顺序在load事件之后);
  • 页面从BF Cache(或其他浏览器缓存)中恢复显示时触发(这是解决iOS缓存问题的最关键场景);
  • 无论页面是通过刷新、后退、前进等何种方式显示,只要最终呈现在用户视野中,都会触发该事件。

(2)关键属性:event.persisted

pageshow事件对象(event)包含一个核心布尔属性——persisted,这是判断页面是否从缓存中恢复的唯一关键依据,无需额外判断逻辑:

  • event.persisted = true:表示当前页面是从BF Cache中恢复的(即用户返回页面时,复用了之前的缓存);
  • event.persisted = false:表示页面是首次加载、强制刷新(Ctrl+F5)或从非缓存状态显示的,属于常规加载场景。

通过persisted属性,我们可以精准区分页面的显示场景,进而针对性执行刷新逻辑——仅在页面从缓存恢复时触发刷新操作,既有效解决缓存问题,又不会影响页面正常加载的性能,兼顾体验与效率。

(3)与load、pagehide事件的区别

很多开发者容易混淆pageshow与load、pagehide事件,导致使用场景出错,这里用表格清晰区分三者的核心差异,方便大家快速对照使用:

事件名称 触发时机 缓存恢复时是否触发 核心作用
load 页面首次加载完成(所有资源加载完毕) 不触发 首次加载时初始化页面、加载数据
pageshow 页面显示时(首次加载、缓存恢复均触发) 触发 监测页面显示状态,处理缓存恢复场景
pagehide 页面隐藏时(跳转、关闭标签页、最小化) 触发 页面隐藏前保存当前状态,避免数据丢失

2.2 pageshow实战:解决iOS返回页面不刷新问题

结合实际开发中最常见的支付场景,为大家提供2个可直接复制使用的实战代码示例,分别适配不同的业务需求,兼顾实用性和易用性。

示例1:基础版——缓存恢复时强制刷新页面

适合对页面实时性要求极高的场景(如支付后必须同步最新订单状态、避免用户重复操作),当页面从缓存恢复时,直接强制刷新页面,确保页面数据完全最新,无任何延迟。

// 监听pageshow事件,专门处理页面从缓存恢复的场景
window.addEventListener('pageshow', function(event) {
  // 判断当前页面是否从BF Cache中恢复
  if (event.persisted) {
    // 强制刷新页面(可根据需求替换为具体的刷新逻辑)
    window.location.reload();
  }
});

示例2:进阶版——缓存恢复时仅更新数据(不强制刷新)

强制刷新会重新加载页面所有资源,可能增加加载耗时、影响用户体验。进阶方案仅重新请求接口、更新页面DOM,不刷新整个页面,既能保证数据实时性,又能兼顾页面性能。

// 初始化页面数据(首次加载、缓存恢复均需执行,复用逻辑减少冗余)
function initPageData() {
  // 模拟请求接口,获取最新数据(实际开发中替换为真实接口地址)
  fetch('/api/order/status')
    .then(res => res.json())
    .then(data => {
      // 更新页面DOM,展示最新订单状态
      document.querySelector('.order-status').textContent = data.status;
      // 处理支付按钮状态(如已支付则置灰,禁止重复点击)
      if (data.status === '已支付') {
        document.querySelector('.pay-btn').disabled = true;
      }
    });
}

// 页面首次加载时,初始化数据
window.addEventListener('load', initPageData);

// 监听pageshow事件,缓存恢复时重新初始化数据(不刷新整个页面)
window.addEventListener('pageshow', function(event) {
  if (event.persisted) {
    initPageData(); // 仅更新数据,兼顾性能与实时性
  }
});

三、补充方案:结合其他方式,彻底规避缓存问题

pageshow事件是解决iOS页面缓存问题的核心方案,但在部分极端场景下(如浏览器缓存策略特殊、业务场景复杂),可结合以下补充方案,形成“核心+辅助”的组合拳,进一步确保效果,避免缓存问题遗漏。

3.1 禁用页面缓存(服务端配合)

通过服务端设置HTTP响应头,明确告诉浏览器不要缓存当前页面,从根源上避免BF Cache机制生效,适合对实时性要求极高的页面(如支付页、订单详情页、表单提交页)。

服务端响应头设置(以Node.js Express为例,其他语言可参考对应语法):

// Node.js Express示例(订单页为例)
app.get('/order', (req, res) => {
  // 设置响应头,禁止浏览器缓存当前页面
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
  res.setHeader('Pragma', 'no-cache');
  res.setHeader('Expires', '0');
  // 渲染订单页面(根据实际业务逻辑调整)
  res.render('order');
});

辅助方案(HTML meta标签,优先级低于HTTP响应头,仅作为补充):

<!-- 页面头部添加meta标签,辅助禁用缓存(兼容部分旧浏览器) -->
<meta http-equiv="Cache-Control" content="no-store, no-cache" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />

3.2 利用history API管理状态

在跳转页面(如跳转到支付页)前,通过history.replaceState方法添加状态标记,返回页面时检测该标记,触发对应刷新逻辑,适合单页应用(SPA)或页面跳转逻辑复杂的场景,灵活性更高。

// 跳转到支付页面前,添加状态标记(标记当前页面需要刷新)
function goToPayment() {
  // 替换当前历史记录,添加needRefresh标记(避免新增历史记录)
  history.replaceState({ needRefresh: true }, document.title);
  // 跳转到支付页面(替换为实际支付页地址)
  window.location.href = '/payment';
}

// 页面初始化时,检测历史状态标记
window.addEventListener('load', function() {
  const state = history.state;
  // 若存在needRefresh标记,说明是从支付页返回,执行刷新逻辑
  if (state && state.needRefresh) {
    initPageData(); // 重新加载数据,更新页面状态
    history.replaceState(null, document.title); // 重置状态,避免重复触发刷新
  }
});

四、注意事项与最佳实践

  • 兼容性友好:pageshow事件兼容所有现代浏览器,包括iOS Safari、Android Chrome、PC端主流浏览器,无需额外处理兼容性,可直接在项目中使用;
  • 避免过度强制刷新:尽量优先选择“仅更新数据”的进阶方案,减少window.location.reload()的使用,避免重复加载资源,提升用户体验;
  • 核心场景双重保障:支付、订单等核心业务场景,建议组合使用“pageshow监听 + 服务端禁用缓存”,双重规避缓存问题,确保业务逻辑正常;
  • 表单场景补充处理:若页面包含表单,返回时需重置表单状态,可在pageshow事件中添加表单重置逻辑(如form.reset()),避免表单残留旧数据。

五、总结

iOS页面返回不刷新的核心原因,是Safari浏览器的BF Cache缓存机制,而pageshow事件作为浏览器原生提供的解决方案,能精准监听页面缓存恢复场景,结合event.persisted属性,可灵活实现页面刷新逻辑,是解决该问题的最直接、高效的方式。

实际开发中,可根据业务场景灵活选择基础版(强制刷新)或进阶版(仅更新数据)方案,配合服务端禁用缓存、history API等辅助方式,既能彻底解决缓存问题,又能兼顾页面性能和用户体验。

如果大家在使用pageshow事件时遇到其他问题(如事件触发异常、数据更新不及时、兼容性异常等),欢迎在评论区交流讨论,共同避坑、提升开发效率~

图片对比组件技

作者 whisper
2026年2月26日 10:13

本组件是一个基于原生 HTML/CSS/JS 开发的交互式图片对比工具(Image Comparison Slider),常用于展示产品渲染前后、照片修图前后或场景变化的效果。

效果如图:

screen_recording_2026-02-26_10-08-26.gif


代码如下:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Halo 图片对比效果</title>
    <style>
        /* 基础样式复现 */
        body {
            margin: 0;
            padding: 0;
            background: #f5f5f5;
        }

        .sp-ba-wrap {
            max-width: 1440px;
            min-width: 343px;
            margin: 0 auto;
            margin-top: 120px;
            padding: 0 20px;
        }

        .sp-ba {
            position: relative;
            margin: 0 auto;
            max-width: 1200px;
            z-index: 2;
        }

        /* 核心对比滑块样式 */
        .banda-slider {
            display: block;
            overflow: hidden;
            position: relative;
            border-radius: 16px;
            width: 100%;
            line-height: 0;
        }

        .banda-slider img {
            width: 100%;
            height: auto;
            display: block;
            user-select: none;
        }

        .banda-reveal {
            left: 0;
            top: 0;
            bottom: 0;
            overflow: hidden;
            position: absolute;
            right: 50%;
            /* 初始位置 */
            z-index: 1;
            border-right: 2px solid #fff;
        }

        .banda-reveal>img {
            height: 100%;
            width: 200%;
            /* 这里必须是父容器的2倍才能保证内容不拉伸 */
            max-width: none;
            object-fit: cover;
        }

        /* 交互控件:透明滑块 */
        .banda-range {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            opacity: 0;
            cursor: ew-resize;
            z-index: 10;
        }

        /* 装饰用的中间圆圈 */
        .banda-handle {
            background: #000;
            border-radius: 50%;
            color: #fff;
            height: 48px;
            width: 48px;
            left: 50%;
            top: 50%;
            position: absolute;
            transform: translate(-50%, -50%);
            pointer-events: none;
            z-index: 5;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .banda-handle:before,
        .banda-handle:after {
            content: "";
            border: solid white;
            border-width: 0 2px 2px 0;
            display: inline-block;
            padding: 3px;
        }

        .banda-handle:before {
            transform: rotate(135deg);
            margin-right: 4px;
        }

        .banda-handle:after {
            transform: rotate(-45deg);
            margin-left: 4px;
        }

        @media only screen and (max-width: 888px) {
            .sp-ba-wrap {
                margin-top: 40px;
            }

        }
    </style>
</head>
<body>

    <div class="sp-ba-wrap">
        <div class="sp-ba">
            <div class="banda-slider" id="mySlider">
                <img src="https://cdn.shopify.com/s/files/1/0268/7297/1373/files/55379ad14f3eb2af576acdc527686e4e_3840x2000_6af3220c-9331-4d97-bec8-3687cd8745f9.jpg?v=1711012998"
                    alt="Before">

                <div class="banda-reveal" id="revealLayer">
                    <img src="https://cdn.shopify.com/s/files/1/0268/7297/1373/files/83793ec91a3bdd17ce22ed844b4e4aeb_3840x2000_b58d63a4-2a39-4550-b890-ff6519f49952.jpg?v=1711012992"
                        alt="After" id="revealImg">
                </div>

                <input type="range" min="0" max="100" value="50" class="banda-range" id="rangeInput">
                <div class="banda-handle" id="handle"></div>
            </div>
        </div>
    </div>

    <script>
        // 逻辑实现:监听滑动条并实时更新 UI
        const range = document.getElementById('rangeInput');
        const revealLayer = document.getElementById('revealLayer');
        const revealImg = document.getElementById('revealImg');
        const handle = document.getElementById('handle');
        const slider = document.getElementById('mySlider');

        range.addEventListener('input', (e) => {
            const value = e.target.value;

            // 1. 更新遮罩层的宽度(实际上是修改 right 距离)
            // 原理:当 value 增加,左侧显示更多,revealLayer 需要向右移
            revealLayer.style.right = (100 - value) + '%';

            // 2. 更新分隔小圆圈的位置
            handle.style.left = value + '%';

            // 3. 动态调整内部图片的宽度,防止拉伸
            // 因为 revealLayer 的宽度在变,其内部图片需要反向维持比例
            const containerWidth = slider.offsetWidth;
            revealImg.style.width = containerWidth + 'px';
        });

        // 窗口大小改变时重置图片宽度
        window.addEventListener('resize', () => {
            revealImg.style.width = slider.offsetWidth + 'px';
        });
        // 初始化执行一次
        revealImg.style.width = slider.offsetWidth + 'px';
    </script>

</body>
</html>

一、 实现的效果

  • 视觉表现:页面中间展示一张图片,通过一条可移动的垂直分割线将画面分为左右两部分,分别显示不同的内容(如:白昼与黑夜、修图前与修图后)。中心配有一个黑色圆形手柄提示用户可进行操作。

  • 交互表现

    • 手动拖拽:用户点击并左右拖动中间的手柄,即可实时改变两侧图片的显示比例。
    • 移动端适配:支持触摸滑动,在手机或平板上拥有流畅的交互体验。
    • “揭开”感:手柄移动的过程类似于拨开一张蒙版,视觉反馈直观且平滑。

二、 实现思路

  1. 分层堆叠(Layering)

    将两张分辨率完全一致的图片放置在同一个父容器中。底层图片(Bottom Image)作为固定基准,顶层图片(Top Image)嵌套在一个带有遮罩属性的容器中。

  2. 动态裁剪(Clipping)

    给顶层图片容器设置 overflow: hidden。通过改变这个容器的宽度(例如从 50% 变为 30%),它就像一扇“移动的门”,遮挡掉上层图片的一部分,从而露出底层的图片。

  3. 视觉对齐(Alignment Fix)

    • 挑战:默认情况下,如果父容器变窄,内部图片通常会随之缩小或变形。
    • 对策:给上层图片设置一个固定的宽度(通常等于外层大容器的宽度),使其不随遮罩容器的缩放而缩放。这样,上下两张图片的内容就能在视觉上完美重合。
  4. 隐形控制(Invisible Control)

    在整个组件最顶层覆盖一个完全透明的 HTML 滑动条 <input type="range">。这样做可以利用浏览器原生的高性能滑动监听,无需自己写复杂的鼠标位移计算逻辑。


三、 实现原理

1. 核心 CSS 结构

  • 遮罩原理:利用 position: absolute 进行定位。遮罩层 banda-reveal 充当“视口”,通过修改它的 rightwidth 属性来控制露出的比例。
  • 布局优化:使用 line-height: 0display: block 消除图片底部常见的像素间隙,确保容器高度完全由图片撑开。

2. 数值映射

组件通过 JavaScript 实时获取滑块的数据并进行映射:

  • 滑块当前值XX (取值范围 01000 \sim 100)
  • 遮罩层宽度W=X%W = X\% (决定揭开多少内容)
  • 手柄偏移量L=X%L = X\% (确保手柄始终在分割线上)

3. 同步逻辑代码

JavaScript 监听 input 事件,实现数据驱动 UI:

JavaScript

// 核心同步逻辑示例
rangeInput.addEventListener('input', (e) => {
    const sliderValue = e.target.value;
    
    // 1. 改变遮罩层宽度(拨开视觉效果)
    revealLayer.style.width = sliderValue + '%'; 
    
    // 2. 同步移动中间的控制手柄
    handle.style.left = sliderValue + '%';       
});

表单写到想摔键盘?聊聊前端常见的复杂状态场景

作者 yuki_uix
2026年2月26日 10:12

写表单这件事,我相信大多数前端开发者都经历过类似的心路历程:最开始觉得不就是几个 <input> 吗,然后需求一条条加进来——验证、错误提示、提交状态、字段依赖……代码量呈指数级膨胀,最后一个"简单"的注册表单能写到 200 行😓。

这篇文章是我在反复踩坑之后的一些思考和总结,聊聊表单状态为什么难,以及如何用工具优雅地解决它。

问题的起源

表单的本质是"用户输入 → 程序处理 → 反馈",看起来很简单。但难就难在,它不只是存储状态,还涉及:

  • 验证:什么时候验证?失焦时?实时?提交时?
  • 依赖:字段 A 的值影响字段 B 的显示或规则
  • 性能:大表单每次输入都触发重渲染,用户感知到卡顿
  • 副作用:异步验证(检查用户名是否已存在)、草稿自动保存

把这些叠加在一起,表单就成了前端状态管理中最复杂的场景之一。


从简单到失控:原生 useState 的演进

最开始的样子

刚入门时,受控组件 + useState 几乎是所有人的第一选择:

// 环境:React
// 场景:简单的登录表单

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="password"
      />
      <button type="submit">login</button>
    </form>
  );
}

两个字段,二十行代码,清晰直观。然后设计上要求:"加个用户名,加个确认密码,加个验证……"

加入验证后的样子

当需要处理验证、错误提示、touched 状态和提交状态时,代码量会爆炸式增长:

// 环境:React
// 场景:带验证的注册表单

function SignupForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
  });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const validate = (name, value) => {
    switch (name) {
      case 'username':
        if (!value) return 'username is required';
        if (value.length < 3) return 'username must be at least 3 characters';
        break;
      case 'email':
        if (!value) return 'email is required';
        if (!/\S+@\S+.\S+/.test(value)) return 'invalid email format';
        break;
      case 'password':
        if (!value) return 'password is required';
        if (value.length < 6) return 'password must be at least 6 characters';
        break;
      case 'confirmPassword':
        if (value !== formData.password) return 'passwords do not match';
        break;
    }
    return '';
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
    if (touched[name]) {
      setErrors((prev) => ({ ...prev, [name]: validate(name, value) }));
    }
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    setTouched((prev) => ({ ...prev, [name]: true }));
    setErrors((prev) => ({ ...prev, [name]: validate(name, value) }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const newErrors = {};
    Object.keys(formData).forEach((key) => {
      const error = validate(key, formData[key]);
      if (error) newErrors[key] = error;
    });

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      setTouched(
        Object.keys(formData).reduce((acc, key) => ({ ...acc, [key]: true }), {})
      );
      return;
    }

    setIsSubmitting(true);
    try {
      await submitForm(formData);
    } catch (err) {
      console.error(err);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 每个字段都要重复这套结构 */}
      <div>
        <input
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.email && errors.email && (
          <span className="error">{errors.email}</span>
        )}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'submitting...' : 'sign up'}
      </button>
    </form>
  );
}

这段代码已经接近 120 行,而且每加一个字段就要同步修改好几个地方。更大的问题是:验证逻辑和组件耦合在一起,性能也有隐患——每次输入都会触发整个组件重渲染。

如果再加上多步骤、动态字段、异步验证……用 useState 就基本走到头了。


受控 vs 非受控:性能背后的设计取舍

理解为什么表单库要这样设计,首先要搞清楚受控和非受控的区别。

受控组件:React 完全掌管 input 的值,每次输入都触发 setState,进而触发重渲染。

非受控组件:值存在 DOM 中,React 只在需要时通过 ref 读取,输入时不触发重渲染。

// 环境:React
// 场景:两种组件的对比演示

// 受控组件:每次按键触发 setState + 重渲染
function ControlledInput() {
  const [value, setValue] = useState('');
  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// 非受控组件:输入时不触发重渲染,提交时读取 ref
function UncontrolledInput() {
  const inputRef = useRef();
  const handleSubmit = () => {
    console.log(inputRef.current.value);
  };
  return <input ref={inputRef} defaultValue="" />;
}

对于一两个字段,受控组件完全没问题。但如果表单有 50 个字段,每次按键都重渲染整个组件,性能问题就会变得明显。

这也是 React Hook Form 的核心设计思路:默认使用非受控组件,只在必要时订阅特定字段的变化。输入时不触发重渲染,只有调用 watch() 订阅的字段变化时才会更新。


验证时机:影响用户体验的关键细节

验证"对不对"是基本要求,验证"在什么时候告诉用户"才是用户体验的关键。

image.png 一种推荐的渐进式验证策略:

时机 触发条件 用户体验
用户还在输入,未 blur 不验证 不打断用户思路
用户 blur 离开字段 触发验证 及时反馈错误
已经 blur 过,继续修改 实时验证 修改即反馈
点击提交 全量验证 兜底检查

React Hook Form 通过 mode 参数控制验证时机,mode: 'onBlur' 是我觉得体验最好的选项。


React Hook Form vs Formik:怎么选?

市面上主流的两个表单库,设计理念有明显差异。

Formik 以受控组件为基础,状态完全托管在 JavaScript 中,思路和 Redux 类似,比较"React 范儿"。

React Hook Form 以非受控组件为基础,最小化重渲染,性能优先,API 也更简洁。

来看看同一个注册表单,两种库怎么写:

// 环境:React
// 场景:基础注册表单对比

// Formik 写法
import { Formik, Form, Field } from 'formik';

function FormikSignup() {
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validate={(values) => {
        const errors = {};
        if (!values.email) errors.email = 'required';
        return errors;
      }}
      onSubmit={(values) => console.log(values)}
    >
      {({ errors, touched }) => (
        <Form>
          <Field name="email" />
          {touched.email && errors.email && <div>{errors.email}</div>}
          <button type="submit">submit</button>
        </Form>
      )}
    </Formik>
  );
}

// React Hook Form 写法
import { useForm } from 'react-hook-form';

function RHFSignup() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    mode: 'onBlur',
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input
        {...register('email', {
          required: 'email is required',
          pattern: { value: /\S+@\S+.\S+/, message: 'invalid email' },
        })}
      />
      {errors.email && <div>{errors.email.message}</div>}
      <button type="submit">submit</button>
    </form>
  );
}

代码量上,React Hook Form 明显更简洁。在性能上,差距更大——Formik 在字段较多时,每次输入都会重渲染整个表单;React Hook Form 默认不重渲染。

我的理解是,如果是新项目从零开始,React Hook Form 是更好的默认选择;如果团队已经在用 Formik,不需要专门切换。


复杂场景实战

场景一:多步骤表单

多步骤表单的关键是:步骤间共享状态,切换步骤前验证当前步骤。

// 环境:React + React Hook Form
// 场景:三步注册流程

import { useForm, FormProvider, useFormContext } from 'react-hook-form';

function MultiStepForm() {
  const [step, setStep] = useState(1);
  const methods = useForm({
    defaultValues: {
      username: '',
      email: '',
      address: '',
      city: '',
    },
  });

  const stepFields = {
    1: ['username', 'email'],
    2: ['address', 'city'],
  };

  const handleNext = async () => {
    // 只验证当前步骤的字段
    const isValid = await methods.trigger(stepFields[step]);
    if (isValid) setStep((s) => s + 1);
  };

  const onSubmit = (data) => {
    console.log('final submit:', data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        {step === 1 && <Step1 />}
        {step === 2 && <Step2 />}

        <div>
          {step > 1 && (
            <button type="button" onClick={() => setStep((s) => s - 1)}>
              previous
            </button>
          )}
          {step < 2 ? (
            <button type="button" onClick={handleNext}>
              next
            </button>
          ) : (
            <button type="submit">submit</button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

function Step1() {
  const { register, formState: { errors } } = useFormContext();
  return (
    <div>
      <input {...register('username', { required: 'required' })} placeholder="username" />
      {errors.username && <span>{errors.username.message}</span>}
      <input {...register('email', { required: 'required' })} placeholder="email" />
      {errors.email && <span>{errors.email.message}</span>}
    </div>
  );
}

FormProvider + useFormContext 的组合让子组件可以直接访问表单实例,不需要逐层传 props。

场景二:动态字段

添加/删除联系人这类场景,useFieldArray 是专门为此设计的:

// 环境:React + React Hook Form
// 场景:可动态添加删除的联系人列表

import { useForm, useFieldArray } from 'react-hook-form';

function DynamicFieldsForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: {
      contacts: [{ name: '', phone: '' }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'contacts',
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            {...register(`contacts.${index}.name`, { required: 'required' })}
            placeholder="name"
          />
          <input
            {...register(`contacts.${index}.phone`, { required: 'required' })}
            placeholder="phone"
          />
          <button type="button" onClick={() => remove(index)}>
            remove
          </button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ name: '', phone: '' })}
      >
        add contact
      </button>

      <button type="submit">submit</button>
    </form>
  );
}

field.iduseFieldArray 自动生成的稳定 ID,用作 key 比用数组下标更可靠。

场景三:表单草稿自动保存

长表单刷新丢失内容体验极差,自动保存草稿是一个值得标配的功能:

// 环境:React + React Hook Form
// 场景:编辑器类表单,刷新不丢失

const DRAFT_KEY = 'article_draft';

function PersistentForm() {
  const { register, handleSubmit, watch, reset } = useForm({
    defaultValues: () => {
      const saved = localStorage.getItem(DRAFT_KEY);
      return saved ? JSON.parse(saved) : { title: '', content: '' };
    },
  });

  const formData = watch();

  // 防抖自动保存,避免频繁写入
  useEffect(() => {
    const timer = setTimeout(() => {
      // 注意:不要保存敏感字段(如密码)
      localStorage.setItem(DRAFT_KEY, JSON.stringify(formData));
    }, 1000);
    return () => clearTimeout(timer);
  }, [formData]);

  const onSubmit = (data) => {
    console.log('submit:', data);
    localStorage.removeItem(DRAFT_KEY); // 提交成功后清除草稿
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('title')} placeholder="title" />
      <textarea {...register('content')} placeholder="content" />
      <button type="submit">submit</button>
    </form>
  );
}

有一个细节值得注意:密码、支付信息这类敏感字段不应该存入 localStorage,自动保存时需要手动过滤。


AI 辅助表单开发:哪里能信任,哪里要警惕

最近越来越多地用 AI 辅助写代码,表单这个场景有些值得分享的观察。

AI 做得好的事情:

生成基础表单结构和验证规则,AI 的质量相当高。你给一个清晰的需求,它能输出 90% 可用的代码。对于常见的模式(注册表单、搜索表单、多选表单),AI 基本不会出错。

AI 容易出问题的地方:

  • 复杂字段依赖:字段 A 改变时,字段 B 的验证规则也要动态调整,AI 生成的代码经常遗漏这个联动
  • 动态字段的状态清理:删除一个联系人时,相关的验证错误需要同步清除,AI 有时候会漏掉
  • 性能优化:AI 不一定意识到受控组件的重渲染问题,可能给一个功能正确但性能不好的方案
  • 异步验证防抖:AI 可能生成没有防抖的异步验证,导致每次按键都发请求

一个对我有用的策略是分步骤让 AI 生成,而不是一次性描述所有需求:

第一步:生成基础表单结构

第二步:添加验证规则(明确指定库和验证时机)

第三步:处理特定复杂场景(动态字段、多步骤等)

每步验证可用后再继续

另外,让 AI 解释它的设计选择,比直接拿代码更有价值——"为什么用 reset 而不是 defaultValues 处理异步数据?"这类追问往往能学到设计思路。

拿到 AI 生成的表单代码,建议检查这几项:

  • 有没有验证规则和错误提示?
  • 是否处理了提交状态(loading / disabled)?
  • 提交失败有没有错误处理?
  • 验证时机是否合理(推荐 onBlur)?
  • 异步验证是否有防抖?
  • 动态字段删除时,状态是否正确清理?

延伸与发散

在研究这些问题时,冒出了一些还没有答案的问题:

React Server Components 下的表单:RSC 不能直接用 React Hook Form(因为它依赖 hooks),Next.js 的 Server Actions 提供了一种新思路,不需要 JS 就能提交表单。这个方向值得关注,但还在快速演进中。

表单状态机:对于非常复杂的多步骤流程(如保险购买、贷款申请),有时候用 XState 这样的状态机库来管理表单的生命周期(编辑中 → 验证中 → 提交中 → 成功/失败)会更清晰。但大多数场景用 React Hook Form 就够了,不必过度设计。

表单生成器:后台管理系统里有大量相似的表单,很自然会想到用 JSON Schema 来描述表单结构,自动生成 UI。这条路技术上可行,但维护复杂度会转移到 schema 设计上,不一定是银弹。

无障碍支持:表单的 aria 属性(aria-requiredaria-invalidaria-describedby)是很容易忽视但很重要的细节,AI 生成的代码也经常漏掉这部分。


小结

表单之所以复杂,是因为它是"状态 + 验证 + 交互 + 性能"的交叉地带。单纯用 useState 能走多远,取决于表单有多简单。

这篇文章更多是我在遇到各种问题后的思考记录,核心观点是:

  • 受控组件直觉,非受控组件性能——React Hook Form 是目前平衡得比较好的方案
  • 验证时机比验证规则本身更影响用户体验,onBlur 是大多数场景的合理默认值
  • AI 能帮你快速生成骨架,但边界情况和性能优化还是需要自己把关
  • 复杂表单先想清楚数据结构,再选工具,而不是反过来

如果你有不同的实践或踩过不同的坑,欢迎交流。表单这件事,说复杂很复杂,说简单也可以很简单,关键是找到适合场景的方案,而不是追求一个通用答案。


参考资料

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

作者 SmalBox
2026年2月26日 10:08

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

在Unity的Shader Graph中,Transformation Matrix节点是一个功能强大且基础的工具,它允许着色器开发者访问和使用Unity渲染管线中的各种变换矩阵。这些矩阵在计算机图形学中扮演着至关重要的角色,它们定义了物体如何从局部空间转换到世界空间,再到视图空间,最终到裁剪空间的过程。理解并正确使用这些变换矩阵是实现复杂视觉效果和优化着色器性能的关键。

Transformation Matrix节点通过提供一个统一且易于使用的接口,简化了在着色器中处理空间变换的复杂性。无论是实现高级光照效果、创建动态材质,还是优化渲染性能,这个节点都是不可或缺的工具。它特别适用于需要精确控制顶点位置或方向的计算,如顶点着色器中的自定义变换、片元着色器中的高级光照计算,以及各种后处理效果。

描述

Transformation Matrix节点的核心功能是在着色器中定义通用的变换矩阵常量值。它输出一个4x4的矩阵,这个矩阵代表了从下拉菜单中选择的特定空间变换。4x4矩阵是计算机图形学中的标准表示形式,因为它能够同时处理三维空间的线性变换(旋转、缩放)和平移操作,这对于完整的空间变换是必需的。

该节点提供了多种变换矩阵选项,包括模型矩阵、视图矩阵、投影矩阵以及它们的各种组合和逆矩阵。每个选项对应着渲染管线中不同阶段的坐标变换,让开发者能够精确控制顶点和向量在不同空间中的表示和计算。

需要注意的是,该节点的两个输出值选项——"反投影"(Inverse Projection)和"反视图投影"(Inverse View Projection)与Unity的内置渲染管线不兼容。当选择这两个选项并将内置渲染管线作为目标时,节点将产生完全黑色的结果。这一限制主要源于内置渲染管线与通用渲染管线(URP)和高清渲染管线(HDRP)在矩阵计算和传递方式上的差异。在URP和HDRP中,这些逆矩阵被预计算并可供使用,而在内置渲染管线中则不是这种情况。

变换矩阵的基本概念

在深入探讨Transformation Matrix节点的具体用法之前,理解变换矩阵的基本概念是至关重要的。在三维计算机图形学中,变换矩阵用于描述点、向量或坐标系从一个空间到另一个空间的转换。这些转换可以包括:

  • 平移:改变对象的位置
  • 旋转:改变对象的方向
  • 缩放:改变对象的大小
  • 剪切:使对象的部分相对于其他部分滑动

在Unity的渲染管线中,顶点通常经历以下空间变换序列:

  1. 局部空间(Local Space):顶点相对于其自身原点的位置
  2. 世界空间(World Space):顶点相对于场景世界原点的位置
  3. 视图空间(View Space):顶点相对于相机的位置和方向
  4. 裁剪空间(Clip Space):顶点经过投影变换后的位置,用于确定哪些部分在视锥体内
  5. 屏幕空间(Screen Space):顶点在最终渲染画面中的位置

Transformation Matrix节点提供的各种矩阵正是用于在这些不同空间之间进行转换。

矩阵的数学基础

4x4变换矩阵在计算机图形学中采用齐次坐标系统,这使得它们能够统一处理三维变换和投影。一个典型的4x4变换矩阵可以表示为:

[ m00 m01 m02 m03 ]
[ m10 m11 m12 m13 ]
[ m20 m21 m22 m23 ]
[ m30 m31 m32 m33 ]

其中:

  • 左上角的3x3子矩阵通常表示旋转和缩放
  • 最右侧的3x1列向量(m03, m13, m23)表示平移
  • 最底部的1x4行向量(m30, m31, m32, m33)用于透视投影

在着色器编程中,理解这些矩阵的结构和数学特性对于正确使用Transformation Matrix节点至关重要。

端口

Transformation Matrix节点的端口设计简洁而高效,只包含一个输出端口,这反映了它的核心功能——提供预定义的变换矩阵值。

名称 方向 类型 绑定 描述
Out 输出 Matrix 4 输出值

输出端口详细解析

Transformation Matrix节点的唯一输出端口"Out"提供所选的4x4变换矩阵。这个输出端口可以连接到任何接受Matrix 4类型输入的节点,如Transform节点、Matrix Construction节点,或自定义函数节点。

输出端口的特性包括:

  • 数据类型:Matrix 4,即4x4浮点数矩阵
  • 绑定:无,表示这个值不是从材质属性或其它外部源绑定,而是由Unity渲染管线内部生成
  • 动态性:根据所选的矩阵类型,输出值可能在每一帧变化(如视图矩阵随相机移动而变化)或保持恒定(如某些情况下的投影矩阵)
  • 精度:矩阵元素的精度取决于目标平台和渲染管线设置

在实际使用中,输出矩阵的准确含义和行为取决于所选的控件选项。例如,当选择"Model"选项时,输出的是当前渲染对象的模型矩阵,这个矩阵对于场景中的每个对象可能是不同的。而当选择"View"选项时,输出的是相机的视图矩阵,这个矩阵对于同一相机渲染的所有对象是相同的。

输出端口的连接应用

Transformation Matrix节点的输出端口可以连接到多种不同类型的节点,实现各种图形效果:

  • 连接到Position节点的输入,实现自定义的空间变换
  • 连接到Calculate Light Direction节点的Matrix输入,实现基于不同空间的光照计算
  • 连接到Transform节点的From和To输入,实现自定义的空间转换
  • 连接到Matrix Split节点的输入,提取矩阵的特定行、列或元素
  • 连接到自定义HLSL函数节点,实现复杂的数学运算

理解输出端口的特性和连接可能性是有效使用Transformation Matrix节点的关键。

控件

Transformation Matrix节点的控件是一个下拉选单,提供了八种不同的变换矩阵选项。这个简单的界面设计隐藏了背后复杂的坐标系统和变换理论,使得即使是对矩阵数学不太熟悉的开发者也能轻松使用这些强大的工具。

名称 类型 选项 描述
下拉选单 Model、InverseModel、View、InverseView、Projection、InverseProjection、ViewProjection、InverseViewProjection 设置输出值

控件选项详细解析

每个控件选项对应着Unity渲染管线中一个特定的变换矩阵,理解这些选项的含义和适用场景对于正确使用Transformation Matrix节点至关重要。

Model(模型矩阵)

模型矩阵,通常表示为M,将顶点从局部空间(对象空间)变换到世界空间。局部空间是相对于对象自身原点的坐标系,而世界空间是场景的全局坐标系。

模型矩阵的特点:

  • 包含对象的平移、旋转和缩放信息
  • 对于场景中的每个渲染对象通常不同
  • 在对象的整个生命周期中可能变化(对于动态对象)
  • 用于将法线、切线等向量从局部空间转换到世界空间

应用场景:

  • 实现对象空间效果,如基于对象位置的纹理映射
  • 计算世界空间位置用于光照和阴影
  • 创建对象特定的变形效果

InverseModel(逆模型矩阵)

逆模型矩阵,表示为M⁻¹,是模型矩阵的逆矩阵,用于将顶点从世界空间变换回局部空间。

逆模型矩阵的特点:

  • 是模型矩阵的逆运算
  • 可用于将世界空间向量转换到对象空间
  • 计算成本相对较高,应谨慎使用

应用场景:

  • 在世界空间中计算但在对象空间中应用的效果
  • 将全局信息(如世界空间光方向)转换到对象空间
  • 实现相对于对象的位置计算

View(视图矩阵)

视图矩阵,表示为V,将顶点从世界空间变换到视图空间(也称为相机空间)。视图空间是以相机为原点的坐标系,相机的观察方向通常为Z轴负方向。

视图矩阵的特点:

  • 由相机的位置和旋转决定
  • 对于同一相机渲染的所有对象相同
  • 随着相机的移动和旋转而变化
  • 用于计算相对于相机的位置和方向

应用场景:

  • 实现屏幕空间效果
  • 计算视差和深度效果
  • 创建基于视图方向的效果

InverseView(逆视图矩阵)

逆视图矩阵,表示为V⁻¹,是视图矩阵的逆矩阵,用于将顶点从视图空间变换回世界空间。

逆视图矩阵的特点:

  • 是视图矩阵的逆运算
  • 可用于将视图空间向量转换到世界空间
  • 在后期处理效果中特别有用

应用场景:

  • 重建世界空间位置从深度纹理
  • 在视图空间中计算但在世界空间中应用的效果
  • 全局光照和反射计算

Projection(投影矩阵)

投影矩阵,表示为P,将顶点从视图空间变换到裁剪空间。裁剪空间是一个齐次坐标空间,用于确定哪些几何体在视锥体内并应该被渲染。

投影矩阵的特点:

  • 由相机的投影属性(视野、宽高比、近远裁剪平面)决定
  • 对于透视投影和正交投影不同
  • 将视图空间坐标转换为齐次裁剪空间坐标
  • 负责应用透视变形

应用场景:

  • 自定义投影效果
  • 非标准相机投影
  • VR和AR应用中的特殊投影需求

InverseProjection(逆投影矩阵)

逆投影矩阵,表示为P⁻¹,是投影矩阵的逆矩阵,用于将顶点从裁剪空间变换回视图空间。

逆投影矩阵的特点:

  • 是投影矩阵的逆运算
  • 可用于将裁剪空间坐标转换回视图空间
  • 与内置渲染管线不兼容

应用场景:

  • 深度纹理的重建和解析
  • 屏幕空间反射和折射
  • 后处理效果中的空间转换

ViewProjection(视图投影矩阵)

视图投影矩阵,表示为VP,是视图矩阵和投影矩阵的组合,直接将顶点从世界空间变换到裁剪空间。

视图投影矩阵的特点:

  • 是视图矩阵和投影矩阵的乘积:VP = P × V
  • 组合了两个变换步骤,提高计算效率
  • 常用于顶点着色器中的最终位置计算

应用场景:

  • 高效的顶点变换
  • 全屏着色器效果
  • 阴影和光照计算

InverseViewProjection(逆视图投影矩阵)

逆视图投影矩阵,表示为(VP)⁻¹,是视图投影矩阵的逆矩阵,用于将顶点从裁剪空间变换回世界空间。

逆视图投影矩阵的特点:

  • 是视图投影矩阵的逆运算: (VP)⁻¹ = V⁻¹ × P⁻¹
  • 组合了两个逆变换
  • 与内置渲染管线不兼容

应用场景:

  • 从屏幕空间位置重建世界空间位置
  • 全局光照计算
  • 高级后处理效果

生成的代码示例

理解Transformation Matrix节点生成的代码对于深入掌握其工作原理和进行高级着色器编程至关重要。以下示例代码展示了该节点在每个模式下对应的HLSL代码,这些代码揭示了节点背后的实际实现。

Model模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_M;

在Model模式下,节点输出Unity的内置模型矩阵UNITY_MATRIX_M。这个矩阵将顶点从对象局部空间变换到世界空间。在实际着色器中,这个矩阵通常用于将顶点位置、法线和其他向量从对象空间转换到世界空间。

使用示例:

  • 计算世界空间位置:float3 worldPos = mul(UNITY_MATRIX_M, float4(localPos, 1.0)).xyz;
  • 转换法线到世界空间:float3 worldNormal = normalize(mul((float3x3)UNITY_MATRIX_M, localNormal));

InverseModel模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_I_M;

在InverseModel模式下,节点输出模型矩阵的逆矩阵UNITY_MATRIX_I_M。这个矩阵将顶点从世界空间变换回对象局部空间。需要注意的是,计算逆矩阵在性能上比使用原矩阵更昂贵,因此应谨慎使用。

使用示例:

  • 将世界空间位置转换到对象空间:float3 localPos = mul(UNITY_MATRIX_I_M, float4(worldPos, 1.0)).xyz;
  • 在世界空间效果中保持对象空间特性

View模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_V;

在View模式下,节点输出视图矩阵UNITY_MATRIX_V。这个矩阵将顶点从世界空间变换到视图空间(相机空间)。视图空间以相机为原点,通常相机的观察方向为Z轴负方向。

使用示例:

  • 计算视图空间位置:float3 viewPos = mul(UNITY_MATRIX_V, float4(worldPos, 1.0)).xyz;
  • 基于视图方向的效果实现

InverseView模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_I_V;

在InverseView模式下,节点输出视图矩阵的逆矩阵UNITY_MATRIX_I_V。这个矩阵将顶点从视图空间变换回世界空间。

使用示例:

  • 将视图空间坐标转换回世界空间:float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1.0)).xyz;
  • 在视图空间计算中重建世界位置

Projection模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_P;

在Projection模式下,节点输出投影矩阵UNITY_MATRIX_P。这个矩阵将顶点从视图空间变换到裁剪空间。投影矩阵负责应用透视效果,并定义视锥体的范围。

使用示例:

  • 计算裁剪空间位置:float4 clipPos = mul(UNITY_MATRIX_P, float4(viewPos, 1.0));
  • 自定义投影效果实现

InverseProjection模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_I_P;

在InverseProjection模式下,节点输出投影矩阵的逆矩阵UNITY_MATRIX_I_P。这个矩阵将顶点从裁剪空间变换回视图空间。需要注意的是,此选项与内置渲染管线不兼容。

使用示例:

  • 从深度纹理重建视图空间位置
  • 屏幕空间反射和折射效果

ViewProjection模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_VP;

在ViewProjection模式下,节点输出视图投影矩阵UNITY_MATRIX_VP。这个矩阵是视图矩阵和投影矩阵的组合,直接将顶点从世界空间变换到裁剪空间。

使用示例:

  • 高效计算裁剪空间位置:float4 clipPos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));
  • 全屏后处理效果

InverseViewProjection模式代码分析

float4x4 _TransformationMatrix_Out = UNITY_MATRIX_I_VP;

在InverseViewProjection模式下,节点输出视图投影矩阵的逆矩阵UNITY_MATRIX_I_VP。这个矩阵将顶点从裁剪空间变换回世界空间。此选项也与内置渲染管线不兼容。

使用示例:

  • 从屏幕空间坐标重建世界空间位置
  • 全局光照和体积效果

实际应用案例

为了更深入地理解Transformation Matrix节点的使用方法,以下提供几个实际应用案例,展示如何在不同场景中利用各种变换矩阵。

案例一:基于对象空间的动态变形

在这个案例中,我们将使用模型矩阵创建基于对象空间的动态变形效果。这种效果可以用于创建摇摆的植物、波动的旗帜或自定义的顶点动画。

实现步骤:

  1. 使用Transformation Matrix节点选择Model模式,获取模型矩阵
  2. 将模型矩阵连接到Transform节点,将世界空间向量转换到对象空间
  3. 在对象空间中计算变形偏移
  4. 使用逆模型矩阵将变形后的位置转换回世界空间
  5. 应用最终的世界空间位置

这种方法的优势在于变形效果相对于对象自身,无论对象在场景中如何移动或旋转,变形效果都会保持一致性。

案例二:屏幕空间反射效果

屏幕空间反射(SSR)是一种常见的高级渲染技术,它使用逆视图投影矩阵从屏幕空间信息重建世界空间位置,从而计算精确的反射效果。

实现步骤:

  1. 使用Transformation Matrix节点选择InverseViewProjection模式
  2. 结合深度纹理和屏幕UV坐标重建世界空间位置
  3. 计算反射向量和反射交点
  4. 在屏幕空间中采样反射颜色
  5. 将反射颜色与表面颜色混合

这种方法能够创建高质量的真实感反射效果,但需要注意性能影响和边缘情况处理。

案例三:自定义投影系统

在某些特殊应用中,如VR、AR或特殊镜头效果,可能需要自定义投影系统。使用投影矩阵和逆投影矩阵可以实现非标准的投影效果。

实现步骤:

  1. 使用Transformation Matrix节点选择Projection模式获取标准投影矩阵
  2. 修改投影矩阵以实现所需的投影效果(如鱼眼、圆柱投影)
  3. 在着色器中应用自定义投影矩阵
  4. 使用逆投影矩阵进行相应的空间转换

这种方法允许开发者突破标准透视投影的限制,创建独特的视觉体验。

性能考虑和最佳实践

虽然Transformation Matrix节点提供了方便的矩阵访问方式,但在实际使用中需要考虑性能影响和最佳实践。

性能考虑

  • 矩阵乘法在着色器中是相对昂贵的操作,应尽量减少不必要的矩阵变换
  • 逆矩阵的计算和使用比原矩阵更昂贵,应谨慎使用
  • 在可能的情况下,优先使用预计算的组合矩阵(如ViewProjection)而不是分别应用多个矩阵
  • 对于静态对象,考虑在CPU端预计算变换结果

最佳实践

  • 在顶点着色器中进行空间变换,而不是在片元着色器中
  • 尽可能使用最少的变换步骤达到所需效果
  • 对于不需要精确矩阵的情况,考虑使用简化计算或近似方法
  • 在不同渲染管线中测试兼容性,特别是使用InverseProjection和InverseViewProjection时

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

提升 Canvas 2D 绘图技术:应对全面工业化场景的系统方法

作者 LeonGao
2026年2月26日 10:04

一、引言:从“小画布”到“工业级绘图引擎”

Canvas 2D 在很多人印象中常常只是:

  • 做个简单的画板;
  • 在网页上画几条线、几张图;
  • 写写 demo 或可视化小玩具。

但在实际的工业场景中,Canvas 2D 承担的角色远远超出这些想象。例如:

  • 工业监控大屏(SCADA / 生产线监控 / IoT 可视化)
  • 重型 MIS / ERP 系统中的复杂流程图、拓扑图、排产甘特图
  • CAD 类工具(平面设计、 PCB 原理图、建筑平面布置)
  • Web 图形编辑器(类似 Figma / 白板 / 流程图工具)
  • 在线图表库 & 海量点位数据可视化(GIS 热力图、轨迹回放)

这些场景要求 Canvas 2D 不仅“能画”,还要:

  • 支持大规模图元(上万、甚至几十万对象);
  • 具备高性能、不卡顿的交互体验;
  • 容易实现复杂的业务逻辑(选中、拖拽、编辑、对齐、吸附、区域选择、撤销/重做等);
  • 可维护、可扩展,能支撑长期演进。

本文将系统梳理:
如何从“会用 API”升级为“能设计工业化 Canvas 2D 绘图系统”的工程师


二、问题与背景:普通 Canvas 开发为何撑不起工业化场景?

2.1 常见困境

在实际项目中,如果仅凭“会使用 Canvas API”去实现复杂绘图系统,很容易遇到:

  1. 性能崩溃

    • 页面中有几千个图元,每次拖动/缩放就卡顿;
    • 频繁全量重绘,主线程被长时间阻塞。
  2. 代码难以维护

    • 绘制逻辑散落各处,drawXXX 函数一大坨;
    • 对象状态(位置、选中、层级)和绘图代码耦合在一起;
    • 新增一个业务图形的功能需要改动大量旧代码。
  3. 交互逻辑混乱

    • 命中判断(hit test)不准,选中/拖拽行为错乱;
    • 事件分发无序,各个图元的交互互相影响;
    • 多选、框选、对齐辅助线等高级交互难以实现。
  4. 缺乏抽象与工程化

    • 没有场景(scene)、图元(shape)、图层(layer)的概念;
    • 没有统一的渲染/刷新机制(render loop);
    • 和业务逻辑混合在一起,无法复用和单独测试。
2.2 工业化场景的关键诉求

与“demo 级”相比,工业化 Canvas 绘图的核心诉求可以总结为“三高一低”:

  • 高性能:大量图元、复杂交互下仍保持流畅(60 FPS 或至少稳定 > 30 FPS)
  • 高抽象:具备通用的对象模型与事件模型,易于扩展新图元和业务能力
  • 高可维护性:模块清晰、职责单一,能有效分工协作与长线维护
  • 低耦合:渲染引擎与业务逻辑尽量解耦,便于移植、升级、做多产品线复用

接下来,我们从架构、性能、交互和工程实践四个维度,一步步讨论如何提升 Canvas 2D 能力来应对这些要求。


三、技术实现:从“画图 API”到“小型 2D 引擎”的演进

3.1 从底层 API 到对象模型:先搭好“图元系统”

工业化场景中,不要直接在业务代码中裸用 Canvas API
更推荐的做法是先构建一套“对象模型(Object Model)”,再用这套模型来描述业务图形。

一个典型的基础对象结构可以是:

// 几何基础类型
type Point = { x: number; y: number };
type Rect = { x: number; y: number; width: number; height: number };

// 通用图元接口
interface Shape {
  id: string;
  // 几何信息
  x: number;
  y: number;
  rotation: number;
  scaleX: number;
  scaleY: number;

  // 样式信息
  fillStyle?: string;
  strokeStyle?: string;
  lineWidth?: number;

  // 绘制方法
  draw(ctx: CanvasRenderingContext2D): void;

  // 碰撞检测 / 命中测试
  containsPoint(p: Point): boolean;

  // 获取包围盒(用于快速过滤)
  getBoundingBox(): Rect;
}

针对具体类型,如矩形、圆形、图片、文本,可以分别实现:

class RectShape implements Shape {
  id: string;
  x: number;
  y: number;
  width: number;
  height: number;
  rotation = 0;
  scaleX = 1;
  scaleY = 1;
  fillStyle?: string;
  strokeStyle?: string;
  lineWidth?: number;

  constructor(init: {
    id?: string;
    x: number;
    y: number;
    width: number;
    height: number;
    fillStyle?: string;
    strokeStyle?: string;
    lineWidth?: number;
  }) {
    this.id = init.id ?? crypto.randomUUID();
    Object.assign(this, init);
  }

  draw(ctx: CanvasRenderingContext2D) {
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.rotate(this.rotation);
    ctx.scale(this.scaleX, this.scaleY);

    if (this.fillStyle) {
      ctx.fillStyle = this.fillStyle;
      ctx.fillRect(0, 0, this.width, this.height);
    }
    if (this.strokeStyle) {
      ctx.strokeStyle = this.strokeStyle;
      ctx.lineWidth = this.lineWidth ?? 1;
      ctx.strokeRect(0, 0, this.width, this.height);
    }
    ctx.restore();
  }

  containsPoint(p: Point): boolean {
    // 简化:假定没有旋转缩放时的判断,可逐步扩展
    const { x, y, width, height } = this;
    return p.x >= x && p.x <= x + width && p.y >= y && p.y <= y + height;
  }

  getBoundingBox(): Rect {
    // 简化版:忽略旋转
    return { x: this.x, y: this.y, width: this.width, height: this.height };
  }
}

要点:

  • 所有图元都实现同一接口,方便统一管理和渲染;
  • 图元自身负责“如何画自己”和“如何判断命中自己”,逻辑内聚;
  • 后续可以在此基础上扩展:组合图元(Group)、连接线(Link)、文本标签(Label)等。
3.2 场景(Scene)与图层(Layer):组织复杂内容

在工业绘图中,“一个大画布 + 许多对象”容易乱。
通常需要引入场景与图层的概念

class Layer {
  id: string;
  visible = true;
  zIndex: number;
  shapes: Shape[] = [];

  constructor(id: string, zIndex = 0) {
    this.id = id;
    this.zIndex = zIndex;
  }

  add(shape: Shape) {
    this.shapes.push(shape);
  }

  removeById(id: string) {
    this.shapes = this.shapes.filter((s) => s.id !== id);
  }

  draw(ctx: CanvasRenderingContext2D) {
    if (!this.visible) return;
    for (const shape of this.shapes) {
      shape.draw(ctx);
    }
  }
}

class Scene {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private layers: Layer[] = [];
  private dirty = true; // 标记是否需要重绘

  constructor(canvas: HTMLCanvasElement) {
    this.canvas = canvas;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('Cannot get 2D context');
    this.ctx = ctx;
  }

  addLayer(layer: Layer) {
    this.layers.push(layer);
    this.layers.sort((a, b) => a.zIndex - b.zIndex);
    this.markDirty();
  }

  markDirty() {
    this.dirty = true;
  }

  render() {
    if (!this.dirty) return;
    const { ctx, canvas } = this;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (const layer of this.layers) {
      layer.draw(ctx);
    }
    this.dirty = false;
  }
}

配合 requestAnimationFrame,形成一个主动控制的渲染循环

function startRenderLoop(scene: Scene) {
  function loop() {
    scene.render();
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
}

要点:

  • 场景负责整体渲染与刷新节奏;
  • 图层分离不同类别的内容(背景栅格、主图元、选中高亮、浮动标注、临时辅助线等);
  • 通过 dirty 标记实现按需刷新,避免在静止状态仍每帧重绘消耗性能。
3.3 命中测试与交互系统:从“点坐标”到“对象事件”

绘图系统最难的往往不是“画”,而是“交互”。

核心需求:

  • 鼠标移动、点击、拖拽、缩放;
  • 框选、多选、节点编辑(例如调整折线的控制点);
  • 悬停高亮、右键菜单、对齐辅助线、吸附等。

关键步骤是:建立“命中测试(hit test) + 事件分发”机制。

一个典型做法:

  1. 在场景上监听 DOM 事件(mousedown, mousemove, mouseup, wheel 等);
  2. 把事件坐标转换为 Canvas 内部坐标(考虑缩放和平移);
  3. 在图层中,从上到下查找“最上层命中的图元”;
  4. 把 DOM 事件包装为图元事件,并派发给对应对象或行为系统。

示例(极简版):

class Scene {
  // ...前略...
  private listenersBound = false;
  private scale = 1;
  private offsetX = 0;
  private offsetY = 0;

  bindEvents() {
    if (this.listenersBound) return;
    this.listenersBound = true;

    this.canvas.addEventListener('mousedown', this.handleMouseDown);
    this.canvas.addEventListener('mousemove', this.handleMouseMove);
    this.canvas.addEventListener('mouseup', this.handleMouseUp);
  }

  private toScenePoint(evt: MouseEvent): Point {
    const rect = this.canvas.getBoundingClientRect();
    const x = (evt.clientX - rect.left - this.offsetX) / this.scale;
    const y = (evt.clientY - rect.top - this.offsetY) / this.scale;
    return { x, y };
  }

  private findShapeAt(p: Point): Shape | null {
    // 从 zIndex 最大的图层开始
    const layers = [...this.layers].sort((a, b) => b.zIndex - a.zIndex);
    for (const layer of layers) {
      if (!layer.visible) continue;
      for (let i = layer.shapes.length - 1; i >= 0; i--) {
        const shape = layer.shapes[i];
        if (shape.containsPoint(p)) {
          return shape;
        }
      }
    }
    return null;
  }

  private handleMouseDown = (evt: MouseEvent) => {
    const p = this.toScenePoint(evt);
    const shape = this.findShapeAt(p);
    if (shape) {
      // 在这里可以触发“选中”等逻辑
      console.log('Clicked shape:', shape.id);
      // 后续可扩展事件系统:shape.onPointerDown(p)
    } else {
      console.log('Clicked on empty area');
    }
  };

  private handleMouseMove = (evt: MouseEvent) => {
    // 可用于 hover 效果 / 拖拽 / 区域选择
  };

  private handleMouseUp = (evt: MouseEvent) => {
    // 结束拖拽或框选
  };
}

要点:

  • 交互不直接写在业务组件里,而由 Scene 控制坐标变换、命中判断;
  • 图元只暴露基本的 containsPoint 与状态接口(如 setSelected(true)),供行为模块使用;
  • 对于复杂编辑操作,可进一步引入“工具(Tool)/ 行为(Behavior)”模式:
    如:选择工具(SelectTool)、矩形创建工具(RectCreateTool)、连接线编辑工具(EdgeEditTool)。
3.4 性能优化:从“能动”到“动得快”

工业化 Canvas 应用的性能优化,常见手段包括:

3.4.1 合理使用双缓冲与离屏 Canvas

场景:

  • 背景栅格、网格线、固定不变的底图(例如工厂平面图);
  • 重复绘制的复杂元素(比如多个相同图案)。

可以通过离屏 Canvas(document.createElement('canvas'))进行预渲染,仅在必要时复用:

function createGridPattern(
  size: number,
  color = '#ccc'
): CanvasPattern | null {
  const offCanvas = document.createElement('canvas');
  offCanvas.width = size;
  offCanvas.height = size;
  const ctx = offCanvas.getContext('2d');
  if (!ctx) return null;

  ctx.strokeStyle = color;
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(size, 0);
  ctx.moveTo(0, 0);
  ctx.lineTo(0, size);
  ctx.stroke();

  const mainCanvas = document.createElement('canvas');
  const mainCtx = mainCanvas.getContext('2d');
  return mainCtx?.createPattern(offCanvas, 'repeat') ?? null;
}

然后在场景中设置背景填充为该 pattern,而不是每帧重新画网格。

3.4.2 视口裁剪与空间索引

如果图元数量巨大(数万以上),全量遍历 containsPoint 和全量绘制会崩掉。
需要:

  1. 视口裁剪(View Culling)
    只绘制当前视口范围内的图元。
    图元可通过 getBoundingBox() 先判 BB 是否与视口相交,不相交则略过。
  2. 空间索引(Spatial Index)
    使用四叉树(Quadtree)、R 树等数据结构加速“找到一个点附近的图元”的操作。

示例:使用简单四叉树做命中预过滤(伪代码简化版):

interface QuadNode {
  bounds: Rect;
  shapes: Shape[];
  children: QuadNode[] | null;
}

class QuadTree {
  root: QuadNode;
  capacity: number;

  constructor(bounds: Rect, capacity = 8) {
    this.root = { bounds, shapes: [], children: null };
    this.capacity = capacity;
  }

  insert(shape: Shape) {
    // 递归将 shape 插入到合适的子节点
  }

  query(point: Point): Shape[] {
    // 返回可能包含该点的 shape 列表(候选集)
    return [];
  }
}

命中测试就变成:先通过 QuadTree 获得少量候选图元,再对这些图元调用 containsPoint 进行精确判断。

3.4.3 减少重绘面积与重排逻辑
  • 对于拖拽一个小图元的场景,可以通过局部重绘(dirty rect)提高性能:
    只清空与该图元相关的区域,而非清空整个 Canvas。
  • 批量更新时,尽量合并操作,在一个 requestAnimationFrame 中统一修改状态再触发渲染。

3.5 工程化能力:与现代前端架构的集成

工业化项目离不开整体前端架构与工程实践。
Canvas 引擎需要与状态管理、UI 框架、后端接口协同工作。

典型模式:

  • 上层使用 Vue / React / Angular 构建 UI(属性面板、图层面板、属性表格等);
  • 中间是一套“绘图引擎 / 场景管理模块”(如前文的 Scene + Layer + Shape);
  • 下层通过 API 与后端通讯(存储图纸、读取配置、实时数据刷新)。

建议:

  1. 引擎与 UI 分离

    • 不要把 Vue/React 组件逻辑直接塞进 Shape;
    • Shape 只关注“绘制与几何”,属性编辑交给外层 UI。
  2. 状态管理统一

    • 利用 Redux / Pinia / MobX 等存放图纸的“文档结构”(各个图元的数据);
    • Canvas 引擎根据 store 中的数据构造图元列表;
    • 修改图元属性时触发 store 更新,再同步到场景。
  3. Undo/Redo(撤销/重做)机制

    • 将“操作”抽象为一个个命令(Command)对象:

      • execute() / undo()
    • 操作堆栈记录所有变化,支持撤销/重做,满足工业工具类产品的刚需。


四、技术优缺点分析与实际应用建议

4.1 Canvas 2D 在工业场景的优缺点

优点:

  1. 跨平台 & 标准化

    • 纯 Web 技术(HTML5 标准);
    • 不依赖浏览器插件,天然跨平台(PC、Pad、部分移动端)。
  2. 实现复杂自由图形相对容易

    • 贝塞尔曲线、裁剪、组合、变换都由 2D API 直接支持;
    • 对于高定制的绘图 UI 自主权巨大。
  3. 与 Web 生态高度兼容

    • 与 Vue/React、前端工程体系结合顺畅;
    • 可直接使用各类工具库(如 RxJS、Immer、D3 的几何算法等)。

缺点:

  1. 无 retained-mode(保留模式)图元

    • Canvas 本身是 immediate-mode(即时绘制),开发者需要自建对象模型与渲染管理;
    • 相比 SVG/DOM 需要更多工程工作。
  2. 对文本/布局支持较弱

    • 文本排版复杂时较难精细控制(尤其是多行文本折行、排版);
    • DOM 更擅长文本文字丰富场景。
  3. 单线程限制 & 性能瓶颈

    • 主线程被大量绘图占用时,容易影响 UI 响应;
    • 需要结合 OffscreenCanvas + Web Worker 等方案做更高级优化。

结论:
强交互、高度定制图形、需要大量图元的工业工具类场景中,Canvas 2D 依然具备很强的实际价值。
关键在于:用工程化的方法把 Canvas 变成一个“小型 2D 引擎”,而不是一个简单画布。


4.2 实战应用建议:如何系统提升自己的 Canvas 2D 水平
  1. 打牢基础:熟悉所有 2D API

    • 路径(Path2D)、变换(translate/rotate/scale/transform);
    • 绘制图像(drawImage)、合成与混合(globalCompositeOperation);
    • 阴影、渐变、裁剪(clip)等高级效果。
  2. 练习构建对象模型和简单引擎

    • 从简单图元开始:矩形、圆形、线段、多边形;
    • 实现:图元类 + 场景类 + 选择/拖拽交互;
    • 尝试添加:缩放平移、网格背景、多选框选。
  3. 学习空间索引与性能优化

    • 实现基本的四叉树 / 网格索引,用于加速命中测试和视口裁剪;
    • 对比“全量重绘”、“视口裁剪”、“离屏 Canvas”的性能差异。
  4. 研究成熟的 Canvas 库与框架

    • Fabric.js、Konva.js、PixiJS(主要是 WebGL,但有 2D fallback)等;
    • 阅读它们的源码或架构文档,模仿其图元/场景/事件设计。
  5. 与 UI 框架整合一个完整 Demo

    • 例如:用 Vue + Canvas 做一个轻量的流程图编辑器或白板;
    • 通过状态管理、Undo/Redo、属性编辑等完整流程打通思路。
  6. 面向业务场景实践

    • 如果你的公司有 SCADA、大屏、流程图、甘特图等需求,可以主动接手这些任务;
    • 在实战中不断打磨自己的引擎抽象和性能策略。

五、结论:Canvas 2D 的工业化道路——“引擎化”与“工程化”

想真正把 Canvas 用到工业级水平,关键不在于“记住多少 Canvas API”,而在于:

  • 是否有一套清晰的图元模型与场景架构;
  • 是否掌握命中测试、事件分发、空间索引和性能优化等核心技术;
  • 是否能把 Canvas 引擎与现代前端工程体系(状态管理、组件化、CI/CD)有效整合。

当你能从“写 demo”转变为“搭一个专用 2D 引擎”时,
Canvas 2D 才真正成为你用于解决工业化可视化、编辑器和工具类产品的长期武器


六、延伸学习资料与参考链接

基础与 API
可参考的 Canvas 库
  • Fabric.js(面向对象的 Canvas 引擎):
    fabricjs.com/
  • Konva.js(支持层、事件的 2D 引擎,支持 Canvas + DOM):
    konvajs.org/
  • PixiJS(主要是 WebGL 2D 渲染,但对场景/图元管理模型非常值得学习):
    pixijs.com/
性能与进阶

JS 函数参数默认值误区解析:传 null 为何不触发默认值?

作者 简离
2026年2月26日 10:02

在 JavaScript 开发中,函数参数默认值是简化代码、处理边界场景的常用语法,既能减少冗余的参数校验代码,也能提升代码的可读性和可维护性。但在实际使用中,很多开发者会陷入一个常见误区——认为只要传入的是“空值”(如 null),就会触发参数默认值,实则不然。本文将从核心规则、代码示例、实用技巧、进阶场景及踩坑点五个方面,详细解析函数参数默认值的生效逻辑,帮你彻底理清其中关键,避免开发中的相关踩坑。

首先,我们明确函数参数默认值的核心生效规则,这是理解所有场景的基础。

一、核心规则:默认值仅在参数为 undefined 时生效

JavaScript 官方规范明确规定:函数参数的默认值,仅在该参数的值为 undefined 时才会被启用。也就是说,只要开发者显式传入了参数值(无论该值是否为“空”),JavaScript 都会将其视为有效的参数输入,不会触发默认值。

以下几种常见场景,均不会触发参数默认值:

  • 传入 null:最易踩坑的场景,很多开发者误将 null 等同于“未传参”
  • 传入 0NaN:数值类型的“空值”或无效值
  • 传入空字符串 '':字符串类型的空值
  • 传入 false:布尔类型的“假值”

结合上述场景可总结,只有两种情况会触发默认值:一是调用函数时未传入该参数,二是主动传入 undefined。为了更直观地验证这一规则,我们通过具体代码示例进一步拆解不同传参场景的表现。

二、代码示例:直观理解生效场景

通过具体代码对比,能更清晰地看到不同传参方式下的结果差异,帮助我们牢记生效规则:

// 定义带有默认值的函数
function getUserName(name = '匿名用户') {
  console.log('当前用户:', name);
}

// 场景1:未传参 → 参数值为 undefined → 触发默认值
getUserName(); // 输出:当前用户:匿名用户

// 场景2:传入有效参数 → 不触发默认值
getUserName('前端开发者'); // 输出:当前用户:前端开发者

// 场景3:传入 null → 不触发默认值
getUserName(null); // 输出:当前用户:null

// 场景4:传入空字符串 → 不触发默认值
getUserName(''); // 输出:当前用户:

// 场景5:传入 0 → 不触发默认值
getUserName(0); // 输出:当前用户:0

// 场景6:主动传入 undefined → 触发默认值
getUserName(undefined); // 输出:当前用户:匿名用户

从上述示例中可以明显看出,只有未传参和主动传入 undefined 时,默认值才会生效;而传入 null 等“空值”时,函数会直接使用传入的 null,这也是很多开发中出现空值报错的常见原因。基于这一问题,实际业务中我们常常需要实现“未传参、传 null 时均使用默认值”的需求,此时可借助专门的语法实现兜底处理。

三、实用技巧:让 null 也能触发默认值

实际业务开发中,我们常常需要实现“未传参、传 null 时,均使用默认值”的需求。此时,仅依靠参数默认值无法满足需求,推荐使用 空值合并运算符(??) 进行手动兜底,其逻辑更精准、更安全。

空值合并运算符(??)的核心逻辑:当左侧值为 null 或 undefined 时,返回右侧的值;否则返回左侧的值。与逻辑或运算符(||)相比,它不会误吞 0、false、空字符串等“假值”,能精准匹配“仅 null/undefined 兜底”的需求,更适合参数兜底场景,具体实现如下:

// 优化后的函数:未传参、传 null 均触发默认值
function getUserName(name) {
  // 当 name 为 null 或 undefined 时,使用默认值
  name = name ?? '匿名用户';
  console.log('当前用户:', name);
}

// 测试场景
getUserName(); // 输出:当前用户:匿名用户(未传参)
getUserName(null); // 输出:当前用户:匿名用户(传 null)
getUserName(''); // 输出:当前用户:(传空字符串,不触发默认值)
getUserName(0); // 输出:当前用户:0(传 0,不触发默认值)

除了普通参数,函数参数解构赋值中,默认值的生效规则也遵循上述核心逻辑,这是开发中另一个高频使用场景,需重点关注。

四、进阶场景:解构赋值中的默认值

在函数参数解构赋值中,默认值的生效规则与普通参数一致,同样仅在参数为 undefined 时生效。但解构赋值存在特殊注意点:若未给解构对象设置默认值,当未传参时会直接报错,因此通常会给解构对象设置一个默认空对象,再给内部属性设置默认值,具体示例如下:

// 解构赋值 + 默认值(推荐写法)
function getUserInfo({ name = '匿名用户', age = 18 } = {}) {
  console.log('用户信息:', { name, age });
}

// 场景1:未传参 → 解构对象为 undefined → 触发外层默认空对象,再触发内部属性默认值
getUserInfo(); // 输出:用户信息:{ name: '匿名用户', age: 18 }

// 场景2:传入部分参数 → 未传的属性触发默认值
getUserInfo({ name: '前端君' }); // 输出:用户信息:{ name: '前端君', age: 18 }

// 场景3:传入 null → 解构对象为 null → 不触发默认值,直接报错
// getUserInfo(null); // 报错:Cannot destructure property 'name' of 'null' as it is null.

// 场景4:优化 null 兼容(结合 ??)
function getUserInfoOpt({ name = '匿名用户', age = 18 } = {}) {
  name = name ?? '匿名用户';
  age = age ?? 18;
  console.log('用户信息:', { name, age });
}
getUserInfoOpt(null); // 输出:用户信息:{ name: &#39;匿名用户&#39;, age: 18 }

结合前面的核心规则、基础示例、实用技巧及进阶场景,我们梳理出开发中最常见的踩坑点,帮助大家规避同类问题。

五、常见踩坑点总结

  1. 不要将 null 等同于 undefined:两者语义不同,null 是“主动传入的空值”,undefined 是“未定义的值”,只有后者会触发默认值。
  2. 避免使用 || 兜底默认值:|| 会将 0、false、空字符串等“假值”都视为无效值,可能导致预期之外的结果,优先使用 ??。
  3. 解构赋值时,务必给外层对象设置默认空对象(= {}),否则未传参时会报错。

综上,函数参数默认值的核心逻辑的是“仅 undefined 触发”,这是 JavaScript 官方规范定义的标准行为。掌握这一规则,结合空值合并运算符(??)处理 null 兼容、给解构对象设置默认空对象等技巧,能帮助我们写出更健壮、更符合预期的代码,减少因空值处理不当导致的线上问题。在实际开发中,只需根据业务场景灵活运用这些方法,就能兼顾代码的简洁性和可靠性。

最好的跨端架构 · Vue 篇:从理念到落地实践

作者 LeonGao
2026年2月26日 09:58

一、引言:为什么要在 Vue 上谈“跨端架构”?

随着业务形态从单一 Web 页面演进到「Web + 小程序 + App + 桌面端」的多终端时代,“一套代码、多端运行”几乎成了前端团队的刚需诉求。
对 Vue 开发者来说,更现实的问题是:

  • 项目已经在用 Vue,能不能不推翻重来,尽量复用现有代码,覆盖更多终端?
  • 如何在保证性能和体验的前提下,实现最大程度的代码共享
  • 如何搭建一套可演进、可维护的跨端架构,而不是“到处打补丁”的项目堆砌?

本文以「最好的跨端架构 · Vue 篇」为主题,从背景问题出发,系统梳理基于 Vue 的主流跨端方案与架构思路,包括:

  • 单一技术栈 & 多运行时:Vue + Web / 小程序 / App 的典型模式;
  • 多端统一抽象层:通过组件层 & 业务层抽象来屏蔽差异;
  • 工程化与架构实战:如何组织目录、如何拆分模块、如何做适配;
  • 不同方案的优缺点分析及选型建议。

适合读者:

  • 有 Vue 基础,想扩展到小程序 / App / 桌面等多端的工程师;
  • 负责前端架构,希望梳理或升级现有多端项目的技术负责人;
  • 想全面了解 Vue 跨端技术生态与工程实践的开发者。

二、问题与背景:多端时代的“碎片化”困局

1. 多端需求带来的典型痛点

当一个项目开始支持多个端时,常见的现实情况是:

  1. 多套代码,重复开发

    • Web 用 Vue + Element Plus
    • 小程序单独用原生 WXML/WXSS 或者 mp-* 框架
    • App 用 uni-app / Flutter / 原生
    • 结果:同一业务逻辑和 UI 被重复实现 2~3 次,维护成本指数级上升。
  2. 功能迭代难统一

    • 新需求上线:Web 先改,几周后再排期给小程序、App;
    • Bug 修复:一个端修完,另一个端没修,线上表现不一致;
    • 多团队并行:分支、版本、接口协议经常“对不上号”。
  3. 技术栈碎片化

    • 团队成员要掌握多种框架和语法;
    • 没有统一的组件库、工具链和调试方式;
    • 知识沉淀零散,轮岗或扩招成本高。
  4. 体验与性能要求提升

    • 用户期望:各端体验差异小,且符合各端平台规范;
    • 业务期望:尽量共享能力,比如统一埋点、统一权限校验、统一 UI 体系。

这些因素共同驱动我们去思考:
有没有一种以 Vue 为核心、可持续演进的跨端架构?


三、基于 Vue 的主流跨端路径概览

在进入具体架构之前,先看目前在 Vue 生态下,典型的多端支持手段:

  1. Web 为主,其他端做“包裹”或降级

    • Web:标准 Vue SPA / MPA
    • App:用 WebView 容器 + H5(如 Capacitor、Cordova、TWA 等)
    • 小程序:使用内嵌 WebView 或仅实现关键路径
    • 特点:开发简单、复用高,但体验略输原生,部分能力受限。
  2. 跨端框架一站式方案(推荐)

    • 代表:uni-app(基于 Vue2/3) 、Taro(Vue 支持)、NutUI + Taro Vue 等
    • 一套 Vue 写法,编译到:H5、各家小程序、App(WebView 或原生渲染)等
    • 特点:统一组件 & API 抽象,较高代码复用度,生态成熟。
  3. 自建多端适配层

    • 业务仍然使用 Vue(2/3),

    • 自己构建「跨端组件层 + 能力适配层」:

      • 比如:<x-button> 在 Web 渲染成 <button>,在小程序编译为 <button> 标签;
      • 能力层如存储、网络、埋点等有统一接口、不同实现。
    • 特点:灵活、可控,但需要较强工程经验和投入成本。

  4. 桌面端 & 其它端

    • 桌面:Electron + Vue、Tauri + Vue
    • TV / IoT:Web 容器 + Vue(带遥控交互适配)
    • 属于延伸场景,这里重点不展开。

下面从架构实践角度,重点讨论基于 Vue 的跨 Web / 小程序 / App 的“相对最优”架构模式


四、解决方案与技术实现:Vue 跨端架构的设计思路

4.1 架构目标与整体思路

核心目标:

  1. 最大化代码共享

    • UI 组件可抽象就抽象;
    • 业务逻辑尽量无端感;
    • 工具函数、数据模型等完全无端。
  2. 最小化平台差异暴露

    • 前端同学开发时尽量不需要考虑“这端不支持某 API”;
    • 差异由「适配层」统一处理。
  3. 工程化可维护

    • 清晰的目录拆分:共用层 / 端专属层;
    • 可靠的构建链路:统一的 CLI / 脚本;
    • 可测试、可持续集成。

整体思路可以概括为:

「以 Vue 语法为统一基础,在其之上抽象统一组件 & 能力 API;
再通过跨端框架或自建编译/适配机制,将统一代码“落地”到各目标平台。」

根据团队情况(重现成还是重架构),可以有两条主线:

  • 方案 A:基于 uni-app 等一站式框架(对现有项目友好、工程成熟)
  • 方案 B:自建 Vue 跨端适配层(对大型项目 & 特殊需求友好)

下面以方案 A 为主线详细展开,实现代码示例和架构拆分;再概括性介绍方案 B。


4.2 方案 A:基于 uni-app 的 Vue 跨端架构(推荐)

uni-app 是基于 Vue 的跨端框架,可以编译到:

  • H5(传统 Web)
  • 各家小程序(微信、支付宝、抖音、百度等)
  • App(通过 WebView + 原生渲染引擎 / uni-app x 实现)

其优势在于:

  • 使用熟悉的 Vue 写法(支持 Vue2/Vue3)
  • 提供了统一的组件体系(<view><button> 等)与统一 API(uni.*
  • 内置打包到多端的构建管线
4.2.1 目录架构示例

以常见的 Vue3 + uni-app 项目为例,可以这样组织:

project-root/
├─ src/
│  ├─ app/
│  │  ├─ App.vue               # 入口 App
│  │  └─ main.ts               # 入口 main
│  ├─ common/
│  │  ├─ constants/            # 业务常量
│  │  ├─ utils/                # 通用工具函数(纯 JS/TS,无端感)
│  │  └─ styles/               # 通用样式/变量
│  ├─ core/
│  │  ├─ api/                  # 统一接口层
│  │  ├─ services/             # 业务服务层(可共用)
│  │  └─ adapters/             # 端能力适配(例如 storage、share、login)
│  ├─ ui/
│  │  ├─ components/           # 端无关 UI 组件(基于 uni 组件抽象)
│  │  └─ pages/                # 页面(路由),页面内部再按端差异细分
│  ├─ platform/
│  │  ├─ h5/                   # H5 端特殊逻辑或组件
│  │  ├─ mp-weixin/            # 微信小程序特殊逻辑
│  │  └─ app/                  # App 端特殊逻辑
│  └─ types/                   # TS 类型声明
├─ uni.config.ts               # uni-app 配置
├─ package.json
└─ ...

关键思想是:

  • common/ + core/ + ui/ 尽可能端无关
  • platform/ 存放端专属代码;
  • adapters/ 把能力差异封装起来,页面层只调用统一 API。
4.2.2 统一能力 API 示例:Storage 适配

在多端中,本地存储 API 经常不同:

  • H5:localStorage / sessionStorage / IndexedDB
  • 小程序:wx.setStorageSync / my.setStorageSync ...
  • uni-app:uni.setStorageSync / uni.getStorageSync

如果项目统一基于 uni-app 运行时,其实可以直接用 uni.*,但为更利于迁移 & 测试,仍推荐加一个抽象层:

// src/core/adapters/storage.ts
export interface StorageAdapter {
  get<T = any>(key: string): T | null;
  set<T = any>(key: string, value: T): void;
  remove(key: string): void;
  clear(): void;
}

class UniStorageAdapter implements StorageAdapter {
  get<T = any>(key: string): T | null {
    try {
      const value = uni.getStorageSync(key);
      return value ? JSON.parse(value) : null;
    } catch (e) {
      return null;
    }
  }
  set<T = any>(key: string, value: T): void {
    uni.setStorageSync(key, JSON.stringify(value));
  }
  remove(key: string): void {
    uni.removeStorageSync(key);
  }
  clear(): void {
    uni.clearStorageSync();
  }
}

export const storage: StorageAdapter = new UniStorageAdapter();

未来如果你要从 uni-app 迁移到纯 Web 或 React Native,只要替换适配器类,而不需要修改业务层代码。

4.2.3 页面与组件层抽象示例

在 uni-app 中,你会大量使用如 <view><text><image> 这样的抽象标签。
在项目中,我们再进一步抽象出更语义化的组件:

<!-- src/ui/components/AppButton.vue -->
<template>
  <button
    class="app-button"
    :class="[`app-button--${type}`, { 'is-disabled': disabled }]"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

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

type ButtonType = 'primary' | 'secondary' | 'danger';

const props = defineProps<{
  type?: ButtonType;
  disabled?: boolean;
}>();

const emit = defineEmits<{
  (e: 'click'): void;
}>();

const handleClick = () => {
  if (!props.disabled) {
    emit('click');
  }
};
</script>

<style scoped>
.app-button {
  padding: 8px 16px;
  border-radius: 4px;
}
/* ...不同type的样式... */
</style>

注意:用于示例时我用了 <button> 标签,在 uni-app 实战中,你会更倾向于使用 <view>/<text> 等跨端标签或内置 <button> 以保障各端兼容。

页面中就可以统一使用:

<!-- src/ui/pages/Login/index.vue -->
<template>
  <view class="login-page">
    <AppInput v-model="form.username" placeholder="用户名" />
    <AppInput
      v-model="form.password"
      type="password"
      placeholder="密码"
    />
    <AppButton type="primary" @click="handleLogin">
      登录
    </AppButton>
  </view>
</template>

<script setup lang="ts">
import { reactive } from 'vue';
import AppInput from '@/ui/components/AppInput.vue';
import AppButton from '@/ui/components/AppButton.vue';
import { loginService } from '@/core/services/auth';

const form = reactive({
  username: '',
  password: '',
});

const handleLogin = async () => {
  await loginService(form.username, form.password);
};
</script>

只要你在各种端都能保证 <AppInput><AppButton> 的实现(或样式)合规,业务页面可实现 95% 以上完全复用。

4.2.4 端差异处理:平台特定代码 + 条件编译

即便使用了统一组件和 API,不可避免仍有端特例,比如:

  • 小程序登录需要调用 wx.login,App 则走原生 SDK,H5 使用 OAuth;
  • 某些 UI 元素在小程序中不推荐展示(例如复杂动画、外部链接)。

在 uni-app 中,可以使用条件编译指令:

// 逻辑层差异示例
const platformLogin = async () => {
  // #ifdef MP-WEIXIN
  const res = await wx.login();
  return callWxLoginApi(res.code);
  // #endif

  // #ifdef H5
  return redirectToOAuth();
  // #endif

  // #ifdef APP-PLUS
  return callNativeLogin();
  // #endif
};
<!-- 视图层差异示例 -->
<view class="download-tip">
  <!-- #ifdef H5 -->
  <text>访问“个人中心”可下载 App 客户端</text>
  <!-- #endif -->

  <!-- #ifdef MP-WEIXIN -->
  <text>在菜单中点击“在浏览器打开”,体验完整功能</text>
  <!-- #endif -->
</view>

关键建议:

  • 把条件编译集中在适配层 / service 层,尽量不要散落在业务页面;
  • 页面只消费统一能力方法,例如:platformLogin()
  • 对于端差异较大的模块,允许单独放在 platform/xxx/ 下,甚至重写对应页面。
4.2.5 接口与状态管理的跨端实践

接口调用一般可以统一使用 uni.request 或自行封装:

// src/core/api/http.ts
import type { UniApp } from '@dcloudio/types';

export interface HttpRequestConfig extends UniApp.RequestOptions {
  baseURL?: string;
}

export function httpRequest<T = any>(
  config: HttpRequestConfig
): Promise<T> {
  const { baseURL = import.meta.env.VITE_API_BASE_URL, url, ...rest } = config;

  return new Promise((resolve, reject) => {
    uni.request({
      url: baseURL + url,
      ...rest,
      success: (res) => {
        if (res.statusCode === 200) {
          resolve(res.data as T);
        } else {
          reject(res);
        }
      },
      fail: reject,
    });
  });
}

状态管理可以统一使用:

  • Vuex(Vue 2)或 Pinia(Vue 3)
  • 全部存放在 src/core/store/,不含端特定逻辑
// src/core/store/user.ts(Pinia 示例)
import { defineStore } from 'pinia';
import { storage } from '@/core/adapters/storage';
import { fetchUserProfile } from '@/core/api/user';

export const useUserStore = defineStore('user', {
  state: () => ({
    token: storage.get<string>('token'),
    profile: null as any,
  }),
  actions: {
    setToken(token: string) {
      this.token = token;
      storage.set('token', token);
    },
    async loadProfile() {
      this.profile = await fetchUserProfile();
    },
  },
});

所有端共享同一套 store,行为一致。


4.3 方案 B:自建 Vue 跨端适配层(高级选项)

如果你的团队对 uni-app 等一站式方案有顾虑(如:编译机制黑盒、bundle 体积、业务侵入性等),也可以采用自建适配层的方式。

核心思路:

  1. 统一业务层:

    • 数据模型(models)、服务层(services)、工具函数(utils)、状态管理(store)完全共用;
    • 只写一套 Vue3 组件逻辑(<script setup>),使用 Composition API。
  2. 多端渲染层:

    • Web:直接使用 Vue3 + Vite;
    • 小程序:通过如taro + taro-vue/kbone 等,把 Vue 组件编译成小程序组件;
    • App:使用 WebView + H5(或 Vue Native / NativeScript-Vue 等)。
  3. 统一组件抽象:

    • 自定义一套类似于 @/ui-kit 的组件库(基于 Vue);
    • 为不同平台提供不同实现(可用 resolveAlias 或构建时替换)。

示例(伪代码):

ui-kit/
├─ Button/
│  ├─ index.ts
│  ├─ Button.web.vue
│  ├─ Button.mp.vue
│  └─ Button.app.vue
└─ ...
// ui-kit/Button/index.ts
import ButtonWeb from './Button.web.vue';
import ButtonMp from './Button.mp.vue';
import ButtonApp from './Button.app.vue';

let Impl: any = ButtonWeb;

if (__PLATFORM__ === 'mp') {
  Impl = ButtonMp;
} else if (__PLATFORM__ === 'app') {
  Impl = ButtonApp;
}

export default Impl;

构建时通过 define 插件(如 Vite 的 define 或 Webpack DefinePlugin),注入 __PLATFORM__ 值,并按端生成 bundle。

此方案能做到极高的控制力与灵活性,但:

  • 工程复杂度较大;
  • 需要自行维护脚手架和构建脚本;
  • 不适合中小团队或交付节点紧张的项目。

因此,一般推荐:优先采用成熟跨端框架(uni-app 等),只在有明确诉求时再考虑自建。


五、优缺点分析与实际应用建议

5.1 基于 uni-app 的 Vue 跨端架构

优点:

  1. 开发门槛低

    • Vue 语法 + 单文件组件,原有 Vue 开发者可快速上手;
    • 官方文档 & 社区资源丰富,遇到问题易查。
  2. 端覆盖广

    • 常见 Web + 各家小程序 + App 一站式支持;
    • 部分端差异由框架屏蔽,前端只需偶尔条件编译。
  3. 工程化成熟

    • 自带 CLI、打包、真机调试、模拟器、HBuilderX 等工具;
    • 与主流 CI/CD 流水线集成相对简单。
  4. 生态与社区支持

    • 有丰富的 uni 组件库与插件市场;
    • 在国内业务环境中,踩坑经验比较充足。

缺点:

  1. 对底层实现可控度有限

    • 编译器与运行时由框架方维护,遇到边缘问题时需要等待更新;
    • 某些高级优化(如体积极致优化、特殊渲染策略)难以自定义。
  2. 对纯 Web / 原生生态融合度略差

    • 如果项目高度依赖某些 Web 特性(比如复杂 DOM 操作),使用 uni 抽象标签会有一些限制;
    • 对复杂原生组件的支持需要通过插件或原生扩展,增加学习成本。
  3. 迁移成本与锁定效应

    • 深度依赖 uni.* 的 API 后,意味着与其他跨端/原生方案的迁移成本上升;
    • 对未来技术栈演进需要做好评估。

适用场景:

  • 团队以 Vue 为主技术栈,希望快速覆盖 H5 + 小程序 + App;
  • 产品形态偏「信息展示 / 业务流程」,对超高性能和炫酷原生能力要求不极端;
  • 希望有稳定的社区生态和工具支持,而非从零搭脚手架。

5.2 自建跨端适配层方案

优点:

  1. 高度灵活与可控

    • 构建流程、组件渲染、性能优化策略全部可自定义;
    • 对未来技术路线自由度更高。
  2. 利于规模化 & 长期演进

    • 对于大型平台,适配层一旦完成,后续多项目可以共用;
    • 容易嵌入公司内部的基础设施与约束体系。
  3. 可以深度融合多种技术栈

    • 比如:部分模块用 React Native,部分用 Vue;
      由适配层统一暴露接口给上层业务。

缺点:

  1. 初期建设成本高

    • 需要强架构能力与编译工具链经验;
    • 研发周期长,短期内见效有限。
  2. 维护难度大

    • 框架升级时,需要同步维护适配层;
    • 新人上手成本高。
  3. 易走向过度抽象

    • 为追求“所有端统一”,容易引入过度复杂的抽象,反而增加开发负担;
    • 需要严谨的规范与约束能力。

适用场景:

  • 大中型公司,有专门前端架构组和基础设施团队;
  • 平台型产品,寿命长、扩展面广;
  • 对性能、体验、技术栈整合有高要求。

5.3 实战建议与落地路径

  1. 从业务最核心的“共性层”入手

    • 先梳理业务中的:公共模型、公共服务、公共组件;
    • 把这些抽取到 core/ + ui/ 层;确保它们尽可能不依赖任何特定端 API。
  2. 选择一套主跨端方案做“骨干”

    • 如果项目还没定:优先选择 uni-app + Vue3;
    • 如果已有 Web 项目:评估是否可以增量引入 uni-app(新模块用 uni,旧模块逐步迁移)。
  3. 尽早规划适配层和目录结构

    • 明确什么放 common/,什么放 platform/,什么放 adapters/
    • 对团队做一次“目录与约定培训”,避免后续反复重构。
  4. 控制条件编译的范围

    • 条件编译集中在适配层和少量关键页面;
    • 严禁在所有业务逻辑到处塞 // #ifdef,否则项目后期会极难维护。
  5. 配合 CI/CD 与质量保障

    • 建立多端自动构建脚本,一次提交,验证多个端;
    • 引入 E2E 测试(例如基于 H5 + 小程序模拟器)验证主流程稳定性。
  6. 阶段性评估与调整

    • 每个里程碑回顾:共用代码比例、各端故障率、迭代效率;
    • 必要时拆出高度端相关的模块,单独维护。

六、结论:Vue 跨端架构的实际价值与未来展望

基于 Vue 的跨端架构,本质是在多端碎片化现实与工程可维护性之间寻求平衡。

通过本文的讨论可以看到:

  • 以 Vue 为统一技术栈,能很好地承载 Web / 小程序 / App 等多种终端;
  • 借助 uni-app 等成熟跨端框架,可以在较短时间内搭建起高复用度的多端工程体系;
  • 通过 统一组件层 + 能力适配层 + 清晰目录结构,可以显著降低多端开发和维护成本,提升团队整体交付效率。

未来趋势上:

  • Vue 官方及社区对跨端(尤其是 Web + 原生)的探索还在继续;
  • 更轻量的运行时、更多样的编译目标(桌面、TV、车机等)正在涌现;
  • “跨端”将逐步从“写一套代码到处跑”的理想,演进为**“以统一业务内核 + 多端体验优化”的综合工程实践**。

在这个过程中,“以 Vue 为核心的跨端架构”仍将是一个实用且具性价比的选择,尤其适合已经广泛采用 Vue 的团队和项目。


七、延伸阅读与参考资料

以下资料有助于你进一步深入:

官方与文档类

跨端相关框架

工程化与架构

一文读懂:CommonJS 和 ES Module 的本质区别

作者 zhEng
2026年2月26日 08:35

面试官:你能说说 CommonJS 和 ES Module 的区别吗?
我:……(脑子里只剩下 requireimport

说实话,这个问题你一定见过,而且99% 的前端都背过标准答案
但真要往深了问一句:

  1. 为什么 ESM 可以 Tree Shaking?CommonJS 不行
  2. 为什么 ESM 的 import 是“只读的”?

很多人,当场就开始“CPU 过载”。

于是我决定直接把底层逻辑捋清楚,以下就是我对 CommonJS 和 ES Module 一次系统性深挖的记录

一、什么是 CommonJS?它解决了什么问题?

1. CommonJS 的诞生背景

在早期 JavaScript 只有浏览器环境时,是没有模块系统的

  • 全局变量污染
  • 文件之间依赖混乱
  • 无法复用代码

于是 Node.js 社区提出了一套解决方案:CommonJS(CMJ)

👉 注意:CommonJS 是社区标准,不是官方语言层面的规范。

CommonJS 的核心特征

  • ✅ 社区标准
  • ✅ 使用函数实现(require
  • ✅ 仅 Node 环境支持
  • ✅ 动态依赖,同步执行

2. CommonJS 为什么叫“动态依赖”?

来看一段最典型的代码:

const moduleName = './a.js';
const a = require(moduleName);

这里的依赖路径,是不是运行时才能确定?这就是动态依赖;

CommonJS 的依赖关系,必须等代码执行时才能知道


3. require 到底做了什么?(核心原理)

你在 Node 中写的:

const a = require('./a.js');

但如果我追问一句:require 加载的模块代码,是“直接执行”的吗? 模块里的 this、exports、module.exports 到底从哪来的?

答案其实藏在 Node.js 对模块的一层“函数包装”里:

function require(path) {
   const cache = {}
  // 1. 如果模块已经加载过,直接返回缓存
  if (cache[path]) {
    return cache[path].exports;
  }

  // 2. 创建模块对象
  const module = {
    id:path
    exports: {}
  };

  // 3. 执行模块代码(用函数包一层)
  function _run(exports, require, module, __filename, __dirname) {
    // 模块源码在这里执行
  }

  _run.call(
    module.exports,
    module.exports,
    require,
    module,
    __filename,
    __dirname
  );

  // 4. 缓存并返回结果
  cache[modulePath] = module;
  return module.exports;
}

假设你有一个文件 a.js,那么文件中的内容会放到上面的_run函数中执行

我们拆开来看:

名称 实际指向
this module.exports
exports module.exports
module.exports module.exports

在模块初始化阶段,这三个引用的是同一个对象。

所以以下判断永远成立:

console.log(arguments); // [exports, require ,module, __filename, __dirname]
console.log(this); // {}
console.log(this === exports); // true
console.log(exports === module.exports); // true

重点来了

  • require 是一个普通函数
  • module.exports 是一个普通对象
  • 模块执行是同步的
  • 导出的值是一次性的值拷贝

二、ES Module:语言层面的模块系统

如果说 CommonJS 是“工具方案”,那么 ES Module(ESM)就是 JavaScript 官方给出的答案

ES Module 的核心关键

  • ✅ 官方标准(ECMAScript)
  • ✅ 使用新语法(import / export
  • 所有环境支持(浏览器 / Node / Deno)
  • ✅ 同时支持静态依赖 & 动态依赖
  • 符号绑定

1. 什么是「静态依赖」?

import { a } from './a.js';

这行代码有两个关键点:

  1. import只能写在顶层
  2. 依赖路径在代码运行前就确定

👉 这意味着什么?

  • 构建工具在编译阶段就能分析依赖
  • 支持Tree Shaking
  • 可以做代码分割、预加载

这也是为什么 ESM 更适合前端工程化


2. ESM 也支持动态依赖,但它是异步的

import('./a.js').then(module => {
  console.log(module.a);
});

和 CommonJS 最大的不同点:

模块系统 动态依赖
CommonJS 同步
ES Module 异步

3. 符号绑定:ESM 最容易被忽略

这是 ESM 和 CommonJS 的本质区别

看一段代码

// a.js
export var a = 1;
export function changeA() {
  a = 2;
}
// index.js
import { a, changeA } from './a.js';
console.log(a); // 1
changeA();
console.log(a); // 2

这里为什么 a 会跟着变化?

真相就是 import 不是赋值,而是“引用同一个符号”

在 ESM 中: 导入的不是值,而是对导出符号的实时绑定

可以理解为:

  • a 在模块内部是一个变量
  • 所有 import 的地方,都指向同一个 a
  • 修改它,所有地方同步变化

这就是「符号绑定(Live Binding)」。


对比 CommonJS(非常关键)

// a.js
var n = 1;
function changeN() {
  n = 2;
}
module.exports = {
  n,
  changeN
}

// b.js
const { n, changeN } = require('./a.js');
console.log(n); // 1
changeN();
console.log(n); // 1

这里的 n

  • 是一次值拷贝
  • 后续模块内部怎么改,外面都不会同步

4. 再看下 下面几个问题

(1) export 和 export default 的区别

  • export:具名导出,可多个
  • export default:默认导出,只能一个
  • 默认导出本质是 { default: xxx }

(2) 下面代码导出了什么?

exports.a = 'a';
module.exports.b = 'b';
this.c = 'c';
module.exports = {
  d: 'd'
};

结果:

{ d: 'd' }

(3)下面代码导出了什么?

exports.a = 1;  
exports = { b: 2 };

结果:

{ a: 1 }

原因是:

  • exports 只是 module.exports 的一个引用
  • 当你重新给exports赋值时,只是断开了引用关系module.exports 并没有变
  • 等价于 let exports = module.exports; exports = {}; 只是改了局部变量

图形编辑器移动操作设计模式实践 —— 不止命令模式

作者 简离
2026年2月26日 08:06

在Web图形编辑器开发中,“移动图形”是最基础且高频的交互操作,不仅需要保证用户拖拽时的流畅体验(图形实时跟随鼠标),还需支持撤销/重做、状态管理、灵活扩展等核心需求。很多开发者第一时间会想到命令模式,但实际上,结合场景需求,还有多种设计模式可实现移动操作,甚至能通过模式组合达到更优的代码可维护性和扩展性。

本文基于实际开发对话场景,梳理图形编辑器移动操作的核心需求,详解命令模式及其他可替代/补充的设计模式,所有示例均使用TypeScript实现,方便直接应用到项目中。

一、核心需求拆解

在动手设计前,先明确图形编辑器移动操作的核心诉求,避免设计偏离实际场景:

  • 流畅交互:拖拽时图形实时跟随鼠标指针,无明显延迟;
  • 状态可追溯:支持撤销/重做,仅记录完整的移动操作(而非拖拽过程中的每一步微操作);
  • 可扩展性:支持多种移动规则(如自由移动、网格对齐、吸附),且能灵活新增;
  • 解耦性:操作逻辑与UI渲染、状态管理分离,便于后续维护和迭代。

二、核心模式:命令模式(最常用,适配撤销/重做)

命令模式是图形编辑器移动操作的首选,核心是将“移动操作”封装为独立命令对象,解耦操作的发起者(UI交互)与执行者(图形对象),同时支持操作记录和撤销/重做。

结合拖拽场景的关键优化:拖拽过程中实时更新图形位置(保证体验),拖拽结束后生成单条命令(避免冗余记录),完全遵循“命令控制图形变化”的核心原则。

2.1 TypeScript实现

// 1. 图形基础类(接收者:真正执行移动操作的对象)
class Graphic {
  constructor(
    public id: string,
    public x: number,
    public y: number,
    public width: number,
    public height: number
  ) {}

  // 核心移动方法:修改图形位置
  move(dx: number, dy: number): void {
    this.x += dx;
    this.y += dy;
  }

  // 辅助方法:判断鼠标是否点击在图形上(用于拖拽触发)
  isPointInside(mouseX: number, mouseY: number): boolean {
    return mouseX >= this.x && mouseX <= this.x + this.width &&
           mouseY >= this.y && mouseY <= this.y + this.height;
  }
}

// 2. 命令接口(规范所有命令的统一方法)
interface Command {
  execute(): void;
  undo(): void;
}

// 3. 移动命令(具体命令:封装移动操作)
class MoveGraphicCommand implements Command {
  private initialX: number; // 移动前的初始X坐标(用于撤销)
  private initialY: number; // 移动前的初始Y坐标(用于撤销)

  constructor(
    private graphic: Graphic,
    private totalDx: number, // 总位移X(拖拽结束后计算)
    private totalDy: number  // 总位移Y(拖拽结束后计算)
  ) {
    // 记录初始状态(仅初始化时记录一次)
    this.initialX = graphic.x;
    this.initialY = graphic.y;
  }

  // 执行命令:移动图形
  execute(): void {
    this.graphic.move(this.totalDx, this.totalDy);
  }

  // 撤销命令:恢复到移动前的状态
  undo(): void {
    this.graphic.x = this.initialX;
    this.graphic.y = this.initialY;
  }
}

// 4. 命令管理器(调用者:管理命令队列,实现撤销/重做)
class CommandManager {
  private history: Command[] = []; // 命令历史队列
  private currentIndex: number = -1; // 当前命令索引

  // 执行命令(清空已撤销的命令,添加新命令)
  executeCommand(command: Command): void {
    if (this.currentIndex < this.history.length - 1) {
      this.history = this.history.slice(0, this.currentIndex + 1);
    }
    command.execute();
    this.history.push(command);
    this.currentIndex++;
  }

  // 撤销操作
  undo(): void {
    if (this.currentIndex >= 0) {
      const command = this.history[this.currentIndex];
      command.undo();
      this.currentIndex--;
    }
  }

  // 重做操作
  redo(): void {
    if (this.currentIndex < this.history.length - 1) {
      this.currentIndex++;
      const command = this.history[this.currentIndex];
      command.execute();
    }
  }
}

// 5. 拖拽控制器(衔接UI交互与命令,处理实时拖拽)
class DragController {
  private draggingGraphic: Graphic | null = null;
  private startMouseX: number = 0;
  private startMouseY: number = 0;
  private startGraphicX: number = 0;
  private startGraphicY: number = 0;

  constructor(private commandManager: CommandManager) {}

  // 开始拖拽(鼠标按下)
  startDrag(graphic: Graphic, mouseX: number, mouseY: number): void {
    this.draggingGraphic = graphic;
    this.startMouseX = mouseX;
    this.startMouseY = mouseY;
    this.startGraphicX = graphic.x;
    this.startGraphicY = graphic.y;
  }

  // 拖拽中(鼠标移动,实时更新图形位置)
  drag(mouseX: number, mouseY: number): void {
    if (!this.draggingGraphic) return;

    // 计算实时位移
    const dx = mouseX - this.startMouseX;
    const dy = mouseY - this.startMouseY;

    // 实时更新图形位置(仅视觉反馈,不记录命令)
    this.draggingGraphic.x = this.startGraphicX + dx;
    this.draggingGraphic.y = this.startGraphicY + dy;
  }

  // 结束拖拽(鼠标释放,生成并执行命令)
  endDrag(): void {
    if (!this.draggingGraphic) return;

    // 计算总位移(拖拽全程的总偏移量)
    const totalDx = this.draggingGraphic.x - this.startGraphicX;
    const totalDy = this.draggingGraphic.y - this.startGraphicY;

    // 只有位移不为0时,才生成命令(避免无效操作)
    if (totalDx !== 0 || totalDy !== 0) {
      const command = new MoveGraphicCommand(
        this.draggingGraphic,
        totalDx,
        totalDy
      );
      this.commandManager.executeCommand(command);
    }

    // 重置拖拽状态
    this.draggingGraphic = null;
  }
}

// 6. 编辑器入口(整合所有模块,模拟UI交互)
class GraphicEditor {
  private graphics: Graphic[] = [];
  private commandManager = new CommandManager();
  private dragController = new DragController(this.commandManager);

  // 添加图形
  addGraphic(graphic: Graphic): void {
    this.graphics.push(graphic);
  }

  // 模拟鼠标按下事件(触发拖拽开始)
  onMouseDown(mouseX: number, mouseY: number): void {
    // 查找被点击的图形(从后往前,优先选中上层图形)
    const targetGraphic = this.graphics.slice().reverse().find(graphic => 
      graphic.isPointInside(mouseX, mouseY)
    );
    if (targetGraphic) {
      this.dragController.startDrag(targetGraphic, mouseX, mouseY);
    }
  }

  // 模拟鼠标移动事件(触发拖拽中)
  onMouseMove(mouseX: number, mouseY: number): void {
    this.dragController.drag(mouseX, mouseY);
    this.refreshCanvas(); // 刷新画布,渲染最新位置
  }

  // 模拟鼠标释放事件(触发拖拽结束)
  onMouseUp(): void {
    this.dragController.endDrag();
    this.refreshCanvas();
  }

  // 模拟画布刷新(实际项目中替换为DOM/Canvas渲染逻辑)
  private refreshCanvas(): void {
    console.log("画布刷新,当前图形状态:", this.graphics);
  }
}

// 测试示例
const editor = new GraphicEditor();
// 添加一个矩形图形
const rect = new Graphic("rect1", 100, 100, 200, 100);
editor.addGraphic(rect);

// 模拟拖拽流程
editor.onMouseDown(150, 150); // 点击图形中心,开始拖拽
editor.onMouseMove(250, 200); // 拖拽到新位置
editor.onMouseUp(); // 释放鼠标,生成移动命令

console.log("拖拽结束后图形位置:", rect.x, rect.y); // 200, 150
editor.commandManager.undo(); // 撤销移动
console.log("撤销后图形位置:", rect.x, rect.y); // 100, 100
editor.commandManager.redo(); // 重做移动
console.log("重做后图形位置:", rect.x, rect.y); // 200, 150

2.2 关键说明

  • 分离“视觉反馈”与“命令执行”:拖拽过程中直接修改图形位置(保证流畅),拖拽结束后才生成单条命令(避免冗余);
  • 命令封装完整状态:移动命令记录图形初始位置,确保撤销时能精准恢复;
  • 命令管理器统一管理:负责命令的执行、撤销、重做,解耦UI交互与命令逻辑。

三、其他可用设计模式(结合场景补充)

命令模式虽常用,但在特定场景下,其他设计模式可更好地解决问题(如状态管理、移动规则扩展、多图形联动、状态备份等)。以下结合图形编辑器移动操作,介绍6种实用设计模式(含对话中提及的备忘录模式),均提供TS示例,覆盖所有相关场景,且各模式间形成互补,方便开发者根据需求灵活选用。

3.1 策略模式:适配多种移动规则

核心思想:定义多种移动算法(如自由移动、网格对齐、水平/垂直锁定),封装为独立策略,可动态切换,无需修改图形或命令代码。

适用场景:需要支持多种移动规则,且规则可灵活扩展(如新增“吸附到参考线”功能)。

// 1. 移动策略接口(规范所有移动算法)
interface MoveStrategy {
  calculateNewPosition(
    currentX: number,
    currentY: number,
    dx: number,
    dy: number
  ): { x: number; y: number };
}

// 2. 具体策略1:自由移动(默认)
class FreeMoveStrategy implements MoveStrategy {
  calculateNewPosition(currentX: number, currentY: number, dx: number, dy: number): { x: number; y: number } {
    return { x: currentX + dx, y: currentY + dy };
  }
}

// 3. 具体策略2:网格对齐(按指定网格大小移动)
class GridMoveStrategy implements MoveStrategy {
  constructor(private gridSize: number = 20) {}

  calculateNewPosition(currentX: number, currentY: number, dx: number, dy: number): { x: number; y: number } {
    // 计算对齐网格后的位置
    const newX = Math.round((currentX + dx) / this.gridSize) * this.gridSize;
    const newY = Math.round((currentY + dy) / this.gridSize) * this.gridSize;
    return { x: newX, y: newY };
  }
}

// 4. 改造图形类,支持设置移动策略
class GraphicWithStrategy {
  public moveStrategy: MoveStrategy = new FreeMoveStrategy(); // 默认自由移动

  constructor(
    public id: string,
    public x: number,
    public y: number,
    public width: number,
    public height: number
  ) {}

  // 结合策略移动图形
  move(dx: number, dy: number): void {
    const { x, y } = this.moveStrategy.calculateNewPosition(this.x, this.y, dx, dy);
    this.x = x;
    this.y = y;
  }
}

// 测试示例
const rect = new GraphicWithStrategy("rect1", 100, 100, 200, 100);
// 切换为网格对齐策略(网格大小20)
rect.moveStrategy = new GridMoveStrategy(20);
rect.move(35, 45); // 原本移动35,45,对齐后为40,60(20的倍数)
console.log(rect.x, rect.y); // 140, 160

3.2 状态模式:管理编辑器交互状态

核心思想:将编辑器的不同交互状态(选择模式、移动模式、缩放模式)封装为独立状态类,状态切换时自动改变行为,避免大量if-else判断。

适用场景:编辑器有多种交互模式,移动操作仅在“移动模式”下生效。

// 1. 状态接口(规范所有状态的行为)
interface EditorState {
  handleMouseDown(editor: StatefulEditor, mouseX: number, mouseY: number): void;
  handleMouseMove(editor: StatefulEditor, mouseX: number, mouseY: number): void;
  handleMouseUp(editor: StatefulEditor): void;
}

// 2. 选择状态(默认状态:点击选中图形,不移动)
class SelectState implements EditorState {
  handleMouseDown(editor: StatefulEditor, mouseX: number, mouseY: number): void {
    const targetGraphic = editor.graphics.find(g => g.isPointInside(mouseX, mouseY));
    if (targetGraphic) {
      editor.selectedGraphic = targetGraphic;
      // 切换到移动状态(点击选中后,拖拽即移动)
      editor.setState(new MoveState());
    }
  }

  handleMouseMove(editor: StatefulEditor, mouseX: number, mouseY: number): void {
    // 选择状态下,鼠标移动不做任何操作
  }

  handleMouseUp(editor: StatefulEditor): void {
    // 选择状态下,鼠标释放不做任何操作
  }
}

// 3. 移动状态(拖拽移动选中的图形)
class MoveState implements EditorState {
  private startMouseX: number = 0;
  private startMouseY: number = 0;
  private startGraphicX: number = 0;
  private startGraphicY: number = 0;

  handleMouseDown(editor: StatefulEditor, mouseX: number, mouseY: number): void {
    if (editor.selectedGraphic) {
      this.startMouseX = mouseX;
      this.startMouseY = mouseY;
      this.startGraphicX = editor.selectedGraphic.x;
      this.startGraphicY = editor.selectedGraphic.y;
    }
  }

  handleMouseMove(editor: StatefulEditor, mouseX: number, mouseY: number): void {
    if (!editor.selectedGraphic) return;

    const dx = mouseX - this.startMouseX;
    const dy = mouseY - this.startMouseY;
    editor.selectedGraphic.x = this.startGraphicX + dx;
    editor.selectedGraphic.y = this.startGraphicY + dy;
    editor.refreshCanvas();
  }

  handleMouseUp(editor: StatefulEditor): void {
    // 移动结束,切换回选择状态
    editor.setState(new SelectState());
    // 生成移动命令(结合命令模式,支持撤销)
    if (editor.selectedGraphic) {
      const totalDx = editor.selectedGraphic.x - this.startGraphicX;
      const totalDy = editor.selectedGraphic.y - this.startGraphicY;
      if (totalDx !== 0 || totalDy !== 0) {
        const command = new MoveGraphicCommand(
          editor.selectedGraphic,
          totalDx,
          totalDy
        );
        editor.commandManager.executeCommand(command);
      }
    }
  }
}

// 4. 带状态的编辑器
class StatefulEditor {
  public graphics: Graphic[] = [];
  public selectedGraphic: Graphic | null = null;
  public state: EditorState = new SelectState(); // 默认选择状态
  public commandManager = new CommandManager();

  // 设置编辑器状态
  setState(state: EditorState): void {
    this.state = state;
  }

  // 转发鼠标事件到当前状态
  onMouseDown(mouseX: number, mouseY: number): void {
    this.state.handleMouseDown(this, mouseX, mouseY);
  }

  onMouseMove(mouseX: number, mouseY: number): void {
    this.state.handleMouseMove(this, mouseX, mouseY);
  }

  onMouseUp(): void {
    this.state.handleMouseUp(this);
  }

  refreshCanvas(): void {
    console.log("画布刷新,当前图形状态:", this.graphics);
  }
}

3.3 组合模式:支持多图形群组移动

核心思想:将单个图形(叶子节点)和多个图形的组合(组合节点)统一视为“图形组件”,使客户端对单个图形和组合图形的移动操作具有一致性。

适用场景:需要支持“选中多个图形,批量移动”功能。

// 1. 图形组件接口(统一叶子和组合节点的行为)
interface GraphicComponent {
  id: string;
  move(dx: number, dy: number): void;
  isPointInside(mouseX: number, mouseY: number): boolean;
}

// 2. 叶子节点:单个图形
class LeafGraphic implements GraphicComponent {
  constructor(
    public id: string,
    public x: number,
    public y: number,
    public width: number,
    public height: number
  ) {}

  move(dx: number, dy: number): void {
    this.x += dx;
    this.y += dy;
  }

  isPointInside(mouseX: number, mouseY: number): boolean {
    return mouseX >= this.x && mouseX <= this.x + this.width &&
           mouseY >= this.y && mouseY <= this.y + this.height;
  }
}

// 3. 组合节点:多个图形的群组
class CompositeGraphic implements GraphicComponent {
  public children: GraphicComponent[] = [];

  constructor(public id: string) {}

  // 添加图形到群组
  add(component: GraphicComponent): void {
    this.children.push(component);
  }

  // 从群组移除图形
  remove(component: GraphicComponent): void {
    this.children = this.children.filter(c => c.id !== component.id);
  }

  // 群组移动:所有子图形同步移动
  move(dx: number, dy: number): void {
    this.children.forEach(child => child.move(dx, dy));
  }

  // 判断鼠标是否点击在群组内(任意子图形被点击即视为选中群组)
  isPointInside(mouseX: number, mouseY: number): boolean {
    return this.children.some(child => child.isPointInside(mouseX, mouseY));
  }
}

// 测试示例
// 创建两个单个图形
const rect1 = new LeafGraphic("rect1", 100, 100, 100, 50);
const rect2 = new LeafGraphic("rect2", 200, 200, 100, 50);

// 创建群组,添加两个图形
const group = new CompositeGraphic("group1");
group.add(rect1);
group.add(rect2);

// 移动群组(两个图形同步移动)
group.move(50, 50);
console.log(rect1.x, rect1.y); // 150, 150
console.log(rect2.x, rect2.y); // 250, 250

3.4 观察者模式:实现状态联动更新

核心思想:定义对象间的一对多依赖,当图形位置(被观察者)变化时,所有依赖它的组件(观察者,如画布、属性面板)自动收到通知并更新。

适用场景:图形移动后,需要同步更新画布渲染、属性面板的坐标显示等。

// 1. 观察者接口
interface Observer {
  update(subject: Subject): void;
}

// 2. 被观察者基类(图形继承此类)
class Subject {
  private observers: Observer[] = [];

  // 注册观察者
  attach(observer: Observer): void {
    this.observers.push(observer);
  }

  // 移除观察者
  detach(observer: Observer): void {
    this.observers = this.observers.filter(o => o !== observer);
  }

  // 通知所有观察者
  protected notify(): void {
    this.observers.forEach(observer => observer.update(this));
  }
}

// 3. 可观察的图形类(被观察者)
class ObservableGraphic extends Subject {
  constructor(
    public id: string,
    private _x: number,
    private _y: number,
    public width: number,
    public height: number
  ) {
    super();
  }

  // 访问器:修改x/y时通知观察者
  get x(): number {
    return this._x;
  }

  set x(value: number) {
    this._x = value;
    this.notify(); // 位置变化,通知观察者
  }

  get y(): number {
    return this._y;
  }

  set y(value: number) {
    this._y = value;
    this.notify(); // 位置变化,通知观察者
  }

  // 移动方法
  move(dx: number, dy: number): void {
    this.x += dx;
    this.y += dy;
  }
}

// 4. 观察者1:画布(更新渲染)
class CanvasObserver implements Observer {
  update(subject: Subject): void {
    if (subject instanceof ObservableGraphic) {
      console.log(`画布更新:图形${subject.id}移动到(${subject.x}, ${subject.y})`);
    }
  }
}

// 5. 观察者2:属性面板(更新坐标显示)
class PropertyPanelObserver implements Observer {
  update(subject: Subject): void {
    if (subject instanceof ObservableGraphic) {
      console.log(`属性面板更新:图形${subject.id}坐标 - X: ${subject.x}, Y: ${subject.y}`);
    }
  }
}

// 测试示例
const graphic = new ObservableGraphic("rect1", 100, 100, 200, 100);
const canvas = new CanvasObserver();
const propertyPanel = new PropertyPanelObserver();

// 注册观察者
graphic.attach(canvas);
graphic.attach(propertyPanel);

// 移动图形,触发观察者更新
graphic.move(50, 50);
// 输出:
// 画布更新:图形rect1移动到(150, 150)
// 属性面板更新:图形rect1坐标 - X: 150, Y: 150

3.5 原型模式:拖拽预览与状态备份

核心思想:通过复制现有图形(原型)创建新对象,无需重新初始化,高效实现拖拽预览、撤销时的状态备份。

适用场景:拖拽时需要显示“预览图形”(不影响原图形),或撤销时需要快速恢复图形状态(适合简单图形,无需额外筛选核心状态),与备忘录模式形成互补。

// 1. 原型接口(定义克隆方法)
interface Prototype {
  clone(): Prototype;
}

// 2. 可克隆的图形类
class CloneableGraphic implements Prototype {
  constructor(
    public id: string,
    public x: number,
    public y: number,
    public width: number,
    public height: number,
    public color: string = "#000000"
  ) {}

  // 克隆方法:创建当前图形的副本
  clone(): CloneableGraphic {
    return new CloneableGraphic(
      `${this.id}_clone`, // 克隆体ID区分原图形
      this.x,
      this.y,
      this.width,
      this.height,
      this.color
    );
  }

  move(dx: number, dy: number): void {
    this.x += dx;
    this.y += dy;
  }
}

// 测试示例(拖拽预览)
const originalGraphic = new CloneableGraphic("rect1", 100, 100, 200, 100, "#ff0000");
// 克隆图形作为预览(拖拽时移动预览,不影响原图形)
const previewGraphic = originalGraphic.clone();

// 拖拽预览图形
previewGraphic.move(50, 50);
console.log("原图形位置:", originalGraphic.x, originalGraphic.y); // 100, 100
console.log("预览图形位置:", previewGraphic.x, previewGraphic.y); // 150, 150

// 拖拽结束,将原图形移动到预览位置
originalGraphic.move(50, 50);
console.log("拖拽结束后原图形位置:", originalGraphic.x, originalGraphic.y); // 150, 150

3.6 备忘录模式:图形状态备份与恢复

核心思想:在不破坏对象封装性的前提下,捕获对象的内部状态并保存,以便后续需要时恢复到该状态。与原型模式的“复制对象”不同,备忘录模式仅保存对象的关键状态,更轻量、更聚焦“状态回溯”。

适用场景:图形移动、修改属性等操作后,需要精准恢复到操作前的状态(如撤销移动时,无需复制整个图形,仅恢复位置状态),常与命令模式协作实现完整的撤销/重做功能,尤其适合复杂图形(属性较多)的场景,可弥补原型模式“复制完整对象”的性能损耗。

// 1. 备忘录类(存储图形的关键状态,不可直接修改)
class GraphicMemento {
  // 仅存储移动相关的关键状态(x、y坐标),按需扩展
  constructor(public readonly x: number, public readonly y: number) {}
}

// 2. 原发器(图形类):创建和恢复备忘录
class MementoGraphic {
  constructor(
    public id: string,
    public x: number,
    public y: number,
    public width: number,
    public height: number
  ) {}

  // 移动图形
  move(dx: number, dy: number): void {
    this.x += dx;
    this.y += dy;
  }

  // 创建备忘录:保存当前状态
  createMemento(): GraphicMemento {
    return new GraphicMemento(this.x, this.y);
  }

  // 恢复备忘录:从备忘录中恢复状态
  restoreMemento(memento: GraphicMemento): void {
    this.x = memento.x;
    this.y = memento.y;
  }
}

// 3. 管理者(可选):负责存储备忘录,避免原发器直接操作备忘录
class MementoManager {
  private mementos: Map<string, GraphicMemento> = new Map(); // key: 图形ID,value: 备忘录

  // 保存备忘录
  saveMemento(graphicId: string, memento: GraphicMemento): void {
    this.mementos.set(graphicId, memento);
  }

  // 获取备忘录
  getMemento(graphicId: string): GraphicMemento | undefined {
    return this.mementos.get(graphicId);
  }
}

// 4. 结合命令模式使用(完善撤销逻辑)
class MementoMoveCommand implements Command {
  private memento: GraphicMemento; // 保存移动前的状态(备忘录)

  constructor(
    private graphic: MementoGraphic,
    private dx: number,
    private dy: number,
    private mementoManager: MementoManager
  ) {
    // 执行命令前,创建并保存备忘录(移动前的状态)
    this.memento = this.graphic.createMemento();
    this.mementoManager.saveMemento(this.graphic.id, this.memento);
  }

  execute(): void {
    this.graphic.move(this.dx, this.dy);
  }

  undo(): void {
    // 从备忘录恢复到移动前的状态
    const memento = this.mementoManager.getMemento(this.graphic.id);
    if (memento) {
      this.graphic.restoreMemento(memento);
    }
  }
}

// 测试示例
const mementoManager = new MementoManager();
const graphic = new MementoGraphic("rect1", 100, 100, 200, 100);

// 创建移动命令,自动保存备忘录
const moveCommand = new MementoMoveCommand(graphic, 50, 50, mementoManager);
moveCommand.execute();
console.log("移动后图形位置:", graphic.x, graphic.y); // 150, 150

// 撤销移动,从备忘录恢复状态
moveCommand.undo();
console.log("撤销后图形位置:", graphic.x, graphic.y); // 100, 100

关键说明:备忘录模式专注于“状态备份与恢复”,与命令模式协作时,命令负责执行操作,备忘录负责保存操作前后的关键状态,让撤销逻辑更简洁、更精准。尤其适合复杂图形(属性较多)的状态回溯,相比原型模式的“复制整个对象”,备忘录仅保存核心状态,更轻量、更节省内存——这也是它与原型模式在状态备份场景中的核心区别,二者相辅相成,可根据图形复杂度灵活选择,与前文原型模式的适用场景形成精准呼应。

四、模式选择与组合建议

单一设计模式难以满足图形编辑器的复杂需求,实际开发中建议根据场景组合使用,以下是高频组合方案:

4.1 常用组合方案

  • 命令模式 + 策略模式:用命令模式管理移动操作(支持撤销),用策略模式切换移动规则(自由/网格/吸附);
  • 命令模式 + 组合模式:用组合模式管理群组图形,用命令模式实现群组移动的撤销/重做;
  • 状态模式 + 观察者模式:用状态模式管理编辑器交互状态,用观察者模式实现图形移动后的联动更新;
  • 命令模式 + 原型模式:用原型模式备份图形初始状态,用命令模式实现撤销时的状态恢复(适合简单图形、拖拽预览场景);
  • 命令模式 + 备忘录模式:用备忘录模式轻量保存图形操作前的关键状态,用命令模式管理操作执行与撤销,兼顾性能与精准性,适配复杂图形的状态回溯需求。

4.2 模式选择对照表

设计模式 核心优势 适用场景
命令模式 支持撤销/重做,解耦操作发起与执行 需要记录操作历史,支持撤销/重做
策略模式 移动规则可扩展、可切换,无需修改核心代码 支持多种移动规则(自由、网格、吸附)
状态模式 简化交互状态管理,避免大量if-else 编辑器有多种交互模式(选择、移动、缩放)
组合模式 统一单个图形与群组的操作逻辑 需要支持多图形群组移动
观察者模式 状态变化自动联动更新,解耦组件依赖 图形移动后需同步更新画布、属性面板等
原型模式 高效复制对象,用于预览、状态备份 拖拽预览、撤销时的状态恢复(适合简单图形)
备忘录模式 轻量保存对象关键状态,不破坏封装,精准恢复 复杂图形状态备份、与命令模式协作实现撤销

五、总结

图形编辑器的移动操作设计,核心是平衡“用户体验”与“代码可维护性”:命令模式是基础,解决撤销/重做和操作解耦;策略模式、状态模式、备忘录模式等用于补充扩展,分别解决移动规则、交互状态、状态备份等细分问题;模式组合则能应对更复杂的场景(如群组移动、多组件联动、复杂图形状态回溯)。

本文所有示例均基于TypeScript实现,可直接复制到项目中修改适配,重点关注“命令模式+策略模式”“命令模式+组合模式”“命令模式+备忘录模式”这三组高频组合,基本能覆盖大部分图形编辑器移动操作的需求。尤其值得注意的是,备忘录模式与原型模式虽都可用于状态备份,但场景各有侧重——备忘录模式轻量保存关键状态,适配复杂图形;原型模式复制完整对象,适配拖拽预览等场景,合理区分二者可进一步优化项目性能。

如果你的编辑器有更特殊的场景(如异步移动、复杂吸附规则),可基于上述模式进一步扩展,核心原则是:将变化的部分封装起来,降低组件间的耦合,让代码更易维护、易扩展。

Vue3 组件生命周期详解

作者 wuhen_n
2026年2月26日 07:57

在上一篇文章中,我们深入探讨了组件从VNode到DOM的渲染过程。本篇文章将聚焦于组件的生命周期——这个贯穿组件从创建到销毁整个过程的时间轴。理解生命周期,不仅能帮助我们写出更可靠的代码,还能在合适的时机做合适的事情。

前言:为什么需要生命周期?

想象一下,我们正在搭建一座房子,一般需要经过以下几个阶段:

  1. 创建阶段:设计图纸、准备材料
  2. 挂载阶段:打地基、砌墙、安装门窗
  3. 更新阶段:翻新墙面、更换家具
  4. 卸载阶段:拆除房屋、清理场地

组件也是如此。在它的整个生命周期中,我们需要在不同的时间点执行不同的操作:

const Component = {
  // 创建时:初始化数据
  created() {
    this.fetchData();
  },
  
  // 挂载后:操作DOM
  mounted() {
    this.$el.focus();
  },

  // 更新时:页面刷新
  updated() {
    this.$el.scrollTop = this.$el.scrollHeight; 
  }
  
  // 卸载前:清理资源
  beforeUnmount() {
    clearInterval(this.timer);
  }
};

生命周期的完整图谱

生命周期全景图

下面这张图展示了 Vue3 组件的完整生命周期流程: 组件生命周期

各个阶段的核心任务

阶段 钩子 核心任务 注意事项
创建阶段 setup / beforeCreate / created 初始化数据、设置响应式 无法访问DOM
挂载阶段 beforeMount / mounted 渲染DOM、操作DOM 可以访问DOM
更新阶段 beforeUpdate / updated 响应数据变化 避免在更新钩子中修改数据
卸载阶段 beforeUnmount / unmounted 清理资源 清除定时器、取消订阅
缓存阶段 activated / deactivated 配合keep-alive 缓存组件的激活/失活

创建阶段:组件诞生

创建阶段的三个钩子

在 Vue3 中,创建阶段实际上由三个关键步骤组成:

  1. 最先执行:setup
  2. 然后执行:beforeCreate
  3. 最后执行:created
export default {
  // 1. 最先执行:setup
  setup() {
    console.log('1. setup 执行');
    
    const count = ref(0);
    
    // setup 中不能使用 this
    // console.log(this); // undefined
    
    return { count };
  },
  
  // 2. 然后执行:beforeCreate
  beforeCreate() {
    console.log('2. beforeCreate 执行');
    console.log('数据尚未初始化:', this.count); // undefined
    console.log('DOM 尚未创建:', this.$el);     // undefined
  },
  
  // 3. 最后执行:created
  created() {
    console.log('3. created 执行');
    console.log('数据已初始化:', this.count);    // 0
    console.log('DOM 尚未创建:', this.$el);      // undefined
  }
};

各钩子的数据访问能力

为了更好地理解每个阶段能做什么,我们用一个表格来展示:

钩子 访问 data 访问 props 访问 computed 访问 methods 访问 DOM 访问 $el
setup ❌ (尚未创建) ❌ (尚未创建)
beforeCreate
created

为什么需要三个创建钩子?

你可能有这样的疑问:为什么有了 setup 还要保留 beforeCreatecreated

这其实是为了兼容性和渐进迁移。在 Vue3 中,setup 实际上是 beforeCreatecreated 的替代品;但是 Vue3 为了向下兼容 Vue2 ,仍然保留了 beforeCreatecreated

Vue2 风格的创建钩子:

export default {
  beforeCreate() {
    // 初始化非响应式数据
    this.nonReactive = {};
  },
  created() {
    // 发起API请求
    this.fetchData();
  }
};

Vue3 组合式风格:

export default {
  setup() {
    // 初始化非响应式数据
    const nonReactive = {};
    
    // 发起API请求
    fetchData();
    
    return { nonReactive };
  }
};

挂载阶段:组件展现

挂载过程的内部机制

挂载阶段是组件第一次将虚拟 DOM 渲染为真实 DOM 的过程:

export default {
  beforeMount() {
    console.log('1. beforeMount 执行');
    console.log('此时已有编译好的模板,但尚未挂载到DOM');
    console.log('DOM 尚不存在:', this.$el);  // undefined
  },
  
  // render 函数在 beforeMount 之后、mounted 之前执行
  render() {
    console.log('2. render 执行');
    return h('div', 'Hello World');
  },
  
  mounted() {
    console.log('3. mounted 执行');
    console.log('DOM 已创建并挂载:', this.$el);     // <div>Hello World</div>
    console.log('可以安全地操作DOM了');
    
    // 可以访问DOM元素
    this.$el.querySelector('input')?.focus();
    
    // 可以集成第三方库
    new Chart(this.$el.querySelector('#chart'), {...});
  }
};

挂载阶段的时序图

挂载阶段的执行时序图

挂载阶段的典型应用场景

1. 操作DOM

this.$el.scrollTop = 100;

2. 获取元素尺寸

const width = this.$refs.box.offsetWidth;

3. 集成第三方库(需要DOM存在)

this.chart = new Chart(this.$refs.canvas, {
  type: 'line',
  data: this.chartData
});

4. 添加全局事件监听

window.addEventListener('resize', this.handleResize);

5. 启动定时器

this.timer = setInterval(this.refreshData, 5000);

6. 卸载前清理操作

window.removeEventListener('resize', this.handleResize);
clearInterval(this.timer);
this.chart?.destroy();

更新阶段:组件响应

更新阶段的时序图

更新阶段的时序图

更新阶段的注意事项

export default {
  beforeUpdate() {
    // ✅ 可以在DOM更新前访问旧状态
    const oldHeight = this.$refs.box.offsetHeight;
    console.log('旧高度:', oldHeight);
    
    // ❌ 不要在更新钩子中修改数据(可能造成死循环)
    // this.count++; // 会触发无限循环
    
    // ✅ 可以在这里手动操作DOM(不推荐)
    // 但要注意这些操作可能会被后续的更新覆盖
  },
  
  updated() {
    // ✅ 可以获取更新后的DOM信息
    const newHeight = this.$refs.box.offsetHeight;
    console.log('新高度:', newHeight);
    
    // ✅ 可以根据新状态调整其他非响应式内容
    if (newHeight > 500) {
      this.$refs.box.classList.add('overflow');
    }
    
    // ❌ 同样避免在这里修改数据
    // ❌ 避免直接操作DOM来"修复"样式,应该通过数据驱动
  }
};

卸载阶段:组件消亡

卸载的完整过程

export default {
  beforeUnmount() {
    console.log('1. beforeUnmount 执行');
    console.log('组件即将被卸载,但依然可以访问');
    console.log('DOM 仍然存在:', this.$el);
    
    // 清理工作
    this.cleanup();
  },
  
  unmounted() {
    console.log('2. unmounted 执行');
    console.log('组件已被卸载');
    console.log('DOM 已移除:', this.$el); // 被移除或置空
    
    // 最终清理
    this.finalCleanup();
  }
};

需要清理的典型资源

  1. 定时器:clearInterval(timer);
  2. 事件监听:window.removeEventListener('resize', handleResize);
  3. 观察者:observer.disconnect();
  4. 网络请求:controller.abort();
  5. 第三方库实例:chart.destroy();
  6. 手动订阅:subscription.unsubscribe();

缓存阶段:KeepAlive 的特殊生命周期

为什么需要缓存阶段?

当组件被 <KeepAlive> 包裹时,它的生命周期会发生变化: KeepAlive生命周期.png

activated 和 deactivated 的使用

const CacheComponent = {
  setup() {
    console.log('setup 执行'); // 只执行一次
    
    const count = ref(0);
    
    // 这些钩子会在组件被缓存时特殊处理
    onMounted(() => {
      console.log('mounted 执行'); // 只执行一次
    });
    
    onActivated(() => {
      console.log('activated 执行'); // 每次进入视图时执行
      
      // 恢复一些状态
      startAnimation();
      startPolling();
    });
    
    onDeactivated(() => {
      console.log('deactivated 执行'); // 每次离开视图时执行
      
      // 暂停一些操作,但不销毁
      stopAnimation();
      stopPolling();
    });
    
    onUnmounted(() => {
      console.log('unmounted 执行'); // 最终销毁时执行
    });
    
    return { count };
  }
};

父子组件的生命周期顺序

挂载阶段的执行顺序

当父子组件嵌套时,生命周期的执行顺序非常关键:

const Parent = {
  setup() { console.log('Parent setup'); },
  beforeCreate() { console.log('Parent beforeCreate'); },
  created() { console.log('Parent created'); },
  beforeMount() { console.log('Parent beforeMount'); },
  mounted() { console.log('Parent mounted'); },
  
  render() {
    return h('div', [
      h(Child)
    ]);
  }
};

const Child = {
  setup() { console.log('Child setup'); },
  beforeCreate() { console.log('Child beforeCreate'); },
  created() { console.log('Child created'); },
  beforeMount() { console.log('Child beforeMount'); },
  mounted() { console.log('Child mounted'); }
};

// 渲染输出顺序:
// 1. Parent setup
// 2. Parent beforeCreate
// 3. Parent created
// 4. Parent beforeMount
// 5. Child setup
// 6. Child beforeCreate
// 7. Child created
// 8. Child beforeMount
// 9. Child mounted
// 10. Parent mounted

更新阶段的执行顺序

当 Parent 的数据变化时:

  1. Parent beforeUpdate
  2. Child beforeUpdate
  3. Child updated
  4. Parent updated

卸载阶段的执行顺序

当父组件被移除时:

  1. Parent beforeUnmount
  2. Child beforeUnmount
  3. Child unmounted
  4. Parent unmounted

执行顺序的规律总结

阶段 执行顺序 原因
创建 父 → 子 父组件先创建,才能传递props给子组件
挂载 子 → 父 父组件需要等待所有子组件挂载完成
更新 父 → 子 父组件数据变化,传递给子组件
卸载 子 → 父 先拆除内部,再拆除外部

Vue3 中两种写法的生命周期对比

Vue3 同时支持两种写法:选项式 API组合式 API

选项式 API 生命周期

  • beforeCreate / created
  • beforeMount / mounted
  • beforeUpdate / updated
  • beforeUnmount / unmounted
  • activated / deactivated
  • errorCaptured
  • renderTracked / renderTriggered:新增的调试钩子

组合式 API 生命周期

  • setup
  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated
  • onErrorCaptured
  • onRenderTracked / onRenderTriggered

两种写法的对应关系表

选项式 API 组合式 API 执行时机
beforeCreate/created 直接在 setup 中编写代码 组件初始化前/组件初始化后
beforeMount/mounted onBeforeMount/onMounted DOM 挂载前/DOM 挂载后
beforeUpdate/updated onBeforeUpdate/onUpdated 数据更新、DOM 更新前/DOM 更新后
beforeUnmount/unmounted onBeforeUnmount/onUnmounted 组件卸载前/组件卸载后
activated/deactivated onActivated/onDeactivated keep-alive 组件激活/keep-alive 组件失活
errorCaptured onErrorCaptured 捕获后代组件错误

核心差异:setup 中的生命周期

setup 函数是最早的生命周期钩子,本身执行在 beforeCreatecreated 之前,属于 beforeCreatecreated 的替代品,因此在 setup 中编写的代码相当于在这两个钩子中执行:

export default {
  setup() {
    // 这些代码相当于在 beforeCreate 和 created 中执行
    
    console.log('相当于 beforeCreate/created');
    
    const count = ref(0);
    
    // 可以在这里执行初始化操作
    fetchData();
    
    // 注册生命周期钩子
    onMounted(() => {
      console.log('mounted');
    });
    
    return { count };
  }
};

<script setup> 的特殊性

<script setup> 的本质

<script setup> 是组合式 API 的语法糖,它在编译时会被转换为普通的 setup() 函数:

<!-- 源码写法 -->
<script setup>
import { ref, onMounted } from 'vue';

const count = ref(0);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log('组件已挂载');
});
</script>
// 编译后
export default {
  setup() {
    const count = ref(0);
    
    function increment() {
      count.value++;
    }
    
    onMounted(() => {
      console.log('组件已挂载');
    });
    
    return { count, increment };
  }
};

<script setup> 中的生命周期变化

<script setup> 中,生命周期钩子的使用变得更加简洁:

  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated

<script setup> 的特殊特性

<script setup>
// 1. 自动返回顶层变量
const count = ref(0);           // 自动暴露给模板
function increment() {}          // 自动暴露给模板

// 2. 支持顶层 await
const data = await fetchData();  // 组件会等待异步操作完成

// 3. 使用 defineProps 和 defineEmits
const props = defineProps({
  title: String
});

const emit = defineEmits(['update']);

// 4. 使用 defineExpose 暴露方法
defineExpose({
  resetCount: () => count.value = 0
});

// 5. 生命周期钩子可以直接使用
onMounted(() => {
  console.log('mounted');
});
</script>

生命周期的最佳实践

各阶段适合做什么

阶段 适合的操作 不适合的操作
setup/created 初始化数据、设置响应式、发起API请求 操作DOM、访问$el
beforeMount 最后一次数据修改机会 操作DOM
mounted 操作DOM、集成第三方库、添加事件监听 同步修改数据(可能触发额外更新)
beforeUpdate 访问更新前的DOM 修改数据(可能死循环)
updated 执行依赖更新后DOM的操作 修改数据(可能死循环)
beforeUnmount 清理资源、移除事件监听 异步操作
unmounted 最终清理 访问已销毁的实例

生命周期调试技巧

使用生命周期追踪

<script setup>
import { onMounted, onUpdated, onRenderTracked, onRenderTriggered } from 'vue';

// 追踪渲染依赖
onRenderTracked((event) => {
  console.log('渲染依赖追踪:', event);
  // {
  //   key: 'count',      // 依赖的属性名
  //   target: {},        // 依赖的目标对象
  //   type: 'get',       // 操作类型
  // }
});

// 追踪渲染触发原因
onRenderTriggered((event) => {
  console.log('渲染触发原因:', event);
  // {
  //   key: 'count',
  //   target: {},
  //   type: 'set',
  //   oldValue: 0,
  //   newValue: 1
  // }
});

// 记录完整生命周期
onBeforeMount(() => console.log('🔄 beforeMount'));
onMounted(() => console.log('✅ mounted'));
onBeforeUpdate(() => console.log('🔄 beforeUpdate'));
onUpdated(() => console.log('✅ updated'));
onBeforeUnmount(() => console.log('🔄 beforeUnmount'));
onUnmounted(() => console.log('✅ unmounted'));
</script>

使用钩子组合

// 创建可复用的生命周期逻辑
function useLogger(componentName) {
  onBeforeMount(() => {
    console.log(`${componentName} 准备挂载`);
  });
  
  onMounted(() => {
    console.log(`${componentName} 已挂载`);
  });
  
  onBeforeUnmount(() => {
    console.log(`${componentName} 准备卸载`);
  });
  
  onUnmounted(() => {
    console.log(`${componentName} 已卸载`);
  });
}

// 在组件中使用
<script setup>
const props = defineProps({ name: String });
useLogger(props.name);
</script>

结语

理解生命周期,就像是掌握了组件从生到死的完整剧本。知道在每个阶段该做什么、不该做什么,才能写出既高效又可靠的Vue应用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

组件渲染:从组件到DOM

作者 wuhen_n
2026年2月26日 07:43

在前面的文章中,我们深入探讨了虚拟DOM的创建和原生元素的挂载过程。但 Vue 真正的威力在于组件系统——它让我们能够将界面拆分成独立的、可复用的模块。本文将揭示 Vue3 如何将我们编写的组件,一步步渲染成真实的 DOM 节点。

前言:组件的魔法

当我们编写这样的Vue组件时:

<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <button @click="sayHello">打招呼</button>
  </div>
</template>

<script>
export default {
  props: ['user'],
  setup(props) {
    const sayHello = () => {
      alert(`你好,我是${props.user.name}`);
    };
    
    return { sayHello };
  }
}
</script>

Vue内部经历了一系列复杂而有序的过程: 组件渲染流程 本文将带你一步步拆解这个过程,理解组件从定义到 DOM 的完整旅程。

组件的VNode结构

组件VNode的特殊性

与原生元素不同,组件的 VNode 有其独特的结构:

const componentVNode = {
  type: UserCard,                  // 对象/函数:表示组件定义
  props: { user: { name: '张三' } }, // 传递给组件的props
  children: {                       // 插槽内容
    default: () => h('span', '默认插槽'),
    header: () => h('h1', '头部')
  },
  shapeFlag: ShapeFlags.STATEFUL_COMPONENT, // 标记为组件
  
  // 组件特有属性
  key: null,
  ref: null,
  component: null,                  // 组件实例(挂载后填充)
  suspense: null,
  scopeId: null,
  slotScopeIds: null
};

组件类型的多样性

Vue3中的组件类型更加丰富:

1. 有状态组件(最常用)

const StatefulComponent = {
  data() { return { count: 0 } },
  template: `<div>{{ count }}</div>`
};

2. 函数式组件(无状态)

const FunctionalComponent = (props) => {
  return h('div', props.message);
};

3. 异步组件

const AsyncComponent = defineAsyncComponent(() => 
  import('./MyComponent.vue')
);

4. 内置组件

const KeepAliveComponent = {
  type: KeepAlive,
  props: { include: 'a,b' }
};

shapeFlag 标志

const enum ShapeFlags {
  ELEMENT = 1,
  FUNCTIONAL_COMPONENT = 1 << 1,  // 2
  STATEFUL_COMPONENT = 1 << 2,     // 4
  COMPONENT = ShapeFlags.FUNCTIONAL_COMPONENT | ShapeFlags.STATEFUL_COMPONENT // 6
}

组件VNode的创建过程

import UserCard from './UserCard.vue';

// 这行代码背后
const vnode = h(UserCard, { user: userInfo }, {
  default: () => h('span', 'children')
});

// 实际执行的是
function createComponentVNode(component, props, children) {
  // 规范化props
  props = normalizeProps(props);
  
  // 提取key和ref
  const { key, ref } = props || {};
  
  // 处理插槽
  let slots = null;
  if (children) {
    slots = normalizeSlots(children);
  }
  
  // 创建VNode
  const vnode = {
    type: component,
    props: props || {},
    children: slots,
    key,
    ref,
    shapeFlag: isFunction(component) 
      ? ShapeFlags.FUNCTIONAL_COMPONENT 
      : ShapeFlags.STATEFUL_COMPONENT,
    
    // 组件实例(稍后填充)
    component: null,
    
    // 其他内部属性
    el: null,
    anchor: null,
    appContext: null
  };
  
  return vnode;
}

组件实例的设计

为什么需要组件实例?

组件实例是组件的"活"的体现,它包含了组件的所有状态和功能: 组件状态与功能

组件实例的结构

一个完整的组件实例包含以下核心部分:

class ComponentInstance {
  // 基础标识
  uid = ++uidCounter;           // 唯一ID
  type = null;                  // 组件定义对象
  parent = null;                 // 父组件实例
  appContext = null;             // 应用上下文
  
  // 状态相关
  props = null;                  // 解析后的props
  attrs = null;                  // 非prop属性
  slots = null;                  // 插槽
  emit = null;                   // 事件发射器
  
  // 响应式系统
  setupState = null;             // setup返回的状态
  data = null;                   // data选项
  computed = null;               // 计算属性
  refs = null;                   // 模板refs
  
  // 生命周期
  isMounted = false;              // 是否已挂载
  isUnmounted = false;            // 是否已卸载
  isDeactivated = false;          // 是否被keep-alive缓存
  
  // 渲染相关
  subTree = null;                // 渲染子树
  render = null;                  // 渲染函数
  proxy = null;                   // 渲染代理
  withProxy = null;               // 带with语句的代理
  
  // 依赖收集
  effects = null;                 // 组件级effects
  provides = null;                // 依赖注入
  components = null;              // 局部注册组件
  directives = null;              // 局部注册指令
  
  constructor(public vnode, parent) {
    this.type = vnode.type;
    this.parent = parent;
    this.appContext = parent ? parent.appContext : vnode.appContext;
    
    // 初始化空容器
    this.props = {};
    this.attrs = {};
    this.slots = {};
    this.setupState = {};
    
    // 创建代理
    this.proxy = new Proxy(this, PublicInstanceProxyHandlers);
  }
}

为什么需要代理?

组件实例的代理(proxy)是为了提供一个统一的访问接口:

// 实例代理处理函数
const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { setupState, props, data } = instance;
    
    // 优先从setupState获取
    if (key in setupState) {
      return setupState[key];
    }
    
    // 然后从props获取
    else if (key in props) {
      return props[key];
    }
    
    // 然后从data获取
    else if (data && key in data) {
      return data[key];
    }
    
    // 最后是内置属性
    else if (key === '$el') {
      return instance.subTree?.el;
    }
    // ... 其他内置属性
  },
  
  set({ _: instance }, key, value) {
    const { setupState, props, data } = instance;
    
    // 按照优先级设置
    if (key in setupState) {
      setupState[key] = value;
    } else if (key in props) {
      // props 是只读的
      console.warn(`Attempting to mutate prop "${key}"`);
      return false;
    } else if (data && key in data) {
      data[key] = value;
    }
    
    return true;
  }
};

这个代理让我们可以在模板中直接使用 count,而不需要写 $data.countsetupState.count

setup 函数的执行时机

setup 的执行时机图

setup的执行时机图

setup 参数解析

setup函数接收两个参数:

setup(props, context) {
  // props: 响应式的props对象
  console.log(props.title);  // 自动解包,无需.value
  
  // context: 一个对象,包含有用的方法
  const { 
    attrs,    // 非prop属性
    slots,    // 插槽
    emit,     // 事件发射
    expose    // 暴露公共方法
  } = context;
  
  // 返回对象,暴露给模板
  return {
    count: ref(0),
    increment() {
      this.count.value++;
    }
  };
}

setup 的内部实现

function setupComponent(instance) {
  const { type, props, children } = instance.vnode;
  const { setup } = type;
  
  if (setup) {
    // 创建setup上下文
    const setupContext = createSetupContext(instance);
    
    // 设置当前实例(用于getCurrentInstance)
    setCurrentInstance(instance);
    
    try {
      // 执行setup
      const setupResult = setup(
        props,           // 只读的props
        setupContext     // 上下文
      );
      
      // 处理返回值
      handleSetupResult(instance, setupResult);
    } finally {
      // 清理
      setCurrentInstance(null);
    }
  }
  
  // 完成组件初始化
  finishComponentSetup(instance);
}

function createSetupContext(instance) {
  return {
    // 非prop属性
    get attrs() {
      return instance.attrs;
    },
    
    // 插槽
    get slots() {
      return instance.slots;
    },
    
    // 事件发射
    emit: instance.emit,
    
    // 暴露公共方法
    expose: (exposed) => {
      instance.exposed = exposed;
    }
  };
}

function handleSetupResult(instance, setupResult) {
  if (setupResult && typeof setupResult === 'object') {
    // 返回对象:作为模板上下文
    instance.setupState = proxyRefs(setupResult);
  } else if (typeof setupResult === 'function') {
    // 返回函数:作为渲染函数
    instance.render = setupResult;
  }
}

render 函数的调用

从 setup 到 render

从setup到render到挂载流程图

render 函数的创建

Vue3 中,render 函数可以通过多种方式获得:

function finishComponentSetup(instance) {
  const Component = instance.type;
  
  // 1. 优先使用setup返回的render函数
  if (!instance.render) {
    if (Component.render) {
      // 2. 使用组件选项中的render
      instance.render = Component.render;
    } else if (Component.template) {
      // 3. 编译模板为render函数
      instance.render = compile(Component.template);
    }
  }
  
  // 对函数式组件的处理
  if (!Component.render && !Component.template) {
    // 如果组件本身是函数,当作render函数
    if (typeof Component === 'function') {
      instance.render = Component;
    }
  }
}

渲染代理的工作机制

渲染代理让模板可以轻松访问各种状态:

const PublicInstanceProxyHandlers = {
  get(target, key) {
    const instance = target._;
    const { setupState, props, data } = instance;
    
    // 1. 特殊处理以$开头的内置属性
    if (key[0] === '$') {
      switch (key) {
        case '$el': return instance.subTree?.el;
        case '$props': return props;
        case '$slots': return instance.slots;
        case '$parent': return instance.parent?.proxy;
        case '$root': return instance.root?.proxy;
        case '$emit': return instance.emit;
        case '$refs': return instance.refs;
      }
    }
    
    // 2. 普通状态查找
    if (setupState && key in setupState) {
      return setupState[key];
    }
    if (props && key in props) {
      return props[key];
    }
    if (data && key in data) {
      return data[key];
    }
    
    // 3. 没找到返回undefined
    return undefined;
  }
};

手写实现:mountComponent

mountComponent的整体流程

  1. 创建组件实例:const instance = createComponentInstance(vnode);
  2. 初始化并执行组件: setupComponent(instance);
  3. 设置渲染effect:setupRenderEffect(instance, container, anchor);
  4. 返回组件实例:return instance;

创建组件实例

let uidCounter = 0;

function createComponentInstance(vnode, parent = null) {
  const instance = {
    // 基础信息
    uid: ++uidCounter,
    vnode,
    type: vnode.type,
    parent,
    
    // 状态
    props: {},
    attrs: {},
    slots: {},
    setupState: {},
    
    // 渲染相关
    render: null,
    subTree: null,
    isMounted: false,
    
    // 生命周期
    isUnmounted: false,
    
    // 代理
    proxy: null,
    
    // emit函数
    emit: () => {},
    
    // 上下文
    appContext: parent ? parent.appContext : vnode.appContext,
    provides: parent ? Object.create(parent.provides) : {}
  };
  
  // 创建代理
  instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers);
  
  // 绑定emit
  instance.emit = createEmit(instance);
  
  return instance;
}

设置渲染 effect

function setupRenderEffect(instance, container, anchor) {
  // 创建组件更新函数
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // 首次挂载
      
      // 1. 执行render函数,生成子树VNode
      const subTree = instance.render.call(
        instance.proxy,    // this指向代理
        instance.proxy     // 第一个参数
      );
      
      // 2. 保存子树
      instance.subTree = subTree;
      
      // 3. 挂载子树
      patch(null, subTree, container, anchor);
      
      // 4. 保存根元素引用
      instance.vnode.el = subTree.el;
      
      // 5. 标记已挂载
      instance.isMounted = true;
      
      // 6. 触发mounted钩子
      invokeLifecycle(instance, 'mounted');
    } else {
      // 更新阶段
      
      // 1. 获取新子树
      const nextTree = instance.render.call(
        instance.proxy,
        instance.proxy
      );
      
      // 2. 保存旧子树
      const prevTree = instance.subTree;
      instance.subTree = nextTree;
      
      // 3. 执行更新
      patch(prevTree, nextTree, container, anchor);
      
      // 4. 更新元素引用
      instance.vnode.el = nextTree.el;
      
      // 5. 触发updated钩子
      invokeLifecycle(instance, 'updated');
    }
  };
  
  // 创建ReactiveEffect
  const effect = new ReactiveEffect(
    componentUpdateFn,
    // 调度器:异步更新
    () => queueJob(instance.update)
  );
  
  // 保存更新函数
  instance.update = effect.run.bind(effect);
  
  // 立即执行首次渲染
  instance.update();
}

完整的mountComponent实现

function mountComponent(vnode, container, anchor) {
  // 1. 创建组件实例
  const instance = createComponentInstance(vnode);
  
  // 2. 初始化 props 和 slots(如果有props 和 slots)
  initProps(instance, vnode.props);
  initSlots(instance, vnode.children);
  
  // 3. 初始化并执行组件
  setupComponent(instance);
  
  // 4. 创建渲染effect
  setupRenderEffect(instance, container, anchor);
  
  // 5. 返回组件实例
  return instance;
}

// 初始化props
function initProps(instance, rawProps) {
  const props = {};
  const attrs = {};
  
  const options = instance.type.props || {};
  
  // 根据组件定义的props进行过滤
  if (rawProps) {
    for (const key in rawProps) {
      if (options[key] !== undefined) {
        // 是定义的prop
        props[key] = rawProps[key];
      } else {
        // 是普通属性
        attrs[key] = rawProps[key];
      }
    }
  }
  
  instance.props = shallowReactive(props);
  instance.attrs = shallowReactive(attrs);
}

// 初始化slots
function initSlots(instance, children) {
  if (children) {
    instance.slots = normalizeSlots(children);
  }
}

// 规范化插槽
function normalizeSlots(children) {
  if (typeof children === 'function') {
    // 单个函数:默认插槽
    return { default: children };
  } else if (Array.isArray(children)) {
    // 数组:也是默认插槽
    return { default: () => children };
  } else if (typeof children === 'object') {
    // 对象:多个插槽
    const slots = {};
    for (const key in children) {
      const slot = children[key];
      slots[key] = (props) => normalizeSlot(slot, props);
    }
    return slots;
  }
  return {};
}

组件渲染的生命周期

完整的组件生命周期流程图

完整的组件生命周期流程图

生命周期钩子的触发时机

// 生命周期钩子的内部实现
const LifecycleHooks = {
  BEFORE_CREATE: 'bc',
  CREATED: 'c',
  BEFORE_MOUNT: 'bm',
  MOUNTED: 'm',
  BEFORE_UPDATE: 'bu',
  UPDATED: 'u',
  BEFORE_UNMOUNT: 'bum',
  UNMOUNTED: 'um'
};

function invokeLifecycle(instance, hook) {
  const handlers = instance.type[hook];
  if (handlers) {
    // 设置当前实例
    setCurrentInstance(instance);
    
    // 执行钩子函数
    if (Array.isArray(handlers)) {
      handlers.forEach(handler => handler.call(instance.proxy));
    } else {
      handlers.call(instance.proxy);
    }
    
    // 清理
    setCurrentInstance(null);
  }
}

一个完整示例的渲染过程

// 示例:父子组件
const Child = {
  props: ['message'],
  setup(props) {
    console.log('Child setup');
    return {};
  },
  render() {
    console.log('Child render');
    return h('div', '子组件: ' + this.message);
  }
};

const Parent = {
  setup() {
    console.log('Parent setup');
    const msg = ref('Hello');
    
    setTimeout(() => {
      msg.value = 'World';
    }, 1000);
    
    return { msg };
  },
  render() {
    console.log('Parent render');
    return h('div', [
      h('h1', '父组件'),
      h(Child, { message: this.msg })
    ]);
  }
};

// 挂载
const vnode = h(Parent);
render(vnode, document.getElementById('app'));

// 控制台输出顺序:
// Parent setup
// Child setup
// Parent render
// Child render
// (1秒后)
// Parent render
// Child render

结语

本文深入剖析了Vue3组件渲染的完整过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

你写的 TypeScript,其实只是穿了件类型外套的 JavaScript

作者 却尘
2026年2月26日 07:32

很多人学了半年 TS,代码里清一色 any,偶尔来个 string | number,然后在简历上写"熟练使用 TypeScript"。这篇文章,就是为了让你彻底告别这种状态。

先说清楚一件事:TypeScript 的类型系统到底在解决什么问题

JavaScript 是动态类型语言。变量可以今天是字符串,明天是数字,后天变成 undefined。这在小项目里无所谓,但一旦代码规模上来——比如一个有 50 个接口、20 个开发者协作的中大型前端项目——没有类型约束,就像在没有红绿灯的十字路口开车,每一次函数调用都是一次赌博

TypeScript 的本质是在编译阶段拦截这些赌局。它不改变运行时行为,但能在你写代码的那一刻,就告诉你哪里会出问题。

理解了这一点,再来看各种类型,你就不会觉得它们是语法糖,而是协议——你和编译器之间的协议,你和团队成员之间的协议。

一、基础类型:别觉得简单,细节决定成败

string / number / boolean

这三个是 TS 里用得最多的类型,也是最容易被忽视的。

let name: string = "Andy";
let age: number = 18;
let isLogin: boolean = false;

看起来平平无奇,但"类型安全"的价值在这里:

let name: string = "andy";
name.toUpperCase(); // ✅ 合法,string 有这个方法
name.toFixed();     // ❌ 直接报错,string 没有 toFixed

换成纯 JS,这个错误只会在运行时才被发现——可能是用户触发了某个边界条件,可能是在生产环境,可能是在凌晨三点。TS 把运行时错误前移到了编写时,这是它最核心的价值。

null 和 undefined 的处理是门学问

很多项目踩过这样的坑:后端接口返回的某个字段"理论上有值",但有时候会是 null。如果你的类型定义是 string,编译器不会报错,但运行时一旦拿到 null 去调用字符串方法,直接崩。

正确姿势:

interface User {
  nickname: string | null; // 明确告诉所有人:这个字段可能为空
}

这样当你试图直接调用 user.nickname.toUpperCase() 时,编译器会强制你先处理 null 的情况。这不是麻烦,这是把锅甩给编译器而不是留给用户

bigint:什么时候才需要它

JavaScript 的 number 类型基于 IEEE 754 双精度浮点数,能精确表示的最大整数是 2^53 - 1,也就是 9007199254740991。超过这个数,精度会丢失。

let big: bigint = 9007199254740991n; // 注意末尾的 n

金融系统、密码学、需要处理超大 ID 的场景才会用到它。普通业务开发遇到的机会不多,但知道它存在,不会在某天看到 n 结尾的数字一脸懵。

二、字面量类型与联合类型:从"能用"到"好用"的关键一跳

字面量类型

type Direction = "left" | "right" | "up" | "down";

function move(dir: Direction) {
  // ...
}

如果参数类型是 string,你传 "diagonal" 进去编译器不会说话。但用字面量联合类型,"diagonal" 直接飘红。类型越窄,保护越强

这个思路很重要:在你确定某个值只会是有限几种可能的时候,不要偷懒用 string,用字面量联合类型把范围锁死。

联合类型与类型缩小(Type Narrowing)

联合类型(Union)表示"这个值可能是 A,也可能是 B":

function print(val: string | number) {
  if (typeof val === "string") {
    console.log(val.toUpperCase()); // 这里 TS 已经知道 val 是 string
  } else {
    console.log(val.toFixed(2));    // 这里 TS 已经知道 val 是 number
  }
}

这叫类型缩小(Type Narrowing)——通过条件判断,编译器会在不同分支里自动推断出更精确的类型。理解这个机制,是写出干净 TS 代码的前提。

交叉类型(Intersection)

联合是"或",交叉是"且":

type User = { name: string; email: string };
type Admin = User & { role: "admin"; permissions: string[] };

Admin 必须同时满足 User 和后面那个对象的结构。在实际项目里,这是组合模块类型的利器,比继承更灵活,比重新定义更省力。

三、any、unknown、never:三个经常被误用的类型

any:能不用就不用

let x: any;
x.foo.bar.baz(); // 不报错,但运行时爆炸

any 是类型系统的逃生舱。它告诉编译器"别管我,我自己负责"。偶尔处理真的无法预知结构的数据,或者接入没有类型声明的第三方库,可以用。但如果你的代码里 any 满天飞,TypeScript 就成了摆设——你得到了所有 TS 的编译复杂度,却没有得到任何类型安全。

unknown:any 的负责任替代品

unknown 同样表示"不知道是什么类型",但它要求你在使用前必须先做类型检查

let x: unknown;

if (typeof x === "string") {
  x.toUpperCase(); // ✅ 通过检查后才能用
}

x.toUpperCase(); // ❌ 直接报错

处理外部输入、API 响应、用户数据时,unknown 是比 any 更安全的选择。

never:表示"这里不应该被执行到"

never 有两个核心用途:

1. 表示函数不会正常返回

function throwError(msg: string): never {
  throw new Error(msg);
}

2. 穷举检查(Exhaustive Check)——这个才是精髓

type Direction = "up" | "down" | "left";

function move(dir: Direction) {
  if (dir === "up") { /* ... */ }
  else if (dir === "down") { /* ... */ }
  else if (dir === "left") { /* ... */ }
  else {
    const _check: never = dir;
    // 如果未来 Direction 加了 "right" 但这里没处理
    // 编译器会在这行报错,提醒你补全逻辑
  }
}

这是一个防御性编程技巧:让编译器帮你检查所有情况是否都被覆盖。在处理状态机、分支逻辑密集的业务代码里,能避免非常隐蔽的 bug。

四、泛型:类型系统的"函数"

泛型(Generic)是 TypeScript 类型系统里最有表达力的特性。你可以把它理解成类型层面的参数——函数接受值的参数,泛型接受类型的参数。

function identity<T>(value: T): T {
  return value;
}

identity<string>("hello"); // 返回类型是 string
identity<number>(42);      // 返回类型是 number

进一步,泛型可以加约束:

function getLength<T extends { length: number }>(val: T): number {
  return val.length;
}

getLength("hello");    // ✅ string 有 length
getLength([1, 2, 3]);  // ✅ array 有 length
getLength(123);        // ❌ number 没有 length,报错

泛型约束用 extends 关键字,表示"T 必须满足某个结构"。这让你写出的工具函数既灵活又安全。

五、工具类型:不要重复造轮子

TS 内置了一批工具类型(Utility Types) ,专门用于对已有类型进行变形。掌握这些,能让你的类型定义简洁一个量级。

Partial:把所有字段变成可选

interface User {
  id: string;
  name: string;
  email: string;
}

type UpdateUserPayload = Partial<User>;
// 等价于 { id?: string; name?: string; email?: string }

更新接口往往只需要传部分字段,Partial 比重新定义一个新 interface 优雅得多。

Pick 和 Omit:精确裁剪类型

type UserPreview = Pick<User, "id" | "name">;
// 只保留 id 和 name

type PublicUser = Omit<User, "password" | "internalNotes">;
// 去掉敏感字段

这两个是一对互补工具。前端展示层经常需要的"脱敏版接口类型",用 Omit 一行搞定。

Record:快速定义映射结构

const userCache: Record<string, User> = {};
// 等价于 { [key: string]: User }

Record<K, V> 比写索引签名更直观。后台管理系统里的权限映射、字典数据、配置对象,Record 用起来非常顺手。

Exclude 和 Extract:在联合类型里做集合运算

type Status = "active" | "inactive" | "banned";

type ActiveStatus = Extract<Status, "active" | "inactive">;
// 结果:"active" | "inactive"

type NonBanned = Exclude<Status, "banned">;
// 结果:"active" | "inactive"

Extract 是取交集,Exclude 是取差集。在处理复杂状态枚举时会用到。

六、条件类型与映射类型:进阶但值得了解

条件类型

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

语法和三元运算符一样,但它运作在类型层面。很多 TS 内置的工具类型(比如 Exclude)底层就是用条件类型实现的。

映射类型

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

keyof T 拿到 T 的所有键,in 遍历它们,然后给每个键加上 readonly 修饰符。PartialRequiredReadonly 这些内置工具类型,背后全是映射类型。

七、interface vs type:别被这个问题困扰太久

这是 TS 社区里被讨论烂了的问题,结论其实挺简单:

interface 定义对象结构,因为它支持声明合并(Declaration Merging) ——同名 interface 会自动合并,这在扩展第三方库类型时很有用。

type 定义联合类型、交叉类型、别名,因为 interface 做不到 type Status = "active" | "inactive" 这种写法。

// interface:适合对象,支持 extends 和合并
interface User {
  name: string;
}
interface User {
  age: number; // 合并生效,不报错
}

// type:更灵活,适合联合/交叉/别名
type ID = string | number;
type AdminUser = User & { role: "admin" };

实际项目里,两者往往混用。不必教条,根据场景选最合适的

八、总结表

以下 12 个类型/特性,覆盖了日常前端开发 90% 以上的场景

类型 / 特性 核心价值
string / number / boolean 基础约束,防止类型误用
联合类型 处理多态数据,配合类型缩小使用
interface 定义数据结构,团队协作的契约
type 定义联合、交叉、别名,比 interface 更灵活
泛型 <T> 复用逻辑的同时保持类型安全
Record 快速定义映射/字典结构
Partial 更新接口的标配
Pick / Omit 从已有类型裁剪出你需要的形状
never 穷举检查,让编译器替你兜底

结语

TypeScript 的类型系统不是负担,是把 bug 消灭在编辑器里的机会。每一个精确的类型定义,都是在为未来的自己、为团队省下一次排查 bug 的时间。

从今天起,遇到 any,先想想能不能换成 unknown。遇到 string,先想想能不能换成字面量联合类型。把类型写得越具体,编译器能帮你做的就越多。

TypeScript 最好的使用方式,是把它当成一个不会累、不会忘、全年无休的代码审查员

渲染器核心:mount挂载过程

作者 wuhen_n
2026年2月26日 07:23

在上一篇文章中,我们深入探讨了虚拟 DOM 的设计与创建。现在,我们有了描述界面的 VNode,接下来要做的就是将它们渲染到真实的页面上。这个过程就是渲染器的职责。本文将深入剖析 Vue3 渲染器的挂载(mount)过程,看看虚拟 DOM 如何一步步变成真实 DOM。

前言:从虚拟 DOM 到真实 DOM

当我们编写这样的 Vue 组件时:

const App = {
  render() {
    return h('div', { class: 'container' }, [
      h('h1', 'Hello Vue3'),
      h('p', '这是渲染器的工作')
    ]);
  }
};

// 创建渲染器并挂载
createApp(App).mount('#app');

在这背后发生了一系列复杂而有序的操作: 渲染流图

本文将聚焦于首次渲染(mount)的过程。

渲染器的设计思想

为什么需要渲染器?

在深入了解代码之前,我们先思考一个问题:为什么 Vue 不直接将模板编译成 DOM 操作指令,而是要引入虚拟 DOM 和渲染器这一层?答案是:解耦跨平台

// 如果直接编译成 DOM 操作
function render() {
  const div = document.createElement('div');
  div.className = 'container';
  // ... 只能运行在浏览器
}

// 通过渲染器抽象
function render(vnode, container) {
  // 具体的创建操作由渲染器实现
  // 浏览器渲染器:document.createElement
  // 小程序渲染器:wx.createView
  // Native 渲染器:createNativeView
}

渲染器的三层架构

Vue3 的渲染器采用了清晰的分层设计: 渲染器三层架构 这种分层设计带来了极大的灵活性:

  • 渲染核心:实现 diff 算法、生命周期等通用逻辑
  • 平台操作层:提供统一的接口,由各平台实现
  • 目标平台:浏览器、小程序、Weex 等

渲染器的创建过程

创建渲染器工厂

渲染器本身是一个工厂函数,它接收平台操作作为参数,返回一个渲染器对象:

/**
 * 创建渲染器
 * @param {Object} options - 平台操作选项
 * @returns {Object} 渲染器对象
 */
function createRenderer(options) {
  // 解构平台操作
  const {
    createElement,  // 创建元素
    createText,     // 创建文本节点
    createComment,  // 创建注释节点
    insert,         // 插入节点
    setText,        // 设置文本内容
    setElementText, // 设置元素文本
    patchProp       // 更新属性
  } = options;

  // ... 渲染核心逻辑

  return {
    render,        // 渲染函数
    createApp      // 创建应用
  };
}

这种设计模式称为依赖注入,它将平台相关的操作从核心逻辑中抽离出来,使得渲染核心可以跨平台复用。

浏览器平台的实现

对于浏览器平台,Vue 提供了对应的 DOM 操作:

// 浏览器平台操作
const nodeOps = {
  // 创建元素:直接调用 document.createElement
  createElement(tag) {
    return document.createElement(tag);
  },
  
  // 创建文本节点
  createText(text) {
    return document.createTextNode(text);
  },
  
  // 创建注释节点
  createComment(text) {
    return document.createComment(text);
  },
  
  // 插入节点:使用 insertBefore 实现通用插入
  insert(child, parent, anchor = null) {
    parent.insertBefore(child, anchor);
  },
  
  // 设置元素文本内容
  setElementText(el, text) {
    el.textContent = text;
  },
  
  // 设置文本节点内容
  setText(node, text) {
    node.nodeValue = text;
  }
};

创建应用 API

渲染器还负责提供 createApp API,这是 Vue 应用的入口:

function createAppAPI(render) {
  return function createApp(rootComponent) {
    const app = {
      // 挂载方法
      mount(rootContainer) {
        // 1. 创建根 VNode
        const vnode = createVNode(rootComponent);
        
        // 2. 调用渲染器
        render(vnode, rootContainer);
        
        // 3. 返回组件实例
        return vnode.component;
      }
    };
    return app;
  };
}

首次渲染的完整流程

从 render 到 patch

当调用 app.mount('#app') 时,渲染器开始工作:

function render(vnode, container) {
  if (vnode) {
    // 存在新 VNode,进行 patch
    // container._vnode 存储上一次的 VNode,首次为 null
    patch(container._vnode || null, vnode, container);
  } else {
    // 没有新 VNode,卸载旧节点
    if (container._vnode) {
      unmount(container._vnode);
    }
  }
  // 保存当前 VNode
  container._vnode = vnode;
}

patch 的分发逻辑

patch 是整个渲染器的核心函数,它根据节点类型分发到不同的处理函数:

function patch(oldVNode, newVNode, container, anchor = null) {
  // 首次渲染,oldVNode 为 null
  if (oldVNode == null) {
    // 根据类型选择挂载方式
    const { type, shapeFlag } = newVNode;
    
    switch (type) {
      case Text:      // 文本节点
        mountText(newVNode, container, anchor);
        break;
      case Comment:   // 注释节点
        mountComment(newVNode, container, anchor);
        break;
      case Fragment:  // 片段
        mountFragment(newVNode, container, anchor);
        break;
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // 原生元素
          mountElement(newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // 组件
          mountComponent(newVNode, container, anchor);
        }
    }
  }
}

下图展示了 patch 的分发流程: pacth 分发流程

为什么需要这么多类型?

不同类型的节点在 DOM 中的表现完全不同:

节点类型 真实 DOM 表示 特点
元素节点 HTMLElement 有标签名、属性、子节点
文本节点 TextNode 只有文本内容
注释节点 Comment 用于注释,不影响渲染
Fragment 无对应节点 多个根节点的容器

原生元素的挂载详解

mountElement 的四个步骤

挂载一个原生元素需要四个核心步骤:

  1. 创建 DOM 元素
  2. 保存 DOM 元素引用
  3. 处理子节点和属性
  4. 插入到容器
function mountElement(vnode, container, anchor) {
  const { type, props, shapeFlag } = vnode;
  
  // 步骤1:创建 DOM 元素
  const el = hostCreateElement(type);
  
  // 步骤2:保存 DOM 元素引用
  vnode.el = el;
  
  // 步骤3:处理子节点和属性
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 情况A:文本子节点
    hostSetElementText(el, vnode.children);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 情况B:数组子节点
    mountChildren(vnode.children, el);
  }
  
  if (props) {
    for (const key in props) {
      hostPatchProp(el, key, null, props[key]);
    }
  }
  
  // 步骤4:插入到容器
  hostInsert(el, container, anchor);
}

子节点的递归挂载

数组子节点的挂载是一个递归过程:

function mountChildren(children, container) {
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    // 递归调用 patch 挂载每个子节点
    // 注意:这里传入的 oldVNode 为 null
    patch(null, child, container);
  }
}

一个完整的挂载示例

让我们通过一个具体例子,观察挂载的全过程:

// 示例 VNode
const vnode = {
  type: 'div',
  props: {
    class: 'card',
    id: 'card-1',
    'data-index': 0
  },
  children: [
    {
      type: 'h2',
      props: { class: 'title' },
      children: '标题',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    },
    {
      type: 'p',
      props: { class: 'content' },
      children: '内容',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    }
  ],
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
};

// 执行挂载
mountElement(vnode, document.getElementById('app'));

// 生成的真实 DOM:
// <div class="card" id="card-1" data-index="0">
//   <h2 class="title">标题</h2>
//   <p class="content">内容</p>
// </div>

属性的处理

属性的分类

在 Web 开发中,元素的属性分为以下几类:

  1. 普通属性:<div id="app" title="标题"></div>
  2. 类名:<div class="container active"></div>
  3. 样式:<div style="color: red; font-size: 16px"></div>
  4. 事件:<div onclick="handleClick"></div>
  5. DOM 属性:<div hidden disabled></div>

属性的设置方式

不同类型的属性,设置方式也不同:

类型 设置方式 示例
普通属性 setAttribute el.setAttribute('id', 'app')
类名 className el.className = 'container'
样式 style 对象 el.style.color = 'red'
事件 addEventListener el.addEventListener('click', handler)
DOM 属性 直接赋值 el.hidden = true

patchProp 的分发逻辑

Vue3 的 patchProp 函数需要处理以下这些情况:

  1. 处理事件:patchEvent(el, key, prevValue, nextValue);
  2. 处理 class:patchClass(el, nextValue);
  3. 处理 style:patchStyle(el, prevValue, nextValue);
  4. 处理 DOM 属性:patchDOMProp(el, key, nextValue);
  5. 处理普通属性:patchAttr(el, key, nextValue);

事件处理的优化

事件处理有一个重要的优化点:避免频繁添加/移除事件监听

不好的做法:每次更新都移除再添加

function patchEventBad(el, key, prevValue, nextValue) {
  const eventName = key.slice(2).toLowerCase();
  
  if (prevValue) {
    el.removeEventListener(eventName, prevValue);
  }
  if (nextValue) {
    el.addEventListener(eventName, nextValue);
  }
}

Vue3 的做法:使用 invoker 缓存

function patchEvent(el, rawKey, prevValue, nextValue) {
  const eventName = rawKey.slice(2).toLowerCase();
  
  // 使用 el._vei 存储事件调用器
  const invokers = el._vei || (el._vei = {});
  let invoker = invokers[eventName];
  
  if (nextValue && invoker) {
    // 有旧调用器:只更新值
    invoker.value = nextValue;
  } else if (nextValue && !invoker) {
    // 无旧调用器:创建新调用器
    invoker = createInvoker(nextValue);
    invokers[eventName] = invoker;
    el.addEventListener(eventName, invoker);
  } else if (!nextValue && invoker) {
    // 没有新值:移除监听
    el.removeEventListener(eventName, invoker);
    invokers[eventName] = null;
  }
}

function createInvoker(initialValue) {
  const invoker = (e) => {
    invoker.value(e);
  };
  invoker.value = initialValue;
  return invoker;
}

这种设计的优势在于:事件监听只添加一次,后续更新只改变回调函数: invoker 缓存

样式的合并处理

patchStyle 需要处理三种情况:

  1. 没有新样式:el.removeAttribute('style');
  2. 新样式是字符串:style.cssText = next;
  3. 新样式是对象:
// 设置新样式
for (const key in next) {
  setStyle(style, key, next[key]);
}

// 移除旧样式中不存在于新样式的属性
if (prev && typeof prev !== 'string') {
  for (const key in prev) {
    if (next[key] == null) {
      setStyle(style, key, '');
    }
  }
}

文本节点和注释节点

文本节点的处理

文本节点是最简单的节点类型:

// 文本节点的类型标识(Symbol 保证唯一性)
const Text = Symbol('Text');
function mountText(vnode, container, anchor) {
  // 1. 创建文本节点
  const textNode = document.createTextNode(vnode.children);
  
  // 2. 保存真实节点引用
  vnode.el = textNode;
  
  // 3. 插入到容器
  container.insertBefore(textNode, anchor);
}

文本节点在 DOM 中的表现:

<!-- 文本节点没有标签,只有内容 -->
Hello World

注释节点的处理

注释节点用于调试和特殊场景:

const Comment = Symbol('Comment');

function mountComment(vnode, container, anchor) {
  // 创建注释节点
  const commentNode = document.createComment(vnode.children);
  vnode.el = commentNode;
  container.insertBefore(commentNode, anchor);
}

注释节点在 DOM 中的表现:

<!-- 这是一个注释节点,不会显示在页面上 -->

Fragment 的处理

Fragment 是 Vue3 新增的特性,允许组件返回多个根节点:

const Fragment = Symbol('Fragment');

function mountFragment(vnode, container, anchor) {
  const { children, shapeFlag } = vnode;
  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点:挂载为文本节点
    mountText(createTextVNode(children), container, anchor);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 数组子节点:挂载所有子节点
    mountChildren(children, container);
  }
  
  // Fragment 本身没有真实 DOM
  // el 指向第一个子节点的 el
  vnode.el = children[0]?.el;
  // anchor 指向最后一个子节点的 el
  vnode.anchor = children[children.length - 1]?.el;
}

Fragment 的 DOM 表现:

<!-- 没有外层包裹元素 -->
<h1>标题</h1>
<p>段落1</p>
<p>段落2</p>

完整的渲染器实现

让我们将上述所有概念整合,实现一个可工作的简化版渲染器:

class Renderer {
  constructor(options) {
    // 注入平台操作
    this.createElement = options.createElement;
    this.createText = options.createText;
    this.createComment = options.createComment;
    this.insert = options.insert;
    this.setElementText = options.setElementText;
    this.patchProp = options.patchProp;
  }

  render(vnode, container) {
    if (vnode) {
      this.patch(null, vnode, container);
      container._vnode = vnode;
    } else if (container._vnode) {
      this.unmount(container._vnode);
    }
  }

  patch(oldVNode, newVNode, container, anchor = null) {
    if (oldVNode === newVNode) return;
    
    const { type, shapeFlag } = newVNode;
    
    // 根据类型分发
    if (type === Text) {
      this.processText(oldVNode, newVNode, container, anchor);
    } else if (type === Comment) {
      this.processComment(oldVNode, newVNode, container, anchor);
    } else if (type === Fragment) {
      this.processFragment(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.ELEMENT) {
      this.processElement(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.COMPONENT) {
      this.processComponent(oldVNode, newVNode, container, anchor);
    }
  }

  processElement(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      this.mountElement(newVNode, container, anchor);
    } else {
      this.patchElement(oldVNode, newVNode);
    }
  }

  mountElement(vnode, container, anchor) {
    // 1. 创建元素
    const el = this.createElement(vnode.type);
    vnode.el = el;
    
    // 2. 处理属性
    if (vnode.props) {
      for (const key in vnode.props) {
        this.patchProp(el, key, null, vnode.props[key]);
      }
    }
    
    // 3. 处理子节点
    const { shapeFlag, children } = vnode;
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      this.setElementText(el, children);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, el);
    }
    
    // 4. 插入容器
    this.insert(el, container, anchor);
  }

  mountChildren(children, container) {
    for (let i = 0; i < children.length; i++) {
      this.patch(null, children[i], container);
    }
  }

  processText(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const textNode = this.createText(newVNode.children);
      newVNode.el = textNode;
      this.insert(textNode, container, anchor);
    } else {
      const el = (newVNode.el = oldVNode.el);
      if (newVNode.children !== oldVNode.children) {
        el.nodeValue = newVNode.children;
      }
    }
  }

  processComment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const commentNode = this.createComment(newVNode.children);
      newVNode.el = commentNode;
      this.insert(commentNode, container, anchor);
    } else {
      newVNode.el = oldVNode.el;
    }
  }

  processFragment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const { shapeFlag, children } = newVNode;
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.patch(null, {
          type: Text,
          children
        }, container, anchor);
      } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.mountChildren(children, container);
      }
    } else {
      this.patchChildren(oldVNode, newVNode, container);
    }
  }

  unmount(vnode) {
    const parent = vnode.el.parentNode;
    if (parent) {
      parent.removeChild(vnode.el);
    }
  }
}

性能优化与最佳实践

避免不必要的挂载

在实际开发中,需要注意避免频繁的挂载和卸载:

// 不推荐:频繁切换导致反复挂载/卸载
function BadExample() {
  return show.value 
    ? h(HeavyComponent) 
    : null;
}

// 推荐:使用 keep-alive 缓存组件
function GoodExample() {
  return h(KeepAlive, null, [
    show.value ? h(HeavyComponent) : null
  ]);
}

合理使用 key

key 在 diff 算法中起着关键作用:

// 不推荐:使用索引作为 key
items.map((item, index) => 
  h('div', { key: index }, item.text)
);

// 推荐:使用唯一标识
items.map(item => 
  h('div', { key: item.id }, item.text)
);

为什么不推荐使用索引作为 key: 不推荐使用索引key

静态内容提升

对于不会变化的静态内容,应该避免重复创建 VNode:

// 编译器会自动优化
// 源码:
// <div>
//   <span>静态文本</span>
//   <span>{{ dynamic }}</span>
// </div>

// 编译后:
const _hoisted_1 = h('span', '静态文本');

function render(ctx) {
  return h('div', [
    _hoisted_1,  // 直接复用
    h('span', ctx.dynamic)
  ]);
}

事件委托优化

对于大量相似元素的交互,使用事件委托:

// 不推荐:每个元素独立事件
list.value.map(item => 
  h('button', {
    onClick: () => handleItem(item)
  }, item.name)
);

// 推荐:使用事件委托
function handleListClick(e) {
  const target = e.target;
  if (target.tagName === 'BUTTON') {
    const index = target.dataset.index;
    handleItem(list.value[index]);
  }
}

h('div', { onClick: handleListClick },
  list.value.map((item, index) => 
    h('button', { 
      'data-index': index 
    }, item.name)
  )
);

完整挂载流程图

下面是完整的挂载流程图: 完整挂载流程图

结语

本文主要介绍了 Vue3 渲染器的挂载全过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

虚拟DOM:VNode的设计与创建

作者 wuhen_n
2026年2月26日 07:15

经过前几篇文章的深入探索,我们完整地构建了 Vue3 的响应式系统。但响应式数据最终要渲染到页面上,这中间的桥梁就是虚拟DOM。今天,我们将深入 Vue3 虚拟 DOM 的设计与实现,看看它如何为高效的页面更新奠定基础。

前言:为什么需要虚拟DOM?

在传统的 jQuery 时代,我们直接操作真实 DOM:

$('#app').html('<div>Hello World</div>');

这种方式虽然直观,但有几个致命问题:

  • 性能开销大:DOM 操作是浏览器中最昂贵的操作之一,频繁的 DOM 操作会严重影响系统性能
  • 难以追踪:复杂应用的状态变化难以管理
  • 手动操作:开发者需要手动维护 DOM 与状态的一致性

虚拟 DOM 的出现解决了这些问题:

// 虚拟 DOM 描述
const vnode = {
  type: 'div',
  props: { class: 'container' },
  children: 'Hello World'
};

// 渲染器将虚拟 DOM 转换为真实 DOM
render(vnode, document.getElementById('app'));

虚拟 DOM 的本质:用 JavaScript 对象来描述真实 DOM 结构,通过比较新旧虚拟 DOM 的差异(diff),最小化地更新真实 DOM。

注:虚拟 DOM 相比真实 DOM 的优势在于:频繁操作 DOM 时,虚拟 DOM 可以先将操作收集,再一次性转成真实 DOM,渲染到页面上;而不需要每次操作都修改真实 DOM。

注:虚拟 DOM 不一定比真实 DOM 快,毕竟没有什么操作的性能能比 document.createElement('div') 更优了!

虚拟 DOM 的结构变化

Vue2 的 VNode 结构

// Vue2 的 VNode 结构(简化)
interface VNode {
  tag?: string;           // 标签名
  data?: VNodeData;       // 属性、事件等
  children?: VNode[];      // 子节点
  text?: string;          // 文本内容
  elm?: Node;             // 对应的真实 DOM
  key?: string | number;  // 唯一标识
  // ... 其他属性
}

Vue3 的 VNode 结构

// Vue3 的 VNode 结构(简化)
interface VNode {
  __v_isVNode: true;      // 标记为 VNode
  type: any;              // 类型:元素标签、组件、Fragment等
  props: any;             // 属性
  children: any;          // 子节点
  shapeFlag: number;      // 节点类型标志(位掩码)
  patchFlag: number;      // 优化标志(位掩码)
  dynamicProps: string[] | null;  // 动态属性列表
  staticCount: number;    // 静态节点计数
  
  key: any;               // 唯一标识
  ref: any;               // 引用
  el: HostNode | null;    // 真实 DOM 节点
  anchor: HostNode | null; // 锚点(Fragment 使用)
  
  // 组件相关
  component: any;         // 组件实例
  suspense: any;          // Suspense 相关
  ssContent: any;         // SSR 内容
  ssFallback: any;        // SSR 回退
  
  // 优化相关
  scopeId: string | null; // 作用域 ID
  slotScopeIds: string[] | null; // 插槽作用域 ID
}

Vue3 VNode 结构的主要变化

1. 更明确的类型标识

type: 'div' | 'span' | MyComponent | Fragment | Text | Comment | Static;

2. 使用 shapeFlag 位掩码标记类型

const enum ShapeFlags {
  ELEMENT = 1,              // 元素节点
  FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件
  STATEFUL_COMPONENT = 1 << 2,    // 状态组件
  TEXT_CHILDREN = 1 << 3,   // 文本子节点
  ARRAY_CHILDREN = 1 << 4,  // 数组子节点
  SLOTS_CHILDREN = 1 << 5,  // 插槽子节点
  TELEPORT = 1 << 6,        // Teleport
  SUSPENSE = 1 << 7,        // Suspense
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  COMPONENT_KEPT_ALIVE = 1 << 9
}

3. 使用 patchFlag 标记动态内容

export const enum PatchFlags {
  TEXT = 1,                 // 动态文本内容
  CLASS = 1 << 1,           // 动态 class
  STYLE = 1 << 2,           // 动态 style
  PROPS = 1 << 3,           // 动态属性
  FULL_PROPS = 1 << 4,      // 全量比较
  HYDRATE_EVENTS = 1 << 5,  // 事件监听
  STABLE_FRAGMENT = 1 << 6, // 稳定 Fragment
  KEYED_FRAGMENT = 1 << 7,  // 带 key 的 Fragment
  UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 Fragment
  NEED_PATCH = 1 << 9,      // 需要非 props 比较
  DYNAMIC_SLOTS = 1 << 10,  // 动态插槽
  DEV_ROOT_FRAGMENT = 1 << 11, // 开发环境根 Fragment
  
  // 特殊标志
  HOISTED = -1,             // 静态提升节点
  BAIL = -2                 // 退出优化
}

VNode 的核心属性

1. type:节点类型

元素节点
const elementVNode = {
  type: 'div',
  props: { class: 'box' },
  children: 'Hello'
};
组件节点
const MyComponent = {
  setup() {
    return () => h('div', '组件内容');
  }
};

const componentVNode = {
  type: MyComponent,
  props: { title: '标题' }
};
文本节点
const textVNode = {
  type: Text,
  props: null,
  children: '文本内容'
};

Fragment(片段)
const fragmentVNode = {
  type: Fragment,
  children: [
    h('div', '子节点1'),
    h('div', '子节点2')
  ]
};
静态节点
const staticVNode = {
  type: 'div',
  props: { class: 'static' },
  children: '静态内容',
  patchFlag: PatchFlags.HOISTED // 标记为提升
};

2. props:属性

function createVNode(type, props, children) {
  const vnode = {
    type,
    props: props || {},
    children,
    // 提取关键属性
    key: props && props.key,
    ref: props && props.ref,
    // 清理 props 中的特殊属性
    ...normalizeProps(props)
  };
  
  return vnode;
}

function normalizeProps(props) {
  if (!props) return {};
  
  // 分离特殊属性
  const { key, ref, ...pureProps } = props;
  
  return {
    props: pureProps,
    key,
    ref
  };
}

3. children:子节点

文本子节点
const vnode1 = {
  type: 'div',
  children: '纯文本',
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
};
数组子节点
const vnode2 = {
  type: 'div',
  children: [
    h('span', '子节点1'),
    h('span', '子节点2')
  ],
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
};
插槽子节点
const vnode3 = {
  type: MyComponent,
  children: {
    default: () => h('div', '默认插槽'),
    header: () => h('div', '头部插槽')
  },
  shapeFlag: ShapeFlags.COMPONENT | ShapeFlags.SLOTS_CHILDREN
};
空子节点
const vnode4 = {
  type: 'div',
  children: null,
  shapeFlag: ShapeFlags.ELEMENT
};

多种 VNode 类型

元素节点

function createElementVNode(tag, props, children) {
  const vnode = {
    type: tag,
    props,
    children,
    shapeFlag: ShapeFlags.ELEMENT
  };
  
  // 设置子节点类型标志
  if (typeof children === 'string') {
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN;
  } else if (Array.isArray(children)) {
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
  }
  
  return vnode;
}

组件节点

function createComponentVNode(component, props, children) {
  const vnode = {
    type: component,
    props,
    children,
    shapeFlag: ShapeFlags.STATEFUL_COMPONENT
  };
  
  // 处理插槽
  if (typeof children === 'object') {
    vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN;
  }
  
  // 组件实例(稍后填充)
  vnode.component = null;
  
  return vnode;
}

文本节点

const Text = Symbol('Text');

function createTextVNode(text) {
  return {
    type: Text,
    props: null,
    children: String(text),
    shapeFlag: ShapeFlags.TEXT_CHILDREN
  };
}

Fragment 节点

const Fragment = Symbol('Fragment');

function createFragmentVNode(children) {
  return {
    type: Fragment,
    props: null,
    children,
    shapeFlag: Array.isArray(children) 
      ? ShapeFlags.ARRAY_CHILDREN 
      : ShapeFlags.TEXT_CHILDREN
  };
}

静态节点

function createStaticVNode(content, count) {
  return {
    type: 'div',
    props: null,
    children: content,
    shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN,
    patchFlag: PatchFlags.HOISTED,
    staticCount: count
  };
}

静态提升(Static Hoisting)

静态提升的原理

我们先来看一段模版代码:

<div>
  <span>静态文本</span>
  <span>{{ dynamic }}</span>
</div>

没有静态提升下的渲染函数:

function render(ctx) {
  return h('div', [
    h('span', '静态文本'), // 每次渲染都创建
    h('span', ctx.dynamic)
  ]);
}

没有静态提升下,对于 <span>静态文本</span> 这段代码,每次渲染时都会创建。

静态提升下的渲染函数:

const _hoisted_1 = h('span', '静态文本'); // 提升到函数外

function render(ctx) {
  return h('div', [
    _hoisted_1, // 直接复用
    h('span', ctx.dynamic)
  ]);
}

静态提升下,对于 <span>静态文本</span> 这段代码,会将静态文本的 VNode 提升到函数外,在需要的时候直接复用即可!

实现静态提升

// 编译器生成的代码示例
import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"

// 静态节点提升
const _hoisted_1 = _createVNode("span", null, "静态文本", PatchFlags.HOISTED)
const _hoisted_2 = _createVNode("div", { class: "static-class" }, [
  _hoisted_1,
  _createVNode("span", null, "另一个静态节点", PatchFlags.HOISTED)
], PatchFlags.HOISTED)

export function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _hoisted_2,  // 直接使用提升的节点
    _createVNode("span", null, _ctx.dynamic, PatchFlags.TEXT)
  ]))
}

Patch Flags 的作用

为什么要用 Patch Flags?

无 Patch Flags:需要全量比较:

function patch(oldVNode, newVNode) {
  // 比较所有属性
  if (oldVNode.props.class !== newVNode.props.class) {
    updateClass();
  }
  if (oldVNode.props.style !== newVNode.props.style) {
    updateStyle();
  }
  if (oldVNode.props.id !== newVNode.props.id) {
    updateId();
  }
  // ... 比较所有可能的属性
}

有 Patch Flags:只比较动态部分:

function patch(oldVNode, newVNode) {
  if (newVNode.patchFlag & PatchFlags.CLASS) {
    // 只有 class 是动态的
    updateClass();
  }
  if (newVNode.patchFlag & PatchFlags.STYLE) {
    // 只有 style 是动态的
    updateStyle();
  }
  // 只比较标记为动态的属性
}

Patch Flags 的实现

// 动态节点标记
function createVNodeWithFlags(type, props, children, flag) {
  const vnode = createVNode(type, props, children);
  vnode.patchFlag = flag;
  
  // 记录动态属性名
  if (flag & PatchFlags.PROPS) {
    vnode.dynamicProps = Object.keys(props).filter(
      key => !isStaticProperty(key)
    );
  }
  
  return vnode;
}

// 使用示例
const dynamicClassVNode = createVNodeWithFlags(
  'div',
  { class: dynamicClass }, // class 动态
  '内容',
  PatchFlags.CLASS
);

const dynamicTextVNode = createVNodeWithFlags(
  'span',
  null,
  dynamicText,
  PatchFlags.TEXT
);

const multipleDynamicsVNode = createVNodeWithFlags(
  'div',
  {
    class: dynamicClass,
    style: dynamicStyle,
    id: 'static-id' // 静态属性
  },
  '内容',
  PatchFlags.CLASS | PatchFlags.STYLE
);
// dynamicProps: ['class', 'style']

h 函数的实现

h 函数的基本实现

/**
 * h 函数:创建 VNode 的辅助函数
 * @param {string|object} type - 节点类型
 * @param {object} props - 属性
 * @param {array|string} children - 子节点
 * @returns {object} VNode
 */
function h(type, props, children) {
  // 处理参数重载
  const args = normalizeArgs(type, props, children);
  
  return createVNode(args.type, args.props, args.children);
}

function normalizeArgs(type, props, children) {
  // 如果没有 props
  if (arguments.length === 2) {
    if (isObject(props) && !isArray(props)) {
      // h('div', { class: 'box' })
      return { type, props, children: null };
    } else {
      // h('div', '文本内容')
      return { type, props: null, children: props };
    }
  }
  
  // 完整参数
  return { type, props, children };
}

function isObject(val) {
  return val !== null && typeof val === 'object';
}

function isArray(val) {
  return Array.isArray(val);
}

完整的 createVNode 实现

/**
 * 创建 VNode
 * @param {any} type - 节点类型
 * @param {object} props - 属性
 * @param {any} children - 子节点
 * @param {number} patchFlag - 优化标志
 * @param {object} dynamicProps - 动态属性列表
 * @returns {object} VNode
 */
function createVNode(type, props, children, patchFlag, dynamicProps) {
  // 处理 props
  props = normalizeProps(props);
  
  // 提取 key 和 ref
  const { key, ref } = props || {};
  
  // 计算 shapeFlag
  const shapeFlag = getShapeFlag(type, children);
  
  // 创建基础 VNode
  const vnode = {
    __v_isVNode: true,
    type,
    props: props || null,
    children,
    shapeFlag,
    
    // 优化相关
    patchFlag: patchFlag || 0,
    dynamicProps: dynamicProps || null,
    
    // 核心属性
    key,
    ref,
    
    // 运行时相关
    el: null,          // 真实 DOM
    anchor: null,      // 锚点(Fragment)
    component: null,   // 组件实例
    parent: null,      // 父 VNode
    
    // 其他
    scopeId: null,
    slotScopeIds: null
  };
  
  // 处理子节点
  normalizeChildren(vnode, children);
  
  // 如果有动态 children,记录
  if (shouldTrackDynamicChildren(vnode)) {
    vnode.dynamicChildren = [];
  }
  
  return vnode;
}

function normalizeProps(props) {
  if (!props) return null;
  
  // 移除 Vue 内部使用的特殊属性
  const { class: klass, style, ...rest } = props;
  
  // 合并 class
  if (klass) {
    rest.class = normalizeClass(klass);
  }
  
  // 合并 style
  if (style) {
    rest.style = normalizeStyle(style);
  }
  
  return rest;
}

function getShapeFlag(type, children) {
  let shapeFlag = 0;
  
  // 判断类型
  if (typeof type === 'string') {
    shapeFlag = ShapeFlags.ELEMENT;
  } else if (type === Text) {
    shapeFlag = ShapeFlags.TEXT_CHILDREN;
  } else if (type === Fragment) {
    shapeFlag = ShapeFlags.FRAGMENT;
  } else {
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT;
  }
  
  // 判断子节点类型
  if (children) {
    if (typeof children === 'string') {
      shapeFlag |= ShapeFlags.TEXT_CHILDREN;
    } else if (Array.isArray(children)) {
      shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
    } else if (isObject(children)) {
      shapeFlag |= ShapeFlags.SLOTS_CHILDREN;
    }
  }
  
  return shapeFlag;
}

function normalizeChildren(vnode, children) {
  if (!children) return;
  
  // 标准化文本子节点
  if (typeof children === 'string' || typeof children === 'number') {
    vnode.children = String(children);
  }
  
  // 标准化数组子节点
  if (Array.isArray(children)) {
    vnode.children = children.map(child => {
      if (typeof child === 'string') {
        return createTextVNode(child);
      }
      return child;
    });
  }
}

function shouldTrackDynamicChildren(vnode) {
  return vnode.patchFlag > 0 || 
         vnode.patchFlag === PatchFlags.HOISTED ||
         vnode.shapeFlag & ShapeFlags.COMPONENT;
}

// 工具函数:规范化 class
function normalizeClass(value) {
  if (typeof value === 'string') return value;
  if (Array.isArray(value)) {
    return value.map(normalizeClass).filter(Boolean).join(' ');
  }
  if (isObject(value)) {
    return Object.keys(value)
      .filter(key => value[key])
      .join(' ');
  }
  return '';
}

// 工具函数:规范化 style
function normalizeStyle(value) {
  if (typeof value === 'string') return value;
  if (Array.isArray(value)) {
    return Object.assign({}, ...value.map(normalizeStyle));
  }
  if (isObject(value)) return value;
  return {};
}

h 函数的完整版本

/**
 * 完整的 h 函数实现
 * 支持多种调用方式:
 * h('div')
 * h('div', { class: 'box' })
 * h('div', '文本')
 * h('div', {}, ['子节点1', '子节点2'])
 * h(Component, { props })
 */
function h(type, propsOrChildren, children) {
  const args = arguments.length;
  
  // h('div')
  if (args === 1) {
    return createVNode(type, null, null);
  }
  
  // h('div', {})
  if (args === 2) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // 第二个参数是 props
      return createVNode(type, propsOrChildren, null);
    } else {
      // 第二个参数是 children
      return createVNode(type, null, propsOrChildren);
    }
  }
  
  // h('div', {}, '文本')
  // h('div', {}, [])
  // h('div', {}, h('span'))
  if (args === 3) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // 有 props
      return createVNode(type, propsOrChildren, children);
    } else {
      // 无 props
      return createVNode(type, null, propsOrChildren);
    }
  }
  
  // 更多参数(不常见)
  const props = propsOrChildren;
  const _children = Array.from(arguments).slice(2);
  return createVNode(type, props, _children);
}

实战:使用 h 函数创建组件

// 定义组件
const MyComponent = {
  setup(props) {
    const count = ref(0);
    
    return () => h('div', { class: 'counter' }, [
      h('h3', props.title),
      h('p', `计数: ${count.value}`),
      h('button', {
        onClick: () => count.value++
      }, '增加')
    ]);
  }
};

// 创建 VNode
const vnode = h(MyComponent, {
  title: '我的计数器'
});

// 模拟渲染
function render(vnode, container) {
  if (typeof vnode.type === 'object') {
    // 组件
    const component = vnode.type;
    const subTree = component.setup(vnode.props);
    render(subTree, container);
  } else if (typeof vnode.type === 'string') {
    // 元素
    const el = document.createElement(vnode.type);
    
    // 设置属性
    if (vnode.props) {
      Object.entries(vnode.props).forEach(([key, value]) => {
        if (key.startsWith('on')) {
          el.addEventListener(key.slice(2).toLowerCase(), value);
        } else {
          el.setAttribute(key, value);
        }
      });
    }
    
    // 处理子节点
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children;
    } else if (Array.isArray(vnode.children)) {
      vnode.children.forEach(child => render(child, el));
    }
    
    container.appendChild(el);
    vnode.el = el;
  }
}

// 挂载
render(vnode, document.getElementById('app'));

结语

Vue3 的虚拟 DOM 在设计上进行了大量的优化,理解虚拟 DOM 的设计与实现,不仅帮助我们写出更高效的 Vue 应用,也为后续学习 diff 算法和渲染器打下坚实基础。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Prop Drilling 再见!React Context 核心概念与实战解析

2026年2月25日 21:44

Prop Drilling 再见!React Context 核心概念与实战解析

引言

在 React 开发中,组件通信是核心问题之一。最基础的方式是父组件通过 props 向子组件传递数据。但随着应用规模扩大,组件树层级变深,数据需要从顶层传递到深层组件时,props 逐层传递会变得非常繁琐且难以维护。这就是所谓的 Prop Drilling 问题。

React 提供了 Context 来解决这个问题。它允许我们在组件树中共享数据,而不必显式地通过每一层 props 传递。本文将从一个具体问题出发,逐步引入 Context,并通过两个实战例子(用户信息共享、主题切换)带你深入理解它的用法和原理。


1. 从问题开始:Prop Drilling 的烦恼

假设我们有一个用户登录的场景:登录后需要在深层嵌套的 UserInfo 组件中显示用户名。如果不使用 Context,我们只能通过 props 一层层往下传递。来看这段代码(来自 App2.jsx):

function Page({ user }) {
  return <Header user={user} />;
}

function Header({ user }) {
  return <UserInfo user={user} />;
}

function UserInfo({ user }) {
  return (
    <div>
      <h1>Hello {user.name}</h1>
    </div>
  );
}

export default function App() {
  const user = { name: "Andrew" };
  return <Page user={user} />;
}

效果图

image.png

组件层级AppPageHeaderUserInfo。为了把 user 传给 UserInfo,中间组件 PageHeader 尽管根本不需要这个数据,却必须接收并继续往下传。如果层级再深一点,或者有多个这样的数据,代码会变得臃肿不堪,修改和维护都很痛苦。就像电影《长安的荔枝》里,荔枝从岭南运到长安,一路辗转,成本极高——这就是 Prop Drilling 的典型问题。

关键点:这种 props 层层传递的模式不仅增加了代码量,还让中间组件与数据耦合,降低了组件的复用性和可读性。


2. Context 初探:用共享容器解决传递问题

2.1 什么是 Context?

Context 提供了一种在组件树中共享数据的方式,而不必通过 props 逐层传递。它像一个全局的数据容器,你可以把数据放在容器里,然后在任何层级的组件中直接取出使用。

Context 的核心由三部分组成:

  • createContext:创建一个上下文容器。
  • Provider:数据提供者,通过 value 属性指定要共享的数据,并包裹需要访问这些数据的组件树。
  • useContext:在函数组件中读取 Context 的值。

2.2 第一个实战:用户信息共享

让我们用 Context 重构上面的用户信息例子。

步骤1:创建并导出 Context

App.jsx 中,我们使用 createContext 创建一个 UserContext,并用 Provider 包裹子组件:

import { createContext } from 'react';
import Page from './views/Page';

// 创建 Context 容器,初始值为 null(当没有 Provider 时使用)
export const UserContext = createContext(null);

export default function App() {
  const user = { name: "Andrew" };
  return (
    // Provider 通过 value 提供数据,包裹的所有后代都能访问
    <UserContext.Provider value={user}>
      <Page />
    </UserContext.Provider>
  );
}

效果图

image.png

打开vue components 可以看到我们的结构

image.png代码解释

  • createContext(null) 创建了一个 Context 对象。参数 null 是默认值,只有当组件没有匹配到 Provider 时才会使用。
  • <UserContext.Provider value={user}>user 对象提供给所有后代组件。这里的 value 可以是任何类型:对象、数组、函数等。
步骤2:中间组件不再需要传递 props

PageHeader 组件现在可以完全移除 props,直接渲染子组件即可:

// Page.jsx
import Header from '../components/Header';

export default function Page() {
  return <Header />;
}

// Header.jsx
import UserInfo from './UserInfo';

export default function Header() {
  return <UserInfo />;
}

这两个组件不再关心 user 数据,它们的作用只是组合子组件,实现了关注点分离。

步骤3:在目标组件中消费数据

UserInfo 组件中,我们通过 useContext 直接获取 user 数据:

import { useContext } from 'react';
import { UserContext } from '../App';

export default function UserInfo() {
  const user = useContext(UserContext); // 读取 Context 的值
  return (
    <div>
      <h1>Hello {user.name}</h1>
    </div>
  );
}

代码解释

  • useContext(UserContext) 返回 UserContext 中最近的 Provider 的 value。如果找不到 Provider,则返回创建 Context 时传入的默认值(这里是 null)。
  • 当 Provider 的 value 变化时,所有使用了 useContext 的组件都会自动重新渲染。

效果:无论 UserInfo 嵌套多深,它都能直接拿到 user 对象,中间组件完全不需要参与数据传递。这就是 Context 的核心价值:提供者(Provider)负责数据,消费者(useContext)负责使用数据,中间组件无感知

打开vue组件


3. 进阶实战:主题切换(动态状态与副作用)

上面例子中,我们传递的是静态数据。实际开发中,经常需要共享动态状态(比如主题、语言)以及修改状态的方法。下面我们实现一个经典的主题切换功能,将主题状态放在 Context 中,并利用 useEffect 同步到 DOM。

3.1 设计思路

  • 创建一个 ThemeContext,管理主题状态('light' 或 'dark')。
  • 提供切换主题的函数 toggleTheme
  • 当主题变化时,通过 useEffect 更新 <html> 元素的 data-theme 属性,配合 CSS 变量实现样式切换。
  • 将状态和方法封装在自定义的 ThemeProvider 组件中,便于复用。

3.2 实现 ThemeProvider

ThemeContext.jsx 中,我们编写如下代码:

import { useContext, useState, useEffect, createContext } from 'react';

// 创建 Context 容器
export const ThemeContext = createContext(null);

// 自定义 Provider 组件,接收 children 作为子组件
export default function ThemeProvider({ children }) {
  // 1. 使用 useState 管理主题状态
  const [theme, setTheme] = useState('light');

  // 2. 定义切换主题的函数
  const toggleTheme = () => {
    setTheme((t) => (t === 'light' ? 'dark' : 'light'));
  };

  // 3. 使用 useEffect 处理副作用:当 theme 变化时更新 DOM 属性
  useEffect(() => {
    // document.documentElement 指向 <html> 元素
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]); // 依赖数组 [theme] 表示只有 theme 变化时才执行

  // 4. 提供 value 对象,包含主题状态和切换函数
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

代码解释

  • useState('light'):初始化主题状态为 'light',返回当前状态 theme 和更新函数 setTheme
  • toggleTheme:使用函数式更新,根据当前值取反,避免依赖外部变量。
  • useEffect:在组件挂载后和 theme 变化时执行。它设置 <html>data-theme 属性,从而触发 CSS 变量切换。
  • value={{ theme, toggleTheme }}:将状态和函数打包成一个对象传递。子组件可以通过解构获取它们。
  • {children}:渲染被 ThemeProvider 包裹的所有子组件。关于 children 的详细说明见后文。

3.3 在根组件中使用 Provider

App.jsx 中,用 ThemeProvider 包裹整个页面:

import ThemeProvider from './contexts/ThemeContext';
import Page from './pages/Page';

export default function App() {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  );
}

这里的 <Page /> 就是 ThemeProviderchildren,它会被渲染在 Provider 内部,因此 Page 及其所有后代都能访问主题数据。

3.4 在组件中消费主题

Header 组件通过 useContext 获取主题和切换函数,并展示当前主题:

import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';

export default function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext); // 解构获取
  return (
    <div style={{ marginBottom: 24 }}>
      <h2>当前主题:{theme}</h2>
      <button className="button" onClick={toggleTheme}>切换主题</button>
    </div>
  );
}

Page 组件只需正常渲染 Header,无需传递任何 props:

import Header from '../components/Header';

export default function Page() {
  return (
    <div style={{ padding: 24 }}>
      <Header />
    </div>
  );
}

3.5 通过 CSS 变量实现主题样式

为了实现主题切换的样式,我们在 theme.css 中定义了两套 CSS 变量,并通过 data-theme 属性选择器切换:

:root {
  --bg-color: #ffffff;
  --text-color: #222;
  --primary-color: #1677ff;
}

/* 当 html 元素有 data-theme="dark" 时,覆盖变量 */
[data-theme='dark'] {
  --bg-color: #141414;
  --text-color: #f5f5f5;
  --primary-color: #4e8cff;
}

body {
  margin: 0;
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: all 0.3s;
}

.button {
  padding: 8px 16px;
  background: var(--primary-color);
  color: #fff;
  border: none;
  cursor: pointer;
}

index.css 中引入:

@import './theme.css';

工作原理

  • :root 定义默认(亮色)主题的 CSS 变量。
  • [data-theme='dark'] 定义暗色主题的变量,覆盖同名变量。
  • useEffect 更新 <html>data-theme 属性时,对应的 CSS 变量生效,页面颜色自动更新。

效果:点击按钮,主题状态变化,useEffect 触发,data-theme 改变,CSS 变量切换,所有使用这些变量的样式都会平滑过渡。

动态效果图

屏幕录制 2026-02-25 213811.gif

打开我们查看vue conponents插件可以看到我们元素的结构

image.png

4. 深入理解 Context

4.1 children 的作用是什么?

ThemeProvider 中,我们看到了 {children}children 是 React 的一个特殊 prop,它代表组件标签之间的内容。例如:

<ThemeProvider>
  <Page />   {/* 这里的 <Page /> 就是 children */}
</ThemeProvider>

等价于 <ThemeProvider children={<Page />} />。在 ThemeProvider 内部,通过 {children} 将传入的内容渲染出来,同时保持 Provider 的包裹作用。这种模式让我们可以封装自己的 Provider,灵活地应用到任何组件树上,而无需硬编码子组件。

4.2 Provider value 的稳定性与性能优化

每次 ThemeProvider 重新渲染时,value 对象 {{ theme, toggleTheme }} 都会重新创建,即使 theme 没有变化。这会导致所有使用 useContext(ThemeContext) 的组件不必要的重新渲染。优化方法是使用 useMemo 缓存 value:

import { useMemo } from 'react';

// 在 ThemeProvider 内部
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return (
  <ThemeContext.Provider value={value}>
    {children}
  </ThemeContext.Provider>
);

这样只有当 themetoggleTheme 变化时,value 才会改变,从而避免不必要的渲染。

4.3 多个 Context 的使用

如果应用中有多个独立的数据需要共享,可以创建多个 Context。例如,用户信息和主题可以分开:

<UserContext.Provider value={user}>
  <ThemeContext.Provider value={{ theme, toggleTheme }}>
    <Page />
  </ThemeContext.Provider>
</UserContext.Provider>

在组件中分别使用 useContext 获取所需数据。将不常一起变化的数据分开,可以减少不必要的渲染。

4.4 Context 的默认值

createContext(defaultValue)defaultValue 只在组件没有匹配到任何 Provider 时使用。如果组件被 Provider 包裹,即使 Provider 的 valueundefined,也不会使用默认值。

4.5 什么时候使用 Context?

  • 主题、用户信息、语言偏好等全局数据。
  • 跨多层组件共享的状态
  • 替代繁琐的 prop drilling

但不要过度使用:对于频繁变化的数据(如表单输入),频繁的 Context 更新会导致所有消费者重新渲染,此时可以考虑更细粒度的状态管理方案(如 Zustand、Redux Toolkit)或拆分 Context。


5. 总结

通过本文的两个例子,我们完整地学习了 React Context 的用法:

  1. 从 Prop Drilling 问题出发,理解了为什么需要 Context。
  2. 用户信息共享:展示了如何用 Context 传递静态数据,消除中间传递。
  3. 主题切换:结合 useStateuseEffect,实现了动态状态的全局共享,并利用 CSS 变量切换主题。
  4. 深入理解:解释了 children 的作用、性能优化、多个 Context 等进阶知识点。

Context 是 React 内置的轻量级状态共享方案,掌握它能让我们更优雅地组织组件间的数据流。希望这篇文章能帮助你彻底弄懂 Context,并在实际项目中灵活运用。


如果你觉得这篇文章对你有帮助,欢迎点赞、评论、收藏!更多 React 进阶知识,请关注我的后续文章。

TS 基础扫盲:类型、接口、类型别名在业务代码里的最小集合

作者 SuperEugene
2026年2月25日 21:19

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、开篇:为什么要关心 TS 类型?

日常业务里经常会遇到:

  • 类型报错Object is possibly 'undefined'Type 'string' is not assignable to type 'number'
  • 不知道选什么anyunknowninterfacetype 什么时候用?
  • 写了很多 TS 却像在写 JS:到处用 any,类型形同虚设

TypeScript 的核心是「类型约束」,把很多问题在编译期暴露出来。但很多人要么写太多类型最后变玄学,要么只会 any,形同 JS。

这篇文章不讲特别深的底层原理,而是围绕:平时写业务时该怎么选、为什么这么选、容易踩哪些坑。从基础类型 → 接口 → 类型别名 → 实战选型 → 踩坑,一次性理清。

二、基础类型扫盲

先把日常最常用的 5 个类型搞清楚。

类型 含义 典型用途
string 字符串 文案、id、枚举值
number 数字(含整数、浮点、NaN) 数量、金额、分页
boolean 布尔 开关、状态
any 任意类型,不做检查 兼容老代码、临时兜底
unknown 任意类型,但必须先检查再用 比 any 更安全的兜底

2.1 string / number / boolean

这三个是基础原始类型,和 JS 里的用法一致,只是加了一层类型标注:

// 变量声明时标注类型
const name: string = '张三';
const age: number = 25;
const isActive: boolean = true;

// 函数参数和返回值
function greet(name: string): string {
  return `你好,${name}`;
}

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

业务里怎么用:接口返回值、表单字段、状态开关,优先用这三个而不是 any

2.2 any:最自由也最危险

any 表示「任意类型」,TS 不再做类型检查。

let data: any = 'hello';
data = 123;        // OK
data = { a: 1 };   // OK
data.toUpperCase(); // 编译通过,但运行时报错!data 实际是 number

问题any 会关闭类型检查,等于回到裸写 JS,很容易在运行时才发现错误。

适用场景

  • 临时接入老接口、第三方库,还没时间写类型
  • 快速迁移 JS 项目到 TS 时的过渡
  • 已经用 try-catch 等做了安全兜底

建议:能不用就不用,用的话尽量加注释说明原因。

2.3 unknown:比 any 更安全的兜底

unknown 也表示「任意类型」,但使用时必须先「收窄」类型,否则不能直接用。

let data: unknown = getFromApi(); // 不知道接口返回什么

// 直接调用会报错
// data.toString();  // Error: 'data' is of type 'unknown'

// 先判断类型再使用
if (typeof data === 'string') {
  console.log(data.toUpperCase()); // OK
} else if (typeof data === 'object' && data !== null && 'name' in data) {
  console.log((data as { name: string }).name); // 收窄后可安全使用
}

和 any 的对比

特性 any unknown
可直接调用方法 ❌ 必须先收窄
可赋给任意类型
类型安全 有(需检查后才用)

建议:拿不到确切类型时,用 unknown 代替 any,通过 typeofin、类型守卫等方式收窄后再用。

三、interface:描述对象形状

interface 用来描述「对象长什么样」:有哪些属性、什么类型、哪些可选。

3.1 基本用法

// 定义用户接口
interface User {
  id: number;
  name: string;
  age?: number;  // 可选属性
}

// 使用
const user: User = {
  id: 1,
  name: '张三'
  // age 可省略
};

3.2 可选属性、只读属性

interface Config {
  readonly apiUrl: string;  // 只读,不能改
  timeout?: number;         // 可选
}

const config: Config = { apiUrl: 'https://api.example.com' };
// config.apiUrl = 'xxx';  // Error: 只读

3.3 继承

interface BaseUser {
  id: number;
  name: string;
}

interface AdminUser extends BaseUser {
  role: 'admin';
  permissions: string[];
}

const admin: AdminUser = {
  id: 1,
  name: '管理员',
  role: 'admin',
  permissions: ['read', 'write']
};

3.4 索引签名(动态属性)

// 属性名是 string,值是 number
interface StringMap {
  [key: string]: number;
}

const map: StringMap = {
  a: 1,
  b: 2
};

业务场景:后端返回的用户、列表项、配置对象等,用 interface 描述结构最合适。

四、type 类型别名:给类型起个名字

type 用来给任意类型起别名,可以是基础类型、对象、联合类型、函数等。

4.1 基本用法

// 基础类型别名
type UserId = number;
type UserName = string;

// 对象类型
type User = {
  id: UserId;
  name: UserName;
};

// 联合类型(常见于业务)
type Status = 'pending' | 'success' | 'error';
type Theme = 'light' | 'dark';

4.2 联合类型、交叉类型

// 联合:A 或 B
type Result = { success: true; data: any } | { success: false; error: string };

// 交叉:A 且 B 的属性合并
type WithTimestamp = User & { createdAt: Date };

4.3 函数类型

type OnChange = (value: string) => void;
type FetchUser = (id: number) => Promise<User>;

业务场景:状态枚举、回调类型、联合类型等,用 type 更合适。

五、interface vs type:怎么选?

这是问得最多的一个问题,先看核心区别:

特性 interface type
声明合并 ✅ 同名可合并 ❌ 同名会报错
继承 extends & 交叉类型
适用对象 对象结构 任意类型
扩展对象 容易 容易
联合/交叉 不常用 常用

5.1 声明合并(interface 独有)

// interface 同名会合并
interface User {
  name: string;
}
interface User {
  age: number;
}
// 等价于 { name: string; age: number }

// type 同名会报错
type User = { name: string };
type User = { age: number };  // Error: 重复声明

业务含义:写插件、扩展第三方类型定义时,用 interface 可以多次补充属性;而 type 只能定义一次。

5.2 选型建议

用 interface

  • 描述对象结构(用户、配置、接口返回值等)
  • 有继承需求(如 extends BaseUser
  • 可能被第三方或插件扩展(依赖声明合并)

用 type

  • 联合类型:'pending' | 'success' | 'error'
  • 交叉类型:User & { role: string }
  • 函数类型:(id: number) => Promise<User>
  • 元组、复杂组合类型

实践中:对象结构优先 interface,其它复杂类型用 type。两者都能描述对象时,很多团队会统一用 interface,可读性更好。

六、实战场景:该怎么写

6.1 接口返回值

// 用 interface 描述
interface UserListItem {
  id: number;
  name: string;
  avatar?: string;
  status: 'active' | 'inactive';
}

interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// 使用
async function fetchUserList(): Promise<ApiResponse<UserListItem[]>> {
  const res = await axios.get('/api/users');
  return res.data;
}

6.2 表单、状态枚举

// 用 type 做联合
type FormStatus = 'draft' | 'submitting' | 'success' | 'error';
type SortOrder = 'asc' | 'desc';

interface FilterState {
  status: FormStatus;
  sortBy: string;
  sortOrder: SortOrder;
}

6.3 事件回调

type OnSearch = (keyword: string) => void;
type OnPageChange = (page: number, size: number) => void;

interface TableProps {
  onSearch: OnSearch;
  onPageChange: OnPageChange;
}

6.4 拿不准类型时用 unknown

async function fetchData(url: string): Promise<unknown> {
  const res = await fetch(url);
  return res.json();
}

// 使用时必须收窄
const data = await fetchData('/api/config');
if (data && typeof data === 'object' && 'theme' in data) {
  const theme = (data as { theme: string }).theme;
  // 安全使用
}

七、踩坑指南

原因 建议
到处用 any,类型失效 any 关闭类型检查 尽量用 unknown,或用具体类型
Object is possibly 'undefined' 可能为 undefined 却直接访问 可选链 obj?.propif 判断、! 断言
interface 和 type 混用一团 团队没约定 对象用 interface,联合/函数用 type
对象字面量多了属性报错 多余属性检查 用变量接收再传入,或加索引签名
第三方库没有类型 老库、非 TS 编写 .d.ts@ts-ignore,标注原因

7.1 多余属性检查

interface User {
  id: number;
  name: string;
}

// 直接传字面量时,多了属性会报错
// createUser({ id: 1, name: '张三', age: 18 });  // Error

// 用变量接收再传则不会(会按结构兼容)
const user = { id: 1, name: '张三', age: 18 };
createUser(user);  // OK

7.2 类型断言要谨慎

// as 断言:你说它是什么,TS 就信
const data = getData() as User;  // 若实际不是 User,运行时可能崩

// 更安全的做法:用类型守卫
function isUser(obj: unknown): obj is User {
  return obj !== null && typeof obj === 'object' && 'id' in obj && 'name' in obj;
}

八、小结

概念 一句话 典型场景
string/number/boolean 基础类型,优先用 接口字段、函数参数、状态
any 任意类型,无检查 临时兜底、兼容老代码,少用
unknown 任意类型,用前须收窄 拿不准类型时的安全选择
interface 描述对象结构 用户、配置、接口返回值
type 类型别名,可联合/交叉 状态枚举、函数类型、复杂组合

记住三点:

  1. 能用具体类型就不用 any,拿不准就用 unknown 再收窄。
  2. 对象结构用 interface,联合/函数/复杂类型用 type
  3. 业务里够用就行,不必一开始就追求完美,先让类型系统帮你兜住大部分错误。

把类型选对,编译期就能发现很多问题,后面的重构和维护都会轻松很多。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

❌
❌