Alien Signals 技术分析之副作用 (Effect)(三)

10 分钟
deepseek/deepseek-chat-v3-0324 Logo

AI 摘要 (由 deepseek/deepseek-chat-v3-0324 生成)

本文介绍了响应式系统中的副作用(Effect)机制,它能够自动监听信号(Signal)和计算属性(Computed)的变化并执行指定操作。Effect通过依赖追踪实现自动化响应,在创建时立即执行一次并记录依赖项,当依赖变化时自动重新运行。文章详细说明了Effect的创建、执行和停止方法,以及其内部工作机制,包括依赖收集和通知传播流程。Effect是连接响应式数据与实际应用行为的关键,与Signal和Computed共同构成了响应式编程的基础。

10.42s
~15344 tokens

在前面的章节中,我们学习了 信号 (Signal) 如何存储基本状态,以及 计算属性 (Computed) 如何根据现有状态派生出新的状态。我们现在拥有了描述和管理应用数据的工具。但是,光有数据还不够,我们通常希望当数据变化时,能够自动执行某些操作,比如更新用户界面、在控制台打印日志、或者向服务器发送请求。

这就是 副作用 (Effect) 发挥作用的地方!

什么是副作用?为什么需要它?

想象一下,我们有一个简单的计数器应用。我们用一个信号 (Signal) 来存储当前的计数值。

typescript
import { signal } from './src/index.js';
const counter = signal(0);
// 我们想在每次 counter 变化时,都在控制台打印出新的值
// 如果手动操作:
console.log("当前计数值:", counter()); // 输出: 当前计数值: 0
counter(1); // 增加计数
// 需要手动再次打印!
console.log("当前计数值:", counter()); // 输出: 当前计数值: 1
counter(2); // 再次增加计数
// 又要手动打印!
console.log("当前计数值:", counter()); // 输出: 当前计数值: 2

和我们在计算属性 (Computed) 中遇到的问题类似,手动在每次数据变化后去执行相应的操作是非常繁琐和容易出错的。我们希望有一种方法能够自动监听数据的变化,并在变化发生时自动执行我们指定的操作。

副作用 (Effect) 就是实现这种自动响应的机制。

你可以把它想象成一个自动化的警报系统

  • 传感器 (依赖项): 你的 信号 (Signal)计算属性 (Computed) 就像是传感器,监测着某个数据。
  • 警报逻辑 (副作用函数): 你提供一个函数,定义当传感器状态改变时需要执行的操作(比如发出警报声、发送通知)。
  • 自动触发: 一旦你设置好这个警报系统,它就会自动运行。当它监控的任何一个传感器(依赖项)的状态发生变化时,警报逻辑(你提供的函数)就会被自动重新执行

副作用通常用于执行那些与响应式系统状态相关的外部操作(即不直接产生新状态,而是影响系统外部环境的操作),例如:

  • 更新 DOM(比如在网页上显示最新的数据)。
  • 打印日志到控制台。
  • 发出网络请求。
  • 与第三方库交互。

如何使用副作用?

使用 effect 函数可以创建一个副作用。你需要传递给它一个副作用函数 (effect function),这个函数包含了你希望在依赖项变化时执行的操作。

1. 创建副作用

让我们用 effect 来自动化上面例子中的日志打印:

typescript
import { signal, effect } from './src/index.js'; // 导入 effect 函数
const counter = signal(0);
// 创建一个副作用来监听 counter 的变化
effect(() => {
  // 这个函数就是副作用逻辑
  const currentValue = counter(); // 在函数内部读取 counter 信号
  console.log("Effect 侦测到变化,当前计数值:", currentValue);
});
console.log("副作用已创建并立即执行了一次。");

输出:

Effect 侦测到变化,当前计数值: 0

副作用已创建并立即执行了一次。

解释:

  • 我们调用 effect 并传入一个箭头函数 () => { ... }
  • 这个函数内部读取了 counter 信号的值 (counter())。master 的响应式系统会自动侦测到这次读取,并将 counter 信号标记为这个 effect依赖项
  • 重要特点: effect 在创建时会立即执行一次其内部的函数。这就是为什么我们马上看到了第一条日志输出 "Effect 侦测到变化,当前计数值: 0"。这确保了你的副作用逻辑能基于初始状态运行一次。

2. 依赖项变化时的自动触发

现在,让我们看看当 counter 信号变化时会发生什么:

(接上例)
typescript
// (接上例)
console.log("准备更新 counter...");
counter(1); // 更新 counter 信号的值
console.log("counter 已更新,触发 effect 重新执行。");
counter(2); // 再次更新 counter
console.log("counter 再次更新,再次触发 effect。");

输出:

// ...之前的输出...

准备更新 counter...

Effect 侦测到变化,当前计数值: 1 // effect 自动重新执行了!

counter 已更新,触发 effect 重新执行。

Effect 侦测到变化,当前计数值: 2 // effect 又自动重新执行了!

counter 再次更新,再次触发 effect。

解释:

  • 当我们执行 counter(1) 时,counter 信号 (Signal) 的值发生了变化。
  • 因为 effect 在首次运行时读取了 counter,响应式系统已经知道这个 effect 依赖于 counter
  • 所以,当 counter 更新时,系统会自动通知这个 effect:“你的依赖项变了!”
  • 然后,系统会重新执行我们提供给 effect 的那个函数 () => { ... }。这就是为什么控制台打印了 "Effect 侦测到变化,当前计数值: 1"。
  • 同样,当我们执行 counter(2) 时,这个过程会再次发生,导致函数又一次执行,打印出 "Effect 侦测到变化,当前计数值: 2"。

总结一下 effect 的行为:

  1. effect(fn) 创建一个副作用。
  2. 副作用函数 fn立即执行一次
  3. fn 执行期间,所有被读取的信号 (Signal) 或 计算属性 (Computed) 都会被自动追踪为该 effect 的依赖项。
  4. 当任何一个依赖项的值发生变化时,副作用函数 fn 会被自动重新执行

3. 停止副作用

有时你可能希望停止一个副作用,让它不再响应依赖项的变化。effect 函数会返回一个停止函数 (stop function)。调用这个函数就可以停止对应的副作用。

typescript
import { signal, effect } from './src/index.js';
const counter = signal(10);
// 创建 effect 并获取停止函数
const stopEffect = effect(() => {
  console.log("Effect 运行中,计数值:", counter());
});
console.log("--- 第一次更新 ---");
counter(11); // Effect 会执行
console.log("--- 停止 Effect ---");
stopEffect(); // 调用停止函数
console.log("--- 第二次更新 ---");
counter(12); // Effect 不再执行
console.log("Effect 已停止。");

输出:

Effect 运行中,计数值: 10 // 首次执行
--- 第一次更新 ---
Effect 运行中,计数值: 11 // 因 counter(11) 而执行
--- 停止 Effect ---
--- 第二次更新 ---
Effect 已停止。 // counter(12) 后,Effect 没有再打印日志

解释:

  • effect(...) 返回了一个名为 stopEffect 的函数。
  • 在调用 stopEffect() 之前,effect 正常工作,每次 counter 变化都会重新运行。
  • 调用 stopEffect() 之后,这个 effect 就被“清理”了。它与它的依赖项(counter)之间的连接被断开。
  • 因此,当 counter(12) 执行时,虽然 counter 的值变了,但之前那个 effect 不再收到通知,也不会再执行了。

这对于在组件卸载或特定条件满足时清理不再需要的自动行为非常有用。

副作用的内部机制(初步了解)

理解副作用的内部工作原理能帮助我们更好地认识响应式系统。

创建时发生了什么? (effect 函数)

当你调用 effect(fn) 时:

  1. 内部会创建一个副作用对象 (Effect Object)。这个对象类似于计算属性 (Computed) 对象,也需要存储:
    • fn: 你传入的副作用函数。
    • flags: 一些状态标记,比如标记自己是副作用 (SubscriberFlags.Effect)。
    • deps: 一个列表(链表),用来记录这个副作用依赖于哪些信号或计算属性。初始为空。
    • subs: 副作用通常不作为其他计算的依赖(它在依赖链的末端),所以这个可能为空或不被主要使用。
    • (它也可能与 EffectScope 相关联,我们将在后面的章节 副作用作用域 (EffectScope) 讨论)。
  1. 立即执行与依赖追踪: 这是与 computed 最大的不同之处。effect 不会等待被读取,而是立即开始第一次执行:
    • 调用 startTracking(this)。设置全局变量 activeSub = thisthis 指向新创建的副作用对象)。
    • 执行 fn: 调用用户提供的 this.fn() 函数。
    • 依赖收集:fn 函数执行期间,如果它读取了某个信号 (Signal)(比如 counter()),信号的读取逻辑会检测到 activeSub(即当前副作用对象),并调用 link(signalObject, this),建立信号与副作用之间的依赖关系(将副作用添加到信号的 subs 列表,将信号添加到副作用的 deps 列表)。
    • 调用 endTracking(this)。清除 activeSub,完成依赖追踪。
  1. 返回停止函数 (effectStop): 这个函数绑定了刚才创建的副作用对象,调用它时可以清理依赖关系。
src/index.ts (简化示意)
typescript
// src/index.ts (简化示意)
export function effect<T>(fn: () => T): () => void {
  // 1. 创建 Effect 对象
  const e: Effect = {
    fn,
    subs: undefined,
    subsTail: undefined,
    deps: undefined,
    depsTail: undefined,
    flags: SubscriberFlags.Effect, // 标记为 Effect
  };
  // (如果当前在 EffectScope 内,建立链接,暂时忽略)
  // if (activeScope !== undefined) { link(e, activeScope); }
  // 2. 立即执行并追踪依赖
  const prevSub = activeSub;
  activeSub = e; // 将当前 effect 设为活动订阅者
  startTracking(e); // 准备追踪依赖
  try {
    e.fn(); // 执行用户函数,期间读取的信号会自动 link 到 e
  } finally {
    endTracking(e); // 完成追踪,清理未使用的依赖
    activeSub = prevSub; // 恢复之前的活动订阅者
  }
  // 3. 返回绑定了 Effect 对象的停止函数
  return effectStop.bind(e);
}
// src/index.ts (简化示意 - effectStop)
function effectStop(this: Subscriber): void {
  // startTracking 和 endTracking 配合可以清理所有依赖
  startTracking(this);
  endTracking(this);
  // (可能还需要将 effect 从其 scope 中移除等清理操作)
}

依赖项变化时发生了什么?

当副作用依赖的某个信号 (Signal)(比如 counter)的值发生变化时:

  1. 信号的写入逻辑 (signalGetterSetter 带参数调用) 会执行。
  2. 它会检查自己的订阅者列表 (subs)。因为在 effect 第一次执行时,counter 通过 link 函数把这个 effect 加入了自己的 subs 列表。
  3. 信号会调用 propagate(this.subs) 来通知它的所有订阅者。
  4. propagate 函数会遍历订阅者列表,找到对应的 effect 对象。
  5. 它会给 effect 对象添加一个标记(例如 NotifiedPendingEffect),表明这个副作用需要被处理。
  6. propagate 可能会将这个 effect 对象添加到一个待处理的通知缓冲区 (notifyBuffer) 中。
  7. 如果当前不在批量更新模式 (batchDepth === 0),信号的写入逻辑会调用 processEffectNotifications()
  8. processEffectNotifications() 会遍历通知缓冲区,找到被标记的 effect
  9. 对于每个需要执行的 effect,它会调用 notifyEffect(e)
  10. notifyEffect 内部(或者它调用的函数):
    • 再次设置 activeSub = e 并调用 startTracking(e)
    • 重新执行 e.fn()
    • 调用 endTracking(e) 并恢复 activeSub

这样,依赖项的变化就触发了副作用函数的重新执行。

简单流程图

关键点:

  • effect 在创建时立即执行一次,并在此期间收集依赖。
  • 依赖项变化时,effect 被标记并排队等待执行(通过 propagatenotifyBuffer)。
  • 系统(通过 processEffectNotificationsnotifyEffect)负责重新执行被标记的 effect 函数。
  • 每次重新执行 effect 函数时,也会进行依赖追踪,允许依赖关系动态变化。
  • effect 返回的函数可以用来停止副作用。

总结

在本章中,我们学习了副作用 (Effect),它是响应式系统中用于执行操作的重要部分:

  • 副作用是一个自动运行的函数,当其内部读取的任何响应式依赖(信号 (Signal)计算属性 (Computed))发生变化时,该函数会重新执行
  • 使用 effect(fn) 创建副作用,fn 会在创建时立即执行一次
  • 副作用会自动追踪依赖,无需手动声明。
  • 它通常用于执行与响应式状态相关的外部操作,如更新 UI、日志记录、网络请求等。
  • effect 函数返回一个停止函数,可以用来手动停止副作用的自动执行和依赖追踪。

副作用将我们的响应式数据与现实世界连接起来,让数据的变化能够驱动应用的实际行为。

下一步

至此,我们已经学习了 master 响应式系统的三个核心用户 API:

  1. 信号 (Signal):存储基本状态。
  2. 计算属性 (Computed):派生状态。
  3. 副作用 (Effect):响应状态变化并执行操作。

这三者共同构成了响应式编程的基础。但是,它们是如何协同工作的?是什么在幕后默默地进行依赖追踪、标记更新和触发执行?