likes
comments
collection
share

vue3 源码学习之渲染器

作者站长头像
站长
· 阅读数 21

前言

写在前面:学习源码最好的方式是自己逐行 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 源码,可以知道源码执行流程如下: vue3 源码学习之渲染器

几个重要的函数: baseCreateRenderer函数中包含了许多闭包函数。以下是这些闭包函数的列表:

  1. mountElement
  2. patchKeyedChildren
  3. patchChildren
  4. patchProps
  5. patchElement
  6. processCommentNode
  7. processText
  8. processFragment
  9. processComponent
  10. processElement
  11. mountChildren
  12. mountComponent
  13. setupRenderEffect
  14. unmount
  15. patch
  16. render
  17. ...

baseCreateRenderer 函数作为 Vue 渲染过程的核心,负责创建渲染器,返回一个 render 函数。render 函数在被调用的时候,会执行 patch 函数。patch 函数执行的过程中,会对组件创建、更新和卸载等操作进行处理。对于组件部分,主要包含以下几个步骤:patch --> processComponent --> mountComponent

在 mountComponent 的过程中,主要会发生以下几个关键步骤:

  1. 创建组件实例:调用 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 使用这些信息来保持数据与组件的渲染结果同步,并管理组件的生命周期。

  1. 初始化组件状态:调用 setupComponent 函数,对组件实例的状态(如 datapropsattrsslots 等)进行标准化处理。
  2. 设置组件渲染过程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 官网介绍: vue3 源码学习之渲染器 从高层面的视角看,Vue 组件挂载时会发生如下几件事:

  1. 编译:Vue 模板被编译为渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
  2. 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。
  3. 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。

运行周期

Vue3 中生命周期函数会按如下顺序执行:

OptionsComposition描述SSR是否调用
setup最先运行
beforeCreate会在实例初始化完成、props 解析之后、data() 和 computed 等选项处理之前立即调用
created当这个钩子被调用时,以下内容已经设置完成:响应式数据、计算属性、方法和侦听器。然而,此时挂载阶段还未开始,因此 $el 属性仍不可用。
beforeMountonBeforeMount当这个钩子被调用时,组件已经完成了其响应式状态的设置,但还没有创建 DOM 节点。它即将首次执行 DOM 渲染过程。
mountedonMounted已经完成 DOM 节点创建。
beforeUpdateonBeforeUpdate在组件即将因为一个响应式状态变更而更新其 DOM 树之前调用。
updatedonUpdated在组件因为一个响应式状态变更而更新其 DOM 树之后调用。
beforeUnmountonBeforeUnmount在一个组件实例被卸载之前调用。
unmountedonUnmounted可以在这个钩子中手动清理一些副作用,例如计时器、DOM 事件监听器或者与服务器的连接。

setupComponent 函数会调用 setupStatefulComponent 函数。在 setupStatefulComponent 函数中,会执行 setup 函数,并调用 finishComponentSetup 函数。因此,setup 函数是最先执行的。接着会执行 options 语法中的 beforeCreate 部分,对数据进行响应化处理,最后执行 created 函数。这样,在 created 生命周期中就可以访问到通过 data 定义的数据。

vue3 源码学习之渲染器

setupRenderEffect 函数中的组件更新函数 componentUpdateFn 可以分为挂载、更新和卸载三种状态。

  • 挂载状态:在挂载状态下,会首先执行 beforeMount 函数,进行 DOM 的挂载,然后执行 mounted 函数。
  • 更新状态:在更新状态下,会首先执行 beforeUpdate 函数,进行 DOM 的更新,然后执行 updated 函数。
  • 卸载状态:在卸载状态下,会首先执行 beforeUnmount 函数,然后卸载组件,最后执行 unmounted 函数。

vue3 源码学习之渲染器

父子组件的挂载过程如下:

  1. 父组件:setup --> beforeCreate --> created --> beforeMount
  2. 子组件:setup --> beforeCreate --> created --> beforeMount --> mounted
  3. 父组件:mounted

父子组件的更新过程如下:

  • 当子组件内部数据变化时,不会影响父组件:
    1. 子组件:beforeUpdate --> updated
  • 当父组件内部数据变化时,不会影响子组件:
    1. 父组件:beforeUpdate --> updated

通过 props 变化:

  • 父组件:beforeUpdate
  • 子组件:beforeUpdate --> updated
  • 父组件:updated

父子组件的卸载过程如下:

  1. 父组件:beforeUnmount
  2. 子组件:beforeUnmount --> unmounted
  3. 父组件:unmounted

diff 算法

新旧虚拟DOM对比采用的算法为 diff 算法,源码位置在 packages/runtime-core/src/renderer.ts,关键函数是 patchKeyedChildrenpatchKeyedChildren 将 diff 算法分为 5 种情况:

  1. 新旧节点从头开始对比 示例节点: 旧:(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
}
  1. 新旧节点从尾开始对比 示例节点: 旧:a (b c) 新:d e (b c) 新旧节点 isSameVNodeType 为 true,表示是相同节点,会进行 patch 操作。

  2. 新增节点 示例节点: 旧:(a b) 新:(a b) c 经过情况1、2处理以后,当 i > e1 & i <= e2,说明需要新增节点

  3. 删除节点 示例节点: 旧:(a b) c 新:(a b) 经过情况1、2、3 处理以后,当 i > e2 & i <= e1,说明需要删除旧节点

  4. 未知序列 示例节点: 旧: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);

页面数据变化如下:

vue3 源码学习之渲染器vue3 源码学习之渲染器vue3 源码学习之渲染器vue3 源码学习之渲染器vue3 源码学习之渲染器vue3 源码学习之渲染器

这一步的操作过程是:

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
评论
请登录