Skip to content

第 3 章 第一个信号:signal 与 effect

📖 第一份订单

"恭喜,你有工作台了。"老墨把一张皱巴巴的订单拍到桌上,"消息城的第一单生意,来了。"

订单来自蛋糕店。老板抱怨:门口那块价格牌,每天都要改好几次——面粉涨价了改,糖涨价了改,改来改去总有客人说"你们牌子上写 12 元,结账怎么收 14 元?"

"所以,"老墨推了推眼镜,"我们要给蛋糕店造一块会自己变的价格牌:面粉价一变,牌上的蛋糕价自己跟着变。全城都在看着我们,别搞砸。"

小铃回到工作台,把第 1 章的旧本子翻开:money = count * 2。她想起那时的烦恼——改了 count,money 不会自己变

"这次不一样了!"她握紧拳头,"我要造出真正的信号。"

老墨走之前留下一张纸条,上面写着三个零件:

造一个信号,你需要三样东西: ① 一个会变的消息 ② 一本记着"谁在听"的本子 ③ 一只"正在听的耳朵"

🎯 本章要解决什么

一句话问题:怎么造一个信号——它一变,所有在听它的自动反应都会自己重新跑一遍?

读完这一章,你会知道:

  • signal 的三个零件,以及它们怎么配合;
  • effect(自动反应)是怎么"听"信号的;
  • TypeScript 的第一个新语法:泛型 <T>

💡 概念讲解

零件①:会变的消息

信号最核心的东西,就是一个会变的盒子。小铃写:

ts
let value = initialValue;  // 盒子里装着消息

写信号的时候,把初始消息装进去;之后想换,就换一个新的。

零件②:听众本子(Set)

"谁在听我?"信号得记住这件事。用什么记?用 Set

Set 你可能没见过,它是 JavaScript 自带的一种"记名字的小本子":

js
const 听众 = new Set();   // 翻开一本空本子
听众.add('小铃');         // 记下一个名字
听众.add('小铃');         // 再记一次……
console.log(听众.size);   // 1 —— 同一个名字只记一次!

注意它的脾气:同一个名字只记一次。这很重要——同一个自动反应听同一个信号一百遍,本子上也只有一个名字。

信号里这样记:

ts
const subscribers = new Set<Effect>();  // 听众本子:谁在听我

零件③:正在听的耳朵(activeEffect)

信号怎么知道"谁在听它"?靠一只全局的耳朵

ts
let activeEffect: Effect | undefined;  // 现在正在跑的自动反应是谁?

规矩很简单:自动反应跑的时候,会先把自己的名字挂到"耳朵"上。它读哪个信号,那个信号一看耳朵——"哦,是你在听我"——就把名字记进自己的听众本子。

mermaid
graph LR
    effect["effect 自动反应 🧒<br/>(先挂上耳朵)"] -->|读 count| count["count 信号 🗞️"]
    count -->|看见耳朵,记下名字| set["听众本子 Set 📒"]
    set -->|count 一变,挨个通知| effect

这一套"读的时候顺便记下谁在听"的动作,有个正式名字,叫依赖收集。记住这个名字,后面每章都会用到它。

自动反应(effect)是什么

小铃的"订报人"终于登场了!它就是自动反应(effect):一段代码,注册的时候就立刻跑一遍,之后它读过的信号只要一变,它就自动再跑一遍。

为什么注册时要立刻跑一遍?

因为不跑,就不知道它会读哪些信号——不知道读谁,就记不了订阅。先跑一遍,边跑边记,订阅就齐了。

✍️ 动手写代码

开工!今天写两个函数:signaleffect。老规矩,一步步来。

第 1 步:建文件,写信号的空壳

src 文件夹里新建 signal.ts,先写第一部分:

ts
// src/signal.ts —— 朴素版信号库(第 3 章)
// 一个自动反应,就是一段"跑一遍"的代码
type Effect = () => void;

export function signal<T>(initialValue: T) {
	let value = initialValue;               // ① 消息本身
	const subscribers = new Set<Effect>();  // ② 听众本子:谁在听我

	// 读:报出消息,顺便记下"谁在听"
	const read = (): T => {
		if (activeEffect !== undefined) {
			subscribers.add(activeEffect);
		}
		return value;
	};

	// 写:换上新消息,挨个通知听众
	const write = (next: T): void => {
		value = next;
		for (const sub of subscribers) {
			sub();
		}
	};
}

等等,报错了?

现在跑 npx tsc --noEmit 会报错:activeEffect 还没定义。别慌,这是故意的——零件③在第 4 步才登场。先把①②看完。

第 2 步:把函数变成"公告牌"

小铃遇到了一个麻烦:signal(1) 到底返回什么?返回一个"读"的函数?还是一个"写"的函数?

"我要的是公告牌!"小铃一拍桌子,"你空手喊它,它告诉你现在是什么;你举着新消息喊它,它就换上新消息——同一个东西,两种用法。"

这个"既读又写"的公告牌,在 JS 里怎么写?答案:一个普通的函数。喊它的时候:

  • 不带参数 → 读;
  • 带一个参数 → 写。

怎么区分"带没带参数"?JS 每个函数里都藏着一个看不见的计数器,叫 arguments,它知道这次喊的时候给了几个参数。

ts
	// ③ 公告牌:空手喊 = 读;举着新消息喊 = 写
	const signalFn = function (next?: T): T {
		if (arguments.length > 0) {
			write(next as T);
			return value;
		}
		return read();
	};

	return signalFn;
}

next?: T 里的问号意思是"这个参数可以有,也可以没有"。arguments.length > 0 就是在问:"这次喊我,给参数了吗?"

第 3 步:写全局耳朵和 effect

现在把文件开头的耳朵补上,再写完 effect

ts
// src/signal.ts —— 朴素版信号库(第 3 章)
// 一个自动反应,就是一段"跑一遍"的代码
type Effect = () => void;

// 全局的"耳朵":现在正在跑的自动反应是谁?
let activeEffect: Effect | undefined;

export function signal<T>(initialValue: T) {
	// ……第 1、2 步写的内容,原样保留……
}

export function effect(fn: () => void): void {
	const run = (): void => {
		const prev = activeEffect;
		activeEffect = run;        // 举起我的耳朵:现在是我在听
		try {
			fn();                  // 跑一遍——读到的信号都会记下我
		} finally {
			activeEffect = prev;   // 跑完,把耳朵还给上一个
		}
	};
	run();  // 注册就立刻跑一遍(先送一期报纸)
}

try / finally 是"不管发生什么,最后一定要做"的保险:就算 fn 中间出错了,耳朵也会被还回去,不会弄丢。

完整版

不想分步抄?直接看本章末尾的"章末完整代码",那里有 signal.ts 的完整内容。

第 4 步:写测试

tests 文件夹里新建 signal.spec.ts

ts
import { describe, expect, test } from 'vitest';
import { signal, effect } from '../src/signal';

describe('信号:读和写', () => {
	test('空手喊,读出当前消息', () => {
		const count = signal(1);
		expect(count()).toBe(1);
	});

	test('举着新消息喊,换上新消息', () => {
		const count = signal(1);
		count(5);
		expect(count()).toBe(5);
	});
});

describe('自动反应:一变全变', () => {
	test('注册时先跑一遍(先送一期报纸)', () => {
		const logs: string[] = [];
		const count = signal(1);
		effect(() => {
			logs.push(`count = ${count()}`);
		});
		expect(logs).toEqual(['count = 1']);
	});

	test('信号一变,自动反应自动再跑一遍', () => {
		const logs: string[] = [];
		const count = signal(1);
		effect(() => {
			logs.push(`count = ${count()}`);
		});
		count(2);
		count(3);
		expect(logs).toEqual(['count = 1', 'count = 2', 'count = 3']);
	});

	test('没读到的信号,变了也不打扰', () => {
		const logs: number[] = [];
		const a = signal(1);
		const b = signal(10);
		effect(() => {
			logs.push(a());
		});
		a(2);
		b(99); // b 没被读,不会触发自动反应
		expect(logs).toEqual([1, 2]);
	});
});

第 5 步:叫检查员!

bash
npm test

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

✓ tests/signal.spec.ts (5 tests)
✓ tests/money.spec.ts (2 tests)
Test Files  2 passed (2)
     Tests  7 passed (7)

7 个测试全绿!🎉 消息城的第一块公告牌,能自己变了

🧰 TS 小课堂:泛型 <T> 首次登场!

你一定注意到了:signal<T>count: T 里的那个 <T> 是什么?

T 是一个"口袋"——先不说是哪种类型,等真正用的时候再装。

ts
const count = signal(1);       // T = number(数字)
const name = signal('小铃');    // T = string(文字)

signal<number> 装进数字,整条函数里 T 就都是数字:value: numberread(): number。装进文字,就都是文字。装了什么,全函数认什么

为什么叫 T?

T 是 Type(类型)的缩写。想装什么类型,就让调用的人说了算——这就是"泛"字的意思:广泛的、通用的

📚 消息城词典

意思记住它
自动反应 (effect)订阅了信号的代码,信号一变就自动重跑订报人——注册先送一期,之后一变就送
听众本子 (Set)信号记"谁在听我"的小本子同一个名字只记一次
耳朵 (activeEffect)全局变量,记着"现在是谁在跑"读信号时,信号靠它认出听众
依赖收集读信号时顺便记下订阅者边跑边记,先跑一遍才记得到

这些词都会进附录·消息城词典

📦 章末完整代码

这一章你的 signal-book 文件夹长这样:

signal-book/
├── demo/
│   └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts        ← 第 2 章的,还在
│   └── signal.ts       ← 今天的新主角
└── tests/
    ├── money.spec.ts
    └── signal.spec.ts

src/signal.ts 完整内容:

ts
// src/signal.ts —— 朴素版信号库(第 3 章)
// 一个自动反应,就是一段"跑一遍"的代码
type Effect = () => void;

// 全局的"耳朵":现在正在跑的自动反应是谁?
let activeEffect: Effect | undefined;

export function signal<T>(initialValue: T) {
	let value = initialValue;               // ① 消息本身
	const subscribers = new Set<Effect>();  // ② 听众本子:谁在听我

	// 读:报出消息,顺便记下"谁在听"
	const read = (): T => {
		if (activeEffect !== undefined) {
			subscribers.add(activeEffect);
		}
		return value;
	};

	// 写:换上新消息,挨个通知听众
	const write = (next: T): void => {
		value = next;
		for (const sub of subscribers) {
			sub();
		}
	};

	// ③ 公告牌:空手喊 = 读;举着新消息喊 = 写
	const signalFn = function (next?: T): T {
		if (arguments.length > 0) {
			write(next as T);
			return value;
		}
		return read();
	};

	return signalFn;
}

export function effect(fn: () => void): void {
	const run = (): void => {
		const prev = activeEffect;
		activeEffect = run;        // 举起我的耳朵:现在是我在听
		try {
			fn();                  // 跑一遍——读到的信号都会记下我
		} finally {
			activeEffect = prev;   // 跑完,把耳朵还给上一个
		}
	};
	run();  // 注册就立刻跑一遍(先送一期报纸)
}

tests/signal.spec.ts 完整内容(和上面第 4 步一样,抄全了不用再看):

ts
import { describe, expect, test } from 'vitest';
import { signal, effect } from '../src/signal';

describe('信号:读和写', () => {
	test('空手喊,读出当前消息', () => {
		const count = signal(1);
		expect(count()).toBe(1);
	});

	test('举着新消息喊,换上新消息', () => {
		const count = signal(1);
		count(5);
		expect(count()).toBe(5);
	});
});

describe('自动反应:一变全变', () => {
	test('注册时先跑一遍(先送一期报纸)', () => {
		const logs: string[] = [];
		const count = signal(1);
		effect(() => {
			logs.push(`count = ${count()}`);
		});
		expect(logs).toEqual(['count = 1']);
	});

	test('信号一变,自动反应自动再跑一遍', () => {
		const logs: string[] = [];
		const count = signal(1);
		effect(() => {
			logs.push(`count = ${count()}`);
		});
		count(2);
		count(3);
		expect(logs).toEqual(['count = 1', 'count = 2', 'count = 3']);
	});

	test('没读到的信号,变了也不打扰', () => {
		const logs: number[] = [];
		const a = signal(1);
		const b = signal(10);
		effect(() => {
			logs.push(a());
		});
		a(2);
		b(99); // b 没被读,不会触发自动反应
		expect(logs).toEqual([1, 2]);
	});
});

跑通了? 只要 npm test 打出 7 passed,消息城的第一块公告牌就完工了!

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

小铃写了一段代码,猜猜 logs 最后长什么样?

ts
const logs: string[] = [];
const x = signal(0);

effect(() => {
	logs.push(x());
});

x(1);
x(2);
👉 点开看答案

['0', '1', '2']

effect 注册时先跑一遍(记下 0),x(1) 通知一次(记下 1),x(2) 再通知一次(记下 2)。

2. 帮小铃找 bug(抓错)

小铃写了半天,可 npm test 一直报红——"信号变了,自动反应却不重跑"。她写的是:

ts
const read = (): T => {
	return value;   // 咦,少了点什么?
};

问题出在哪?

👉 点开看答案

read少了"记下谁在听"!她忘了写:

ts
if (activeEffect !== undefined) {
	subscribers.add(activeEffect);
}

不记订阅,听众本子永远是空的,信号变了自然没人被通知。这就是依赖收集——读的时候必须顺便记一笔。

3. 填空(补全)

effect 里有一行是关键,它把"正在跑的自动反应"挂到耳朵上。补全它:

ts
const run = (): void => {
	const prev = activeEffect;
	activeEffect = /* 这里该写什么? */;
	try {
		fn();
	} finally {
		activeEffect = prev;
	}
};
👉 点开看答案

run 自己:activeEffect = run;

因为要挂到耳朵上的,正是"现在正在跑的这个自动反应"——就是 run 自己。

4. 小挑战(造东西)

signal + effect 造一个小铃的温度计

  • 一个温度信号 temperature(初始 20 度);
  • 一个自动反应:每次温度一变,就打印一句话——低于 15 度说"好冷啊🥶",高于 30 度说"好热啊🔥",中间说"刚刚好😊"。

写完跑 npm test 或直接加进测试里验证。

👉 点开看答案

一种写法:

ts
const temperature = signal(20);

effect(() => {
	const t = temperature();
	if (t < 15) {
		console.log('好冷啊🥶');
	} else if (t > 30) {
		console.log('好热啊🔥');
	} else {
		console.log('刚刚好😊');
	}
});

temperature(10);  // 打印:好冷啊🥶
temperature(35);  // 打印:好热啊🔥

注意:这个自动反应现在只能打印,关不掉——每次温度一变它都会跑。想让它"辞职"?那是第 15 章的事,先记在心里。

🏁 本章小结

  1. 信号有三样零件:会变的消息、听众本子(Set)、全局的耳朵(activeEffect);
  2. **自动反应(effect)**注册就立刻跑一遍,靠"耳朵"让读到的信号记下自己,一变就自动重跑——一变全变终于成真了;
  3. 泛型 <T> 是个口袋,装什么类型,全函数认什么。

下一章,给公告牌装上计算值——蛋糕价不用手写,面粉价一变它自己算出来!