Skip to content

第 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 这个函数要返回 Tcomputed 返回的"读"函数也要返回 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.ts

src/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();
👉 点开看答案

runs1

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 章再教它先核实。先记在心里!

🏁 本章小结

  1. 计算值(computed)是根据别的信号算出来的消息,是依赖图里的中转站
  2. 它有三个本事:第一次读才算(惰性)、缓存(算过就记住)、依赖一变就自动重算并转发
  3. isFirst 的小心思想告诉你:"第一次算"不是"更新",不该惊动任何人——这种边界上的讲究,以后会越来越多。

下一章,消息城要搞一场"大促销"——同一刻改好几个价,我们看看会发生什么乱子。