cd ../

Dive into Vue:调度执行

2024.12.18Vue / ES6

Previously on Dive into Vue: 响应式系统

调度执行机制,指在响应式系统中trigger函数具备的,可以决定副作用执行的时机、次数和方式的能力。在 Vue 的响应式系统中,除了依赖追踪,调度机制也是一个至关重要的组成部分。它决定了副作用在何时、以何种顺序执行,以及如何避免不必要的计算。深入调度执行机制,是理解 Vue 响应式系统不可或缺的步骤。

为什么需要调度机制?

在当前版本的响应式系统视线中,代码不包含调度机制。当触发trigger函数时,会立即执行副作用。考虑下面的例子:

const count = ref(0)

watchEffect(() => {
  console.log(count.value)
})

count.value++
count.value++
count.value++

如果没有调度机制,上述代码会输出:

0
1
2
3

而在 Vue 中,实际上只会输出:

0
3

即:数据只更新了一次。不难得出结论:在 Vue 中,实际上trigger不会直接执行副作用,而是将更新任务统一交给了调度机制进行处理。这避免了大量中间无用状态导致的重复更新。

响应式系统中的代码实现目前还无法做到这一点。在已有逻辑的基础上,可以模仿 Vue 实现简化的调度机制。

支持调度器

当前的任务,是需要trigger可以通过调度机制调用副作用。这就需要修改副作用函数的结构,使其支持传入一个自定义的调度器。

// 副作用函数注册时,允许传入`options`配置项,通过`options.scheduler`指定自定义调度器
type EffectRegisterOptions = {
  scheduler?: (fn: Function) => void
}
// 更新副作用函数的类型,使用`options`存储注册时的配置项
type EffectFn = Function & {
  deps: Deps[]
  options: EffectRegisterOptions
}

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

  effectFn.options = options
  effectFn.deps = []
  effectFn()
}

调度器options.scheduler可由用户自定义。那么,如何实现上文中提到的省略中间状态、避免额外更新的调度器呢?可以利用微任务队列在事件循环中「延后执行」的特性。简单的实现代码如下:

// 任务队列
const queue = new Set<Function>()
// 利用 Promise 实现微任务队列
const p = Promise.resolve()
// 是否正在刷新任务队列
let isFlushing = false

function flushJob() {
  if (isFlushing) return
  isFlushing = true
  p.then(() => {
    // 将队列中的任务依次执行
    queue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(() => {
  console.log(proxyObj.count)
}, {
  scheduler: (fn) => {
    // 每次调度时,将副作用函数添加到队列中而非立即执行
    queue.add(fn)
    flushJob()
  }
})

这个调度器使用 Set 来实现一个任务队列。当前文中多次调用count.value ++时,实际上只有一次更新操作会被添加到队列中;随后,isFlusing标志保证了flushJob在一个周期内无论被调用多少次,都只会执行一次。当队列执行完毕并最终被清空时,isFlusing标志会被重置为false,下一个周期又会重复以上过程。

该调度器只为演示 Vue 内部「连续多次修改响应式数据但只会触发一次更新」的基本原理,调度器可以由用户自定义来实现复杂的副作用执行策略。Vue 内部实现的调度器思路类似,但更加完善。

接着,在trigger函数中,当需要执行副作用时,如果副作用函数effectFn上指定了调度器,那么调用调度器函数而不是立即执行副作用。

function trigger(target: object, key: PropertyKey) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const deps = depsMap.get(key)
  if (!deps) return
  const effectsToRun = new Set<EffectFn>()
  deps.forEach((effectFn) => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

截至目前,响应式系统已经具备了调度能力,并且还实现了一个行为类似 Vue 的调度器。

计算属性computed

Vue 中的一个重要功能就是计算属性computed。计算属性接受一个 getter,返回计算结果。计算属性具有如下特性:

  • Lazy:不会立即返回值,只在访问时进行计算;
  • 缓存:只有当计算属性依赖的响应式数据发生变化时,才会重新计算。

计算属性的这两点特性均可以通过调度机制实现。

Lazy Effect

在计算属性中,计算是懒执行的。必须要实现一个同样懒执行的副作用函数,才能作为实现计算属性的基础。

观察当前版本的effect实现:

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

  effectFn.options = options
  effectFn.deps = []
  effectFn()
}

effect(() => console.log('effect run'))

在调用effect注册副作用时,由于高亮的第 13 行,副作用总是立即执行一次。若要实现懒执行,需要在 13 行执行前增加判断,表明是否立即执行副作用。因此,增加参数options.lazy,若为true,则不立即执行,而是返回副作用函数effectFn以便在后续的某个时刻手动调用求值。

type EffectRegisterOptions = {
  scheduler?: (fn: Function) => void
  lazy?: boolean
}

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

  effectFn.options = options
  effectFn.deps = []
  if (!options.lazy) {
    effectFn()
  }
  return effectFn
}

effect(() => console.log('effect run'))

此时,当调用effect注册副作用且同时指定lazy: true时,副作用函数就不会立即执行了。

下面考虑一个使用计算属性的例子:

const fullName = computed(() => proxyObj.firstName + ' ' + proxyObj.lastName)

在这个例子中,副作用是一个 getter 函数。在这种情况下仅仅实现了手动调用副作用是不够的,还需要能够在手动调用副作用时获取返回值(即计算结果)。为了实现这个目标,需要对effect进行修改,增加可以保存和返回计算结果的能力。

function effect(fn: Function, options: EffectRegisterOptions = {}) {
  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
}

接下来,基于 Lazy Effect 编写计算属性computed的实现函数:

function computed(getter: Function) {
  const effectFn = effect(() => {
    // 增加一个输出,更好地识别计算属性在何时进行求值
    console.log('effectFn invoked')
    return getter()
  }, { lazy: true })
  
  const obj = {
    get value() {
      return effectFn()
    }
  }

  return obj
}

const obj = {
  firstName: 'John',
  lastName: 'Doe'
}
const proxyObj = new Proxy(obj, { /* get, set */ })

// ↓ 调用 computed 并不会触发
const fullName = computed(() => proxyObj.firstName + ' ' + proxyObj.lastName)
// ↓ 计算属性仅在访问 fullName.value 时才会进行求值
console.log(fullName.value)

缓存计算结果

上文已经实现了懒执行的计算属性。但当多次访问fullName.value时,即便依赖的响应式数据proxyObj.firstNameproxyObj.lastName没有发生变化,计算属性也会重新求值。为了避免重复计算,需要缓存计算结果。

在计算属性的实现中,增加一个变量cache,用于缓存计算结果;使用dirty标志位表示是否需要重新计算。当dirtytrue时,才进行重新求值,否则当访问value时应当直接返回cache

function computed(getter: Function) {
  let cache: any
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true
  })

  const obj = {
    get value() {
      if (dirty) {
        cache = effectFn()
        dirty = false
      }
      return cache
    }
  }

  return obj
}

用于判断是否需要重新计算的根据就是dirty的值。可以看到dirty会在每一次重新计算后被标记为false。那么什么时候应当把dirty置为true呢?我们知道当响应式数据发生变化时computed应当重新求值。因此,在响应式数据发生变化时,就需要把dirty置为true

此处可以利用effectscheduler调度器选项来实现。当响应式数据发生变化时,会调用副作用的调度器函数(如有),因此可以在computed中调用effect注册副作用时传入调度器:

function computed(getter: Function) {
  let cache: any
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        cache = effectFn()
        dirty = false
      }
      return cache
    }
  }

  return obj
}

现在,尝试多次访问fullName.value

console.log(fullName.value)
console.log(fullName.value)
proxyObj.firstName = 'Jane'
console.log(fullName.value)
console.log(fullName.value)

副作用仅执行了两次,说明计算属性返回的值是缓存的结果。

嵌套的副作用

考虑以下例子,即在副作用中读取计算属性的值:

const fullName = computed(() => proxyObj.firstName + ' ' + proxyObj.lastName)

effect(() => {
  console.log('effect run with value: ', fullName.value)
})

proxyObj.firstName = 'Jane'
// effect run with value: John Doe

可以看到,当proxyObj.firstName发生变化时,副作用会被触发,但计算属性的值依然是旧值。我们期望的结果是当proxyObj.firstName发生变化时,计算属性的值也会发生变化。

分析原因,这本质上是一个副作用的嵌套。外层的副作用读取fullName.value,而fullName目前并非响应式数据,访问其.value属性也不会触发track的依赖收集逻辑。最终,外层 effect 没有被收集到任何依赖里,即便改变了proxyObj.firstName,也不会重新执行外层 effect。

经过以上分析,不难得出结论:要使此例按预期工作,关键是需要在访问fullName.value时能够触发track的逻辑进行依赖收集。不妨手动调用track函数实现这一过程;此外前文已有叙述,当响应式数据发生变化时将调用副作用调度器函数,通过在调度器中手动调用trigger,即可触发外层 effect 的重新执行。

function computed(getter: Function) {
  let cache: any
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
      // 当计算属性依赖的响应式数据发生变化时,手动调用 trigger 触发依赖的 effect 重新执行
      trigger(obj, 'value')
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        cache = effectFn()
        dirty = false
      }
      // 当访问计算属性的值时,手动调用 track 进行依赖收集
      track(obj, 'value')
      return cache
    }
  }

  return obj
}

此时,再次执行本节中的例子,代码按预期工作。

侦听器watch

上面一节代码实现了计算属性,本节将实现侦听器watch

回想侦听器的使用方法:

watch(target, (newVal, oldVal) => {
  console.log('count changed from', oldVal, 'to', newVal)
})

可以得出几个特性:

  1. 侦听器侦听一个响应式数据或一个 getter;
  2. 当侦听的目标发生变化时,执行传入的回调函数;
  3. 传入回调时,可接收新值和旧值参数;
  4. 可以传入immediate选项,在侦听器创建时立即执行回调;
  5. 可以传入flush参数,指定回调函数的执行时机。

其中,第 5 条特性与 Vue 对 DOM 的更新机制相关。前 4 条特性目前可以被实现。

侦听对象,执行回调

Vue 中,watch的第一个参数可以是响应式数据或一个 getter 函数。首先考虑响应式数据的情况:假设需要侦听的响应式数据是proxyObj,当proxyObj.count被修改为 100 时应当执行回调函数。这天然符合前文中 effect 的调度器的特征(即在响应式数据发生变化时被调用)。因此要实现第 2 条功能,只需要在watch的实现逻辑中,于调度器函数内调用传入的回调函数即可。

function watch(target, cb: () => void) {
  effect(() => target.count, {
    scheduler() {
      cb()
    }
  })
}

watch(proxyObj, () => {
  console.log('count changed')
})

proxyObj.count ++

但是,代码中注册副作用的时候,硬编码了target.count,这使得watch只能侦听count属性。事实上,如果修改了proxyObj的其他属性,我们也期望侦听器的回调可以执行。若要实现这一点,需要对 effect 传入的副作用函数进行封装,递归地访问target中的所有属性:

function watch(target: object, cb: () => void) {
  effect(() => traverse(target), {
    scheduler() {
      cb()
    }
  })
}

function traverse(value: any, seen = new Set<any>) {
  // 基本数据类型或已经被访问过,则不递归
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  // 暂不考虑数组等其他数据结构
  // value 作为普通对象,递归访问其所有属性
  for (const k in value) {
    traverse(value[k], seen)
  }
  return value
}

接下来考虑传入的target是一个 getter 函数的情况。如果用户直接传入一个 getter,那么就不需要在effect中调用traverse函数,因为 getter 函数内部已经包含了对响应式数据的访问逻辑。此时需要增加一层判断:

function watch(target: object | Function, cb: () => void) {
  let getter: Function;
  if (typeof target === 'function') {
    getter = target
  } else {
    getter = () => traverse(target)
  }
  effect(() => getter(), {
    scheduler() {
      cb()
    }
  })
}

支持回调参数

在第 3 条功能特性中,侦听器的回调函数支持包含两个参数,分别是侦听对象发生变化后的新值和变化前的旧值,这需要能够在执行 getter 的时候保存其返回值。在实现计算属性时,已经实现了类似的缓存功能,通过启用lazy来实现手动调用获取返回值。在此处,可以使用类似的思路:

function watch(target: object | Function, cb: (newVal?: any, oldVal?: any) => void) {
  let getter: Function;
  if (typeof target === 'function') {
    getter = target
  } else {
    getter = () => traverse(target)
  }
  let newVal: any, oldVal: any
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      // 副作用执行时,产生新值
      newVal = effectFn()
      // 调用回调函数
      cb(newVal, oldVal)
      // 更新旧值
      oldVal = newVal
    }
  })

  // 手动调用获取旧值
  oldVal = effectFn()
}

watch(() => proxyObj.count, (newVal, oldVal) => {
  console.log('count changed from', oldVal, 'to', newVal)
})

在上面的代码中,已知调度器会在侦听对象发生变化时被调用,因此可以在调度器中执行effectFn获取响应式数据变化后的新值。在调用watch时手动执行的effectFn发生于完成依赖收集后,此时响应式数据没有发生变化,获取到的就是初始值。此后,每次触发cb后,都更新旧值,避免在下次触发cb时得到到错误的旧值。

立即执行的watch

在 Vue 中,立即执行的watch指在创建侦听器时立即执行一次回调函数。这个功能相对比较简单,只需要在watch的实现逻辑中,增加一个判断:如果immediate选项为true,则在创建侦听器时手动调用一次回调函数即可。

type WatchOptions = {
  immediate?: boolean
}

function watch(
  target: object | Function,
  cb: (newVal?: any, oldVal?: any) => void,
  options: WatchOptions = {}
) {
  let getter: Function;
  if (typeof target === 'function') {
    getter = target
  } else {
    getter = () => traverse(target)
  }
  let newVal: any, oldVal: any

  // 将调度器函数封装为 job
  const job = () => {
    newVal = effectFn()
    cb(newVal, oldVal)
    oldVal = newVal
  }

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job
  })

  // 如果传入了 immediate 选项,立即调用 job 函数
  if (options.immediate) {
    job()
  } else {
    oldVal = effectFn()
  }
}

值得注意的一点是,设置了立即执行并调用job()时,旧值应当是undefined

目前为止的完整代码

响应式系统核心:

// 全局存储桶,保存每个对象的依赖关系
type Bucket = WeakMap<object, DepsMap>
// 响应式对象中,每个属性和其对应的副作用函数集合的依赖关系表
type DepsMap = Map<PropertyKey, Deps>
// 每个属性的副作用函数集合
type Deps = Set<EffectFn>
// 副作用函数注册时,允许传入`options`配置项,通过`options.scheduler`指定自定义调度器
type EffectRegisterOptions = {
  scheduler?: (fn: Function) => void
  lazy?: boolean
}
// 更新副作用函数的类型,使用`options`存储注册时的配置项
type EffectFn = Function & {
  deps: Deps[]
  options: EffectRegisterOptions
}

const obj: Record<PropertyKey, any> = {
  firstName: 'John',
  lastName: 'Doe'
}

const bucket: Bucket = new WeakMap()
let activeEffect: EffectFn
const effectStack: EffectFn[] = []

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

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

function track(target: object, key: PropertyKey) {
  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)
  console.log(activeEffect)
  // 错误:activeEffect.deps 是 undefined,activeEffect 是一个函数,并非预期的`EffectFn`类型
  activeEffect.deps.push(deps)
}

function trigger(target: object, key: PropertyKey): void {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const deps = depsMap.get(key)
  if (!deps) return
  const effectsToRun = new Set<EffectFn>()
  deps.forEach((effectFn) => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
    effectsToRun.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

const proxyObj = new Proxy(obj, {
  get: (target, key: PropertyKey) => {
    track(target, key)
    return target[key]
  },
  set: (target, key: PropertyKey, value) => {
    target[key] = value
    trigger(target, key)
    return true
  }
})

计算属性:

function computed(getter: Function) {
  let dirty = true
  let cache: any = undefined

  const effectFn = effect(() => {
    console.log('effectFn invoked')
    return getter()
  }, {
    lazy: true,
    scheduler() {
      dirty = true
      trigger(obj, 'value')
    }
  })
  
  const obj = {
    get value() {
      if (dirty) {
        cache = effectFn()
        dirty = false
      }
      track(obj, 'value')
      return cache
    }
  }

  return obj
}

侦听器:

type WatchOptions = {
  immediate?: boolean
}

function watch(
  target: object | Function,
  cb: (newVal?: any, oldVal?: any) => void,
  options: WatchOptions = {}
) {
  let getter: Function;
  if (typeof target === 'function') {
    getter = target
  } else {
    getter = () => traverse(target)
  }
  let newVal: any, oldVal: any

  const job = () => {
    newVal = effectFn()
    cb(newVal, oldVal)
    oldVal = newVal
  }

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job
  })

  if (options.immediate) {
    job()
  } else {
    oldVal = effectFn()
  }
}

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

  return value
}
Fin.
cd ../