Vue3.2中reactivity的优化

3,282 阅读9分钟

前言

  • More efficient ref implementation (~260% faster read / ~50% faster write)
  • ~40% faster dependency tracking
  • ~17% less memory usage

这是一位社区大佬@basvanmeurs,在Vue3.2中,对响应式做出的优化

  1. ref API 的读效率提升 260%,写效率提升约为 50% 。
  2. 依赖收集的效率提升 40%
  3. 内存占用减少 17% 。

看到这种程度的提升,我只能说:我斑愿称你为最强!

这位大佬是怎么做到的呢?我们来从Vue3.0开始说起。

一、Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)

1、 用法

new Proxy(target, handler)

const origin = {}
const proxy = new Proxy(origin, {
  get(target, key, reciver) {
      console.log('飒!')
      return target[key]
  },
  set(target, key, value) {
      target[key] = value
      return true
  }
})
proxy.a = 'aaaa'
proxy.a		// print 飒!

不了解具体用法的同学可以去MDN上看看讲解,或者阮一峰的ES6也介绍的很清晰。传送门~

2、Proxy并不快

Vue3.0使用了proxy可能会让人有错觉,proxy的速度比原本的defineProperty要快,其实并不是。这篇文章写了一些例子,比较详细的对比了proxydefineProperty的执行速度。

该例子是统计了,每秒内通过=definePropertyproxy这三种进行赋值的次数。

vue3.2.png

在图中可以比较清晰的看到,相较于普通的赋值和definePropertyproxy根本都不是一个数量级。

既然不快,那为什么还要用proxy?

因为proxy解决了以下的问题:

  1. 在vue2.x中,受限于Object.defineProperty,收集依赖只能通过getter,触发依赖只能通过setter。新增属性,删除属性都无法触发响应式。
  2. 同样的原因,我们无法让Map、Set这类数据类型转变为响应式,Proxy可以。
  3. Vue2.x中,为了性能的考量,数组通过劫持原生方法的方式实现的响应式,但是通过Proxy我们不在去考虑数组的空位导致的empty问题。

3、Proxy并不是深层代理

对于深层的对象,proxy只会代理第一层,并不会递归的将对象的每一层都代理到。

const origin = {
  data: {
    a: 1,
    b: 2
  },
  msg: 'message'
}

const handler = {
  get(target, key, reciver) {
    console.log('代理')
    return Reflect.get(target, key, reciver)
  }
}

const proxy = new Proxy(origin, handler)

proxy.msg // print 代理
const data = proxy.data // print 代理

data.a // 没有任何console.log

从这个例子可以看到,对于'data''msg'这两个属性,proxy都会进行代理,但是当单独去访问data.a的时候,代理就消失了

原因其实也很简单,new Proxy返回的是一个代理对象,但是proxy.data返回的是origin对象中data属性的值,这个值并不再是proxy代理对象了。

Vue中reactive的实现,其实是递归的将对象全部都代理了一遍

二、Vue3.0的响应式

实际上Vue3.0中的响应式和原本2.x的思路是一样的,Vue3.0中用到了monorepo,将响应式进行结偶,作为单独的reactivity模块。

随之而来的发生改变的是一些API,简化后大致为以下四个重要的角色:

  1. effect
  2. effectFunction (简称fn)
  3. Dep
  4. ReactiveData

我们来一个一个聊

1、ReactiveData

这个东西其实就是响应式数据,在Vue2.x中使用Object.defineProperty做的,Vue3.0中使用Proxy做的

2、Dep

当触发了Reactiveget的时候,ReactiveData就会去收集依赖,以便在下次数据发生变化的时候触发依赖中收集的函数。

首先第一个问题:依赖所收集的函数是什么呢?其实就是effect,在3.0中这类函数被称之为副影响函数(满满的react风,真是应了那句 — wherever React go, others follow)

另外,与2.x的区别在于,之前的Dep都通过闭包的方式,保存在了getter中;在3.0中,Vue通过一个Map将所有的Dep统一进行了管理,如图

vue3.2 - 1.jpg

3、effect

上面也提到了effect,其实就是副影响函数,我们先看看他是如何使用的,再说它的作用

import { reactive, effect } from '@vue/reactivity'
const person = reactive({
  name: 'Itachi',
  age: 26
})

const fn = () => console.log(person.age)

effect(fn)
// print 26

person.age = 27
// print 27

看了这个例子,我觉得大家多少也理解这个effect到底是干啥的了,其实很简单,说通俗点可以说是 '搭桥' 的,让reactiveData和使用了reactiveData的函数,互相之间建立起联系,这就是effect所做的事情。

要完成 '搭桥' 这件事,除了effect之外,我们还需要

  1. effectStack:栈用来保存当前正在执行的effect,因为effect之间经常会存在层叠的调用。
  2. activeEffect:当前正在执行的栈顶的那个effect,好让ReactiveData更加精准的收集到这个依赖。

那显而易见,effectiFunction,其实就是例子中的fn这个函数

4、流程

具体流程如下,大家细品~。

vue3.2-2.jpg

5、 源码

// 原始数据和proxy数据的映射
const proxyMap = new WeakMap()
// 依赖统一管理 { target => key => dep }
const targetMap = new WeakMap()
// effect执行栈
const effectStack = []
// 栈顶
const activeEffect = null

function reactive(target) {
  // 如果已经reactive过,直接拿缓存
  if (proxyMap.has(target)) return proxyMap.get(target)
  // 简单类型无法proxy,直接返回值即可
  if (typeof target !== 'object') return target

  const proxy = new Proxy(target, {
    get,
    set
  })
  proxy
  return proxy
}

function get(target, key, reciver) {
  const res = Reflect.get(target, key, reciver)
  // 收集
  track(target, key)
  if (isObject(res)) {
    // 递归proxy
    res = reactive(res)
  }
  return res
}

function set(target, key, value, reciver) {
  const oldValue = traget[key]
  const res = Reflect.set(target, key, value, reciver)
  // 触发
  trigger(target, key, value, oldValue)
  return res
}

// 收集
function track(target, key) {
  // deps的统一管理
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  // 收集依赖
  if (dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

// 触发
function trigger(traget, key, value, oldValue) {
  if (value === oldValue) return
  let depsMap = tragetMap.get(target)
  if (!depsMap) return
  let deps = depsMap.get(key)
  if (!deps) return
  deps.forEach(effect => effect())
}

function effect(fn) {
  const effect = function () {
    // 清理上一次的缓存
    cleanup(effect)
    try {
      effectStack.push(effect)
      activeEffect = effect
      return fn()
    } finally {
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1] || null
    }
  }
  effect.raw = fn
  effect.deps = []
  return effect
}

// 重置依赖
function cleanup(effect) {
  const { deps } = effect
  for (let dep of deps) dep.delete(effect)
  deps.length = 0
}

6、哪里可以优化

其实可以优化的地方就在于这个cleanup,每当effect再次执行的时候,都要先将上一次收集过的清空掉,重新进行收集,这么做的目的其实是为了避免上一次收集到的依赖,本次不需要去收集的情况所导致的依赖收集错误

但是大部分场景中依赖的变动其实是相对较小的,并不需要如此大刀阔斧的进行全部清空,再次收集。

我们可以通过提前标记旧的依赖,当执行完effect之后,再标记新的依赖,通过新旧对比,来判断依赖是否需要进行清理和保留。

那么应对这种effect的层叠调用,同一个ReactiveData的属性,可能应用在多个effect中,这种一对多的情况我们该如何进行精准判断呢?

答案:位掩码

三、位掩码

除了+ - * /之外,我们还有一种位运算。

|、&、 ~ 、<<、>> 这些位运算符

假设我们给每一位的1赋予意义,我们就可以通过按位符与&,来判断当前的值是否有此权限,vue3中就是用这个方法区分的componentelementSVG...

const ELEM = 1
const SVG = 1 << 2
const COMPONENT = 1 << 3
const FRAGMENG = 1 << 4
const PORTAL = 1 << 5

function isElement(vnode) {
    return vnode.type & ELEM > 0
}

四、Vue3.2是如何优化的

上文提到过effect是嵌套调用的,所以我们用effectTrackDepth来记录目前这个effect在第几层,每当有effect执行effectTrackDepth++,每当effect执行完毕effectTrackDepth--

再通过trackOpBit作为它位标记,可以理解为唯一ID,具体为 trackOpBit = 1 << effectTrackDepth

对于dep我们也需要改造一下,原来的dep就只是一个set,我们在此基础上加上两个属性,用来标记该属性上次和本次在哪些effect中使用过,再通过对比进行删除和新增。

由于一个reactiveData的属性可能会用到多个effect中,所以我们通过按位或给dep打标记,又因为每个effect的位标记各不相同,在通过按位与判断得出的值是否大于零,这样就可以分辨出这个值到底都在哪些effect中用过了。

举个例子

improt { reactive, effect } from '@vue/reactivity'

const data = reactive({ a: 1 })
effect(() => {  // effectTrackDepth = 0  trackOpbit = 1 << 0

  console.log(data.a) // data => 'a' => dep.tag |= trackOpbit        dep.tag = 1
  
  effect(() => {  // effectTrackDepth = 1  trackOpbit = 1 << 1
    
    console.log(data.a + 1) // data => 'a' => dep.tag |= trackOpbit  dep.tag = 3
    
  })
})

// 最后我们可以通过 dep.tag & 2 > 0  来判断该dep是否在特定的effect中使用过

1、 改造Dep

Dep我们仅仅需要给原本的Set增加两个属性即可

原本的track方法也需要改变一点

// effect层级
const effectTrackDepth = 0
// 位标记
const trackOpBit = 1

// 收集
function track(target, key) {
  // deps的统一管理
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = createDep()))
  }
  // 收集依赖
  let shouldTrack = false
  if (!newTracked(dep)) {
    // 打上新标记
    dep.n |= trackOpBit
    shouldTrack = !wasTrack(dep) // 原本没有
  }
  if (shouldTrack) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

// 创建Dep
function createDep() {
  const dep = new Set()
  dep.w = 0 // 旧标记
  dep.n = 0 // 新标记
  return dep
}
// 判断原来是否标记过
function wasTracked(dep) {
  return (dep.w & trackOpBit) > 0
}
// 判断本次是否标记过
function newTracked(dep) {
  return (dep.n & trackOpBit) > 0
}

2、 改造effect

Effect我们需要做几个事情

  1. 在effect执行前,先将effectTrackDepth++
  2. 将原本收集到的dep打上自己的标记,作为旧标记
  3. 执行期间通过track给dep.n打上新标记
  4. 执行结束开始对比dep.w 和 dep.n,整理依赖
  5. effectTrackDepth--

ok 我们来看代码

// 判断原来是否标记过
function wasTracked(dep) {
  return (dep.w & trackOpBit) > 0
}
// 判断本次是否标记过
function newTracked() {
  return (dep.n & trackOpBit) > 0
}
function initDepMarkers({ deps }) {
  deps.forEach(dep => (dep.w |= trackOpBit))
}
function finalizeDepMarkers(effect) {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let dep of deps) {
      if (wasTrack(dep) && !newTrack(dep)) {
        // 之前收集到了这次没有
        dep.delete(effect)
      } else {
        deps[ptr++] = dep
      }
      // 重置,为了下一次执行做准备
      deps.w &= ~trackOpBit
      deps.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

function effect(fn) {
  const effect = function () {
    try {
      // 标记effect层级
      trackOpBit = 1 << ++effectTrackDepth
      // 给之前收集到的依赖打上旧标记
      initDepMarkers(effect)
      effectStack.push((activeEffect = effect))
      return fn()
    } finally {
      // 执行完effect,看一下需要删除那些依赖添加哪些依赖
      finalizeDepMarkers(effect)
      trackOpBit = 1 << --effectTrackDepth
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1] || null
    }
  }
  effect.raw = fn
  effect.deps = []
  return effect
}

3、整体代码

// 原始数据和proxy数据的映射
const proxyMap = new WeakMap()
// 依赖统一管理 { target => key => dep }
const targetMap = new WeakMap()
// effect执行栈
const effectStack = []
// 栈顶
const activeEffect = null
// effect层级
const effectTrackDepth = 0
// 位标记
const trackOpBit = 1

function reactive(target) {
  // 如果已经reactive过,直接拿缓存
  if (proxyMap.has(target)) return proxyMap.get(target)
  // 简单类型无法proxy,直接返回值即可
  if (typeof target !== 'object') return target

  const proxy = new Proxy(target, {
    get,
    set
  })
  proxy
  return proxy
}

function get(target, key, reciver) {
  const res = Reflect.get(target, key, reciver)
  // 收集
  track(target, key)
  if (isObject(res)) {
    // 递归proxy
    res = reactive(res)
  }
  return res
}

function set(target, key, value, reciver) {
  const oldValue = traget[key]
  const res = Reflect.set(target, key, value, reciver)
  // 触发
  trigger(target, key, value, oldValue)
  return res
}

// 收集
function track(target, key) {
  // deps的统一管理
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = createDep()))
  }
  // 收集依赖
  let shouldTrack = false
  if (!newTracked(dep)) {
    // 打上新标记
    dep.n |= trackOpBit
    shouldTrack = !wasTrack(dep) // 原本没有
  }
  if (shouldTrack) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

// 触发
function trigger(traget, key, value, oldValue) {
  if (value === oldValue) return
  let depsMap = tragetMap.get(target)
  if (!depsMap) return
  let deps = depsMap.get(key)
  if (!deps) return
  deps.forEach(effect => effect())
}

// 创建Dep
function createDep() {
  const dep = new Set()
  dep.w = 0 // 旧标记
  dep.n = 0 // 新标记
  return dep
}
// 判断原来是否标记过
function wasTracked(dep) {
  return (dep.w & trackOpBit) > 0
}
// 判断本次是否标记过
function newTracked() {
  return (dep.n & trackOpBit) > 0
}
function initDepMarkers({ deps }) {
  deps.forEach(dep => (dep.w |= trackOpBit))
}
function finalizeDepMarkers(effect) {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let dep of deps) {
      if (wasTrack(dep) && !newTrack(dep)) {
        // 之前收集到了这次没有
        dep.delete(effect)
      } else {
        deps[ptr++] = dep
      }
      // 重置,为了下一次执行做准备
      deps.w &= ~trackOpBit
      deps.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

function effect(fn) {
  const effect = function () {
    try {
      // 标记effect层级
      trackOpBit = 1 << ++effectTrackDepth
      // 给之前收集到的依赖打上旧标记
      initDepMarkers(effect)
      effectStack.push((activeEffect = effect))
      return fn()
    } finally {
      // 执行完effect,看一下需要删除那些依赖添加哪些依赖
      finalizeDepMarkers(effect)
      trackOpBit = 1 << --effectTrackDepth
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1] || null
    }
  }
  effect.raw = fn
  effect.deps = []
  return effect
}

4、还需要注意什么

到此为止其实我们已经实现的很不错了,但是有什么我们没有考虑的呢,其实是有的

位掩码是通过二进制的位数来做判断,那么二进制的长度是无限的吗?

显然不是。在JS中number是32位,其中一位还要作为正负号,所以我们也只有31位可用,如果effect的层叠超过31层的话,我们该怎么办呢?

那就只能走我们原来的cleanup方法啦。

五、为什么要看源码

最后的碎碎念,这次在看新vue代码的时候,我其实很震惊的,作为很普通的一个研发,我们大部分的时间都是花费在实现公司的业务。

我在知道vue更新到3.2后,我第一次开始看vue3的源码,我也仅仅只看了reactivity,短短的时间,vue就又更新了7个小版本,可见速度之快,读的速度都赶不上更新的速度,代码一直在变,你花了半天读的代码没准哪天就迭代掉了,那看的意义是什么呢?

我经常被问的哑口无言。你会用vue不就可以了吗?花大把时间去理解源码真的有必要吗?所以理由到底是什么呢?

其实我觉得大部分人看源码的原因都是为了应试,我一开始去看vue2.x的理由也是这个。

当然也有很多大佬说,看了是为了学设计模式,学习一些高级技巧,学习其中的思想,并用到自己的项目中,我听到这些的时候我很认同。

但我自认为做到这一步很难,我也努力在自己的项目中去寻找场景,但是始终没有用上我在vue中学到的所谓“思想”

那对于我这种前端渣渣看源码的意义是什么呢?

当我看到reactive中proxy的时候,我其实突然顿悟了。很多场景我们用不到很多原生的API,比如Proxy。我甚至都不会用它,不理解他的运行机制。

但是当我为了搞懂reactive中的Proxy去查MDN,去写小例子测试proxy功能的时候,我突然觉得这些高深的开源框架,不仅能教你高大上的设计思想,也同时可以帮你夯实前端基础。

源码就像本书一样,雅俗共赏,高手可以去学设计模式,小菜鸟也可以从中夯实基础,慢慢转变成高手。

就这样~大家共勉~

六、参考资料