Vue2响应式原理

Vue2 使用 defineProperty 实现响应式原理。重写对象的 gettersetter 函数,在 getter 中收集依赖,在 setter 中触发依赖,以此实现响应式,但是要递归观测 object 中的所有 key ,会有性能问题。
array 类型的数据,需要改写数组中的方法才能实现响应式,而 proxy 可以检测数组的变化。

副作用函数

副作用函数指的是会产生副作用的函数。

假设在一个副作用函数中读取了某个对象的属性:

  1. const obj = { text: 'hello world' }
  2. function effect() {
  3. // effect 函数的执行会读取 obj.text
  4. document.body.innerText = obj.text
  5. }

如上面的代码所示,副作用函数 effect 会设置 body 元素的 innerText 属性,其值为 obj.text

响应式数据的实现

如何才能拦截一个对象属性的读取和设置操作。在 ES2015 之前,只能通过 [Object.defineProperty](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 函数实现,这也是 Vue.js2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 [Proxy](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 来实现,这也是 Vue.js3 所采用的方式。

一个简单的响应系统的工作流程如下:

  • 当读取操作发生时,将副作用函数收集到“桶”中;
  • 当设置操作发生时,从“桶”中取出副作用函数并执行。

使用 Proxy 实现:

  1. // 存储副作用函数的桶
  2. const bucket = new Set()
  3. // 原始数据
  4. const data = { text: 'hellow world' }
  5. // 对原始数据的代理
  6. const obj = new Proxy(data, {
  7. // 拦截读取操作
  8. get(target, key) {
  9. // 将副作用函数 effect 添加到存储副作用函数的桶中
  10. bucket.add(effect)
  11. // 返回属性值
  12. return target[key]
  13. },
  14. // 拦截设置操作
  15. set(target, key, newVal) {
  16. // 设置属性值
  17. target[key] = newVal
  18. // 把副作用函数从桶里取出并执行
  19. bucket.forEach(fn => fn())
  20. // 返回 true 代表设置操作成功
  21. return true
  22. }
  23. })

注册副作用函数

前面的代码中硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect ,那么代码就不能正确地工作。
因此需要提供一个用来注册副作用函数的机制,如下:

  1. // 用一个全局变量存储被注册的副作用函数
  2. let activeEffect
  3. // effect 函数用于注册副作用函数
  4. function effect(fn) {
  5. // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
  6. activeEffect = fn
  7. // 执行副作用函数
  8. fn()
  9. }

使用方式:

  1. effect(
  2. // 一个匿名的副作用函数
  3. () => {
  4. document.body.innerText = obj.text
  5. }
  6. )

然后将 Proxy 的实现也改一下:

  1. const bucket = new Set()
  2. const data = { text: 'hellow world' }
  3. const obj = new Proxy(data, {
  4. get(target, key) {
  5. if (activeEffect) { // 新增
  6. bucket.add(activeEffect) // 新增
  7. } // 新增
  8. return target[key]
  9. },
  10. set(target, key, newVal) {
  11. target[key] = newVal
  12. bucket.forEach(fn => fn())
  13. return true
  14. }
  15. })

副作用函数与被操作的目标字段建立关系

前面当读取属性时,无论设置的是哪一个属性,都会把副作用函数收集到“桶”里,当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。
解决办法:在副作用函数与被操作的字段之间建立联系。需要修改“桶”的数据结构。

如果用 **target** 来表示一个代理对象所代理的原始对象,用 **key** 来表示被操作的字段名,用 **effectFn** 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:

  1. target
  2. |__ key
  3. |__ effectFn

需要使用 WeakMap 代替 Set 作为桶的数据结构。

为什么要使用 WeakMap。WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。如果使用 Map 来代替 WeakMap ,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。

  1. // 存储副作用函数的桶
  2. const bucket = new WeakMap()
  3. const obj = new Proxy(data, {
  4. // 拦截读取操作
  5. get(target, key) {
  6. // 没有 activeEffect,直接 return
  7. if (!activeEffect) return target[key]
  8. // 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
  9. let depsMap = bucket.get(target)
  10. // 如果不存在 depsMap ,那么新建一个 Map 并与 target 关联
  11. if (!depsMap) {
  12. bucket.set(target, (depsMap = new Map()))
  13. }
  14. // 再根据 key 从 depsMap 中取得 deps ,它是一个 Set 类型,
  15. // 里面存储着所有与当前 key 相关联的副作用函数: effects
  16. let deps = depsMap.get(key)
  17. // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
  18. if (!deps) {
  19. depsMap.set(key, (deps = new Set()))
  20. }
  21. // 最后将当前激活的副作用函数添加到“桶”里
  22. deps.add(activeEffect)
  23. // 返回属性值
  24. return target[key]
  25. },
  26. // 拦截设置操作
  27. set(target, key, newVal) {
  28. // 设置属性值
  29. target[key] = newVal
  30. // 根据 target 从桶中取得 depsMap ,它是 key --> effects
  31. const depsMap = bucket.get(target)
  32. if (!depsMap) return
  33. // 根据 key 取得所有副作用函数 effects
  34. const effects = depsMap.get(key)
  35. // 执行副作用函数
  36. effects && effects.forEach(fn => fn())
  37. }
  38. })

从代码中可以看出构建数据结构的方式:

  • WeakMaptarget --> Map 构成;
  • Mapkey --> Set 构成。

其中 WeakMap 的键是原始对象 target ,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key , Map 的值是一个由副作用函数组成的 Set。

代码优化

将 get 拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑单独封装到一个 track 函数中,函数的名字叫 track 是为了表达追踪的含义。同样,可以把触发副作用函数重新执行的而逻辑封装到 trigger 函数中。

  1. const obj = new Proxy(data, {
  2. get(target, key) {
  3. // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
  4. track(target, key)
  5. return target[key]
  6. },
  7. set(target, key, newVal) {
  8. target[key] = newVal
  9. // 把副作用函数从桶里取出并执行
  10. trigger(target, key)
  11. }
  12. })
  13. // 在 get 拦截函数内调用 track 函数追踪变化
  14. function track(target, key) {
  15. if (!activeEffect) return
  16. let depsMap = bucket.get(target)
  17. if (!depsMap) {
  18. bucket.set(target, (depsMap = new Map()))
  19. }
  20. let deps = depsMap.get(key)
  21. if (!deps) {
  22. depsMap.set(key, (deps = new Set()))
  23. }
  24. deps.add(activeEffect)
  25. }
  26. // 在 set 拦截函数内调用 trigger 函数触发变化
  27. function trigger(target, key) {
  28. const depsMap = bucket.get(target)
  29. if (!depsMap) return
  30. const effects = depsMap.get(key)
  31. effects && effects.forEach(fn => fn())
  32. }

需要解决的问题:

  1. 不必要的更新
  2. 嵌套情况
  3. 无限递归循环

调度器实现连续多次修改响应式数据但只会触发一次更新。
computed 与 lazy 的实现
watch的实现(立即执行、执行时机)
竞态问题

理解Proxy与Reflect

Proxy 只能代理对象,无法代理非对象值。
Reflect 是一个全局对象。能实现副作用函数与响应式数据之间建立响应联系。

JavaScript 对象及 Proxy 的工作原理

常规对象
异质对象:Proxy

在 ECMAScript 规范中使用 [[xxx]] 来代表内部方法或内部槽。
内部方法具有多态性。多态的概念也就是说,不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。

如何代理 Object

对一个普通对象的所有可能的读取操作:

  • 访问属性:obj.foo。(使用get拦截)
  • 判断对象或原型上是否存在给定的key:key in obj 。(使用has拦截)
  • 使用 for…in 循环遍历对象: for(const key in obj){} 。(使用ownKeys拦截)

新增、修改、删除对象键值的代理。

合理地触发响应

当值没有发生变化,则不需要触发响应。
只有当 receiver 是 target 的代理对象时才触发更新,这样就能屏蔽由原型引起的更新,从而避免不必要的更新操作。

浅响应与深响应

shallowReactive:浅响应。所谓的浅响应,指的是只有对象的第一层属性是响应的。

只读和浅只读

只读意味着既不可以设置对象的属性值,也不可以删除对象的属性。

代理数组

数组是一个异质对象。因为数组对象的[[DefineOwnProperty]]内部方法与常规对象不同。

对数组元素或属性的“读取”操作:

  • 通过索引访问数组元素值:arr[0]。
  • 访问数组的长度:arr.length。
  • 把数组作为对象,使用 for…in 循环遍历。
  • 使用 for…of 迭代遍历数组。
  • 数组的原型方法,如 concat/join/every/some/find/findIndex/includes 等,以及其他所有不改变原数组的原型方法。

对数组元素或属性的设置操作:

  • 通过索引修改数组元素值:arr[1] = 3 。
  • 修改数组长度: arr.length = 0。
  • 数组的栈方法: push/pop/shift/unshift 。
  • 修改原数组的原型方法: splice/fill/sort 等。

代理Set和Map

使用Proxy代理集合类型的数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在很大的不同。下面总结了Set和Map这两个数据类型的原型属性和方法。

Set 类型的原型属性和方法如下。

  • size:返回集合中元素的数量。
  • add(value):向集合中添加给定的值。
  • clear():清空集合。
  • delete(value):从集合中删除给定的值。
  • has(value):判断集合中是否存在给定的值。
  • keys():返回一个迭代器对象。可用于 for…of 循环,迭代器对象产生的值为集合中的元素值。
  • 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 设置新的键值对。
  • keys():返回一个迭代器对象。迭代过程中会产生键值对的 key 值。
  • values():返回一个迭代器对象。迭代过程中会产生键值对的 value 值。
  • entries():返回一个迭代器对象。迭代过程中会产生由 [key, value] 组成的数组值。
  • forEach(callback[, thisArg]):forEach 函数会遍历 Map 数据的所有键值对。并对每一个键值对调用 callback 函数。forEach 函数接收可选的第二个参数 thisArg ,用于指定 callback 函数执行时的 this 值。