vue3 reactivity源码阅读总结

前言

vue3响应式这块的源码,在vue框架中是比较核心的模块,对我来说,阅读难度还是有一些的,特别是ts,因为之前没怎么在项目中写过,刚开始看起来很不习惯,但是得益于我的好兄弟chatgpt的帮助,再加上适当的练习,自然就不是问题了,慢慢也就习惯了。

除了已经克服的ts的问题,直接看源码还是会感觉不知道该看什么,不知道哪里是重点,好在我买了霍春阳大佬的书《Vue设计与实现》,本身讲的就是源码的东西,而且讲的很细,所以我是一边看书,一边看源码的,当然,书上讲的自然没有源码详细,而且源码还再不停迭代,但是大致内容是不会变得,如果遇到大的变动,我们不妨可以看看源码的提交记录,了解那些地方为什么要那样写,好处是什么,再不行,可以多看看测试用例,说不定就能找到答案,因为看测试用例比较直白一点,所以,我现在看某部分源码之前,还是习惯先看测试用例的,毕竟有些方法可能都没有用过,不建议直接看源码。

为了加深自己对源码的理解,觉得有必要总结一下,加深记忆。

附上源码地址v3.2.45

vue3响应式相比vue2的优点

vue2的响应式是使用Object.defineProperty实现的,由于api的限制,只能拦截对象属性的getset方法,很多功能是无法实现的,存在不少缺点,比如:

  • 已代理的对象,新增和删除属性无法监听,只能通过官方提供的setdelete方法去操作才行

  • 通过索引修改数组的元素值,或者是使用某些会改变数组长度的方法,比如push、pop、splice等方法,为了解决这个问题,前者使用set方法,后者重写数组相关方法,覆盖数组原型上的方法,以此实现响应式

  • 无法代理SetMap数据,不能实现响应式

  • 将data中数据转换成响应式数据时,需要一次性递归,数据非常多或者数据层级较深时,比较消耗性能

对比之下,vue3的优势很明显,vue3使用Proxy api,代理方式更多,除了getset,还支持has、delete、ownKeys等,还可以监听数组元素的变化,以及SetMap数据结构也能监听,对于响应式数据的生成,并非一次性递归所有属性进行转化,而是每次get的时候将非原始数据的属性转为Proxy对象返回

准备

文件目录介绍

源码版本是3.2.45

src
├── baseHandlers.ts #定义了用于代理普通对象和数组的 Proxy handler
├── collectionHandlers.ts #定义了用于代理 Set、Map、WeakSet 和 WeakMap 的 Proxy handler
├── computed.ts #实现了计算属性
├── deferredComputed.ts #实现了延迟计算的计算属性,只有在真正需要获取计算属性的值时才会计算
├── dep.ts #依赖集合相关方法,用于收集和管理响应式数据的依赖
├── effect.ts #定义副作用函数的注册方法,定义收集副作用函数的track方法和触发副作用函数的trigger方法
├── effectScope.ts #定义了 effectScope 类型和相应的 API,用于管理 effect 的生命周期
├── index.ts
├── operations.ts #枚举了track和trigger的操作方法
├── reactive.ts #实现了将非原始值转换成响应式对象的函数
├── ref.ts #实现了将原始值转换成响应式对象的函数
└── warning.ts #定义了警告的方法,用于在开发环境,打印一些警告信息

关于调试

  • 在vue package下调试

    在vue3根目录package.json中定义有脚本,直接运行即可:

    pnpm run dev
    

    然后在vue目录下随便创建个文件夹(一般是examples),创建html文件,引入dist中的源码即可

  • 在reactivity package下调试

    因为vue3源码架构是monorepo架构,所有也能直接将reactivity单独打包,在其内部生成dist文件,所以单纯看reactivity源码的时候,可以直接它里边写demo调试,需要在vue3根目录运行如下命令:

    node ./scripts/dev.js reactivity -f esm-browser
    

    打包目标是reactivity,模块的格式是esm-browser,./scripts/dev.js不传参数时,默认打vue这个完整的包,默认模块的格式是global(iife)。

    然后在reactivity下随便创建个文件夹(一般是examples),创建创建html文件,引入dist中的源码即可:

    <script type="module">
      import { computed, ref, effect } from '../dist/reactivity.esm-browser.js'
      const foo = ref('foo')
      console.log(foo.value)
    </script>
    

effect文件

effect.ts 是vue3响应式的核心模块,实现了以副作用函数自动收集响应式数据的依赖,并在响应式数据发生变更的时候,自动触发依赖重新执行的功能。

其中,副作用函数是需要通过effect方法来执行的,effect方法会将传入的副作用函数换成ReactiveEffect实例保存,并在合适的时候调用ReactiveEffect实例的方法run去执行副作用函数,为了方便描述,就把ReactiveEffect实例抽象的看作是副作用函数,因为响应式数据的依赖集合中存储的就是ReactiveEffect实例,所以也可以把依赖抽象看作是副作用函数,所以要先知道这几个概念,避免后续混淆

effect

effect源码:

function effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions
): ReactiveEffectRunner {
  if ((fn as ReactiveEffectRunner).effect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  const _effect = new ReactiveEffect(fn)
  if (options) {
    // 合并options到ReactiveEffect实例,有scope配置时,执行recordEffectScope注册副作用函数到effectScope
    extend(_effect, options)
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
  if (!options || !options.lazy) {
    _effect.run()
  }
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
  runner.effect = _effect
  return runner
}

effect方法接收一个副作用函数fn和配置选项options,然后根据副作用函数生成一个ReactiveEffect实例,options的lazy用来配置不立即执行副作用函数,而是响应式数据变更后再执行,具体所有的配置如下所示:

interface ReactiveEffectOptions extends DebuggerOptions {
  lazy?: boolean // 是否立即执行
  scheduler?: EffectScheduler // 调度器,用来实现更灵活和复杂的功能,比如计算属性
  scope?: EffectScope // effect作用域,用来统一管理一些副作用函数
  allowRecurse?: boolean // 是否允许递归调用自身的副作用函数
  onStop?: () => void // 副作用函数被注销后的钩子函数
}

options继承了dev环境下用于调试的DebuggerOptions接口。
effect方法会返回一个runner,其实就是ReactiveEffect实例的run方法,且绑定了自身的实例,一般用来手动调用副作用函数,或者调用stop方法在合适时机注销自身

ReactiveEffect

ReactiveEffect类是用来封装副作用函数的,上面也说了,响应式数据的依赖就是ReactiveEffect实例,ReactiveEffect类有很多的实例属性,以及实例方法runstop,简略源码如下所示:

class ReactiveEffect<T = any> {
  active = true
  deps: Dep[] = []
  parent: ReactiveEffect | undefined = undefined

  // @internal
  computed?: ComputedRefImpl<T>
  // @internal
  allowRecurse?: boolean
  // @internal
  private deferStop?: boolean

  onStop?: () => void
  // dev only
  onTrack?: (event: DebuggerEvent) => void
  // dev only
  onTrigger?: (event: DebuggerEvent) => void

  // fn和scheduler使用public声明,所以无需this.fn = fn; this.scheduler = scheduler
  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
    scope?: EffectScope
  ) {
    recordEffectScope(this, scope) // 有scope的话,会被scope统一管理
  }

// run() {...}
// stop() {...}
}

从代码可以看出,有几个属性标识了@internal,用来提示该属性仅供内部使用,fnscheduler即作为constructor的参数,又使用了public 修饰符,这样写可以将其直接转为ReactiveEffect类的public实例属性。介绍下非@internalpublic实例属性:

  • active属性表示实例的激活状态,当调用stop方法,会将实例状态转为非激活状态

  • deps属性用来存储当前实例(也可以说是副作用函数)对应的所有响应式数据的依赖集合(Dep)[Dep, Dep,...]

  • parent属性用来存储当前实例的父级ReactiveEffect,适用于effect嵌套使用的场景

  • fn是一个方法,实际的副作用函数

  • scheduler是一个方法,调度器,用于实现更复杂的功能

run()

run实例方法比较复杂,源码如下:

run() {
  // 判断是否是激活状态,不是的话,以普通方法直接执行并返回
  if (!this.active) {
    return this.fn()
  }
  // 记录当前层级effect的shouldTrack,shouldTrack在track的时候有用
  let lastShouldTrack = shouldTrack
  // 类似if (effectStack.includes(this)) return,就是判断effect调用栈中是否包含当前effect,包含的话就返回,可查看commit 2993a24
  let parent: ReactiveEffect | undefined = activeEffect
  while (parent) {
    if (parent === this) {
      return
    }
    parent = parent.parent
  }
  try {
      
    // 执行this.fn之前,通过this.parent记录当前执行的ReactiveEffect,并将activeEffect指向当前,this.fn执行完毕后再将activeEffect的值恢复回来
    this.parent = activeEffect // 如果effect没有嵌套执行时,则activeEffect === undefined
    activeEffect = this
    shouldTrack = true

    // effectTrackDepth表示effect方法嵌套执行的深度
    // trackOpBit以二进制的形式来跟踪当前effect执行的深度,这么写是为了性能考虑
    trackOpBit = 1 << ++effectTrackDepth
      
    // maxMarkerBits最大为30,考虑使用SMI技术优化,通过位运算进行高效的计算
    if (effectTrackDepth <= maxMarkerBits) {
      // 对deps中的每个dep的 w 标记进行初始化,
      initDepMarkers(this)
    } else {
      // 一般不会进入这里,除非effect嵌套超过30层
      cleanupEffect(this)
    }
    return this.fn()
  } finally {
    if (effectTrackDepth <= maxMarkerBits) {
      // 将activeEffect对应deps中每一个dep的w和n中对应当前调用栈的bit位,置为0
      finalizeDepMarkers(this)
    }
    trackOpBit = 1 << --effectTrackDepth

    activeEffect = this.parent
    shouldTrack = lastShouldTrack
    this.parent = undefined
      
    // 当在ReactiveEffect执行过程中调用stop,则deferStop会被置为true,然后ReactiveEffect执行结束后调用stop
    if (this.deferStop) {
      this.stop()
    }
  }
}

run方法其实主要目的是为了执行真正的副作用函数this.fn,但是因为effect是可以嵌套执行的,在执行this.fn时,可能会继续执行一个内部的effect,所以在执行this.fn之前或之后,会添加一些代码用来处理入栈前的准备工作,以及出栈后的回归工作,具体为何这样写,是因为内部effect下面如果还有响应式数据的读取操作时,会收集错依赖。

还有一个很重要的问题,就是this.fn方法在执行前,需要先清理它收集的依赖集合,就是把当前副作用函数从响应式数据的依赖集合中删除,再进行收集,虽然依赖集合是Set数据类型,不会有重复的问题,但是,如果遇到如下存在分支切换逻辑的代码,就会出现问题:

const isShow = ref(false)
const text = ref('red')
let dummy
effect(() => {
    dummy = isShow.value ? text.value : 'green'
})
isShow.value = false
text.value = 'yellow' // 不应该触发副作用函数重新执行

isShow的值为false时,text的值变化后,就不应该再触发副作用函数重新执行了,为此,需要每次执行this.fn之前,需要清理依赖,当isShow改变后,触发this.fn重新执行,并清理依赖重新收集,text.value没有读取到,不会触发get方法进行收集依赖,所以text的依赖集合中已经没有了当前的副作用函数,text的值变化后,就不会触发副作用函数重新执行了。vue3之前的版本是副作用函数执行前,会把当前副作用函数从响应式数据对应的依赖集合中全部删除,不管副作用函数重新执行时,是否还会被响应式数据收集,当前版本是优化过的,后面会具体讲解。

stop()

stop方法比较简单,源码如下:

stop() {
  if (activeEffect === this) {
    this.deferStop = true
  } else if (this.active) {
    cleanupEffect(this)
    if (this.onStop) {
      this.onStop()
    }
    this.active = false
  }
}

副作用函数内部如果调用了stop方法,则会命中第一个判断条件,例如:

const useStop = ref(false)
const runner = effect(() => {
  if (useStop.value) {
    runner.effect.stop()
  }
})
useStop.value = true

因为方法正在执行中时,自身调用了stop方法,会导致finalizeDepMarkers无法将ReactiveEffect实例属性deps中依赖集合dep的(n和w)置为0,所以stop方法一定要等ReactiveEffect实例执行结束后再执行,可以参考effect测试用例edge case: self-stopping effect tracking ref。

stop方法最终还是要执行第二个判断条件的逻辑,执行cleanupEffect,调用onStop钩子,ReactiveEffect实例属性active状态置为false

响应式的优化

之前讲过,副作用函数中存在分支切换逻辑的代码,副作用函数如果不从响应式数据的依赖集合中删除它自己,就会导致,一些和副作用函数没有关系的响应式数据发生变化时,使副作用函数重新执行。为了解决这个问题,每次执行副作用函数时,都先从响应式数据的依赖集合中删除当前副作用函数,然后再重新收集依赖,这样就可以解决此问题。但是这样其实不太妥当,因为一个副作用函数中,存在分支切换逻辑的代码不会太多,每次执行都先清空可能不太划算,比较消耗性能,有优化的空间,为此,有大佬提出了新的方式。

为了更好的理解,我们假设副作用函数不能嵌套执行,每次都只能执行一个。然后副作用函数执行的时候,给响应式数据的依赖集合Dep上添加两个属性,一个是w,等于1时,表示当前副作用函数已经被收集过,等于0时,表示没有被收集过;另一个是n,等于1时,表示当前副作用函数是新收集的,等于0时,表示不是新收集的Dep的类型如下代码所示:

type Dep = Set<ReactiveEffect> & TrackedMarkers
interface TrackedMarkers {
  w: number // 只能是0和1
  n: number // 只能是0和1
}

上面说过副作用函数是以ReactiveEffect实例保存的,ReactiveEffect实例有一个属性deps,用来存储所有涉及的响应式数据的依赖集合的引用,这个要知道。

然后开始执行如下代码,副作用函数中的响应式数据有flagtext

const flag = ref(true)
const text = ref('hello')
effect(() => {
  flag.value ? text.value : 'world'
})
flag.value = false

它们的依赖集合是depdep.wdep.n默都是认为0:

// 单纯的描述dep
const dep = new Set()
dep.w = 0
dep.n = 0
  • 执行effect方法,会触发副作用函数首次执行:

    1. 副作用函数执行前,开始尝试将它的deps中所有depw置为1,但是因为副作用函数还没执行过,依赖还未收集,故deps[]

    2. 开始执行副作用函数,触发flagtextget进行依赖收集,同时会将flagtextdep.n置为1,表示是新收集的,副作用函数的deps中也会保存flagtext的依赖集合,此时deps为:

      [
       dep, // flag的依赖集合 w:0 n:1
       dep  // text的依赖集合 w:0 n:1 
      ]
      
    3. 副作用函数执行完毕后,会从dep.w === 1 && dep.n === 0dep中,删除当前副作用函数对应的依赖,因为此时flagtext的依赖都不符合条件,所以无需删除,最后会将deps的所有depwn置为0,此时deps:

      [
        dep, // flag的依赖集合 w:0 n:0
        dep  // text的依赖集合 w:0 n:0
      ]
      
  • flag.value = false会触发副作用函数第二次执行:

    1. 副作用函数执行前,开始尝试将它的deps中所有depw置为1,此时依赖已经被收集过了,故deps

      [
        dep, // flag的依赖集合 w:1 n:0
        dep  // text的依赖集合 w:1 n:0
      ]
      
    2. 开始执行副作用函数,触发flagget进行依赖收集,会将flagdep.n置为1,flag的依赖已经收集过了,不会重复收集,textget没有执行,故状态不变,此时deps为:

      [
        dep, // flag的依赖集合 w:1 n:1
        dep  // text的依赖集合 w:1 n:0
      ]
      
    3. 副作用函数执行完毕后,会从dep.w === 1 && dep.n === 0dep中,删除当前副作用函数对应的依赖,因为此时text的依赖符合条件,所以会把当前副作用函数对应的依赖从textdep中删除,最后会将deps的所有depwn置为0,此时deps:

      [
        dep, // flag的依赖集合 w:0 n:0
      ]
      
  • 此时再更改text.value的话,已经不会触发副作用函数重新执行了,很完美。

为了便于理解,我们简化了很多,把effect当作是不能嵌套的,但是实际effect是可以嵌套的,比如通过链式调用计算属性的场景,就是一个计算属性的getter内引入另一个计算属性进行计算。为此,Depwn只有0和1,已经不够用了,所以源码中才用更多位的二进制来记录所有嵌套层级的wn,用trackOpBit来指向当前正在指向effect层级,我还写了一个小demo来简单模拟多层嵌套effect的执行过程,真实的源码部分就不再讲了。

track

track方法是给响应式数据进行依赖收集的,响应式数据只有在副作用函数内部进行读取操作的,才会触发依赖收集,源码如下:

function track(target: object, type: TrackOpTypes, key: unknown) {
  // shouldTrack用来控制依赖是否要收集,被收集过的shouldTrack为false
  // activeEffect不存在,说明响应式数据的读取操作,不在副作用函数内
  if (shouldTrack && activeEffect) {
    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()))
    }

    const eventInfo = __DEV__
      ? { effect: activeEffect, target, type, key }
      : undefined

    trackEffects(dep, eventInfo) // 尝试收集依赖
  }
}

function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    /**
     * 举个例子,在执行到第二个getter的时候,newTracked(dep)为true,则shoulTrack保持false,不会再收集重复的依赖
     * effect(() => {
        b = a.value
        console.log(a.value)
      })
     */
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep) // 没有被收集过的,才会收集
    }
  } else {
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
  }

  if (shouldTrack) {
    dep.add(activeEffect!) // 往依赖集合中添加当前副作用函数,即ReactiveEffect实例
    activeEffect!.deps.push(dep) // 同时给当前副作用函数添加响应式数据依赖集合的引用
    if (__DEV__ && activeEffect!.onTrack) {
      activeEffect!.onTrack({
        effect: activeEffect!,
        ...debuggerEventExtraInfo!
      })
    }
  }
}

trigger

如果响应式数据已经收集了依赖,则响应式数据发生变更时就会触发其依赖集合中的所有依赖重新执行,源码如下:

// 响应式数据变更的方式
const enum TriggerOpTypes {
SET = 'set',
ADD = 'add',
DELETE = 'delete',
CLEAR = 'clear'
}
function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (!depsMap) {
// never been tracked
return
}
// deps,用来保存所有要执行的依赖
let deps: (Dep | undefined)[] = []
// CLEAR模式,执行所有依赖
if (type === TriggerOpTypes.CLEAR) {
deps = [...depsMap.values()]
} else if (key === 'length' && isArray(target)) { // 数组length变化
const newLength = Number(newValue)
// target是数组时,lenth变化,或者设置的索引大于lenth(说明间接更改了length),都要触发length对应依赖重新执行
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= newLength) {
deps.push(dep)
}
})
} else {
// 正常key的SET | ADD | DELETE 需要触发key对应的依赖
if (key !== void 0) {
deps.push(depsMap.get(key))
}
// 根据具体情况,判断是否触发ITERATE_KEY和MAP_KEY_ITERATE_KEY的依赖集合
switch (type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isIntegerKey(key)) {
// new index added to array -> length changes
deps.push(depsMap.get('length'))
}
break
case TriggerOpTypes.DELETE:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) { // Map专属
deps.push(depsMap.get(ITERATE_KEY))
}
break
}
}
const eventInfo = __DEV__
? { target, type, key, newValue, oldValue, oldTarget }
: undefined
if (deps.length === 1) {
if (deps[0]) {
if (__DEV__) {
triggerEffects(deps[0], eventInfo)
} else {
triggerEffects(deps[0])
}
}
} else {
const effects: ReactiveEffect[] = []
for (const dep of deps) {
if (dep) {
effects.push(...dep)
}
}
if (__DEV__) {
triggerEffects(createDep(effects), eventInfo)
} else {
triggerEffects(createDep(effects))
}
}
}
function triggerEffects(
dep: Dep | ReactiveEffect[],
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// spread into array for stabilization
const effects = isArray(dep) ? dep : [...dep]
// 先执行计算属性
for (const effect of effects) {
if (effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
}
function triggerEffect(
effect: ReactiveEffect,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// 副作用函数内触发依赖的话,不执行
if (effect !== activeEffect || effect.allowRecurse) {
if (__DEV__ && effect.onTrigger) {
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
}
if (effect.scheduler) { // 有调度器则执行调度器
effect.scheduler()
} else {
effect.run()
}
}
}

各种响应式数据如何代理

总结一下Object、Array、Set和Map、基本数据类型是如何进行依赖的收集和触发的

Object对象

Object对象的所有可能的读取操作如下:

  • 访问属性:obj.foo
  • 判断对象或原型链上是否有给定的keykey in obj
  • 使用for...in循环遍历对象:for(const key in obj)

如何收集依赖:

  • 第一种可以直接通过Proxyget拦截,使用键名key来存储依赖
  • 第二种直接通过Proxyhas拦截,也是使用键名key来存储依赖,has本身也相当于读取操作,所以需要收集依赖
  • 第三种for...in循环,可以通过ProxyownKeys拦截,但是因为for...in操作不针对某一个key,而是针对对象的所有key,所以我们使用一个独一无二的值Symbol('iterate')来存储对应的依赖

Object对象的所有可能的修改操作如下:

  • 修改属性:obj.foo = 2
  • 新增属性:obj.bar = 'is_new'
  • 删除属性:delete obj.foo

如何触发依赖重新执行:

  • 修改属性值会触发key的依赖重新执行
  • 新增属性会触发Symbol('iterate')的依赖重新执行,因为for...in操作遍历的是对象的key,对象key的增减,都要重新执行for...in操作,而value变化了就不受影响,当然除非我们在for...in循环内部读取了value,才会触发for...in重新执行,这也是很常见的情况,不过那就是get的职责了
  • 删除和新增是一样的,会触发Symbol('iterate')的依赖重新执行

Array数组

数组是一种特殊的对象,和对象有一些共性和不同的地方

Array数组的所有可能的读取操作如下:

  • 通过索引读取数组的元素值:arr[0]
  • 访问数组的长度:arr.length
  • 把数组作为对象,使用for...in操作遍历:for(const key in arr)
  • 使用for...of迭代遍历数组
  • 数组的原型方法,如concatjoineverysomefindfindIndexincludes等,以及所有不改变原数组的原型方法

如何收集依赖:

  • 前三种与对象的依赖收集是一样的操作
  • for...of迭代遍历数组比较复杂一些,要先了解一些概念,首先for...of是用来遍历迭代对象的,迭代对象需要实现迭代协议,也就是实现@@iterator方法。@@name标志在ECMAScript规范里用来代指javascript内建的symbols值,@@iterator对应的就是Symbol.iterator,所以如果一个对象实现了Symbol.iterator方法,那它就是可迭代对象。因为数组是一个可迭代对象,所以它的原型上是有Symbol.iterator方法的,除了Symbol.iterator方法,还有valueskeysentries方法,Symbol.iterator方法其实就是values方法。values方法会读取数组的length和所有的valuekeys方法会读取数组的length和所有的keyentries方法会读取数组的length和所有的[key, value]。讲完了这些,我们就知道了for...of需要收集length和所有的索引的依赖,还是使用get方法就行
  • 数组的原型方法中,includesindexOflastIndexOf会读取数组的length和所有索引,因为数组转为Proxy代理后这些方法存在一些问题,需要拦截这几个方法,在拦截的时候去收集length和所有索引的依赖就可以了。joinconcat方法都会读取length和所有索引,无需特意处理,所以直接走get方法收集依赖,有点不解的地方是joinconcat方法名本身为啥也会收集依赖?every方法的依赖收集行为同上,somefindfindIndex方法有点特别,他们只要符合回调函数的条件,就不会继续遍历了,所以它们只收集length和符合条件之前的索引的依赖,其他的同理,就不一个个说了

Array数组的所有可能的修改操作如下:

  • 通过索引修改数组的元素值:arr[0] = 1
  • 修改数组长度:arr.length = 0
  • 数组的栈方法:pushpopshiftunshift
  • 修改原数组的原型方法:splicefillsort

如何触发依赖重新执行:

  • 修改索引值会触发索引的依赖重新执行,但是如果修改的是一个不存在索引,会间接的触发length值的修改,所以会触发length的依赖重新执行
  • length的值如果改小了,则会间接影响到那些大于更新后length的索引,所以那些索引对应的依赖和length对应的依赖会重新执行
  • 数组的栈方法会隐式修改数组的长度,更大的问题是它们执行的时候,即会读取length值,也会隐式更改length值,为了不使程序栈溢出,需要对这些即会读取length,也会修改length的原型方法,做些特别处理,执行这些方法的时候,将shouldTrack = false,不进行依赖收集,执行完毕后,再将shouldTrack = true
  • splice方法也是和上面一样的操作。fill方法会修改所有的索引值,所以所有索引的依赖会重新触发。sort方法执行也是根据条件来的,触发条件被排序的索引,会重新触发依赖执行

Set和Map

SetMap数据的原型属性和方法大致相同,不同点在于给集合添加数据的方法,Set使用add方法,而Map使用Set方法。

Set类型原型属性和方法如下:

  • size:返回集合中元素的数量
  • add(value):向集合中添加给定的值
  • clear():清空集合
  • delete(value):从集合中删除给定的值
  • has(value):判断集合中是否存在给定的值
  • keys():返回一个迭代器对象
  • values():对于Set集合类型来说,keys()values()等价
  • entries():返回一个迭代器对象,每一次迭代值为[value, value]
  • forEach(callback[, thisArg])forEach函数会遍历集合中的所有元素,并对每一个元素调用callback函数,forEach函数接收可选的第二个参数thisArg,用于指定callback执行时的this

Map类型原型属性和方法如下:

  • size:返回Map数据中键值对的数量
  • clear():清空Map
  • delete(key):删除指定key的键值对
  • has(key):判断Map中否存在给定的key的键值对
  • get(key):读取指定key对应的值
  • set(key, value):为Map设置新的键值对或修改已存在key的键值对
  • keys():返回一个迭代器对象,每一次迭代值为键值对的key
  • values():返回一个迭代器对象,每一次迭代值为键值对的value
  • entries():返回一个迭代器对象,每一次迭代值为键值对的[key, value]
  • forEach(callback[, thisArg])forEach函数会遍历Map中的所有键值对,并对每一个键值对调用callback函数,forEach函数接收可选的第二个参数thisArg,用于指定callback执行时的this

如何代理:

相较于ObjectArray可以通过get拦截器拦截后,直接返回属性或索引的值,SetMap的读取操作是调用自身原型的hasget方法,因为Proxy对象没有对应的方法,所以需要我们自定义实现相关方法,这样当我们通过Proxy实例访问MapSet方法的时候,就可以在get拦截器内拿到方法名称,然后返回我们自定义实现的方法,注意,针对MapSet类型数据,Proxy拦截器只用到了get。自定义实现的方法,简单理解就是使用了策略模式,将每个方法对应的逻辑封装成一个方法,内部还是执行MapSet的原生方法,源码中用mutableInstrumentations对象存储所有的策略:

const mutableInstrumentations: Record<string, Function> = {
get(this: MapTypes, key: unknown) {
return get(this, key)
},
get size() {
return size(this as unknown as IterableCollections)
},
has,
add,
set,
delete: deleteEntry,
clear,
forEach: createForEach(false, false)
}
// 给 mutableInstrumentations添加相关迭代方法
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator]
iteratorMethods.forEach(method => {
mutableInstrumentations[method as string] = createIterableMethod(
method,
false,
false
)
})

MapSet的所有可能的读取操作如下:

  • size:访问器属性
  • has(value)
  • get(key)
  • keys()
  • values()
  • entries()
  • forEach(callback[, thisArg])

如何收集依赖:

  • mutableInstrumentations定义的size也是个访问器属性,get size() {}会获取target自身的size属性,以Symbol('iterate')存储依赖。
  • mutableInstrumentations定义的hasget方法逻辑差别不大,Setvalue存储依赖,Mapkey存储依赖
  • mutableInstrumentations定义的迭代方法中,只有当数据类型是Map,且方法是keys时,是针对整个Map的所有key,所以会以Symbol(Map key iterate)存储依赖,其他的所有情况,都是针对集合所有元素或Map所有键值对的,所以会以Symbol(iterate)存储依赖
  • mutableInstrumentations定义的forEach方法, 是针对集合所有元素或Map所有键值对的,所以会以Symbol(iterate)存储依赖

MapSet的所有可能的更新操作如下:

  • clear()
  • delete(key)
  • add(value)
  • set(key, value)

如何触发依赖重新执行:

  • clear()操作会使SetMap数据的所有依赖重新执行
  • delete(key)操作会使MapkeySetvalue以及Symbol(iterate)的依赖重新执行,如果是Map,还会触发Symbol(Map key iterate)的依赖重新执行
  • add(value)操作会触发SetSymbol(iterate)的依赖重新执行
  • set(key, value)操作分两种,一种新增,一种更新。新增操作时,会触发MapSymbol(iterate)Symbol(Map key iterate)的依赖重新执行,更新操作时,会触发对应key的依赖执行

基本数据类型

基本数据类型的代理是最简单的

基本数据类型如numberstringbooleannullundefinedSymbolbigint,无法使用Proxy直接代理,为此需要将其转为一个对象包裹起来,像这样{ value: '' },然后给这个对象的value添加gettersetter,这样每次进行读和写的时候都可以拦截到,在getter中收集依赖,在setter中触发依赖执行。

补充一些知识

  • ref也可以处理非基本数据类型,如果传入一个ObjectArray等非基本数据类型,在创建RefImpl实例时,会通过toReactive(value)将它们转为Proxy对象

  • 如果在reactive类型数据内部有属性是ref类型,在读取和设置时会自动解包,无需再通过.value去操作

    • 读取时getter内部会执行如下代码:

      if (isRef(res)) {
      // ref unwrapping - skip unwrap for Array + integer key.
      return targetIsArray && isIntegerKey(key) ? res : res.value // 自动解包
      }
      
    • 设置时setter内部会执行如下代码,如果旧值是ref,而新值非ref,说明是给ref属性值赋值,则只需要通过oldValue.value = value更新ref就可以了

      if (!shallow) {
      if (!isShallow(value) && !isReadonly(value)) {
      oldValue = toRaw(oldValue)
      value = toRaw(value)
      }
      // isRef(oldValue) && !isRef(value) 表示给ref属性值赋值
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
      }
      }
      
  • 使用toRefs(...reactive({ a: 1, b: 2 }))可以解决reactive数据展开时,响应式丢失的问题

  • 为什么在模板中可以不用使用.value读取和修改ref?因为使用了proxyRefs方法对ref数据进行了代理

  • toRef方法转成的ref数据与普通ref数据是不太一样的,普通ref数据内部是RefImpl类,而toRef返回的ref数据内部是ObjectRefImpl类,ObjectRefImpl类自身并没有依赖的收集和触发能力,而是基于被转数据,所以正常的用法是将响应式数的某个属性通过toRef转为ref

计算属性

computed

实现计算属性的核心是ComputedRefImpl类,ComputedRefImpl类源码如下:

class ComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true
public readonly [ReactiveFlags.IS_READONLY]: boolean = false
public _dirty = true // 是否需要重新计算
public _cacheable: boolean // this._cacheable = !isSSR
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean
) {
// 第二个参数为调度器
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this) // 只有在effect内部读取当前计算属性才会触发dep执行,否则dep为空不执行
}
})
this.effect.computed = this
this.effect.active = this._cacheable = !isSSR
this[ReactiveFlags.IS_READONLY] = isReadonly
}
get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
trackRefValue(self)
// _dirty为true,则需要重新执行getter
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}
return self._value
}
set value(newValue: T) {
this._setter(newValue)
}
}

从源码可以看出来,computedComputedRefImpl类和refRefImpl类是很相似的,它们都是属于__v_isRef类型,通过拦截gettersetter,处理响应逻辑。computed之所以能够实现自动计算的能力,是因为它内置了ReactiveEffect实例,就是在内部使用了副作用函数对getter内的所有响应式数据进行了依赖收集,并使用调度器scheduler,这样,当getter内的响应式数据发生变更后,就会触发scheduler执行,而不是getter重新执行。

调度器使用一个实例属性_dirty,来控制读取计算属性时,使用缓存数据还是重新触发getter方法计算

// --调度器方法
// 如果需要重新计算,会把_dirty置为true
() => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
}
// --get value() 内部
// _dirty为true,则需要重新执行getter
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}

deferredComputed

相比computeddeferredComputed实现了如下功能:

  • 异步计算
  • 懒加载(懒加载trigger方法)

DeferredComputedRefImpl类是deferredComputed的具体实现,先附上源码:

const tick = /*#__PURE__*/ Promise.resolve()
const queue: any[] = []
let queued = false
const scheduler = (fn: any) => {
queue.push(fn)
if (!queued) {
queued = true
tick.then(flush)
}
}
const flush = () => {
for (let i = 0; i < queue.length; i++) {
queue[i]()
}
queue.length = 0
queued = false
}
class DeferredComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
private _dirty = true // 是否需要重新计算
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true
public readonly [ReactiveFlags.IS_READONLY] = true
constructor(getter: ComputedGetter<T>) {
let compareTarget: any
let hasCompareTarget = false
let scheduled = false
// 第二个参数是ReactiveEffect实例的调度器
this.effect = new ReactiveEffect(getter, (computedTrigger?: boolean) => {
if (this.dep) {
if (computedTrigger) {
compareTarget = this._value
hasCompareTarget = true
} else if (!scheduled) {
const valueToCompare = hasCompareTarget ? compareTarget : this._value
scheduled = true
hasCompareTarget = false
scheduler(() => {
if (this.effect.active && this._get() !== valueToCompare) {
triggerRefValue(this)
}
scheduled = false
})
}
for (const e of this.dep) {
if (e.computed instanceof DeferredComputedRefImpl) {
e.scheduler!(true /* computedTrigger */)
}
}
}
this._dirty = true
})
this.effect.computed = this as any
}
private _get() {
if (this._dirty) {
this._dirty = false
return (this._value = this.effect.run()!)
}
return this._value
}
get value() {
trackRefValue(this)
return toRaw(this)._get()
}
}

异步计算

如果熟悉vue组件的异步更新原理,可能就比较好理解了,当我们在一个微任务内多次更改响应式数据,那么组件就会在nextTick进行更新,这对性能优化很重要,减少了不必要的dom更新,这里其实是一样的道理。

该功能主要依赖的变量和方法:

  • queue:微任务异步队列
  • queued:排队等待的标识,确保当前微任务期间加入的任务,会在下一次微任务中调用flush刷新
  • flush():刷新(执行)微任务队列
  • scheduled:是否执行scheduler标识,为了确保一个微任务期间,多次更改响应式数据,只会触发更新一次
  • scheduler(fn):往queue存入任务方法

计算属性的getter所依赖的响应式数据发生变更,会触发ReactiveEffect实例的调度器执行,判断当前计算属性是否有依赖,有的话,如果满足computedTrigger !== true && scheduled === false,会将scheduled = true,并调用scheduler方法创建一个任务,存入queue队列,只有这个任务执行完毕,scheduled的值才会恢复false,而这个任务只有在微任务结束后才会执行,不管期间依赖数据变更了多少次,任务执行的时候都只会取最终的更新结果,这就达到了使计算属性异步计算的目的。

懒加载

该功能主要依赖的变量和方法:

  • hasCompareTarget
  • compareTarget

我们注意到,上面提到的存入queue队列的任务中有this._get() !== valueToCompare这样一个判断,说明我们存入到queue中的任务最终还并不一定会触发计算属性的依赖(并不是计算属性的getter)重新执行,只有计算属性的计算结果发生了变化才会触发其依赖重新执行。

至于计算属性链式调用计算属性的情况,就不说了,太让人头大了😖,自行查看测试用例了解:sync access of invalidated chained computed should not prevent final effect from running

最后

断断续续的基本把这块写完了,文笔稀烂,有些内容自己懂了,但是写下来让别人也能看懂还是非常不容易的,接下来准备开始看runtime-core模块,继续总结……

参考资料

书籍:Vue.js设计与实现

原文链接:https://juejin.cn/post/7227062606040760380 作者:心有猛虎嗷呜

(0)
上一篇 2023年4月30日 上午10:58
下一篇 2023年4月30日 上午11:08

相关推荐

发表回复

登录后才能评论