
如图,页面上是一颗按钮,数字从 0 变成 1。模板写的是 {{ count }},点击处理函数里只有一行 count.value++。这行执行完,按钮上的字仍然是 0。字变成 1,要等当前这个函数结束之后。
下面用 Vue 3.5.12 把这次点击走完。例子就是这个组件:
vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
function add() {
count.value++
}
</script>
<template>
<button @click="add">{{ count }}</button>
</template>
count 是一个 ref。.value 里存着数字 0。模板里写 {{ count }} 时,Vue 会读到这个数字,同时记住:以后 count 变了,要通知这个组件重新描述界面。
1、按钮背后是一个普通对象
组件要画出按钮时,先得到一棵普通 JavaScript 对象,再拿这棵对象去创建或修改页面上的节点。这棵对象树就是虚拟 DOM。树上的一个节点叫 VNode,由 createBaseVNode 创建。
按钮这一次大致是:
js
{
type: 'button',
props: { onClick: add },
children: '0',
shapeFlag: 9,
patchFlag: 1,
el: null
}
type 是标签名 'button'。如果这一层是组件,type 就是组件选项,页面上不会多出一个叫组件的标签。
props 里放属性、class、style,以及 onClick 这种监听。children 这里是字符串 '0',也就是按钮里面的文字。
el 用来记住「这个对象对应页面上的哪个节点」。创建对象时它是 null,按钮还没出现在页面上。挂载之后它指向那个真实的 <button>。之后改文字、改 class、删除,都用这个引用,不用再按选择器去找。
组件自己也有一个 VNode。它和按钮的关系是:
组件 VNode 的 el 最后和按钮 VNode 的 el 指向同一个元素,也就是子树最外面那个真实节点。
1.1、shapeFlag:这个对象是按钮,还是组件
patch 是后面负责「对照新旧对象、改页面」的函数。它不逐个猜 type 是字符串还是组件,而是看 shapeFlag。这是一个用数字按位叠加上去的标记。
这篇按钮用得到的几个值:
| 标记 | 数值 | 表示 |
|---|---|---|
| 元素 | 1 | type 是 'button'、'div' 这种标签 |
| 函数组件 | 2 | type 是一个函数 |
| 有状态组件 | 4 | type 是带 setup 或状态的组件选项 |
| 组件 | 6 | 2 和 4 合在一起,只要带这个值就按组件处理 |
| 文本子节点 | 8 | children 是字符串 |
| 数组子节点 | 16 | children 是一组子 VNode |
按钮是元素,再加「子节点是文字」,所以是 1 + 8 = 9。根上那个组件带 6。patch 看到 6 就去跑组件;看到 1 就去创建或修改元素。
这里有一个容易看错的细节。按钮的 children 是字符串 '0' 时,Vue 直接把这几个字写进按钮的 textContent,不会再为「0」单独做一个文本节点对象。只有 children 是数组时,才会一个一个去处理子 VNode。单独的文本 VNode(内部类型是 Text)才会走到 document.createTextNode。这颗按钮走的是 textContent。
2、为什么要先做这个对象
2.1、渲染函数交出的是「现在应该长什么样」
add 把 count.value 改成 1 之后,这个组件会再执行一次渲染函数,得到一棵新对象。旧对象还留着,因为它上面记着页面上那个按钮。
| 旧对象 | 新对象 | |
|---|---|---|
type |
'button' |
'button' |
children |
'0' |
'1' |
el |
页面上的那个按钮 | 还没填,对比时会沿用旧的 |
标签没变,文字从 '0' 变成 '1'。渲染器就去改那个按钮的 textContent。
模板和手写的 render 都停在「返回对象」这一步。document.createElement、setAttribute 出现在后面的 patch 里。组件代码只负责在数据变了之后,把界面再描述一遍。
2.2、创建元素的函数是另传进去的
patch、mountElement 这些函数在 @vue/runtime-core 里。它们不调用 document,只调用创建渲染器时传进来的一组方法:创建元素、插入、改属性、改文字、删除。
浏览器的实现在 @vue/runtime-dom,叫 nodeOps:
js
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
createElement: (tag, namespace, is, props) => {
return namespace === 'svg'
? doc.createElementNS(svgNS, tag)
: doc.createElement(tag)
},
setElementText: (el, text) => {
el.textContent = text
}
insert 用的是 insertBefore。第三个参数 anchor 是「插到谁前面」。anchor 为空时,insertBefore(child, null) 就是插到父节点末尾。
createApp 把这组方法加上 patchProp(专门处理 class、style、事件和普通属性),交给 createRenderer。平时写页面,用的就是浏览器这一套。自定义渲染器换掉的也是这一套,虚拟 DOM 的对比过程可以留在 runtime-core。
2.3、编译器会标明:这次只要看文字,还是还要看 class
模板在构建时会被编译成渲染函数。编译器如果能看出「这个节点每次会变哪里」,就把结果写进 patchFlag,免得更新时把整个节点再摸一遍。
只有文字会变的按钮,标记是 1,名字叫 TEXT。class 和文字都会变的节点是另一段模板:
vue
<div :class="cls">{{ count }}</div>
编译结果大致是:
js
(_openBlock(),
_createElementBlock(
'div',
{ class: _ctx.cls },
_toDisplayString(_ctx.count),
3 /* TEXT, CLASS */
))
3 就是 1 + 2:既要看文字,也要看 class。_toDisplayString 把 count 转成页面上要显示的字符串。
一块模板里,有的子节点永远不变,有的会变。例如:
vue
<div>
<p>次数</p>
<p>{{ count }}</p>
</div>
编译器在创建这块的时候开了一个名单,叫 Block。patchFlag 大于 0 的节点会被放进名单,存在块根节点的 dynamicChildren 上。上面这个 div 的名单里只有第二个 p。「次数」挂载过一次之后,更新不再访问它。
更新时 patchElement 看见 dynamicChildren,就走 patchBlockChildren,只递归名单里的节点。
这篇会碰到的标记:
| patchFlag | 数值 | 更新时做什么 |
|---|---|---|
| TEXT | 1 | 文字变了才写 textContent |
| CLASS | 2 | 只对比 class |
| STYLE | 4 | 只对比 style |
| PROPS | 8 | 只走编译器记下的那几个属性名 dynamicProps |
| FULL_PROPS | 16 | 属性可能有增删,把新旧属性完整比一遍 |
| 静态 | -1 | 提升后缓存的节点,直接跳过 |
| 放弃优化 | -2 | 信息不够,按普通方式对比整棵子树 |
手写 h() 时,编译器没机会标这些,patchFlag 经常是 -2。运行时仍用同一套 patch,只是这次要看的范围更大。
3、点击之后,字为什么不会马上变
count.value++ 改的是数据,并且通知「渲染时读过它的那个函数」。页面要等这个函数被排进队列、再真正执行,才会变。
微任务是当前这段 JavaScript 跑完之后、浏览器把下一帧画上屏幕之前,会先执行的 Promise 回调。十次 count.value++ 写在同一个函数里,画面上不会闪出 1、2、3,最后一次刷新会把最终的数字写上去。
3.1、组件挂上页面时,渲染函数被记成一个 Effect
createApp(App).mount('#app') 走到组件挂载 mountComponent 时,先做 setupComponent:执行 setup,把 count 准备好。然后 setupRenderEffect 给这个组件建一个 ReactiveEffect。Effect 就是「依赖变了要重新跑」的函数,这里的函数体叫 componentUpdateFn。
js
const effect = instance.effect = new ReactiveEffect(componentUpdateFn)
const update = instance.update = effect.run.bind(effect)
const job = instance.job = effect.runIfDirty.bind(effect)
job.id = instance.uid
effect.scheduler = () => queueJob(job)
update()
这几行里有三个入口,后面会各用一次:
update()马上跑函数。组件还没挂过,所以这是第一次渲染。job是runIfDirty。依赖确实变过才跑;刚刚已经渲染过、没有新的变化,就跳过。scheduler在依赖变化时被调用。它不直接渲染,只把job放进队列。
第一次 update() 进入 componentUpdateFn,再进入 renderComponentRoot,执行编译出来的渲染函数。渲染函数读到 count 时,当前正在跑的就是这个 Effect,于是 count 的依赖名单里记下它。以后 count 变化,通知的就是这次读过它的 Effect。
3.2、count 变了,任务先排队
再次执行 add,Effect 的 trigger() 发现有 scheduler,于是调用 queueJob(job)。渲染函数要等队列刷新。
queueJob 和这次点击有关的行为有三件。
任务已经在队列里时,job.flags 的某一位是 1,不会再放一次。add 里连续写十次 count.value++,这个组件的更新任务仍然只有一个,刷新时渲染函数只跑一遍,用的是最后那个数字。
队列按 job.id 排。id 是组件的 uid,创建得越早,数字越小。父组件先被创建,刷新时排在子组件前面。这样父组件先根据新数据产出子组件的 VNode,子组件再对着新的 props 渲染。
queueFlush() 负责安排刷新。这一轮还没安排过,就执行 Promise.resolve().then(flushJobs)。nextTick 等的就是这个 Promise:
js
function nextTick(fn) {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(fn) : p
}
currentFlushPromise 就是上面那个 then(flushJobs)。所以在 nextTick 的回调里读按钮文字,已经是新的。在 add 函数里面立刻读 button.textContent,还是 0,因为刷新还排在当前函数后面。
3.3、队列刷新时才产生新对象并 patch
flushJobs 从头到尾执行队列。组件任务是 runIfDirty:距离上次渲染,依赖确实变过,才执行 componentUpdateFn。
组件的 isMounted 已经是 true,走更新分支。
如果这次是父组件传来了新的组件 VNode,先做 updateComponentPreRender,把新 props 写到实例上。然后 renderComponentRoot 再跑一遍渲染函数,得到 nextTree。上一棵保存在 instance.subTree。
js
patch(
prevTree,
nextTree,
hostParentNode(prevTree.el),
getNextHostNode(prevTree),
instance
)
第三个参数是旧节点现在的父 DOM,按钮的父节点就是 #app。第四个参数是旧节点的下一个兄弟,用来决定新节点如果要插入,应该插到谁前面。这颗按钮没有被换掉,这个插入位置用不上,但 patch 的参数始终留着它。
对比结束,新的组件 VNode 的 el 指向新树根上的真实节点。onUpdated 不在这一步立刻调用,它进入后置队列,等主队列里的组件都更新完再跑。因此在 onUpdated 里读 DOM,这次的文字已经写完。
3.4、父组件把新 props 传下来时,子组件可能提前渲染
按钮这个例子只有一个组件,count 是它自己的数据,队列里只有它。如果改成父组件持有 count,再用 props 传给子组件:
vue
<!-- 父 -->
<Child :n="count" />
<!-- 子 -->
<button>{{ n }}</button>
count 变化时,读过它的是父组件的渲染 Effect,先入队的是父组件。父组件刷新、patch 到 <Child> 时,发现 props 从 { n: 0 } 变成了 { n: 1 },会在父组件这次更新里直接调用子组件的 instance.update()。子组件的渲染函数因此在父组件的 patch 途中就跑了。
子组件如果自己也有别的数据在同一轮变过,它的任务可能也在队列里。轮到它时,runIfDirty 发现渲染刚刚做过、依赖已经对上,这次就不再跑。
props 没有变化时,shouldUpdateComponent 不通过。新的组件 VNode 直接沿用旧的 el,子组件的渲染函数这次不执行。
4、第一次挂载:对象怎么变成按钮
mount('#app') 在 runtime-dom 里先找到容器。容器如果是一个元素,会执行 container.textContent = ''。#app 里面原来写的「加载中」会被清掉,避免和后面渲染出来的按钮叠在一起。
然后把根组件交给渲染器。容器上的 container._vnode 用来记住上一次渲染的根 VNode,第一次是空的:
js
patch(container._vnode || null, vnode, container)
container._vnode = vnode
根 VNode 的 shapeFlag 带 6,patch 进入 mountComponent,再进入上一节的 setupRenderEffect。instance.isMounted 还是假,componentUpdateFn 走第一次渲染:
js
const subTree = instance.subTree = renderComponentRoot(instance)
patch(null, subTree, container, anchor, instance)
initialVNode.el = subTree.el
这里的 patch(null, subTree) 里,第一个参数是旧 VNode。null 表示这个位置还没有节点,按新对象创建。新对象的 type 是 'button',shapeFlag 带 1,进入 mountElement。
mountElement 里和按钮有关的顺序是:
js
el = vnode.el = hostCreateElement(vnode.type, namespace, props && props.is, props)
hostSetElementText(el, vnode.children)
hostPatchProp(el, key, null, props[key], namespace, parentComponent)
hostInsert(el, container, anchor)
先 document.createElement('button'),并把返回的元素记到 vnode.el。此时元素还没进文档。
子节点是字符串,所以 textContent 写成 '0'。如果 children 是数组,这里会改成 mountChildren,对每个子对象再调用一次 patch(null, child, ...)。
然后 patchProp 处理 onClick。第一次没有旧的监听函数,Vue 在元素上放一个调用器 invoker,addEventListener 注册的是这个调用器。调用器内部再去调 add。监听函数和业务函数隔了一层,是为了下次业务函数换成另一个时,不必 removeEventListener 再绑一次。
属性写完才 insertBefore。anchor 为空,按钮插到 #app 的末尾。
子树都挂完,组件 VNode 的 el 也指向这个按钮。instance.isMounted 设为 true。onMounted 在后置队列里,插入已经完成,所以在 onMounted 里可以读到按钮、可以量它的尺寸。
如果页面是服务端先输出 HTML、浏览器再接管,走的是另一条路:容器里已经有按钮,hydrateNode 按 VNode 去对现有节点,给它们补上监听和 el。上面的 createElement 是浏览器里从空容器开始的第一次挂载。
5、第二次进来,改的是原来那个按钮
count 变成 1 之后,微任务里再次 patch。手里一边是旧按钮对象(children 是 '0',el 指向页面上的按钮),一边是新按钮对象(children 是 '1')。
两个参数是同一个对象时直接返回。被静态提升、多次渲染复用的那个对象会走到这条路,页面上的节点保持原样。
type 或 key 对不上,说明这个位置已经不是原来那个节点。旧的元素会 removeChild;如果旧的是组件,还会走卸载,调用 onUnmounted。然后把这个位置当成空的,按新对象重新 createElement。button 换成 span,或者列表里这个位置的 key 变了,都是这条路。
这颗按钮前后都是 'button',也没有 key 变化,于是:
js
const el = n2.el = n1.el
新对象从此指着页面上原来那个按钮。patchFlag 是 TEXT,'0' !== '1',执行 el.textContent = '1'。这次更新没有去动点击监听:编译结果里 onClick 被缓存了,前后是同一个函数,patchProp 不会重绑。
监听函数真的换成另一个时,patchProp 找到元素上原来的 invoker,把 invoker.value 换成新函数。DOM 上的 addEventListener 还是第一次那个调用器。
5.1、子节点是一组元素时,key 决定能否搬走
按钮内部只有一串文字,用不到列表对比。子节点一旦是数组,patchChildren 会进入列表逻辑。带 key 时走 patchKeyedChildren。
三个条目从 A B C 变成 A C B,key 分别是 a、b、c:
| 位置 | 旧 | 新 |
|---|---|---|
| 0 | A,key 为 a | A,key 为 a |
| 1 | B,key 为 b | C,key 为 c |
| 2 | C,key 为 c | B,key 为 b |
key 相同的节点被认成同一个。C 原来的那个 DOM 元素被挪到 B 前面,B 的元素挪到后面,元素内部的输入状态、子组件实例可以留着。具体搬哪些、尽量少搬,用的是最长递增子序列:已经排好的那一段不动,只移动不在这段里的节点。
没有 key 时按位置比。位置 1 上旧的是 B、新的是 C,标签如果都是 div,Vue 会复用位置 1 上那个 DOM,把里面的内容从 B 改成 C。DOM 节点本身还在原处,只是内容被写掉了。列表会增删、会排序时,条目上要写稳定的 key,这样搬走的是原来那个元素。