vue3 源码学习之渲染器
前言
写在前面:学习源码最好的方式是自己逐行 debug,理解每一行代码的作用和实现过程,同时将自己的理解记录下来,形成自己的笔记。这样不仅能够深入理解源码,还能够加深对编程语言的理解,提高自己的编程能力。不是一味地看文章或视频,而是要自己动手实践哦。
渲染流程
用来学习源码的示例代码如下:
<body>
<div id="app"></div>
</body>
<script>
const { reactive, h, render } = Vue
const component = {
setup() {
const obj = reactive({
name: '张三'
})
setTimeout(() => {
obj.name = '李四'
}, 2000);
return () => h('div', obj.name)
}
}
const vnode = h(component)
// 挂载
render(vnode, document.querySelector('#app'))
</script>
通过一步步 debugger 源码,可以知道源码执行流程如下:

几个重要的函数:
baseCreateRenderer函数中包含了许多闭包函数。以下是这些闭包函数的列表:
mountElementpatchKeyedChildrenpatchChildrenpatchPropspatchElementprocessCommentNodeprocessTextprocessFragmentprocessComponentprocessElementmountChildrenmountComponentsetupRenderEffectunmountpatchrender- ...
baseCreateRenderer 函数作为 Vue 渲染过程的核心,负责创建渲染器,返回一个 render 函数。render 函数在被调用的时候,会执行 patch 函数。patch 函数执行的过程中,会对组件创建、更新和卸载等操作进行处理。对于组件部分,主要包含以下几个步骤:patch --> processComponent --> mountComponent。
在 mountComponent 的过程中,主要会发生以下几个关键步骤:
-
创建组件实例:调用
createComponentInstance函数创建一个新的组件实例(instance)。每个实例都具有唯一的uid,并关联大量属性。instance是一个 Vue 组件实例,在其上挂载了组件的各种属性和方法,主要包括:uid:组件实例的唯一标识符。vnode:组件的虚拟 DOM 节点。type:组件的类型。parent:组件的父级实例。appContext:组件所属的应用上下文。root:组件的根实例。next:组件的下一个兄弟节点。subTree:组件的子树。update:组件更新函数。scope:组件的作用域。render:组件的渲染函数。proxy:组件的代理对象。components:组件中注册的其他组件。directives:组件中使用的指令。propsOptions:组件的属性选项。emitsOptions:组件的事件选项。emit:触发事件的函数。emitted:组件已触发的事件。data:组件的数据。props:组件的属性。attrs:组件的特性。slots:组件的插槽。refs:组件的引用。setupState:组件的 setup 函数状态。setupContext:组件的 setup 上下文。isMounted:组件是否已挂载。isUnmounted:组件是否已卸载。isDeactivated:组件是否已停用。bc:组件的 onBeforeCreate 生命周期钩子函数。c:组件的 created 生命周期钩子函数。bm:组件的 beforeMount 生命周期钩子函数。m:组件的 mounted 生命周期钩子函数。bu:组件的 beforeUpdate 生命周期钩子函数。u:组件的 updated 生命周期钩子函数。
这些属性和方法构成了一个组件实例的完整描述,Vue 使用这些信息来保持数据与组件的渲染结果同步,并管理组件的生命周期。
- 初始化组件状态:调用
setupComponent函数,对组件实例的状态(如data、props、attrs、slots等)进行标准化处理。 - 设置组件渲染过程:
setupRenderEffect函数负责设置组件的渲染过程。其中,componentUpdateFn函数用于处理组件更新。通过调用renderComponentRoot方法生成新的虚拟 DOM,并将其挂载在instance.subTree上。为了生成新的虚拟 DOM,会调用组件的render函数。该render函数是由编译器根据组件的字符串模板template生成的。此外,setupRenderEffect函数内部创建了一个effect实例。当组件内的数据发生变更时,依赖响应被触发,进而执行componentUpdateFn方法。该方法生成新的虚拟 DOM,与旧的虚拟 DOM 进行对比,根据对比结果更新真实的 DOM 节点,从而实现数据响应能力。
Vue 渲染引擎的核心原理是通过模板字符串 template 的编译产生虚拟 DOM,并利用响应式代理来实现数据变更时自动同步到 DOM 的功能。Vue 渲染过程包括创建组件实例、初始化组件状态、设置响应式代理、通过 effect 监听数据变更、生成新的虚拟 DOM、更新真实 DOM 等关键步骤。
来自 Vue3 官网介绍:
从高层面的视角看,Vue 组件挂载时会发生如下几件事:
- 编译:Vue 模板被编译为渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
- 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。
- 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。
运行周期
Vue3 中生命周期函数会按如下顺序执行:
| Options | Composition | 描述 | SSR是否调用 |
|---|---|---|---|
| setup | 最先运行 | ||
| beforeCreate | 会在实例初始化完成、props 解析之后、data() 和 computed 等选项处理之前立即调用 | ||
| created | 当这个钩子被调用时,以下内容已经设置完成:响应式数据、计算属性、方法和侦听器。然而,此时挂载阶段还未开始,因此 $el 属性仍不可用。 | ||
| beforeMount | onBeforeMount | 当这个钩子被调用时,组件已经完成了其响应式状态的设置,但还没有创建 DOM 节点。它即将首次执行 DOM 渲染过程。 | 否 |
| mounted | onMounted | 已经完成 DOM 节点创建。 | 否 |
| beforeUpdate | onBeforeUpdate | 在组件即将因为一个响应式状态变更而更新其 DOM 树之前调用。 | 否 |
| updated | onUpdated | 在组件因为一个响应式状态变更而更新其 DOM 树之后调用。 | 否 |
| beforeUnmount | onBeforeUnmount | 在一个组件实例被卸载之前调用。 | 否 |
| unmounted | onUnmounted | 可以在这个钩子中手动清理一些副作用,例如计时器、DOM 事件监听器或者与服务器的连接。 | 否 |
setupComponent 函数会调用 setupStatefulComponent 函数。在 setupStatefulComponent 函数中,会执行 setup 函数,并调用 finishComponentSetup 函数。因此,setup 函数是最先执行的。接着会执行 options 语法中的 beforeCreate 部分,对数据进行响应化处理,最后执行 created 函数。这样,在 created 生命周期中就可以访问到通过 data 定义的数据。

setupRenderEffect 函数中的组件更新函数 componentUpdateFn 可以分为挂载、更新和卸载三种状态。
- 挂载状态:在挂载状态下,会首先执行
beforeMount函数,进行 DOM 的挂载,然后执行mounted函数。 - 更新状态:在更新状态下,会首先执行
beforeUpdate函数,进行 DOM 的更新,然后执行updated函数。 - 卸载状态:在卸载状态下,会首先执行
beforeUnmount函数,然后卸载组件,最后执行unmounted函数。

父子组件的挂载过程如下:
- 父组件:
setup-->beforeCreate-->created-->beforeMount - 子组件:
setup-->beforeCreate-->created-->beforeMount-->mounted - 父组件:
mounted
父子组件的更新过程如下:
- 当子组件内部数据变化时,不会影响父组件:
- 子组件:
beforeUpdate-->updated
- 子组件:
- 当父组件内部数据变化时,不会影响子组件:
- 父组件:
beforeUpdate-->updated
- 父组件:
通过 props 变化:
- 父组件:
beforeUpdate - 子组件:
beforeUpdate-->updated - 父组件:
updated
父子组件的卸载过程如下:
- 父组件:
beforeUnmount - 子组件:
beforeUnmount-->unmounted - 父组件:
unmounted
diff 算法
新旧虚拟DOM对比采用的算法为 diff 算法,源码位置在 packages/runtime-core/src/renderer.ts,关键函数是 patchKeyedChildren,patchKeyedChildren 将 diff 算法分为 5 种情况:
- 新旧节点从头开始对比 示例节点: 旧:(a b) c 新:(a b) d e 新旧节点 isSameVNodeType 为 true,表示是相同节点,会进行 patch 操作。isSameVNodeType 函数为:
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}
-
新旧节点从尾开始对比 示例节点: 旧:a (b c) 新:d e (b c) 新旧节点 isSameVNodeType 为 true,表示是相同节点,会进行 patch 操作。
-
新增节点 示例节点: 旧:(a b) 新:(a b) c 经过情况1、2处理以后,当 i > e1 & i <= e2,说明需要新增节点
-
删除节点 示例节点: 旧:(a b) c 新:(a b) 经过情况1、2、3 处理以后,当 i > e2 & i <= e1,说明需要删除旧节点
-
未知序列 示例节点: 旧:a b [c d e] f g 新:a b [e c d h] f g
源码 debugger 使用代码如下:
const { h, render } = Vue
const vnode = h('ul', [
h('li', {
key: 1
}, 'a'),
h('li', {
key: 2
}, 'b'),
h('li', {
key: 3
}, 'c'),
h('li', {
key: 4
}, 'd'),
h('li', {
key: 5
}, 'e'),
h('li', {
key: 6
}, 'f'),
h('li', {
key: 7
}, 'g')
])
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h('ul', [
h('li', {
key: 1
}, 'n-a'),
h('li', {
key: 2
}, 'n-b'),
h('li', {
key: 5
}, 'n-e'),
h('li', {
key: 3
}, 'n-c'),
h('li', {
key: 4
}, 'n-d'),
h('li', {
key: 8
}, 'n-h'),
h('li', {
key: 6
}, 'n-f'),
h('li', {
key: 7
}, 'n-g')
])
render(vnode2, document.querySelector('#app'))
}, 2000);
页面数据变化如下:
![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
|---|
这一步的操作过程是:
1)旧节点更新:根据旧节点的 key 在新节点序列中找到对应的节点,patch 新元素。我们可以看到旧节点 c、d、e 会被更新为 n-c、n-d、n-e
2)旧节点删除:如果旧节点根据 key 在新节点序列中没有找到对应的节点,会执行 unmount 操作,删除掉该旧节点
3) 新节点新增:经过上面2步骤以后,旧节点遍历完成,对于新节点只剩下新增和移动两种操作。方法内部定义了一个数组 newIndexToOldIndexMap,作为一个映射,关联新索引(newIndex)和旧索引(oldIndex),旧的索引默认有一个 +1 的偏移量,用于确定最长子序列。以示例节点为例说明:newIndexToOldIndexMap 默认为 [0 0 0 0]
新节点 a b [e c d h] f g
index 0 1 [2 3 4 5] 6 7
key 1 2 [5 3 4 8] 6 7
newIndexToOldIndexMap [0 0 0 0]
[5 3 4 0]
在循环遍历旧节点序列的时候,会将旧节点在新节点序列的 newIndex 放入到相应的 newIndexToOldIndexMap 中。推理可知,如果 newIndexToOldIndexMap 的某个元素仍然为 0, 说明该元素对应的新节点没有旧节点与之对应,需要做新增节点的操作。
4)新节点移动:newIndexToOldIndexMap 数组,旧索引默认有 +1 的偏移量,因此需要找到该数组的最长子序列,然后移动相关元素。同时,还有一个变量 moved 用于标记是否需要移动。原理是在求 newIndex 时,索引应为递增的。如果发现 newIndex 不是递增的,说明节点位置不正确,需要进行移动操作。
// 示目前为止找到的最大新索引
let maxNewIndexSoFar = 0
...
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
关于最长子序列的讲解:Vue3 DOM Diff 核心算法解析 | 童欧巴博客
转载自:https://juejin.cn/post/7241480662137684029





