Skip to content

第 20 章 引擎与整车:createReactiveSystem

📖 把引擎拆出来

"老墨,"小铃看着满满一屏的 signal.ts,忽然问,"咱们的信号库,现在是一个大整块——连线、盖章、核实、订报人的一生、订阅小组、手动发刊……全在一锅里。真实库也是这样吗?"

老墨笑了:"真实库不是。真实库把代码分成了两半:引擎整车。"

"引擎……整车?"

"引擎,就是那套最通用的图算法——连接线怎么牵、传播怎么走、怎么核实脏不脏。它不认识'信号''订报人'——它只认识线。"

"那'信号''订报人'呢?"

"它们是整车——消息城专属的那一层:公告牌、中转站、订报人的一生……引擎不管这些,整车负责造它们,然后把'该怎么处理每种节点'教给引擎。"

老墨在纸上画:

mermaid
graph TD
    subgraph 引擎 engine(src/system.ts)
        link["link / unlink(牵线摘线)"]
        propagate["propagate(传播盖章)"]
        checkDirty["checkDirty(核实)"]
        shallow["shallowPropagate(浅传播)"]
    end
    subgraph 整车 index(src/index.ts)
        api["signal / computed / effect / effectScope / trigger ……"]
        cb["update / notify / unwatched(引擎的三个插口)"]
    end
    cb -->|"插进引擎"| link
    cb -->|"插进引擎"| propagate
    cb -->|"插进引擎"| checkDirty
    cb -->|"插进引擎"| shallow

"引擎有三个插口update(重算/消费)、notify(排队订报人)、unwatched(没人订了怎么办)。整车把这三个插口接上,引擎就能动了——一套引擎,可以插到任何整车上去。这就是 createReactiveSystem 的由来:'造一套响应式系统'。"

🎯 本章要解决什么

一句话问题:怎么把信号库拆成引擎(system)和整车(index)——引擎只认线和章,整车负责造消息城?

读完这一章,你会知道:

  • 引擎和整车各管什么;
  • createReactiveSystem 工厂:收三个插口,返回五件套(link/unlink/propagate/checkDirty/shallowPropagate);
  • 文件怎么分:src/system.ts + src/index.ts(和真实库一模一样的结构)。

💡 概念讲解

引擎的"三插口"

引擎只认线和章,但它有三个地方需要"问整车":

插口引擎什么时候问整车怎么答
update(sub)核实的时候:"这个节点,真变了吗?"中转站重算一遍;信号对一对新旧
notify(sub)传播的时候:"这个订报人,排队!"把它排进发刊名单(带父订报人、内层先跑)
unwatched(sub)摘线的时候:"它没人订了!"中转站歇业、订报人辞职、小组解散

引擎问,整车答——引擎不管答案是什么,只管"该问的时候问"

工厂:createReactiveSystem

ts
const { link, unlink, propagate, checkDirty, shallowPropagate } =
	createReactiveSystem({ update, notify, unwatched });

createReactiveSystem 是一个工厂:吃进三个插口,吐出一套引擎五件套。整车只需要在开头接一次线,之后到处用五件套干活。

为什么分开

  1. 和真实库对齐:真实库就是 src/system.ts(引擎)+ src/index.ts(整车)——分开了,第 21 章毕业时才能逐字对照;
  2. 引擎可以复用:想造一套"另一种 API 的信号库"?不用重写算法,把引擎拿过来,接上自己的三个插口就行(真实库的 README 就教这个)。

✍️ 动手写代码

今天的活是拆家——把 signal.ts 拆成两个文件。拆完,行为一个字不变,48 个测试(对我们 _dev 来说)继续全绿。

不想一步步抄?

这章的代码量不小。先照着抄 system.ts,再看"完整代码"里的 index.ts 改动清单。抄到一半乱了,直接跳"章末完整代码"。

第 1 步:建引擎(src/system.ts)

新建 src 文件夹里的 system.ts,把"图纸 + 六面旗 + 五件套"搬进去,包成一个工厂:

ts
// src/system.ts —— 信号库的引擎(第 20 章)
// 引擎只认"线"和"章"——它不认识信号/订报人。
// 三个插口(update/notify/unwatched)由整车提供。

// 小旗子(标志):告诉引擎"现在是什么状态"
export const enum ReactiveFlags {
	None = 0,           // 什么都没有
	Mutable = 1,        // 可变
	Watching = 2,       // 盯着
	RecursedCheck = 4,  // 正在跑
	Recursed = 8,       // 递归记号
	Dirty = 16,         // 过期
	Pending = 32,       // 待核实
	HasChildEffect = 64,// 我有孩子
}

// 节点:消息城里的一切,都是节点
export interface ReactiveNode {
	deps?: Link;            // 我的依赖链(链头)
	depsTail?: Link;        // 我的依赖链(链尾)
	subs?: Link;            // 我的听众链(链头)
	subsTail?: Link;        // 我的听众链(链尾)
	flags: ReactiveFlags;   // 我的小旗子
}

// 连接线:一笔订阅(线上贴着期号)
export interface Link {
	version: number;        // 期号
	dep: ReactiveNode;      // 被依赖的(消息源)
	sub: ReactiveNode;      // 依赖的(订阅者)
	prevDep?: Link;         // 依赖链上的前一根线
	nextDep?: Link;         // 依赖链上的后一根线
	prevSub?: Link;         // 听众链上的前一根线
	nextSub?: Link;         // 听众链上的后一根线
}

// 工厂:吃进三个插口,吐出一套引擎五件套
export function createReactiveSystem({
	update,
	notify,
	unwatched,
}: {
	update(sub: ReactiveNode): boolean;
	notify(sub: ReactiveNode): void;
	unwatched(sub: ReactiveNode): void;
}) {
	return { link, unlink, propagate, checkDirty, shallowPropagate };

	// 连线:能复用旧线就复用(认"期号")
	function link(dep: ReactiveNode, sub: ReactiveNode, version: number): void {
		// ……第 14 章那份,原样搬来……
	}

	// 摘线:没人听它了,问整车"怎么办"(unwatched)
	function unlink(link: Link): void {
		// ……第 15 章那份,原样搬来……
		// (末尾的 unwatched(dep) 改成 unwatched(dep) —— 插口直接调用)
	}

	// 传播:边盖章边下传(认"正在跑的自己")
	function propagate(link: Link, innerWrite: boolean): void {
		// ……第 13 章那份,原样搬来……
		// (notify(sub as EffectNode) 改成 notify(sub) —— 插口直接调用)
	}

	// 核实:沿着依赖链挨个问"你们真的变了吗?"
	function checkDirty(node: ReactiveNode): boolean {
		// ……第 12 章那份,原样搬来……
		// (update(dep as SignalNode | ComputedNode) 改成 update(dep) —— 插口直接调用)
		// ((dep as ComputedNode).deps 改成 dep.deps —— deps 本来就在图纸上)
	}

	// 浅传播:给一层听众的"待核实"章升级成"过期"章
	function shallowPropagate(link: Link): void {
		// ……第 9 章那份,原样搬来……
	}

	// 这根连接线还连在吗?
	function isValidLink(checkLink: Link, sub: ReactiveNode): boolean {
		// ……第 13 章那份,原样搬来……
	}
}

搬家的三处小改

引擎里的代码几乎全是原样搬来,只有三处改成"问插口":

  1. unlink 末尾:unwatched(dep as ……)unwatched(dep)(插口直接收 ReactiveNode);
  2. propagate 里:notify(sub as EffectNode)notify(sub)
  3. checkDirty 里:update(dep as ……)update(dep)(dep as ComputedNode).depsdep.deps

第 2 步:把 signal.ts 改成整车(src/index.ts)

signal.ts 改名成 index.ts,然后做三件事:加导入、删引擎、接插口

① 顶部加导入——从引擎里拿图纸、旗子和工厂:

ts
// src/index.ts —— 信号库的整车(第 20 章)
// 消息城专属的一层:公告牌、中转站、订报人……引擎的五件套从 system.ts 拿。
import { createReactiveSystem, ReactiveFlags, type Link, type ReactiveNode } from './system';

② 删掉引擎部分——这些内容已经搬去 system.ts,从 index.ts 里整段删掉:

  • ReactiveFlags 枚举;
  • ReactiveNodeLink 两张图纸;
  • linkunlinkpropagatecheckDirtyshallowPropagateisValidLink 六个函数。

③ 在全局状态后面接插口——用工厂把三插口插进引擎,拿回五件套:

ts
// 插插口:把"该怎么做"告诉引擎,拿回引擎五件套
const { link, unlink, propagate, checkDirty, shallowPropagate } =
	createReactiveSystem({ update, notify, unwatched });

其余全部保留:节点类型(SignalNode/ComputedNode/EffectNode/EffectScopeNode)、全局状态、purgeDepsupdate/notify/unwatched(三个插口的实现)、flush/runCleanup/disposeAllDepsInReverse/dispose/disposeScope/run、以及全部的 API(signal/computed/effect/effectScope/trigger/startBatch/endBatch/getBatchDepth/getActiveSub/setActiveSub/untracked)。

插口的签名

update/notify/unwatched 的实现一个字都不用改——引擎的插口收的是 ReactiveNode,TypeScript 的"方法双向兼容"允许窄签名填宽插口。如果 tsc 报了类型错误,把插口实现的首个参数放宽成 ReactiveNode 即可。

第 3 步:改测试的导入

所有测试文件里,把 from '../src/signal' 改成 from '../src/index'。只有 recursion.spec.ts 特殊——它还要从引擎拿旗子(和真实库的测试一模一样的姿势):

ts
import { signal, effect, getActiveSub } from '../src/index';
import { ReactiveFlags } from '../src/system';

第 4 步:叫检查员!(回归)

bash
npm test

🚀 现在跑一下,你应该看到

✓ 全部 14 个测试文件、42 条测试,一个都不红。

42 个,全绿。 拆家完成——引擎和整车分家了,行为一个字没变。🎉

🧰 TS 小课堂:工厂函数和"插口"

今天有两个新词:工厂函数依赖注入

工厂函数

createReactiveSystem 是一个工厂函数——"吃进参数,造出新东西":

ts
createReactiveSystem({ update, notify, unwatched });
// 吃进三个插口 → 吐出一套引擎五件套

工厂和普通函数的区别:普通函数干活,工厂函数造东西(常常是造一组有关联的东西,还一起带回来)。咱们已经见过一个工厂了——signal(1) 本身也是工厂:吃进初始值,造出一个"公告牌"函数。

依赖注入(插口)

引擎有 update/notify/unwatched 三个插口——它自己不实现"怎么重算""怎么排队""怎么辞职",全靠整车提供。这叫依赖注入:把"怎么做"的依赖,从外面"注入"进来。

mermaid
graph LR
    engine["引擎:该问的时候问 🤔"] --> ask["update? notify? unwatched?"]
    ask --> answer["整车:我来答 ✅<br/>重算/排队/辞职"]

好处:引擎不认识信号、订报人、小组——它只认线和章。换一套 API(比如 .get()/.set() 风格),插口换了,引擎一行不用改

📚 消息城词典

意思记住它
引擎 (system)最通用的图算法:牵线、传播、核实只认线和章
整车 (index)消息城专属 API:信号、订报人、小组……负责造节点、答插口
插口 (callback)引擎问整车"怎么办"的三个口update / notify / unwatched
工厂 (factory)吃进参数、造出新东西的函数createReactiveSystem

引擎/整车是正式消息城术语,会同步进附录·消息城词典

📦 章末完整代码

这一章你的 signal-book/src 文件夹长这样(多了一个文件、改了一个名):

signal-book/
├── demo/
│   └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   ├── system.ts      ← 新的:引擎(五件套 + 图纸 + 旗子)
│   ├── index.ts       ← 改名来的:整车(消息城 API)
│   └── signal.ts      ← 已经不存在了(改名成 index.ts)
└── 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
    ├── batch-lifecycle.spec.ts
    └── untracked.spec.ts

src/system.ts 完整内容(第 1 步抄的那份——五件套函数体照搬第 9-15 章的各份):

ts
// src/system.ts —— 信号库的引擎(第 20 章)
// 引擎只认"线"和"章"——它不认识信号/订报人。
// 三个插口(update/notify/unwatched)由整车提供。

// 小旗子(标志):告诉引擎"现在是什么状态"
export const enum ReactiveFlags {
	None = 0,           // 什么都没有
	Mutable = 1,        // 可变
	Watching = 2,       // 盯着
	RecursedCheck = 4,  // 正在跑
	Recursed = 8,       // 递归记号
	Dirty = 16,         // 过期
	Pending = 32,       // 待核实
	HasChildEffect = 64,// 我有孩子
}

// 节点:消息城里的一切,都是节点
export interface ReactiveNode {
	deps?: Link;            // 我的依赖链(链头)
	depsTail?: Link;        // 我的依赖链(链尾)
	subs?: Link;            // 我的听众链(链头)
	subsTail?: Link;        // 我的听众链(链尾)
	flags: ReactiveFlags;   // 我的小旗子
}

// 连接线:一笔订阅(线上贴着期号)
export interface Link {
	version: number;        // 期号
	dep: ReactiveNode;      // 被依赖的(消息源)
	sub: ReactiveNode;      // 依赖的(订阅者)
	prevDep?: Link;         // 依赖链上的前一根线
	nextDep?: Link;         // 依赖链上的后一根线
	prevSub?: Link;         // 听众链上的前一根线
	nextSub?: Link;         // 听众链上的后一根线
}

// 工厂:吃进三个插口,吐出一套引擎五件套
export function createReactiveSystem({
	update,
	notify,
	unwatched,
}: {
	update(sub: ReactiveNode): boolean;
	notify(sub: ReactiveNode): void;
	unwatched(sub: ReactiveNode): void;
}) {
	return { link, unlink, propagate, checkDirty, shallowPropagate };

	// 连线:能复用旧线就复用(认"期号")
	function link(dep: ReactiveNode, sub: ReactiveNode, version: number): void {
		const prevDep = sub.depsTail;
		if (prevDep !== undefined && prevDep.dep === dep) {
			return;                              // ① 刚连的就是它:不用再连
		}
		const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps;
		if (nextDep !== undefined && nextDep.dep === dep) {
			nextDep.version = version;           // ② 旧线还用得着:贴上期号,复用!
			sub.depsTail = nextDep;
			return;
		}
		const prevSub = dep.subsTail;
		if (prevSub !== undefined && prevSub.version === version && prevSub.sub === sub) {
			return;                              // ③ 依赖那边已经连着同一期了:不用再连
		}
		const newLink: Link = {
			version,
			dep,
			sub,
			prevDep,
			nextDep,
			prevSub,
			nextSub: undefined,
		};
		if (prevDep !== undefined) {
			prevDep.nextDep = newLink;
		} else {
			sub.deps = newLink;
		}
		if (nextDep !== undefined) {
			nextDep.prevDep = newLink;
		}
		sub.depsTail = newLink;
		if (prevSub !== undefined) {
			prevSub.nextSub = newLink;
		} else {
			dep.subs = newLink;
		}
		dep.subsTail = newLink;
	}

	// 摘线:没人听它了,问整车"怎么办"(unwatched)
	function unlink(link: Link): void {
		const { dep, sub, prevDep, nextDep, prevSub, nextSub } = link;
		if (prevDep !== undefined) {
			prevDep.nextDep = nextDep;
		} else {
			sub.deps = nextDep;
		}
		if (nextDep !== undefined) {
			nextDep.prevDep = prevDep;
		} else {
			sub.depsTail = prevDep;
		}
		if (prevSub !== undefined) {
			prevSub.nextSub = nextSub;
		} else if ((dep.subs = nextSub) === undefined) {
			unwatched(dep);   // 没人听它了:问整车怎么办
		}
	}

	// 传播:边盖章边下传(认"正在跑的自己")
	function propagate(link: Link, innerWrite: boolean): void {
		const tickets: Link[] = [];        // 回程票
		let l: Link | undefined = link;    // 现在站在哪根线上

		while (l !== undefined) {
			const next: Link | undefined = l.nextSub;   // 同层的下一站(先记住)
			const sub: ReactiveNode = l.sub;
			let flags = sub.flags;

			if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending))) {
				sub.flags = flags | ReactiveFlags.Pending;
				if (innerWrite) {
					sub.flags |= ReactiveFlags.Recursed;
				}
			} else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) {
				flags = ReactiveFlags.None;
			} else if (!(flags & ReactiveFlags.RecursedCheck)) {
				sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending;
			} else if (!(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) && isValidLink(l, sub)) {
				sub.flags = flags | ReactiveFlags.Recursed | ReactiveFlags.Pending;
				flags &= ReactiveFlags.Mutable;
			} else {
				flags = ReactiveFlags.None;
			}

			if (flags & ReactiveFlags.Watching) {
				notify(sub);               // 问整车:排队
			}
			if (flags & ReactiveFlags.Mutable) {
				const subSubs: Link | undefined = sub.subs;
				if (subSubs !== undefined) {
					if (next !== undefined) {
						tickets.push(next);
					}
					l = subSubs;
					continue;
				}
			}

			l = next ?? tickets.pop();
		}
	}

	// 核实:沿着依赖链挨个问"你们真的变了吗?"
	function checkDirty(node: ReactiveNode): boolean {
		type Ticket = { link: Link; dep: ReactiveNode };
		const tickets: Ticket[] = [];
		let l: Link | undefined = node.deps;
		let dirty = false;

		while (true) {
			if (l === undefined) {
				if (tickets.length === 0) {
					return dirty;
				}
				const ticket: Ticket = tickets.pop()!;
				if (dirty) {
					if (update(ticket.dep)) {
						continue;
					}
					dirty = false;
				} else {
					ticket.dep.flags &= ~ReactiveFlags.Pending;
				}
				l = ticket.link.nextDep;
				continue;
			}

			const dep: ReactiveNode = l.dep;
			if (dep.flags & ReactiveFlags.Dirty) {
				if (update(dep)) {
					dirty = true;
					l = undefined;
				} else {
					l = l.nextDep;
				}
			} else if (dep.flags & ReactiveFlags.Pending) {
				tickets.push({ link: l, dep });
				l = dep.deps;
			} else {
				l = l.nextDep;
			}
		}
	}

	// 浅传播:给一层听众的"待核实"章升级成"过期"章
	function shallowPropagate(link: Link): void {
		let l: Link | undefined = link;
		while (l !== undefined) {
			const sub = l.sub;
			if ((sub.flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) {
				sub.flags = sub.flags | ReactiveFlags.Dirty;
			}
			l = l.nextSub;
		}
	}

	// 这根连接线还连在吗?
	function isValidLink(checkLink: Link, sub: ReactiveNode): boolean {
		let l = sub.depsTail;
		while (l !== undefined) {
			if (l === checkLink) {
				return true;
			}
			l = l.prevDep;
		}
		return false;
	}
}

src/index.ts全部内容 = 第 19 章的 signal.ts,按第 2 步做了三处手术:加导入、删引擎、接插口。其余(节点类型、purgeDeps、三插口实现、flush/run/dispose/disposeScope、全部 API)一字未动

测试的导入改动:全部 '../src/signal''../src/index'recursion.spec.ts 额外加一行 import { ReactiveFlags } from '../src/system';

跑通了? 只要 npm test 打出 42 passed,引擎和整车就分家了——引擎谁都能插,整车专属消息城

🎮 动手试试

怎么玩

先自己写答案,再点开对照。忍得住才点开哦!

1. 预言输出(猜一猜)

小铃想试试"引擎不知道消息城"到底是什么意思。她问:如果引擎的 notify 插口接的是一个空函数notify: () => {}),会发生什么?

👉 点开看答案

订报人永远不会排队——消息变了,谁也不醒。

propagate 传播时,遇到"盯着"的节点就调 notify(sub)——可插口是空的,什么也没发生。引擎照样走完全程(盖章、下钻都正常),只是**"排队"这一步没人接**。

这正说明引擎和整车各管各的:引擎负责'该通知的时候调 notify',整车负责'notify 到底干什么'。插口空着,引擎不知道、也不管——它只保证"该问的时候问了"。

2. 帮小铃找 bug(抓错)

小铃把代码搬进 system.ts 时,还留着旧写法:

ts
			if (flags & ReactiveFlags.Watching) {
				notify(sub as EffectNode);   // 咦?EffectNode 在 system.ts 里不存在!
			}

结果 tsc 报错:'EffectNode' 不存在。为什么?应该怎么写?

👉 点开看答案

因为 EffectNode 是整车的图纸(在 index.ts 里),引擎里没有它。

引擎只认 ReactiveNode——插口的类型就是 notify(sub: ReactiveNode)。所以直接写:

ts
				notify(sub);   // 插口直接收 ReactiveNode

引擎不该知道"订报人"长什么样——它只要把"这个节点在盯着"传给插口,怎么处理是整车的事。

3. 填空(补全)

index.ts 里接插口少了一样东西。补全它:

ts
const { link, unlink, propagate, checkDirty, shallowPropagate } =
	createReactiveSystem({ /* 这里该写什么? */ });
👉 点开看答案

update, notify, unwatched

ts
const { link, unlink, propagate, checkDirty, shallowPropagate } =
	createReactiveSystem({ update, notify, unwatched });

三个插口就是整车的三个"答话人":update 答'真变了吗'、notify 答'谁排队'、unwatched 答'没人订了怎么办'。不接全,引擎就有口没人答。

4. 小挑战(迷你整车)

不造完整 API——只验证引擎能独立工作。用 createReactiveSystem 配上三个"空插口",再手动牵一根线:

ts
import { createReactiveSystem, ReactiveFlags, type ReactiveNode } from '../src/system';

const engine = createReactiveSystem({
	update: () => false,
	notify: () => {},
	unwatched: () => {},
});

const a: ReactiveNode = { flags: ReactiveFlags.Mutable };
const b: ReactiveNode = { flags: ReactiveFlags.Mutable };

engine.link(a, b, 1);

// 验证:b 的依赖链上,第一根线就是 a
expect(b.deps?.dep).toBe(a);

写进测试跑一下——这就是"引擎插到任何整车都能用"的最小证明。

👉 点开看答案

核心就是上面那份代码。跑起来应该全绿:

  • engine.link(a, b, 1) 牵一根线:a 的听众链、b 的依赖链都挂上它;
  • expect(b.deps?.dep).toBe(a)——b 依赖 a ✓;
  • 三个空插口说明:引擎只做"牵线","牵完线之后干什么"完全交给插口——空着也能牵线,插上就能全自动。

这就是依赖注入的威力:引擎不知道消息城,消息城也能把它用起来

🏁 本章小结

  1. 信号库拆成了引擎(system:牵线、传播、核实的通用图算法)和整车(index:消息城专属 API);
  2. createReactiveSystem 是工厂:吃进三个插口(update/notify/unwatched),吐出一套五件套——引擎只问,整车只答;
  3. 文件结构和真实库对齐(src/system.ts + src/index.ts)——42 个测试全绿,行为一字没变

引擎和整车分家了。老墨把最后一张图纸拍在桌上,上面写着两个字:

毕业。

下一章,就是全书的大结局:跑绿真实库的全部测试,和朴素版比一比速度——消息城的信号库,终于要毕业了。