
从 mini-vue 一路写到组件挂载我卡在一个特别微妙的地方页面已经能通过 render 函数渲染出来了但数据一变界面纹丝不动。说白了组件还停留在“一次性渲染”阶段响应式系统收集的依赖没被真正使用起来。把这一步打通mini-vue 才从“首屏渲染的玩具”变成有更新能力的雏形框架。这篇我就来完整聊聊组件更新功能的实现思路和踩坑过程核心集中在 effect scheduler 的调度链路、instance.update 的创建、props 变更判断shouldUpdateComponent以及最基础的 patch 修补动作。这篇内容适合刚写完 mountComponent、正准备让组件响应数据变化的朋友。我会按一个真实的运行顺序来写尽量把每一步“为什么这样做”讲透同时给出可以直接抄进自己仓库的代码块。过程中我也踩了不少坑后面会单独整理一节排查实录都是调试时真会遇到的情况。1. 组件更新到底在更新什么先想清楚场景再动手1.1 组件更新的触发源头数据变了依赖它的组件要重跑先想一个最简单场景一个 Counter 组件render 函数读了一个count变量页面上显示count: 0。点击按钮让count我们希望界面自动变成 1。这个诉求拆开来看就是数据变化后组件需要“重新执行一次 render 函数”用新的返回值去对比旧值再更新真实 DOM。但这里有个容易绕晕的概念组件并不是“整个刷新”而是先重跑 render 拿到新的虚拟 DOM 树再通过一定策略把新旧虚拟 DOM 的差异落到真实 DOM 上。所以组件更新的直接动作是重跑 render而落在真实 DOM 上的动作是虚拟 DOM 的补丁patch。这里为什么要用虚拟 DOM 中间层因为直接拿真实 DOM 做比较代价太高而且很难判断哪些属性变了、哪些子节点该保留。虚拟 DOM 只是一层普通 JavaScript 对象比较对象属性的成本远低于操作浏览器 DOM。mini-vue 这一步的价值就是先把“重跑 render 最基础的 props/text 修补”跑通至于高效的 children diff是后面独立的一个话题。1.2 从挂载到更新render 函数的同一条路走两次在我的 mini-vue 实现里组件挂载和更新用的是同一个函数叫componentUpdateFn。初始化时走isMounted false的分支更新时走isMounted true的分支。这个设计跟 Vue 3 源码是一致的好处是 effect 只需要维护这一个更新函数将来数据变化时无论触发初始化还是更新都执行同一套渲染逻辑。两个分支做的事情差异很大挂载分支执行 renderpatch(null, subTree, container)得到一个全新的 DOM 树并把 subTree 存到instance.subTree。更新分支先取instance.subTree当作旧树再执行 render 得到新树然后patch(prevTree, nextTree)把旧树对应的 DOM 更新到新树对应的状态。为什么更新时不用重新创建整个真实 DOM因为大部分节点在两次渲染之间是相同的直接替换整个容器会导致组件内部状态丢失、性能浪费、焦点被重置还可能触发大量不必要的事件回调。所以更新的核心思路是“找出差异局部修补”而不是“整体推倒重来”。1.3 组件更新要解决的三个问题重新渲染、props 同步、DOM 修补梳理下来组件更新其实要解决三个层次的问题缺一不可第一render 函数要能重新执行。这颗发起点就是前面实现的响应式系统关键在于 effect 在初始化渲染时收集了依赖数据变化后能自动触发这个 effect。第二新 props 要给到子组件实例。父组件重渲染时会生成一个新的 vnodevnode 上有新的 props 对象但子组件实例还握着旧 props。如果直接用旧 props 渲染页面就会表现出一副“没更新”的样子。第三旧虚拟 DOM 要与新虚拟 DOM 对齐。这是 patch 的责任比较组件或元素的 props、children并操作真实 DOM。下面几个章节我会按这条链路逐个展开先说动力来源再进组件内部最后落到 patch。2. 更新的动力引擎响应式系统与调度器的联动2.1 回顾依赖收集effect 和 trigger 是如何牵线的mini-vue 的响应式系统在更早的章节中已经实现reactive用 Proxy 拦截 get 和 setget 时调用track收集依赖set 时调用trigger触发依赖。track会把当前正在执行的 effect 收集到targetMap - depsMap - dep这个三层结构里trigger则从这些结构中找到对应的 effect 集合。组件更新能跑起来的根基在这里初始化时执行setupRenderEffect里面会创建 effect并执行一次effect.run()。run 内部会设置全局activeEffect然后执行componentUpdateFn也就是执行 render 并挂载 DOM。render 函数读取响应式数据时track就会把当前 effect 记录到那几个响应式数据的依赖集合里。举个具体例子如果 render 里写了count 1那么count这个 key 对应的 dep 集合里就有这个组件的渲染 effect。将来任何地方执行countsetter 就会触发trigger让这个 effect 重新执行。这一步是整个组件更新机制的地基。2.2 调度器出场为什么不能直接跑 effect而要排队异步执行如果trigger直接调用effect.run()同步地让组件重新渲染有一个很明显的性能问题同一轮事件循环里你连续改了好几个响应式数据组件就会被渲染好几次。比如在一个函数里连续执行count.value 1; name.value x; title.value y如果每次 set 都立即触发渲染组件会跑三次 render、三次 patch实际上只需要最后一次结果。这就是为什么 Vue 3 的 effect 支持一个可选的scheduler参数当 trigger 触发时不直接执行 effect 的 fn而是执行 scheduler由调度逻辑决定“什么时候真正跑 fn”。可以把调度器理解成教练手里的换人名单场上有人举手要换人教练先记下来等到比赛暂停再统一处理如果同一时间好几个人要求换人也只会在暂停时一次性处理完。挂在组件身上的 scheduler 用一句话说就是把instance.update丢进一个全局任务队列然后用微任务去 flush 这个队列。我在scheduler.ts里的实现大概是这样的const jobQueue new SetFunction() const p Promise.resolve() let isFlushing false export function nextTick(fn?: Function) { return fn ? p.then(fn) : p } export function queueJobs(job: Function) { if (!jobQueue.has(job)) { jobQueue.add(job) } queueFlush() } function queueFlush() { if (isFlushing) return isFlushing true nextTick(flushJobs) } function flushJobs() { isFlushing false const jobs [...jobQueue] jobQueue.clear() for (const job of jobs) { job() } }这里用Set而不是普通数组就是为了天然去重。同一个instance.update在同一轮 tick 内只会被加入一次这是避免“一次修改触发多次渲染”的关键。2.3 从一次数据修改到页面刷新的完整链路梳理把整个链路串起来数据更新后实际发生的动作是这样的用户点击按钮执行count。count的 setter 被 Proxy 拦截调用trigger。trigger找到count对应的 dep 集合遍历里面的 effect。因为组件渲染 effect 绑定了 scheduler所以不直接执行componentUpdateFn而是执行 scheduler。scheduler 把instance.update加入 jobQueue同时注册微任务 flushJobs。当前同步代码执行完毕后微任务启动flushJobs 取出instance.update并执行。instance.update内部执行componentUpdateFn此时isMounted已经是 true走到更新分支。先处理instance.next持有的新 vnode同步 props。执行 render得到新的 subTree。调用patch(prevTree, nextTree)更新真实 DOM。这个链路里值得注意的一点是渲染并不是在数据改变那一刻同步发生的而是被推迟到了微任务里。第一次看源码时容易产生误解以为count之后界面立刻就会变化其实中间隔了一个微任务的调度。真正学习的时候建议在flushJobs和patch开头各打一个断点看调用顺序很快就能建立肌肉记忆。3. 组件自己更新setupRenderEffect 里的第二个分支3.1 instance.update 的创建effect 与组件更新的绑定组件要能自我更新必须先有一个“稳定可复用”的更新函数。这个函数在setupRenderEffect里被创建并且跟组件的渲染 effect 绑定。实现上我是这样写的function setupRenderEffect(instance, container) { const componentUpdateFn () { if (!instance.isMounted) { const subTree instance.render() patch(null, subTree, container, instance) instance.subTree subTree instance.isMounted true } else { const prevTree instance.subTree const nextTree instance.render() instance.subTree nextTree patch(prevTree, nextTree, container, instance) } } const effect new ReactiveEffect(componentUpdateFn, () { queueJobs(instance.update) }) instance.update effect.run.bind(effect) instance.isMounted false instance.subTree effect.run() }这里有个细节很关键instance.update effect.run.bind(effect)这一步必须在effect.run()之前完成吗严格来说初始化渲染阶段只会 track 依赖不会 trigger所以 scheduler 不会执行理论上顺序没有强制要求。但从可维护性角度看还是把instance.update先赋值再去跑首次渲染这样任何意外情况下的触发都能正确进入调度队列。3.2 更新分支的执行流程取旧树、跑新树、打补丁当instance.update被调度执行后componentUpdateFn会再次运行走到isMounted true的分支。这个分支做的事情可以拆成四步第一步取出instance.subTree作为旧的虚拟 DOM 树。之所以叫subTree是因为它只是组件 render 返回的那一棵子树组件本身是一个 vnode容器里挂载的则是这棵子树。第二步重新执行instance.render()。注意render 内部读取的响应式数据已经建立依赖所以只要相关数据变化这次执行就会拿到最新状态。第三步立刻把instance.subTree替换为nextTree。这个顺序最好别反。第四步调用patch(prevTree, nextTree, container, instance)对新旧子树递归进行对比。为什么第三步要先替换引用再去 patch因为 patch 是同步递归执行的在比较组件元素、处理子组件时可能会发生各种嵌套更新。如果instance.subTree一直指向旧树patch 过程中一旦有其他地方读取instance.subTree拿到的就是过期的 vnode导致逻辑错乱。提前替换能确保instance.subTree始终代表这一轮的最新渲染结果。3.3 一个不该省略的细节instance.subTree 什么时候替换这个问题我当初真踩过坑。最初写更新分支时我先 patch 再替换 subTreepatch(prevTree, nextTree, container) instance.subTree nextTree // 错误示例表面上看没问题但一旦子组件更新的 trigger 在 patch 过程中再次调度父组件的 update父组件的componentUpdateFn有可能读取到过期的subTree然后把不一致的旧树当作“最新状态”去做下一次 patch轻则重复 patch重则无限递归。Vue 3 源码里的做法就是先替换再 patch。这不是玄学而是对组件实例内部状态一致性的保护。改成本文给出的顺序之后我的调试报错明显减少。这里可以记一条经验凡是能保证“内部引用先更新”的地方就绝不要拖到 patch 之后。4. 组件因父组件而更新props 变化如何被精准捕获4.1 父组件重渲染时子组件 props 为什么是敏感地带组件不仅会因为自身读取的响应式数据变化而更新还会因为父组件重新渲染而收到新 props。比如父组件 render 里写了h(Child, { count: parentCount.value })父组件因为parentCount变化而重渲染时会创造一个新的子组件 vnode里面携带新的 props 对象。此时子组件实例本身并没有读取导致变化的那些响应式数据如果不做处理子组件会停留在旧 props 的渲染结果上。另一个容易被忽略的点是父组件每次重渲染都可能生成一个全新的 props 对象哪怕里面的值从语义上没有变化。但组件更新不能只判断 props 的引用是否变化而是要比较每个 key 对应的值是否真的不同。这就是shouldUpdateComponent存在的理由。4.2 shouldUpdateComponent 的实现与判断逻辑shouldUpdateComponent做的事情很简单浅层遍历新旧 vnode 的 props逐个比较值再检查旧 props 里有没有在新 props 中消失的 key。任何一个差异都返回 true。export function shouldUpdateComponent(prevVNode, nextVNode) { const { props: prevProps {} } prevVNode const { props: nextProps {} } nextVNode for (const key in nextProps) { if (nextProps[key] ! prevProps[key]) { return true } } for (const key in prevProps) { if (!(key in nextProps)) { return true } } return false }这个比较是浅比较也就是说如果 props 里某个属性是一个对象对象内部的某个字段变了但 props 对象本身是同一引用那么这里会判断“没变化”。这在常规代码中是安全的因为父组件渲染时往往会产生新的 props 对象但如果有人手动缓存了 props 对象就有可能出现“值其实变了但没被检测到”的情况。学习阶段不用过度纠结这个但要知道 Vue 3 的 props 本身设计成浅层响应式加只读实际框架中还有更多约束。4.3 instance.next 与批量更新中的 props 同步父组件更新时子组件的更新函数可能已经在队列里排队了。如果此时父组件再直接调用子组件的instance.update()会导致子组件被重复调度甚至出现上下游互相调度的死循环。Vue 3 的做法是把“即将到来的新 vnode”存在instance.next上等子组件自己的更新函数真正执行时再从instance.next里同步最新 props。渲染器里处理组件更新的核心代码是这样function updateComponent(n1, n2) { const instance (n2.component n1.component) // 先把最新 vnode 交给组件实例确保 props 同步不会丢 instance.next n2 if (shouldUpdateComponent(n1, n2)) { instance.update() } }然后在componentUpdateFn更新分支的开头加上 props 同步逻辑if (instance.next) { instance.vnode instance.next instance.props instance.next.props instance.next null }这段逻辑解决了我的一个巨大困惑为什么 Vue 渲染器不直接在updateComponent里替换instance.props原因是那个时刻子组件的 render 可能还没执行直接替换会跟当前队列的执行顺序产生冲突。把新 props 暂存在instance.next等真正执行组件更新时统一换掉才能保证数据一致。5. 更新落地的补丁动作patch 如何让 DOM 跟上新状态5.1 patch 的入口判断相同 VNode 才走更新不同就整树替换patch是更新流程的出口。它接收四个参数旧 vnode、新 vnode、容器、父组件实例。逻辑分两步第一步如果新旧 vnode 都存在但它们的type不同说明已经不是同一个节点了这时候没有可比性直接卸载旧节点把 n1 置空当作挂载新节点处理。第二步根据新 vnode 的 shapeFlag 判断类型是组件就走processComponent是元素就走processElement。function patch(n1, n2, container, parentInstance) { if (n1 n1.type ! n2.type) { unmount(n1) n1 null } const { shapeFlag } n2 if (shapeFlag ShapeFlags.COMPONENT) { processComponent(n1, n2, container, parentInstance) } else if (shapeFlag ShapeFlags.ELEMENT) { processElement(n1, n2, container, parentInstance) } }这里有个判断值得留意type 相同并不代表不需要更新而是进入“细粒度比较”的阶段。比如同一个 div 元素props 和 children 可能都变了patch 接下来就要处理这些差异。5.2 patchProps 的更新与删除逻辑元素更新时最基础的补丁动作是更新属性。processElement里区分挂载和更新挂载走mountElement更新走patchElement。function patchElement(n1, n2, container) { const el (n2.el n1.el) patchProps(el, n1.props, n2.props) patchChildren(n1, n2, el) }patchProps要处理两种情况。第一种新 props 中某个 key 的值跟旧值不同就调用 DOM 操作去更新它。第二种旧 props 中存在的 key 在新 props 中消失了说明这个属性应该被移除需要把新值当作 null 传给 patchProp让它走删除逻辑。function patchProps(el, oldProps, newProps) { oldProps oldProps || {} newProps newProps || {} for (const key in newProps) { const prev oldProps[key] const next newProps[key] if (prev ! next) { patchProp(el, key, prev, next) } } for (const key in oldProps) { if (!(key in newProps)) { patchProp(el, key, oldProps[key], null) } } }浏览器端的patchProp我做了简化只处理好三种情况function patchProp(el, key, prevValue, nextValue) { if (key.startsWith(on)) { const eventName key.slice(2).toLowerCase() if (prevValue) { el.removeEventListener(eventName, prevValue) } if (nextValue) { el.addEventListener(eventName, nextValue) } } else if (nextValue null) { el.removeAttribute(key) } else { el.setAttribute(key, nextValue) } }事件类的 props 需要特别注意一定要先移除旧监听器再绑定新监听器否则连续更新时会越绑越多造成事件重复触发的诡异 bug。框架学习到这个阶段会发现很多 bug 不是因为逻辑多复杂而是细节顺序没处理好。5.3 关于 children diff 的一句话预告本篇够用下一篇才是重点组件更新如果涉及子节点数组理想情况是做一个带 key 的 diff复用尽可能多的真实 DOM。不过“实现组件更新功能”这一步我的策略是先让功能闭环新旧 children 都简单处理如果新 children 是文本直接textContent替换如果新 children 是数组就把旧数组全部卸载重新挂载新数组。function patchChildren(n1, n2, container) { const oldChildren n1.children const newChildren n2.children if (typeof newChildren string) { if (oldChildren ! newChildren) { container.textContent newChildren } } else if (Array.isArray(newChildren)) { if (Array.isArray(oldChildren)) { unmountChildren(oldChildren) mountChildren(newChildren, container) } else { container.textContent mountChildren(newChildren, container) } } }这种“全量替换”的做法在功能上是正确的只是在列表量大的时候性能差一些。真实的 diff 算法会尽量复用 DOM通过 key 去定位可复用的节点。这一步先不展开下一篇专门写 diff 的时候再讲否则一次塞太多容易消化不了。6. 更新功能踩坑实录这些问题我排查了半个晚上6.1 数据变了页面不动的三个大概率原因这是最典型的“脑内模拟一切正常实际跑起来页面不动”的困境。我排查下来主要有三个原因。第一个原因是 render 里读取的响应式对象与自己修改的响应式对象不是同一个代理。很多人会有意无意地解构出原始对象或者在某个地方调用了toRaw导致修改的是原始对象而 Proxy 的 set 拦截不生效。观察做法是所有响应式数据都通过统一入口去读和写不要偷偷把原始对象存到另外的变量里。第二个原因是track时没有正确的activeEffect。effect 在执行过程中如果发生嵌套或者某个地方手动调用了effect.stop()就会导致依赖收集失效。排查方法是在 render 函数里加个console.log看数据初始化时是否真的执行了 render再在track里打日志看依赖收集到哪些 key。第三个原因是 scheduler 没有正确入队。常见错误是queueJobs里 job 为 undefined比如 effect 创建前就引用了instance.update。这类问题也容易排查把flushJobs里的执行改成console.log(job)看队列里到底存了什么。6.2 改一次数据 render 跑了两次更新队列没有去重我最初实现的queueJobs用的是普通数组写起来很简单queue.push(job)结果发现一个数据变化就触发两次 render。原因是在同一轮更新里父组件因响应式数据变化被调度patch 子组件时又调用了instance.update如果不去重同一个 update 就会在队列里出现两次。后来改成Set存储队列问题立刻消失。这也解释了 Vue 3 源码里为什么用Set来存任务队列而不是数组它要保证同一个 job 在同一个 tick 里只执行一次。如果你在实现时遇到“更新一次但 console.log 打印两次”优先检查这一步。6.3 循环更新的死结在 render 中修数据就是死路有一次我在 render 里写了类似“进入页面就count”的逻辑本意是想做一次初始化。结果控制台直接轰炸出Maximum call stack size exceeded整个页面卡死。原因是 render 执行时读取了count产生依赖随后立刻修改count触发同一个 effect 的调度下一次 render 又修改count无限循环。这个问题的本质是“渲染”和“数据更新”形成了一个闭环。解决方案也很简单不要在 render、computed 这类派生逻辑里改写它依赖的响应式数据。需要派生数据时用 computed 或普通变量计算而不是在 render 中修改源数据。如果确实要在组件挂载后做副作用应该放到事件回调或其他生命周期钩子里执行而不是渲染阶段。这段排查经历让我加深了一个认识组件更新这个功能真正难的不是 patch 那一套代码而是调度链路的时序控制以及什么时候“能改数据”、什么时候“绝对不能改数据”。把这些边界摸清楚后面学 diff、学异步组件都会顺畅很多。我在调试更新功能时最有用的工具不是 console.log 轰炸而是给scheduler里的queueJobs打一个断点看调用栈从哪里来再在componentUpdateFn的更新分支打个断点看它执行了几次。把这两条链路看清楚组件更新的整套机制基本就刻在脑子里了。下一步我准备把 patchChildren 换成真正的带 key diff顺便把 props 更新里的 style、class 特殊处理补全组件更新这块的闭环才算真正收口。