什么是响应式数据与副作用函数

响应式数据是指当数据发生变化时,能够自动触发相关操作的数据。副作用函数则是可能影响其他代码执行的函数。例如:

let val = 1
function effect() {
  val = 2 // 修改全局变量,产生副作用
}

在上面的例子中,effect 函数修改了全局变量 val,这会影响其他依赖 val 的代码,因此它是一个副作用函数。

再看一个更贴近 Vue.js 场景的例子:

const obj = { text: 'hello world' }
function effect() {
  document.body.innerText = obj.text
}

这里,effect 函数读取了 obj.text 并设置到页面的 innerText 上。如果 obj.text 发生变化,我们希望 effect 函数自动重新执行,从而更新页面内容。这就是响应式数据的核心目标:当数据变化时,自动触发依赖它的副作用函数。

但普通 JavaScript 对象无法感知自身属性的读写操作,因此我们需要一种机制来拦截这些操作,Vue.js 3 使用了 ES2015+ 的 Proxy 来实现这一目标。

响应式数据的基本实现

要让 obj 变成响应式数据,关键在于拦截属性的读取设置操作。具体来说:

  • 读取时:记录调用该属性的副作用函数。
  • 设置时:触发所有依赖该属性的副作用函数重新执行。

我们可以用一个 Set 集合(称为“桶”)来存储副作用函数,并通过 Proxy 拦截对象的读写操作。以下是一个简单的实现:

const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
  get(target, key) {
    bucket.add(effect)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    bucket.forEach(fn => fn())
    return true
  }
})

function effect() {
  document.body.innerText = obj.text
}

effect()
setTimeout(() => {
  obj.text = 'hello vue3'
}, 1000)

在这个例子中:

  1. effect 函数执行时,读取 obj.text 会触发 get,将 effect 存入 bucket
  2. 1 秒后修改 obj.text 会触发 set,从 bucket 中取出 effect 并重新执行,从而更新页面。

然而,这个实现有一个明显的问题:副作用函数被硬编码为 effect,如果函数名改变或使用匿名函数,代码就会失效。接下来,我们需要改进设计,构建一个更灵活的响应系统。

构建完善的响应系统

为了解决硬编码问题,我们引入一个全局变量 activeEffect 来存储当前执行的副作用函数,并设计一个 effect 函数来注册副作用函数:

let activeEffect
function effect(fn) {
  activeEffect = fn
  fn()
}

同时,我们优化 Proxy 的实现:

const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
  get(target, key) {
    if (activeEffect) {
      bucket.add(activeEffect)
    }
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    bucket.forEach(fn => fn())
    return true
  }
})

effect(() => {
  document.body.innerText = obj.text
})

setTimeout(() => {
  obj.text = 'hello vue3'
}, 1000)

这样,副作用函数无需固定名称,甚至可以是匿名函数。但新的问题出现了:如果设置一个未被读取的属性(例如 obj.notExist),仍然会触发副作用函数执行,这是不必要的。

优化桶的数据结构

为解决上述问题,我们需要建立副作用函数与具体属性之间的精确联系。使用 WeakMapMapSet 构建一个树形结构:

  • WeakMap:以目标对象(target)为键,值为一个 Map
  • Map:以属性名(key)为键,值为一个 Set
  • Set:存储依赖该属性的副作用函数。

代码实现如下:

const bucket = new WeakMap()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
  get(target, key) {
    if (!activeEffect) return target[key]
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    const depsMap = bucket.get(target)
    if (!depsMap) return true
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
    return true
  }
})

使用 WeakMap 的好处是,当目标对象不再被引用时,它可以被垃圾回收器回收,避免内存泄漏。

封装 track 和 trigger

为了提高代码可维护性,我们将依赖收集和触发逻辑封装为 tracktrigger 函数:

function track(target, key) {
  if (!activeEffect) return
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    trigger(target, key)
    return true
  }
})

这样,代码结构更清晰,且便于扩展。

处理分支切换与清理

在实际应用中,副作用函数可能涉及条件分支。例如:

const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })

effect(() => {
  document.body.innerText = obj.ok ? obj.text : 'not'
})

obj.ok 变为 false 时,obj.text 不再被读取,但之前的依赖关系仍然存在,导致修改 obj.text 仍会触发副作用函数执行。为了解决这个问题,我们需要在副作用函数执行前清理旧的依赖关系。

为此,我们为副作用函数添加一个 deps 属性,存储所有相关的依赖集合,并在每次执行前清理:

let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

function track(target, key) {
  if (!activeEffect) return
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
  activeEffect.deps.push(deps)
}

这样,每次副作用函数执行前,cleanup 会移除旧的依赖关系,执行后重新建立新的依赖关系,避免不必要的更新。

避免无限递归

考虑以下场景:

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })

effect(() => {
  obj.foo++
})

obj.foo++ 同时触发了 get(读取 obj.foo)和 set(设置新值)。在 set 中,trigger 会再次调用副作用函数,而此时副作用函数尚未执行完毕,导致无限递归调用,栈溢出。

解决办法是在 trigger 中添加守卫条件,跳过当前正在执行的副作用函数:

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}

这样,只有与当前执行的副作用函数不同的函数才会被触发,避免了无限递归。

支持嵌套副作用函数

在 Vue.js 中,组件渲染可能导致嵌套的副作用函数。例如:

effect(() => {
  Foo.render() // 外层 effect
  effect(() => {
    Bar.render() // 内层 effect
  })
})

为支持嵌套,我们引入一个 effectStack 栈,确保 activeEffect 始终指向当前执行的副作用函数:

let activeEffect
const effectStack = []

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}

这样,外层和内层副作用函数的依赖关系不会混淆,响应式数据只会与直接读取它的副作用函数建立联系。

可调度性:控制执行时机与次数

响应系统的可调度性允许开发者控制副作用函数的执行时机和次数。我们为 effect 函数添加 options 参数,支持自定义调度器:

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options
  effectFn.deps = []
  effectFn()
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

例如,延迟执行副作用函数:

effect(
  () => console.log(obj.foo),
  {
    scheduler(fn) {
      setTimeout(fn)
    }
  }
)

或通过任务队列去重执行:

const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false

function flushJob() {
  if (isFlushing) return
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(
  () => console.log(obj.foo),
  {
    scheduler(fn) {
      jobQueue.add(fn)
      flushJob()
    }
  }
)

这种机制可以避免过渡状态的重复执行,优化性能。

计算属性与懒执行

计算属性是 Vue.js 的重要特性,它基于懒执行的副作用函数。我们通过 lazy 选项实现:

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    const res = fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    return res
  }
  effectFn.options = options
  effectFn.deps = []
  if (!options.lazy) {
    effectFn()
  }
  return effectFn
}

function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
    }
  })
  return {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
}

为支持其他副作用函数依赖计算属性,我们在读取 value 时调用 track,在 scheduler 中调用 trigger

function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value')
      return value
    }
  }
  return obj
}

Watch 的实现

watch 是观测响应式数据的工具,其核心是基于 effectscheduler

function watch(source, cb, options = {}) {
  let getter = typeof source === 'function' ? source : () => traverse(source)
  let oldValue, newValue
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job
  })
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  for (const k in value) {
    traverse(value[k], seen)
  }
  return value
}

支持立即执行和控制执行时机:

function watch(source, cb, options = {}) {
  let getter = typeof source === 'function' ? source : () => traverse(source)
  let oldValue, newValue
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === 'post') {
        Promise.resolve().then(job)
      } else {
        job()
      }
    }
  })
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

处理竞态问题

在异步场景中,watch 可能面临竞态问题。例如,多次修改数据触发多次请求,后发请求可能先返回,导致结果错误。Vue.js 通过 onInvalidate 解决:

function watch(source, cb, options = {}) {
  let getter = typeof source === 'function' ? source : () => traverse(source)
  let oldValue, newValue
  let cleanup
  function onInvalidate(fn) {
    cleanup = fn
  }
  const job = () => {
    newValue = effectFn()
    if (cleanup) {
      cleanup()
    }
    cb(newValue, oldValue, onInvalidate)
    oldValue = newValue
  }
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === 'post') {
        Promise.resolve().then(job)
      } else {
        job()
      }
    }
  })
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

使用示例:

watch(obj, async (newValue, oldValue, onInvalidate) => {
  let expired = false
  onInvalidate(() => {
    expired = true
  })
  const res = await fetch('/path/to/request')
  if (!expired) {
    finalData = res
  }
})