从 reactive 入手,实现对象属性读取、修改、新增和删除的拦截

2024-05-14 19:30:41

前面已经梳理了响应式的基本流程:读取数据时进行依赖收集,修改数据时派发更新。

这一篇开始,按照这个流程手写一个简化版的响应式系统。为了先把核心逻辑梳理清楚,这里只使用 js 文件。

先把 reactive 的基础结构搭起来:

  • 使用 Proxy 代理对象
  • 拦截对象的读取、修改、新增和删除
  • 预留 tracktrigger 两个入口
  • 处理嵌套对象、重复赋值和无效删除这些细节

创建 reactive 入口

先创建一个 reactive.js 文件。

reactive 的作用就是接收一个对象,并返回这个对象的代理对象:

export function reactive(target) {
  const proxy = new Proxy(target, {
    get(target, key) {
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      return Reflect.set(target, key, value)
    }
  })

  return proxy
}

再创建一个 test.js 简单验证一下:

import { reactive } from './reactive.js'

const obj = {
  a: 1
}

const proxyObj = reactive(obj)

proxyObj.a
proxyObj.a = 2

执行 proxyObj.a 时会进入 get,执行 proxyObj.a = 2 时会进入 set

拆分 handler

如果继续把所有拦截逻辑都写在 reactive.js 中,后面代码会越来越乱。

所以先把 Proxyhandler 拆出去,创建 behavior/index.js

function get(target, key) {
  console.log('track')
  return Reflect.get(target, key)
}

function set(target, key, value) {
  console.log('trigger')
  return Reflect.set(target, key, value)
}

export default {
  get,
  set
}

然后更新 reactive.js

import handler from './behavior/index.js'

export function reactive(target) {
  const proxy = new Proxy(target, handler)
  return proxy
}

这样 reactive.js 只负责创建代理对象,具体的拦截逻辑交给 behavior/index.js

定义操作类型

读取和修改只是最基础的两类操作。

对于一个对象来说,还会有其他行为:

proxyObj.a
'a' in proxyObj
Object.keys(proxyObj)
proxyObj.a = 2
proxyObj.b = 3
delete proxyObj.a

这些操作在响应式系统中需要区分开。

所以创建 constant/enum.js

/**
 * 收集依赖的操作
 * GET:读取属性,比如 `proxyObj.a`
 * HAS:判断属性是否存在,比如 `'a' in proxyObj`
 * ITERATE:遍历对象属性,比如 `Object.keys(proxyObj)`
 */
export const TrackOpTypes = {
  GET: 'get',
  HAS: 'has',
  ITERATE: 'iterate'
}

/**
 * 派发更新的操作
 * SET:修改已有属性
 * ADD:新增属性
 * DELETE:删除属性
 */
export const TriggerOpTypes = {
  SET: 'set',
  ADD: 'add',
  DELETE: 'delete'
}

接入 track 和 trigger

接下来创建两个入口文件。

effect/track.js

function track(target, type, key) {
  console.log('track', type, key)
}

export default track

effect/trigger.js

function trigger(target, type, key) {
  console.log('trigger', type, key)
}

export default trigger

然后更新 behavior/index.js

import { TrackOpTypes, TriggerOpTypes } from '../constant/enum.js'
import track from '../effect/track.js'
import trigger from '../effect/trigger.js'

function get(target, key) {
  track(target, TrackOpTypes.GET, key)
  return Reflect.get(target, key)
}

function has(target, key) {
  track(target, TrackOpTypes.HAS, key)
  return Reflect.has(target, key)
}

function set(target, key, value) {
  trigger(target, TriggerOpTypes.SET, key)
  return Reflect.set(target, key, value)
}

function handleDelete(target, key) {
  trigger(target, TriggerOpTypes.DELETE, key)
  return Reflect.deleteProperty(target, key)
}

function ownKeys(target) {
  track(target, TrackOpTypes.ITERATE)
  return Reflect.ownKeys(target)
}

export default {
  get,
  set,
  has,
  ownKeys,
  deleteProperty: handleDelete
}

这里多处理了三个拦截:

  • has:对应 'key' in proxyObj
  • ownKeys:对应 Object.keys(proxyObj)for...in 等遍历行为
  • deleteProperty:对应 delete proxyObj.key

这样对象的常见操作就基本覆盖到了。

修复细节问题

代理对象

import handler from './behavior/index.js'
import { isObject } from './utils/util.js'

// 创建代理对象和原始对象之间的关系
const proxyMap = new WeakMap()

export function reactive(target) {
  // 不是对象,直接返回
  if (!isObject(target)) {
    return target
  }

  // 如果已经代理过了,直接返回之前的代理对象
  if (proxyMap.has(target)) {
    return proxyMap.get(target)
  }

  const proxy = new Proxy(target, handler)
  proxyMap.set(target, proxy)

  return proxy
}

嵌套对象处理

把测试代码改成这样:

import { reactive } from './reactive.js'

const obj = {
  a: 1,
  b: 2,
  c: {
    name: '小明',
    age: 18
  }
}

const proxyObj = reactive(obj)

proxyObj.c.name

这里访问的是 proxyObj.c.name,直觉上应该触发两次读取:

  1. 读取 proxyObj.c
  2. 读取 proxyObj.c.name

但现在只会触发一次 track

原因是 proxyObj 只有第一层被代理了,c 这个对象本身还是普通对象。读取到 c 之后,再访问 c.name,就不会进入 Proxyget 了。

所以在 get 中需要判断读取结果是否为对象。如果是对象,就继续调用 reactive 包一层:

import { reactive } from '../reactive.js'
import { isObject } from '../utils/util.js'

function get(target, key) {
  track(target, TrackOpTypes.GET, key)

  const result = Reflect.get(target, key)

  if (isObject(result)) {
    return reactive(result)
  }

  return result
}

补充一个工具函数:

export function isObject(target) {
  return typeof target === 'object' && target !== null
}

这样访问 proxyObj.c.name 时,c 也会被转成响应式对象。

避免无效更新

现在的 set 只要执行赋值,就会触发 trigger

proxyObj.a = 1

proxyObj.a 本来就是 1,这次赋值其实没有产生变化,不应该派发更新。

所以在触发更新前,需要先比较新旧值:

function set(target, key, value) {
  const oldValue = Reflect.get(target, key)
  const result = Reflect.set(target, key, value)

  if (hasChanged(oldValue, value)) {
    trigger(target, TriggerOpTypes.SET, key)
  }

  return result
}

再补充一个工具函数 hasChanged

export function hasChanged(oldValue, value) {
  return !Object.is(oldValue, value)
}

删除属性时也有类似问题:

delete proxyObj.f

如果 proxyObj 上本来就没有 f,删除它也不应该触发更新。

function handleDelete(target, key) {
  const hasKey = Object.hasOwn(target, key)
  const result = Reflect.deleteProperty(target, key)

  if (hasKey && result) {
    trigger(target, TriggerOpTypes.DELETE, key)
  }

  return result
}

这样只有属性原本存在,并且删除成功时,才会触发 trigger

区分新增和修改

proxyObj.d = 100

如果 d 原来不存在,这次操作不是修改已有属性,而是新增属性。

新增和修改都会触发 set,所以需要在 set 里区分操作类型:

function set(target, key, value) {
  const type = Object.hasOwn(target, key)
    ? TriggerOpTypes.SET
    : TriggerOpTypes.ADD

  const oldValue = Reflect.get(target, key)
  const result = Reflect.set(target, key, value)

  if (hasChanged(oldValue, value)) {
    trigger(target, type, key)
  }

  return result
}

这样:

  • 修改已有属性时,触发 SET
  • 新增属性时,触发 ADD

总结

目前完成了这些能力:

  • 使用 Proxy 创建响应式对象
  • 拦截属性读取、修改、新增、删除和遍历
  • 在读取时调用 track
  • 在修改时调用 trigger

下一步就可以开始实现 effect,让读取时收集当前正在执行的函数,修改时再把这个函数重新执行。

当前代码

到这里,核心代码可以整理成下面这样:

// behavior/index.js
import { TrackOpTypes, TriggerOpTypes } from '../constant/enum.js'
import track from '../effect/track.js'
import trigger from '../effect/trigger.js'
import { reactive } from '../reactive.js'
import { hasChanged, isObject } from '../utils/util.js'

function get(target, key) {
  track(target, TrackOpTypes.GET, key)

  const result = Reflect.get(target, key)

  if (isObject(result)) {
    return reactive(result)
  }

  return result
}

function has(target, key) {
  track(target, TrackOpTypes.HAS, key)
  return Reflect.has(target, key)
}

function set(target, key, value) {
  const type = Object.hasOwn(target, key)
    ? TriggerOpTypes.SET
    : TriggerOpTypes.ADD

  const oldValue = Reflect.get(target, key)
  const result = Reflect.set(target, key, value)

  if (hasChanged(oldValue, value)) {
    trigger(target, type, key)
  }

  return result
}

function handleDelete(target, key) {
  const hasKey = Object.hasOwn(target, key)
  const result = Reflect.deleteProperty(target, key)

  if (hasKey && result) {
    trigger(target, TriggerOpTypes.DELETE, key)
  }

  return result
}

function ownKeys(target) {
  track(target, TrackOpTypes.ITERATE)
  return Reflect.ownKeys(target)
}

export default {
  get,
  set,
  has,
  deleteProperty: handleDelete,
  ownKeys
}