计算属性是现代响应式编程中的核心概念,它是一种基于其他状态自动派生的只读数据。本文使用 TypeScript 实现一个功能完整的计算属性系统。
计算属性的特点
在实现之前,让我们先理解计算属性系统必须具备的两个基础特性:
- 只读性(Read-only):计算属性的值不能直接修改,只能通过改变它依赖的源数据来间接更新。
- 循环依赖检测(Circular Dependency Detection):检测并防止循环依赖导致的无限递归。
除此之外,不同的计算属性实现在以下三个方面可能采用不同的设计策略:
1. 依赖收集策略
-
动态收集:每次重新计算时都会重新收集依赖关系
- 优点:自动追踪依赖变化,无需手动管理
- 缺点:有一定的运行时开销
-
静态收集:只在首次计算时收集依赖,之后不再改变
- 优点:性能开销小
- 缺点:无法处理条件依赖(如
condition ? a : b),可能导致依赖追踪不完整,需要开发者手动管理依赖,常见的方式是使用权限管理机制,在读写时,如果没有声明依赖,则直接抛出异常
-
无法收集:有些依赖无法定义。比如与 Topo 有关。此时,只能通过在「具体的逻辑内标脏」来实现计算属性。也即没有收集依赖这个过程。
2. 是否需要重新计算
如何判断 「是否需要重新计算」
- 版本号对比:原子状态维护版本号,计算属性记录依赖的版本快照。如果不一样,即需要重新计算
- 脏标记:通过标记来判断是否需要重新计算
- 通过缓存约定:缓存消失,即需要重新计算。注意,有些计算属性是没有缓存的,比如 vue.watch
判断「是否需要重新计算」的时机:
- 立即判断:依赖变化时,立即判断。版本号是一种立即判断的策略;其他可以通过「反向依赖关系+立即通知」实现立即判断。
- 特定时机判断:收集依赖的变化,在特定时机进行判断
3. 重新计算时机
当依赖失效后,何时触发重新计算?
-
惰性计算(Lazy / Pull):访问时才重新计算
- 适用场景:计算属性可能不会被立即使用
- 特点:按需计算,避免不必要的计算开销
-
立即计算(Eager / Push):依赖变化时立即重新计算
- 适用场景:需要保持状态实时同步
- 特点:需要建立反向依赖(从原子状态到计算属性),在原子状态变化时主动推送更新
-
批量计算(Batched):收集变化,在特定时机(如 Flush)统一计算
- 适用场景:短时间内多次修改,需要减少重复计算
- 特点:结合了惰性和立即计算的优点
使用 TypeScript 实现 Computed
理解了计算属性的特点后,让我们动手实现一个功能完整的计算属性系统。本文计算属性的实现采用 动态收集依赖 + 版本号机制 + 惰性计算 的机制。我们将采用渐进式的方式,从基础类型定义开始,逐步构建出包含自动依赖收集、智能缓存、惰性计算等高级特性的完整系统。
1:定义基础类型
首先,我们需要定义系统中的基础数据结构。在响应式系统中,存在两种基本类型:可变的原子状态(Primitive)和派生的计算状态(Computed)。
// 原子数据:可以被修改的基础状态
interface Primitive<T> {
$$type: "primitive";
init: T;
}
// 计算数据:基于其他状态派生的只读数据
interface Computed<T> {
$$type: "computed";
read: StateReadInComputed<T>;
}
type Signal<T> = Primitive<T> | Computed<T>;
type StateReadInComputed<T> = (get: Getter) => T;
type Getter = <T>(signal: Signal<T>) => T;
关键点:
- 使用
$$type字段区分原子状态和计算状态 Getter是一个通用的读取函数,用于在计算函数中访问依赖
2:状态存储设计
有了基础类型定义后,我们需要考虑如何存储这些状态的实际值。这里的核心挑战是:如何判断一个计算属性的缓存是否仍然有效。这里选择了版本号机制。
interface PrimitiveState<T> {
val: T;
version: number; // 版本号,每次更新 +1
}
interface ComputedState<T> {
val?: T;
version: number;
dependencies: Map<Signal<unknown>, number>; // 记录依赖及其 version
}
type SignalState<T> = PrimitiveState<T> | ComputedState<T>;
// 使用 WeakMap 存储状态,避免内存泄漏
const stateMap = new WeakMap<Signal<unknown>, SignalState<unknown>>();
关键点:
- 使用 版本号(version) 来追踪状态变化,这是实现智能缓存的关键
- Primitive 的 version 初始值为 1(表示已挂载),Computed 的 version 初始值为 0(表示未计算)
- 使用 WeakMap 存储状态,当 Signal 对象被回收时,状态也会自动清理
dependencies存储依赖及其对应的版本号快照
3:创建 Signal
现在我们需要提供简洁的 API 来创建原子状态和计算属性。注意,这些创建函数返回的只是轻量级的描述对象,真正的状态会延迟到首次访问时才初始化。
export function primitive<T>(initialValue: T): Primitive<T> {
return {
$$type: "primitive",
init: initialValue,
};
}
export function computed<T>(read: StateReadInComputed<T>): Computed<T> {
return {
$$type: "computed",
read,
};
}
关键点:
- Signal 对象本身是轻量级的,不直接存储值
- 真正的状态存储在全局中,实现了数据和定义的分离
4:读取状态(get)
接下来实现读取逻辑。这是整个系统的入口函数,需要区分原子状态和计算状态的不同处理方式。
export function get<T>(signal: Signal<T>): T {
return getValue(signal, getSignalState(signal));
}
function getSignalState<T>(signal: Signal<T>): SignalState<T> {
let state = stateMap.get(signal) as SignalState<T>;
if (!state) {
if (signal.$$type === "primitive") {
state = { val: signal.init, version: 1 }; // primitive 初始 version 为 1
} else {
state = { version: 0, dependencies: new Map() }; // computed 初始 version 为 0
}
stateMap.set(signal, state);
}
return state;
}
function getValue<T>(signal: Signal<T>, state: SignalState<T>, computedContext?: ComputedContext): T {
if (signal.$$type === "primitive") {
return (state as PrimitiveState<T>).val;
} else {
return getComputed(signal, state as ComputedState<T>, computedContext);
}
}
关键点:
- 惰性初始化:只有在首次访问时才创建状态
- Primitive 直接返回值,Computed 需要特殊处理
getValue统一了读取逻辑,并且接受可选的computedContext参数用于循环依赖检测- 通过 version 是否为 0 来判断 Computed 是否已经被计算过(挂载状态)
5:更新原子状态(set)
有了读取功能后,我们还需要能够更新原子状态。每次更新时,版本号会递增,从而让依赖该状态的计算属性知道需要重新计算。
export function set<T>(signal: Primitive<T>, value: T): void {
const primitiveState = getSignalState(signal);
if (primitiveState.val !== value) {
primitiveState.val = value;
primitiveState.version += 1;
}
}
关键点:
- 只有 Primitive 可以被修改
- 只有在值真正变化时才递增版本号,避免不必要的重新计算
- 版本号递增会让所有依赖该状态的 Computed 缓存失效
6:自动依赖收集
现在来实现计算属性最核心的特性之一:自动依赖收集。当计算函数执行时,我们需要追踪它访问了哪些状态,并记录下它们的版本号。
function getInComputed<V, T>(signal: Signal<V>, computedState: ComputedState<T>, computedContext: ComputedContext): V {
const dep = getSignalState(signal);
computedState.dependencies.set(signal, dep.version);
return getValue(signal, dep, computedContext);
}
关键点:
- 在 Computed 的计算函数中,每次访问其他 Signal 时都会记录依赖
- 记录的不仅是依赖的 Signal,还有当时的版本号
getValue统一处理 Primitive 和 Computed 的取值逻辑computedContext用于在整个计算链中传递上下文,支持循环依赖检测
7:计算状态管理与循环依赖检测
在实现惰性计算之前,我们需要引入一个重要的概念:计算上下文(ComputedContext)。这是用于追踪整个计算过程的上下文对象,它维护了每个 Computed 的状态,从而支持循环依赖检测。
// 计算状态:dirty -> computing -> valid
type ComputedStatus = "dirty" | "computing" | "valid";
// 计算上下文:获取一个计算值时需要维护的状态
interface ComputedContext {
computedStatus: WeakMap<ComputedState<unknown>, ComputedStatus>;
}
状态转移说明:
dirty:缓存失效,需要重新计算computing:正在计算中(用于检测循环依赖)valid:缓存有效,可以直接使用
通过这个状态机,我们可以检测循环依赖:如果在计算过程中遇到状态为 computing 的 Computed,说明形成了循环依赖。
8:惰性计算与循环依赖防护
现在实现 getComputed 函数,这是整个系统最精妙的部分。它首先检查缓存是否有效,只有在缓存失效时才重新计算,并且能够检测循环依赖。
export class CircularDependencyError extends Error {}
function getComputed<T>(signal: Computed<T>, computedState: ComputedState<T>, computedContext?: ComputedContext): T {
// 如果没有上下文,说明是入口,创建上下文
if (!computedContext) {
computedContext = {
computedStatus: new WeakMap(),
};
}
const status = getStatus(computedState, computedContext);
switch (status) {
case "computing":
throw new CircularDependencyError(); // 检测到循环依赖
case "valid":
return computedState.val!; // 缓存有效,直接返回
case "dirty":
// 继续执行重新计算
}
dirtyToComputing(computedState, computedContext);
// 清空旧依赖,准备重新收集
computedState.dependencies = new Map();
// 执行计算函数(自动收集依赖)
const result = signal.read((dep) => getInComputed(dep, computedState, computedContext!));
computingToValid(computedState, computedContext);
if (computedState.val !== result) {
// 值变化,更新状态
computedState.val = result;
computedState.version += 1;
} else if (computedState.version === 0) {
// 首次计算完成,version 从 0 变为 1
computedState.version += 1;
}
return result;
}
关键点:
- 惰性计算:只有在访问时才计算
- 智能缓存:通过状态机判断缓存是否有效,有效则直接返回
- 循环依赖检测:如果状态为
computing,说明形成循环依赖,抛出错误 - 状态转移:从
dirty->computing->valid的严格状态转移 - 依赖收集:每次计算都会重新收集依赖
- 版本管理:只有值真正变化或首次计算时才更新版本号
9:缓存验证机制与状态查询
最后,我们需要实现缓存验证逻辑。核心包括两个函数:isValid 用于检查缓存是否有效,getStatus 用于获取 Computed 的当前状态。
function isValid(computed: Computed<unknown>, computedContext: ComputedContext): boolean {
const computedState = getSignalState(computed);
if (!isMounted(computedState)) {
return false;
}
// 遍历所有依赖
for (const [dep, savedVersion] of computedState.dependencies.entries()) {
if (dep.$$type === "computed") {
// 计算属性无法直接知道是否为脏,需要进一步判断
const depStatus = getStatus(dep, computedContext);
assert(depStatus != "computing", "判断状态时,不会返回 computing");
if (depStatus === "dirty") {
// 继续判断,获取最新值
getComputed(dep, computedContext);
}
}
const depState = stateMap.get(dep);
if (!depState) {
// 依赖不存在。对应的状态被删除
return false;
}
if (depState.version !== savedVersion) {
// 依赖值更新
return false;
}
}
return true;
}
function getStatus<T>(computedState: ComputedState<T>, computedContext: ComputedContext): ComputedStatus {
let status = computedContext.computedStatus.get(computedState);
if (!status) {
status = isValid(computedState, computedContext) ? "valid" : "dirty";
computedContext.computedStatus.set(computedState, status);
}
return status;
}
关键点:
- 通过比对版本号来判断依赖是否变化。
- Computed 的版本号一样,不代表 Computed 没有脏,需要进一步往下看。直到判断 Primitive 的版本号
- 递归验证:如果依赖的是另一个 Computed,通过
getStatus递归检查其状态 - 状态缓存:在
computedContext中缓存每个 Computed 的状态,避免重复计算 - 精确追踪:只有真正变化的依赖才会导致缓存失效
- 挂载检查:通过
version === 0判断 Computed 是否从未计算过
使用示例
让我们通过几个实际例子来看看它是如何工作的:
基础使用:
// 创建原子状态
const firstName = primitive("John");
const lastName = primitive("Doe");
// 创建计算属性
const fullName = computed((get) => {
return `${get(firstName)} ${get(lastName)}`;
});
console.log(get(fullName)); // "John Doe"
// 修改原子状态
set(firstName, "Jane");
console.log(get(fullName)); // "Jane Doe" (自动重新计算)
// 多层依赖
const greeting = computed((get) => {
return `Hello, ${get(fullName)}!`;
});
console.log(get(greeting)); // "Hello, Jane Doe!"
set(lastName, "Smith");
console.log(get(greeting)); // "Hello, Jane Smith!" (自动传播更新)
循环依赖检测:
// 创建循环依赖
let computedA: ReturnType<typeof computed<number>>;
let computedB: ReturnType<typeof computed<number>>;
computedA = computed((get) => get(computedB as any) + 1);
computedB = computed((get) => get(computedA as any) + 1);
try {
get(computedA); // 抛出 CircularDependencyError
} catch (error) {
if (error instanceof CircularDependencyError) {
console.log("检测到循环依赖!");
}
}
条件依赖收集:
const flag = primitive(true);
const a = primitive(1);
const b = primitive(2);
const result = computed((get) => {
return get(flag) ? get(a) : get(b);
});
console.log(get(result)); // 1
// 修改 b 不会触发重新计算,因为当前不依赖 b
set(b, 100);
console.log(get(result)); // 1 (仍然是 1,使用缓存)
// 修改 a 会触发重新计算
set(a, 10);
console.log(get(result)); // 10
// 切换 flag,现在依赖 b
set(flag, false);
console.log(get(result)); // 100
使用 TypeScript 实现 Watch - 基于反向依赖的响应式副作用
在前面的 Computed 实现中,我们采用了**惰性计算(Pull)**策略——只有在访问时才检查依赖是否变化并按需重新计算。这种方式高效且符合 Computed 的语义:按需读取,避免不必要的计算。
但有时我们需要**立即响应(Push)**依赖的变化,在依赖更新时主动执行某些副作用(如日志记录、UI 更新、数据同步等),这就是 watch 的应用场景。
Watch 的本质是一种特殊的计算属性:它没有返回值,只执行副作用函数。与 Computed 的主要区别在于:
- Computed:惰性的、有缓存的、有返回值的
- Watch:立即的、无缓存的、无返回值的
为了实现 Watch,我们需要在现有系统上增加反向依赖追踪能力,让 Signal 知道有哪些 Effect 依赖它,从而在变化时主动推送通知。本节将使用 反向依赖 + 立即计算 的方式实现 Watch。
1. 扩展 Signal 状态:增加反向依赖追踪
为了支持 Push 模式,我们需要让每个 Signal 知道有哪些节点(Computed 或 Effect)依赖它。这需要在状态存储中增加**反向依赖(Reverse Dependencies)**字段:
interface PrimitiveState<T> {
val: T;
version: number;
redDeps: Set<Signal<unknown>>; // 新增:反向依赖集合
}
interface ComputedState<T> {
val?: T;
version: number;
dependencies: Map<Signal<unknown>, number>; // 正向依赖:我依赖谁
redDeps: Set<Signal<unknown>>; // 新增:反向依赖:谁依赖我
}
关键点:
dependencies是正向依赖:记录"我依赖哪些 Signal"redDeps是反向依赖:记录"哪些节点依赖我"- 这种双向链接使得我们既能惰性计算(通过正向依赖),又能立即推送(通过反向依赖)
接下来,在 Computed 读取依赖时,需要同时建立反向依赖关系:
function getInComputed<V, T>(
signal: Signal<V>,
currentComputed: Computed<T>, // 当前正在计算的 Computed
computedState: ComputedState<T>,
computedContext: ComputedContext
): V {
const dep = getSignalState(signal);
// 建立正向依赖:记录版本号
computedState.dependencies.set(signal, dep.version);
// 建立反向依赖:让被依赖的 Signal 知道有 Computed 依赖它
dep.redDeps.add(currentComputed);
return getValue(signal, dep, computedContext);
}
注意:在实际实现中,getInComputed 的调用处需要传入当前的 Computed Signal 对象,以便建立反向依赖。
在 getComputed 时,也需要重新清除依赖
function clearDep(computed: Computed<unknown>) {
const computedState = getSignalState(computed);
// 从所有依赖的 redDeps 中移除当前 computed
for (const dep of computedState.dependencies.keys()) {
const depState = stateMap.get(dep);
if (depState) {
depState.redDeps.delete(computed);
}
}
// 清空依赖列表
computedState.dependencies.clear();
// 注意:不清除 redDeps,因为它记录的是"谁依赖我"
}
2. 定义 Effect 类型与状态
Effect 是一种特殊的响应式节点:它类似于 Computed,但不产生值,只执行副作用。我们需要定义 Effect 的函数签名和运行时状态:
// Effect 函数:接收 Getter,执行副作用,无返回值
type Effect = (get: Getter) => void;
// Effect 的运行时状态
interface EffectState {
fn: Effect; // 副作用函数
dependencies: Set<Signal<unknown>>; // 正向依赖:这个 Effect 依赖哪些 Signal
}
// 全局反向依赖映射:Signal -> 依赖它的所有 Effects
// 使用 WeakMap 避免内存泄漏
const mountedEffects: WeakMap<Signal<unknown>, Set<EffectState>> = new WeakMap();
关键点:
EffectState只需要正向依赖,不需要版本号(Effect 没有缓存)mountedEffects是全局的反向依赖索引,用于快速找到受影响的 Effects- 与 Computed 不同,Effect 的依赖关系存储在独立的结构中,而不是
stateMap
3. 依赖管理:挂载与卸载
Effect 需要动态管理依赖关系。每次 Effect 重新执行时,都需要先清除旧依赖,再建立新依赖(因为依赖可能动态变化)。我们实现两个核心函数:
/**
* 挂载 Effect:建立双向依赖关系
* - 在 Signal 的反向依赖列表中添加此 Effect
* - 在 Effect 的正向依赖列表中添加此 Signal
*/
function mountEffect(signal: Signal<unknown>, effectState: EffectState) {
// 1. 建立反向依赖:signal -> effect
let effects = mountedEffects.get(signal);
if (!effects) {
effects = new Set();
mountedEffects.set(signal, effects);
}
effects.add(effectState);
// 2. 建立正向依赖:effect -> signal
effectState.dependencies.add(signal);
}
/**
* 卸载 Effect:清除所有依赖关系
* - 从所有依赖的 Signal 的反向依赖列表中移除此 Effect
* - 清空 Effect 的正向依赖列表
*/
function unmountEffect(effectState: EffectState) {
// 遍历此 Effect 的所有依赖
for (const signal of effectState.dependencies) {
const effects = mountedEffects.get(signal);
if (effects) {
effects.delete(effectState); // 从反向依赖中移除
// 如果该 Signal 不再有任何 Effect 依赖,清理 WeakMap 条目
if (effects.size === 0) {
mountedEffects.delete(signal);
}
}
}
// 清空正向依赖
effectState.dependencies.clear();
}
关键点:
mountEffect同时建立正向和反向依赖,保持数据一致性unmountEffect彻底清理依赖关系,防止内存泄漏- 每次 Effect 重新执行前都要先
unmountEffect,避免依赖累积
4. Effect 中的依赖收集
当 Effect 执行时,它会通过 get 函数访问各种 Signal。我们需要在这个过程中自动收集依赖:
/**
* 在 Effect 中读取 Signal 的值
* - 自动建立依赖关系
* - 返回 Signal 的当前值
*/
function getInEffect<T>(signal: Signal<T>, effectState: EffectState): T {
// 建立依赖关系
mountEffect(signal, effectState);
// 返回值(如果是 Computed 会触发惰性计算)
return getValue(signal, getSignalState(signal));
}
关键点:
getInEffect类似于getInComputed,都是在特定上下文中读取 Signal- 每次读取都会调用
mountEffect建立依赖关系 - 如果读取的是 Computed,会触发其惰性计算逻辑
- Effect 不需要
computedContext,因为它不参与循环依赖检测(Effect 没有返回值,不能被其他节点依赖)
5. 收集变化的 Signals:智能传播与截断优化
当一个 Primitive 变化时,我们需要找出所有真正受影响的节点。这里有一个关键的性能优化点:如果某个 Computed 重新计算后值没有变化,就不需要继续传播到它的下游节点。这种机制称为传播截断(Propagation Cutoff)。
为什么需要传播截断?
考虑这样的依赖链:A → B → C → D
- 当 A 变化时,B 重新计算
- 如果 B 的新值与旧值相同(例如
Math.max(get(A), 0)在 A 从 -1 变为 -2 时结果都是 0) - 那么 C 和 D 完全不需要重新计算,更不需要触发依赖它们的 Effects
没有截断的代价:
- 需要遍历整个依赖图收集所有可能受影响的节点
- 即使 B 的值没变,也会调用
getValue(C)和getValue(D) - 虽然有缓存机制,但仍然有函数调用和版本检查的开销
有截断的优势:
- 按层级处理,一旦发现某层的 Computed 值没变就停止
- 完全不调用下游节点的计算函数
- 在深度依赖链中性能提升明显
实现:层级传播 + 版本号截断
采用**广度优先遍历(BFS)**的方式,按层级处理依赖链,并利用版本号判断是否需要继续传播:
/**
* 收集所有真正变化的 Signals(带截断优化)
*
* 采用广度优先遍历(BFS)按层级处理依赖链,通过版本号判断值是否真正变化。
* 核心优化:如果某个 Computed 重新计算后值未变(version 不变),
* 则截断传播,不再处理其下游节点。
*
* @param changedSignal - 发生变化的 Primitive
* @returns 所有值真正发生变化的 Signal 集合
*/
function collectChangedSignals(changedSignal: Primitive<unknown>): Set<Signal<unknown>> {
// 记录所有值真正变化的 signals
const actuallyChanged = new Set<Signal<unknown>>();
actuallyChanged.add(changedSignal); // 初始改变的 signal 肯定变了
// BFS 层级遍历:当前层的 signals
let currentLayer: Signal<unknown>[] = [changedSignal];
// 避免重复处理同一个节点
const processed = new Set<Signal<unknown>>();
// 按层级处理,直到没有下一层
while (currentLayer.length > 0) {
const nextLayer: Signal<unknown>[] = [];
// 处理当前层的每个 signal
for (const signal of currentLayer) {
if (processed.has(signal)) continue;
processed.add(signal);
// 获取当前 signal 的所有下游依赖(反向依赖:谁依赖我)
const downstreams = getSignalState(signal).redDeps;
// 检查每个下游 Computed 是否真正变化
for (const downstream of downstreams) {
if (processed.has(downstream)) continue;
// 保存旧版本号,用于判断值是否真正变化
const oldVersion = getSignalState(downstream).version;
// 重新计算这个 computed
getValue(downstream);
// 检查版本号是否变化
const newVersion = getSignalState(downstream).version;
if (newVersion !== oldVersion) {
// 🔑 值真正变化了:加入变化集合,并继续传播到下一层
actuallyChanged.add(downstream);
nextLayer.push(downstream);
}
// 🔑 值没变:截断传播,不加入 nextLayer
}
}
// 进入下一层
currentLayer = nextLayer;
}
return actuallyChanged;
}
关键点:
- 层级处理(BFS):使用
currentLayer和nextLayer确保父节点在子节点之前处理 - 版本号判断:利用 Computed 的
version字段判断值是否真正变化- 只有值变化时
version才会增加(在getComputed中实现) - 版本号不变说明值未变,无需传播
- 只有值变化时
- 立即检查截断:处理完一个 Computed 立即检查,决定是否加入下一层
- 避免重复处理:用
processedSet 避免同一个节点被重复处理 - 纯函数设计:只负责收集,不执行副作用,职责单一
6. 执行副作用:根据变化触发 Effects
根据收集到的变化 Signals,找出并执行所有相关的 Effects:
/**
* 执行所有受影响的 Effects
* @param changedSignals - 所有值发生变化的 Signal 集合
*/
function runEffects(changedSignals: Set<Signal<unknown>>) {
// 收集所有需要执行的 effects(去重)
const effectsToRun = new Set<EffectState>();
for (const signal of changedSignals) {
const effects = mountedEffects.get(signal);
if (effects) {
for (const effectState of effects) {
effectsToRun.add(effectState);
}
}
}
// 执行所有需要运行的 effects
for (const effectState of effectsToRun) {
// 先清除旧依赖
unmountEffect(effectState);
// 重新执行,并自动收集新依赖
effectState.fn(<T>(signal: Signal<T>) => getInEffect(signal, effectState));
}
}
关键点:
- 依赖收集:遍历所有变化的 Signals,找出依赖它们的 Effects
- 去重机制:使用
Set确保每个 Effect 只执行一次(即使它依赖多个变化的 Signals) - 先卸载再执行:清除旧依赖后重新执行,确保依赖关系始终最新
- 自动重新收集:Effect 执行时会自动通过
getInEffect重新建立依赖关系
7. 对外 API
现在我们已经实现了完整的内部机制,接下来提供对外的 API 接口。
setForEffect - 更新 Primitive 并触发 Effects
这是一个组合函数,它将前两节的逻辑整合在一起:
/**
* 更新 Primitive 的值,并触发相关 Effects
*
* 这个函数组合了两个核心步骤:
* 1. 收集所有真正变化的 Signals(带截断优化)
* 2. 执行所有受影响的 Effects
*
* @param signal - 要更新的 Primitive
* @param value - 新的值
*/
export function setForEffect<T>(signal: Primitive<T>, value: T): void {
// 只有值真正变化时才执行后续逻辑
if (set(signal, value)) {
// 第一步:收集所有真正变化的 Signals
const changedSignals = collectChangedSignals(signal);
// 第二步:执行相关的 Effects
runEffects(changedSignals);
}
}
关键点:
- 值检查优化:通过
set函数的返回值判断是否真正变化,避免不必要的传播 - 两阶段处理:先收集后执行,职责清晰
- 对用户友好:用户只需调用一个函数,内部自动处理所有复杂逻辑
watch - 创建响应式副作用
提供一个简洁的 API 用于创建响应式副作用:
/**
* 创建一个响应式 Effect,当依赖的 Signal 变化时自动重新执行
*
* @param fn - Effect 函数,接收一个 get 函数用于读取 Signal 的值
* @returns 清理函数(dispose),调用后会停止监听并清理所有依赖关系
*
* @example
* const count = primitive(0);
* const dispose = watch((get) => {
* console.log('count:', get(count));
* });
*
* setForEffect(count, 1); // 自动执行 effect
* dispose(); // 停止监听
*/
export function watch(fn: Effect): () => void {
// 创建 Effect 状态
const effectState: EffectState = {
fn,
dependencies: new Set(),
};
// 立即执行一次,收集初始依赖
effectState.fn(<T>(signal: Signal<T>) => getInEffect(signal, effectState));
// 返回清理函数
return () => {
unmountEffect(effectState);
};
}
关键点:
- 立即执行:
watch会在创建时立即执行一次,这与 Vue 的watchEffect类似 - 自动依赖收集:首次执行时自动收集依赖,后续变化时自动响应
- 返回清理函数:调用返回的函数可以停止监听,防止内存泄漏
- 简洁的 API:使用者不需要关心底层的依赖管理细节
8. 使用示例
下面通过几个实际例子展示 Watch 的使用场景:
示例 1:监听原始值变化
const count = primitive(0);
// 监听 count 变化
const dispose = watch((get) => {
console.log("count changed:", get(count));
});
// 输出: count changed: 0 (立即执行)
setForEffect(count, 1); // 输出: count changed: 1
setForEffect(count, 2); // 输出: count changed: 2
dispose(); // 停止监听
setForEffect(count, 3); // 不再输出
示例 2:监听计算属性变化
const firstName = primitive("John");
const lastName = primitive("Doe");
const fullName = computed((get) => `${get(firstName)} ${get(lastName)}`);
// 监听计算属性
const dispose = watch((get) => {
console.log("Full name:", get(fullName));
});
// 输出: Full name: John Doe
setForEffect(firstName, "Jane");
// 输出: Full name: Jane Doe (自动响应)
setForEffect(lastName, "Smith");
// 输出: Full name: Jane Smith (自动响应)
dispose();
示例 3:条件依赖
const useMetric = primitive(true);
const celsius = primitive(25);
const fahrenheit = primitive(77);
const displayTemp = watch((get) => {
if (get(useMetric)) {
console.log(`Temperature: ${get(celsius)}°C`);
} else {
console.log(`Temperature: ${get(fahrenheit)}°F`);
}
});
// 输出: Temperature: 25°C
// 修改 celsius,会触发 effect
setForEffect(celsius, 30);
// 输出: Temperature: 30°C
// 修改 fahrenheit,不会触发 effect(因为当前不依赖它)
setForEffect(fahrenheit, 86);
// 无输出
// 切换单位后,依赖关系自动更新
setForEffect(useMetric, false);
// 输出: Temperature: 86°F
// 现在修改 celsius 不会触发,修改 fahrenheit 会触发
setForEffect(celsius, 35); // 无输出
setForEffect(fahrenheit, 90); // 输出: Temperature: 90°F
示例 4:多层依赖链
const a = primitive(1);
const b = computed((get) => get(a) * 2);
const c = computed((get) => get(b) + 10);
watch((get) => {
console.log("c:", get(c));
});
// 输出: c: 12 (1 * 2 + 10)
setForEffect(a, 5);
// 输出: c: 20 (5 * 2 + 10)
// Effect 会自动响应整条依赖链的变化
完整代码
computed 的代码
/**
* 1. primitive: 原子数据
* 2. computed: readonly;lazy update;auto collect dependent;version cache
* 3. API: get(primitive/computed) set(primitive)
*/
// ============= Signal =============
export type Signal<T> = Primitive<T> | Computed<T>;
export type Getter = <T>(signal: Signal<T>) => T;
type UpdateComputed<T> = (get: Getter) => T;
export interface Primitive<T> {
$$type: "primitive";
init: T;
}
interface Computed<T> {
$$type: "computed";
update: UpdateComputed<T>;
}
// ============= State =============
interface PrimitiveState<T> {
val: T;
version: number; // 版本号,每次更新 +1
redDeps: Set<Computed<unknown>>; // 建立反向依赖,用于通知 watch 回调
}
interface ComputedState<T> {
val?: T;
version: number;
dependencies: Map<Signal<unknown>, number>; // 记录依赖及其 version
redDeps: Set<Computed<unknown>>; // 建立反向依赖,用于通知 watch 回调
}
type SignalState<T> = PrimitiveState<T> | ComputedState<T>;
const stateMap = new WeakMap<Signal<unknown>, SignalState<unknown>>();
// ============= API =============
/**
* 创建一个 Primitive
*/
export function primitive<T>(initialValue: T): Primitive<T> {
return {
$$type: "primitive",
init: initialValue,
};
}
/**
* 创建一个 Computed
*/
export function computed<T>(update: UpdateComputed<T>): Computed<T> {
return {
$$type: "computed",
update,
};
}
/**
* 读取 Signal 的值
*/
export function get<T>(signal: Signal<T>): T {
return getValue(signal);
}
/**
* 更新 Primitive 的值
*/
export function set<T>(signal: Primitive<T>, value: T): boolean {
const primitiveState = getSignalState(signal);
if (primitiveState.val !== value) {
primitiveState.val = value;
primitiveState.version += 1;
return true;
}
return false;
}
// ============= Implement =============
export function getSignalState<T>(signal: Primitive<T>): PrimitiveState<T>;
export function getSignalState<T>(signal: Computed<T>): ComputedState<T>;
export function getSignalState<T>(signal: Signal<T>): SignalState<T>;
export function getSignalState<T>(signal: Signal<T>): SignalState<T> {
let state = stateMap.get(signal);
if (!state) {
if (signal.$$type === "primitive") {
state = {
val: signal.init,
// primitive 直接挂载
version: 1,
redDeps: new Set(),
};
} else {
state = {
// computed 需要时挂载
version: 0,
dependencies: new Map(),
redDeps: new Set(),
};
}
stateMap.set(signal, state);
}
return state as SignalState<T>;
}
export function getValue<T>(signal: Signal<T>, computedContext?: ComputedContext): T {
if (signal.$$type === "primitive") {
return getSignalState(signal).val;
} else {
return getComputed(signal, computedContext);
}
}
// computed 内部取值
function getInComputed<V, T>(signal: Signal<V>, computed: Computed<T>, computedContext: ComputedContext): V {
const dep = getSignalState(signal);
getSignalState(computed).dependencies.set(signal, dep.version);
dep.redDeps.add(computed);
return getValue(signal, computedContext);
}
function isMounted<T>(computedState: ComputedState<T>) {
return computedState.version > 0;
}
function assert(result: boolean, messge: string) {
if (!result) {
throw messge;
}
}
// 状态转移: dirty -> computing -> valid
type ComputedStatus = "dirty" | "computing" | "valid";
// 计算上下文: 获取一个计算值时,需要维护的状态
interface ComputedContext {
computedStatus: WeakMap<ComputedState<unknown>, ComputedStatus>;
}
function isValid(computed: Computed<unknown>, computedContext: ComputedContext): boolean {
const computedState = getSignalState(computed);
if (!isMounted(computedState)) {
return false;
}
// 遍历所有依赖
for (const [dep, savedVersion] of computedState.dependencies.entries()) {
if (dep.$$type === "computed") {
// 计算属性无法直接知道是否为脏,需要进一步判断
const depStatus = getStatus(dep, computedContext);
assert(depStatus != "computing", "判断状态时,不会返回 computing");
if (depStatus === "dirty") {
// 继续判断,获取最新值
getComputed(dep, computedContext);
}
}
const depState = stateMap.get(dep);
if (!depState) {
// 依赖不存在。对应的状态被删除
return false;
}
if (depState.version !== savedVersion) {
// 依赖值更新
return false;
}
}
return true;
}
function getStatus<T>(computedState: ComputedState<T>, computedContext: ComputedContext): ComputedStatus {
let status = computedContext.computedStatus.get(computedState);
if (!status) {
status = isValid(computedState, computedContext) ? "valid" : "dirty";
computedContext.computedStatus.set(computedState, status);
}
return status;
}
function dirtyToComputing<T>(computedState: ComputedState<T>, computedContext: ComputedContext) {
assert(computedContext.computedStatus.get(computedState) == "dirty", "只能从 dirty 转移到 computing");
computedContext.computedStatus.set(computedState, "computing");
}
function computingToValid<T>(computedState: ComputedState<T>, computedContext: ComputedContext) {
assert(computedContext.computedStatus.get(computedState) == "computing", "只能从 computing 转移到 valid");
computedContext.computedStatus.set(computedState, "valid");
}
export class CircularDependencyError extends Error {}
function clearDep(computed: Computed<unknown>) {
const computedState = getSignalState(computed);
// 从所有依赖的 redDeps 中移除当前 computed
for (const dep of computedState.dependencies.keys()) {
const depState = stateMap.get(dep);
if (depState) {
depState.redDeps.delete(computed);
}
}
// 清空依赖列表
computedState.dependencies.clear();
// 注意:不清除 redDeps,因为它记录的是"谁依赖我"
}
function getComputed<T>(computed: Computed<T>, computedContext?: ComputedContext): T {
// 如果没有上下文,说明是入口,创建上下文
if (!computedContext) {
computedContext = {
computedStatus: new WeakMap(),
};
}
const computedState = getSignalState(computed);
const status = getStatus(computedState, computedContext);
switch (status) {
case "computing":
throw new CircularDependencyError();
case "valid":
return computedState.val!;
case "dirty":
// 重新计算
}
dirtyToComputing(computedState, computedContext);
clearDep(computed);
// 执行计算(会自动收集依赖)
const result = computed.update((dep) => getInComputed(dep, computed, computedContext!));
computingToValid(computedState, computedContext);
if (computedState.val !== result) {
// 更新状态
computedState.val = result;
computedState.version += 1;
} else if (!isMounted(computedState)) {
computedState.version += 1;
}
return result;
}
watch 的代码
import { Getter, getValue, Signal, getSignalState, Primitive, set } from "./computed.ts";
type Effect = (get: Getter) => void;
interface EffectState {
fn: Effect; // 只能读,不能写
dependencies: Set<Signal<unknown>>; // 存储正向依赖
}
// effect 依赖的信号(反向依赖:signal -> effects)
const mountedEffects: WeakMap<Signal<unknown>, Set<EffectState>> = new WeakMap();
// 清除正向依赖和反向依赖
function unmountEffect(effectState: EffectState) {
// 从所有依赖的 signal 中移除这个 effectState
for (const signal of effectState.dependencies) {
const effects = mountedEffects.get(signal);
if (effects) {
effects.delete(effectState);
if (effects.size === 0) {
mountedEffects.delete(signal);
}
}
}
effectState.dependencies.clear();
}
// 建立正向依赖和反向依赖
function mountEffect(signal: Signal<unknown>, effectState: EffectState) {
let effects = mountedEffects.get(signal);
if (!effects) {
effects = new Set();
mountedEffects.set(signal, effects);
}
effects.add(effectState);
effectState.dependencies.add(signal);
}
function getInEffect<T>(signal: Signal<T>, effectState: EffectState) {
mountEffect(signal, effectState);
return getValue(signal);
}
function collectChangedSignals(changedSignal: Primitive<unknown>): Set<Signal<unknown>> {
const actuallyChanged = new Set<Signal<unknown>>();
actuallyChanged.add(changedSignal);
let currentLayer: Signal<unknown>[] = [changedSignal];
const processed = new Set<Signal<unknown>>();
while (currentLayer.length > 0) {
const nextLayer: Signal<unknown>[] = [];
for (const signal of currentLayer) {
if (processed.has(signal)) continue;
processed.add(signal);
const downstreams = getSignalState(signal).redDeps;
for (const downstream of downstreams) {
if (processed.has(downstream)) continue;
const oldVersion = getSignalState(downstream).version;
getValue(downstream);
const newVersion = getSignalState(downstream).version;
if (newVersion !== oldVersion) {
actuallyChanged.add(downstream);
nextLayer.push(downstream);
}
}
}
currentLayer = nextLayer;
}
return actuallyChanged;
}
function runEffects(changedSignals: Set<Signal<unknown>>) {
const effectsToRun = new Set<EffectState>();
for (const signal of changedSignals) {
const effects = mountedEffects.get(signal);
if (effects) {
for (const effectState of effects) {
effectsToRun.add(effectState);
}
}
}
for (const effectState of effectsToRun) {
unmountEffect(effectState);
effectState.fn(<T>(signal: Signal<T>) => getInEffect(signal, effectState));
}
}
export function setForEffect<T>(signal: Primitive<T>, value: T): void {
if (set(signal, value)) {
const changedSignals = collectChangedSignals(signal);
runEffects(changedSignals);
}
}
/**
* 创建一个响应式 effect,当依赖的信号变化时自动重新执行
*
* @param fn - effect 函数,接收一个 get 函数用于读取信号值
* @returns 清理函数,调用后会停止监听并清理所有依赖关系
*/
export function watch(fn: Effect): () => void {
const effectState: EffectState = {
fn,
dependencies: new Set(),
};
// 执行 effect 函数,收集依赖
effectState.fn(<T>(signal: Signal<T>) => getInEffect(signal, effectState));
return () => {
unmountEffect(effectState);
};
}
进一步优化
- 内存优化: 当前状态全局存储,不利于测试和管理
- 错误处理
- 异步处理