Vue组件更新全链路解析:响应式、调度器与渲染器如何协作
发布时间:2026/10/2 16:00:14来源:尧图网络
mini-vue系列写到第35篇前面把响应式系统、虚拟DOM、组件挂载都打通了结果在“组件更新”这个环节卡了一小段时间。说难也不难就是一下子跨了三个模块数据变动触发的是响应式系统任务排队靠的是调度器真正操作DOM的又是渲染器。如果你单独看每个模块都觉得对连起来就是跑不通那多半是在组件更新这条链路上缺了几个关键的连接点。这篇就围绕“组件更新”这条完整链路把从数据变化到界面刷新的每个环节拆开讲透。内容会覆盖为什么组件更新需要单独的链路、两个不同的触发入口、更新与挂载怎么分流、调度器为什么要异步合并、生命周期钩子在更新阶段怎么接进来以及最后怎么调试和验证自己的实现。下面直接进入正文。1. 为什么组件更新需要一条完整的链路1.1 从“数据变了”到“界面变了”中间缺了什么先想一个问题mini-vue里组件挂载时做了什么简化后大概是这样创建组件实例、处理setup返回的数据、调用render拿到虚拟DOM树、然后patch到真实容器里。const instance createComponentInstance(vnode, parent) setupComponent(instance) // 这里拿到 subTree 后 patch 到 container 上 const subTree instance.render.call(instance.proxy) patch(null, subTree, container, instance)这个过程是一次性的跑完就结束了。但真正的业务场景里用户点击按钮、输入框敲字、网络请求返回都会让响应式数据发生变化。如果每次变化都要重新走一遍“创建实例挂载”那组件的本地状态、DOM节点、事件绑定全都丢了性能更是灾难。所以组件更新要做的是保留组件实例重新执行渲染函数对比新旧两棵虚拟DOM树只更新发生变化的部分。这句话里最难的部分其实是“怎么让渲染函数在数据变化之后被重新执行”——这就是响应式系统要接进来的原因。1.2 挂载之后再谈更新贯穿响应式、调度与渲染三大模块在Vue 3的设计里组件的渲染函数并不是裸调用的而是被包在一个effct副作用函数里。整个流程用一句话概括就是组件渲染时读取响应式数据数据被收集为依赖数据变化时触发依赖让包裹渲染函数的effect重新执行effect的执行不是同步的而是被调度器放进异步队列真正执行时渲染函数重新生成新的subTree渲染器拿它与旧subTree做patch。这个链路里响应式系统负责“感知变化”调度器负责“决定何时更新”渲染器负责“如何更新”。三者各管一段任何一个环节断了界面都不会正确刷新。所以组件更新的第一步就是要把“渲染”这件事变成一个有依赖收集能力的effect。这也是整个组件更新功能的核心基石。代码上就是下面这一段const instance createComponentInstance(vnode, parent) setupComponent(instance) // 关键把 update 函数包进 effectinstance.update 保存副作用函数本体 instance.update effect( () { // 这里是组件更新的主体逻辑下面第五节会展开 }, { scheduler: () queueJob(instance.update) } )这里有个容易忽略的点effect先把原始副作用函数包了一层然后返回给调用者。返回的这个函数才是真正需要被trigger执行的。如果直接把原始副作用函数丢给trigger调度器就走不进去了异步合并也就失效了。2. 两个触发入口渲染器内部的trigger与组件的forceUpdate2.1 入口一数据变化时由trigger间接触发的自动更新先看最常见的一条路。组件挂在页面上后用户点击按钮修改了countsetup() { const state reactive({ count: 0 }) return { state, onClick() { state.count } } }count变化后trigger会找到所有依赖该数据的effect并执行。由于组件渲染effect在读取state.count时已经被收集所以它会被trigger拿出来执行。这个执行过程并非直接调用而是进入effect的调度器分支// 响应式系统中的 trigger简化版 function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const deps depsMap.get(key) if (!deps) return const effectsToRun new Set() deps.forEach((dep) { // 跳过正在执行的 effect if (dep ! activeEffect) { effectsToRun.add(dep) } }) effectsToRun.forEach((effectFn) { // 关键有 scheduler 就交给 scheduler没有才直接 run if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn) } else { effectFn() } }) }也就是说自动更新的本质是响应式数据是“因”render effect被trigger触发是“果”。整个过程中组件更新逻辑不需要知道具体是哪个数据变了响应式系统已经替它做好了依赖追踪。2.2 入口二主动调用的forceUpdate强制更新第二条路不依赖响应式数据变化而是直接调用组件实例的update方法。什么时候会用到典型场景是父组件重新渲染时子组件的props并没有变化但父组件想让子组件跟着刷新。或者是使用模板编译后的组件某些情况下需要手动强制执行一次渲染。在mini-vue里父组件渲染出的子组件vnode在patch时如果发现新vnode对应一个已经挂载过的组件实例就会走组件更新逻辑// 父组件重新渲染后patch子组件vnode function patch(oldVNode, newVNode, container, parentComponent) { // ... if (oldVNode.type ! newVNode.type) { // 类型不同直接卸载重建 unmount(oldVNode) oldVNode null } if (oldVNode null) { // 挂载新组件 mountComponent(newVNode, container, parentComponent) } else { // 组件更新 updateComponent(oldVNode, newVNode) } }updateComponent的实现则很简单function updateComponent(oldVNode, newVNode) { const instance oldVNode.component // 先把新 vnode 暂存到实例上 instance.nextVNode newVNode // 直接调用 update本质等同强制刷新 instance.update() }这里有个容易混淆的点instance.update到底是effect还是原始副作用函数我在第一版实现里搞混过一次。严格来说instance.update应该保存的是effect包装后的可执行函数这样无论trigger、scheduler还是外部调用拿到的入口都是一致的。如果存的是原始函数那么scheduler里调用queueJob(instance.update)时排队的其实是未经effect包装的副作用会丢失上下文。2.3 两路入口如何汇聚到同一个渲染effect两条入口看起来路径不同但最终都汇聚到同一个函数——instance.update。区别只在触发方式触发方式发起方经过调度器典型场景自动更新响应式系统trigger是组件内响应式数据变化强制更新父组件patch或手动调用是父组件刷新子组件、手动刷新有人会问为什么父组件patch子组件时不直接改props而是直接调update这就牵涉到另一个未展开的机制props本身也是响应式的父组件更新时传给子组件的props对象已经变了子组件实例持有的props引用在updateComponent里被重新赋值然后再触发update子组件的render重新执行时就能读到新props。在mini-vue的更新函数里处理nextVNode这段逻辑也放在update内部if (instance.nextVNode) { // 拿出暂存的新vnode const nextVNode instance.nextVNode instance.nextVNode null // 更新组件实例上的vnode引用 instance.vnode nextVNode // 这里可以再扩展更新props、处理setupState等 }两条入口最终汇聚到同一条update链路就意味着只要把update函数写对两种场景就同时解决了。这也是为什么这节标题叫“入口”——它们只是在各自场景下把实例的update踢起来真正干活的地方在后面。3. 更新与挂载的分流isMounted标志到新旧subTree的patch3.1 update函数的核心逻辑现在进入整个组件更新功能的心脏部分。在我自己的mini-vue实现里update函数结构长这样const instance createComponentInstance(vnode, parent) setupComponent(instance) const update () { // 首次执行前调用 render 拿到 subTree然后挂载 let nextSubTree if (!instance.isMounted) { // ----- 挂载分支 ----- if (instance.beforeMount) { invokeArrayFns(instance.beforeMount) } // 首次渲染 nextSubTree render.call(instance.proxy, instance.proxy) instance.subTree nextSubTree patch(null, nextSubTree, container, instance) instance.isMounted true } else { // ----- 更新分支 ----- if (instance.beforeUpdate) { invokeArrayFns(instance.beforeUpdate) } // 处理暂存的新vnode父组件触发的更新会走到这里 if (instance.nextVNode) { instance.vnode instance.nextVNode instance.nextVNode null } // 保存旧树生成新树 const prevSubTree instance.subTree nextSubTree render.call(instance.proxy, instance.proxy) instance.subTree nextSubTree // 关键更新时 patch 的容器不是初始 container而是旧树的 el 父节点 patch(prevSubTree, nextSubTree, prevSubTree.el.parentNode, instance) if (instance.updated) { invokeArrayFns(instance.updated) } } } instance.update effect(update, { scheduler: () queueJob(instance.update) })这段代码里有几个细节值得单独展开。3.2 新旧props的处理与instance.nextVNode的临时站位第一个细节是instance.nextVNode。这个东西本质上是“新vnode的临时存放区”。父组件更新时新vnode就放在这里但update函数执行时需要先把它赋给instance.vnode清空nextVNode再去渲染。这样做的好处是如果连续收到多个新vnode只有最后一个会被真正渲染之前的会被覆盖丢弃。第二个细节是更新时patch的容器。很多人会在这里踩坑更新分支的patch用的还是挂载时的container结果发现DOM节点被反复追加而非替换。原因很简单挂载时patch的容器是组件挂载点而更新时新旧subTree的根节点已经存在于父容器中了应该用旧树的el的parentNode作为container这样才能让patch函数去“替换”而不是“追加”。Vue 3源码里对container参数的处理也是这套逻辑组件更新的patch是在instance.subTree.el的父节点上进行的。理解了这一点就不会把新旧subTree的patch容器写错。第三个细节是新旧树对比时的key和type。mini-vue的patch函数里对同类型节点比如都是div会走patchElement对不同类型的节点比如div换成span会走卸载重建。组件更新时如果新旧subTree根节点类型不一致整个子树都会被替换。这是正常行为也是需要保持的原则不能为了“尽量复用”而忽略type判断否则diff的正确性会出问题。我在实现时还有一个体会更新分支和挂载分支的代码能共用尽量共用但生命周期钩子不能共用。挂载阶段的beforeMount、mounted和更新阶段的beforeUpdate、updated语义完全不同。如果偷懒在update函数里统一调一批钩子就会出现挂载时执行了两次updated之类的问题。这也是为什么update函数里要用isMounted明显分隔开两个分支。4. scheduler与queueJob为什么组件更新不能是同步的4.1 一次修改多次更新被合并成一个任务聊完组件内部的update再来看调度。细心的人会注意到update函数被effect包裹时传了scheduler选项。这个scheduler的作用是当effect被trigger触发时不直接执行而是把任务放进队列。为什么要绕一圈看一个极端场景function onClick() { state.count state.name new name state.list.push(1) }如果没有调度器一次点击就会连续触发三次渲染DOM被更新三次浏览器一帧之内做大量无意义的工作。有了scheduler三次trigger都只会把同一个instance.update丢进队列而队列本身有去重逻辑。mini-vue的queueJob实现并不复杂const queue new Set() function queueJob(job) { queue.add(job) // 微任务阶段统一执行保证一帧内只 flush 一次 Promise.resolve().then(() { flushJobs() }) } function flushJobs() { queue.forEach((job) { job() }) queue.clear() }用Set做队列天然去重。同一个组件在同一个事件循环里无论触发多少次更新最终只会执行一次render。这个设计是Vue性能的关键来源之一mini-vue虽然简化了但核心思路要保留。4.2 调度器的实现细节与边界情况实现queueJob时有几个边界问题必须想清楚。第一个是flush时机。用Promise.resolve().then()是一种方式也可以用queueMicrotask。区别不大但要注意如果flush逻辑里又触发了新的trigger可能会造成死循环。实践中的做法是给flush加一个isFlushing标志正在flush时新进队列的任务放到下一轮。第二个问题是effect的执行顺序。两个组件同时更新时queue里既有A组件的update又有B组件的update。应该先执行谁在Vue里父组件的更新需要先于子组件因为子组件可能依赖父组件传入的新props。mini-vue如果要严格处理需要在flushJobs里按组件层级排序。这一节先提个醒具体排序逻辑可以放在父组件更新子组件那篇再展开。第三个问题是副作用执行时机。update函数里render读取响应式数据会重新收集依赖。如果此时effect正在执行读取速度并不会造成问题但有一个隐患render中途如果某个响应式数据再次变化会重新触发trigger结果scheduler又把update丢回队列。为了避免这种“自激循环”mini-vue里需要在trigger时跳过当前正在执行的effect。我最初的实现没加这个判断导致在某些循环渲染场景下栈溢出排查了半天才定位到是trigger少了dep ! activeEffect的过滤。这个细节建议在写响应式系统时就先埋好。5. 生命周期钩子在更新阶段的行为beforeUpdate与updated5.1 钩子的获取与调用方式组件更新不能只做DOM diff还得把生命周期钩子接进来。Vue组件的更新阶段有beforeUpdate和updated两个钩子前者在重新渲染之前执行后者在patch完成之后执行。在mini-vue里钩子从组件类型的options上获取// 简化版从组件配置里读取生命周期钩子 const { beforeUpdate, updated } instance.type.options为了兼容mixin合并后钩子可能是数组的情况调用时不能直接hook()而是要遍历调用function invokeArrayFns(fns) { if (Array.isArray(fns)) { fns.forEach((fn) fn.call(instance.proxy)) } else { fns.call(instance.proxy) } }这里为什么要绑定instance.proxy因为用户写beforeUpdate时往往期望this指向组件实例的代理对象这样能直接访问到data、props、setup返回的数据。如果用普通函数调用this就是undefined严格模式下或全局对象各种奇怪的报错会扑面而来。5.2 钩子执行时机对正确性的影响钩子执行时机不是随便定的。beforeUpdate必须放在render之前原因是有的用户会在beforeUpdate里修改数据如果修改操作发生在render之前这次修改能被当前这次渲染读取到如果放在render之后修改会触发下一轮调度造成额外的一次渲染。Vue官方对beforeUpdate的要求就是“可以在更新前读取DOM旧状态可以修改数据不需要手动触发更新”。为了达到这个语义位置必须卡准。updated钩子必须放在patch之后。原因更直接updated里访问dom应该拿到的是更新之后的真实DOM。来看一个容易写错的例子。假设实现时把updated钩子放在了render之后、patch之前// 错误示例 nextSubTree render.call(instance.proxy) if (instance.updated) invokeArrayFns(instance.updated) // 这里拿到的还是旧DOM patch(prevSubTree, nextSubTree, ...)用户在这种错误实现下写这样一段代码updated() { console.log(this.$el.textContent) // 期望是新值实际是旧值 }就会出现拿到的DOM内容与页面显示不一致的诡异问题。这种问题特别难排查因为它不报错只是值不对。调试了好半天才想到日志输出顺序的问题。所以我实现的顺序是铁律beforeUpdate在render之前updated在patch之后。6. 调试思路、测试用例与组件更新之外的扩展6.1 断点链路从trigger到patch的完整搭桥如果组件更新功能完成后发现界面不刷新我建议按下面这条链路打断点排查第一站track函数。确认渲染函数读取响应式数据时依赖有没有被收集。没收集到后面全白搭。判断方法是在render函数里故意读一个数据看依赖集合里有没有新加入的内容。第二站trigger函数。修改数据后确认它能找到依赖集合并且effect带上了scheduler。这里最容易发现的问题是effect的dep没挂到对应key上。第三站queueJob。确认任务进队列了、Set去重正常。如果这里断了界面不会刷新但控制台也不报错很有迷惑性。第四站update函数。确认挂载/更新分流正确旧树和新树都拿到手了。第五站patch。确认新旧subTree被正确对比容器参数正确。我自己实际调试时遇到过一个非常有迷惑性的情况前四站全通第五站的patch也执行了但页面就是不更新。最后发现是更新分支里patch用的container传成了挂载时的container导致新树被添加到旧节点的兄弟位置视觉上像是“没更新”。所以记住更新时容器应该是prevSubTree.el.parentNode。6.2 测试用例的构造写测试是验证组件更新功能的最高效手段。mini-vue常见的测试写法是配合一个测试渲染器把组件渲染到一个容器元素里import { h, reactive, render } from ../src test(组件更新修改响应式数据后重新渲染, () { const container document.createElement(div) const component { setup() { const state reactive({ count: 0 }) return { state, increase() { state.count } } }, render() { return h(div, { id: test }, count: ${this.state.count}) } } render(h(component), container) expect(container.innerHTML).toBe(div idtestcount: 0/div) // 修改响应式数据触发组件更新 container.firstChild.component.update() // 或者通过reactive数据变化触发 // 这里直接调用update模拟强制更新 // 由于queueJob是异步的需要等待微任务 return Promise.resolve().then(() { expect(container.innerHTML).toBe(div idtestcount: 0/div) }) })这里有个经验之谈直接用组件实例的update方法触发更新只适合做单元测试。更贴近真实场景的是修改响应式数据让队列自动flush。异步断言时Promise.resolve().then()是微任务而queueJob用的也是微任务顺序上需要小心。如果断言时机不对就改成await new Promise(r setTimeout(r, 0))让宏任务兜底确保队列flush完成。6.3 从组件更新到父子组件更新的边界组件更新打通之后下一步还有两件事别急着跳过。一件是props更新。父组件重渲染时传给子组件的新props要通过updateComponent拿到的nextVNode里的props对象来更新组件实例的props。这个更新时机应该在render之前否则子组件render读到的还是旧props。另一件是组件卸载。当父组件的vnode树里某个子组件节点消失时应该调用该组件实例的unmount清理事件监听、响应式依赖而不是简单地把DOM节点删掉就完事。mini-vue到组件更新阶段可以先不做完整的unmount但至少要想清楚为什么卸载不能写在patch里因为卸载涉及组件生命周期和普通元素的移除不是一回事。我之前在实现组件更新时为了省事把更新逻辑全部堆在一个函数里后面加props更新时改得头大。建议是update函数只做“更新”这件事本身new props的合并、nextVNode的处理单独抽成小函数。哪怕现在mini-vue规模不大分清楚边界会省很多后续的力。组件更新的链路捋清楚后再回头看最初那个“数据变了界面没变”的问题定位思路就会非常清晰先看响应式有没有收集到渲染effect再看调度器有没有把任务排进队列最后看渲染器有没有正确patch新旧树。每个模块各司其职问题就无处藏身。
网站建设高端定制企业官网