非原始值的响应式方案(二、完全代理Object)

非原始值的响应式方案(二、完全代理Object)

有了上一节的基础,这节我们来正式的手写一下 Vue3 中对于 Object 的响应式代理。

2956字

有了上篇文章对于 JS 对象、Proxy 以及 Reflect 的了解,现在我们已经有了可以正式实现响应式数据的理论基础了。

如何正确的代理一个Object

在最开始的时候我们使用 Proxy 的 get 去拦截了 读取 这一操作,但是实际上 读取 这一操作在实际应用层面是一个很宽泛的概念。我们平时在代码中经常使用的一些语法很可能就是间接的触发了读取的操作。完备的响应式系统应该 100% 完全覆盖所有可能的基本语义触发情况。

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

  • 直接访问其属性:obj.name
  • 判断对象 & 原型上面是否存在给定的 keykey in obj
  • 使用 for ... in 来循环遍历对象:for (const key in obj) {}

接下来要做的就是如何正确的将这些情况全部拦截到了。我们看一下之前实现的 get 拦截方式:

javascript
// 对原始数据使用 Proxy 进行代理
export function reactive(data) {
  return new Proxy(data, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newVal) {
      target[key] = newVal
      trigger(target, key)
      return true
    },
  })
}

我们现在已经拦截了第一点。我们在之前的文章中说过了一个对象具有其必不可少的基本语义,我们在代码中对其的所有操作在底层实际上都是进行了基本语义函数的调用。

所以,这些读取操作,从本质上都是一个个基本语义的调用。 我们要做的就是找到这些操作背后的基本语义是什么,然后对其进行直接拦截并代理,就可以了!

在这里我直接借用书上的结论:in 操作符的运算结果是调用一个叫做 HasProperty 的抽象方法。 而这个抽象方法也是调用 [[HasProperty]] 这个基本语义得到的。而这个基本语义对应的 Proxy trap 函数是 has

所以我们可以解决第二点了:

javascript
// 对原始数据使用 Proxy 进行代理
export function reactive(data) {
  return new Proxy(data, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newVal) {
      target[key] = newVal
      trigger(target, key)
      return true
    },
    has(target, key) {
      track(target, key)
      return Reflect.has(target, key)
    },
  })
}

在这个的基础之上,我们可以进一步了解 for ... in 操作的基本语义。在 ECMA 规范中,这个操作是由 for (const key of Reflect.ownkeys(obj)) 来实现的,所以其基本语义就是 [[ownKeys]]

接下来就解决一下第三点吧:

javascript
const ITERATE_KEY = Symbol()

// 对原始数据使用 Proxy 进行代理
export function reactive(data) {
  return new Proxy(data, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newVal) {
      target[key] = newVal
      trigger(target, key)
      return true
    },
    has(target, key) {
      track(target, key)
      return Reflect.has(target, key)
    },
    ownKeys(target) {
      track(target, ITERATE_KEY)
      return Reflect.ownKeys(target)
    },
  })
}

在这里,我们使用了一个外部的常量,ITERATE_KEY ,并且其为 Symbol 类型。核心原因是,我们在拦截 ownKeys 操作的时候,我们不知道正在拦截到的到底是哪个属性!

typescript
/**
 * A trap for `Reflect.ownKeys()`.
 * @param target The original object which is being proxied.
 */
ownKeys?(target: T): ArrayLike<string | symbol>;

可以看到官方的 DTS 里面的回调参数也只有一个 target。因此,这个操作实际上 不与任何具体的键进行绑定,所以我们就用一个唯一的 key 对这种操作代为标识,也就是一个 Symbol 类型。

所以,我们在触发的时候也必须得用相同的 key 进行触发。问题来了,到底什么时候会触发 ITERATE_KEY 对应的副作用函数呢?

由于 for ... in 语法是对一个对象内部的所有属性键进行遍历,所以我们这么写副作用函数:

javascript
const data = { name: 'non_hana' }

const obj = reactive(data)

effect(() => {
  for (const key in obj) {
    console.log(`副作用函数重新执行了!${key}`)
  }
})

obj.age = 18

这里,我们一开始的 data 对象只有一个属性 name,后面我们给它加了一个新的 age 属性。加了之后,这个循环会从执行一次变为执行两次,这个时候就需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。

为了做到这一点,我们要对 set 拦截函数进行改写。

javascript
set(target, key, newVal, receiver) {
  const res = Reflect.set(target, key, newVal, receiver)
  trigger(target, key)
  return res
},

按照目前的实现,给 obj 添加一个新的属性的时候,set 接收到的 key 仍然是 age,而这和 ITERATE_KEY 一点关系都没有。所以这样子还不能正确的触发响应。

所以只要当添加属性时,我们手动把与 ITERATE_KEY 关联的副作用函数取出来执行就可以了。不过为了达成这一点,我们需要同时改 set 函数本身与对应的 trigger 函数。改动 set 函数主要为了 区分添加新属性还是单纯改变已有属性的值,改动 trigger 函数则是正确的触发 ITERATE_KEY 对应的副作用函数。

我们先来改 set 函数:

javascript
set(target, key, newVal, receiver) {
  const type = Object.prototype.hasOwnProperty.call(target, key)
    ? 'SET'
    : 'ADD'
  const res = Reflect.set(target, key, newVal, receiver)
  trigger(target, key, type)
  return res
},

其实很简单,直接用 Object.hasOwnProperty 来判断一下 key 是否在 target 里面就可以了,用 call 来指定被实施的对象。然后用 type 来进行标识,最后把判断的结果作为 trigger 的第三个参数传给 trigger 就行,然后 trigger 内部再进行判断处理。

好,接下来就是改写一下 trigger:

javascript
const TriggerType = {
  SET: 'SET',
  ADD: 'ADD',
}

// 触发副作用函数的重新执行
function trigger(target, key, type) {
  const depsMap = bucket.get(target)
  if (!depsMap)
    return

  const effects = depsMap.get(key)
  const iterateEffects = depsMap.get(ITERATE_KEY)

  const effectsToRun = new Set()

  effects
  && effects.forEach((effectFn) => {
    if (effectFn !== activeEffectFn) {
      effectsToRun.add(effectFn)
    }
  })

  if (type === TriggerType.ADD) {
    iterateEffects
    && iterateEffects.forEach((effectFn) => {
      if (effectFn !== activeEffectFn) {
        effectsToRun.add(effectFn)
      }
    })
  }

  effectsToRun.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    }
    else {
      effectFn()
    }
  })
}

其实也就是往 effectsToRun 里面把 ITERATE_KEY 对应的函数 Set 给推进去而已,如果是 ADD 操作就推进去,不是就不推进去。这里的枚举如果是用 TypeScript 写,直接用 enum 数据就可以。

在最后,还剩下最后一个操作就是 delete,删除对象的某个属性。当然,delete 肯定也是由基本语义实现的。查看 ECMA 规范:

可以看到,delete 操作依赖的就是单纯的 [[Delete]] 内部方法,我们可以使用 Proxy trap 中的 deleteProperty 方法进行拦截:

javascript
const TriggerType = {
  SET: 'SET',
  ADD: 'ADD',
  DELETE: 'DELETE',
}

// ...

deleteProperty(target, key) {
  const hadKey = Object.prototype.hasOwnProperty.call(target, key)
  const res = Reflect.deleteProperty(target, key)

  if (res && hadKey) {
    trigger(target, key, TriggerType.DELETE)
  }

  return res
},

需要注意的是,我们当删除了一个对象的某个 key,这势必也会触发 for ... in 遍历的次数的变化,所以一般我们也需要直接触发 ITERATE_KEY 相关联的副作用函数重新执行。为此,我们直接改一下 iterateEffects 对应的获取条件:

javascript
if (type === TriggerType.ADD || type === TriggerType.DELETE) {
  iterateEffects
  && iterateEffects.forEach((effectFn) => {
    if (effectFn !== activeEffectFn) {
      effectsToRun.add(effectFn)
    }
  })
}

这样就可以啦!我们来试试吧!

javascript
const data = { name: 'non_hana' }

const obj = reactive(data)

effect(() => {
  for (const key in obj) {
    console.log(`副作用函数重新执行了!${key}`)
  }
})

obj.age = 18
delete obj.age

看一下输出!

结果很好!至此,对于一个标准 JS 对象的代理基本上就结束了。

合理地触发响应

虽然我们之前对于对象的代理做了非常多的工作,也实现了相对完善的代理方式,但是现在触发响应的方式还是相对不完善的。

值不变不应触发响应

在现在的实现中,我们只要对响应式变量的某个属性进行了 = 的操作,无论值是否发生变化都会触发 set,这听起来就很傻,因为 ECMA 规范并没有指定 设置 必须得设置与之前不同的值,而只要是设置操作,就触发的是 [[Set]] 这个基本语义。

所以我们来解决吧!

一个最直接的想法就是,在 set 这个 Proxy trap 函数里面,我们拿到 oldValue 之后与 newValue 进行比较,如果 oldValue !== newValue,那么就直接 trigger。这确实是 99% 是对的,不过忘了一个东西:NaN,这东西是 JS 唯一一个 非自反(irreflexive) 的值,也就是 NaN === NaN 不成立的值。不过问题不大,我们单独把它拿出来处理就行了。

改一下 set 部分的代码:

javascript
set(target, key, newValue, receiver) {
  const oldValue = target[key]

  const type = Object.prototype.hasOwnProperty.call(target, key)
    ? TriggerType.SET
    : TriggerType.ADD
  const res = Reflect.set(target, key, newValue, receiver)

  if (
    oldValue !== newValue &&
    (oldValue === oldValue || newValue === newValue)
  ) {
    trigger(target, key, type)
  }

  return res
},

这样就解决了 NaN 的问题。不过,除了 NaN 以外,还有一些其他的设置场景,比如从原型上继承属性的情况。

我们来写一下这样的代码:

javascript
const obj = {}
const proto = { age: 18 }
const child = reactive(obj)
const parent = reactive(proto)

// 把 parent 设置为 child 的原型
Object.setPrototypeOf(child, parent)

effect(() => {
  console.log(child.age)
})

child.age = 19

运行一下:

可以看到,结果输出了两次!

原因是什么呢?我们可以看到,首先,child 上面原本是空的,没有属性,所以在读取 child.age 属性的时候,实际上是从继承的原型 parent 上面进行读取的。

我们一点点来看这里的 get 与 set 的触发流程。首先在副作用依赖收集的部分,由于 child 本身是响应式数据,所以不可避免地会执行 Reflect(obj, 'age', receiver),执行 obj.age 的读取。而 obj 上并没有 age 属性,怎么办呢?在 ECMA 规范中指明了,如果当前对象没有对应的属性那么就会去其原型上面找,在这里就指的是 parent.age,然后读它。但是 parent 本身也是响应式数据,相当于触发了它的基本语义 [[Get]] 然后触发了 track 追踪依赖,导致最后的结果是 child.ageparent.age 都被依赖收集了。

之后,当进行 child.age = 19 的时候,同样的道理,child 上面没有 age 属性,那么就会直接调用其原型上的 age 属性然后执行其基本语义 [[Set]],从而触发 trigger。而刚才我们刚说过 child.ageparent.age 都被依赖收集了,所以设置一次会导致触发两次,就是这个原因。

既然会触发两次,我们可以 屏蔽掉一次,留下一次。为了确保响应式系统的可预测性,我们选择屏蔽掉原型上的那一次基本语义,为了做到这一点,我们需要区分开来哪次是原型上的基本语义哪次是目标对象的基本语义。

当执行 child.age = 19 时,触发的 set trap 中的 target 是 obj,receiver 是 child。而 child 正是 obj 的代理对象,这其中是有一个明确的联系。

而当发现 obj 上面没有 age 时,会到其原型上去找。此时的 target 就变成了 proto,而 proto 和 child 没有联系 。我们可以凭这一点来判断。

为了利用起这个特点,我们需要给 get trap 加一个属性来让代理函数能够主动访问其原始对象:

javascript
get(target, key, receiver) {
  if (key === 'raw') {
    return target
  }
  track(target, key)
  return Reflect.get(target, key, receiver)
},

之后我们在 set 函数里面用起来:

javascript
set(target, key, newValue, receiver) {
  const oldValue = target[key]

  const type = Object.prototype.hasOwnProperty.call(target, key)
    ? TriggerType.SET
    : TriggerType.ADD
  const res = Reflect.set(target, key, newValue, receiver)

  if (target === receiver.raw) {
    if (
      oldValue !== newValue &&
      (oldValue === oldValue || newValue === newValue)
    ) {
      trigger(target, key, type)
    }
  }

  return res
},

这样我们就可以判断是响应式数据的继承还是数据本身触发副作用函数了。运行一下代码:

成功!

深响应与浅响应

我们或多或少都有使用过 Vue 中的 shallowReactivereactive。实际上,我们目前的实现还是一个 浅响应 的系统,也就是说我们无法去代理 多层嵌套 的数据。举个例子:

javascript
const obj = reactive({ people: { name: 'non_hana', age: 18 } })

effect(() => {
  console.log('obj.people.name', obj.people.name)
})

// 修改数据
obj.people.name = 'hana'

按照现在的实现,修改数据之后是无法触发这个副作用函数的。当读取 obj.people.name 时,首先第一个读取到的是 obj.people,然后直接使用 Reflect.get 返回了结果,这个结果目前还是一个 普通对象 而非响应式数据,所以在访问 obj.people.name 的时候还是无法建立响应联系的。

为了能够将深层嵌套的属性也同样进行响应式注册,我们要把返回的逻辑改一下。在读取属性值的时候,先检测这个属性值是不是一个对象,如果是对象则 递归调用 reactive 函数将其包装成响应式数据并返回

javascript
get(target, key, receiver) {
  if (key === 'raw') {
    return target
  }
  track(target, key)

  const res = Reflect.get(target, key, receiver)
  if (typeof res === 'object' && res !== null) {
    return reactive(res)
  }
  return res
},

当然,有时候我们也 只想要浅响应,在目前的 Vue 当中是用 shallowReactive 实现的。我们之前写的那个 reactive 实际上就是浅响应。为了让这两个函数的实现部分统一而仅通过函数名区分,我们将创建响应式数据的函数进行封装:

javascript
// 创建响应式数据
function createReactive(data, isShallow = false) {
  return new Proxy(data, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
      track(target, key)

      const res = Reflect.get(target, key, receiver)
      if (isShallow) {
        return res
      }
      if (typeof res === 'object' && res !== null) {
        return reactive(res)
      }
      return res
    },
    // ...
  })
}

// 深响应
export function reactive(data) {
  return createReactive(data)
}

// 浅响应
export function shallowReactive(data) {
  return createReactive(data, true)
}

这样子,我们就可以通过 reactiveshallowReactive 两个不同的函数分别实现深拷贝和浅拷贝了!

只读和浅只读

有时候我们会希望一些数据是只读的,不能够被修改。当其被修改时,会收到一条警告信息。基于上面的 createReactive 函数,我们可以简单的实现 readonly 函数。先改改 createReactive 使其接收第三个参数:

javascript
// 创建响应式数据
function createReactive(data, isShallow = false, isReadonly = false) {
  return new Proxy(data, {
    set(target, key, newValue, receiver) {
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的!`)
        return true
      }

      const oldValue = target[key]

      const type = Object.prototype.hasOwnProperty.call(target, key)
        ? TriggerType.SET
        : TriggerType.ADD
      const res = Reflect.set(target, key, newValue, receiver)

      if (target === receiver.raw) {
        if (
          oldValue !== newValue
          && (oldValue === oldValue || newValue === newValue)
        ) {
          trigger(target, key, type)
        }
      }

      return res
    },
    deleteProperty(target, key) {
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的!`)
        return true
      }

      const hadKey = Object.prototype.hasOwnProperty.call(target, key)
      const res = Reflect.deleteProperty(target, key)

      if (res && hadKey) {
        trigger(target, key, TriggerType.DELETE)
      }

      return res
    },
  })
}

我们改了一下 setdeleteProperty 这两个 trap,使其能够根据传入的第三个参数来判断是否输出警告信息与不执行下面的代码。

既然是只读,那么肯定不允许修改,不修改就说明肯定不会重新触发副作用函数,那也就没必要建立响应联系了。所以也得改 get trap:

javascript
get(target, key, receiver) {
  if (key === 'raw') {
    return target
  }

  if (!isReadonly) {
    track(target, key)
  }

  const res = Reflect.get(target, key, receiver)
  if (isShallow) {
    return res
  }
  if (typeof res === 'object' && res !== null) {
    return createReactive(data)
  }
  return res
},

接下来就是二次封装 createReactive 函数了:

javascript
export function readonly(data) {
  return createReactive(data, false, true)
}

不过到目前为止,现在实现的应该也只能叫做浅只读,因为我们并没有递归的去调用 readonly 实现深层对象属性的代理。我们可以借助之前实现深响应与浅响应的逻辑,在递归上去做手脚:

javascript
get(target, key, receiver) {
  if (key === 'raw') {
    return target
  }

  if (!isReadonly) {
    track(target, key)
  }

  const res = Reflect.get(target, key, receiver)
  if (isShallow) {
    return res
  }
  if (typeof res === 'object' && res !== null) {
    return isReadonly ? readonly(res) : reactive(data)
  }
  return res
},

isShallow 为 false 时,会走到 if (typeof res === 'object' && res !== null) { 的函数体内部。如果 isReadonly 为 true,则会递归调用 readonly 进行深层代理,反之调用 reactive 进行深层代理。

接下来就是对其进行二次封装了:

javascript
// 深只读
export function readonly(data) {
  return createReactive(data, false, true)
}

// 浅只读
export function shallowReadonly(data) {
  return createReactive(data, true, true)
}

这样子之后,我们的深浅响应、深浅只读就都做好了。

评论0