计算属性的一种简单 TS 实现

152 阅读7分钟

计算属性是现代响应式编程中的核心概念,它是一种基于其他状态自动派生的只读数据。本文使用 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;
}

关键点

  1. 层级处理(BFS):使用 currentLayernextLayer 确保父节点在子节点之前处理
  2. 版本号判断:利用 Computed 的 version 字段判断值是否真正变化
    • 只有值变化时 version 才会增加(在 getComputed 中实现)
    • 版本号不变说明值未变,无需传播
  3. 立即检查截断:处理完一个 Computed 立即检查,决定是否加入下一层
  4. 避免重复处理:用 processed Set 避免同一个节点被重复处理
  5. 纯函数设计:只负责收集,不执行副作用,职责单一

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));
  }
}

关键点

  1. 依赖收集:遍历所有变化的 Signals,找出依赖它们的 Effects
  2. 去重机制:使用 Set 确保每个 Effect 只执行一次(即使它依赖多个变化的 Signals)
  3. 先卸载再执行:清除旧依赖后重新执行,确保依赖关系始终最新
  4. 自动重新收集: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);
  };
}

进一步优化

  • 内存优化: 当前状态全局存储,不利于测试和管理
  • 错误处理
  • 异步处理