[Vue] Vue3 的响应式原理?

该话题涉及的相关内容

  • 原理:Proxy、track、trigger
  • 新增属性
  • 遍历后新增
  • 遍历后删除或者清空
  • 获取 keys
  • 删除对象属性
  • 判断属性是否存在
  • 性能

推荐阅读文档: https://juejin.cn/post/6844904122479542285

响应式仓库

Vue3 不同于 Vue2 也体现在源码结构上,Vue3 把耦合性比较低的包分散在 packages 目录下单独发布成 npm 包。 这也是目前很流行的一种大型项目管理方式 Monorepo

其中负责响应式部分的仓库就是 @vue/reactivity,它不涉及 Vue 的其他的任何部分,是非常非常 「正交」 的一种实现方式。

甚至可以轻松的集成进 React https://juejin.cn/post/6844904095594381325

区别

Proxy 和 Object.defineProperty 的使用方法看似很相似,其实 Proxy 是在 「更高维度」 上去拦截属性的修改的,怎么理解呢?

Vue2 中,对于给定的 data,如 { count: 1 },是需要根据具体的 key 也就是 count,去对「修改 data.count 」 和 「读取 data.count」进行拦截,也就是

Object.defineProperty(data, 'count', {
  get() {},
  set() {},
})

必须预先知道要拦截的 key 是什么,这也就是为什么 Vue2 里对于对象上的新增属性无能为力。

而 Vue3 所使用的 Proxy,则是这样拦截的:

new Proxy(data, {
  get(key) { },
  set(key, value) { },
})

可以看到,根本不需要关心具体的 key,它去拦截的是 「修改 data 上的任意 key」 和 「读取 data 上的任意 key」。

所以,不管是已有的 key 还是新增的 key,都逃不过它的魔爪。

但是 Proxy 更加强大的地方还在于 Proxy 除了 get 和 set,还可以拦截更多的操作符。

简单的例子🌰

先写一个 Vue3 响应式的最小案例,本文的相关案例都只会用 reactiveeffect 这两个 api。如果你了解过 React 中的 useEffect,相信你会对这个概念秒懂,Vue3 的 effect 不过就是去掉了手动声明依赖的「进化版」的 useEffect

React 中手动声明 [data.count] 这个依赖的步骤被 Vue3 内部直接做掉了,在 effect 函数内部读取到 data.count 的时候,它就已经被收集作为依赖了。

Vue3:

// 响应式数据
const data = reactive({
  count: 1
})

// 观测变化
effect(() => console.log('count changed', data.count))

// 触发 console.log('count changed', data.count) 重新执行
data.count = 2

React:

// 数据
const [data, setData] = useState({
  count: 1
})

// 观测变化 需要手动声明依赖
useEffect(() => {
  console.log('count changed', data.count)
}, [data.count])

// 触发 console.log('count changed', data.count) 重新执行
setData({
  count: 2
})

也可以把 effect 中的回调函数联想到视图的重新渲染、 watch 的回调函数等等…… 它们是同样基于这套响应式机制的。

而本文的核心目的,就是探究这个基于 Proxy 的 reactive api,到底能强大到什么程度,能监听到用户对于什么程度的修改。

讲讲原理

先最小化的讲解一下响应式的原理,其实就是在 Proxy 第二个参数 handler 也就是陷阱操作符中,拦截各种取值、赋值操作,依托 tracktrigger 两个函数进行依赖收集和派发更新。

track 用来在读取时收集依赖。

trigger 用来在更新时触发依赖。

track

function track(target: object, type: TrackOpTypes, key: unknown) {
  const depsMap = targetMap.get(target);
  // 收集依赖时 通过 key 建立一个 set
  let dep = new Set()
  targetMap.set(ITERATE_KEY, dep)
  // 这个 effect 可以先理解为更新函数 存放在 dep 
  dep.add(effect)
}

target 是原对象。

type 是本次收集的类型,也就是收集依赖的时候用来标识是什么类型的操作,比如上文依赖中的类型就是 get,这个后续会详细讲解。

key 是指本次访问的是数据中的哪个 key,比如上文例子中收集依赖的 key 就是 count

首先全局会存在一个 targetMap,它用来建立 数据 -> 依赖 的映射,它是一个 WeakMap 数据结构。

targetMap 通过数据 target,可以获取到 depsMap,它用来存放这个数据对应的所有响应式依赖。

depsMap 的每一项则是一个 Set 数据结构,而这个 Set 就存放着对应 key 的更新函数。

是不是有点绕?我们用一个具体的例子来举例吧。

const target = { count: 1}
const data = reactive(target)

const effection = effect(() => {
  console.log(data.count)
})

对于这个例子的依赖关系,

  1. 全局的 targetMap 是:
targetMap: {
  { count: 1 }: dep
}
  1. dep 则是
dep: {
  count: Set { effection }
}

这样一层层的下去,就可以通过 target 找到 count 对应的更新函数 effection 了。

trigger

这里是最小化的实现,仅仅为了便于理解原理,实际上要复杂很多,

其实 type 的作用很关键,先记住,后面会详细讲。

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
) {
  // 简化来说 就是通过 key 找到所有更新函数 依次执行
  const dep = targetMap.get(target)
  dep.get(key).forEach(effect => effect())
}