从零到一打造 Vue3 响应式系统 Day 9 - Effect:调度器实现与应用

到目前为止,我们的 effect 会在依赖的数据发生变化时,立即重新执行。 这种简单直接的模式在很多情况下都有效,但当遇到密集且连续的数据变更时,它可能会引发不必要的性能问题。

为什么需要 Effect 调度器?

JavaScript 复制代码
const count = ref(0)
effect(() => {
  console.log('渲染组件:', count.value)
  // 复杂的 DOM 操作...
})

// 连续修改
count.value = 1  // 触发渲染
count.value = 2  // 又触发渲染
count.value = 3  // 再次触发渲染

在上方案例中我们可以看到,如果 effect 中包含复杂的 DOM 操作,连续的赋值会造成三次重新渲染。但实际上,我们往往只需要最后一次变更的结果,这时候就需要调度器来优化这个过程。

什么是 Effect 调度器?

调度器是一个控制 effect 执行时机的机制:

  • 没有调度器:数据变化 → 立即执行 effect
  • 有调度器:数据变化 → 调度器决定何时/如何执行 effect

特性

避免同步连续触发多次更新

JavaScript 复制代码
// 避免同步连续触发多次更新
const scheduler = (job) => {
  Promise.resolve().then(job) // 在下一个微任务中执行
}

effect(() => {
  console.log(count.value)
}, { scheduler })

count.value = 1 // 不会立即执行
count.value = 2 // 不会立即执行
count.value = 3 // 只有最后一次的变更会在微任务中执行

Vue 组件更新调度

JavaScript 复制代码
effect(() => {
  // 组件渲染逻辑
}, {
  scheduler: queueJob // 加入更新队列,而不是立即更新
})

防抖、节流

JavaScript 复制代码
const debounceScheduler = debounce((job) => job(), 100)

effect(() => {
  // 高频触发的逻辑
}, { scheduler: debounceScheduler })

调度器用法

JavaScript 复制代码
import { ref, effect } from '../dist/reactivity.esm.js'

const count = ref(0)

effect(() => {
  console.log('Effect', count.value)
}, {
  scheduler() {
    console.log('触发调度器')
  }
})

setTimeout(() => {
  count.value = 1
}, 1000)

当前效果

arduino 复制代码
Effect 0
// 1秒后
Effect 1

预期效果

使用调度器后,setTimeout 中的赋值不再直接触发 effect 的执行:

arduino 复制代码
Effect 0
// 1秒后
触发调度器

Class 类知识补充

要实现这个可选的调度器,我们需要利用 JavaScript Class 的特性。

我们先来补充这个知识点:

  • 一般的类
JavaScript 复制代码
class Person {
  constructor(name) {
    this.name = name
  }

  sayHi() {
    console.log('我是原型方法', this.name)
  }
}

const p = new Person('张三')

p.sayHi()
// 输出:我是原型方法 张三
  • 实例属性覆盖原型方法

当实例上存在与原型链上同名的方法时,会优先调用实例上的方法。

JavaScript 复制代码
class Person {
  constructor(name) {
    this.name = name
  }

  sayHi() {
    console.log('我是原型方法', this.name)
  }
}

const p = new Person('张三')

// 在实例 p 上直接定义一个 sayHi 方法
p.sayHi = function() {
  console.log('我是实例属性', this.name)
}

p.sayHi()
// 输出:我是实例属性 张三

实现调度器

要求

  1. 当依赖更新时,如果用户提供了 scheduler,则执行 scheduler
  2. 如果没有传入 scheduler,仍然要执行 run()

实现思路

  1. 用户传入的 scheduler 是一个可选方法。
  2. 当用户传入时,我们可以利用"实例属性覆盖原型方法"的特性,将其附加到 ReactiveEffect 实例上。
  3. 为了保证更新的入口稳定,我们新建一个 notify 方法,由它来决定是调用实例上的 scheduler 还是原型上的 scheduler
TypeScript 复制代码
export let activeSub;

export class ReactiveEffect { 
  constructor(public fn: Function) {}

  run() {
    const prevSub = activeSub
    activeSub = this
    
    try {
      return this.fn()
    } finally {
      activeSub = prevSub
    }   
  }

  /*
   * 如果依赖数据发生变化,由此方法通知更新。
   */
  notify() {
    this.scheduler()
  }
  
  /*
   * 默认的调度器,直接调用 run 方法。
   * 如果用户传入了自定义的 scheduler,它会作为实例属性覆盖掉这个原型方法。
   */
  scheduler() {
    this.run()
  }
}

export function effect(fn, options) {
  const e = new ReactiveEffect(fn)
  
  // 将 options (包含 scheduler) 合并到 effect 实例上
  Object.assign(e, options)
  
  e.run()

  /*
   * 绑定 this,确保 runner 函数在外部被调用时,
   * 内部的 this 依然指向 effect 实例 e。
   * 如果直接 return e.run,会丢失 this 上下文。
   */
  const runner = e.run.bind(e)

  // 将 effect 实例挂载到 runner 函数上,方便外部访问
  runner.effect = e
  
  return runner
}

相应地,propagate 函数中更改为执行 notify 方法:

TypeScript 复制代码
export function propagate(subs) {
  // ...
  // 更改为执行 notify 方法
  // 因为 scheduler 方法可能会被用户覆盖,
  // 因此使用 notify 作为稳定的更新入口。
  queuedEffect.forEach(effect => effect.notify())
}

丢失 this 是指什么?

如果直接返回 e.run 会发生什么?这就涉及到了 this 指向问题。

请参考下方示例:

JavaScript 复制代码
export function effect(fn, options) {
  const e = new ReactiveEffect(fn)
  Object.assign(e, options)
  e.run()
  
  return e.run  // 丢失 this
}

// 使用时
const runner = effect(() => console.log('effect'))
runner() // 错误! 此时 run 方法内部的 this 是 undefined 或 window

图解 notify() 执行步骤

执行原型方法 (左)

这是 effect 的默认行为,当我们这样使用它时:effect(() => { ... })

  1. 数据变化 → propagate :当响应式数据的值被修改时,会触发其 setter,最终由 propagate 函数开始遍历依赖该数据的 effect
  2. propagateeffect.notify() :在 propagate 的循环里面,我们统一调用 effect.notify(),让它作为更新的固定入口点。
  3. effect.notify()scheduler()notify() 内部会去调用 this.scheduler()。在这种情况下,因为我们创建 effect 时没有提供任何 options,所以 effect 实例上并不存在 自己的 scheduler 属性。
  4. scheduler()run() :根据 JavaScript 的原型链规则,它会去寻找 ReactiveEffect 原型上的 scheduler() 方法。我们默认的 scheduler() 方法,就是直接调用 this.run()。因此,effect 的核心逻辑被立即执行。

使用调度器 (右)

  1. 数据变化 → propagateeffect.notify() :前两个步骤和原型状况完全相同:数据变更,propagate 遍历并调用 effect.notify()
  2. effect.notify()scheduler()notify() 内部会调用 this.scheduler()。但关键在于,这次我们在创建 effect 时,通过 options 传入了一个自定义的 scheduler 函数。
  3. scheduler() → 用户的调度器逻辑Object.assign(e, options) 会把用户的 scheduler 函数作为一个实例属性 附加到 effect 对象上。根据 JavaScript 的优先级规则,实例属性高于原型方法。因此,程序在 effect 实例上直接找到这个自定义的 scheduler 并执行它,而不会再去查找原型链。

今天,我们通过引入调度器,将 effect 的核心逻辑(做什么)与执行策略(何时做)进行了分离。其中 fn 负责"做什么",而 scheduler 负责决定"什么时候做"。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

相关推荐
Mintopia2 小时前
🚀 Next.js 全栈 E2E 测试:Playwright vs Cypress
前端·javascript·next.js
原生高钙2 小时前
JS设计模式指南
前端·javascript
拳打南山敬老院2 小时前
漫谈 MCP 构建之Resources篇
前端·后端·ai编程
golang学习记2 小时前
从0死磕全栈第九天:Trae AI IDE一把梭,使用react-query快速打通前后端接口调试
前端
超人9212 小时前
我用纯前端技术打造了一个开发者工具箱,10+实用工具助力提效!
前端
bug_kada2 小时前
详解 React useCallback & useMemo
前端·react.js
Mintopia2 小时前
⚡ WebAssembly 如何加速 AIGC 模型在浏览器中的运行效率?
前端·javascript·aigc
AAA_Tj2 小时前
前端动画技术全景指南:四大动画技术介绍
前端
断竿散人2 小时前
乾坤微前端框架的沙箱技术实现原理深度解析
前端·javascript·前端框架