
三年前我第一次把 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.valueactiveEffect 记录下当前渲染函数依赖了 count。用户点击按钮count.value 触发 setter。set 拦截器调用 trigger找到依赖 Set 里的渲染 effect。调度器把渲染任务丢进 jobQueuePromise 微任务里统一执行。微任务执行 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 两次比如文本节点和事件回调里都访问了 counttrack 会把这个 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 这个名字对我来说就是一段比较长的周末实验的记录它证明了框架原理并不神秘只是需要有人愿意一层层拆开来看。