从 ref 和 reactive 入手,理解 Vue 响应式数据的创建方式

2024-05-14 19:30:41

实现响应式

要实现响应式,第一步是先得到一个“响应式数据”。

Vue 3 中,最常见的创建方式就是 refreactive

const count = ref(0)
const state = reactive({
  name: 'vue',
  age: 3
})

这两个 API 表面上用法不太一样:

  • ref 通常用来包装基础类型,也可以包装对象
  • reactive 只能接收对象类型
  • 使用 ref 时需要通过 .value 读取和修改数据
  • 使用 reactive 时可以直接通过对象属性访问

但它们要解决的问题是一样的:拦截数据的读取和修改

在 js 中,想要拦截数据变化,常见方式主要有两种:

  • Object.defineProperty
  • Proxy

Vue 2 使用的是 Object.defineProperty,而 Vue 3 的响应式系统主要基于 Proxy 实现。

从源码中复制了一下并精简

ref 的实现

// ref 的实现
function ref(value) {
  if (isRef(value)) {
    return value
  }

  return new RefImpl(value)
}

class RefImpl<T> {
  private _value: T
  private _rawValue: T

  public dep?: Dep = undefined

  public readonly __v_isRef = true

  constructor(
    value: T,
    public readonly __v_isShallow: boolean
  ) {
    this._rawValue = __v_isShallow ? value : toRaw(value)
    this._value = __v_isShallow ? value : toReactive(value)
  }

  get value() {
    // 依赖收集
  }

  set value(newVal) {
    // 派发更新
  }
}

export function toReactive<T extends unknown>(value: T): T {
  return isObject(value) ? reactive(value) : value
}

ref 的核心其实就是创建了一个 RefImpl 实例。

这也是为什么使用 ref 时需要 .value 的原因:真正被拦截的不是变量 count 本身,而是 RefImpl 实例上的 value 属性。

读取 count.value 时,会进入 get value();修改 count.value 时,会进入 set value()

这两个入口就是做依赖收集和派发更新的关键。

reactive 的实现

再看 reactive

// reactive 的实现
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  // ...
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

export function reactive(target: object) {
  // ...
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}

reactive 的核心是通过 new Proxy() 创建代理对象。

当我们读取属性时,会触发 Proxyget 拦截;当我们修改属性时,会触发 Proxyset 拦截。

到这里可以得到一个结论:

  • ref 是通过 get valueset value 拦截 .value
  • reactive 是通过 Proxy 拦截对象属性
  • 虽然它们的实现方式不同, 但最终都是在 读取修改 这两个时机在处理

响应式的本质

响应式的核心可以拆成两件事:

  • 依赖收集
  • 派发更新

依赖收集

简单来说,就是在读取响应式数据时,把当前正在执行的副作用函数记录下来。

effect(() => {
  console.log(state.count)
})

执行这段代码时会读取 state.count,所以响应式系统需要知道:

这个 effect 依赖了 state.count

这样后面 state.count 发生变化时,才能重新执行对应的 effect

派发更新

在修改响应式数据时,找到之前收集过的依赖,并重新执行。

state.count++

总结

count 改变后,响应式系统会找到依赖 state.count 的副作用函数,然后触发更新。

所以,一个最简化的响应式流程大概是:

  1. 执行 effect
  2. 读取响应式数据,触发 get
  3. get 中收集当前 effect
  4. 修改响应式数据,触发 set
  5. set 中找到依赖并重新执行