如果你跟我一样在一步步手写 mini-vue,走到第 35 个主题之前,你应该已经能完成初始挂载了:createApp、renderer、patch、mountComponent这些流程都跑得通,一个组件能正常渲染到页面上。但这个时候你会遇到一个绕不开的问题——只要动手改state,页面要么纹丝不动,要么直接报错,甚至把整个 DOM 全部重建一遍。这节“mini-vue 实现组件更新功能”,就是专门把“响应式系统”和“渲染器”这两条线彻底接通,让组件在数据变化时只做精准的局部更新。
这节内容适合谁?正在手写 Vue 3 源码复刻版、想搞明白“改了数据之后框架内部到底发生了什么”的同学,或者已经在做框架设计、需要梳理渲染链路的人。我会从更新链路设计、核心代码实现、常见坑排查三个角度展开,把第 35 节背后值得琢磨的设计决策都讲清楚。
1. 组件更新:把两条技术线接在一起的“最后一公里”
1.1 这一节到底解决了什么问题
先想一个问题:一个组件初始化渲染之后,响应式数据变了,页面凭什么跟着变?如果只靠前面已经实现的“响应式 API + 虚拟节点 + patch 挂载”,数据变化这件事根本传不到渲染器。因为trigger能触发的是一个effect函数,而渲染函数render并不是effect。
所以在实现组件更新之前,组件的“初始挂载”和“数据驱动”是断联的。你只能用reactive手动改数据,然后眼睁睁看着控制台里值变了,页面毫无反应。要打通这条链路,必须做一件事:让每一个组件实例都拥有一个属于它自己的effect,这个effect内部调用render生成新的虚拟节点,并让响应式依赖自动收集到这个effect上。
类比一下:初始挂载像是“把家具搬进新房子”,更新功能就是“住进去之后,按需调整某把椅子的位置”。如果每次改数据都先把整栋房子拆了再重建,那焦点、滚动位置、组件内部状态全都会丢失,代价极高。所以组件更新的核心诉求只有三个字:最小化。尽可能少地创建新节点、尽可能少地修改真实 DOM,最好只更新变化的那一小块。
这一节在 mini-vue 系列里的定位,恰恰是从“能渲染”迈向“能用”的关键转折点。前面几十节已经把响应式、虚拟节点、props、slots、emit 都铺垫好了,它们各自独立工作时看不出什么威力,但一旦你通过更新机制把它们串成一个闭环,整个框架才真正开始“活”起来。
1.2 更新机制的三个核心参与者:effect、scheduler、patch
组件更新不是一个单一函数,而是一套分工明确的协作机制。我拆开来讲,你先记住这三个角色:
- effect:负责把组件渲染函数变成响应式副作用。每个组件实例有一个 update 函数,它其实就是包了一层 effect 的 runner。渲染函数里读取了哪些响应式数据,这些数据就会把当前组件更新 effect 收集进自己的依赖集合。
- scheduler:负责控制更新函数的执行时机。数据变化触发 effect 后,不直接同步调用更新函数,而是交给调度器入队,再用微任务批量冲刷队列。这样你在一个事件里连续改十次数据,组件也只更新一次。
- patch:负责在更新时比较新旧虚拟节点,决定是直接替换、复用改造还是新建。它是更新流程的“路由器”,确保从组件根节点到子元素都能按类型走对处理分支。
这三者缺一不可。没有 effect,响应式数据连更新函数都找不到;没有 scheduler,性能会退化成同步低效更新;没有 patch,更新就只能粗暴地整棵卸载重挂。整节内容的核心,就是让这三个角色协同工作。
2. 核心实现拆解:更新链路里的每个关键环节
2.1 先把 effect 调通:带 scheduler 的响应式副作用
组件更新在响应式层面要做的第一件事,是扩展effect的能力。普通effect(fn)会在响应式数据变化时同步执行 fn,但组件更新不能这么干。假设你在一个点击事件里把count连加三次,同步执行的话,渲染函数会被调用三次,DOM 会被反复 patch 三次,这是纯浪费。
所以ReactiveEffect需要支持一个options参数,里面可以传递scheduler。trigger触发时,如果这个 effect 存在scheduler,就走调度器,否则才走默认的run:
let activeEffect: ReactiveEffect | undefined class ReactiveEffect { deps: Dep[] = [] options?: { scheduler?: EffectScheduler } constructor(private _fn: () => any, options?: { scheduler?: EffectScheduler }) { this.options = options } run() { activeEffect = this const result = this._fn() activeEffect = undefined return result } } export const trigger = (target, key) => { const depsMap = targetMap.get(target) if (!depsMap) return const deps = depsMap.get(key) if (!deps) return const effects = new Set(deps) effects.forEach((effect) => { if (effect.options?.scheduler) { effect.options.scheduler(effect.run.bind(effect)) } else { effect.run() } }) }这里有一个非常容易被忽略的细节:scheduler接收的参数是effect.run,而不是你自己写的业务函数。因为组件更新函数被effect包了一层之后,外部拿到的是runner,而runner内部会调用run。调度器里把这个run入队,等微任务冲刷时再执行,本质上就是在“触发”和“真正执行更新”之间插入了一层缓冲。
我自己第一次写的时候,直接传了componentUpdateFn进去,结果响应式依赖收集的上下文全乱了。因为run还有一个重要职责:执行前设置activeEffect,执行后清空。如果你绕过run直接用原函数,render 里访问响应式数据时,activeEffect就是undefined,依赖根本收集不到。
2.2 renderer 改造:mount 和 update 共用一套 patch 入口
渲染器的职责是“根据 vnode 操作 DOM”,它并不关心这个 vnode 是第一次来还是第 N 次来。所以在设计上,patch应该是统一入口,通过第一个参数n1是否存在来区分挂载还是更新:
const patch = (n1, n2, container, anchor, parentComponent) => { if (n1 && !isSameVNodeType(n1, n2)) { unmount(n1) n1 = null } const { type, shapeFlag } = n2 switch (type) { case Text: // 处理文本节点 break case Fragment: // 处理片段节点 break default: if (shapeFlag & ShapeFlags.ELEMENT) { processElement(n1, n2, container, anchor, parentComponent) } else if (shapeFlag & ShapeFlags.COMPONENT) { processComponent(n1, n2, container, anchor, parentComponent) } } }isSameVNodeType的判断条件是type相同且key相同。如果类型或 key 变了,说明这不是“同一个东西”,旧节点没有复用价值,直接卸载再挂新节点。比如v-if切换两个不同类型的组件,或者列表项 key 对不上,都会走这条路。
组件处理函数也随之变成双分支:
const processComponent = (n1, n2, container, anchor, parentComponent) => { if (!n1) { mountComponent(n2, container, anchor, parentComponent) } else { updateComponent(n1, n2) } }第一版实现更新功能时我有一种错误倾向:想单独写一个“更新组件”的函数,从组件实例的根 DOM 重新挂载。这是本末倒置,因为组件的子节点也可能会变化,必须统一回到patch去递归比较。复用patch入口不仅省事,还保证了所有类型的节点更新逻辑都收敛在同一条路径上。
2.3 组件实例的更新入口:setupRenderEffect 的关键逻辑
挂载组件时,mountComponent里已经创建了实例,初始化了 props、slots、setupState,接下来最关键的是这个函数:
const setupRenderEffect = (instance, initialVNode, container, anchor) => { const componentUpdateFn = () => { if (!instance.isMounted) { // 初次挂载阶段 const { proxy } = instance const subTree = (instance.subTree = instance.render.call(proxy, proxy)) patch(null, subTree, container, anchor, instance) initialVNode.el = subTree.el instance.isMounted = true } else { // 更新阶段 const { proxy, next } = instance if (next) { next.el = instance.vnode.el updateComponentPreRender(instance, next) } const subTree = instance.render.call(proxy, proxy) const prevSubTree = instance.subTree instance.subTree = subTree patch(prevSubTree, subTree, container, anchor, instance) } } instance.update = effect(componentUpdateFn, { scheduler: () => queueJobs(instance.update), }) }这里藏着四个非常关键的设计点。
第一,初次挂载也必须用 effect 包起来。虽然叫“setupRenderEffect”,但它不是只在更新阶段发挥作用。render 函数会访问响应式 proxy,访问发生在componentUpdateFn内部,而componentUpdateFn是被effect包装的,所以那些响应式依赖会收集到这个组件自己的 effect 上。这才是“数据变化能找到组件更新函数”的根。
第二,用isMounted区分挂载和更新。初次挂载时n1不存在,patch第一个参数传null,这正好复用挂载逻辑。更新时,新老子树都有了,传给patch后渲染器会走 diff 流程。
第三,next的处理。父组件重新渲染时,会产生一个新的组件 vnode,这个新 vnode 上带着新的 props、slots。但是当前实例上的 props 还是旧的呢,而且更新 effect 已经跑起来了,它内部闭包访问的是 instance 上的旧 props。所以需要先把新 vnode 上的 props、slots 同步到实例上,再调用 render 生成新子树。updateComponentPreRender就是干这个的:
const updateComponentPreRender = (instance, nextVNode) => { instance.vnode = nextVNode instance.props = nextVNode.props instance.slots = nextVNode.slots instance.next = null }不处理 next 会出现一个经典 bug:子组件 props 变了,但子组件渲染函数里读到的还是旧值,因为实例的 props 从未被更新。
第四,instance.update绑定的是effect返回的 runner,scheduler里把 runner 丢进队列。这样一来,外部触发更新时,走的是异步批量路径,而不是同步执行。
2.4 shouldUpdateComponent:不是所有变化都需要重渲染
updateComponent是从父组件视角触发的子组件更新入口:
const updateComponent = (n1, n2) => { const instance = (n2.component = n1.component) if (shouldUpdateComponent(n1, n2)) { instance.next = n2 instance.update() } else { n2.el = n1.el instance.vnode = n2 } }这里有个复用逻辑:n1 是旧 vnode,n2 是新 vnode。必须把n2.component指向n1.component,不能重新创建实例,因为组件实例代表的是“这个组件的状态”,同一位置上的组件更新时状态要保留。
shouldUpdateComponent的作用是“拦截无意义的更新”。父组件重新渲染时,哪怕子组件的 props 完全没变,也会生成一个新的子组件 vnode。如果不管三七二十一都触发子组件更新,那一个 App 重渲染,整个组件树全都要跟着跑一遍,性能直接崩。
所以至少要做一个 props 层面的浅比较:
const 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 完全一致,就不触发子组件更新。对于 slots,更严格的做法是判断prevChildren !== nextChildren,这里可以留作后续课程扩展的点。
3. 动手实操:完整实现组件更新功能的步骤与代码
3.1 复现前先确认你已有的能力
第 35 节不是从零开始,它建立在一系列前置能力之上。我建议你先检查自己的 mini-vue 项目里是否已经具备这些模块,缺哪个补哪个,否则直接加更新逻辑会处处碰壁:
- 响应式系统:
reactive、effect、track、trigger,以及ReactiveEffect支持options.scheduler。 - 渲染器基础:
createRenderer、createApp、patch能处理元素节点和文本节点。 - 组件挂载:
mountComponent里能创建组件实例、调用 setup、初始化 props、处理 slots、注册 emit。 - 虚拟节点类型:
ShapeFlags里有ELEMENT、COMPONENT、STATEFUL_COMPONENT等标记。
你可以用 vitest 写单元测试,也可以直接在index.html里引打包产物做手测。我的习惯是两条腿走路:核心逻辑用测试跑,交互效果用页面看,因为有些 DOM 行为(比如焦点丢失)只有真实页面才暴露得出来。
3.2 关键代码补全:从 queueJobs 到 updateComponent
完整链路需要补四块代码。第一块是调度器,新建一个scheduler.ts:
const queue: (() => void)[] = [] let isFlushing = false const p = Promise.resolve() export const queueJobs = (job) => { if (!queue.includes(job)) { queue.push(job) } if (!isFlushing) { isFlushing = true p.then(() => { isFlushing = false const currentQueue = [...queue] queue.length = 0 currentQueue.forEach((fn) => fn()) }) } }写这段代码时注意两个坑。第一,入队前要检查queue.includes(job),否则同一个更新函数在一个 tick 里可能被推入多次。第二,冲刷前要先拷贝当前队列再清空,因为 job 执行过程中可能产生新的更新任务,不能跟当前批次混在一起。
第二块是 renderer 里的入口改造。patch增加n1判断,processComponent增加更新分支,这部分逻辑参考 2.2 的代码。第三块是updateComponent和shouldUpdateComponent,参考 2.4 的代码。第四块是setupRenderEffect里的双分支,参考 2.3 的代码。
如果你已经按顺序实现了 props、slots、emit,那么更新功能补完后,整条链路应该是:trigger找到组件渲染 effect → 走scheduler入队 → 微任务冲刷时执行instance.update→componentUpdateFn判断isMounted走更新分支 → 生成新 subTree →patch(prevSubTree, subTree)递归 diff。
3.3 验证你的更新功能:写一个能证明“局部更新”的用例
实现完成后,不要只满足于“页面数字变了”。我强烈建议你做一个能证明“这是局部更新而不是整树重建”的验证。比如写一个父组件,里面既有响应式数字,也有一个静态的、不会变的 DOM 节点,并且在静态节点上挂一个标志:
const App = { setup() { const count = ref(0) const increment = () => count.value++ return { count, increment } }, render() { return h('div', [ h('p', this.count), h('div', { style: 'color: red' }, '我不应该被重建'), h('button', { onClick: this.increment }, '加一'), ]) }, }第一次渲染时,在“我不应该被重建”这个红色 div 上打一个自定义属性或往它的 DOM 对象上挂一个 key,比如div._myFlag = 'keep'。然后点击按钮,等组件更新完成后检查这个 div 是否还在,_myFlag是否还在。如果还在,说明你的patch在 diff 时成功复用了同一个 DOM 节点;如果不在,说明你的更新逻辑把整个子树卸载重建了,那就得检查patch里是不是漏了n1或者isSameVNodeType判断。
还可以测批量调度。给componentUpdateFn开头加一行console.log('render called'),然后在一个事件里连续执行三次increment()。理论上只会打印一次 render called。如果打印了三次,说明 scheduler 没生效,update被同步执行了。
4. 常见问题与排查技巧实录
4.1 数据改了页面不更新:先检查三条链路
页面不更新是第 35 节最常见的现象。我把它总结成一张排查表,按顺序查:
| 检查点 | 具体做法 | 常见原因 |
|---|---|---|
| 依赖收集 | 在 render 里读proxy属性,而不是直接读 setup 返回值 | 组件实例的 proxy 没配好,或者 render 里访问的是this以外的非响应式值 |
| effect 绑定 | 确认instance.update是通过effect(componentUpdateFn, ...)创建的 | 如果直接在外部手动调render,依赖收集无效 |
| trigger 是否走 scheduler | 在trigger里打印日志,看 effect 的scheduler是否存在 | 创建 effect 时没有传options,导致走了默认同步执行 |
| isMounted 判断 | 打印instance.isMounted的值 | 如果更新分支写错,可能每次 patch 的 n1 都是 null,走了挂载 |
我踩过最蠢的一个坑是在track里手动去重后,把activeEffect给丢掉了。你在调试时可以先把track和trigger的日志打开,把每次依赖收集和触发都打出来,会非常直观。
4.2 页面更新了,但是整个 DOM 被重建:diff 走错了分支
如果页面会变,但你把旧 DOM 节点上的引用一查发现全换了,那就说明 patch 在组件更新时走了“卸载重挂”路径。最常见的原因有两个:
一是patch里直接判断n1 && !isSameVNodeType(n1, n2)的逻辑没写对。如果isSameVNodeType一直返回 false,那么每次更新都会把旧节点卸载再挂新节点。检查一下你的key和type比较是不是写成了恒 false。
二是setupRenderEffect更新分支里的prevSubTree取错了。如果你在初次挂载完成后没有保存instance.subTree,更新时prevSubTree可能拿到的是undefined,那patch(undefined, subTree)自然就变成新建了。
我在 3.3 里说的那个“红色 div 打标记”验证法,就是为了快速暴露这个问题。不要靠肉眼判断,直接把 DOM 引用打印出来对比,一目了然。
4.3 更新死循环:render 里改了响应式数据
页面卡死通常发生在更新 effect 执行期间又触发了同一个 effect 的依赖。最常见的编码错误是在 render 函数里修改响应式数据,比如:
render() { this.count++ // 出现在 render 里的自增 return h('p', this.count) }render 读取count时会收集依赖,count++会触发更新,于是无限“收集-触发-收集”循环。另外,如果scheduler实现里忘记使用微任务队列,而是同步 flush,那么即便代码逻辑正常,也可能因为任务嵌套而栈溢出。
遇到卡死,先注释掉 render 里的业务逻辑确认是不是环,再检查 scheduler 是否确实异步。
4.4 子组件 props 一直拿到旧值:next 没处理干净
这个 bug 很隐蔽,现象是父组件的值已经变了,子组件渲染函数打印出来的 props 还是上一轮的。原因基本都出在updateComponent只调用了instance.update(),但没有把新的 vnode 同步到实例上。
记住一个顺序:先同步 next,再渲染新子树。也就是说,componentUpdateFn里要先进updateComponentPreRender(instance, next),把instance.props、instance.slots更新掉,再调用render。如果发现instance.next一直存在、被重复消费,检查updateComponentPreRender结束处有没有把instance.next置为null。
调试这类问题,我常用的办法是在updateComponentPreRender和render之间打印instance.props,看是不是已经更新成最新值。
做完整条更新链路后,我最大的体会是:手写框架最难的往往不是单个知识点,而是让多个模块在正确时机完成交接。响应式负责“知道变了”,调度器负责“安排更新”,渲染器负责“精确修改”,三者缺一不可,顺序错一点都不行。
最后分享一个我自己的小习惯:每次实现完更新功能,我会故意写一个“反例”,比如把 scheduler 改成同步执行,把shouldUpdateComponent直接返回 true,然后观察性能面板和日志输出。这种反向验证能帮你把每个环节的作用刻进肌肉记忆,比单纯跑通 happy path 有效得多。