Skip to content

第六章:响应系统 - computed && watch

01:开篇

对于响应性系统而言,除了我们在前两章接触的 refreactive 之外,还有另外两个也是我们经常使用到的,那就是:

  1. 计算属性:computed
  2. 侦听器:watch

那么本章节,我们就来看一下,这两个 API 是如何进行实现的。

在看本章节的内容之前,大家需要:搞明白 vue 3computedwatch 的作用和基本用法

搞明白了这两个 API 的基本用法之后,大家就可以来开始本章节的学习啦~~~

02:源码阅读:computed 的响应性,跟踪 Vue 3 源码实现逻辑

计算属性 computed基于其响应式依赖被缓存,并且在依赖的响应式数据发生变化时 重新计算

那么根据计算属性的概念,我们可以创建对应的测试实例:packages/vue/examples/imooc/computed.html

html
<script>
  const { reactive, computed, effect } = Vue

  const obj = reactive({
    name: '张三'
  })

  const computedObj = computed(() => {
    return '姓名:' + obj.name
  })

  effect(() => {
    document.querySelector('#app').innerHTML = computedObj.value
  })

  setTimeout(() => {
    obj.name = '李四'
  }, 2000);
</script>

在以上测试实例中,程序主要执行了 5 个步骤:

  1. 使用 reactive 创建响应性数据
  2. 通过 computed 创建计算属性 computedObj,并且触发了 objgetter
  3. 通过 effect 方法创建 fn 函数
  4. fn 函数中,触发了 computedgetter
  5. 延迟触发了 objsetter

那么在这 5 个步骤中,有些步骤进行的操作我们是了解的,所以我们只需要看之前没有了解过得即可。

computed

computed 的代码在 packages/reactivity/src/computed.ts 中,我们可以在这里为 computed 函数增加断点:

  1. 代码进入 computed 函数

  2. 执行 const onlyGetter = isFunction(getterOrOptions) 方法:

    1. getterOrOptions 为传入的第一个参数, 因为我们传入的为函数,所以 onlyGetter = true
  3. 执行:getter = getterOrOptions ,即:getter 为我们传入的函数

  4. 执行:setter = NOOPNOOP() => {}

  5. 执行:new ComputedRefImpl创建 ComputedRefImpl 实例。那么这里的 ComputedRefImpl 是什么呢?

  6. 进入 ComputedRefImpl

    1. 在构造函数中,可以看到:**创建了 ReactiveEffect 实例 **,并且传入了两个参数:

      1. getter:触发 computed 函数时,传入的第一个参数
      2. 匿名函数:当 this._dirtyfalse 时,会触发 triggerRefValue,我们知道 triggerRefValue依次触发依赖
      js
      () => {
       // _dirty 表示 “脏” 的意思,这里可以理解为 《依赖的响应性数据发生了变化,计算属性需要重新计算了》
       if (!this._dirty) {
         this._dirty = true
         triggerRefValue(this)
       }
      }
    2. 而对于 ReactiveEffect 而言,我们之前也是有了解过的:

      1. 它位于 packages/reactivity/src/effect.ts 文件中
      2. 提供了一个 run 方法 和一个 stop 方法:
        1. run 方法:触发 fn,即传入的第一个参数
        2. stop 方法:语义上为停止的意思,目前咱们还没有实现
      3. 生成的实例,我们一般把它叫做 effect
    3. 执行 this.effect.computed = this,即:effect 实例 被挂载了一个新的属性 computed 为当前的 ComputedRefImpl 的实例。

    4. ReactiveEffect 构造函数执行完成

    5. computed 中返回了 ComputedRefImpl 实例

由以上代码可知,当我们在执行 computed 函数时:

  1. 定义变量 getter 为我们传入的回调函数
  2. 生成了 ComputedRefImpl 实例,作为 computed 函数的返回值
  3. ComputedRefImpl 内部,利用了 ReactiveEffect 函数,并且传入了 第二个参数

computed 的 getter

computed 代码执行完成之后,我们在 effect 中触发了 computedgetter

js
computedObj.value

根据我们之前在学习 ref 的时候可知,.value 属性的调用本质上是一个 get value 的函数调用,而 computedObj 作为 computed 的返回值,本质上是 ComputedRefImpl 的实例, 所以此时会触发 ComputedRefImpl 下的 get value 函数。

  1. 进入ComputedRefImpl 下的 get value 函数

  2. 执行 trackRefValue(self) ,该方法我们是有过了解的,知道它的作用是:收集依赖,它接收一个 ref 作为参数,该 ref 本质上就是 ComputedRefImpl 的实例:

    image-20230812190504022

  3. 执行 self._dirty = false ,我们知道 _dirty 的意思,如果 _dirty = true 则会 触发执行依赖 。在 当前(标记为 false 之前)self._dirty = true

  4. 所以接下来执行 self.effect.run()!,执行了 run 方法,我们知道 run 方法内部其实会触发 fn 函数,即:computed接收的第一个参数

  5. 接下来把 self._value = self.effect.run()! ,此时 self._value 的值为 computed 第一个参数(fn 函数)的返回值, 即为:计算属性计算之后的值

  6. 最后执行 return self._value,返回计算的值

由以上代码可知:

  1. ComputedRefImpl 实例本身就没有 代理监听,它本质上是一个 get valueset value 的触发
  2. 在每一次 get value 被触发时,都会主动触发一次 依赖收集
  3. 根据 _dirty 和 _cacheable 的状态判断,是否需要触发 run 函数
  4. computed 的返回值,其实是 run 函数执行之后的返回值

ReactiveEffect 的 scheduler

到现在为止,我们貌似已经分析完成了 computed 的源码执行逻辑,但是大家仔细来看上面的逻辑分析,可以发现,目前这样的逻辑还存在一些问题。

我们知道对于计算属性而言,当它依赖的响应式数据发生变化时,它将重新计算。那么换句话而言就是:当响应性数据触发 setter 时,计算属性需要触发依赖

在上面的代码中,我们知道,当 《每一次 get value 被触发时,都会主动触发一次 依赖收集》,但是 触发依赖 的地方在哪呢?

根据以上代码可知:在 ComputedRefImpl 的构造函数中,我们创建了 ReactiveEffect 实例,并且传递了第二个参数,该参数为一个回调函数,在这个回调函数中:我们会根据 的状态来执行 triggerRefValue ,即 触发依赖,重新计算。

那么这个 ReactiveEffect 第二个参数 是什么呢?它会在什么时候被触发,以 触发依赖 呢?

我们来看一下:

  1. 进入 packages/reactivity/src/effect.ts

  2. 查看 ReactiveEffect 的构造函数,可以第二个参数为 scheduler

  3. scheduler 表示 调度器 的意思,我们查看 packages/reactivity/src/effect.tstriggerEffect 方法,可以发现这里进行了调度器的判定:

    js
    function triggerEffect(...) {
     ...
       if (effect.scheduler) {
         effect.scheduler()
       }
      ...
    }
  4. 那么接下来我们就可以跟踪一下代码的实现。

跟踪代码

我们知道 延迟两秒之后,会触发 obj.namereactivesetter 行为,所以我们可以在 packages/reactivity/src/baseHandlers.ts 中为 set 增加一个断点:

  1. 进入 reactivesetter (注意:这里是延迟两秒之后 setter 行为)

  2. 跳过之前的相同逻辑之后,可知,最后会触发:trigger(target, TriggerOpTypes.SET, key, value, oldValue) 方法

  3. 进入 trigger 方法:

  4. 同样跳过之前相同逻辑,可知,最后会触发:triggerEffects(deps[0], eventInfo) 方法

  5. 进入 triggerEffects 方法:

  6. 这里要注意:因为我们在 ComputedRefImpl 的构造函数中,执行了 this.effect.computed = this所以此时的 if (effect.computed) 判断将会为 true

    1. 此时我们注意看 effects,此时 effect 的值为 ReactiveEffect 的实例,同时 scheduler 存在值

    2. 接下来进入 triggerEffect

      1. triggerEffect

      2. 执行 if (effect.scheduler) 判断,因为 effect 存在 scheduler ,所以会 执行 scheduler 函数

      3. 此时会进入 ComputedRefImpl 类的构造函数中,传递的回调函数

        1. 进入 scheduler 回调
        2. 此时 this 的状态如下

        634f680009dfb54013940722

        1. 所以会执行 triggerRefValue 函数:

          1. 进入 triggerRefValue 函数

          2. 会再次触发 triggerEffects 函数,把当前的 this.dep 作为参数传入

            1. 再次进入 triggerEffects

            2. 注意: 此时的 effects 的值为:

              image-20230812193923674

            3. 这次的 ReactiveEffect 不再包含 调度器

            4. 接下来进入 triggerEffect

              1. triggerEffect 因为 effect 不再包含调度器 scheduler

              2. 所以会直接执行 fn 函数

              3. fn 函数的触发,标记着 computedObj.value 触发,而我们知道 computedObj.value 本质上是 get value 函数的触发,所以代码接下来会触发 ComputedRefImplget value

              4. 接下来进入 get value

                1. 进入 get value

                2. 执行 self._value = self.effect.run()!,而 run 函数的执行本质上是 fn 函数的执行,而此时 fn 函数为:

                  image-20230812194044295

                3. 执行该函数得到计算的值

                4. 最后作为 computedObj.value 的返回值

                5. 省略后续的触发…

至此,整个 obj.name 引发的副作用全部执行完成。

由以上代码可知,整个的计算属性的逻辑是非常复杂的,我们来做一下整理:

  1. 整个事件有 obj.name 开始

  2. 触发 proxy 实例的 setter

  3. 执行 trigger第一次触发依赖

  4. 注意,此时 effect 包含调度器属性,所以会触发调度器

  5. 调度器指向 ComputedRefImpl 的构造函数中传入的匿名函数

  6. 在匿名函数中会:再次触发依赖

  7. 即:两次触发依赖

  8. 最后执行 :

    js
    () => {
       return '姓名:' + obj.name
     }

得到值作为 computedObj 的值

总结

那么到这里我们基本上了解了 computed 的执行逻辑,里面涉及到了一些我们之前没有了解过的概念,比如 调度器 scheduler ,并且整体的 computed 的流程也相当复杂。

所以接下来我们去实现 computed 的时候,会分步骤一步一步执行。

03:框架实现:构建 ComputedRefImpl ,读取计算属性的值

对于 computed 而言,整体比较复杂,所以我们将分步进行实现。

那么对于本小节而言,我们的首先的目标是:构建 ComputedRefImpl 类,创建出 computed 方法,并且能够读取值

  1. 创建 packages/reactivity/src/computed.ts
js
   import { isFunction } from '@vue/shared'
   import { Dep } from './dep'
   import { ReactiveEffect } from './effect'
   import { trackRefValue } from './ref'
   
   /**
    * 计算属性类
    */
   export class ComputedRefImpl<T> {
   	public dep?: Dep = undefined
   	private _value!: T
   
   	public readonly effect: ReactiveEffect<T>
   
   	public readonly __v_isRef = true
   
   	constructor(getter) {
   		this.effect = new ReactiveEffect(getter)
   		this.effect.computed = this
   	}
   
   	get value() {
   		// 触发依赖
   		trackRefValue(this)
   		// 执行 run 函数
   		this._value = this.effect.run()!
   		// 返回计算之后的真实值
   		return this._value
   	}
   }
   
   /**
    * 计算属性
    */
   export function computed(getterOrOptions) {
   	let getter
   
   	// 判断传入的参数是否为一个函数
   	const onlyGetter = isFunction(getterOrOptions)
   	if (onlyGetter) {
   		// 如果是函数,则赋值给 getter
   		getter = getterOrOptions
   	}
   
   	const cRef = new ComputedRefImpl(getter)
   
   	return cRef as any
   }
  1. packages/shared/src/index.ts 中,创建工具方法:
js
   /**
    * 是否为一个 function
    */
   export const isFunction = (val: unknown): val is Function =>
   	typeof val === 'function'
  1. packages/reactivity/src/effect.ts 中,为 ReactiveEffect 增加 computed 属性:
js
   export class ReactiveEffect<T = any> {
   	/**
   	 * 存在该属性,则表示当前的 effect 为计算属性的 effect
   	 */
   	computed?: ComputedRefImpl<T>
   	....
  1. 最后不要忘记在 packages/reactivity/src/index.tspackages/vue/src/index.ts 导出
  2. 创建测试实例:packages/vue/examples/reactivity/computed.html
html
   <body>
     <div id="app"></div>
   </body>
   <script>
     const { reactive, computed, effect } = Vue
   
     const obj = reactive({
       name: '张三'
     })
   
     const computedObj = computed(() => {
       return '姓名:' + obj.name
     })
   
     effect(() => {
       document.querySelector('#app').innerHTML = computedObj.value
     })
   
     setTimeout(() => {
       obj.name = '李四'
     }, 2000);
   </script>

那么此时,我们可以发现,计算属性,可以正常展示。

但是:obj.name 发生变化时,我们可以发现浏览器 并不会 跟随变化,即:计算属性并非是响应性的。那么想要完成这一点,我们还需要进行更多的工作才可以。

04:框架实现:computed 的响应性:初见调度器,处理脏的状态

根据之前的代码可知,如果我们想要实现 响应性,那么必须具备两个条件:

  1. 收集依赖:该操作我们目前已经在 get value 中进行。
  2. 触发依赖:该操作我们目前尚未完成,而这个也是我们本小节主要需要做的事情。

那么根据第二小节的源码可知,这部分代码是写在 ReactiveEffect 第二个参数上的:

js
() => {
			if (!this._dirty) {
				this._dirty = true
				triggerRefValue(this)
			}
		}

这个参数是一个匿名函数,被叫做 scheduler 调度器。

该匿名函数中,又涉及到了一个 _dirty 变量,该变量我们把它叫做

那么想要实现 computed 的响应性,就必须要搞明白这两个东西的概念:

调度器

调度器 scheduler 是一个相对比较复杂的概念,它在 computedwatch 中都有涉及,但是在当前的 computed 实现中,它的作用还算比较清晰。

所以根据我们秉承的:没有使用就当做不存在 的理念,我们只需要搞清楚,它在当前的作用即可。

根据我们在第二小节的源码阅读,我们可以知道,此时的 scheduler 就相当于一个 回调函数

triggerEffect 只要 effect 存在 scheduler,则就会执行该函数。

_dirty 脏

对于 dirty 而言,相对比较简单了。

它只是一个变量,我们只需要知道:它为 false 时,表示需要触发依赖。为 true 时表示需要重新执行 run 方法,获取数据。 即可。

实现

那么明确好了以上两个概念之后,接下来我们就来进行下 computed 的响应性实现:

  1. packages/reactivity/src/computed.ts 中,处理脏状态和 scheduler
js
   export class ComputedRefImpl<T> {
   	...
   
   	/**
   	 * 脏:为 false 时,表示需要触发依赖。为 true 时表示需要重新执行 run 方法,获取数据。即:数据脏了
   	 */
   	public _dirty = true
   
   	constructor(getter) {
   		this.effect = new ReactiveEffect(getter, () => {
   			// 判断当前脏的状态,如果为 false,表示需要《触发依赖》
   			if (!this._dirty) {
   				// 将脏置为 true,表示
   				this._dirty = true
   				triggerRefValue(this)
   			}
   		})
   		this.effect.computed = this
   	}
   
   	get value() {
   		// 触发依赖
   		trackRefValue(this)
   		// 判断当前脏的状态,如果为 true ,则表示需要重新执行 run,获取最新数据
   		if (this._dirty) {
   			this._dirty = false
   			// 执行 run 函数
   			this._value = this.effect.run()!
   		}
   
   		// 返回计算之后的真实值
   		return this._value
   	}
   }
  1. packages/reactivity/src/effect.ts 中,添加 scheduler 的处理:
js
   export type EffectScheduler = (...args: any[]) => any
   
   /**
    * 响应性触发依赖时的执行类
    */
   export class ReactiveEffect<T = any> {
   	/**
   	 * 存在该属性,则表示当前的 effect 为计算属性的 effect
   	 */
   	computed?: ComputedRefImpl<T>
   
   	constructor(
   		public fn: () => T,
   		public scheduler: EffectScheduler | null = null
   	) {}
   	...
   }
  1. 最后不要忘记,触发调度器函数
js
   /**
    * 触发指定依赖
    */
   export function triggerEffect(effect: ReactiveEffect) {
     // 存在调度器就执行调度函数
     if (effect.scheduler) {
       effect.scheduler()
     } 
     // 否则直接执行 run 函数即可
     else {
       effect.run()
     }
   }

此时,重新执行测试实例,则发现 computed 以具备响应性。

05:框架实现:computed 的缓存性

我们知道 computed 区别于 function 最大的地方就是:computed 具备缓存,当多次触发计算实行时,那么计算属性只会计算 一次

那么秉承着这样的一个理念,我们来创建一个测试用例:

  1. 创建 packages/vue/examples/reactivity/computed-cache.html
js
   <script>
     const { reactive, computed, effect } = Vue
   
     const obj = reactive({
       name: '张三'
     })
   
     const computedObj = computed(() => {
       console.log('计算属性执行计算');
       return '姓名:' + obj.name
     })
   
     effect(() => {
       document.querySelector('#app').innerHTML = computedObj.value
       document.querySelector('#app').innerHTML = computedObj.value
     })
   
     setTimeout(() => {
       obj.name = '李四'
     }, 2000);
   </script>

运行到浏览器,我们发现当前代码出现了 死循环 的问题。

那么这个 死循环 是因为什么呢?

如果我们想要实现计算属性的缓存性,又应该如何进行实现呢?

带着这两个问题,我们继续来往下看。

为什么会出现死循环

我们为当前的代码进行 debugger,查看出现该问题的原因。我们知道这个死循环是在 延迟两秒后 出现的,而延迟两秒之后是 obj.name 的调用,即: reactivesetter 行为被触发,也就是 trigger 方法触发时:

  1. packages/reactivity/src/effect.ts 中的 trigger 方法增加断点,延迟两秒之后,进入断点:

  2. 此时执行的代码是 obj.name = '李四',所以在 target{name: '李四'}

  3. 但是要 注意,此时 targetMap 中,已经在 收集过 effect 了,此时的 dep 中包含一个 计算属性effect

    image-20230812194901747

  4. 代码继续向下进行,进入 triggerEffects(dep) 方法

  5. triggerEffects(dep) 方法中,继续进入 triggerEffect(effect)

  6. triggerEffect 中接收到的 effect,即为刚才查看的 计算属性的 effect

    image-20230812195015855

  7. 此时因为 effect 中存在 scheduler,所以会执行该计算属性的 scheduler 函数,在 scheduler 函数中,会触发 triggerRefValue(this),而 triggerRefValue 则会再次触发 triggerEffects

  8. 特别注意: 此时 effects 的值为 计算属性实例的 dep

    image-20230812195100430

  9. 循环 effects,从而再次进入 triggerEffect 中。

  10. 再次进入 triggerEffect,此时 effect非计算属性的 effect,即 fn 函数

    image-20230812200836391

  11. 因为他 不是 计算属性的 effect ,所以会直接执行 run 方法。

  12. 而我们知道 run 方法中,其实就是触发了 fn 函数,所以最终会执行:

js
() => {
    document.querySelector('#app').innerHTML = computedObj.value
    document.querySelector('#app').innerHTML = computedObj.value
  }
  1. 但是在这个 fn 函数中,是有触发 computedObj.value 的,而 computedObj.value 其实是触发了 computedget value 方法。

  2. 但是在这个 fn 函数中,是有触发 computedObj.value 的,而 computedObj.value 其实是触发了 computedget value 方法。

    1. 第一次进入:

      1. 进入 computedget value
      2. 首先收集依赖
      3. 接下来检查 dirty脏的状态,执行 this.effect.run()!
      4. 获取最新值,返回
    2. 第二次进入:

      1. 进入 computedget value
      2. 首先收集依赖
      3. 接下来检查 dirty脏的状态,**因为在上一次中 dirty 已经为 false **,所以本次 不会在触发 this.effect.run()!
      4. 直接返回结束
  3. **按说代码应该到这里就结束了,**但是不要忘记,在刚才我们进入到 triggerEffects 时,effets 是一个数组,内部还存在一个 computedeffect,所以代码会 继续 执行,再次来到 triggerEffect 中:

    1. 此时 effectcomputedeffect

      image-20230812195840157

    2. 这会导致,再次触发 scheduler

    3. scheduler 中还会再次触发 triggerRefValue

    4. triggerRefValue 又触发 triggerEffects再次生成一个新的 effects 包含两个 effect,就像 第七步 一样

    5. 从而导致 死循环

以上逻辑就是为什么会出现死循环的原因。

那么明确好了导致死循环的代码逻辑之后,接下来就是如何解决这个死循环的问题呢?

PS:这里大家要注意: vue-next-mini 是一个学习 vue 3 核心源代码的库,所以它在一些复杂业务中会存在各种 bug。而这样的 bugvue3 的源码中处理完善的逻辑非常非常复杂,我们不可能完全按照 vue 3 的标准来去处理。

所以我们秉承着 最少代码的实现逻辑 来解决对应的 bug,它 并不是一个完善的方案(相比于 vue 3 源代码),但是我们可以保证 它是 vue 3 的源码逻辑,并且是

如何解决死循环

想要解决这个死循环的问题,其实比较简单,我们只需要在 packages/reactivity/src/effect.ts 中的 triggerEffects 中修改如下代码:

js
export function triggerEffects(dep: Dep) {
	// 把 dep 构建为一个数组
	const effects = isArray(dep) ? dep : [...dep]
	// 依次触发
	// for (const effect of effects) {
	// 	triggerEffect(effect)
	// }

	// 不在依次触发,而是先触发所有的计算属性依赖,再触发所有的非计算属性依赖
	for (const effect of effects) {
		if (effect.computed) {
			triggerEffect(effect)
		}
	}
	for (const effect of effects) {
		if (!effect.computed) {
			triggerEffect(effect)
		}
	}
}

那么为什么这样就可以解决死循环的 bug 呢?

我们再按照刚才的顺序跟踪下代码进行查看:

  1. packages/reactivity/src/effect.ts 中的 trigger 方法增加断点,延迟两秒之后,进入断点:

  2. 此时执行的代码是 obj.name = '李四',所以在 target{name: '李四'}

  3. 但是要 注意,此时 targetMap 中,已经在 收集过 effect 了,此时的 dep 中包含一个 计算属性effect

  4. 代码继续向下进行,进入 triggerEffects(dep) 方法

  5. triggerEffects(dep) 方法中,继续进入 triggerEffect(effect)

  6. triggerEffect 中接收到的 effect,即为刚才查看的 计算属性的 effect

    image-20230812200116939

  7. 此时因为 effect 中存在 scheduler,所以会执行该计算属性的 scheduler 函数,在 scheduler 函数中,会触发 triggerRefValue(this),而 triggerRefValue 则会再次触发 triggerEffects

  8. **** 不同从这里开始****

  9. 因为此时我们在 triggerEffects 中,增加了 判断逻辑,所以 永远会先触发 计算属性的 effect

  10. 所以此时再次进入到 triggerEffect 时,此时的 effect 依然为 计算属性的 effect

    image-20230812200918310

  11. 从而因为存在 scheduler,所以会执行:

    js
    () => {
      // 判断当前脏的状态,如果为 false,表示需要《触发依赖》
      if (!this._dirty) {
        // 将脏置为 true,表示
        this._dirty = true
        triggerRefValue(this)
      }
    })
  12. 但是此时要注意:此时 _dirty 脏的状态true,即:不会触发 triggerRefValue 来触发依赖,此次计算属性的 scheduler 调度器会 直接结束

  13. 然后代码 跳回到 triggerEffects 两次循环中,使用 非计算属性的 effect 执行 triggerEffect 方法

  14. 本次进入 triggerEffect 时,effect 数据如下:

    image-20230812201001169

  15. 那么这次 run 的执行会触发 两次 computedget value

  16. 所以代码会进入到 computedget value 中:

    1. 第一次进入:
      1. 进入 computedget value
      2. 首先收集依赖
      3. 接下来检查 dirty脏的状态,执行 this.effect.run()!
      4. 获取最新值,返回
    2. 第二次进入:
      1. 进入 computedget value
      2. 首先收集依赖
      3. 接下来检查 dirty脏的状态,**因为在上一次中 dirty 已经为 false **,所以本次 不会在触发 this.effect.run()!
      4. 直接返回结束

所有代码逻辑结束。

查看测试实例的打印,computed 只计算了一次。

总结

那么到这里我们就解决了计算属性的死循环问题和缓存的问题。

其实解决的方式非常的简单,我们只需要控制 computedeffect非 computedeffect 的执行顺序,通过明确的 dirty 来控制 runtriggerRefValue 的执行即可。

06:总结:computed 计算属性

那么到这里我们已经完成了 computed 计算属性的构建。

接下来我们来总结一下计算属性实现的重点:

  1. 计算属性的实例,本质上是一个 ComputedRefImpl 的实例
  2. ComputedRefImpl 中通过 dirty 变量来控制 run 的执行和 triggerRefValue 的触发
  3. 想要访问计算属性的值,必须通过 .value ,因为它内部和 ref 一样是通过 get value 来进行实现的
  4. 每次 .value 时都会触发 trackRefValue 即:收集依赖
  5. 在依赖触发时,需要谨记,先触发 computedeffect,再触发非 computedeffect

07:源码阅读:响应性的数据监听器 watch,跟踪源码实现逻辑

我们可以点击 这里 来查看 watch 的官方文档。

watch 的实现和 computed 有一些相似的地方,但是作用却与 computed 大有不同。watch 可以监听响应式数据的变化,从而触发指定的函数

vue3 中使用 watch 的代码如下所示:

js
watch(() => obj.name, (value, oldValue) => {
  console.log('watch 监听被触发');
  console.log('oldValue', oldValue);
  console.log('value', value);
}, {
  immediate: true,
  deep: true
})

上述代码中 watch 函数接收三个参数:

  1. 监听的响应式对象
  2. 回调函数 cb
  3. 配置对象:options
    1. immediatewatch 初始化完成后被立刻触发一次
    2. deep:深度监听

由此可见,watch 函数颇为复杂,所以我们在跟踪 watch 的源码实现时,应当分步骤进行跟踪。

基础的 watch 实例

修改 packages/vue/examples/imooc/watch.html 实例代码如下:

js
<script>
  const { reactive, watch } = Vue

  const obj = reactive({
    name: '张三'
  })

  watch(obj, (value, oldValue) => {
    console.log('watch 监听被触发');
    console.log('value', value);
  })

  setTimeout(() => {
    obj.name = '李四'
  }, 2000);
</script>

在以上代码中:

  1. 首先通过 reactive 函数构建了响应性的实例
  2. 然后触发 watch
  3. 最后触发 proxysetter

摒弃掉之前熟悉的 reactive,我们从 watch 函数开始跟踪:

watch 函数

  1. packages/runtime-core/src/apiWatch.ts 中找到 watch 函数,开始 debugger

  2. 执行 doWatch 函数:

    1. 进入 doWatch 函数
    2. 因为 sourcereactive 类型数据,所以 getter = () => source,目前 sourceproxy 实例,即:
    js
    getter = () => Proxy{name: '张三'}
    1. 紧接着,指定 deep = true,即:source 为 reactive 时,默认添加 options.deep = true
    2. 执行 if (cb && deep),条件满足:
      1. 创建新的常量 baseGetter = getter
    3. 执行 let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
      1. 其中 isMultiSource 表示是否有多个源,我们当前只有一个源,所以 oldValue = INITIAL_WATCHER_VALUE
      2. INITIAL_WATCHER_VALUE = {}
    4. 执行 const job: SchedulerJob = () => {...},我们知道 Scheduler 是一个调度器,SchedulerJob 其实就是一个调度器的处理函数,在之前我们接触了一下 Scheduler 调度器,但是并没有进行深入了解,那么这里将涉及到调度器的比较复杂的一些概念,所以后面我们想要实现 watch,还需要 深入的了解下调度器的概念,现在我们暂时先不需要管它。
    5. 接下来还是 调度器 概念,直接执行:let scheduler: EffectScheduler = () => queuePreFlushCb(job)
    6. 6、7 结合,将得到一个完整的调度器函数 scheduler,该函数被触发时,会返回 queuePreFlushCb(job) 函数执行的结果
    7. 代码继续执行得到一个 ReactiveEffect 的实例,注意: 该实例包含一个完善的调度器 scheduler
    8. 代码继续执行,进入如下判断逻辑:
    js
     // cb 是 watch 的第二个参数
     if (cb) {
       // immediate 是 options 中的 immediate,表示:watch 是否立刻执行。
       // 那么根据这个这个概念和一下代码,其实可以猜测:《 job() 触发,表示 watch 被立刻执行了一次 》
       if (immediate) {
        job()
       } else {
         // 不包含 immediate,则通过 effect.run() 获取旧值。
         // 根据我们前面创建 effect 的代码可知,run() 的执行其实是 getter 的执行。
         // 所以此处可以理解为 getter 被触发,则获取了 oldValue
         // 我们的代码将执行 else
        oldValue = effect.run()
     }
    1. 最后 return 了一个回调函数:
    js
    return () => {
       effect.stop()
       if (instance && instance.scope) {
         remove(instance.scope.effects!, effect)
       }
     }

    回调函数中的代码我们 无需深究,但是根据 代码语义 stop 停止remove 删除 ,可以猜测:该函数被触发 watch 将停止监听,同时删除依赖

    那么至此 watch 函数的逻辑执行完成。

    由以上代码可知:

    1. watch 函数的代码很长,但是逻辑还算清晰
    2. 调度器 schedulerwatch 中很关键
    3. schedulerReactiveEffect 两者之间存在互相作用的关系,一旦 effect 触发了 scheduler 那么会导致 queuePreFlushCb(job) 执行
    4. 只要 job() 触发,那么就表示 watch 触发了一次

reactive 触发 setter

等待两秒,reactive 实例将触发 setter 行为,setter 行为的触发会导致 trigger 函数的触发,所以我们可以直接在 trigger 中进行 debugger

  1. trigger 中进行 debugger

  2. 根据我们之前的经验可知,trigger 最终会触发到 triggerEffect,所以我们可以 省略中间 步骤,直接进入到 triggerEffect

    1. 进入 triggerEffect

    2. 此时 effect 为:

      image-20230812201722164

    3. 关键其中两个比较重要的变量:

      1. fn:值为 traverse(baseGetter())

        1. 根据 2-4-1 可知 baseGetter = getter

        2. 根据 2-2 可知:getter = () => Proxy{name: 'xx'}

        3. 所以 fn = traverse(() => Proxy{name: 'xx'})

      2. scheduler:值为 () => queuePreFlushCb(job)

        1. 目前已知 job() 触发表示 watch 被回调一次
    4. 因为 scheduler 存在,所以会直接执行 scheduler,即等同于直接执行 queuePreFlushCb(job)

    5. 所以接下来我们 进入 queuePreFlushCb 函数,看看 queuePreFlushCb 做了什么:

      1. 进入 queuePreFlushCb

      2. 触发 queueCb(cb, ..., pendingPreFlushCbs, ...) 函数,此时 cb = job,即:cb() 触发一次,意味着 watch 触发一次

        1. 进入 queueCb 函数

        2. 执行 pendingQueue.push(cb)pendingQueue 从语义中看表示 队列 ,为一个 数组

        3. 执行 queueFlush() 函数:

          1. 进入 queueFlush() 函数

          2. 执行 isFlushPending = true

          3. 执行 currentFlushPromise = resolvedPromise.then(flushJobs)

            1. 查看 resolvedPromise 可知:const resolvedPromise = Promise.resolve(),即:promise 的成功状态
            2. 我们知道 promise 主要存在 三种状态
            3. 待定(pending):初始状态,既没有被兑现,也没有被拒绝。
            4. 已兑现(fulfilled):意味着操作成功完成。
            5. 已拒绝(rejected):意味着操作失败。
            6. 结合语义,其实可知:isFlushPending = true 应该是一个 标记,表示 promise 进入 pending 状态
            7. 而同时我们知道 Promise.resolve() 是一个 已兑现 状态的状态切换函数,它是一个 异步的微任务 ,即:它是一个优先于 setTimeout(() => {}, 0) 的异步任务
          4. flushJobs 是将是一个 .then 中的回调,即 异步执行函数,它会等到 同步任务执行完成之后 被触发

          5. 我们可以 flushJobs 函数内部增加一个断点

      3. 至此整个 trigger 就执行完成

由以上代码可知:

  1. 整个 trigger 的执行核心是触发了 scheduler 调度器,从而触发 queuePreFlushCb 函数
  2. queuePreFlushCb 函数主要做了以下几点事情:
    1. 构建了任务队列 pendingQueue
    2. 通过 Promise.resolve().thenflushJobs 函数扔到了微任务队列中

同时因为接下来 同步任务已经执行完成,所以 异步的微任务 马上就要开始执行,即接下来我们将会进入 flushJobs 中。

flushJobs 函数

  1. 进入 flushJobs 函数

  2. 执行 flushPreFlushCbs(seen) 函数,这个函数非常关键,我们来看一下:

    1. 第一行代码执行 if (pendingPreFlushCbs.length),这个 pendingPreFlushCbs 此时的值为:

      image-20230812202720937

      1. 通过截图代码可知,pendingPreFlushCbs 为一个数组,其中第一个元素就是 job 函数
        1. 从 《reactive 触发 setter2-5-2》 中可以看到传参
      2. 执行 activePreFlushCbs = [...new Set(pendingPreFlushCbs)],即:activePreFlushCbs = pendingPreFlushCbs
      3. 执行 for 循环,执行 activePreFlushCbs[preFlushIndex](),即从 activePreFlushCbs 这个数组中,取出一个函数,并执行。
        1. 根据 23 步可知,此时取出并且执行的函数即为 :job 函数!

那么到这里,job 函数被成功执行,我们知道 job 执行意味着 watch 执行,即当前 watch 的回调 即将被执行

由以上代码可知:

  1. flushJobs 的主要作用就是触发 job,即:触发 watch

job 函数

  1. 进入 job 的执行函数

  2. 执行 const newValue = effect.run(),此时 effect 为 :

    image-20230812202923524

    1. 我们知道执行 run,本质上是执行 fn
    2. traverse(baseGetter()) 即为 traverse(() => Proxy{name: 'xx'})
    3. 结合代码获取到的是 newValue,所以我们可以大胆猜测,测试 fn 的结果等同于:
    js
    fn: () => {name: '李四'}
    1. 接下来执行:callWithAsyncErrorHandling(cb ......)

      1. 进入 callWithAsyncErrorHandling函数:

      2. 函数接收的第一个参数 fn 的值为 watch 的第二个参数 cb

        image-20230812203148648

      3. 接下来执行 callWithErrorHandling(fn ......)

        1. 进入 callWithErrorHandling

        2. 这里的代码就比较简单了,其实就是触发了 fn(...args),即:watch 的回调被触发,此时 args 的值为:

          image-20230812203230495

        3. 但是比较有意思的是,这里执行了一次 try ... catch

          js
            try {
              res = args ? fn(...args) : fn()
            } catch (err) {
              handleError(err, instance, type)
            }
        4. TODO…

截止到此时 watch 的回调终于 被触发了

由以上代码可知:

  1. job 函数的主要作用其实就是有两个:
    1. 拿到 newValueoldValue
    2. 触发 fn 函数执行

总结

到目前为止,整个 watch 的逻辑就已经全部理完了。整体氛围了四大块:

  1. watch 函数本身
  2. reactivesetter
  3. flushJobs
  4. job

整个 watch 还是比较复杂的,主要是因为 vue 在内部进行了很多的 兼容性处理,使代码的复杂度上升了好几个台阶,我们自己去实现的时候 会简单很多 的。

08:框架实现:深入 scheduler 调度系统实现机制

经过了 computed 的代码和 watch 的代码之后,其实我们可以发现,在这两块代码中都包含了同样的一个概念那就是:调度器 scheduler。完整的来说,我们应该叫它:调度系统

整个调度系统其实包含两部分实现:

  1. lazy:懒执行
  2. scheduler:调度器

懒执行

懒执行相对比较简单,我们来看 packages/reactivity/src/effect.ts 中第 183 - 185 行的代码:

js
if (!options || !options.lazy) {
  _effect.run()
}

这段代码比较简单,其实就是如果存在 options.lazy不立即 执行 run 函数。

我们可以直接对这段代码进行实现:

js
export interface ReactiveEffectOptions {
	lazy?: boolean
	scheduler?: EffectScheduler
}

/**
 * effect 函数
 * @param fn 执行方法
 * @returns 以 ReactiveEffect 实例为 this 的执行函数
 */
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
	// 生成 ReactiveEffect 实例
	const _effect = new ReactiveEffect(fn)
  // !options.lazy 时
	if (!options || !options.lazy) {
		// 执行 run 函数
		_effect.run()
	}
}

那么此时,我们就可以新建一个测试案例来测试下 lazy,创建 packages/vue/examples/reactivity/lazy.html

html
<script>
  const { reactive, effect } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(() => {
    console.log(obj.count);
  }, {
    lazy: true
  })

  obj.count = 2

  console.log('代码结束');

</script>

当不存在 lazy 时,打印结果为:

shell
1
2
代码结束

lazytrue 时,因为不在触发 run,所以不会进行依赖收集,打印结果为:

js
代码结束

scheduler:调度器

调度器比懒执行要稍微复杂一些,整体的作用分成两块:

  1. 控制执行顺序
  2. 控制执行规则

控制执行顺序

我们先来看一个 vue 3 官网的例子,创建测试案例 packages/vue/examples/imooc/scheduler.htm

js
<script>
  const { reactive, effect } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(() => {
    console.log(obj.count);
  })

  obj.count = 2

  console.log('代码结束');

</script>

当前代码执行之后的打印顺序为:

1
2
代码结束

那么现在我们期望 在不改变测试案例代码顺序的前提下 修改一下代码的执行顺序,使其变为:

1
代码结束
2

那么想要达到这样的目的我们应该怎么做呢?

修改一下当前测试案例的代码:

js
  // 调用 effect 方法
  effect(() => {
    console.log(obj.count);
  }, {
    scheduler() {
      setTimeout(() => {
        console.log(obj.count);
      })
    }
  })

我们给 effect 传递了第二个参数 optionsoptions 是一个对象,内部包含一个 scheduler 的选项,此时再次执行代码,得到 期望 的打印结果。

那么为什么会这样呢?

我们来回忆一下我们的代码,我们知道,目前在我们的代码中,执行 scheduler 的地方只有一个,那就是在 packages/reactivity/src/effect.ts 中:

js
/**
 * 触发指定的依赖
 */
export function triggerEffect(effect: ReactiveEffect) {
	if (effect.scheduler) {
		effect.scheduler()
	} else {
		effect.run()
	}
}

effect 存在 scheduler 时,我们会执行该调度器,而不是直接执行 run,所以我们就可以利用 这个特性,在 scheduler 函数中执行我们期望的代码逻辑。

接下来,我们也可以为我们的 effect 增加 scheduler,以此来实现这个功能:

  1. packages/reactivity/src/effect.ts 中:
diff
   export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
   	// 生成 ReactiveEffect 实例
   	const _effect = new ReactiveEffect(fn)
   
   +	// 存在 options,则合并配置对象
   +	if (options) {
   +		extend(_effect, options)
   +	}
   
   	if (!options || !options.lazy) {
   		// 执行 run 函数
   		_effect.run()
   	}
   }
  1. packages/shared/src/index.ts 中,增加 extend 函数:
js
   /**
    * Object.assign
    */
   export const extend = Object.assign
  1. 创建测试案例 packages/vue/examples/reactivity/scheduler.html
html
   <script>
     const { reactive, effect } = Vue
   
     const obj = reactive({
       count: 1
     })
   
     // 调用 effect 方法
     effect(() => {
       console.log(obj.count);
     }, {
       scheduler() {
         setTimeout(() => {
           console.log(obj.count);
         })
       }
     })
   
     obj.count = 2
   
     console.log('代码结束');
   
   </script>

最终,得到期望的执行顺序。

控制执行规则

说完了执行顺序,那么对于执行规则而言,指的又是什么意思呢?

同样我们来看下面的例子 packages/vue/examples/imooc/scheduler-2.html

html
<script>
  const { reactive, effect } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(() => {
    console.log(obj.count)
  })

  obj.count = 2
  obj.count = 3

</script>

运行当前测试实例,得出打印结果:

js
1
2
3

但是我们知道,对于当前代码而言,最终的执行结果是必然为 3 的,那么我们可以不可以 跳过 中间的 2 的打印呢?

那么想要达到这个目的,我们可以按照以下的流程去做:

  1. packages/runtime-core/src/index.ts 中,为 ./scheduler 新增一个导出方法:

    diff
    export { 
      nextTick, 
    +  queuePreFlushCb 
    } from './scheduler'
  2. 在测试实例中,使用 queuePreFlushCb 配合 scheduler

diff
   // 调用 effect 方法
   effect(() => {
     console.log(obj.count)
   }, {
 +    scheduler() {
 +      queuePreFlushCb(() => { console.log(obj.count) })
 +    }
   })
  1. 得到打印结果为:
js
   1
   3 // 打印两次

那么为什么会这样呢?queuePreFlushCb 又做了什么?

第七小节:watch 的源码阅读 中,我们知道在 packages/runtime-core/src/apiWatch.ts第 348 行

js
scheduler = () => queuePreFlushCb(job)

通过 queuePreFlushCb 方法,构建了 scheduler 调度器。而根据源码我们知道 queuePreFlushCb 方法,最终会触发(这里不再详细讲解源码执行流程,忘记的同学可以看一下 第七小节:watch 的源码阅读):

js
resolvedPromise.then(flushJobs)

那么根据以上逻辑,我们也可以实现对应的代码:

  1. 创建 packages/runtime-core/src/scheduler.ts
js
   // 对应 promise 的 pending 状态
   let isFlushPending = false
   
   /**
    * promise.resolve()
    */
   const resolvedPromise = Promise.resolve() as Promise<any>
   /**
    * 当前的执行任务
    */
   let currentFlushPromise: Promise<void> | null = null
   
   /**
    * 待执行的任务队列
    */
   const pendingPreFlushCbs: Function[] = []
   
   /**
    * 队列预处理函数
    */
   export function queuePreFlushCb(cb: Function) {
   	queueCb(cb, pendingPreFlushCbs)
   }
   
   /**
    * 队列处理函数
    */
   function queueCb(cb: Function, pendingQueue: Function[]) {
   	// 将所有的回调函数,放入队列中
   	pendingQueue.push(cb)
   	queueFlush()
   }
   
   /**
    * 依次处理队列中执行函数
    */
   function queueFlush() {
   	if (!isFlushPending) {
   		isFlushPending = true
   		currentFlushPromise = resolvedPromise.then(flushJobs)
   	}
   }
   
   /**
    * 处理队列
    */
   function flushJobs() {
   	isFlushPending = false
   	flushPreFlushCbs()
   }
   
   /**
    * 依次处理队列中的任务
    */
   export function flushPreFlushCbs() {
   	if (pendingPreFlushCbs.length) {
   		let activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
   		pendingPreFlushCbs.length = 0
   		for (let i = 0; i < activePreFlushCbs.length; i++) {
   			activePreFlushCbs[i]()
   		}
   	}
   }
  1. 创建 packages/runtime-core/src/index.ts ,导出 queuePreFlushCb 函数:
js
 export { queuePreFlushCb } from './scheduler'
  1. packages/vue/src/index.ts 中,新增导出函数:
js
 export { queuePreFlushCb } from '@vue/runtime-core'
  1. 创建测试案例 packages/vue/examples/reactivity/scheduler-2.html
html
   <script>
     const { reactive, effect, queuePreFlushCb } = Vue
   
     const obj = reactive({
       count: 1
     })
   
     // 调用 effect 方法
     effect(() => {
       console.log(obj.count)
     }, {
       scheduler() {
         queuePreFlushCb(() => { console.log(obj.count) })
       }
     })
   
     obj.count = 2
     obj.count = 3
   
   </script>
  1. 执行代码可得打印结果为:
js
   1
   3 // 打印两次

那么至此,我们就完成了调度器中两个比较重要的概念。

总结

懒执行相对比较简单,所以我们的总结主要针对调度器来说明。

调度器是一个相对比较复杂的概念,但是它本身并不具备控制 执行顺序执行规则 的能力。

想要完成这两个能力,我们需要借助一些其他的东西来实现,这整个的一套系统,我们把它叫做 调度系统

那么到目前,我们调度系统的代码就已经实现完成了,这个代码可以在我们将来实现 watch 的时候直接使用。

09:框架实现:初步实现 watch 数据监听器

那么这一小节,我们来看一下 watch 函数应该如何进行实现。

  1. 创建 packages/runtime-core/src/apiWatch.ts 模块,创建 watchdoWatch 函数:
js
  /**
    * watch 配置项属性
    */
   export interface WatchOptions<Immediate = boolean> {
   	immediate?: Immediate
   	deep?: boolean
   }
   
   /**
    * 指定的 watch 函数
    * @param source 监听的响应性数据
    * @param cb 回调函数
    * @param options 配置对象
    * @returns
    */
   export function watch(source, cb: Function, options?: WatchOptions) {
   	return doWatch(source as any, cb, options)
   }
   
   function doWatch(
   	source,
   	cb: Function,
   	{ immediate, deep }: WatchOptions = EMPTY_OBJ
   ) {
   	// 触发 getter 的指定函数
   	let getter: () => any
   
   	// 判断 source 的数据类型
   	if (isReactive(source)) {
   		// 指定 getter
   		getter = () => source
   		// 深度
   		deep = true
   	} else {
   		getter = () => {}
   	}
   
   	// 存在回调函数和deep
   	if (cb && deep) {
   		// TODO
   		const baseGetter = getter
   		getter = () => baseGetter()
   	}
   
   	// 旧值
   	let oldValue = {}
   	// job 执行方法
   	const job = () => {
   		if (cb) {
   			// watch(source, cb)
   			const newValue = effect.run()
   			if (deep || hasChanged(newValue, oldValue)) {
   				cb(newValue, oldValue)
   				oldValue = newValue
   			}
   		}
   	}
   
   	// 调度器
   	let scheduler = () => queuePreFlushCb(job)
   
   	const effect = new ReactiveEffect(getter, scheduler)
   
   	if (cb) {
   		if (immediate) {
   			job()
   		} else {
   			oldValue = effect.run()
   		}
   	} else {
   		effect.run()
   	}
   
   	return () => {
   		effect.stop()
   	}
   }
  1. packages/reactivity/src/reactive.tsreactive 类型的数据,创建 标记
js
 export const enum ReactiveFlags {
   	IS_REACTIVE = '__v_isReactive'
   }
   
   function createReactiveObject(
   	...
   ) {
   	...
   	// 未被代理则生成 proxy 实例
   	const proxy = new Proxy(target, baseHandlers)
   	// 为 Reactive 增加标记
   	proxy[ReactiveFlags.IS_REACTIVE] = true
   ...
   }
js

   /**
    * 判断一个数据是否为 Reactive
    */
   export function isReactive(value): boolean {
   	return !!(value && value[ReactiveFlags.IS_REACTIVE])
   }
  1. packages/shared/src/index.ts 中创建 EMPTY_OBJ
js
   /**
    * 只读的空对象
    */
   export const EMPTY_OBJ: { readonly [key: string]: any } = {}
  1. packages/runtime-core/src/index.tspackages/vue/src/index.ts 中导出 watch 函数
  2. 创建测试实例 packages/vue/examples/reactivity/watch.html
html
   <script>
     const { reactive, watch } = Vue
   
     const obj = reactive({
       name: '张三'
     })
   
   
     watch(obj, (value, oldValue) => {
       console.log('watch 监听被触发');
       console.log('value', value);
     })
   
     setTimeout(() => {
       obj.name = '李四'
     }, 2000);
   </script>

此时运行项目,却发现,当前存在一个问题,那就是 watch 监听不到 reactive 的变化

那么这是因为什么呢?

大家可以先思考下这个问题,我们下一小节继续来说~~~

10:问题分析:watch 下的依赖收集原则

现在我们还差一步就可以完成 watch 的响应式数据监听了,那么这一步是什么呢?

根据代码可知,watch 内部本质上也是通过:ReactiveEffect + scheduler 进行实现的。

那么对于 ReactiveEffect 而言,我们知道,他需要拥有两个先决条件才可以完成响应性:

  1. 依赖收集
  2. 触发依赖

那么对于我们当前的代码而言,我们在 setTimeout 中,触发了 触发依赖 操作。但是我们在哪里进行的 依赖收集呢?

答案是:没有

这就是我们为什么没有办法触发 watch 监听的原因。

那么这个依赖收集我们应该怎么做呢?

不知道大家还记不记得,我们之前在看源码的时候,看到过一个 traverse 方法。

之前的时候,我们一直没有看过该方法,那么现在我们可以来说一下它了。

它的源码在 packages/runtime-core/src/apiWatch.ts 中:

查看源代码可以发现,这里面的代码其实有些 莫名其妙,他好像什么都没有做,只是在 循环的进行 xxx.value 的形式,我们知道 xxx.value 这个行为,我们把它叫做 getter 行为。并且这样会产生 副作用,那就是 依赖收集!

所以我们知道了,对于 traverse 方法而言,它就是一个不断在触发响应式数据 依赖收集 的方法。

我们可以通过该方法来触发依赖收集,然后在两秒之后,触发依赖,完成 scheduler 的回调。

11:框架实现:完成 watch 数据监听器的依赖收集

根据上一节所说,我们接下来就需要去实现 traverse 函数,在 packages/runtime-core/src/apiWatch.ts 中,创建 traverse 方法:

js
/**
 * 依次执行 getter,从而触发依赖收集
 */
export function traverse(value: unknown) {
	if (!isObject(value)) {
		return value
	}

	for (const key in value as object) {
		traverse((value as any)[key])
	}
	return value
}

doWatch 中通过 traverse 方法,构建 getter

js
// 存在回调函数和deep
if (cb && deep) {
  // TODO
  const baseGetter = getter
  getter = () => traverse(baseGetter())
}

此时再次运行测试实例, watch 成功监听。

同时因为我们已经处理了 immediate 的场景:

js
if (cb) {
		if (immediate) {
			job()
		} else {
			oldValue = effect.run()
		}
	} else {
		effect.run()
	}

所以,目前 watch 也支持 immediate 的配置选项,ref 场景下的处理也可以得到支持,具体可见如下测试案例 packages/vue/examples/reactivity/watch-2.html

html
<script>
  const { ref, watch } = Vue

  const obj = ref({
    name: '张三'
  })

  watch(obj.value, (value, oldValue) => {
    console.log('watch 监听被触发');
    console.log('value', value);
  }, {
    immediate: true
  })

  setTimeout(() => {
    obj.value.name = '李四'
  }, 2000);
</script>

12:总结:watch 数据侦听器

那么现在我们已经完成了 watch 侦听器的实现。

对于 watch 而言本质上还是依赖于 ReactiveEffect 来进行的实现。

本质上依然是一个 依赖收集触发依赖 的过程。只不过区别在于此时的依赖收集是被 “被动触发” 的。

除此之外,还有一个调度器的概念,对于调度器而言,它起到的的主要作用就是 控制执行顺序控制执行规则 ,但是大家也需要注意调度器本身只是一个函数,想要完成调度功能,还需要其他的东西来配合才可以。

13:总结

那么到这里,咱们整个的 响应系统 就全部讲解完成了。整个响应系统我们分成了:

  1. reactive
  2. ref
  3. computed
  4. watch

四大块来进行分别的实现。

通过之前的学习可以知道,响应式的核心 APIProxy。整个 reactive 都是基于此来进行实现。

但是 Porxy 只能代理 复杂数据类型,所以延伸除了 get valueset value 这样 以属性形式调用的方法refcomputed 之所以需要 .value 就是因为这样的方法。

好,响应系统 闭幕,下一章我们将开始学习新的大模块: 渲染系统

Released under the MIT License.