学习笔记--如何理解vue3响应系统

83 阅读22分钟

我们知道,会影响视图变化的数据就是响应数据,当响应式数据发生变化时,视图也会发生变化。在vue2中响应式原理是通过 Object.defineProperty 来实现的,而在vue3的响应性核心 API 则是 proxy

Vue2 的响应式原理

vue2Object.defineProperty 作为响应性的核心 API ,该 API 可以监听:指定对象的指定属性的 gettersetter。 我们来举个例子看看是怎么实现的:

<script>
  // 定义一个商品对象,包含价格和数量
  let quantity = 2
  let product = {
    price: 10,
    quantity: quantity
  }
  // 总价格
  let total = 0;
  // 计算总价格的匿名函数
  let effect = () => {
    total = product.price * product.quantity;
  };

  // 第一次打印
  effect();
  console.log(`总价格:${total}`); // 总价格:20

  // 监听 product 的 quantity 的 setter
  Object.defineProperty(product, 'quantity', {
    // 监听 product.quantity = xx 的行为,在触发该行为时重新执行 effect
    set(newVal) {
      // 注意:这里不可以是 product.quantity = newVal,因为这样会重复触发 set 行为
      quantity = newVal
      // 重新触发 effect
      effect()
    },
    // 监听 product.quantity,在触发该行为时,以 quantity 变量的值作为 product.quantity 的属性值
    get() {
      return quantity
    }
  });
</script>

那么此时我们就通过 Object.defineProperty 方法成功监听了 quantity 属性的 gettersetter 行为,现在当 quantity 发生变化时,effect 函数将重新计算,以此得到最新的 total

Object.defineProperty 在设计层的缺陷

vue2 使用 Object.defineProperty 作为响应性的核心 API,但是在 vue3 的时候却放弃了这种方式,转而使用 Proxy。为什么会这样呢?因为Object.defineProperty 存在一个缺陷。我们来看一个例子:

<template>
  <div id="app">
    <ul>
      <li v-for="(val, key, index) in obj" :key="index">
        {{ key }} - {{ val }}
      </li>
    </ul>
    <button @click="addObjKey">为对象增加属性</button>
    <hr />
    <ul>
      <li v-for="(item, index) in arr" :key="index">
        {{ item }}
      </li>
    </ul>
    <button @click="addArrItem">为数组添加元素</button>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
    return {
      obj: {
        name: '张三',
        age: 30
      },
      arr: ['张三', '李四']
    }
  },
  methods: {
    addObjKey() {
      this.obj.gender = '男'
      console.log(this.obj) // 通过打印可以发现,obj 中存在 gender 属性,但是视图中并没有体现
    },
    addArrItem() {
      this.arr[2] = '王五'
      console.log(this.arr) // 通过打印可以发现,arr 中存在 王五,但是视图中并没有体现
    }
  }
}
</script>

在上面的例子中,我们呈现了 vue2 中响应性的限制:

  1. 当为 对象 新增一个没有在 data 中声明的属性时,新增的属性 不是响应性
  2. 当为 数组 通过下标的形式新增一个元素时,新增的元素 不是响应性

会出现这种情况的原因是:

  1. vue 2 是以 Object.defineProperty  作为核心 API 实现的响应性
  2. Object.defineProperty 只可以监听 指定对象的指定属性的 getter 和 setter
  3. 被监听了 gettersetter 的属性,就被叫做 该属性具备了响应性

那么这就意味着:我们 必须要知道指定对象中存在该属性,才可以为该属性指定响应性。

但是由于 JavaScript 的限制,我们没有办法监听到 指定对象新增了一个属性,所以新增的属性就没有办法通过 Object.defineProperty 来监听 gettersetter,所以 新增的属性将失去响应性

既然已知vue2中有这样的缺陷,那么接下来就看 vue3 是如何解决这些缺陷的吧。

Vue3 的响应式原理

因为 Object.defineProperty 存在的问题,所以 vue3 中修改了这个核心 API,改为使用 Proxy 进行实现。

proxy 顾名思义就是 代理 的意思。proxy 可以监听到对对象进行的访问、赋值等操作,并在这些操作发生时通知相关依赖以维护响应式系统的更新。我们来看代码:

<script>
  // 定义一个商品对象,包含价格和数量
  let product = {
    price: 10,
    quantity: 2
  }

  // new Proxy 接收两个参数(被代理对象,handler 对象)。
  // 生成 proxy 代理对象实例,该实例拥有《被代理对象的所有属性》 ,并且可以被监听 getter 和 setter
  // 此时:product 被称为《被代理对象》,proxyProduct 被称为《代理对象》
  const proxyProduct = new Proxy(product, {
    // 监听 proxyProduct 的 set 方法,在 proxyProduct.xx = xx 时,被触发
    // 接收四个参数:被代理对象 tager,指定的属性名 key,新值 newVal,最初被调用的对象 receiver
    // 返回值为一个 boolean 类型,true 表示属性设置成功
    set(target, key, newVal, receiver) {
      // 为 target 附新值
      target[key] = newVal
      // 触发 effect 重新计算
      effect()
      return true
    },
    // 监听 proxyProduct 的 get 方法,在 proxyProduct.xx 时,被触发
    // 接收三个参数:被代理对象 tager,指定的属性名 key,最初被调用的对象 receiver
    // 返回值为 proxyProduct.xx 的结果
    get(target, key, receiver) {
      return target[key]
    }
  })

  // 总价格
  let total = 0;
  // 计算总价格的匿名函数
  let effect = () => {
    total = proxyProduct.price * proxyProduct.quantity;
  };

  // 第一次打印
  effect();
  console.log(`总价格:${total}`); // 总价格:20
</script>

在以上代码中,我们可以发现,ProxyObject.defineProperty 存在一个非常大的区别,那就是:

proxy

  1. Proxy 将代理一个对象(被代理对象),得到一个新的对象(代理对象),同时拥有被代理对象中所有的属性。
  2. 当想要修改对象的指定属性时,我们应该使用 代理对象 进行修改
  3. 代理对象 的任何一个属性都可以触发 handlergettersetter

Object.defineProperty

  1. Object.defineProperty指定对象的指定属性 设置 属性描述符
  2. 当想要修改对象的指定属性时,可以使用原对象进行修改
  3. 通过属性描述符,只有 被监听 的指定属性,才可以触发 gettersetter

所以当 vue3 通过 Proxy 实现响应性核心 API 之后,vue不会 再存在新增属性时失去响应性的问题。

proxy的最佳拍档:Reflect

Reflect对象经常和Proxy代理一起使用。

查看 MDN 的文档介绍可以知道,Reflect提供的所有静态方法和Proxy第2个handle对象中的方法参数很相似:

Reflect 静态方法

Reflect.get(target, propertyKey[, receiver])

Reflect.has(target, propertyKey)

Reflect.set(target, propertyKey, value[, receiver])

...

handler 对象的方法

handler.has()

handler.get()

handler.set()

...

我们通过代码来了解一下Reflectgetset 的作用:

<script>
  const obj = {
    name: '张三'
  }

  console.log(obj.name) // 张三
  console.log(Reflect.get(obj, 'name')) // 张三
</script>

两次打印的结果是相同的。这其实也就说明了 Reflect.get(obj, 'name') 本质上和 obj.name 的作用 相同

根据文档,对于 Reflect.get 而言,它还存在第三个参数 receiver,官网的介绍为:

如果target对象中指定了getterreceiver则为getter调用时的this值。

这是什么意思呢?我们来看以下代码:

<script>
  const p1 = {
    lastName: '张',
    firstName: '三',
    // 通过 get 标识符标记,可以让方法的调用像属性的调用一样
    get fullName() {
      return this.lastName + this.firstName
    }
  }

  const p2 = {
    lastName: '李',
    firstName: '四',
    // 通过 get 标识符标记,可以让方法的调用像属性的调用一样
    get fullName() {
      return this.lastName + this.firstName
    }
  }

  console.log(p1.fullName) // 张三
  console.log(Reflect.get(p1, 'fullName')) // 张三
  // 第三个参数 receiver 在对象指定了 getter 时表示为 this
  console.log(Reflect.get(p1, 'fullName', p2)) // 李四
</script>

在以上代码中,我们可以利用 p2 作为第三个参数 receiver ,以此来修改 fullName 的打印结果。即:此时触发的 fullName 不是 p1 的 而是 p2

那么明确好了这个之后,我们再来看下面这个例子:

<script>
  const p1 = {
    lastName: '张',
    firstName: '三',
    // 通过 get 标识符标记,可以让方法的调用像属性的调用一样
    get fullName() {
      return this.lastName + this.firstName
    }
  }

  const proxy = new Proxy(p1, {
    // target:被代理对象
    // receiver:代理对象
    get(target, key, receiver) {
      console.log('触发了 getter');
      return target[key]
    }
  })

  console.log(proxy.fullName);
</script>

在以上这个代码中,我们触发了 proxy.fullName,在这个 fullName 中又触发了 this.lastName + this.firstName 那么:getter 应该被触发几次?

此时 getter 应该被触发 3 次! 但是 实际只触发了 1 次! 为什么?

因为在 this.lastName + this.firstName 这个代码中,我们的 thisp1而非 proxy !所以 lastNamefirstName 的触发,不会再次触发 getter

想要实现这个想法,那么就需要使用到 Reflect.get 了。

我们修改以上代码:

const proxy = new Proxy(p1, {
    // target:被代理对象
    // receiver:代理对象
    get(target, key, receiver) {
      console.log('触发了 getter');
      // return target[key]
      return Reflect.get(target, key, receiver)
    }
  })

修改代码之后,我们发现,此时 getter 得到了三次的触发!

当我们期望监听代理对象的 gettersetter 时,不应该使用 target[key] ,因为它在某些时刻(比如 fullName)下是不可靠的。而 应该使用 Reflect ,借助它的 getset 方法,使用 receiver(proxy 实例) 作为 this,以达到期望的结果(触发三次 getter)。

使用 Reflect 的好处是它提供的方法与操作符和内置函数在语义和行为上保持一致,使得代码更加统一和易于阅读。同时,通过 Reflect 提供的方法,可以在某些情况下获得更好的性能和更严格的错误处理。

需要注意的是,Reflect 方法的返回值通常与相应的操作符和内置函数的返回值一致,但在某些情况下会略有不同,例如 Reflect.set() 方法返回一个布尔值,表示属性是否设置成功。

Reflect 提供了一组与对象操作相关的方法,可以用于替代一些原本只能通过语言内部操作的功能,使得代码更加统一、易于阅读和维护。

实现Reactive

reactive 函数,本质上是返回了一个 proxy 实例,我们先来实现这个 reactive 函数,得到 proxy 实例。

1.创建reactive.ts 模块。

import { mutableHandlers } from './baseHandlers'

/**
 * 响应性 Map 缓存对象
 * key:target
 * val:proxy
 */
export const reactiveMap = new WeakMap<object, any>()

/**
 * 为复杂数据类型,创建响应性对象
 * @param target 被代理对象
 * @returns 代理对象
 */
export function reactive(target: object) {
	return createReactiveObject(target, mutableHandlers, reactiveMap)
}

/**
 * 创建响应性对象
 * @param target 被代理对象
 * @param baseHandlers handler
 */
function createReactiveObject(
	target: object,
	baseHandlers: ProxyHandler<any>,
	proxyMap: WeakMap<object, any>
) {
	// 如果该实例已经被代理,则直接读取即可
	const existingProxy = proxyMap.get(target)
	if (existingProxy) {
            return existingProxy
	}

	// 未被代理则生成 proxy 实例
	const proxy = new Proxy(target, baseHandlers)

	// 缓存代理对象
	proxyMap.set(target, proxy)
	return proxy
}

创建baseHandlers.ts 模块:

/**
 * 响应性的 handler
 */
export const mutableHandlers: ProxyHandler<object> = {}

通过以上代码,我们可以注意到,Reactive实现的核心是createReactiveObject函数,而createReactiveObject的核心则是 new Proxy(target, baseHandlers),也就是说,我们只需要传入target,以及mutableHandlers这个Handlers即可将一个对象转换为响应式,我们下一步就可以关注mutableHandlers的实现即可。

什么是 WeakMap?它和 Map 有什么区别?

在继续学习Vue3的响应式核心原理前,我们还需要了解什么是 WeakMap。

对比 WeakMapMap 的文档可知,他们两个具备一个核心共同点,那就是:都是 {key, value} 的结构对象

但是对于 WeakMap 而言,他却存在两个不同的地方:

  1. key 必须是对象
  2. key 是弱引用的

其中第一个不同点比较好理解,但是第二个不同点是什么意思呢?那么我们本小节就来看一下这个 弱引用 指的是什么?

概念

弱引用:不会影响垃圾回收机制。即:WeakMap 的 key 不再存在任何引用时,会被直接回收。

强引用:会影响垃圾回收机制。存在强应用的对象永远 不会 被回收。

我们来看下面两个例子:

// map
<script>
  // target 对象
  let obj = {
    name: '张三'
  }
  // 声明 Map 对象
  const map = new Map()
  // 保存键值对
  map.set(obj, 'value')
  // 把 obj 置空
  obj = null
</script>

在当前这段代码中,如果我们在浏览器控制台中,打印 map 那么打印结果如下:

e479f95b-3ffa-4550-a4a8-60e4697fa01e.png

即:虽然 obj 已经不存在任何引用了,但是它并没有被回收,依然存在于 Map 实例中。这就证明 Map 是强应用的,哪怕 obj 手动为 null,但是它依然存在于 Map 实例中。

接下来同样的代码,我们来看 WeakMap:

  // target 对象
  let obj = {
    name: '张三'
  }
  // 声明 Map 对象
  const wm = new WeakMap()
  // 保存键值对
  wm.set(obj, 'value')
  // 把 obj 置空
  obj = null

在当前这段代码中,如果我们在浏览器控制台中,打印 wm 那么打印结果如下:

bdec80af-c3aa-4283-b227-59a8c423426f.png

此时 WeakMap 中不存在任何值,即:obj 不存在其他引用时, WeakMap 不会阻止垃圾回收,基于 obj 的引用将会被清除。这就证明了 WeakMap弱引用特性。

实现createGetter && createSetter

那么当我们搞明白了 WeakMap 的特性之后,那么接下来我们就来看看 mutableHandlers 如何处理。

我们知道对于 Proxy 而言,它的 handler 可以监听 代理对象gettersetter,那么此时的 mutableHandlers 就是监听 代理对象 gettersetter 的核心部分。

所以接下来我们需要创建对应的 getset 监听:

/**
 * 响应性的 handler
 */
export const mutableHandlers: ProxyHandler<object> = {
	get,
	set
}

getter

/**
 * getter 回调方法
 */
const get = createGetter()

/**
 * 创建 getter 回调方法
 */
function createGetter() {
	return function get(target: object, key: string | symbol, receiver: object) {
		// 利用 Reflect 得到返回值
		const res = Reflect.get(target, key, receiver)
		// 收集依赖
		track(target, key)
		return res
	}
}

setter

/**
 * setter 回调方法
 */
const set = createSetter()

/**
 * 创建 setter 回调方法
 */
function createSetter() {
	return function set(
		target: object,
		key: string | symbol,
		value: unknown,
		receiver: object
	) {
		// 利用 Reflect.set 设置新值
		const result = Reflect.set(target, key, value, receiver)
		// 触发依赖
		trigger(target, key, value)
		return result
	}
}

mutableHandlers对象的get属性是一个用于处理属性获取的函数,会进行依赖收集和转换为响应式对象的操作。而set属性是一个用于处理属性设置的函数,会触发依赖更新。 这样,当我们使用mutableHandlers作为处理器创建响应式对象时,执行get方法时,会进行依赖收集,而执行set方法时会触发依赖更新(视图更新),即实现了一个对象的响应式。

track && trigger

通过实现mutableHandlers可以看出,所谓将一个对象由普通对象变成一个响应式对象只是干了一件事:

重写一个对象(包括对象的对象,即深层)的get set方法,以达到劫持一个对象的get set方法,在get中进行依赖收集 track(target, key);,在set触发的时候进行触发依赖 trigger(target, key, value)来更新视图。

gettersetter 中分别调用了 track && trigger 方法,所以我们需要分别创建对应方法,事实上,tracktrigger函数都存在于effect.ts中。

创建 effect.ts

/**
 * 用于收集依赖的方法
 * @param target WeakMap 的 key
 * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取
 */
export function track(target: object, key: unknown) {
	console.log('track: 收集依赖')
}

/**
 * 触发依赖的方法
 * @param target WeakMap 的 key
 * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取
 * @param newValue 指定 key 的最新值
 * @param oldValue 指定 key 的旧值
 */
export function trigger(
	target: object,
	key?: unknown,
	newValue?: unknown
) {
	console.log('trigger: 触发依赖')
}

那么至此我们就可以:

  1. getter 时,调用 track 收集依赖
  2. setter 时,调用 trigger 触发依赖

构建 effect 函数,生成 ReactiveEffect 实例

effect.ts 中,创建 effect 函数:

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

接下来实现 ReactiveEffect 的基础逻辑:

/**
 * 单例的,当前的 effect
 */
export let activeEffect: ReactiveEffect | undefined

/**
 * 响应性触发依赖时的执行类
 */
export class ReactiveEffect<T = any> {
	constructor(public fn: () => T) {}

	run() {
		// 为 activeEffect 赋值
		activeEffect = this

		// 执行 fn 函数
		return this.fn()
	}
}

那么根据以上代码可知,最终 vue 会执行 effect 传入的 回调函数,即:

document.querySelector('#app').innerText = obj.name

那么此时,obj.name 的值,应该可以被渲染到 html 中。 我们创建测试实例,测试一下:

<body>
  <div id="app"></div>
</body>

<script>
  const { reactive, effect } = Vue

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

  // 调用 effect 方法
  effect(() => {
    document.querySelector('#app').innerText = obj.name
  })
</script>

那么此时,我们成功 渲染了数据到 html ,那么接下来我们需要做的就是:obj.name 触发 setter 时,修改视图,以此就可实现 响应性数据变化

所以,下面我们就需要分别处理 gettersetter 对应的情况了。

实现track && trigger

根据我们在 baseHandlers.ts 中的代码可知,当触发 getter 行为时,其实我们会触发 track 方法,进行 依赖收集,当触发 setter 行为时,会触发 trigger 方法,来 触发依赖

那么这里就涉及到了两个概念:

  1. 依赖收集:track
  2. 触发依赖:trigger

所以接下来如果我们想要实现这两个函数,那么就需要先搞清楚什么是 依赖收集触发依赖

什么是响应性

所谓的响应性其实指的就是:当响应性数据触发 setter 时执行 fn 函数.

那么想要达到这样的一个目的,那就必须要在:getter 时能够收集当前的 fn 函数,以便在 setter 的时候可以执行对应的 fn 函数

但是对于收集而言,如果仅仅是把 fn 存起来还是不够的,我们还需要知道,当前的这个 fn哪个响应式数据对象哪个属性对应的,只有这样,我们才可以在 该属性 触发 setter 的时候,准确的执行响应性。

那么我们应该如何确定以这一点呢?

如何进行依赖收集

我们之前在在 reactive.ts 中创建过一个 WeakMap

export const reactiveMap = new WeakMap<object, any>()

我们知道 WeakMap 它的 key 必须是一个对象,并且 key 是一个弱引用的。

那么大家想一想我们可不可以这样:

1705751020210.jpg

图表表示: a1c48250-7db9-4d82-bd12-b20d79ab885e.png

那么这样我们就可以关联上 指定对象的指定属性执行函数 fn 之间的关系,当触发 setter 时,直接执行 对应对象的对应属性的 fn 即可。

那么明确好了这样的一个概念之后,接下来我们就可以根据以上理念进行对应的实现。

构建 track 依赖收集函数

effect.ts 写入如下代码:

type KeyToDepMap = Map<any, ReactiveEffect>
/**
 * 收集所有依赖的 WeakMap 实例:
 * 1. `key`:响应性对象
 * 2. `value`:`Map` 对象
 * 		1. `key`:响应性对象的指定属性
 * 		2. `value`:指定对象的指定属性的 执行函数
 */
const targetMap = new WeakMap<any, KeyToDepMap>()
/**
 * 用于收集依赖的方法
 * @param target WeakMap 的 key
 * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取
 */
export function track(target: object, key: unknown) {
	// 如果当前不存在执行函数,则直接 return
	if (!activeEffect) return
	// 尝试从 targetMap 中,根据 target 获取 map
	let depsMap = targetMap.get(target)
	// 如果获取到的 map 不存在,则生成新的 map 对象,并把该对象赋值给对应的 value
	if (!depsMap) {
		targetMap.set(target, (depsMap = new Map()))
	}
	//为指定 map,指定key 设置回调函数
	depsMap.set(key, activeEffect)
  // 临时打印
	console.log(targetMap)
}

此时运行测试函数,查看打印的 depsMap,可得以下数据:

a97e59db-3a62-4de5-98da-86527f4799b9.png 那么此时证明,此时:指定对象的指定属性对应的 fn 已经被成功的保存到了 WeakMap 中了。

构建 trigger 触发依赖

在上一小节中,我们已经成功保存依赖到 WeakMap 中了,那么接下来我们就可以在 setter 的时候触发保存的依赖,以此来达到 响应性 数据的效果了。

effect.ts 中:

/**
 * 触发依赖的方法
 * @param target WeakMap 的 key
 * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取
 */
export function trigger(
	target: object,
	key?: unknown
) {
	// 依据 target 获取存储的 map 实例
	const depsMap = targetMap.get(target)
	// 如果 map 不存在,则直接 return
	if (!depsMap) {
		return
	}
	// 依据 key,从 depsMap 中取出 value,该 value 是一个 ReactiveEffect 类型的数据
	const effect = depsMap.get(key) as ReactiveEffect
	// 如果 effect 不存在,则直接 return
	if (!effect) {
		return
	}
	// 执行 effect 中保存的 fn 函数
	effect.fn()
}

此时,我们就可以在触发 setter 时,执行保存的 fn 函数了。

至此我们就已经完成了一个简单的 响应式依赖数据处理

总结:单一依赖的 reactive

通过以上的努力,我们目前已经构建了一个简单的 reactive 函数,使用 reactive 函数,配合 effect 可以实现出一个 响应式数据渲染功能,那么这一小节,我们把整个的流程做一个总结:

  1. 首先我们在 reactive.ts 中,创建了一个 reactive 函数,该函数可以帮助我们生成一个 proxy 实例对象
  2. 通过该 proxy 实例的 handler 可以监听到对应的 gettersetter
  3. 然后我们在 effect.ts 中,创建了一个 effect 函数,通过该函数可以创建一个 ReactiveEffect 的实例,该实例的构造函数可以接收传入的回调函数 fn,并且提供了一个 run 方法
  4. 触发 run 可以为 activeEffect 进行赋值,并且执行 fn 函数
  5. 我们需要在 fn 函数中触发 proxygetter,以此来激活 handlerget 函数
  6. handlerget 函数中,我们通过 WeakMap 收集了 指定对象,指定属性fn,这样的一步操作,我们把它叫做 依赖收集
  7. 最后我们可以在 任意时刻,修改 proxy 的数据,这样会触发 handlersetter
  8. handlersetter 中,我们会根据 指定对象 target指定属性 key 来获取到保存的 依赖,然后我们只需要触发依赖,即可达到修改数据的效果

功能升级:响应数据对应多个 effect

在我们之前的实现中,还存在一个小的问题,那就是:每个响应性数据属性只能对应一个 effect 回调 我们来看下面这个例子:

<body>
  <div id="app">
    <p id="p1"></p>
    <p id="p2"></p>
  </div>
</body>

<script>
  const { reactive, effect } = Vue

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

  // 调用 effect 方法
  effect(() => {
    document.querySelector('#p1').innerText = obj.name
  })
  effect(() => {
    document.querySelector('#p2').innerText = obj.name
  })

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

在以上的代码中,我们新增了一个 effect 函数,即:name 属性对应两个 DOM 的变化

但是当我们运行该代码时发现,p1 的更新渲染是无效的!

那么这是因为什么呢?

查看我们的代码可以发现,我们在构建 KeyToDepMap 对象时,它的 Value 只能是一个 ReactiveEffect,所以这就导致了 一个 key 只能对应一个有效的 effect 函数

那么假如我们期望:一个 key 可以对应 多个 有效的 effect 函数的话,那么应该怎么做呢?

我们只需要 KeyToDepMap Value 可以对应一个数组 不就可以了吗?

fd8f926d-ac44-4b0b-a9d6-7f930f865889.png 如上图所示,我们可以构建一个 Setset 是一个 “数组”,值不会重复) 类型的对象,作为 Mapvalue

我们可以把它叫做 Dep ,通过 Dep 来保存 指定 key 的所有依赖

构建 Dep 模块,处理一对多的依赖关系

想要处理 dep 模块,那么我们需要对 tracktrigger 进行改造:

track

  1. 创建 dep.ts 模块:
import { ReactiveEffect } from './effect'

export type Dep = Set<ReactiveEffect>
  
 /**
 * 依据 effects 生成 dep 实例
 */
export const createDep = (effects?: ReactiveEffect[]): Dep => {
	const dep = new Set<ReactiveEffect>(effects) as Dep
	return dep
}

2.在 effect.ts,修改 KeyToDepMap 的泛型:

import { Dep } from './dep'

type KeyToDepMap = Map<any, Dep>

3.修改 track 方法,处理 Dep 类型数据:

export function track(target: object, key: unknown) {
	...
	// 获取指定 key 的 dep
	let dep = depsMap.get(key)
	// 如果 dep 不存在,则生成一个新的 dep,并放入到 depsMap 中
	if (!dep) {
		depsMap.set(key, (dep = createDep()))
	}

	trackEffects(dep)
}

/**
 * 利用 dep 依次跟踪指定 key 的所有 effect
 * @param dep
 */
export function trackEffects(dep: Dep) {
	dep.add(activeEffect!)
}

此时,我们已经把指定 key 的所有依赖全部保存到了 dep 函数中,那么接下来我们就可以在 trigger 函数中,依次读取 dep 中保存的依赖。

trigger

  1. effect.ts 中:
export function trigger(
	target: object,
	key?: unknown,
) {
	// 依据 target 获取存储的 map 实例
	const depsMap = targetMap.get(target)
	// 如果 map 不存在,则直接 return
	if (!depsMap) {
		return
	}
	// 依据指定的 key,获取 dep 实例
	let dep: Dep | undefined = depsMap.get(key)
	// dep 不存在则直接 return
	if (!dep) {
		return
	}
	// 触发 dep
	triggerEffects(dep)
}

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

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

至此,我们即可在 trigger 中依次触发 dep 中保存的依赖

reactive 函数的局限性

目前我们已经成功的完成了一个相对完善的 reactive 函数,通过它配合 effect 函数可以实现对应的响应性渲染。

但是对于 reactive 而言,它其实是具备一些局限性的。那么具体都有哪些局限性呢?

我们来思考以下两个问题:

  1. reactive 可以对简单数据类型使用吗?比如:reactive('张三')
  2. 当我们对 reactive 返回的响应性数据进行解构时,解构之后的属性还会具备响应性吗?

那么下面我们就对这两个问题,一个一个进行解释:

reactive 可以对简单数据类型使用吗?

我们知道,对于 reactive 函数而言,它会把传入的 object 作为 proxytarget 参数,而对于 proxy 而言,他只能代理 对象,而不能代理简单数据类型,所以说:我们不可以使用 reactive 函数,构建简单数据类型的响应性

当我们对 reactive 返回的响应性数据进行解构时,解构之后的属性还会具备响应性吗?

一个数据是否具备响应性的关键在于:是否可以监听它的 getter setter ****。而根据我们的代码可知,只有 proxy 类型的 代理对象 才可以被监听 gettersetter ,而一旦解构,对应的属性将不再是 proxy 类型的对象,所以:解构之后的属性,将不具备响应性。

总结与新的问题

那么到现在我们知道了,reactive 不可以对 简单数据类型使用,并且 不可以解构。那么如果我们期望 简单数据类型也具备响应性,那么我们又应该如何做呢?

熟悉 vue 3 之后,我们肯定知道,此时我们可以使用 ref 函数来进行实现。

那么 ref 函数它又是因为什么可以构建简单数据类型的响应性,又为什么必须要通过 .value 访问数据呢?这些在后面ref章节会得到解答

实现ref

vue3ref 函数会根据传入的数据类型进行分开处理,所以我们要按照复杂数据类型和简单数据类型来分别进行实现。

实现ref 函数 - 构建复杂数据类型的响应性

我们先来实现针对复杂数据类型的响应性处理代码逻辑。

1.创建ref.ts 模块:

import { createDep, Dep } from './dep'
import { activeEffect, trackEffects } from './effect'
import { toReactive } from './reactive'

export interface Ref<T = any> {
	value: T
}

/**
 * ref 函数
 * @param value unknown
 */
export function ref(value?: unknown) {
	return createRef(value, false)
}

/**
 * 创建 RefImpl 实例
 * @param rawValue 原始数据
 * @param shallow boolean 形数据,表示《浅层的响应性(即:只有 .value 是响应性的)》
 * @returns
 */
function createRef(rawValue: unknown, shallow: boolean) {
	if (isRef(rawValue)) {
		return rawValue
	}
	return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
	private _value: T

	public dep?: Dep = undefined

	// 是否为 ref 类型数据的标记
	public readonly __v_isRef = true

	constructor(value: T, public readonly __v_isShallow: boolean) {
		// 如果 __v_isShallow 为 true,则 value 不会被转化为 reactive 数据,即如果当前 value 为复杂数据类型,则会失去响应性。对应官方文档 shallowRef :https://cn.vuejs.org/api/reactivity-advanced.html#shallowref
		this._value = __v_isShallow ? value : toReactive(value)
	}

	/**
	 * get语法将对象属性绑定到查询该属性时将被调用的函数。
	 * 即:xxx.value 时触发该函数
	 */
	get value() {
		trackRefValue(this)
		return this._value
	}

	set value(newVal) {}
}

/**
 * 为 ref 的 value 进行依赖收集工作
 */
export function trackRefValue(ref) {
	if (activeEffect) {
		trackEffects(ref.dep || (ref.dep = createDep()))
	}
}

/**
 * 指定数据是否为 RefImpl 类型
 */
export function isRef(r: any): r is Ref {
	return !!(r && r.__v_isRef === true)
}

2.在reactive.ts 中,新增 toReactive 方法:

/**
 * 将指定数据变为 reactive 数据
 */
export const toReactive = <T extends unknown>(value: T): T =>
	isObject(value) ? reactive(value as object) : value

3.新增 isObject 方法:

/**
 * 判断是否为一个对象
 */
export const isObject = (val: unknown) =>
	val !== null && typeof val === 'object'

ref 函数构建完成。

对于 ref 函数,会返回 RefImpl 类型的实例,复杂数据类型将会转化为 reactive 返回的 proxy 实例,无论我们执行 obj.value.name 还是 obj.value.name = xxx 本质上都是触发了  get value,之所以会进行 响应性 是因为 obj.value 是一个 reactive 函数生成的 proxy。所以,针对于 ref 的复杂数据类型而言,它的响应性本身,其实是 利用 reactive 函数 进行的实现,即:

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

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

本质上的实现方案其实是完全相同的,都是利用 reactive 函数,返回一个 proxy 实例,监听 proxygettersetter 进行的依赖收集和依赖触发。

但是它们之间也存在一些不同的地方,比如:

  1. ref 的返回值是一个 RefImpl 类型的实例对象
  2. 想要访问 ref 的真实数据,需要通过 .value 来触发 get value 函数,得到被 toReactive 标记之后的 this._value 数据,即:proxy 实例
  3. reactive 会直接返回一个 proxy 的实例对象,不需要通过 .value 属性得到

实现ref 函数 - 构建简单数据类型的响应性

1.在ref.ts 中,完善 set value 函数:

class RefImpl<T> {
	private _value: T
	private _rawValue: T
  ...

	constructor(value: T, public readonly __v_isShallow: boolean) {
		...

		// 原始数据
		this._rawValue = value
	}

	...

	set value(newVal) {
		/**
		 * newVal 为新数据
		 * this._rawValue 为旧数据(原始数据)
		 * 对比两个数据是否发生了变化
		 */
		if (hasChanged(newVal, this._rawValue)) {
			// 更新原始数据
			this._rawValue = newVal
			// 更新 .value 的值
			this._value = toReactive(newVal)
			// 触发依赖
			triggerRefValue(this)
		}
	}
}

/**
 * 为 ref 的 value 进行触发依赖工作
 */
export function triggerRefValue(ref) {
	if (ref.dep) {
		triggerEffects(ref.dep)
	}
}

由以上代码可知:

  1. 简单数据类型的响应性,不是基于 proxyObject.defineProperty 进行实现的,而是通过:set 语法,将对象属性绑定到查询该属性时将被调用的函数 上,使其触发 xxx.value = '李四' 属性时,其实是调用了 xxx.value('李四') 函数。
  2. value 函数中,触发依赖

所以,我们可以说对于 ref 标记的简单数据类型,不具备数据件监听的概念,即本身并不是响应性的。

只是因为 vue 通过了 set value() 的语法,把 函数调用变成了属性调用的形式,让我们通过主动调用该函数,来完成了一个 “类似于” 响应性的结果。

总结

ref 函数本质上是生成了一个 RefImpl 类型的实例对象,通过 getset 标记处理了 value 函数.为什么 ref 类型的数据,必须要通过 .value 访问值呢?因为 ref 需要处理简单数据类型的响应性,但是对于简单数据类型而言,它无法通过 proxy 建立代理。所以 vue 通过 get value()set value() 定义了两个属性函数,通过 主动 触发这两个函数(属性调用)的形式来进行 依赖收集触发依赖。所以我们必须通过 .value 来保证响应性。