Vue 渲染原理:防脱发指南

72 阅读11分钟

核心流程一图流

image.png

创建

建议先阅读渲染流程再阅读创建流程

Setup

vue2 new Vue 创建实例

  • setup启动函数中定义了许多变量,props,data,methods等
  • 在组件的初始化节点,vuejs 内部处理这些options,将定义的变量添加到组件实例上,
  • render的时候内部通过 with(this){} (通过闭包)去访问组件实例内部的变量

所以 vue2 是将响应式数据全量存储在了vue实例对象闭包中,执行 render 函数的时候通过 with(this) 获取到当前的数据生成 vnode 。new Function 生成的函数默认在全局作用域执行,但是可以通过 with(this) 改变

vue3 new 对象创建实例

程序渲染流程

  1. 创建组件实例

    1. 定义组件属性
  2. 设置组件实例

    1. 初始化props,插槽

    2. 有状态组件则配置相关设置

    3. 创建上下文代理

      1. 不同状态的数据存储在不同的地方比如setupState|ctx|data|props (vue2形式的api)
      2. 执行渲染函数的时候,直接访问渲染上下文 instance.ctx中的属性,通过instance.ctx的代理将对属性的访问和修改代理到setupState|ctx等地方
  3. 设置并运行带有副作用的渲染函数(记录到effect中)

应用程序初始化

  • creatApp 创建渲染器

  • app.mount 重写 mount

    • 在不同的宿主载体上抹平平台差异
    • 输出标准化容器,触发标准渲染流程,提供跨平台能力
  • mount 负责触发渲染流程

    • createNode 创建Vnode
    • render 渲染Vnode
  • Vnode 元素JS对象

    • 注释vnode
    • htmlVnode
    • 组件vnode
    • 抽象渲染过程
    • 提供跨平台能力
    • 使用vnode不一定会比直接操作DOM性能更好

创建vnode

  1. 创建组件实例 Vnode

通过对象的方式创建当前组件的实例

  1. 设置组件实例

instance保留组件的上下文信息,插槽等内容,同时组件上挂载了组件的渲染副作用 compnentEffect

渲染vnode

  1. renderComponetRoot 渲染生成对应子树 subTree vnode

  2. patch 将 vnode 挂载到 container 中

  3. 处理普通DOM元素,processElement,挂载或者更新:掉用 patch/mountElement

    1. 创建DOM元素 createElement 是平台相关的渲染函数,web平台则是操作dom
    2. 处理Prop Style Event
    3. 处理Child mountChildren 递归 patch 深度优先遍历树
  4. 此时DOM元素都被挂载到 container 容器上了

渲染流程

渲染流程包括了首次渲染和更新,首次渲染会compile/从dist中获取 render Function,后续直接用render function 生成的新的 vnode 树

总的来说,vue的渲染流程就是从 template 到 vnode ,全量遍历 vnode 树,局部 diff 和更新 vnode 树,最终挂载到dom上的一个过程

从 template 视角看:

  1. template 被 mount 函数读取,通过compileToFunc 函数编译成 函数字符串
  2. 在首次执行或者响应式数据触发的时候被执行 new Function ( funcString ) ,函数通过闭包来访问当前的响应式数据, 创建了模板对应的vnode树
  3. 这个 vnode 树被保存在内容存中,直到下一次diff完成,这棵树被替换

从响应式数据的视角看

  1. 第一次执行的时候这些响应式数据被收集到watcher中
  2. 当响应式数据发生变化的时候,watcher 会执行

$mount 函数

  1. mount函数从 xx.vue 中获取 template ,将 template 及其对应的副作用丢入 compileToFunctions

  2. compileFunctions 中调用compile 最终会返回一个 render 的函数字符串

  3. new 一个 Watcher 将 template 和响应式数据联系起来

  4. patch 将 Vnode 经过比较后挂载到 DOM 结构上

compileToFunctions

故名思义,这个函数将 mount 函数交过来的 template 编译成 function,但是没有直接执行

  • 函数编译结果被缓存,有缓存则用缓存,直接返回

    • 对于vue loader预编译,会放在dist中 (磁盘中)
    • 对于已经获取的render,会在 vm.option.render 中(内存中)
  • 没有缓存,执行 compile 函数,将渲染 template 到函数编译出来,以字符串的形式传递

compile

compile 是将 template 编译成 render 字符串的形式的函数

为什么是字符串而不是函数本身

  1. 环境兼容,字符串可以被序列化后传递,而函数可能由于环境不同而报错
  2. 在vue-loader中,将模板编译成了字符串,避免了函数闭包作用域的污染
  3. 支持开发环境按需编译,提高运行开销

parse

将 template 编译成 AST

optimize

对部分AST 标记静态节点优化比较过程

generate

将AST拼接生成 render 函数的字符串

对于vue2 : 最外层需要包裹一个 with(this){}。vue2需要通过闭包访问全局变量

对于vue3 :在使用响应式数据的时候显示的绑定了数据,他们会被放在context的各个属性下,在使用render函数的时候传入当前的实例上下文即可 render(vm.ctx)

Watcher

简单来说,watcher 在响应式数据更新的时候感知到,然后触发重新更新到流程。(以下流程)

patch

image.png

patch 是一个深度优先遍历树的过程,这个过程遍历的是整颗树,但是不是每一个节点都会执行对比和挂载。

通过响应式数据定位到受影响的节点,再去执行对比。对于标记了静态的节点会直接跳过diff

在 patch 中执行 diff 算法,同层比较节点

通过执行 compileToFuncition 中的函数字符串 (new Function ()) 执行渲染函数

这里讨论更新的情况

  • patch 从根节点开始,传入新旧 vnode

    • 如果相同,进入节点的比较 patchVnode
    • 如果不同,销毁旧节点创建新节点
  • 最终将 vnode 生成 dom 挂载到 container 上

patchVnode

两个相同的节点传入对孩子做进一步的比较

  • 复用当前元素的 DOM 结构 ( elm属性 )

  • 通过编译时的标记剪枝 , 对于静态节点,直接复用,对于动态节点,则更新props

  • 处理子节点

    • 都有子节点的情况:调用 updateChildren(elm,oldch,newch) 执行diff
    • 一方有一方没有的直接删除或者添加

updateChildren

传入的是相同的父节点对应的子节点列表,在这个函数中执行diff算法

  • 对于相同的头/尾,直接复用节点,递归进入 patchVnode
  • 对于不同的节点,执行 DIFF 计算更改的操作

避免子组件重复更新

updateComponent:invalidateJob避免子组件自身变化导致重复更新,然后执行子组件的 instance.update 触发子组件更新

快速Diff算法

diff算法依赖于key,如果没有key,则vue3会跳过diff,直接全量patch。源码中通过patchUnkeyedChildren对其做分支判断

下面提到的是否相同,都是用key做判断的

预处理前后置节点

从前往后遍历新旧节点

  • 若节点相同,直接调用patch更新节点信息
  • 指针停留在第一个不同节点处,记录下标为 i

从后往前遍历新旧节点

  • 若节点相同,则直接patch更新节点信息
  • 指针停留在第一个不同节点处,记录下标为,旧 e1,新e2

仅新增

i > e1 && i <= e2 ,添加i 到 e2 的新节点列表 (闭区间)

仅卸载

i >= e2 && i < e1, 卸载从 i 到 e1 的旧节点列表(闭区间)

其他复杂情况

i < e2 && i < e2

目的

获取一个source数组:

截取需要复杂操作的部分新旧节点,source长度等同于它,source数组记录了对应下标的新子节点子序列的值在旧子节点整序列中的值

这里需要两个循环,时间复杂度为O(n^2)

vue3的实现

  1. 处理相同的前缀 ,记录下标为 i
  2. 处理相同的后缀,记录下标分别为 e1 是旧,e2 是新

e1 e2 i 都是相同的最后一个元素的下一个

    // 1. 处理相同前缀
while (i < oldLen && i < newLen && sameVNode(oldChildren[i], newChildren[i])) i++

    // 2. 处理相同后缀
let e1 = oldLen - 1
let e2 = newLen - 1
while (e1 >= i && e2 >= i && sameVNode(oldChildren[e1], newChildren[e2])) {
    e1--
    e2--
}

3. 根据i e1 e2 的关系判断策略

1.  仅需要增加 e1 < i
2.  仅需要删除 e2 < i
3.  除此之外是复杂策略

4. 处理仅需要增加 :从 i 到 e2 左闭右闭都是需要增加的

  1. 处理仅需要删除 :从 i 到 e1 左闭右闭都是需要删除的
// 3. 新节点需要新增
if (i > e1) {
for (let j = i; j <= e2; j++) {
    const node = domAPI.createElement(newChildren[j].type)
    const anchor = parent.children[e1 + 1] || null
    domAPI.insertBefore(parent, node, anchor)
}
return
}

// 4. 旧节点需要删除
if (i > e2) {
for (let j = i; j <= e1; j++) {
    domAPI.removeChild(parent, oldChildren[j]._el)
}
return
}

6. 处理复杂情况(对于新节点复杂的区间是从 i 到 e2)

1.  记录从 i 到 e2 新节点到下标的映射 `keyToNewIndexmap`

2.  从 i 到 e1 遍历旧节点

3.  获取旧节点key在新节点中的下标

4.  如果没有则移除这个节点

5.  如果有

    1.  则记录当前节点在新数组中的相对位置(以从i 到 e2 为参考系)source\[k - newStart] = j
    2.  计算source中的最长递增子序列(注意要忽略-1)
const keyToNewIndexMap = new Map()
for (let j = i; j <= e2; j++) {
    keyToNewIndexMap.set(newChildren[j].key, j)
}
const source = new Array(e2 - i + 1).fill(-1)
const oldStart = i
const newStart = i

for (let j = oldStart; j <= e1; j++) {
    const oldChild = oldChildren[j]
    const k = keyToNewIndexMap.get(oldChild.key)

    if (k !== undefined) {
        source[k - newStart] = j
        if (k < newStart) {
            domAPI.insertBefore(parent, oldChild._el, parent.children[k] || null)
        }
    } else {
        domAPI.removeChild(parent, oldChild._el)
    }
}

7. 从后向前遍历 source 数组

1.  如果当前位置下标是 -1 ,说明在旧数组中没有,则挂载元素,挂载在`(parent.children[ j + newStart ])`前面
2.  如果有,但是不是当前子序列元素(初始是最后一个),则复用旧元素 `oldChildren[source[j]]`,挂载到 paret.children\[j + newStart + 1] 前面
3.  如果在子序列中,不用操作,让当前子序列元素向前移动
const lis = getSequence(source)
let lisPtr = lis.length - 1
for (let j = source.length - 1; j >= 0; j--) {
    if (source[j] === -1) {
        const node = domAPI.createElement(newChildren[j + newStart].type)
        domAPI.insertBefore(parent, node, parent.children[j + newStart] || null)
    } else if (j !== lis[lisPtr]) {
        const oldChild = oldChildren[source[j]]
        domAPI.insertBefore(
            parent,
            oldChild._el,
            parent.children[j + newStart + 1] || null
        )
    } else {
        lisPtr--
    }
}
keyToNewIndexMap
  • key是新数组的 vnode key
  • value 是在新数组中的位置
  • 用于判断旧节点是否还在新数组中,以及通过旧元素所在的位置计算出 source
source 数组
  • source 的长度是新数组复杂区间的长度(初始化为 -1)
  • key 保存的是节点在新数组的复杂区间的相对位置
  • value 保存的是当前节点在旧数组中的绝对位置
  • 如果绝对位置是不变的(相对于旧数组)那么说明他们可以作为锚点不被移动

一句话:source 是以新的节点顺序排列的旧节点下标

优化后获取source数组的时间复杂度为O(n)

求解最长递增子序列 (动态规划)

no.300 找的是长度,但是我们要的是序列,所以需要改进一下

300.最长递增子序列

力扣题目链接(opens new window)

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:

  • 输入:nums = [10,9,2,5,3,7,101,18]
  • 输出:4
  • 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:

  • 输入:nums = [0,1,0,3,2,3]
  • 输出:4

示例 3:

  • 输入:nums = [7,7,7,7,7,7,7]
  • 输出:1

提示:

  • 1 <= nums.length <= 2500
  • -10^4 <= nums[i] <= 104

题解:

  1. 确定dp数组的含义

    1. dp[i] 是以 i 为结尾的最长子序列的长度
  2. 确定状态转换方程

    1. 当nums[i] > nums[j] , dp[i] = max(dp[j],dp[i] )
    2. 为什么要比较dp[i], 因为我们要遍历 i 前的 j ,此时要取一个最大的 dp[i]
  3. 确定dp数组初始化

    1. dp数组初始化为全 1
    2. i 的遍历从 1 开始,因为0一定是0
  4. 举例遍历dp数组

部分图片来自网络,侵删。笔者才疏学浅,各位读者多多担待,不吝赐教。