vue3.2 在渲染阶段中是如何收集依赖?在更新阶段如何派发更新的?

2,529 阅读12分钟

2.jpg

前言

vue的一个重要的功能,数据响应式,也就是当数据更新时,vue会自动的去更新模板中所有与被修改数据相关的所有数据,有一个关键点,模板如何知道使用了什么数据,数据如何知道模板什么地方用到了自己,这就是vue中两个十分重要的东西,收集依赖和派发更新。

vue3.2的响应式优化

在vue3.2版本更新中,有一个十分重大的更新,vue.js3.2更新介绍,对于响应式的性能优化,

image.png

文中有一个介绍,直接百度翻译过来就是

  • 更高效的ref实现(读取速度提高约260%,写入速度提高约50%)
  • 依赖项跟踪速度提高约40%
  • 内存使用减少约17%
  • 模板编译器也得到了一些改进:
  • 创建普通元素VNode的速度提高约200%
  • 更积极的持续提升

这个优化室友GitHub的一位大佬@basvanmeurs 提出的,如此可怕的性能提升,简单解释一下原理:

先介绍几个东西:

  1. effectTrackDepth 记录和控制依赖嵌套的层数
  2. maxMarkerBits 可能会在依赖中会嵌套依赖,这是用来限制依赖嵌套,最多嵌套30层,但是一般达不到
  3. trackOpBit 依赖的归属标记,依靠effectTrackDepth产生,如何验证当前依赖是收集过的依赖、新收集的依赖,全是靠这个标记进行位运算, image.png

验证收集过的依赖的wasTracked函数,验证新收集的依赖的newTracked函数,至于这两个函数是如何验证的,前面说到过trackOpBit依靠 effectTrackDepth产生 计算方式:先通过trackOpBit = 1 << ++effectTrackDepth,如第一层依赖,effectTrackDepth为1,1位移1位,trackOpBit变成了2,所以当 假设dep.w为0,那dep.w & trackOpBit结果就是0,不大于0返回false,所以这个依赖不是收集过的依赖,再假设dep.n为2,那dep.n & trackOpBit结果就是2,大于0返回true,所以这个依赖是新收集的依赖,这样无需其他的新增和删除,只需在所有依赖中找到需要的就行。

依赖是如何嵌套的 关系是如何产生的

可以看看下面的例子

const count = ref(0);
const double = computed(() => {
  return 8 * computed(() => {
    return count * 2;
  });
});

computed会产生依赖,也就是当外层的computed执行的过程中,使用的数据依赖里面的computed的计算结果,这就产生了依赖嵌套,依赖嵌套的结构类似于树的结构,如下图 image.png 第一层的trackOpBit为2 如果用wasTracked函数或者是newTracked函数判断,里面的w和n必须是2,才能通过,如果是里面的w和n是4,那就代表这里依赖是被嵌套的依赖,这下就很好的区分依赖与依赖之间的关系,其他的优化的地方,等到后面的收集依赖和派发更新,再继续说

其他优化相关

几个工具方法
// 判断是否应该追踪
export function isTracking() {
  return shouldTrack && activeEffect !== undefined
}

// 全局暂停追踪
export function pauseTracking() {
  trackStack.push(shouldTrack)
  shouldTrack = false
}

// 全局可能(允许)追踪
export function enableTracking() {
  trackStack.push(shouldTrack)
  shouldTrack = true
}

// 恢复到 enableTracking() 或者是 pauseTracking()之前
export function resetTracking() {
  const last = trackStack.pop()
  shouldTrack = last === undefined ? true : last
}

// x & y > 0 x = y 2 & 4 = 0 2 & 2 = 2
// 验证是否为收集过的依赖
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

// 验证是否为新收集的依赖
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

// 初始化dep 标记为收集过的依赖
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit // set was tracked // 标记依赖已经收集
    }
  }
}

// 清除依赖的所有dep中删除effect 清除effect的信息
function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}
几个全局变量
// 记录当前的层数
let effectTrackDepth = 0

// effect 归属标记
export let trackOpBit = 1

// effect依赖嵌套最大层数 (注:嵌套就比如在一个依赖中带有其他依赖,如:computed中使用computed) 
// 最多支持30层 如果超出了这个范围 会进入清除模式
const maxMarkerBits = 30

// 全局 effectStack “栈”
const effectStack: ReactiveEffect[] = []

// 当前激活的effect
let activeEffect: ReactiveEffect | undefined

收集依赖

我们先来看看vue的收集依赖,这里或许有人会疑惑,什么是依赖。首先,一个vue模板在渲染成页面的时候(为了方便,文中所有的环境都是chrome浏览器)必须要有数据,否则页面就是一个空白页面,也就是说页面的展示依赖于数据,这就是所谓的依赖,而收集依赖就是将页面渲染时,使用到的数据收集起来。

但是收集依赖并不是去收集数据本身,而是数据去收集将自己渲染到页面中的函数或者是其他用到了数据的函数,这些函数有一个统一的名称:副作用函数,副作用这里就不展开,简单来说就是一个函数对函数外界有任何使用都是副作用函数。而这里就是渲染函数对外界的数据的引用

<div id="app">
  <p @click="addCount">{{ count }}</p>
</div>
<script src="https://unpkg.com/vue/vue@next"><script>
<script>
    const {createApp, ref} = Vue
    const App = {
        setup() {
            const count = ref(0)
            function addCount() {
                count.value++
            }
        }
    }
    const app = createApp(App)
    app.mount('#app')
</script>

这是一个简单的VueApp,就用这个例子来聊聊收集依赖,首先是先将模板转换成渲染函数render

const _Vue = Vue
const { createElementVNode: _createElementVNode } = _Vue

const _hoisted_1 = { id: "app" }
const _hoisted_2 = ["onClick"]

return function render(_ctx, _cache, $props, $setup, $data, $options) {
  with (_ctx) {
    const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    return (_openBlock(), _createElementBlock("div", _hoisted_1, [
      _createElementVNode("p", { onClick: addCount }, _toDisplayString(count), 9 /* TEXT, PROPS */, _hoisted_2)
    ]))
  }
}

// Check the console for the AST

可以发现函数中有一个对数据的引用_toDisplayString(count)这个函数会触发三个get拦截处理方法,分别是获取数据对象(setup返回的对象会被代理)的get、获取数据对象中的count时的getcount自己本身的get,

image.png image.png

在每次从数据对象setupSatate中取数据的时候,首先会经过RuntimeCompiledPublicInstanceProxyHandlers,这时vue渲染函数从外面获取数据的唯一接口,这个接口会调用PublicInstanceProxyHandlers去获取数据,在PublicInstanceProxyHandlers中会对用户试图访问的数据进行一系列的处理,判断数据是否能够被修改或者获取,如果数据是数组,会对数组中每一个值进行处理,这或许是为了后面能够确认数组中那些地方变化了,最后返回给渲染函数,而在这个过程中,就去触发数据本身的get拦截方法,如上面的这个例子,因为countref类型,所以就会走ref收集依赖的过程

image.png 先看看ref是如何去拦截值的获取和修改,ref的原理是把类产生的实例对象当成一个容器,一个容器只保存一个值,拦截则是存取描述符,也就是gettersetter。在渲染函数执行过程中,执行到了数据的getter,回去调用trackRefValue

image.png

trackRefValue作为ref收集依赖的入口,会先进行一些预处理:isTracking()只有全局允许追踪才会收集这个依赖,这样做的目的是为了防止收集到在收集依赖的过程中所产生的依赖,就不会出现在vue3版本中,每次收集依赖之前都需要清除一次依赖,提高了性能,以及一些基本处理:拿到原始ref,初始化ref上的dep,dep上有两个标记,w:表示为已经收集过了 n:表示这是新收集的依赖 这两个标记是作为后面派发更新时,依赖是否要执行的重要依据

image.png

最后开始执行核心逻辑trackEffect

trackEffect

image.png 在正式收集依赖之前,依旧会先做一些验证,如嵌套有没有超出限制,超出限制就是清除模式、是不是新收集的依赖,不是就标记是新收集的依赖(因为这里是要是收集这个依赖),再验证是不是收集过的依赖,不是才应该去收集依赖, 到最后如果是应该去收集依赖,就开始双向存储依赖,收集依赖的过程完毕,

派发更新

一个页面总是需要和用户交互,交互的内容大部分都是数据改变,这个时候就需要使用前面收集的依赖,通过修改数据,就会触发这些依赖运行,我们继续使用上面的例子

<div id="app">
  <p @click="addCount">{{ count }}</p>
</div>
<script src="https://unpkg.com/vue/vue@next"><script>
<script>
    const {createApp, ref} = Vue
    const App = {
        setup() {
            const count = ref(0)
            function addCount() {
                count.value++
            }
        }
    }
    const app = createApp(App)
    app.mount('#app')
</script>

在我触发点击事件,导致数据发生变化,数据的拦截处理方法就开始有动作了,setter拦截到了数据的修改去触发triggerRefValue派发更新的入口函数

image.png image.png triggerRefValue函数并没有做多少处理,在triggerEffect函数中,根据情况是将依赖放入队列还是直接执行,派发更新的流程执行完毕,

这时会发现执行其实是effectrun函数或者是effect中的scheduler函数,所以真正核心其实是effect是如何产生的,内部做了那些优化处理,这才是我们研究的重要点,

ReactiveEffect

effect其实是让数据和渲染函数产生一个依赖关系,在vue3版本,也是有创建的effect的,但是那个时候只是定义了一个函数createReactiveEffect,通过这个函数创建,在vue3.2版本更新之后,就需要通过实例化来创建,vue3.2内部实现了一个ReactiveEffect类,下面就一部分一部分去解析这个类

构造函数

image.png fn是数据变化的需要执行的副作用函数,但是有的时候fn可能就已经是effect了,所以需要去找到fn的原始函数,scheduler是副作用函数在队列中的执行的函数,recordEffectScope是属于EffectScope的处理,这里不多赘述,感兴趣可以去看看effectScope.ts文件

run函数

code.png run函数是依赖执行的开始,scheduler内部其实也是调用了run函数,依赖的执行模仿了函数的入栈和出栈的方式,在全局有一个effectStack作为effect的“执行栈”,每当有一个effect开始执行,就会进入effectStack,这就是入栈,直到整个effect执行完毕之后,effect就会被移出effectStack,这就是出栈。

这也可以防止一个依赖执行多次,在effect执行前,会先验证是不是已经在执行了。如果不是,走正常流程,先进入栈,开启追踪,产生一个新的trackOpBit,判断是否超过嵌套限制,超过了就会走清除模式,但是一般都是不会超过的,正常就是标记为是收集过的依赖,就开始执行副作用函数,可能这个依赖会触发其他依赖,如果是嵌套的,在嵌套依赖执行前,会先加一,依赖执行完毕之后会退回去,trackOpBit就又变回去了。这样就可以区分依赖。且会使用initDepMarkers对将要执行的依赖标记为收集过的依赖,

在副作用函数执行完毕之后的一些处理:先是对依赖进行整理,在前面说得到过,如果在页面渲染的过程中,会用到数据,但是有些数据并不会影响页面,但是依旧在初次渲染的过程中被收集了,在vue3是把全部依赖删除后重新收集,十分浪费性能,优化过后,更多的是去用依赖上的标记去判断,很少对删除和新增的操作,提高了性能

image.png

最后就是将执行完毕的effect移出栈,回到恢复到 enableTracking() 或者 pauseTracking()之前,拿到最后栈区最后一个effect,run函数执行完毕

stop函数

image.png

这个函数功能就比较简单了,只是单纯为停止依赖,和清除effect自身的所有信息,如果后面有添加onStop函数就执行就行了

类的其他属性

image.png

deps:是当前副作用用到的dep全部放在自己本身,方便直接读取

computedcomputed产生的依赖比较和watch等不同,需要做一些处理特殊的处理,(在computed.ts文件)

allowRecurse:允许嵌套

ReactiveEffect相关的API

有时候,用户希望自定义一个函数能够跟着数据的变化而执行,但是实例化一个ReactiveEffect有点费劲,提供了几个api

effect函数

effect函数可以很方便的去产生一个effect, 且会返回一个effect runner,可以用于后面停止副作用函数随着数据变化而执行,

image.png 在让自定义副作用函数的和数据产生依赖关系之后,也可以某个时刻手动运行,其中,有一个参数optionseffect传递配置,如懒加载,在没有懒加载或者没有传递配置的情况,函数会立即运行一次。实现核心主要依赖于ReactiveEffect类。

stop函数

image.png effect函数执行最后,返回的effect runnereffect本身会有相互引用,可以很方便的在停止副作用函数的执行,可以直接runner.effect.stop(),也可以调用stop()函数

其他的响应式API的收集依赖和派发更新

相对于ref单独对值的收集依赖和派发更新,vue也有不一样的处理方法,也就tracktrigger函数,与trackRefValuetriggerRefValue一样,他们最终都是去调用了trackEffecttriggerEffect函数,只是在调用之前做的处理不同。在此之前先介绍一个存储数据于effect依赖关系的对象targetMap,这是一个WeakMap对象,使用WeakMap的原因是因为浏览器会自动的吧WeakMap内部可能没有用的数据当作垃圾回收。结构如下

{
  target1: {
    key1: {
      effect1,
      effect2
      ...
    },
    key2: {

    }
    ...
  },
  target2: {
    
  }
  ...
}

track函数

image.png 在全局允许收集依赖的情况下,会先去targetMap试图找到当前依赖是否存在,不存在就是一一创建,最后传递给trackEffect,其中dep的创建是通过createDep且将这些依赖标记为新收集的依赖

image.png 这是为了包装依赖,方便配合优化,且dep是一个Set对象,这样副作用函数就不会添加.

trigger函数

trigger的执行过程就比较复杂,我们一步步分析,

image.png

首先肯定确定依赖的数据存在,依赖的数据不存在那还更新啥,直接结束,

image.png 更新也分不同情况,第一种情况是清空操作,如数组清空、Map和Set清空,就是直接执行所有的依赖,第二种情况是数据是数组且是新增操作,则会找到数组中所有新增的依赖,

image.png

最后一种情况就是对集合类型的删除新增操作,如果集合类型就找到在track阶段设置的keyMAP_KEY_ITERATE_KEYITERATE_KEY的依赖,两个区别就在于Map类型带有key,而Set类型不带,如果是数组就会带着length的依赖一起收集,(数组长度也是一个依赖,删除和新增项都会影响到length),至于为什么只有在ADD的时候才收集数组的length的依赖,因为数组没有delete方法和set方法,其他两种情况都是调用了自己本身的上的deleteset方法

image.png

到最后的调用triggerEffect也分情况,一种是数据变化产生的两个嵌套依赖,一种是单纯的数据修改,产生的多个依赖,每个依赖也需要使用creatDep进行包装。

总结

响应式的优化,对于vue的性能提升十分巨大,主要有一下几点,

  1. ref不在使用track去收集依赖,而是重新写了一个方法trackRefValue,将所有收集的依赖放在了自己身上deps中,在需要派发更新的时候只需要去遍历deps即可。无需要去执行track复杂查找,提高了性能,而track依旧是将所有的依赖放在了全局变量target

  2. 对依赖的包装,通过w和n两个标记的位运算,可以更好的管理依赖,不需要每次都把所有的依赖清空了,再去收集依赖,可以通过w和n两个标记判断依赖是否为需要的,减少了对内存的使用,(不需要对SetMap进行多次的操作)

  3. ReactiveEffect的实现,这样可以更好的对effect生产和管理。

这一次的优化可以说不仅仅是对vue的优化,而是对所有的VueApp有巨大的优化,

以上就是我对收集依赖和派发更新的理解,如果有说的不对的或遗漏的,也请各路大佬指出,如果有更好的理解,也希望大佬能在评论区中说明,谢谢。