Appearance
第 17 章 手动发刊:trigger
📖 价目表被"外人"改了
"老墨!出事了!"小铃冲进办公室,手里举着一张价目表,"蛋糕店的价目表是数组——客人点了单,代码就往数组里加一个名字。可你看:"
ts
const orders = signal<string[]>([]);
const orderCount = computed(() => orders().length);
orders().push('草莓蛋糕'); // 直接往数组里塞!
console.log(orderCount()); // 还是 0?!"客人明明下单了,订报人却不知道!"小铃很着急。
老墨看了一眼,不慌不忙:"因为 orders().push(...) 是直接改——它没有走信号的写(orders(...)),所以传播压根没被惊动。"
"那……订报人订了这块价目表,不就白订了?"
"别急。消息城早料到了这种情况——我们造一条手动发刊的路:改完消息,手动喊一声'发刊',把订报人都叫醒。"
ts
trigger(orders); // 手动发刊!
console.log(orderCount()); // 1 ✅"trigger 就是'手动发刊'的按钮——告诉消息城:有些消息被我直接改了,去通知订报人吧。"
🎯 本章要解决什么
一句话问题:消息被直接改(绕过信号赋值)时,怎么手动通知所有订报人?
读完这一章,你会知道:
- 什么时候需要
trigger:直接改了消息,传播没被惊动; trigger怎么工作:临时耳朵收集读到的信号 → 挨个手动发刊;- 为什么
trigger里要攒批、要戴正在跑的章。
💡 概念讲解
问题:绕过信号的"直接改"
信号的写(orders([...]))会触发传播。但 orders().push(...) 是直接改数组内部——信号根本不知道,传播根本没跑。
mermaid
graph LR
write["orders([...]) 走信号的写"] --> propagate["传播 📣 自动发刊"]
push["orders().push(...) 直接改"] --> nothing["❌ 传播没被惊动"]trigger 的三板斧
trigger(fn) 怎么补上这条路?三步:
mermaid
graph LR
step1["① 临时耳朵 👂<br/>造一个临时的'正在听'的节点"] --> step2["② 收集 📝<br/>fn() 读到的信号都记下来"]
step2 --> step3["③ 手动发刊 📣<br/>挨个通知这些信号的订报人"]- 临时耳朵:造一个临时节点,挂到耳朵上——
fn()里读到的信号,都会把它记进听众链(收集); - 攒批:
fn()里如果有写,先攒着,最后一起结算(免得发一半刊); - 手动发刊:
fn()跑完,把收集到的信号挨个propagate+shallowPropagate——订报人被叫醒、中转站被核实。
为什么临时耳朵要戴"正在跑"的章
临时耳朵挂了 RecursedCheck(正在跑)——万一 fn() 里读了一个信号又写它,传播会路过这个临时耳朵:看到"正在跑",只盖递归记号、不排队。不然它会像一个假订报人被排进队里,发刊时一叫——它根本没有run,直接崩掉。
✍️ 动手写代码
今天加一个函数:trigger。它借用了消息城所有现成的零件——临时耳朵、攒批、传播、核实。
第 1 步:写 trigger
在 effectScope 附近(effect 后面)加 trigger:
ts
// 手动发刊:直接改完消息后,手动通知所有订报人
export function trigger(fn: () => void): void {
// ① 临时耳朵:fn 里读到的信号都会被记下来
const sub: ReactiveNode = {
flags: ReactiveFlags.Watching | ReactiveFlags.RecursedCheck,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const prevSub = activeSub;
activeSub = sub;
++batchDepth; // ② 攒批:fn 里的写先不发刊
try {
fn(); // 读信号(收集) / 直接改消息
} finally {
activeSub = prevSub;
sub.flags = ReactiveFlags.None;
// ③ 手动发刊:把收集到的信号挨个通知
let l = sub.deps;
while (l !== undefined) {
const dep = l.dep;
const next = l.nextDep;
unlink(l); // 摘掉临时耳朵自己
const subs = dep.subs;
if (subs !== undefined) {
propagate(subs, runDepth > 0);
shallowPropagate(subs);
}
l = next;
}
if (--batchDepth === 0) {
flush(); // 攒批攒够了,一起发刊
}
}
}对照"三板斧"看:
- ① 临时耳朵:
sub就是一个普通的 ReactiveNode,挂到耳朵上(activeSub = sub); - ② 收集 + 攒批:
fn()跑的时候,读到的信号会把sub记进听众链(link干的)——这就是"收集";++batchDepth让fn()里的写先不发刊; - ③ 手动发刊:跑完,沿着
sub的依赖链走,把收集到的每个信号摘掉临时耳朵,然后propagate(盖章排队)+shallowPropagate(升级过期章)——订报人该醒的醒、中转站该算的算。
为什么先摘临时耳朵再发刊?
propagate 发刊时,如果临时耳朵还挂在听众链上,它会像一个"假订报人"被排队。先 unlink 摘掉它,发刊的对象就是真正的订报人。
第 2 步:写新测试(手动发刊)
在 tests 文件夹里新建 trigger.spec.ts:
ts
import { describe, expect, test } from 'vitest';
import { signal, computed, effect, trigger } from '../src/signal';
describe('手动发刊', () => {
test('直接改消息不触发,trigger 手动发刊才触发', () => {
const arr = signal<number[]>([]);
const length = computed(() => arr().length);
expect(length()).toBe(0);
arr().push(1); // 直接改,绕过信号
expect(length()).toBe(0); // 没触发
trigger(arr); // 手动发刊!
expect(length()).toBe(1);
});
test('一次手动发刊多个信号', () => {
const logs: number[] = [];
const src1 = signal<number[]>([]);
const src2 = signal<number[]>([]);
const total = computed(() => src1().length + src2().length);
effect(() => {
logs.push(total());
});
expect(logs).toEqual([0]);
src1().push(1);
src2().push(2);
trigger(() => {
src1();
src2();
});
expect(logs).toEqual([0, 2]);
expect(total()).toBe(2);
});
test('trigger 里先读再写,写也攒进同一批', () => {
const logs: number[] = [];
const a = signal(1);
effect(() => {
logs.push(a());
});
expect(logs).toEqual([1]);
trigger(() => {
a(); // 读:收集
a(2); // 写:进同一批
});
expect(logs).toEqual([1, 2]);
});
});第一个测试就是价目表的故事:直接改不触发,trigger 手动发刊。第二个测试一次发刊两个信号。第三个测试证明 fn 里"先读再写"也能攒成一批、一次结算。
第 3 步:叫检查员!
bash
npm test🚀 现在跑一下,你应该看到:
✓ tests/trigger.spec.ts (3 tests) ✓ tests/scope.spec.ts (3 tests) ✓ tests/cleanup.spec.ts (5 tests) ✓ tests/recursion.spec.ts (2 tests) ✓ tests/checkup.spec.ts (2 tests) ✓ tests/checkdirty.spec.ts (2 tests) ✓ tests/subscription.spec.ts (2 tests) ✓ tests/naive-problems.spec.ts (2 tests) ✓ tests/batch.spec.ts (4 tests) ✓ tests/computed.spec.ts (4 tests) ✓ tests/signal.spec.ts (5 tests) ✓ tests/money.spec.ts (2 tests) Test Files 12 passed (12) Tests 36 passed (36)36 个,全绿。 价目表的手动发刊按钮造好了——直接改的消息,也能叫醒订报人。🎉
🧰 TS 小课堂:函数也是数据
trigger 的用法很特别——它吃进去一个函数:
ts
trigger(arr); // 把 arr 这个函数当参数传进去!
trigger(() => { src1(); src2(); }); // 或者传一个箭头函数这在 JS/TS 里完全合法,因为:函数也是数据——它可以像数字、文字、数组一样:
- 存进变量:
const read = arr;(arr 本身就是个函数); - 传给别的函数:
trigger(arr)(arr 被 trigger 吃掉); - 被别的函数返回:
signal(1)返回一个函数(第 3 章起就这样了)。
把"函数当参数传"的那个函数,有个名字叫回调(callback)——"我把这个函数交给你,你到点叫我"。消息城里到处是回调:
| 函数 | 吃进去的回调 | 什么时候"叫"它 |
|---|---|---|
signal(1) | —— | (它自己就是回调) |
computed(getter) | 计算规则 | 被问到/过期时 |
effect(fn) | 干活规则 | 出生、消息一变时 |
trigger(fn) | 收集规则 | 立刻跑一遍,跑完发刊 |
函数是数据——这是 JS/TS 最强大的本事之一,消息城从头到尾都在用它。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 手动发刊 (trigger) | 直接改完消息后,手动通知订报人 | 消息城的"喊一嗓子"按钮 |
| 临时耳朵 | trigger 造的临时"正在听"节点 | fn 读到的信号都被它收集 |
| 收集 | fn 跑的时候,把读到的信号记下来 | 临时耳朵的听众链 |
| 回调 (callback) | 当参数传给别的函数的函数 | "到点叫我" |
手动发刊是正式消息城术语(CONTEXT.md 里早就有它);临时耳朵/收集/回调是本章用语。
📦 章末完整代码
这一章你没有新文件,signal.ts 加了一个函数 + 一个新测试文件。文件夹结构:
signal-book/
├── demo/
│ └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│ ├── money.ts
│ └── signal.ts ← 加了:trigger
└── tests/
├── money.spec.ts
├── signal.spec.ts
├── computed.spec.ts
├── batch.spec.ts
├── naive-problems.spec.ts
├── subscription.spec.ts
├── checkdirty.spec.ts
├── checkup.spec.ts
├── recursion.spec.ts
├── cleanup.spec.ts
├── scope.spec.ts
└── trigger.spec.ts ← 今天的新测试src/signal.ts 的其他部分和第 16 章那份一样,今天只加了一个 trigger 函数(第 1 步那份,放在 effectScope 附近)。
tests/trigger.spec.ts 的完整内容,就是上面"第 2 步"抄的那份——抄全了就不用再看。
✅ 跑通了? 只要
npm test打出36 passed,手动发刊的路就通了——直接改的消息,也能叫醒订报人。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃先走了一次正常的写,又手动发了一次刊。猜猜 logs 最后长什么样?
ts
const logs: number[] = [];
const a = signal(1);
const b = signal(10);
effect(() => logs.push(a() + b()));
a(2);
trigger(() => {
b(); // 读:收集
b(20); // 写:进同一批
});👉 点开看答案
logs 是 [11, 12, 32]。
- 注册:1 + 10 = 11;
a(2):正常的写,自动发刊 → 2 + 10 = 12;trigger:b()被收集,b(20)攒进同一批——最后手动发刊 → 2 + 20 = 32。
trigger 只发一次刊,订报人只醒一次——b() 的读和 b(20) 的写,攒成了一笔。
2. 帮小铃找 bug(抓错)
小铃写 trigger 时,把"先摘临时耳朵"忘了——发刊前没有 unlink:
ts
// 挨个手动发刊
let l = sub.deps;
while (l !== undefined) {
const dep = l.dep;
const next = l.nextDep;
// 咦?摘临时耳朵呢?
const subs = dep.subs;
if (subs !== undefined) {
propagate(subs, runDepth > 0);
shallowPropagate(subs);
}
l = next;
}结果:测试崩了(或卡住)。为什么?
👉 点开看答案
因为临时耳朵还挂在听众链上,被当成订报人排进了队。
propagate 走听众链时,临时耳朵(戴着"正在跑"的章)会被盖上递归记号、不排队——可万一它没被认出(比如它已经被 sub.flags = None 摘掉了"正在跑"的章),它就像个"假订报人"被排进队。发刊时 flush 一叫它 run()——它根本没有 run,直接崩掉。
先 unlink(l) 把它从听众链上摘掉,发刊的对象才是真正的订报人。
3. 填空(补全)
trigger 里"挨个手动发刊"少了一行。补全它:
ts
if (subs !== undefined) {
/* 这里该写什么? */
shallowPropagate(subs);
}👉 点开看答案
写:
ts
propagate(subs, runDepth > 0);先 propagate(盖章、排队订报人),再 shallowPropagate(把"待核实"升级成"过期")——两步一起,订报人该醒的醒、中转站该算的算。这和信号写里做的一模一样。
4. 小挑战(购物车)
用 trigger 造一个购物车:
cart信号:一个数组(装商品名);total计算值:数组长度(买了几样);- 一个订报人打印
买了 X 样; - 直接
push两次(绕过信号),然后trigger一次——验证:订报人只醒一次、total正确、日志只有两条。
👉 点开看答案
一种写法:
ts
const logs: string[] = [];
const cart = signal<string[]>([]);
const total = computed(() => cart().length);
effect(() => {
logs.push(`买了 ${total()} 样`);
});
expect(logs).toEqual(['买了 0 样']);
cart().push('面包'); // 直接改 ×2
cart().push('牛奶');
trigger(cart); // 手动发刊一次
expect(logs).toEqual(['买了 0 样', '买了 2 样']); // 只醒一次!
expect(total()).toBe(2);两次直接改,攒成一次手动发刊——订报人只醒一次,total 直接算对。这就是 trigger 的用法:改完消息,喊一嗓子。
🏁 本章小结
- 手动发刊(
trigger)补上了"直接改消息"的路:改完喊一嗓子,订报人照常醒来; - 它的三板斧:临时耳朵收集读到的信号 → 攒批(fn 里的写先不发刊)→ 挨个手动发刊(先摘耳朵,再 propagate + shallowPropagate);
- 36 个测试全绿——消息城现在什么路都通:信号写、手动改、都能叫醒订报人。
手动发刊借了攒批的力——++batchDepth 那一行。老墨在下一张图纸上写下:
攒批,该正式登场了。
下一章,把攒批这位"老朋友"正式打磨一遍——看看它和订报人的一生、手动发刊怎么配合。