Vue 3 虚拟 DOM:作用、触发机制,以及怎样变成页面上的真实节点

如图,页面上是一颗按钮,数字从 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。它和按钮的关系是:

flowchart TD app[&#34;页面上的 #app&#34;] --> comp[&#34;组件 VNode:没有自己的标签&#34;] comp --> btn[&#34;button 的 VNode:type 是 button,children 是 0&#34;] btn --> el[&#34;真实的 button 元素&#34;]

组件 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>
flowchart TD div[&#34;外层 div,作为一整块&#34;] --> static[&#34;p:次数。不放进 dynamicChildren&#34;] div --> dynamic[&#34;p:count。更新时只对比这一处&#34;]

编译器在创建这块的时候开了一个名单,叫 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++ 改的是数据,并且通知「渲染时读过它的那个函数」。页面要等这个函数被排进队列、再真正执行,才会变。

flowchart TD click[&#34;add 里 count.value++&#34;] --> track[&#34;渲染 Effect 发现 count 脏了&#34;] track --> queue[&#34;queueJob:这个组件只入队一次&#34;] queue --> micro[&#34;当前函数结束后的微任务 flushJobs&#34;] micro --> render[&#34;再跑渲染函数,得到 children 为 1 的新对象&#34;] render --> patch[&#34;patch 旧按钮和新按钮&#34;] patch --> dom[&#34;沿用原来的 button,textContent 写成 1&#34;]

微任务是当前这段 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
flowchart TD mount[&#34;mount #app&#34;] --> clear[&#34;清空容器里原有内容&#34;] clear --> root[&#34;patch 根组件,旧 VNode 是空的&#34;] root --> setup[&#34;执行 setup,建立渲染 Effect&#34;] setup --> renderFn[&#34;渲染函数返回 button 的 VNode&#34;] renderFn --> create[&#34;document.createElement button&#34;] create --> text[&#34;textContent 写成 0&#34;] text --> listen[&#34;patchProp 挂上点击&#34;] listen --> insert[&#34;insertBefore 插进 #app&#34;]

根 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')。

flowchart TD start[&#34;patch 旧按钮对象和新按钮对象&#34;] --> same{&#34;两个参数是不是同一个对象&#34;} same -->|是| back[&#34;直接返回,页面不动&#34;] same -->|否| match{&#34;type 和 key 是否都相同&#34;} match -->|否| rebuild[&#34;卸掉旧节点,按新对象重新创建&#34;] match -->|是| reuse[&#34;n2.el = n1.el,沿用原来的按钮&#34;] reuse --> text[&#34;patchFlag 含 TEXT,且文字变了,textContent 写成 1&#34;]

两个参数是同一个对象时直接返回。被静态提升、多次渲染复用的那个对象会走到这条路,页面上的节点保持原样。

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,这样搬走的是原来那个元素。

相关推荐
jerrywus2 小时前
chrome-devtools-mcp 总弹授权、抢标签页?我是这样一步步配好的
前端·agent·mcp
weixin_BYSJ19872 小时前
电竞先锋网站---附源码02032
java·vue.js·spring boot·python·django·tomcat·php
郑州光合科技余经理2 小时前
同城小程序开发:同一笔单在用户端和商家端各显示到哪
android·java·开发语言·前端·后端·uni-app·php
zzppff1p3 小时前
用AI写了个小游戏
前端·javascript
陈随易3 小时前
终于 Token 自由了,AI 开支下降 10 倍
前端·后端·程序员
gqc583 小时前
Markdown 写完怎么验证?我现在的 4 步本地检查流程
前端
计算机魔术师4 小时前
我重算了新云的账,发现亏的不是算力,是时间
前端
wiseyao12194 小时前
uniapp scroll-view滚动到最后
前端·javascript·uni-app
陈随易4 小时前
从今天起,你只需要一个浏览器之Ego lite
前端·后端·程序员