三年前我第一次把 Vue 的响应式原理图背得滚瓜烂熟,觉得 Proxy、依赖收集、虚拟 DOM 这些东西我算是懂了。直到有一次在代码评审里被问到“一次状态变更后,从 setValue 到页面重绘,中间到底发生了哪几步”,我发现自己只能说出个大框架,细节全是模糊的。
所以就有了 vue.6 这个项目。先说清楚,它跟官方 Vue 6 没有任何关系,是我给自己写的一个微型前端运行时起的代号,版本号正好走到 0.6。这个项目不做模板编译器、不做脚手架,只保留三样核心:响应式系统、虚拟节点、渲染器。目标只有一个——让人花一个周末就能完整复现“数据变 -> 依赖触发 -> DOM 更新”这条链路。这篇文章就把这套轮子的设计思路、关键代码和踩坑记录全部拿出来,不管你是刚入行想搞懂框架原理,还是准备面试想答透响应式细节,应该都能有点收获。
1. vue.6 到底想验证什么:为什么我不直接啃源码
1.1 读源码和写源码,是两种完全不同的学习路径
很多人想搞懂 Vue 内部机制,第一反应是打开 node_modules 里的源码。我也不例外,但实际读下来发现一个问题:官方源码为了性能和兼容性,做了大量抽象和分支处理,比如优化模式的标记、位运算、各种 cache 策略。这些优化代码会把最核心的骨架埋得很深,你很容易看着看着就迷失在函数调用栈里。
自己写一个极简版本就不一样。写 vue.6 时我可以主动放弃所有优化,只保留最直白的实现。比如依赖收集就是三张表:WeakMap 存对象、Map 存属性、Set 存副作用函数。没有分支、没有兼容逻辑,每一行代码都能对上一条原理。对我来说,这才是真正把“知道”变成“理解”的过程。
1.2 项目边界:我明确砍掉了哪些功能
动手之前我先列了一张范围表,避免做成一个四不像的大杂烩。
| 模块 | 实现程度 | 说明 |
|---|---|---|
| 响应式系统(reactive / ref / computed) | 完整实现 | 核心目标,必须做扎实 |
| effect 副作用调度(同步 / 微任务队列) | 完整实现 | 控制更新时机,必须做 |
| 虚拟节点 h 函数与渲染器 mount | 完整实现 | 能跑通基础渲染 |
| patch 更新与 keyed diff | 基础版 | 支持增删、移动,不做双端 diff |
| 组件化(props + 事件派发) | 简易版 | 只支持普通函数组件 |
| 模板编译器 / SFC 解析 | 明确放弃 | 运行时只认 render 函数 |
| SSR / 服务端渲染 | 明确放弃 | 与主题无关 |
| 自定义指令 / keep-alive / 过渡动画 | 明确放弃 | 留作后续扩展 |
这张表最大的价值不是列功能,而是告诉我每一条“不做”的理由。比如模板编译,Vue 真正跑在浏览器里的核心其实根本不关心模板长什么样,模板编译后的产物就是 render 函数,所以 vue.6 直接从 render 函数开始反而是抓住了本质。
1.3 我给它设计的验收标准
一个微型框架好不好,不能只看能不能跑,我给 vue.6 定了三条验收标准:
- 计数器:点击按钮 count 加一,页面只更新一次,不能闪烁。
- 列表:数组增删时,DOM 只做最小变动,绑定的事件不能错位。
- 计算属性:读取两次 value,中间没数据变化时,getter 只能执行一次。
这三条看着简单,实际上把响应式、调度、diff 三个核心模块全部串起来了。后面每一章讲到的东西,基本都能回到这三条标准上验证。
2. 第零步:先放弃模板编译,把渲染函数作为唯一入口
2.1 为什么绕开模板反而更容易看清框架本质
Vue 使用者在业务里写的是 template,但 template 对运行时来说是不存在的。它会被编译器解析成 AST,再生成一段 render 函数代码传给运行时。换句话说,浏览器里根本没有“模板”这个东西,只有 render 函数在执行后返回的虚拟节点树。
对 vue.6 来说,最干净的做法就是把模板编译全部砍掉,让使用者直接手写 render 函数。这在一开始有点反直觉,但好处非常明显:复杂度和框架核心逻辑解耦了。我可以在不看任何编译代码的情况下,完整理解运行时的工作过程。理解之后再去看编译器,会发现编译器只是“生成 render 函数的工具”,心理负担小很多。
2.2 虚拟节点长什么样:h 函数的最小实现
虚拟节点(vnode)本质上就是一个描述 DOM 的普通对象。vue.6 里我把它设计成这样:
{ type: 'div', // 标签名或组件 props: { id: 'app' }, // 属性、事件等,统一放这里 children: ['hello'], // 字符串子节点或 vnode 数组 key: null, // 列表 diff 时使用 el: null // 挂载后保存真实 DOM 引用 }对应的 h 函数也很简单:
function h(type, props, ...children) { return { type, props: props || {}, children: children.flat(), key: props && props.key ? props.key : null, el: null }; }这里有两个细节值得说一下。第一个是 children.flat(),因为我们允许嵌套调用 h,很容易产生数组套数组的情况,摊平一层能让渲染器少处理很多边界情况。第二个是 key 的处理,很多初学虚拟 DOM 的人会忽略 key,但它是后面列表 diff 的核心,必须从一开始就在 vnode 结构里留出位置。
2.3 从 h 函数到一棵树的拼装思路
写业务的时候,render 函数看起来就是手工拼接树:
render() { return h('div', { class: 'container' }, [ h('h1', null, '标题'), h('button', { onClick: handleClick }, '点我') ]); }h 函数执行完,得到的就是一棵纯数据结构的 vnode 树。这一步非常朴素,但它把“界面长什么样”和“界面怎么更新”彻底分开了。渲染器只需要关心这棵树,不需要知道用户写的是模板还是手写 JSX 或者其他什么 DSL。我现在回头看,这个切入点比一开始就搞模板编译要高得多。
3. 响应式系统:effect / track / trigger 这三个角色的分工
3.1 依赖收集的本质:谁读了这个值,就把谁记下来
响应式系统是整个 vue.6 的发动机。我最初以为它很复杂,拆开看其实就两个动作:读的时候记录依赖,写的时候通知依赖。
为了记录依赖,需要一张全局的依赖表。这张表是三层结构:
- 第一层:WeakMap,键是被代理的原始对象,值是下一层 Map。
- 第二层:Map,键是属性名,值是第三层 Set。
- 第三层:Set,存放所有读取过该属性的副作用函数。
选 WeakMap 而不是普通 Map 是有讲究的。WeakMap 对键是弱引用,如果某个被代理对象不再被使用,它整条依赖记录都会被垃圾回收,不需要手动清理。这个选择在写小项目时不觉得,但它是从真实生产经验里沉淀出来的设计,直接用就好。
3.2 reactive 的最小实现
我一开始的 reactive 只做了 get 和 set 两个拦截:
const targetMap = new WeakMap(); let activeEffect = null; function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) { depsMap = new Map(); targetMap.set(target, depsMap); } let dep = depsMap.get(key); if (!dep) { dep = new Set(); depsMap.set(key, dep); } dep.add(activeEffect); activeEffect.deps.push(dep); } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const dep = depsMap.get(key); if (!dep) return; dep.forEach(effectFn => effectFn()); } function reactive(target) { return new Proxy(target, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { const res = Reflect.set(target, key, value, receiver); trigger(target, key); return res; } }); }这里有一个初学者很容易忽略的点:为什么一定要用 Reflect.get / Reflect.set,而不是直接 target[key]?因为如果对象的属性是 getter,直接访问会让 this 指向原始对象,导致依赖收集失效;Reflect 能保证 this 指向代理对象。这个细节我一开始没注意,后来发现 computed 里的嵌套读取总是收集不到依赖,排查了半天才反应过来。
3.3 effect 的自动订阅与依赖清理
光有 reactive 还不够,还得有东西去订阅。effect 函数的作用就是“执行一次副作用,并自动收集它读取过的所有响应式依赖”。
function effect(fn) { const effectFn = () => { cleanup(effectFn); activeEffect = effectFn; const res = fn(); activeEffect = null; return res; }; effectFn.deps = []; effectFn(); return effectFn; } function cleanup(effectFn) { effectFn.deps.forEach(dep => dep.delete(effectFn)); effectFn.deps.length = 0; }每次执行前先把上一次收集的依赖删掉,再重新跑一遍,重新收集。这样能保证依赖集合永远是“此刻真正需要的依赖”。如果不做清理,条件渲染里的分支一旦切换,旧依赖还留在 Set 里,会导致多余更新。这个清理动作是响应式系统里最容易漏的一环,但它恰恰决定了更新次数是否精确。
3.4 computed:惰性求值加脏标记
computed 的实现比想象中简单。关键就两个状态:一个缓存值,一个脏标记。依赖变化时只把脏标记置为 true,不立即重算;真正读取 value 时才检查脏标记,重算并更新缓存。
function computed(getter) { let value; let dirty = true; const effectFn = effect(() => { if (dirty) { value = getter(); } }, { lazy: true, scheduler() { dirty = true; } }); return { get value() { if (dirty) { dirty = false; return effectFn(); } return value; } }; }这里 effect 需要支持第二个参数对象,包含 lazy 和 scheduler。lazy 让副作用先不执行;scheduler 让依赖变化时不直接执行副作用,而是走自定义调度。核心思想是:读一次才算一次,读完就锁住,依赖变化只解锁不算。
4. 渲染器:把 vnode 变成真实 DOM 的挂载与 patch 流程
4.1 挂载阶段:从零创建真实 DOM
响应式系统负责“什么时候更新”,渲染器负责“怎么更新”。渲染器第一步是挂载,也就是把 vnode 树变成真实 DOM 树。vue.6 里 mount 的逻辑非常直白:
function mount(vnode, container) { const el = document.createElement(vnode.type); vnode.el = el; if (vnode.props) { for (const key in vnode.props) { if (key.startsWith('on')) { const eventName = key.slice(2).toLowerCase(); el.addEventListener(eventName, vnode.props[key]); } else if (key === 'style') { Object.assign(el.style, vnode.props.style); } else { el.setAttribute(key, vnode.props[key]); } } } if (typeof vnode.children === 'string') { el.textContent = vnode.children; } else if (Array.isArray(vnode.children)) { vnode.children.forEach(child => mount(child, el)); } container.appendChild(el); }挂载阶段把 el 引用存回 vnode 上非常重要,因为后面 patch 时需要拿旧节点的 el 做替换和复用。事件处理也在这个阶段绑定,属性处理上我特意把 style 和其他属性分开,是因为 style 直接 setAttribute 在某些浏览器上会有兼容问题,Object.assign 到 style 对象上更稳。
4.2 patch 的最小更新策略
patch 的任务是:给定旧 vnode 和新 vnode,尽量复用真实 DOM,只更新变化的差异。vue.6 里我采用了一个保守但正确的策略:同类型节点才复用,类型不同直接整个替换。
function patch(oldNode, newNode) { if (oldNode.type !== newNode.type) { oldNode.el.replaceWith(createNode(newNode)); return; } const el = (newNode.el = oldNode.el); // 更新 props const oldProps = oldNode.props || {}; const newProps = newNode.props || {}; for (const key in newProps) { if (newProps[key] !== oldProps[key]) { setProp(el, key, newProps[key]); } } for (const key in oldProps) { if (!(key in newProps)) { removeProp(el, key); } } // 更新 children if (typeof newNode.children === 'string') { if (newNode.children !== oldNode.children) el.textContent = newNode.children; } else if (Array.isArray(newNode.children)) { patchChildren(el, oldNode.children, newNode.children); } }这里没有做夸张的性能优化,但有一个很重要的点:新旧 children 的 diff 单独抽成 patchChildren。这样逻辑清晰,后面想升级 diff 算法时只需要动一个函数。
4.3 列表 diff:为什么 key 是灵魂
列表渲染是最容易出错的场景。vue.6 里我实现了简化版 keyed diff,核心思路是:先根据新 children 建一个 key 到 index 的映射表,然后遍历旧 children,看 key 是否还存在,不存在就删除,存在就 patch,最后再处理新增和移动。
function patchChildren(parent, oldChildren, newChildren) { const oldMap = new Map(); oldChildren.forEach((child, index) => oldMap.set(child.key ?? index, { child, index })); const newMap = new Map(); newChildren.forEach((child, index) => newMap.set(child.key ?? index, child)); newChildren.forEach((newChild, index) => { const match = oldMap.get(newChild.key ?? index); if (match) { patch(match.child, newChild); } else { mount(newChild, parent, index); } }); oldChildren.forEach((oldChild, index) => { if (!newMap.has(oldChild.key ?? index)) { oldChild.el.remove(); } }); }这是一版非常粗略的 diff,没有处理节点移动顺序。但即便如此,key 的作用已经体现得很明显:用 key 作为身份标识,渲染器才知道哪个旧节点对应哪个新节点。如果用数组 index 当 key,列表头部插入一个元素时,所有节点都会认错爸爸,导致状态错乱。这个问题我后面在第六章专门讲。
4.4 事件绑定的一个性能陷阱
写渲染器时我还踩过一个不算 bug 但很影响性能的坑。最初在更新 props 时,我遇到 onClick 变化就 removeEventListener 再 addEventListener,频繁触发时 DOM 操作非常夸张。后来我换了个思路:每个节点只绑定一次事件处理函数,然后用一个内部容器对象统一存事件回调,事件触发时再从容器里取最新的回调执行。
这样的事件代理思路跟 Vue 官方事件缓存的本质是一样的。对于 vue.6 这种教学项目,可能感知不到性能差距,但养成“避免销毁重建”的习惯很重要,后面做任何 UI 库都会受益。
5. 把响应式和渲染器接起来:跑通一个完整 Demo 的全过程
5.1 关键接缝:调度器为什么要存在
有了响应式和渲染器,还差最后一个接缝:状态变化触发的是 effect,而渲染本身也是 effect。如果每次状态变化都同步执行渲染,就会出现改动三次 state 却渲染三次 DOM 的情况。真实框架不可能接受这种浪费,所以需要调度器把渲染任务合并到同一个微任务里。
const jobQueue = new Set(); let isFlushing = false; function queueJob(job) { jobQueue.add(job); if (isFlushing) return; isFlushing = true; Promise.resolve().then(() => { jobQueue.forEach(fn => fn()); jobQueue.clear(); isFlushing = false; }); }jobQueue 用 Set 而不是数组,天然实现去重:同一个渲染任务只进一次队列。isFlushing 标志位防止重复创建微任务。这个设计虽然短,但它把“同步改数据、异步批量更新”这件事讲清楚了。
5.2 createApp 与完整接线
有了调度器,vue.6 的入口 createApp 就可以把所有模块接起来:
function createApp(rootComponent) { return { mount(container) { const context = { setupState: rootComponent.setup ? rootComponent.setup() : {} }; let prevNode = null; function render() { const vnode = rootComponent.render.call(context, context.setupState); if (prevNode === null) { mount(vnode, container); } else { patch(prevNode, vnode); } prevNode = vnode; } effect(render, { scheduler: () => queueJob(render) }); } }; }这段代码就是整套框架的连接点。第一次调用 mount 走挂载分支,后续状态变化时 effect 被触发,但不会立即执行渲染,而是进入调度器;同一个微任务里的多次状态变更,只会让 render 执行一次。
5.3 计数器 Demo 的完整运行时序
为了验证接线是否正确,我写了一个很经典的计数器:
const app = createApp({ setup() { const count = ref(0); return { count }; }, render(context) { return h('div', null, [ h('span', null, `计数: ${context.count.value}`), h('button', { onClick: () => { context.count.value++; } }, '加一') ]); } });这里需要说明的是,ref 在 render 里必须通过 .value 读取,因为 ref 本质是一个带劫持的对象。运行时序是这样的:
- mount 阶段执行一次 render,生成 vnode 树并挂载。
- render 里读取 context.count.value,activeEffect 记录下当前渲染函数依赖了 count。
- 用户点击按钮,count.value++ 触发 setter。
- set 拦截器调用 trigger,找到依赖 Set 里的渲染 effect。
- 调度器把渲染任务丢进 jobQueue,Promise 微任务里统一执行。
- 微任务执行 render,生成新 vnode,再走 patch 更新已有 DOM。
整个过程走过来,我对“一次点击,页面只更新一次”这件事有了一种实感,不再是死记硬背的结论。
5.4 调度器去重的边界条件
调度器虽然只有十多行代码,但边界条件不少。最典型的场景:一个 effect 依赖了两个响应式属性,两次赋值发生在同一个同步块里。
state.a = 1; state.b = 2;如果没有 Set 去重,render 会被加入任务队列两次,微任务执行时页面更新两遍。用 Set 之后,第二次加入时发现已经存在,自动忽略。这个细节直接决定了计数器验收标准里的“只更新一次”能不能通过。
6. 在 vue.6 的验证阶段踩过的三个坑,以及我怎么排掉的
6.1 坑一:一次更新跑了两次 render,问题出在依赖清理时机
计数器写好后,我发现点击一次按钮,render 居然执行了两次。第一次怀疑是事件绑了两次,检查后发现不是。加日志排查,发现 effect 的依赖集合里同一个渲染函数出现了两份。
根因在 cleanup 的时机:我在 effectFn 一开始做 cleanup,删掉旧依赖;然后才执行 fn 重新收集。如果 fn 执行过程中读了同一个 key 两次,比如文本节点和事件回调里都访问了 count,track 会把这个 effect 加到同一个 dep Set 里。Set 本来应该去重,但因为我上一轮还没删干净,旧依赖和新依赖同时在 Set 里,触发时就跑两次。
排查过程很枯燥:先打印 dep Set 大小,发现点击后 size 变大了;再在 track 里加断点,确认同一个 effect 被 add 了两次;最后把 cleanup 提前到 track 之前,并在每次收集时检查当前 effect 是否已经在 dep 中。结论是:依赖清理必须放在“上一次收集的依赖被使用之前”,同时 track 里要防止重复 add。修完之后效果立竿见影,点击一次精确执行一次。
6.2 坑二:列表用 index 当 key,倒序数据后事件全部错位
列表渲染我一开始偷懒,没要求用户传 key,直接拿 index 当兜底标识。结果在做一个 todo 删除的 Demo 时出问题了:删除第二项后,第三项的按钮点击触发的是第二项的回调。
原因很简单:index 是位置标识,不是身份标识。删除第二项后,原来的第三项变成了 index 2,渲染器认为它还是原来的第三项,于是直接把状态对应的回调改掉,整棵子树的状态全错位了。
修复方式不是写更复杂的 diff,而是从规范层面规定:列表必须有稳定 key,没有就不允许用 index 兜底。vue.6 的 patchChildren 里我删掉了 index 兜底逻辑,改成 key 缺失时直接告警。这个决定看起来有点“不讲理”,但它逼着使用者在数据源就给每个条目一个唯一 id,反而避免了各种诡异 bug。
6.3 坑三:props 即改即传导致的中间态闪烁
最后一个坑比较隐蔽。我给组件 Props 传了一个对象,子组件在 setup 里面直接读取 props 的某个字段去拼样式。父组件更新数据时,props 对象同步变了,子组件的渲染任务还没来得及进微任务队列,子组件内部读取到的已经是新值了,但实际 DOM 还是旧值。
表现出来就是界面中间态闪烁:一个数字刚显示出来,马上跳到另一个数字,中间明显闪了一下。根因是“数据同步更新”和“DOM 异步更新”之间出现了一个时间差,任何在渲染函数之外直接读响应式数据并加工的逻辑,都会踩到这个坑。
修复方案是统一规范:外部数据和内部派生状态必须分开。父组件的 props 只能在 render 函数内部读取,内部派生状态一律用 computed,让脏标记跟着调度器一起走。这样一来,任何中间态读取得到的都是缓存里的旧值,直到统一渲染时才拿到新值。这个坑教给我的不是补丁怎么写,而是框架的“更新时机”必须是一个整体策略,不能边边角角地打补丁。
7. 跑完这套轮子之后,我对前端框架的认知发生了什么变化
最后说说我自己的体会。写 vue.6 之前,我觉得框架是一堆黑盒魔法的组合;写完 0.6 版本之后,我发现核心链路其实就六七个模块:响应式、调度、虚拟节点、挂载、patch、props 流转。每个模块单独拎出来都不难,难的是模块之间的契约设计。比如 effect 返回的必须是“可清理、可重新执行”的函数,渲染器必须统一通过 el 引用操作真实 DOM,调度器必须对任务去重——这些契约一旦定好,整个系统就稳了。
对我个人来说,最大的收获是调试能力变强了。以前业务代码出问题,我只能靠经验猜;现在我能直接沿着依赖表、任务队列、vnode 树一路查下去,定位到具体是哪一次 trigger、哪一个 key 触发了更新。这种能力在平时的开发里非常值钱,尤其是面对那些“偶发性状态错乱”的 bug。
如果你也想试试,我的建议是从小处开始:先写一个 ref,再写一个 effect,然后写一个只支持字符串子节点的 mount。不要一上来就做模板编译和双端 diff,那会把战线拉得很长。等核心链路跑通了,再回头加功能,你会发现每一步都有清晰的落点。vue.6 这个名字对我来说就是一段比较长的周末实验的记录,它证明了框架原理并不神秘,只是需要有人愿意一层层拆开来看。