Skip to content

怎样手写一个响应式系统

Vue 最标志性的功能就是其低侵入性的响应式系统。组件状态都是由响应式的 JavaScript 对象组成的。当更改它们时,视图会随即自动更新。这让状态管理更加简单直观,但理解它是如何工作的也是很重要的,这可以帮助我们避免一些常见的陷阱。

理解的最快方法就是自己动手实现一边,今天我们就来手写一个迷你的响应式系统。

与文章配套的例子源码在这里:an-example-of-how-to-write-a-reactivity-system

响应式

根据响应式组件通知效果可以知道,响应式机制的主要功能就是,可以把普通的 JavaScript 对象封装成为响应式对象,拦截数据的获取和修改操作,实现依赖数据的自动化更新

所以,一个最简单的响应式模型,我们可以通过 reactive 或者 ref 函数,把数据包裹成响应式对象,并且通过 effect 函数注册回调函数,然后在数据修改之后,响应式地通知 effect 去执行回调函数即可。

光这么说可能还不太好理解,我们可以通过一个简单的小例子来直观的感受下响应式的效果。

Vue 的响应式是可以独立在其他平台使用的。比如你可以新建 test.js,使用下面的代码在 node 环境中使用 Vue 响应。以 reactive 为例,我们使用 reactive 包裹 JavaScript 对象之后,每一次对响应式对象 counter 的修改,都会执行 effect 内部注册的函数:

js
// test.js
const { reactive, effect } = require('@vue/reactivity')

let dummy
const counter = reactive({ num1: 1, num2: 2 })
effect(() => {
  dummy = counter.num1 + counter.num2
  console.log(dummy)
})
setInterval(() => {
  counter.num2++
}, 1000)

执行 node test.js 之后,你就可以看到 effect 内部的函数会一直调用,每次 count.value 修改之后都会执行。

看到这个 API 估计你有点疑惑,effect 内部的函数式如何知道 count 已经变化了呢?

我们先来看一下响应式整体的流程图,上面的代码中我们使用 reactive 把普通的 JavaScript 对象包裹成响应式数据了。

所以,在 effect 中获取 counter.num1 和 counter.num2 的时候,就会触发 counter 的 get 拦截函数;get 函数,会把当前的 effect 函数注册到一个全局的依赖地图中去。这样 counter.num1 在修改的时候,就会触发 set 拦截函数,去依赖地图中找到注册的 effect 函数,然后执行

响应式整体流程图

具体是怎么实现的呢?我们从第一步把数据包裹成响应式对象开始。先看 reactive 的实现。

reactive

基于测试先行的开发思想,我们先来写一个测试用例,然后再去实现 reactive 的功能。

我们安装 ava 测试工具,然后在 test.js 中写一个测试用例,使用下面代码测试 reactive 的功能,能够在响应式数据 ret 更新之后,执行 effect 中注册的函数:

js
// test.js
import test from 'ava'
import { reactive, effect } from '@vue/reactivity'

test('test', async (t) => {
  const ret = reactive({ num: 0 })
  let val
  effect(() => {
    val = ret.num
  })
  t.is(val, 0)
  ret.num++
  t.is(val, 1)
  ret.num++
  t.is(val, 2)
})

执行 npm run test,可以看到测试用例执行通过了,然后我们就可以开始实现 reactive 了。

之前讲过在 Vue3 中,reactive 是通过 ES6 中的 Proxy 特性实现的属性拦截,所以,在 reactive 函数中我们直接返回 newProxy 即可:

js
// reactive.js
export function reactive(target) {
  if (typeof target !== 'object') {
    console.warn(`reactive  ${target} 必须是一个对象`)
    return target
  }

  return new Proxy(target, mutableHandles)
}

可以看到,下一步我们需要实现的就是 Proxy 中的处理方法 mutableHandles

这里会把 Proxy 的代理配置抽离出来单独维护,是因为,其实 Vue3 中除了 reactive 还有很多别的函数需要实现,比如只读的响应式数据、浅层代理的响应式数据等,并且 reactive 中针对 ES6 的代理也需要单独的处理。

这里我们只处理 js 中对象的代理设置。

mutableHandles

好,看回来,我们剖析 mutableHandles。它要做的事就是配置 Proxy 的拦截函数,这里我们只拦截 get 和 set 操作,进入到 baseHandlers.js 文件中。

我们使用 createGetter 和 createSetters 来创建 set 和 get 函数,mutableHandles 就是配置了 set 和 get 的对象返回。

  • get 中直接返回读取的数据,这里的 Reflect.get 和 target[key]实现的结果是一致的;并且返回值是对象的话,还会嵌套执行 reactive,并且调用 track 函数收集依赖。
  • set 中调用 trigger 函数,执行 track 收集的依赖。
js
// mutableHandles.js

const get = createGetter()
const set = createSetter()

function createGetter(shallow = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    track(target, 'get', key)
    if (isObject(res)) {
      // 值也是对象的话,需要嵌套调用reactive
      // res就是target[key]
      // 浅层代理,不需要嵌套
      return shallow ? res : reactive(res)
    }
    return res
  }
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    // 在触发 set 的时候进行触发依赖
    trigger(target, 'set', key)
    return result
  }
}
export const mutableHandles = {
  get,
  set,
}

我们先看 get 的关键部分,track 函数是怎么完成依赖收集的。

track

具体写代码之前,把依赖收集和执行的原理我们梳理清楚,看下面的示意图:

依赖收集和执行的原理

在 track 函数中,我们可以使用一个巨大的 targetMap 去存储依赖关系。map 的 key 是我们要代理的 target 对象,值还是一个 depsMap,存储这每一个 key 依赖的函数,每一个 key 都可以依赖多个 effect。上面的代码执行完成,depsMap 中就有了 num1 和 num2 两个依赖。

而依赖地图的格式,用代码描述如下:

js
targetMap = {
 target: {
   key1: [回调函数1,回调函数2],
   key2: [回调函数3,回调函数4],
 },
  target1: {
   key3: [回调函数5]
 }

}

好,有了大的设计思路,我们来进行具体的实现,在 reactive 下新建 effect.js。

由于 target 是对象,所以必须得用 map 才可以把 target 作为 key 来管理数据,每次操作之前需要做非空的判断。最终把全局变量 activeEffect 存储在集合之中,至于 activeEffect 是什么,先将它理解为正在执行的 effect 函数就好:

js
// effect.js
const targetMap = new WeakMap()

let activeEffect = null

export function track(target, type, key) {
  // console.log(`触发 track -> target: ${target} type:${type} key:${key}`)

  // 1. 先基于 target 找到对应的 dep
  // 如果是第一次的话,那么就需要初始化
  // {
  //   target1: {//depsmap
  //     key:[effect1,effect2]
  //   }
  // }
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 初始化 depsMap 的逻辑
    // depsMap = new Map()
    // targetMap.set(target, depsMap)
    // 上面两行可以简写成下面的
    targetMap.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
  }
  if (!deps.has(activeEffect) && activeEffect) {
    // 防止重复注册
    deps.add(activeEffect)
  }
  depsMap.set(key, deps)
}

get 中关键的收集依赖的 track 函数我们已经讲完了,继续看 set 中关键的 trigger 函数。

trigger

有了上面 targetMap 的实现机制,trigger 函数实现的思路就是从 targetMap 中,根据 target 和 key 找到对应的依赖函数集合 deps,然后遍历 deps 执行依赖函数

看实现的代码:

js
export function trigger(target, type, key) {
  // console.log(`触发 trigger -> target:  type:${type} key:${key}`)
  // 从targetMap中找到触发的函数,执行他
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // 没找到依赖
    return
  }
  const deps = depsMap.get(key)
  if (!deps) {
    return
  }
  deps.forEach((effectFn) => {
    if (effectFn.scheduler) {
      effectFn.scheduler()
    } else {
      effectFn()
    }
  })
}

可以看到执行的是 effect 的 scheduler 或者 run 函数,这是因为我们需要在 effect 函数中把依赖函数进行包装,并对依赖函数的执行时机进行控制,这是一个小的设计点。

effect

然后我们来实现 effect 函数。

下面的代码中,我们把传递进来的 fn 函数通过 effectFn 函数包裹执行,在 effectFn 函数内部,把函数赋值给全局变量 activeEffect;然后执行 fn() 的时候,就会触发响应式对象的 get 函数,get 函数内部就会把全局变量 activeEffect 所指向的函数,也就是此时正在执行的 effectFn 存储到依赖地图中,完成依赖的收集:

js
export function effect(fn, options = {}) {
  // effect嵌套,通过队列管理
  const effectFn = () => {
    try {
      activeEffect = effectFn
      //fn执行的时候,内部读取响应式数据的时候,就能在get配置里读取到activeEffect
      return fn()
    } finally {
      activeEffect = null
    }
  }
  if (!options.lazy) {
    //没有配置lazy 直接执行
    effectFn()
  }
  effectFn.scheduler = options.scheduler // 调度时机 watchEffect 会用到
  return effectFn
}

effect 传递的函数,比如可以通过传递 lazy 和 scheduler 来控制函数执行的时机,默认是同步执行。

scheduler 存在的意义就是我们可以手动控制函数执行的时机,方便应对一些性能优化的场景,比如数据在一次交互中可能会被修改很多次,我们不想每次修改都重新执行依次 effect 函数,而是合并最终的状态之后,最后统一修改一次。

scheduler 怎么用你可以看下面的代码,我们使用数组管理传递的执行任务,最后使用 Promise.resolve 只执行最后一次,这也是 Vue 中 watchEffect 函数的大致原理。

js
const obj = reactive({ count: 1 })
effect(
  () => {
    console.log(obj.count)
  },
  {
    // 指定调度器为 queueJob
    scheduler: queueJob,
  },
)
// 调度器实现
const queue: Function[] = []
let isFlushing = false
function queueJob(job: () => void) {
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(() => {
      let fn
      while ((fn = queue.shift())) {
        fn()
      }
    })
  }
}

好了,绕了这么一大圈终于执行完了函数,估计你也看出来了封装了很多层。

之所以封装这么多层就是因为,Vue 的响应式本身有很多的横向扩展,除了响应式的封装,还有只读的拦截、浅层数据的拦截等等,这样,响应式系统本身也变得更加灵活和易于扩展,我们自己在设计公用函数的时候也可以借鉴类似的思路。

现在你就可以在 test.js 中测试一下我们手写的 reactive 的功能了,将其中的 reactive 和 effect 函数替换成我们自己实现的函数,然后执行 node test.js,可以看到测试用例执行通过了。

另一个选择 ref 函数

有了 track 和 trigger 的逻辑之后,我们用 ref 函数实现就变得非常简单了。

ref 的执行逻辑要比 reactive 要简单一些,不需要使用 Proxy 代理语法,直接使用对象语法的 getter 和 setter 配置,监听 value 属性即可。

看下面的实现,在 ref 函数返回的对象中,对象的 get value 方法,使用 track 函数去收集依赖,set value 方法中使用 trigger 函数去触发函数的执行。

js
export function ref(val) {
  if (isRef(val)) {
    return val
  }
  return new RefImpl(val)
}
export function isRef(val) {
  return !!(val && val.__isRef)
}

// ref就是利用面向对象的getter和setters进行track和trigget
class RefImpl {
  constructor(val) {
    this.__isRef = true
    this._val = convert(val)
  }
  get value() {
    track(this, 'value')
    return this._val
  }

  set value(val) {
    if (val !== this._val) {
      this._val = convert(val)
      trigger(this, 'value')
    }
  }
}

// ref也可以支持复杂数据结构
function convert(val) {
  return isObject(val) ? reactive(val) : val
}

你能很直观地看到,ref 函数实现的相对简单很多,只是利用面向对象的 getter 和 setter 拦截了 value 属性的读写,这也是为什么我们需要操作 ref 对象的 value 属性的原因。

值得一提的是,ref 也可以包裹复杂的数据结构,内部会直接调用 reactive 来实现,这也解决了大部分同学对 ref 和 reactive 使用时机的疑惑,现在你可以全部都用 ref 函数,ref 内部会帮你调用 reactive。

computed

Vue 中的 computed 计算属性也是一种特殊的 effect 函数,我们可以新建 computed.spec.js 来测试 computed 函数的功能,computed 可以传递一个函数或者对象,实现计算属性的读取和修改。比如说可以这么用:

js
test('test computed', async (t) => {
  // computed 基本使用
  const ret = reactive({ count: 1 })
  const num = ref(2)
  const sum = computed(() => num.value + ret.count)
  t.is(sum.value, 3)

  ret.count++
  t.is(sum.value, 4)
  num.value = 10
  t.is(sum.value, 12)

  // computed 属性修改
  const author = ref('Lucas Liu')
  const course = ref('How to use computed')
  const title = computed({
    get() {
      return author.value + ':' + course.value
    },
    set(val) {
      ;[author.value, course.value] = val.split(':')
    },
  })
  t.is(title.value, 'Lucas Liu:How to use computed')

  author.value = 'someone'
  course.value = 'something'
  t.is(title.value, 'someone:something')

  // 计算属性赋值
  title.value = 'Lucas Liu:How to use computed'
  t.is(author.value, 'Lucas Liu')
  t.is(course.value, 'How to use computed')
})

怎么实现呢?我们新建 computed 函数,看下面的代码,我们拦截 computed 的 value 属性,并且定制了 effect 的 lazy 和 scheduler 配置,computed 注册的函数就不会直接执行,而是要通过 scheduler 函数中对 _dirty 属性决定是否执行。

js
export function computed(getterOrOptions) {
  // getterOrOptions可以是函数,也可以是一个对象,支持get和set
  // 还记得清单应用里的全选checkbox就是一个对象配置的computed
  let getter, setter
  if (typeof getterOrOptions === 'function') {
    getter = getterOrOptions
    setter = () => {
      console.warn('计算属性不能修改')
    }
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  return new ComputedRefImpl(getter, setter)
}
class ComputedRefImpl {
  constructor(getter, setter) {
    this._setter = setter
    this._val = undefined
    this._dirty = true
    // computed就是一个特殊的effect,设置lazy和执行时机
    this.effect = effect(getter, {
      lazy: true,
      scheduler: () => {
        if (!this._dirty) {
          this._dirty = true
          trigger(this, 'value')
        }
      },
    })
  }
  get value() {
    track(this, 'value')
    if (this._dirty) {
      this._dirty = false
      this._val = this.effect()
    }
    return this._val
  }
  set value(val) {
    this._setter(val)
  }
}

总结

最后我们来回顾一下今天学到的内容。通过手写迷你的响应式原型,我们学习了 Vue 中响应式的地位和架构。

响应式的主要功能就是可以把普通的 JavaScript 对象封装成为响应式对象,在读取数据的时候通过 track 收集函数的依赖关系,把整个对象和 effect 注册函数的依赖关系全部存储在一个依赖图中

定义的 dependsMap 是一个巨大的 Map 数据,effect 函数内部读取的数据都会存储在 dependsMap 中,数据在修改的时候,通过查询 dependsMap,获得需要执行的函数,再去执行即可。

dependsMap 中存储的也不是直接存储 effect 中传递的函数,而是包装了一层对象对这个函数的执行实际进行管理,内部可以通过 active 管理执行状态,还可以通过全局变量 shouldTrack 控制监听状态,并且执行的方式也是判断 scheduler 和 run 方法,实现了对性能的提升。

我们在日常项目开发中也可以借鉴响应式的处理思路,使用通知的机制,来调用具体数据的操作和更新逻辑,灵活使用 effect、ref、reactive 等函数把常见的操作全部变成响应式数据处理,会极大的提高我们开发的体验和效率。