万字细说 Vue3 响应式原理(下) | ref 与 computed 实现

237 阅读14分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 2 天,点击查看活动详情

前言

本文是细说 Vue3 响应式原理的下篇,阅读前请确保你已阅读过上篇 reactive 与 watch 实现

本文将实现 ref 与 computed 的功能,并分析其中的一些细节优化

预备函数

接下来会复用一些在上篇已经实现的函数,在这里列举出来,不作讲解。

工具函数与变量

const isArray = Array.isArray
const isObject = (val) => val !== null && typeof val === 'object'
let activeEffect // 存储激活的响应式依赖

ReactiveEffect - 响应式依赖类

class ReactiveEffect {
  constructor(fn, scheduler) {
    this.fn = fn // 收集依赖的函数
    this.scheduler = scheduler // 调度程序
    this.deps = [] // 存储所有用到此 effect 的dep
    this.parent = undefined // 用于保存前一个 activeEffect
  }
  // 收集依赖
  run() {
    try {
      // 保存之前的 effect
      this.parent = activeEffect
      activeEffect = this
      return this.fn()
    } finally {
      // 处理完毕 恢复之前的 effect
      activeEffect = this.parent
      this.parent = undefined
    }
  }
  // 停止作用,从所有dep中删除此effect
  stop() {
    const { deps } = this
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(this)
    }
    deps.length = 0
  }
}

trackEffects - 添加依赖

function trackEffects(dep) {
  dep.add(activeEffect)
  activeEffect.deps.push(dep)
}

triggerEffects - 执行依赖

function triggerEffects(dep) {
  for (const effect of dep) {
    effect.scheduler()
  }
}

createDep - 创建依赖集合

后续会在其中进行细节优化

function createDep(effects) {
  const dep = new Set(effects)
  return dep
}

toRaw - 获取代理的原对象

function toRaw(observed) {
  const raw = observed && observed['__v_raw']
  return raw ? toRaw(raw) : observed
}

watch - 监视函数

为了方便讲解与测试,我们使用基础版的 watch 函数,后续会针对 ref 和 computed 扩展其功能。

function watch(getter, callback) {
  let oldValue
  const job = () => {
    const newValue = effect.run()
    callback(newValue, oldValue)
    oldValue = newValue
  }
  const effect = new ReactiveEffect(getter, job)
  oldValue = effect.run()
  // 返回一个可以取消监听的函数
  return () => {
    effect.stop()
  }
}

reactive - 对象响应式

同样为了方便,我们只采用基础版的 reactive 功能,获取会因 ref 和 computed 的加入扩展 getter 与 setter

const targetMap = new WeakMap()
const get = createGetter()
const set = createSetter()
function createGetter() {
  return function (target, key) {
    if (key === '__v_raw') {
      return target // 供 toRaw 获取原对象
    }
    track(target, key) // 收集对象依赖,具体代码见上篇
    const res = Reflect.get(target, key)
    return reactive(res) // 处理多层对象
  }
}
function createSetter() {
  return function (target, key, value) {
    let oldValue = target[key]
    const result = Reflect.set(target, key, value)
    if (value !== oldValue) {
      trigger(target, key) // 触发对象依赖,具体代码见上篇
    }
    return result
  }
}

const handler = { get, set }
const reactiveMap = new WeakMap()
function reactive(obj) {
  if (!isObject(obj)) return obj
  let p = reactiveMap.get(obj)
  if (!p) {
    p = new Proxy(obj, handler)
    reactiveMap.set(obj, p)
  }
  return p
}

实现逻辑

与基于 Proxy 的 reactive 不同,ref 和 computed 都是基于 Object.defineProperty 实现的,我们在使用的时候,也都是访问修改它们的 value 属性。

在源码中,ref 和 computed 分别对应一个类,在类中定义 value 属性的 getter 与 setter,来实现响应式功能。而且与其有关的响应式依赖并不存入全局的 targetMap,而是存在各自的 dep 属性中。

实现 ref

基础功能

我们先实现一个基础版的 ref,只需在 getter 中跟踪依赖,在 setter 中触发依赖就好。

function ref(value) {
  return new RefImpl(value)
}
// ref类
class RefImpl {
  constructor(value) {
    this.dep = createDep()
    this._value = value // 老值
  }
  get value() {
    trackRefValue(this) // 收集依赖
    return this._value
  }
  set value(newVal) {
    if (newVal !== this._value) {
      this._value = newVal
      triggerRefValue(this) // 触发依赖
    }
  }
}
// 收集  ref 依赖
function trackRefValue(ref) {
  if (activeEffect) {
    trackEffects(ref.dep) // 添加依赖
  }
}
// 触发 ref 依赖
function triggerRefValue(ref) {
  if (ref.dep) {
    triggerEffects(ref.dep) // 执行依赖
  }
}

// 测试
const num = ref(1)
watch(
  () => num.value,
  (now, pre) => {
    console.log(`num从${pre}变成了${now}`)
  }
)
num.value++ // num从1变成了2
num.value++ // num从2变成了3

逻辑图:

image.png

ref 与 watch 的交互

我们平时监听 ref,并不会这样子书写 watch(() => rev.value, callback),而是直接将 ref 对象传过去 watch(ref, callback),所以我们扩展一下 watch 的功能,使其允许直接传 ref

我们要如何知道传入的 getter 是不是一个 ref 呢?其实很简单,只要给 ref 对象设置一个特殊的属性就好了。

class RefImpl {
  constructor(value) {
    this.dep = createDep()
    this._value = value // 老值
  }
  ……
}
// 判断是否为 ref
function isRef(r) {
  return r && r.__v_isRef
}

然后我们在 watch 中做一下修改,当传入的 getter 是一个ref时,自动改为 () => ref.value 的形式

function watch(getter, callback) {
  if (isRef(getter)) { // 处理 ref
    getter = () => getter.value
  }
  ……
}

然后 watch 还允许使用传一个 ref 数组,我们也实现一下

function watch(getter, callback) {
  if (isRef(getter)) { // 处理ref
    getter = () => getter.value
  } else if (isArray(getter)) { // 处理 ref 数组
    const arr = getter
    getter = () => arr.map((ref) => ref.value)
  }
  ……
}

ref 与 reactive 的交互

我们知道,当我们向 ref 传入一个对象时,该对象会被 reactive 代理

而对象被 reactive 封装后因为对象被代理后返回的是新对象,原对象地址不同,所以牵扯到对象的比较,我们只比较它们的原对象是否同地址。

class RefImpl {
  constructor(value) {
    this.__v_isRef = true
    this.dep = createDep()
    this._rawValue = toRaw(value) // 原值
    this._value = reactive(value) // 老值 如果是对象,使用reactive代理
  }
  get value() {
    trackRefValue(this) // 收集依赖
    return this._value
  }
  set value(newVal) {
    newVal = toRaw(newVal) // 转化成原值比较
    if (newVal !== this._rawValue) {
      this._rawValue = newVal
      this._value = reactive(newVal)
      triggerRefValue(this) // 触发依赖
    }
  }
}

当 ref 的数据不是对象时,_value_rawValue 始终相等

至此,ref 算是实现完了,还有一点小尾巴放在 computed 中实现更为合适

实现 computed

用法与目标

我们根据基础的用法开始实现,大家用 computed 更多时候应该传递一个 getter 函数吧,然后当其中用到的响应式数据发生改变,再次访问 computed 的话,其值就会更新

像下面这样:

const num = ref(1)
const double = computed(() => {
  console.log('计算属性更新了')
  return num.value * 2
})

console.log(double.value) // 2 计算属性更新了
num.value++
num.value++
console.log(double.value) // 6 计算属性更新了

getter 函数并不是在你更改响应式数据的时候执行,而是当你再次访问的时候,才会重新执行获取新值

基础功能

我们先只实现上面那个简单的例子

我们发现,想要实现 computed 的自动更新,需要收集其中的依赖,这点与 watch 很像。我们可以仿照 watch 中的做法,区别在于其调度函数暂时还没有内容

所以一个基础般的 computed 就做好了

function computed(getter) {
  return new ComputedRefImpl(getter)
}
// computed 类
class ComputedRefImpl {
  constructor(getter) {
    this.effect = new ReactiveEffect(getter, () => {})
  }
  get value() {
    return this.effect.run()
  }
}

const num = ref(1)
const double = computed(() => num.value * 2)
console.log(double.value) // 2
num.value++
console.log(double.value) // 4

插入响应式

基础版的 computed 实现了 value 的自动更新,但是并不能像 ref 一样被响应式依赖,所以我们为其插入响应式功能。

conputed 收集和触发依赖的逻辑和 ref 是一样的,可以复用之前的 trackRefValuetriggerRefValue 函数

class ComputedRefImpl {
  constructor(getter) {
    this.dep = createDep()
    this.effect = new ReactiveEffect(getter, () => {
      triggerRefValue(this) // 触发依赖
    })
  }
  get value() {
    trackRefValue(this) // 收集依赖
    return this.effect.run()
  }
}

// 举例测试
const num = ref(1)
const double = computed(() => {
  return num.value * 2
})
// 监听double
watch(
  () => double.value,
  (now, pre) => {
    console.log(`double从${pre}变成了${now}`)
  }
)

num.value++ // double从2变成了4
num.value++ // double从4变成了6

来张逻辑图:

image.png

在上面的流程中,创建了两次 ReactiveEffect,图中蓝色方框\color{blue}{蓝色方框}使用的是 watch 创建的响应式依赖,存储在 conputed 的 dep 属性中,红色方框\color{red}{红色方框}使用的是 computed 创建的响应式依赖,存储在 ref 的 dep 属性中。

当 ref 的 value 改变后,触发 ReactiveEffect\color{red}{ReactiveEffect}的调度函数,然后在其中执行 triggerEffect\color{blue}{triggerEffect},再执行 ReactiveEffect\color{blue}{ReactiveEffect} 的调度函数,将提示信息输出到控制台。

数据缓存

我们知道,computed 的数据是有缓存性的,只有其依赖的响应式数据改变了,才会重新执行 getter 获取新值

像下面这样

const num = ref(1)
const double = computed(() => {
  console.log('double获取了新值')
  return num.value * 2
})

double.value // double获取了新值
double.value
double.value

num.value++
double.value // double获取了新值

我们为 conputed 对象增加两个属性,_value 缓存值,_dirty 标记缓存是否可用。只有在缓存失效(数据脏了)的时候,才去调用 getter

class ComputedRefImpl {
  constructor(getter) {
    this.dep = createDep()
    this._value = undefined // 缓存值
    this._dirty = true // 缓存是否已失效
    this.effect = new ReactiveEffect(getter, () => {
      // 执行调度函数说明其依赖的响应值已改变,缓存脏了
      if (!this._dirty) {
        this._dirty = true // 下次取值需要重新计算
        triggerRefValue(this)
      }
    })
  }
  get value() {
    trackRefValue(this)
    // 如果缓存不可以,才重新计算
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
}

可写的计算属性

我们在调用 computed 时,传参可以不是一个 getter 函数,而是一个具有 get 和 set 函数属性的对象,来创建一个可写的计算属性

实现也很简单,在 computed 中判断一下是不是函数,如果不是那就取对象的 get/set 来创建可写计算属性

function computed(getterOrOptions) {
  let getter
  let setter
  // 判断是否为函数
  const onlyGetter = typeof getterOrOptions === 'function'
  if (onlyGetter) {
    getter = getterOrOptions
    setter = () => {}
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  return new ComputedRefImpl(getter, setter)
}

而往 ComputedRefImpl 写值的时候,调用一下 setter 就行。一般可写的计算属性,其 setter 中会改变依赖源(比如说是ref)的数值,然后触发 ref 的依赖,进而改变 computed 的值,再触发其自身的依赖……就是重复前文插入响应式一节的流程

class ComputedRefImpl {
  constructor(getter, setter) {
    this.setter = setter
    ……
  }
  set value(newValue) {
    this.setter(newValue)
  }
  ……
}

conputed 与 reactive 的交互

我们平时开发中,应该经常会像下面这样,在 reactive 中使用 computed

const num = ref(1)
const obj = reactive({
  double: computed(() => num.value * 2),
})

console.log(obj.double) // 2

按理来说 obj.double 应该是个 ComputedRefImpl 示例,而在我们却能直接获取其 value

这就涉及到 reactive 中对 Ref 对象的解包

实现起来很简单,首先在 ComputedRefImpl 示例加上 __v_isRef 标志

class ComputedRefImpl {
  constructor(getter, setter) {
    this.__v_isRef = true
    ……
  }
}

然后在 reactive 的 getter 与 setter 中,判断是否为 Ref 对象,如果是的话就进行解包处理。

function createGetter() {
  return function (target, key) {
    if (key === '__v_raw') {
      return target
    }
    track(target, key)
    const res = Reflect.get(target, key)
    
    // 如果是 Ref 对象,解包
    if (isRef(res)) return res.value

    return reactive(res) // 将返回值用reactive包裹
  }
}
function createSetter() {
  return function (target, key, value) {
    let oldValue = target[key]
    
    // 如果旧值是 Ref 对象且新值不是 Ref 对象,依赖就交给 Ref 触发
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    }
    
    const result = Reflect.set(target, key, value)
    if (value !== oldValue) {
      trigger(target, key)
    }
    return result
  }
}

需要注意,在 reactive 读写 Ref 对象时,虽然不会触发依赖(改为触发 Ref 的依赖),但依旧会收集依赖到 targetMap 中,因为该属性可能被赋值一个新的 Ref 对象,此时就要触发之前收集到的依赖

画个图吧

image.png

至此 Vue3 响应式的四大金刚就完成了,市面上的所有教程都就止步于此了

但这里不是,我还要分析源码中的优化细节,让你们在面试中能聊出和别人不一样的东西

细节优化

懒创建

先说个简单的,在 ref 和 computed 对象中,他们 dep 属性的创建操作被移动到了 trackRefValue 函数中

class RefImpl {
  constructor(value) {
    this.dep = undefined
    ……
  }
}
class ComputedRefImpl {
  constructor(getter, setter) {
    this.dep = undefined
    ……
  }
}

function trackRefValue(ref) {
  if (shouldTrack && activeEffect) {
    ref = toRaw(ref)
    trackEffects(ref.dep || (ref.dep = createDep()))
  }
}

就是在使用前省了集合的创建,聊胜于无吧。

Vue 中还有很多这样的细节,比如渲染器、实例上下文,都是到用的时候才去创建

依赖的清除

接下来的应该都是市面上没人讲过的东西,反正我看源码的时候没搜到相关资料,思考了半天。

先说解决了什么问题吧,看下面这个测试:

const needPositive = ref(true)
const positiveNum = ref(1) // 正数
const negativeNum = ref(-1) // 负数
const num = computed(() => {
  if (needPositive.value) {
    console.log(`计算属性更新了,值为${positiveNum.value}`)
    return positiveNum.value
  } else {
    console.log(`计算属性更新了,值为${negativeNum.value}`)
    return negativeNum.value
  }
})

num.value // 计算属性更新了,值为1

positiveNum.value++
num.value // 计算属性更新了,值为2
negativeNum.value--
num.value // (无输出,negativeNum没有被跟踪)

needPositive.value = false
num.value // 计算属性更新了,值为-2

positiveNum.value++
num.value // 计算属性更新了,值为-2 (positiveNum依旧被跟踪!)
negativeNum.value--
num.value // 计算属性更新了,值为-3

发现问题了吗,上面的示例中,计算属性 num 已经不依赖 positiveNum 了,但因为之前收集过它的依赖,在其修改后,又会使下次访问 num 时 getter 函数重新调用,进而执行所有依赖此计算属性的调度函数,即使数据没有改变

所以,我们不仅要保证计算属性能跟踪其内部的所有响应式依赖,还要保证在某些响应式数据无法触及时,清除它们的的依赖

暴力全清

先从简单粗暴的方法来,每次执行 getter 前都把上次收集到的所有依赖清除掉,在执行函数时重新收集依赖,不就能保证每一个依赖都确实会导致数据改变吗

那要如何清除呢?其实在上篇已经实现了,只是当时不是重点,一笔带过没有细说。

还记得 ReactiveEffect 示例中的 deps 属性吗?里面存有所有用到此依赖的 dep,我们的依赖都是双向添加的,你在使用此依赖的同时,我也存有你的 dep 集合

function trackEffects(dep) {
  dep.add(activeEffect)
  activeEffect.deps.push(dep)
}

当时清除依赖的操作我们实现在 stop 方法中,此时要将其抽离成一个函数,每次收集依赖前都调用,实现如下:

class ReactiveEffect {
  constructor(fn, scheduler) {
    this.fn = fn // 收集依赖的函数
    this.scheduler = scheduler // 调度程序
    this.deps = [] // 存储所有用到此 effect 的dep
    this.parent = undefined // 用于保存前一个 activeEffect
  }
  run() {
    try {
      // 保存之前的 effect
      this.parent = activeEffect
      activeEffect = this
      cleanupEffect(this) // 清除上次收集到的依赖
      return this.fn() // 重新收集依赖
    } finally {
      // 处理完毕 恢复之前的 effect
      activeEffect = this.parent
      this.parent = undefined
    }
  }
  stop() {
    // 从所有使用此实例的dep中清除此实例
    cleanupEffect(this)
  }
}

// 从deps删除依赖
function cleanupEffect(effect) {
  const { deps } = effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect)
  }
  deps.length = 0
}

逻辑可能有点绕,带领大家分析一遍示例

  • 第一次访问的时候,运行 getter 函数,只读取了 positiveNumneedPositive,所以只将 effect 添加进它们的 dep 集合中。由于是双向收集,所以它们的 dep 已被添加进了 num 的 deps 数组中;

image.png

  • 然后修改 positiveNum,由于其 dep 集合中包含 num 的 effect,执行调度函数,标记缓存已脏,需重新计算;
  • 之后又访问,重新执行 getter,控制台打印输出。虽然中间清除了一遍依赖,但访问的数据没有变化,又将依赖添了回来;
  • 后来修改了 negativeNum 由于其没有被依赖,并不会导致缓存失效
  • 转折点在于修改了 needPositive,触发依赖,标记缓存已失效,但此时依赖关系依旧是上图的情况
  • 但是在下一次访问 num 的时候,清空了之前的了之前的依赖,断开了 dep、deps、effect 之间的连接,这次代码执行和上次不同,读取了 negativeNum,依赖关系变为下图的情况

image.png

  • 最后就是常规的改值读取,验证了 positiveNum 已不被依赖

标记清除

每次都暴力全清肯定是个笨方法嘛,我们其实只需要清除这次访问中没有使用到的响应式依赖

所以我们将本次使用到的依赖打上标记,访问完对比一下,将没有访问的依赖清除掉,这部分实现还是较难理解的,我先将代码展示出来,然后画图讲解把

标记就打在dep集合上createDep 函数不止是创建一个集合这么简单,它还为集合添加了两个属性,值都是数字类型

const createDep = (effects) => {
  const dep = new Set(effects)
  dep.w = 0 // 是否已被收集
  dep.n = 0 // 在新的一轮访问中是否仍被收集
  return dep
}

定义一些变量和方法,验证某个 dep 是否已被收集/仍被收集

let effectTrackDepth = 0 // 当前响应式依赖的嵌套层数
let trackOpBit = 1 // 用于比较的变量
const wasTracked = (dep) => (dep.w & trackOpBit) > 0 // 检查dep是否已被收集
const newTracked = (dep) => (dep.n & trackOpBit) > 0 // 检查dep在新的一轮收集中是否仍被收集

在收集依赖时给 dep 打上标记

function trackEffects(dep) {

  let shouldTrack = false
  
  if (!newTracked(dep)) { // 如果不是新的依赖
    dep.n |= trackOpBit // 标记此dep新的一轮收集中仍被收集(新收集)
    shouldTrack = !wasTracked(dep)
  }
  
  if (shouldTrack) {
    // 双向添加
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

ReactiveEffect.run 中标记已收集的依赖和清除未收集的依赖

class ReactiveEffect {
  constructor(fn, scheduler = null) {
    this.fn = fn // 收集依赖的函数
    this.scheduler = scheduler // 更新函数
    this.deps = [] // 存储所有用到此 effect 的dep
    this.parent = undefined // 用于保存前一个 activeEffect
  }
  run() {
    try {
      this.parent = activeEffect
      activeEffect = this
      
      // 将deps中的所有dep 标记已收集,标记符号位一个二进制数
      trackOpBit = 1 << ++effectTrackDepth
      const { deps } = this
      if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
          deps[i].w |= trackOpBit // 设置w属性,标记为已收集
        }
      }
      
      return this.fn()
    } finally {
      
      const { deps } = this
      if (deps.length) {
        let length = 0
        for (let i = 0; i < deps.length; i++) {
          const dep = deps[i]
          // 如果这个dep已收集但本次循环并没有被收集,清除它
          if (wasTracked(dep) && !newTracked(dep)) {
            dep.delete(this)
          } else {
            // 正常循环,覆盖已删除的dep
            deps[length++] = dep
          }
          // 清除dep上的标记
          dep.w &= ~trackOpBit
          dep.n &= ~trackOpBit
        }
        deps.length = length // 清除末尾的dep
      }
      // 复位全局的标记符号
      trackOpBit = 1 << --effectTrackDepth
 
      activeEffect = this.parent
      this.parent = undefined
    }
  }
  stop() {
    cleanupEffect(this)
  }
}

接下来带着大家跟着实例走一遍流程

第一次访问,依赖收集,所有依赖都是新的,访问到的依赖都被双向收集,下图是收集过程时的中间状态,在 trackEffects 函数中将 dep.n 置 1

image.png

然后收集完成,标记复位

image.png

下一次收集是在修改了 positiveNum 之后,在 ReactiveEffect.rundep.w 置 1,在 trackEffects 函数中将 dep.n 置 1

image.png

然后尝试清除依赖,发现所有已收集的依赖在这一轮依旧被收集,无依赖被删除

image.png

在下一轮,修改了 needPositive,还是在 ReactiveEffect.rundep.w 置 1,在 trackEffects 函数中将 dep.n 置 1,但这次结果可不一样了

image.png

清除依赖时,发现 positiveNum 的 dep 已收集,但本轮并未收集,说明是一个用不到的依赖,将其清除,与计算属性断开了连接。

然后复位,成为下图的情况

image.png

最后就是验证操作,无需分析了。

总结下来就是这几条:

  • dep.w 表示当前依赖已被收集
  • dep.n 表示当前依赖在本轮仍被收集
  • dep.w 且 !dep.n 的即将被清除

只是因为 Vue 采取的是位标记,可能有点绕

位标记

在这里聊聊为什么依赖清除采用位标记,最直接的原因就是位运算块,还节省空间,处理多层嵌套的依赖收集也很方便。

比如当两个 ReactiveEffect 依赖收集嵌套运行时,dep.w/dep.n 有三种可能取值

  • 1:表示被外层的 ReactiveEffect 所依赖
  • 2:表示被内层的 ReactiveEffect 所依赖
  • 3:表示被内层和外层的 ReactiveEffect 同时依赖

在 Vue 中计算属性的嵌套是经常发生的,如果不采取位标记的话,就要耗费大量的空间和性能来确定当前 dep 的状态

位标记也是有极限的,正数最多31位,所以最多可以标记30层的嵌套,如果真有超过30层的嵌套,就使用暴力清除

1 << 30 // 1073741824
1 << 31 // -2147483648

至此,Vue 响应式的四大金刚都讲完了,这不值得你点个赞?

结语

如果喜欢或有所帮助的话,希望能点赞关注,鼓励一下作者。

如果文章有不正确或存疑的地方,欢迎评论指出。