Appearance
第 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):一段代码,注册的时候就立刻跑一遍,之后它读过的信号只要一变,它就自动再跑一遍。
为什么注册时要立刻跑一遍?
因为不跑,就不知道它会读哪些信号——不知道读谁,就记不了订阅。先跑一遍,边跑边记,订阅就齐了。
✍️ 动手写代码
开工!今天写两个函数:signal 和 effect。老规矩,一步步来。
第 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: number、read(): 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.tssrc/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 章的事,先记在心里。
🏁 本章小结
- 信号有三样零件:会变的消息、听众本子(Set)、全局的耳朵(activeEffect);
- **自动反应(effect)**注册就立刻跑一遍,靠"耳朵"让读到的信号记下自己,一变就自动重跑——一变全变终于成真了;
- 泛型
<T>是个口袋,装什么类型,全函数认什么。
下一章,给公告牌装上计算值——蛋糕价不用手写,面粉价一变它自己算出来!