Skip to content

第 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/>挨个通知这些信号的订报人"]
  1. 临时耳朵:造一个临时节点,挂到耳朵上——fn() 里读到的信号,都会把它记进听众链(收集);
  2. 攒批fn() 里如果有写,先攒着,最后一起结算(免得发一半刊);
  3. 手动发刊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 干的)——这就是"收集";++batchDepthfn() 里的写先不发刊;
  • ③ 手动发刊:跑完,沿着 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;
  • triggerb() 被收集,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 的用法:改完消息,喊一嗓子

🏁 本章小结

  1. 手动发刊trigger)补上了"直接改消息"的路:改完喊一嗓子,订报人照常醒来;
  2. 它的三板斧:临时耳朵收集读到的信号 → 攒批(fn 里的写先不发刊)→ 挨个手动发刊(先摘耳朵,再 propagate + shallowPropagate);
  3. 36 个测试全绿——消息城现在什么路都通:信号写、手动改、都能叫醒订报人。

手动发刊借了攒批的力——++batchDepth 那一行。老墨在下一张图纸上写下:

攒批,该正式登场了。

下一章,把攒批这位"老朋友"正式打磨一遍——看看它和订报人的一生、手动发刊怎么配合。