Appearance
第 4 章 计算值:computed
📖 蛋糕店的联动菜单
蛋糕店的生意越来越好了,老板笑得合不拢嘴,但他又找到小铃:"再帮我一个忙!"
"现在我要改两个数:面粉价一变,蛋糕价要变;糖价一变,蛋糕价也要变。蛋糕价 = 面粉价 × 2 + 糖价 × 3。你能让这块牌子自己算吗?"
小铃想了想。她知道怎么让消息自己变——信号!但她马上遇到了新问题:
"蛋糕价也是一个'会变的消息',可它不是谁喂给我的,是算出来的。我总不能面粉一变,就手动把蛋糕价改成新数字吧?"
她试着用上一章的自动反应硬凑:
ts
const flour = signal(2); // 面粉价
const sugar = signal(3); // 糖价
let cakePrice = 0; // 蛋糕价
effect(() => {
cakePrice = flour() * 2 + sugar() * 3; // 手动转发:面粉/糖一变就重算
});跑起来倒是能工作,可小铃越看越别扭:"这哪是'会自己算的牌子'?这分明是我自己当传话筒,每次都要写一遍转发规则……"
老墨在旁边看了一会儿,开口了:"消息城里有一种更聪明的店铺,叫中转站。你不需要自己当传话筒——把计算规则交给中转站,它替你算。"
"它怎么替我算?"
"第一,有人问它,它才第一次开算;第二,算过的结果它记在小本子上(这叫缓存),没变化就不重算;第三,它依赖的消息一变,它就自动重算、自动转发给在听它的人。你只管问,别的都交给它。"
小铃的眼睛亮了:"这不就是……一个会自己算的信号吗?!"
🎯 本章要解决什么
一句话问题:怎么造一个计算值(computed)——根据别的信号算出来的消息,会自己更新,还会记住算好的结果?
读完这一章,你会知道:
- computed 的三个本事:第一次读才算、缓存、一变就重算并转发;
- 为什么"有人问才第一次算"这么重要;
- 计算值在依赖图里的位置:信号 → 中转站 → 自动反应。
💡 概念讲解
中转站长什么样
计算值(computed)在依赖图里,是夹在信号和自动反应中间的中转站:
mermaid
graph LR
flour["flour 信号 🗞️<br/>面粉价"] --> cake["cakePrice 计算值 🧮<br/>蛋糕价"]
sugar["sugar 信号 🗞️<br/>糖价"] --> cake
cake --> effect["effect 自动反应 🧒<br/>(在听蛋糕价的人)"]信号只管"我有消息",计算值负责"我按规则算",自动反应负责"一有结果就行动"——各干各的,谁也不用当传话筒。
三个本事
本事一:有人问,才第一次开算。
小铃写 const cake = computed(规则) 时,蛋糕价还没有算。直到有人第一次读它(cake()),它才跑一遍规则。为什么要这样?因为"没人问的答案,算了也白算"——先欠着,问到再说。这个习惯有个专门的名字,叫惰性(懒)。别小看这个"懒",第 9 章我们会发现,它还能更懒。
本事二:结果记在小本子上(缓存)。
算过一次之后,结果被记住。下次再有人问,只要依赖的消息没变,直接翻小本子报答案,不重新算。
本事三:依赖一变,自动重算 + 自动转发。
它依赖的面粉价或糖价一变,它立刻按规则重算,然后通知所有在听它的人。这靠的正是第 3 章的耳朵——算规则的时候,读到的信号会把中转站记进听众本子。
✍️ 动手写代码
今天在 src/signal.ts 末尾追加一个 computed。第 3 章的内容一字不动。
第 1 步:加壳子
打开 src/signal.ts,在文件末尾(effect 函数后面)追加:
ts
export function computed<T>(getter: () => T): () => T {
let value: T; // 缓存:上次算好的结果
let hasValue = false; // 算过没有?(还没算 = false)
const subscribers = new Set<Effect>(); // 谁在听我
}注意三样东西:
value—— 小本子,记上次算好的结果;hasValue—— "算过没有"的开关,一开始是false(没算过);subscribers—— 自己的听众本子(它自己也是一个"消息",也有人在听它)。
第 2 步:写"重算"函数
在壳子里追加:
ts
// 重算:跑一遍规则,顺便订阅读到的信号
const recompute = (): void => {
const isFirst = !hasValue; // 这是第一次算吗?
const prev = activeEffect;
activeEffect = recompute; // 举起耳朵:现在是我在听
try {
value = getter(); // 按规则算!
hasValue = true;
} finally {
activeEffect = prev; // 把耳朵还回去
}
// 算完了,告诉所有在听我的人(第一次算不算"更新",不通知)
if (!isFirst) {
for (const sub of subscribers) {
sub();
}
}
};recompute 和上一章的自动反应很像:跑之前举起耳朵,跑完还回去。跑的过程中读到的信号,会把 recompute 记进它们的听众本子——这就是"依赖一变,通知我重算"的订阅。
isFirst 是这一章的一个关键小心思:第一次算不算"更新"。如果第一次算就去通知听众,而听众正是那个"问问题的人",它会被通知着再跑一遍——重复干活!所以第一次算,悄悄记下结果就行,谁都不通知。
第 3 步:写"读"函数
在壳子里追加:
ts
// 计算值只有"读",没有"写"——它是算出来的,不能被人手动改
const read = (): T => {
if (activeEffect !== undefined) {
subscribers.add(activeEffect); // 记下:这位在听我
}
if (!hasValue) {
recompute(); // 第一次被读:先算一遍
}
return value;
};
return read;和信号不一样的是:计算值没有"写"——它是算出来的,不是被人喂进去的。所以它就是一个普普通通的"读"函数,不需要 arguments 那套变身术。
第 4 步:写测试
在 tests 文件夹里新建 computed.spec.ts:
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed } from '../src/signal';
describe('计算值:读', () => {
test('第一次读才计算', () => {
let runs = 0;
const count = signal(1);
const double = computed(() => {
runs++;
return count() * 2;
});
expect(runs).toBe(0); // 还没人读,一次都没算
expect(double()).toBe(2);
expect(runs).toBe(1);
});
test('缓存:依赖没变,不重新算', () => {
let runs = 0;
const count = signal(1);
const double = computed(() => {
runs++;
return count() * 2;
});
expect(double()).toBe(2);
expect(double()).toBe(2);
expect(runs).toBe(1); // 问了两次,只算了一次!
});
});
describe('计算值:一变全变', () => {
test('依赖变了,自动重算,自动反应跟着跑', () => {
const logs: number[] = [];
const count = signal(1);
const double = computed(() => count() * 2);
effect(() => {
logs.push(double());
});
count(2);
count(3);
expect(logs).toEqual([2, 4, 6]);
});
test('没被读过的计算值,不会打扰任何人', () => {
const logs: string[] = [];
let neverRuns = 0;
const count = signal(1);
const neverRead = computed(() => {
neverRuns++;
return count() * 100; // 从来没人读它
});
const double = computed(() => count() * 2);
effect(() => {
logs.push(`double = ${double()}`);
});
count(2);
expect(neverRuns).toBe(0); // 没被读,一次都没算
expect(logs).toEqual(['double = 2', 'double = 4']);
});
});第 5 步:叫检查员!
bash
npm test🚀 现在跑一下,你应该看到:
✓ tests/computed.spec.ts (4 tests) ✓ tests/signal.spec.ts (5 tests) ✓ tests/money.spec.ts (2 tests) Test Files 3 passed (3) Tests 11 passed (11)11 个测试全绿!🎉 蛋糕店的中转站上线了——面粉一变,蛋糕价自己算、自己改。
🧰 TS 小课堂(巩固复习)
这一章没有新语法,但有一个老朋友要加深印象:泛型 <T> 出现了两次!
ts
export function computed<T>(getter: () => T): () => T {读法:computed 是个口袋函数——<T> 先占个位置,getter 这个函数要返回 T,computed 返回的"读"函数也要返回 T。三个地方共用同一个 T:
ts
const cake = computed(() => flour() * 2 + sugar() * 3);
// T = number:getter 返回数字,cake() 也返回数字一个口袋,全家通用——这就是泛型的威力。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 计算值 (computed) | 根据别的信号算出来的消息 | 中转站:问它才第一次算,算过就记住 |
| 缓存 (cache) | 把算好的结果记在小本子上 | 没变化就翻本子,不重算 |
| 惰性 (lazy) | "没人问就不干活"的脾气 | 先欠着,问到再说 |
| 转发 | 重算完通知所有在听它的人 | 中转站往下一站送消息 |
📦 章末完整代码
这一章你的 signal-book 文件夹长这样:
signal-book/
├── demo/
│ └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│ ├── money.ts
│ └── signal.ts ← 今天追加了 computed
└── tests/
├── money.spec.ts
├── signal.spec.ts
└── computed.spec.tssrc/signal.ts 完整内容(第 3 章 + 今天的 computed):
ts
// src/signal.ts —— 朴素版信号库(signal + effect + computed)
// 一个自动反应,就是一段"跑一遍"的代码
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(); // 注册就立刻跑一遍(先送一期报纸)
}
export function computed<T>(getter: () => T): () => T {
let value: T; // 缓存:上次算好的结果
let hasValue = false; // 算过没有?(还没算 = false)
const subscribers = new Set<Effect>(); // 谁在听我
// 重算:跑一遍规则,顺便订阅读到的信号
const recompute = (): void => {
const isFirst = !hasValue; // 这是第一次算吗?
const prev = activeEffect;
activeEffect = recompute; // 举起耳朵:现在是我在听
try {
value = getter(); // 按规则算!
hasValue = true;
} finally {
activeEffect = prev; // 把耳朵还回去
}
// 算完了,告诉所有在听我的人(第一次算不算"更新",不通知)
if (!isFirst) {
for (const sub of subscribers) {
sub();
}
}
};
// 计算值只有"读",没有"写"——它是算出来的,不能被人手动改
const read = (): T => {
if (activeEffect !== undefined) {
subscribers.add(activeEffect); // 记下:这位在听我
}
if (!hasValue) {
recompute(); // 第一次被读:先算一遍
}
return value;
};
return read;
}tests/computed.spec.ts 完整内容(和上面第 4 步一样):
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed } from '../src/signal';
describe('计算值:读', () => {
test('第一次读才计算', () => {
let runs = 0;
const count = signal(1);
const double = computed(() => {
runs++;
return count() * 2;
});
expect(runs).toBe(0); // 还没人读,一次都没算
expect(double()).toBe(2);
expect(runs).toBe(1);
});
test('缓存:依赖没变,不重新算', () => {
let runs = 0;
const count = signal(1);
const double = computed(() => {
runs++;
return count() * 2;
});
expect(double()).toBe(2);
expect(double()).toBe(2);
expect(runs).toBe(1); // 问了两次,只算了一次!
});
});
describe('计算值:一变全变', () => {
test('依赖变了,自动重算,自动反应跟着跑', () => {
const logs: number[] = [];
const count = signal(1);
const double = computed(() => count() * 2);
effect(() => {
logs.push(double());
});
count(2);
count(3);
expect(logs).toEqual([2, 4, 6]);
});
test('没被读过的计算值,不会打扰任何人', () => {
const logs: string[] = [];
let neverRuns = 0;
const count = signal(1);
const neverRead = computed(() => {
neverRuns++;
return count() * 100; // 从来没人读它
});
const double = computed(() => count() * 2);
effect(() => {
logs.push(`double = ${double()}`);
});
count(2);
expect(neverRuns).toBe(0); // 没被读,一次都没算
expect(logs).toEqual(['double = 2', 'double = 4']);
});
});✅ 跑通了? 只要
npm test打出11 passed,中转站就完工了!
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃写了一段代码,猜猜 runs 最后是几?
ts
let runs = 0;
const a = signal(2);
const b = computed(() => {
runs++;
return a() * 10;
});
a(3);
a(4);
b();
b();👉 点开看答案
runs 是 1。
a(3)、a(4) 时 b 还没被读过——一次都没算(第一次读才算)。直到 b() 才第一次算(runs 变成 1),第二次 b() 直接翻缓存,不算。
2. 帮小铃找 bug(抓错)
小铃的中转站有个怪毛病:每次有人第一次读它,那个"问问题的人"会被通知着多跑一遍。她发现是这段代码的问题:
ts
if (!isFirst) {
for (const sub of subscribers) {
sub();
}
}小铃想:把 if (!isFirst) 删掉不就好了?反正都要通知。她删掉之后,测试更红了。为什么?isFirst 到底在保护什么?
👉 点开看答案
isFirst 保护的是第一次被读的那次。
想一想:有人读 computed → 触发第一次 recompute → 如果这里不拦住、直接通知所有听众——可"问问题的人"已经被记进听众本子了(读的时候先 subscribers.add 了)——它就会被通知着再跑一遍,白跑。第一次算不是"更新",不该惊动任何人。
删掉 if (!isFirst) 后,第一个测试(第一次读才计算)里的自动反应会跑两遍,日志多一行,全乱套。
3. 填空(补全)
computed 的壳子里少了一行。补上它,让"问问题的人"被记进听众本子:
ts
const read = (): T => {
// 这里少了一行!
if (!hasValue) {
recompute();
}
return value;
};👉 点开看答案
补上:
ts
if (activeEffect !== undefined) {
subscribers.add(activeEffect);
}这行和第 3 章信号里的"读"一模一样——任何消息被读,都要记下谁在听。
4. 小挑战(造东西)
回到蛋糕店!老板要一个总价牌:
flour信号:面粉价(初始 2 元/袋);sugar信号:糖价(初始 3 元/袋);cake计算值:蛋糕价 = 面粉价 × 2 + 糖价 × 3;- 一个自动反应:蛋糕价一变,打印
蛋糕 X 元。
写完跑测试验证:改面粉价为 5,应该打印 蛋糕 19 元(5×2 + 3×3)。
👉 点开看答案
一种写法:
ts
const flour = signal(2);
const sugar = signal(3);
const cake = computed(() => flour() * 2 + sugar() * 3);
effect(() => {
console.log(`蛋糕 ${cake()} 元`);
});
flour(5); // 打印:蛋糕 19 元
sugar(1); // 打印:蛋糕 13 元注意:朴素版的中转站有个"太勤快"的毛病——哪怕没人问它,只要面粉或糖一变,它也会立刻重算。这个毛病第 8 章起会被治好(它学会"被问到才重算");不过它有时还会白算(比如源改了个来回),第 9 章再教它先核实。先记在心里!
🏁 本章小结
- 计算值(computed)是根据别的信号算出来的消息,是依赖图里的中转站;
- 它有三个本事:第一次读才算(惰性)、缓存(算过就记住)、依赖一变就自动重算并转发;
isFirst的小心思想告诉你:"第一次算"不是"更新",不该惊动任何人——这种边界上的讲究,以后会越来越多。
下一章,消息城要搞一场"大促销"——同一刻改好几个价,我们看看会发生什么乱子。