万字细说 Vue3 响应式原理(上) | reactive 与 watch 实现

1,732 阅读12分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

前言

我们知道,Vue3 响应式相关的主要有 4 个 api:ractive、watch、ref、conputed,我们会逐个将它们实现

由于内容较多,分上下两篇

  • 上篇介绍并实现 ractive 与 watch
  • 下篇实现 ref 与 conputed,并分析其中的一些优化

什么是响应式

上手之前,咱们先谈谈什么是响应式

我们有事件A与事件B,我们希望执行完事件A后,事件B能够自动执行,这便是响应式

以 Vue 的响应式举例,事件A就是 ractive 等数据的改变,而事件B是我们在 watch 中定义调度函数的执行

还有一种是CSS布局的响应式,与前者也同理,页面/容器大小的变化(事件A),引起元素样式改变(事件B)

而我们今天要实现的,就是 ractive 与 watch 的响应式功能

基础响应式实现

我们先根据一个简单的情况,实现一个基础的响应式,然后再不断改善,增强扩展其功能

明确目标

首先要知道,reactive 和 watch 要一起使用才有效,光调用 reactive 只是获取了一个 Proxy 代理对象,得用 watch 注册调度函数,才能在数据修改后体会到响应式的效果

然后我们明确要实现的功能

const obj = reactive({
  a: 1,
})

watch(
  () => obj.a,
  (now, pre) => {
    console.log(`obj.a从${pre}改变为${now}`)
  }
)

obj.a = 2 // 控制台自动打印:obj.a从1改变为2

代码实现

基础 reactive

我们知道 reactive 用法是传入一个对象,用 Proxy 包装后返回

其中 Proxy 可以拦截数据的读写,所以基础代码如下

const handler = {
  get(target, key) {
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    return Reflect.get(target, key, value)
  },
}
function reactive(obj) {
  const p = new Proxy(obj, handler)
  return p
}

基础 watch

而 watch 接受两个参数,数据源与调度函数,其中数据源也要以一个函数的形式传递

function watch(getter, callback) {}

二者结合

接下来,就要将两个函数结合

在 watch 中,我们有数据源函数,可以进行数据的访问,而数据的访问会被 getter 拦截,我们可以通过全局变量的形式将 callback 公开,getter 函数中就可以将此调度函数保存起来,在触发 setter 时访问函数并调用

let activeEffect // 全局变量

function watch(getter, callback) {
  activeEffect = callback
  getter() // 触发数据源的读取拦截器
  activeEffect = undefined
}

下一步,getter 中要如何保存这一函数,在 setter 中如何调用呢?来看看 Vue 的做法

  • 首先,项目中可能使用许多响应式对象,所以用 targetMap 存储,其类型为 WeakMap,键为对象
  • 其次,每个对象可能有许多属性,所以 targetMap 的值是一个 keyMap,其类型为 Map
  • 然后,对象的属性可能绑定很多调度函数,所以 keyMap 的值是 dep,类型为集合
  • dep 中存储的就是一个个 callback 调度函数

代码如下

const targetMap = new WeakMap()
const handler = {
  get(target, key) {
    if (activeEffect) {
      let keyMap = targetMap.get(target)
      if (!keyMap) {
        keyMap = new Map()
        targetMap.set(target, keyMap)
      }
      let dep = keyMap.get(key)
      if (!dep) {
        dep = new Set()
        keyMap.set(key, dep)
      }
      dep.add(activeEffect) // 存储调度函数
    }
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    let oldValue = target[key]
    // 先赋值,再触发函数
    const result = Reflect.set(target, key, value)
    if (value !== oldValue) {
      const keyMap = targetMap.get(target)
      if (!keyMap) return // 可能未读取过就直接赋值
      const dep = keyMap.get(key)
      for (const job of dep) {
        job(value, oldValue)
      }
    }
    return result
  },
}

用一张图展示它们间的关系

image.png

至此,基础响应式的功能就已经实现了,完整代码及测试结果如下

代码分离

我们已经实现了基础的响应式,但距离 Vue 的还有不小的差距。我们还有很多问题都没有处理,但是所有代码都写在一个函数中不易阅读也不易维护,所以先进行代码分离。

本节只进行了代码分离,功能没有变化,各函数逻辑可在下一节的逻辑图体现

const targetMap = new WeakMap()
let activeEffect

const get = createGetter()
const set = createSetter()
// 创建getter
function createGetter() {
  return function (target, key) {
    track(target, key)
    return Reflect.get(target, key)
  }
}
// 创建setter
function createSetter() {
  return function (target, key, value) {
    let oldValue = target[key]
    const result = Reflect.set(target, key, value)
    if (value !== oldValue) {
      trigger(target, key)
    }
    return result
  }
}
// 收集依赖
function track(target, key) {
  if (activeEffect) {
    let keyMap = targetMap.get(target)
    if (!keyMap) {
      keyMap = new Map()
      targetMap.set(target, keyMap)
    }
    let dep = keyMap.get(key)
    if (!dep) {
      dep = createDep()
      keyMap.set(key, dep)
    }
    trackEffects(dep) // 存储调度函数
  }
}
function trackEffects(dep) {
  dep.add(activeEffect)
}
// 触发依赖
function trigger(target, key) {
  const keyMap = targetMap.get(target)
  if (!keyMap) return
  const dep = keyMap.get(key)
  triggerEffects(dep) // 触发调度函数
}
function triggerEffects(dep) {
  for (const job of dep) {
    job()
  }
}
// 创建dep,后续优化时使用
function createDep() {
  const dep = new Set()
  return dep
}

const handler = { get, set }
function reactive(obj) {
  const p = new Proxy(obj, handler)
  return p
}
// 将传参功能移到了watch中
function watch(getter, callback) {
  let oldValue
  const job = () => {
    const newValue = getter()
    callback(newValue, oldValue)
    oldValue = newValue
  }
  activeEffect = job
  oldValue = getter()
  activeEffect = undefined
}

有些函数的分离目前来看可能多此一举,但它们将会在实现 ref 与 computed 时复用

ReactiveEffect

在源码中有一个 ReactiveEffect 类,封装了依赖收集的过程

假如我们在 watch 的 getter 里又调用 watch,这样前一个 activeEffect 就会被覆盖,无法正确注册调度函数。

虽然我们实际开发中不会这样,目前来看还没什么用,但在后续处理 computed 的嵌套与优化时都需要用到它,为了避免以后大篇幅地修改,趁着刚开始就使用这个类吧

class ReactiveEffect {
  constructor(fn, scheduler) {
    this.fn = fn // 收集依赖的函数
    this.scheduler = scheduler // 调度程序
    this.deps = [] // 存储的是dep
    this.parent = undefined // 用于保存前一个 activeEffect
  }
  // 收集依赖
  run() {
    // 值的访问过程可能出错,所以采用 try finally 的形式
    try {
      // 保存之前的 effect
      this.parent = activeEffect
      activeEffect = this
      return this.fn()
    } finally {
      // 处理完毕 恢复之前的 effect
      activeEffect = this.parent
      this.parent = undefined
    }
  }
  // 停止作用,从所有dep中删除此effect
  stop() {
    const { deps } = this
    if (deps.length) {
      for (let i = 0; i < deps.length; i++) {
        deps[i].delete(this)
      }
      deps.length = 0
    }
  }
}

跟着修改一下 watch、trackEffects、triggerEffects中的代码

function watch(getter, callback) {
  let oldValue
  const job = () => {
    const newValue = effect.run()
    callback(newValue, oldValue)
    oldValue = newValue
  }
  const effect = new ReactiveEffect(getter, job)
  oldValue = effect.run() // 收集依赖 维护老值
  // 返回一个可以取消监听的函数
  return () => {
    effect.stop()
  }
}
function trackEffects(dep) {
  dep.add(activeEffect)
  activeEffect.deps.push(dep) // 双向添加
}
function triggerEffects(dep) {
  for (const effect of dep) {
    effect.scheduler() // 执行调度函数
  }
}

最终各部分关系是这样

image.png

reactive 功能增强

接下来让我们一步步增强基础响应式的功能

处理多层对象

当 reactive 中有多层对象时,每层都需要使用 Proxy 代理,不过这一过程是在真正访问时才进行

实现也很简单,递归就好了,主要修改 createGetter 和 reactive 函数

const isObject = (val) => val !== null && typeof val === 'object'

function createGetter() {
  return function (target, key) {
    track(target, key)
    const res = Reflect.get(target, key)
    return reactive(res) // 将返回值用reactive包裹
  }
}
const reactiveMap = new WeakMap() // 创建的代理对象保存下来,避免重复创建
function reactive(obj) {
  if (!isObject(obj)) return obj // 不是对象直接返回
  let p = reactiveMap.get(obj)
  if (!p) {
    p = new Proxy(obj, handler)
    reactiveMap.set(obj, p)
  }
  return p
}

拦截更多操作

Vue 能拦截的不止读写,还有属性的检测、遍历与删除,对应三个拦截器函数

拦截 has 操作很简单,和直接访问属性一样

function has(target, key) {
  const result = Reflect.has(target, key)
  track(target, key)
  return result
}

拦截遍历操作时,要用一个特殊的 key 来注册依赖,表示迭代器

const ITERATE_KEY = Symbol('iterate') // 迭代器
function ownKeys(target) {
  track(target, ITERATE_KEY)
  return Reflect.ownKeys(target)
}

这里不跟踪具体属性是因为:用户通过遍历操作获取了元素的属性名,如有需要,自然会访问其属性值,在访问属性值的时候还会 track,这里只需要追踪迭代器就好了;而如果使用的只是属性名的话,能修改对象属性名的也就只有删除操作,让它触发迭代器就好了。

删除属性是一个写操作,要触发依赖。这里要给 trigger 传了第三个参数,因为删除操作不仅要触发 key 本身的依赖,还影响了迭代器

function deleteProperty(target, key) {
  const hadKey = target.hasOwnProperty(key)
  const result = Reflect.deleteProperty(target, key)
  // 存在此属性且删除成功,触发依赖
  if (result && hadKey) {
    trigger(target, key, 'delete')
  }
  return result
}

function trigger(target, key, type) {
  const keyMap = targetMap.get(target)
  if (!keyMap) return
  const deps = []
  deps.push(keyMap.get(key)) // 属性自身的依赖

  switch (type) {
    case 'delete':
      // 删除操作格外触发迭代器依赖
      deps.push(keyMap.get(ITERATE_KEY))
      break
  }
  
  if (deps.length == 0) {
    triggerEffects(deps[0])
  } else {
    const effects = [] // 汇总 effect
    for (const dep of deps) {
      if (dep) {
        effects.push(...dep)
      }
    }
    triggerEffects(createDep(effects))
  }
}
// 多添一个参数
function createDep(effects) {
  const dep = new Set(effects)
  return dep
}

本文相较于源码,将参数 key 与 type 的位置互换了,但不影响功能实现

逻辑图如下: image.png

处理数组

思来想去还是把这部分讲了把,V3 的数组处理不比 V2 简单

因为数组的方法会触发很多次 get 与 set,详情可看我之前这篇文章,而且数组又要维护 length 这一特殊变量

拦截数组方法

Vue 中的处理也很粗暴,只要是查值类的方法,直接追踪所有元素,而改值类的方法,不追踪依赖,只触发扳机

说的很简单,但代码实现又是一堆,首先提供一个得到代理对象的原对象的方法 toRaw

toRaw 实现很简单,定义一个特殊属性就好

function createGetter() {
  return function (target, key) {
    // 使 toRaw 返回原对象
    if (key === '__v_raw') {
      return target
    }
    track(target, key)
    const res = Reflect.get(target, key)
    return reactive(res) // 将返回值用reactive包裹
  }
}
// 获取代理的原对象
// 递归是为了处理多层代理嵌套的极端情况,本文其实并不需要
function toRaw(observed) {
  const raw = observed && observed['__v_raw']
  return raw ? toRaw(raw) : observed
}

然后在定义控制是否收集依赖的变量和方法

let shouldTrack = true
const trackStack = [] // 可能收集依赖时多层嵌套,用栈存储
function pauseTracking() { // 停止收集依赖
  trackStack.push(shouldTrack)
  shouldTrack = false
}
function resetTracking() { // 恢复依赖收集
  shouldTrack = trackStack.pop()
}
// 将其加入收集依赖函数中
function track(target, key) {
  if (shouldTrack && activeEffect) {
    ……
  }
}

接下来就是重写数组的方法,并在 getter 中返回

function createGetter() {
  return function (target, key) {
    // 读取的是数组方法,返回已封装的方法
    if (isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
      return Reflect.get(arrayInstrumentations, key)
    }
    ……
  }
}

// 重写数组方法
const arrayInstrumentations = createArrayInstrumentations()
function createArrayInstrumentations() {
  const instrumentations = {}
  // 取值的方法
  ;['includes', 'indexOf', 'lastIndexOf'].forEach((key) => {
    instrumentations[key] = function (...args) {
      // 通过this获取调用此方法的数组对象
      const arr = toRaw(this)
      // 直接追踪全部索引
      for (let i = 0, l = this.length; i < l; i++) {
        track(arr, i + '')
      }
      // 然后用执行原生方法,使用“原值”和“代理值”分别执行一遍,获取结果
      const res = arr[key](...args)
      if (res === -1 || res === false) {
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })
  // 写值的方法
  ;['push', 'pop', 'shift', 'unshift', 'splice'].forEach((key) => {
    instrumentations[key] = function (...args) {
      pauseTracking() // 暂停依赖跟踪
      // 调用原生方法,但是改变this指向代理对象
      // 期间会有写值的操作,一样会触发扳机
      const res = toRaw(this)[key].apply(this, args) 
      resetTracking() // 恢复依赖收集
      return res
    }
  })
  return instrumentations // 返回数组方法对象
}

这便是 Vue 针对数组方法的处理了

维护 length

监控 length

除了直接访问 length 属性外,遍历数组时也会用到

所以我们将遍历操作的拦截器修改一下,如果是数组的话,则跟踪的 key 是 length

const isArray = Array.isArray
function ownKeys(target) {
  track(target, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

触发 length

首先在 setter 中,如果拦截的是数组而且修改的是数字索引,要判断一下会不会修改 length

// 判断是否为索引数字的函数
const isIntegerKey = (key) => key !== 'NaN' && key[0] !== '-' && '' + parseInt(key) === key

function createSetter() {
  return function (target, key, value) {
    let oldValue = target[key]
    const result = Reflect.set(target, key, value)
    // 判断length是否改变
    const changeLength = isArray(target) && isIntegerKey(key) && Number(key) >= target.length
    if (value !== oldValue) {
      trigger(target, key, changeLength ? 'add' : '')
    }
    return result
  }
}

然后在 trigger 中添加 length 扳机的触发

function trigger(target, key, type) {
  const keyMap = targetMap.get(target)
  if (!keyMap) return
  const deps = []
  deps.push(keyMap.get(key)) // 属性自身的依赖

  switch (type) {
    case 'delete':
      // 删除操作格外触发迭代器依赖
      deps.push(keyMap.get(ITERATE_KEY))
      break
    case 'add':
      // 修改了length
      deps.push(keyMap.get('length'))
      break
  }
  if (deps.length == 0) {
    triggerEffects(deps[0])
  } else {
    const effects = []
    for (const dep of deps) {
      if (dep) {
        effects.push(...dep)
      }
    }
    triggerEffects(createDep(effects))
  }
}

至此,数组处理完毕,逻辑图如下:

image.png

watch 功能增强

相比于 reactive 这改一点哪改一点,watch 的增强就简单一些,只需要实现一些配置项就好了

我们知道,Vue 的 watch 方法可以传递第三个参数,包含 immediate、deep、flush 配置项

immediate

给 watch 传递 immediate 选项,能使调度函数立刻执行一次,实现起来也很简单,一个判断语句搞定

function watch(getter, callback, { immediate } = {}) {
  let oldValue
  const job = () => {
    const newValue = effect.run()
    callback(newValue, oldValue)
    oldValue = newValue
  }
  const effect = new ReactiveEffect(getter, job)
  if (immediate) {
    job()
  } else {
    oldValue = effect.run()
  }
  // 返回一个可以取消监听的函数
  return () => {
    effect.stop()
  }
}

deep

给 watch 传递 deep 选项,表示开启深度监听

当要监视的属性值是一个对象时,watch 不仅会监视该属性的变化,还会监视其属性值对象内部属性的变化

这要求我们在收集依赖时,不仅仅是执行用户传来的 getter,还要在 getter 返回值是一个对象时,递归访问其属性

function watch(getter, callback, { immediate, deep } = {}) {
  // 修改 getter 函数
  if (deep) {
    const baseGetter = getter // 保存之前的 getter
    getter = () => traverse(baseGetter()) // 深度遍历对象
  }
  ……
}

// 深度遍历对象
function traverse(value, seen = new Set()) {
  // 不是对象就返回
  if (!isObject(value)) {
    return value
  }
  // 处理循环引用
  if (seen.has(value)) {
    return value
  }
  seen.add(value)
  // 数组的话就遍历每一项
  if (isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      traverse(value[i], seen)
    }
  } else if (value) {
  // 对象的话就遍历所有属性,读取属性值
    for (const key in value) {
      traverse(value[key], seen)
    }
  }
  return value
}

flush

watch 的 flush 配置项,是设置该监听器调度函数的执行是同步还是异步的,有三个可选项:

  • sync:调度函数同步执行
  • pre:调度函数异步执行,默认项
  • post:也是异步执行,用来实现异步组件的功能,本文并不涉及

而我们之前实现的 watch 是 flush: 'sync' 的版本,接下来我们实现调度函数的异步执行

异步执行与同步执行的区别在于,同步执行会立刻执行调度函数,而异步执行会先将调度函数存储起来,当同步代码执行完成后,再统一执行。好处在于减少了执行次数,只以最终值为准

实现方式就是当监听到数据源改变,立刻将调度函数推入队列中,利用 promise 来创建微任务,异步执行

watch 中就只需要改一下回调函数,将同步执行改为使用 queuePreFlushCb 异步执行

function watch(getter, callback, { immediate, deep, flush } = {}) {
  ……
  let scheduler
  if (flush == 'sync') {
    scheduler = job // 同步执行
  } else {
    // 默认是 pre
    scheduler = () => {
      queuePreFlushCb(job) // 异步执行
    }
  }
  const effect = new ReactiveEffect(getter, scheduler)
  ……
}

queuePreFlushCb 的实现还是较复杂的,先展示代码,再进行讲解

const pendingPreFlushCbs = [] // 等待异步执行的函数队列,简称等待队列
let activePreFlushCbs = null // 正在同步执行的函数队列,简称执行队列
let preFlushIndex = 0 // 执行队列的索引
const resolvedPromise = Promise.resolve() // 已解决的 promise 用于创建微任务
let currentFlushPromise = null // 当前正在执行的 promise

function queuePreFlushCb(cb) {
  // 没有执行队列或执行队列中不包含此函数
  if (!activePreFlushCbs || !activePreFlushCbs.includes(cb, preFlushIndex + 1)) {
    pendingPreFlushCbs.push(cb)
  }
  if (!currentFlushPromise) {
    currentFlushPromise = resolvedPromise.then(flushPreFlushCbs)
  }
}
function flushPreFlushCbs() {
  if (pendingPreFlushCbs.length) {
    // 等待函数队列去重
    activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
    pendingPreFlushCbs.length = 0 // 清空等待队列

    for (preFlushIndex = 0; preFlushIndex < activePreFlushCbs.length; preFlushIndex++) {
      // 函数执行过程中,可能会往等待队列添加新函数
      activePreFlushCbs[preFlushIndex]()
    }

    activePreFlushCbs = null
    preFlushIndex = 0
    flushPreFlushCbs() // 递归执行,直至清空所有调度函数
  } else {
    currentFlushPromise = null // 置空当前正在执行的 promise
  }
}

开始定义一堆变量,变量含义在注释中也已经表明了

queuePreFlushCb 函数负责将调度函数推入等待队列中,并注册一个 promise,让其异步执行 flushPreFlushCbs 函数。

而推入队列的条件 !activePreFlushCbs || !activePreFlushCbs.includes(cb, preFlushIndex + 1) 意思就是正在执行的函数队列为空(无调度函数正在执行)或者从执行队列的当前索引开始,没有找到要推入的函数(将来不会执行),就将此函数推入等待队列中。

includes(cb, preFlushIndex + 1) 的判断条件还有隐层含义,表示允许调度函数递归执行自身的,如果去掉 +1 的话,就表示不允许其递归执行,我们以允许递归的形式实现。(Vue 的模板更新函数就是不允许递归执行自身的)

flushPreFlushCbs 函数负责清空等待队列中的调度函数

先将等待队列中的函数去重,赋给执行队列,然后遍历执行

因为调度函数执行过程中可能会改变响应式数据,触发 watch,往等待队列中添加新的函数

所以在末尾又递归执行了 flushPreFlushCbs,直至等待队列中无任务。

逻辑图如下:

image.png

在 Vue 的实现中,还使用了一个 Map 保存了在一次微任务执行过程中每个调度函数的执行次数,每个函数最多递归执行 100 次,所以我们也加一下吧

function flushPreFlushCbs(seen = new Map()) {
  if (pendingPreFlushCbs.length) {
    // 等待函数队列去重
    activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
    pendingPreFlushCbs.length = 0 // 清空等待队列

    for (preFlushIndex = 0; preFlushIndex < activePreFlushCbs.length; preFlushIndex++) {
      // 函数执行过程中,可能会往等待队列添加新函数
      const job = activePreFlushCbs[preFlushIndex]
      const count = seen.get(job)
      // 每个函数最多递归执行100次
      if (!(count > 100)) {
        seen.set(job, count + 1 || 1)
        job()
      }
    }

    activePreFlushCbs = null
    preFlushIndex = 0
    flushPreFlushCbs() // 递归执行,直至清空所有调度函数
  } else {
    currentFlushPromise = null // 置空当前正在执行的 promise
  }
}

总结

至此,reactive 和 watch 的功能就实现完了

为方便讲解,相较于 Vue 源码有部分改动,但功能实现完全一致,完整代码如下

还是有一部分内容没有实现的,就是对 Set 与 Map 类型对象的处理,但是本文已经很长了,相关内容在我的这篇文章中有所讲解,同学们如有需要,后续再补上这部分功能的代码实现。

结语

下篇已更新,实现了 ref 和 computed

如果喜欢或有所帮助的话,希望能点赞关注,鼓励一下作者。

如果文章有不正确或存疑的地方,欢迎评论指出。