从零实现Vue3的响应式库(2)

495 阅读6分钟

本文章接上一篇:从零实现Vue3的响应式库(1)

在上一篇文章里已基本实现了一个简单的 reactivity 系统,但是还有一些边界情况需要处理,我们来看一下下面这个例子

const state = reactive({ counts: { a: 1, b: 2 } })

effect(() => {
  console.log(Object.keys(state.counts))
})

Reflect.set(state.counts, 'c', 3) //并没有触发effect

按照之前的设想,在给 state.counts 添加了c属性后,应该会执行 effect,并输出['a', 'b', 'c'],但是事实上并没有触发,其实这里的原因是我们并没有拦截到Object.keys这个操作,如果你有仔细看过 Proxy 的文档的话,你应该能发现在 handlers 里面还有一个属性ownKeys,它可以帮我们拦截Object.keys(),Reflect.ownKeys(),Object.getOwnPropertyNames(),Object.getOwnPropertySymbols()这些操作,知道这些后,我们就可以继续进行扩展了

ownKeys

首先添加两个枚举,一会将用到

// operations.ts

//收集依赖的操作类型
export const enum TrackOpTypes {
  GET = 'get',
  ITERATE = 'iterate'
}

//触发依赖的操作类型
export const enum TriggerOpTypes {
  SET = 'set',
  ADD = 'add',
  DELETE = 'delete'
}

然后在之前的baseHandlers里添加ownKeys

export const ITERATE_KEY = Symbol()

export const baseHandlers: ProxyHandler<object> = {
  get() {
    //...
  },
  set() {
    //...
  },
  deleteProperty() {
    //...
  },
  ownKeys(target: Target): (string | number | symbol)[] {
    track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
    return Reflect.ownKeys(target)
  }
}

在这个ownKeys里面直接执行 track 收集依赖,注意我们之前的 track 只有两个参数(target, key),我们等下还要对 track 做下修改,使它接收参数是(target, type, key)type表示收集依赖的类型,就是刚才定义的TrackOpTypes,现在我们有GETITERATE,我们在 ownKeys 里就用ITERATE表明它是一个迭代操作,然后第三个参数ITERATE_KEY触发收集的 key 值,因为Object.keys()这种操作不是针对具体的某一个 key 的,所以我们就用ITERATE_KEY,其中ITERATE_KEY = Symbol()

然后把 track 的参数改下

function track(target: object, type: TrackOpTypes, key: unknown) {
  //...
}

其实这个 type 我们现在也用不上,不过为了和一会的的 trigger 统一,还是加上的好,方便以后其他功能使用。

好了,写完 ownKeys 后,按照之前的逻辑,其实就可以收集到依赖了,因为传的 key 是ITERATE_KEY,所以我们获取 ownKeys 的依赖时,也要从ownKeys取。那么我们要在什么时候把这些依赖取出来呢,其实很简单,因为 ownKeys 针对的迭代操作是不关心属性的修改的,只关心属性的添加或删除,换句话说我们把{ a: 1, b: 2 }改成{ a: 1, b: 3 }Object.keys()拿到的结果是不会变的,都是['a','b'],那有人可能会问了:和Object.keys()对应的还有Object.values(),这个总会变吧?其实,Object.values()内部还是会触发ownKeys,它是先拿的 key,再用 key 去拿 value,这个过程中不但触发了ownKeys,还触发了每个属性的get,修改属性后它自然而然会触发依赖更新的,所以在这里我们只需要关心两种操作即可,就是添加属性和删除属性,因为只有这两种操作会引起 keys 的变化,根据这个思路,我们来继续实现:

  set(target: Target, key: string | symbol, value: any, receiver: object) {
    const hadKey = hasOwn(target, key)
    // 设置value
    const result = Reflect.set(target, key, value, receiver)
    // 通知更新
    trigger(
      target,
      hadKey ? TriggerOpTypes.SET : TriggerOpTypes.ADD,
      key,
      value
    )
    return result
  },
  deleteProperty(target: Target, key: string | symbol) {
    // 判断要删除的key是否存在
    const hadKey = hasOwn(target, key)
    // 执行删除操作
    const result = Reflect.deleteProperty(target, key)
    // 只在存在key并且删除成功时再通知更新
    if (hadKey && result) {
      trigger(target, TriggerOpTypes.DELETE, key, undefined)
    }
    return result
  }

这里对之前的setdeleteProperty做了些修改,在 set 中判断了下 key 是否已经存在(hadKey),然后根据这个判断是新增还是修改,这里的trigger和之前的track一样,也是加了个 type 参数;在deleteProperty中就是传了个TriggerOpTypes.DELETE表明是个删除操作,接下来再修改下之前写的 trigger 函数:

// 通知更新
export function trigger(
  target: object,
  type: TriggerOpTypes,
  key: any,
  newValue?: any
) {
  // 获取该对象的depsMap
  const depsMap = targetMap.get(target)
  // 获取不到时说明没有触发过getter
  if (!depsMap) {
    return
  }
  const effects = new Set<ReactiveEffect>()
  const add = (data: Set<ReactiveEffect> | undefined) => {
    if (data) {
      data.forEach((effect) => {
        if (effect !== activeEffect) {
          effects.add(effect)
        }
      })
    }
  }
  add(depsMap.get(key))
  if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
    add(depsMap.get(ITERATE_KEY))
  }
  // 然后根据key获取deps,也就是之前存的effect函数
  // 执行所有的effect函数
  effects.forEach((effect) => {
    effect()
  })
}

这个改的比较多,最主要的是其中这一段代码:

if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
  add(depsMap.get(ITERATE_KEY))
}

就是判断下这个操作是添加或者删除的时候,就添加depsMap.get(ITERATE_KEY),这个就是之前在ownKeys里面收集的依赖了,这样我们就有了Object.keys的收集依赖=>触发依赖的流程,你可以再试下文章开头的例子:在添加了 c 属性后,effect 函数成功地执行了。

数组的边界情况

现在再试下另外一种情况

const state = reactive({ counts: [1, 2, 3] })

effect(() => {
  console.log(state.counts.length)
})

state.counts[3] = 4

这种情况并不会触发 effect,这是因为这里的 effect 函数只收集了countslength,而我们修改时是通过3这个属性去修改的,这样当然不会触发依赖更新了,其实改的话很简单:

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key: any,
  newValue?: any
) {
  // ...
  if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
    add(depsMap.get(Array.isArray(target) ? 'length' : ITERATE_KEY))
  }
  // ...
}

因为数据也可以看作是一个对象,它的下标就是 key,之前的 ADD 和 DELETE 同样适用于数组,这样在这里只需要判断下是否是数组,是数组的话就从length这个 key 中取依赖,否则说明它是个对象,就还从ITERATE_KEY中取,这样修改之后,再运行上面那个例子,就能成功达到我们想要的效果了。

has

我们再来看下另外一种情况

const state = reactive({ counts: { a: 1, b: 2 } })

effect(() => {
  console.log(Reflect.has(state.counts, 'a'))
})

Reflect.deleteProperty(state.counts, 'a')

这个例子同样没有达到我们想要的效果,其实这是因为我们也没有拦截到操作,所以就没有收集依赖。在 Proxy 的 handlers 中还有另外一个属性就是has,它可以帮我们拦截到in操作符,这里的Reflect.has也会被拦截。下面我们来使用has改下我们的代码。

// handlers.ts

export const baseHandlers: ProxyHandler<object> = {
  get() {
    //...
  },
  set() {
    //...
  },
  deleteProperty() {
    //...
  },
  ownKeys() {
    //...
  },
  has(target: object, key: string | symbol): boolean {
    const result = Reflect.has(target, key)
    track(target, TrackOpTypes.HAS, key)
    return result
  }
}

新加了一个 has,其主要就是 track 触发收集就可以了,现在上面的例子就可以成功运行了。

总结

这篇文章主要是对之前的代码增加了一些边界情况的判断,这些边界情况是经常能碰到的,其实除了这些还有很多的边界情况需要处理,比如还有数组的一些方法、Map、Set,还有一些值的类型的判断等,我这里就不写了,如果你感兴趣的话,你可以去@vue/reactivity这个库里面看,我这两篇文章也是参考这个库去实现的,虽然不如他这个库复杂,但是基本原理是一样的。

其实如果你认真研究下,就会发现这种响应式原理并不复杂,里面并没有什么神奇的魔法,都是围绕着一个 Proxy 去展开的,看过这篇文章后,相信你也能实现一个自己的响应式库。