# Vue3 响应式原理

Vue 是一套用于构建用户界面的开源 JavaScript 渐进式框架,它被设计为可以自底向上逐层应用。

在 Vue 中,关注的核心是 MVC 模式中的视图层,并制定了一套非侵入性的响应式系统,开发者只需将视图与对应的模型进行绑定,Vue 便能自动观测模型的变动,并重绘视图。

# 观察者模式

无论是 Vue2 还是 Vue3,响应式系统都是基于观察者模式实现的。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。

subobject_observe

此处的目标对象可以类比我们传递给 Vue 的 data 选项,而观察者对象则是我们直接书写或者编译生成的渲染函数。

但是,在项目的实际开发中我们并没有手动的去为数据添加任何依赖,也没有在改变数据时去触发任何更新,整个流程是如何运转起来的呢?答案便是数据劫持。

# 核心 API - Proxy

当把一个普通的 JavaScript 对象作为 data 选项传给应用或组件实例的时候,Vue 会使用带有 gettersetter 的处理程序遍历其所有属性并将其转换为 Proxy。

在 Vue 2 中主要使用的是 Object.defineProperty() 方法,而在 Vue 3 中则是使用更强大的 Proxy 对象。

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

const handler = {
  get: function(obj, prop) {
    return prop in obj ? obj[prop] : 666
  },
}

const p = new Proxy({}, handler)

console.log('c' in p, p.c) // false, 666

从上面的示例代码来看 Proxy 和 Object.defineProperty() 方法是极其相似的,当然绝非如此简单,但目前这样理解就可以了。

# 数据劫持

在 Proxy 的帮助下我们可以很容易地感知到用户对对象采取的相关操作(包括对属性的读取、更改,甚至是删除),并且重新定义原本的行为。

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver)

      // TODO: 依赖收集
      return isObject(res) ? reactive(res) : res
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)

      // TODO: 触发更新
      return result
    },
  }

  return new Proxy(target, handler)
}

如此一来,我们便可以在用户无感知的情况下去收集依赖,并在数据后续发生改变时得到通知。这就是 Vue 响应式系统的本质。

# 依赖收集

当从组件中的 data() 返回一个对象时,它在内部交由 reactive() 函数使其成为响应式对象。模板会被编译成能够使用这些响应式属性的渲染函数,当函数调用时并开始收集依赖。

那么谁来调用函数,又发现依赖呢?此处需要了解的是一个 effect() 函数,它类似于 Vue 2 中的 Watcher 会自动包装和调用传入的函数。

// 作为一个追踪渲染函数的变量,以便知道当前的依赖是谁并进行收集
let activeEffect = null

function effect(fn) {
  const effect = createReactiveEffect(fn)

  effect()

  return effect
}

function createReactiveEffect(fn) {
  const effect = function() {
    activeEffect = effect
    fn()
  }

  return effect
}

这里的 fn() 就类似我们的渲染函数,当它运行时会把当前被包装后的渲染函数记录到 activeEffect。这样如果在渲染函数中访问了我们的响应式对象,我们在 getter() 函数中就会得到通知,此时便可收集依赖。

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver)

      track(target, key)
      return isObject(res) ? reactive(res) : res
    },
  }
  // ...
}

// 最终 targetMap 的数据结果为 { [target]: { [key]: set[] } }
// 表示存储着 target 对象的 key 属性的依赖集合
const targetMap = new WeakMap()

function track(target, key) {
  if (!activeEffect) return

  let depsMap = targetMap.get(target)

  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }

  let dep = depsMap.get(key)

  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }

  dep.add(activeEffect)
}

现在依赖收集就算是完成了,接下来就是要在数据改变的时候进行更新。

# 触发更新

在数据对象变成响应式对象后,数据的变更都会被我们的 setter() 函数所感知到,因此触发更新的操作就是从这里开始的。

function reactive(target) {
  const handler = {
    //...
    set(target, key, value, receiver) {
      const hadKey =
        isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key)
      const result = Reflect.set(target, key, value, receiver)

      if (hadKey) {
        trigger(target, 'set', key, value)
      } else {
        trigger(target, 'add', key, value)
      }

      return result
    },
  }
  //...
}

function isIntegerKey(key) {
  return '' + parseInt(key, 10) === key
}

首先,我们判断本次触发的操作是修改还是新增。对于普通对象而言我们直接用 hasOwnProperty() 方法判断当前对象是否拥有这个属性即可,而对于数组则有些特殊。

如果操作的是像 length 这样的属性的话可以将数组按照普通对象处理,但当操作的属性是索引的时候,我们还需要判断操作的索引是否小于当前数组的长度,如果小于则表示这个索引是已经存在的。

判断好本次操作后,接着便是真正的触发更新了。

function trigger(target, type, key, newValue) {
  const depsMap = targetMap.get(target)
  const effects = depsMap.get(key)

  effects && effects.forEach(effect => effect())
}

目前为止,一个简单的响应式流程就完成了,可以试试下面的例子,页面上的 666 将在一秒后自动变为 999,你也可以继续尝试将 state.des 改为其它值。

const state = reactive({ name: '手艺人', des: 666 })

effect(() => {
  document.body.innerHTML = `${state.name}: ${state.des}`
})

setTimeout(() => {
  state.des = 999
}, 1000)

# 聊聊数组

我们知道 Vue 2 中的响应式系统在数组方面存在一些缺陷。比如,当你修改数组的长度,或者用索引直接设置一个数组项时,Vue 是无法检测到数组的变动的。

Proxy 对象的出现让这些问题迎刃而解,不过目前我们的实现还有所欠缺,比如在下面实例中,页面并不会发生改变。

const state = reactive({
  members: ['x'],
})

effect(() => {
  document.body.innerHTML = state.members[0] || 'y'
})

setTimeout(() => {
  state.members.length = 0
}, 1000)

这是因为在我们函数中并没有访问 length 属性,也就没有收集到任何依赖,但它的改变确实导致了我们引用的第一个元素改变了,因此我们需要对其进行特殊处理。

现在,我们获取依赖不能简单的通过属性去获取,因为如果将 length 属性的值设置为比当前元素个数更小时,依赖那些被删除的元素的函数也应该被再次执行。

function trigger(target, type, key, newValue) {
  const depsMap = targetMap.get(target) // 还记得我们存储依赖的结构吗?下面我们创建 add() 函数来协助完成依赖收集
  const effects = new Set()
  const add = effectsToAdd => {
    effectsToAdd && effectsToAdd.forEach(effect => effects.add(effect))
  }

  if (key === 'length' && isArray(target)) {
    depsMap.forEach((dep, key) => {
      // 如果当前的索引小于 length 的值,相关的依赖也应该被收集
      if (key === 'length' || key >= newValue) {
        add(dep)
      }
    })
  } else {
    add(depsMap.get(key))
  }

  effects && effects.forEach(effect => effect())
}

好像好起来了,但是反过来如果我们通过索引新增了一些元素呢?

const state = reactive({
  members: ['x'],
})

effect(() => {
  document.body.innerHTML = state.members.length
})

setTimeout(() => {
  state.members[9] = 'y'
}, 1000)

此时的 length 也会发生改变,所以我们应该讲 length 相关的依赖也添加进去。

function trigger(target, type, key, newValue) {
  //  ...

  if (key === 'length' && isArray(target)) {
    // ...
  } else {
    add(depsMap.get(key))

    switch (type) {
      case 'set':
        add(depsMap.get('length'))
    }
  }

  effects && effects.forEach(effect => effect())
}

好了,响应式系统就先聊到这吧。

# 总结

简单回顾一下,Vue 中的响应式系统都是基于观察者模式实现的,其中观察者的添加和将变化通知观察者的操作皆由带有 gettersetter 的处理程序自动处理。

在初始化时,Vue 先对组件的数据对象进行数据劫持,然后在后续的渲染当中完成依赖收集,最后在数据发生变化时触发页面更新。

reactive

事实上,上面我们已经基于这个思路实现了一个简版的响应式系统。