Vue3响应系统设计-下

2023-08-26 14:56:13 浏览数 (2)

继续分析响应式的设计,一步步深入,会比上一篇难理解些

无限递归的坑

一个完善的响应式系统,需要考虑诸多细节,比如下面这个无限递归的例子

代码语言:javascript复制
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })
effect(() => obj.foo  )

上面的这个代码会导致栈溢出 Uncaught RangeError: Maximum call stack size exceeded

实际上,把 obj.foo 这个自增操作分开来看,它相当于

代码语言:javascript复制
effect(() => {
    // 语句
    obj.foo = obj.foo   1
  })

问题出现场景是这样的:首先读取 obj.foo 的值,触发 track 操作,将当前副作用函数收集到“桶”中,接着将其加 1 后再赋值给obj.foo,此时会触发 trigger 操作,即把“桶”中的副作用函数取出并执行。由于该副作用函数正在执行中,还没有执行完毕,就要开始下一次的执行。导致无限递归地调用自己,于是就产生了栈溢出。

这个问题要如何解决呢?

关键是副作用函数执行的时候,要避免trigger再次触发执行;这里可以发现,track跟trigger触发的都是同个activeEffect,那就可以基于此增加判断条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

代码语言:javascript复制
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 => {
    // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if (effectFn !== activeEffect) {  // 新增
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
  // effects && effects.forEach(effectFn => effectFn())
}

这样就可以修复问题了

effect函数的可调度性

可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。先来看下下面的代码

代码语言:javascript复制
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
  console.log(obj.foo)
})
obj.foo  
console.log('结束了')

这段代码的输出结果如下

代码语言:javascript复制
1
2
'结束了'

现在假设需求有变,输出顺序需要调整为:

代码语言:javascript复制
1
'结束了'
2

可以把语句 obj.foo 和语句 console.log('结束了') 位置互换。有什么办法在不调整代码的情况下实现需求呢? 这时就需要响应系统支持调度

可以为 effect 函数设计一个选项参数 options,允许用户指定调度器

代码语言:javascript复制
effect(
  () => {
    console.log(obj.foo)
  },
  // options
  {
    // 调度器 scheduler 是一个函数
    scheduler(fn) {
      // ...
    }
  }
)

effect函数新增一个options参数,同时把options挂载在副作用函数上

代码语言:javascript复制
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // 将 options 挂载到 effectFn 上
  effectFn.options = options  // 新增
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

有了调度函数,在trigger函数中触发副作用函数执行时,可以直接调用调度器函数,从而把控制权外放

代码语言:javascript复制
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()  // 新增
    }
  })
}

在trigger触发执行的时候,优先判断有没有调度器,如果存在,直接调用调度器函数,没有的话,直接执行副作用函数;

有了调度器,就可以实现前面的需求了

代码语言:javascript复制
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(
  () => {
    console.log(obj.foo)
  },
  // options
  {
    // 调度器 scheduler 是一个函数
    scheduler(fn) {
      // 将副作用函数放到宏任务队列中执行
      setTimeout(fn)
    }
  }
)
obj.foo  
console.log('结束了')

用setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能实现期望的打印顺序了

另外调度器还可以控制它的执行次数,这个很重要,考虑下面例子

代码语言:javascript复制
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
  console.log(obj.foo)
})
obj.foo  
obj.foo  

它的输出如下

代码语言:javascript复制
1
2
3

如果只关系最终结果,不关心过程,期望的打印结果是

代码语言:javascript复制
1
3

也可以基于调度器来实现

代码语言:javascript复制
// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {
  // 如果队列正在刷新,则什么都不做
  if (isFlushing) return
  // 设置为 true,代表正在刷新
  isFlushing = true
  // 在微任务队列中刷新 jobQueue 队列
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false
  })
}
effect(() => {
  console.log(obj.foo)
}, {
  scheduler(fn) {
    // 每次调度时,将副作用函数添加到 jobQueue 队列中
    jobQueue.add(fn)
    // 调用 flushJob 刷新队列
    flushJob()
  }
})
obj.foo  
obj.foo  

任务队列jobQueue是一个Set数组,可以自动去重,在执行的时候,isFlushing来判断一个周期只执行一次,最终的执行是用p.then将函数添加到微任务队列,在队列内完成遍历执行

vue在连续多次修改响应数据,当只会触发一次更新,思路跟这个是相同的

computed 与 lazy

现在设计实现的effect函数,都会立即执行传递给它的副作用函数,例如

代码语言:javascript复制
effect(
  // 这个函数会立即执行
  () => {
    console.log(obj.foo)
  }
)

有些场景,我们不希望立即执行,而是在需要的时候才执行,可以在options中添加lazy属性来达到目的

代码语言:javascript复制
effect(
  // 指定了 lazy 选项,这个函数不会立即执行
  () => {
    console.log(obj.foo)
  },
  // options
  {
    lazy: true
  }
)

相应的effect函数也要调整,当options.lazy为true时,不立即执行副作用函数,并且把副作用函数返回,由外部手动执行

代码语言:javascript复制
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 = []
  // 只有非 lazy 的时候,才执行
  if (!options.lazy) {  // 新增
    // 执行副作用函数
    effectFn()
  }
  // 将副作用函数作为返回值返回
  return effectFn  // 新增
}

然后就可以手动执行副作用函数了

代码语言:javascript复制
const effectFn = effect(() => {
  console.log(obj.foo)
}, { lazy: true })
// 手动执行副作用函数
effectFn()

单纯手动执行其实意义不大,不过如果把副作用函数看做一个getter,例如

代码语言:javascript复制
 const effectFn = effect(
   // getter 返回 obj.foo 与 obj.bar 的和
   () => obj.foo   obj.bar,
   { lazy: true }
 )

在手动执行副作用函数时,可以拿到返回值

代码语言:javascript复制
const effectFn = effect(
  // getter 返回 obj.foo 与 obj.bar 的和
  () => obj.foo   obj.bar,
  { lazy: true }
)
// value 是 getter 的返回值
const value = effectFn()

由于新增了返回值,需要再对effect函数做一些修改

代码语言:javascript复制
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    // 将 fn 的执行结果存储到 res 中
    const res = fn()  // 新增
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    // 将 res 作为 effectFn 的返回值
    return res  // 新增
  }
  effectFn.options = options
  effectFn.deps = []
  if (!options.lazy) {
    effectFn()
  }
  return effectFn
}

effectFn把真正的副作用函数 fn 的执行结果,保存到 res 变量中,然后将其作为 effectFn 函数的返回值,这样接下来就可以实现计算属性computed了

代码语言:javascript复制
function computed(getter) {
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true
  })
  const obj = {
    // 当读取 value 时才执行 effectFn
    get value() {
      return effectFn()
    }
  }
  return obj
}

computed 函数返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回

我们可以使用 computed 函数来创建一个计算属性

代码语言:javascript复制
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo   obj.bar)
console.log(sumRes.value)  // 3

现在computed函数已经可以实现懒计算了,只有当真正读取sumRes.value 的值时,它才会进行计算并得到值,但是多次访问sumRes.value 的值,会导致effectFn的多次计算,所以computed方法还要继续优化,增加缓存功能

代码语言:javascript复制
function computed(getter) {
  // value 用来缓存上一次计算的值
  let value
  // dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true
  })
  const obj = {
    get value() {
      // 只有“脏”时才计算值,并将得到的值缓存到 value 中
      if (dirty) {
        value = effectFn()
        // 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
        dirty = false
      }
      return value
    }
  }
  return obj
}

用value来缓存上一次计算的值,用dirty来表示是否需要重新计算,这样每次访问sumRes.value,拿到的都是缓存的值了

不过有个明显的漏洞,当修改obj.foo 或 obj.bar 的值的时候, sumRes.value 返回的值不会变,所以当obj.foo的值改变的时候,需要把dirty改成true,这个要如何实现呢?

可以利用之前结算的调度器来实现,代码如下

代码语言:javascript复制
function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    // 添加调度器,在调度器中将 dirty 重置为 true
    scheduler() {
      dirty = true
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return obj
}

由于effect添加了scheduler调度器,在每次getter说依赖的响应式数据变化的时候,可以把dirty置为true,这样就可以得到预期的结果了

上面的设计趋于完美了,不过还有一个缺陷,当在另外一个effect读取计算属性,当修改obj.foo的值,不会触发副作用函数的重新执行

代码语言:javascript复制
const sumRes = computed(() => obj.foo   obj.bar)
effect(() => {
  // 在该副作用函数中读取 sumRes.value
  console.log(sumRes.value)
})
// 修改 obj.foo 的值
obj.foo  

对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把computed 内部的 effect 收集为依赖。外层的 effect 不会被内层 effect 中的响应式数据收集,这个怎么办?

其实方法不难,就是在读取计算属性的值时,手动调用 track 函数进行追踪;当响应式数据发生变化时,再手动调用 trigger 函数触发响应

代码语言:javascript复制
function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        // 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
        trigger(obj, 'value')
      }
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      // 当读取 value 时,手动调用 track 函数进行追踪
      track(obj, 'value')
      return value
    }
  }
  return obj
}

我们把计算属性返回的对象obj作为track函数的第一个参数,这样也就跟外层的effect函数建立关系了,在数据发生变化后,再手动调用trigger触发响应

watch的实现原理

watch其实就是一个响应式数据,数据一旦变化,有相应的回调

代码语言:javascript复制
watch(obj, () => {
  console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo  

上面的watch方法,可以利用effect方法配合scheduler选项来实现

代码语言:javascript复制
effect(() => {
  console.log(obj.foo)
}, {
  scheduler() {
    // 当 obj.foo 的值变化时,会执行 scheduler 调度函数
  }
})

在副作用函数中,访问响应式数据,就把函数跟响应式数据建立联系,再加上scheduler的回调,可以有最简单的watch函数的实现方式

代码语言:javascript复制
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb()
      }
    }
  )
}

上面函数可以正常工作,不过有个坑,硬编码了对source.foo的读取,如果非foo属性是无法触发watch的回调了,这里要怎么办?

其实很简单,遍历响应式数据的所有变量,让它跟副作用函数简历关联

代码语言:javascript复制
function watch(source, cb) {
  effect(
    // 调用 traverse 递归地读取
    () => traverse(source),
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb()
      }
    }
  )
}
function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value)
  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen)
  }
  return value
}

traverse会递归的读取所有的变量,从而实现任意属性都可以触发回调执行了,另外,扩大下watch的功能,既可以监听响应式对象,也可以是一个getter函数

代码语言:javascript复制
watch(
  // getter 函数
  () => obj.foo,
  // 回调函数
  () => {
    console.log('obj.foo 的值变了')
  }
)

那watch方法要如何处理getter的场景呢?其实很简单,把getter当做特殊的traverse函数即可

代码语言:javascript复制
function watch(source, cb) {
  // 定义 getter
  let getter
  // 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则按照原来的实现调用 traverse 递归地读取
    getter = () => traverse(source)
  }
  effect(
    // 执行 getter
    () => getter(),
    {
      scheduler() {
        cb()
      }
    }
  )
}

通过判断source的类型,如果是getter类型,就在effect中直接执行,建立响应式联系;

另外,watch方法,还缺少旧值与新值的回调,这样要怎么处理?

利用effect函数的lazy选项,可以快速的实现

代码语言:javascript复制
function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  // 定义旧值与新值
  let oldValue, newValue
  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler() {
        // 在 scheduler 中重新执行副作用函数,得到的是新值
        newValue = effectFn()
        // 将旧值和新值作为回调函数的参数
        cb(newValue, oldValue)
        // 更新旧值,不然下一次会得到错误的旧值
        oldValue = newValue
      }
    }
  )
  // 手动调用副作用函数,拿到的值就是旧值
  oldValue = effectFn()
}

由于是lazy加载,所以先主动调用下effectFn,拿到旧值,然后在每次的scheduler回调中,再去更新新值

watch的立即执行和执行时机

watch正常是数据变成了,才会触发回调执行,不过有个immediate参数,来指定回调立即执行,这个要怎么实现?

代码语言:javascript复制
watch(obj, () => {
  console.log('变化了')
}, {
  // 回调函数会在 watch 创建时立即执行一次
  immediate: true
})

回调的执行是在scheduler函数中的,把scheduler函数封装下,再手动执行一次就可以了

代码语言:javascript复制
function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  let oldValue, newValue
  // 提取 scheduler 调度函数为一个独立的 job 函数
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      // 使用 job 函数作为调度器函数
      scheduler: job
    }
  )
  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 job,从而触发回调执行
    job()
  } else {
    oldValue = effectFn()
  }
}

另外,watch还有一个flush参数,可以指定回调在在一个微队列中,等待DOM更新结束后执行

代码语言:javascript复制
watch(obj, () => {
  console.log('变化了')
}, {
  flush: 'post' // 还可以指定为 'pre' | 'sync'
})

其实现如下

代码语言:javascript复制
function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  let oldValue, newValue
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 在调度函数中判断 flush 是否为 'post',如果是,将其放到微
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

指定为post的时候,job会被放在微队列中执行,实现了异步延迟执行

过期的副作用

我们考虑一个竞态问题的场景

代码语言:javascript复制
let finalData
watch(obj, async () => {
  // 发送并等待网络请求
  const res = await fetch('/path/to/request')
  // 将请求结果赋值给 data
  finalData = res
})

上面这段代码,可能会存在问题;

我们第一次修改 obj 对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求 A。随着时间的推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都在进行中,如果请求B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请求的结果,这样是不符合常规场景的

但由于请求 B 是后发送的,因此我们认为请求 B 返回的数据才是“最新”的,而请求 A 则应该被视为“过期”的,所以我们希望变量 finalData 存储的值应该是由请求B 返回的结果,而非请求 A 返回的结果

这个问题要如何处理呢?其实可以在callback增加第三个参数onInvalidate,它是一个函数,利用这个回调可以处理过期的副作用函数

代码语言:javascript复制
watch(obj, async (newValue, oldValue, onInvalidate) => {
  // 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
  let expired = false
  // 调用 onInvalidate() 函数注册一个过期回调
  onInvalidate(() => {
    // 当过期时,将 expired 设置为 true
    expired = true
  })
  // 发送网络请求
  const res = await fetch('/path/to/request')
  // 只有当该副作用函数的执行没有过期时,才会执行后续操作。
  if (!expired) {
    finalData = res
  }
})

那么onInvalidate是如何实现的呢?

其实很简单,在 watch 内部每次检测到变更后,在副作用函数重新执行之前,会先调用我们通过 onInvalidate 函数注册的过期回调,仅此而已

代码语言:javascript复制
function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  let oldValue, newValue
  // cleanup 用来存储用户注册的过期回调
  let cleanup
  // 定义 onInvalidate 函数
  function onInvalidate(fn) {
    // 将过期回调存储到 cleanup 中
    cleanup = fn
  }
  const job = () => {
    newValue = effectFn()
    // 在调用回调函数 cb 之前,先调用过期回调
    if (cleanup) {
      cleanup()
    }
    // 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
    cb(newValue, oldValue, onInvalidate)
    oldValue = newValue
  }
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

定义了 cleanup 变量,用来存储用户通过onInvalidate 函数注册的过期回调。在 job 函数内,每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数cleanup

代码语言:javascript复制
watch(obj, async (newValue, oldValue, onInvalidate) => {
  let expired = false
  onInvalidate(() => {
    expired = true
  })
  const res = await fetch('/path/to/request')
  if (!expired) {
    finalData = res
  }
})
// 第一次修改
obj.foo  
setTimeout(() => {
  // 200ms 后做第二次修改
  obj.foo  
}, 200)

在 200ms 时第二次修改了 obj.foo 的值,会导致 watch 的回调函数再次执行。 watch 的回调函数第二次执行之前,会优先执行之前注册的过期回调,这会使得第一次执行的副作用函数内闭包的变量 expired 的值变为 true,即副作用函数的执行过期了。于是等请求 A 的结果返回时,其结果会被抛弃,从而避免了过期的副作用函数带来的影响

结束语

这个系列,主要是参考Vue.js设计与实现这个本书。响应式数据是Vue的核心概念,整体了解下来,确实有一定的难度,部分逻辑也是看了多遍才理解透,需要考虑很多的实际场景,看完后由衷的佩服框架的设计者,这是多么天才型的人才才可以如何优雅的处理好这一个个场景问题

0 人点赞