Vue3响应式系统-调度器

106 阅读4分钟

日常生活中,我们在过十字路口的时候,会按照红灯停,路灯行的规则通行,但遇到一些特殊状况,就需要交警叔叔来指挥交通了,比如:

  1. 学生们放学过马路,汽车先停下,为学生们让行
  2. 交通拥堵,让直行的车先走,转弯的车停下
  3. 某些重大事件时,设立交通管制
  4. ... 交警叔叔指挥交通就可以看作是一种调度行为,他决定什么时候通行?通行多久?多少人通行等等

那么,在 Vue3 的设计中,调度就是指当 trigger 动作触发副作用函数重新执行的时候,有能力决定副作用函数的时机、次数以及方式

调度函数

首先,我们直接看一看 Vue3 中,是如何实现调度器的:

// 定义响应式数据
const obj = reactive({text: 'hello, vue'})
// 注册副作用函数
effect(
// 真正的副作用函数
() => {
  document.body.innerText = obj.text
}, {
  // 调度器是一个函数 
  scheduler() {
   // ...
  }
})

obj.text = 'hello, world'

effect 函数用来注册副作用函数。按照响应式的逻辑,读取 text 值时,会将副作用函数收集到依赖集合中,当修改 obj.text 的值时,触发 trigger 函数,副作用函数会重新执行,这样页面上的内容也发生了变化

scheduler 函数调用的时机就在 trigger 函数中:

function trigger() {
  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()
    }
  })
}

如果 options 中存在 scheduler 函数,就执行scheduler中的逻辑,否则,执行副作用函数

调度函数的应用

改变执行顺序

// 定义响应式数据
const obj = reactive({text: 'hello, vue'})
// 注册副作用函数
effect(
// 真正的副作用函数
() => {
  console.log(obj.text)
})

obj.text = 'hello, world'

console.log('顺序改变啦')

现在函数的执行顺序为:

  1. 打印:hello, vue
  2. 打印:hello, world
  3. 打印:顺序改变啦 若想让打印顺序变为:
  4. 打印:hello, vue
  5. 打印:顺序改变啦
  6. 打印:hello, world 该怎么做呢?我们很容易想到在 scheduler 使用 setTimeout
// 定义响应式数据
const obj = reactive({text: 'hello, vue'})
// 注册副作用函数
effect(
 // 真正的副作用函数
 () => {
  console.log(obj.text)
},{
 scheduler(fn) {
  // setTimout 会将函数放到宏任务队列中执行,因此顺序会发生改变
  setTimeout(() => {
   fn()
  }, 0)
 }
})

obj.text = 'hello, world'

console.log('顺序改变啦')

改变执行次数

const obj = reactive({foo: 1})
effect(()=>{ console.log(obj.foo) })

obj.foo++
obj.foo++

现在,我们希望最终打印的结果是:

  1. 打印:1
  2. 打印:3 不用考虑中间的状态,那么如何实现呢?

同样是在 options 的 scheduler 函数中,利用 jobQueue 和 flushJob 两个函数实现了控制执行次数

const data = reactive({foo: 1})
const jobQueue = new Set()
// 创建一个 promise 实例,将任务添加到微任务队列中
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()
  }
})

obj.foo++
obj.foo++

原理分析

以上函数之所以可以控制执行次数,原因在于巧妙地利用了微任务

  1. effect 函数执行,将副作用函数也就是 effect 中传入的第一个参数推入到 jobQueue 队列中。打印出1
  2. 执行 flushJob 函数。isFlushingfalse,将 JobQueue 中的函数添加到微任务队列中执行。此时,isFlushingtrue
  3. 执行 obj.foo++, 触发 trigger 函数,再次执行 scheduler 中的内容。副作用函数被推入到 jobQueue 队列中。由于 isFlushing 为 true,代表正在刷新队列,不会执行后续操作。
  4. 执行第二个 obj.foo++, 同上一步一样
  5. 此时,脚本执行完毕,也就是宏任务执行完毕,开始执行微任务队列。执行 JobQueue 中的函数,由于它是一个 set,所以其实只有一个函数(队列中原本存在两个函数)。经过了两次的自增,foo 已经变为了 3,所以此时打印出的就是 3

watcher

watch 函数的作用是,观测一个响应式数据,当数据发生变化时,执行相应的回调函数。

本质上是利用 effectoptions.scheduler 选项,基本代码如下:

function watch(data, cb) {
  effect(
   () => data.foo,
   {
	 scheduler() { cb() }
   }
  )
}

当然,这不是 watch 的全部实现。真正的 watch 实现是非常复杂的,这只是它的基本结构

computed

computed 属性毫无疑问也使用到了调度器,还记得计算属性的一个特性嘛?在使用时才会执行

这个特性就是通过在scheduler中添加 lazy 属性实现的。这里就不展开叙述,详细内容可以阅读《Vuejs设计与实现》这本书。

总结

scheduler 调度器本质上就是回调函数的应用,虽然原理十分简单,但配合JS的其他能力,让 Vue 变得非常灵活且强大。