CCState:为大型 Web 应用设计的状态管理库

260 阅读31分钟

CCState 是一个基于 Signal 的状态管理库。它通过三种语义化的信号类型(State、Computed、Command)实现读写能力隔离,并原生支持 async/await 的异步计算,让状态管理变得简单直观。CCState 与框架无关,可与 React、Vue、Solid.js 等任何 UI 框架无缝集成。它在实际项目中得到验证,为大规模应用而设计。

快速上手

Signal

Signal 是一个轻量级的描述对象,它本身不存储值,只是一个"引用"或"标识符"。所有 Signal 的值都存储在 Store 中。CCState 提供三种 Signal 类型:

State - 可读写信号,表示原子状态:

import { state } from "ccstate";

// State 是一个信号,只是声明了状态的存在
const count$ = state(0);
const user$ = state({ name: "Alice", age: 30 });

// 值存储在 Store 中
const store = createStore();
store.set(count$, 10); // 将值 10 写入 Store
const value = store.get(count$); // 从 Store 读取值

Computed - 只读信号,读取时执行计算逻辑:

import { computed } from "ccstate";

// Computed 是一个信号,声明了计算逻辑
// 它本身不存储值,只在被读取时执行计算
const double$ = computed((get) => get(count$) * 2);
const userName$ = computed((get) => get(user$).name);

// 读取 Computed 时,执行计算逻辑,计算结果缓存在 Store 中
const doubled = store.get(double$); // 执行计算: get(count$) * 2

// Computed 内只能读取信号,不能写入
const invalid$ = computed(({ get, set }) => {
  // ❌ 编译错误: Computed 的 get 回调中没有 set 参数
  set(count$, 10);
});

Command - 只写信号,写入时执行业务逻辑:

import { command } from "ccstate";

// Command 是一个信号,声明了业务逻辑
// 它本身不存储值,只在被写入时执行逻辑
const increment$ = command(({ get, set }) => {
  // Command 内可以读取和写入信号
  set(count$, get(count$) + 1);
});

// 写入 Command 时,执行业务逻辑
store.set(increment$); // 执行: set(count$, get(count$) + 1)

// Command 不能被读取
// store.get(increment$);  // ❌ 编译错误: Command 不能被读取

Store - 状态容器,存储所有 Signal 的值:

import { createStore } from "ccstate";

// Store 是实际存储值的地方
const store = createStore();

// Signal 只是标识符,值存储在 Store 中
store.set(count$, 10); // 将值 10 存入 Store
const value = store.get(count$); // 从 Store 读取值 10

基础使用

通过 Store 读取和修改状态:

// 读取状态
const count = store.get(count$); // 0
const double = store.get(double$); // 0

// 修改 State
store.set(count$, 10);
console.log(store.get(count$)); // 10
console.log(store.get(double$)); // 20(自动重新计算)

// 执行 Command
store.set(increment$);
console.log(store.get(count$)); // 11

使用 watch 订阅状态变化,当依赖的信号变化时自动执行

store.watch((get) => {
  console.log("Count:", get(count$));
});
// 输出: Count: 11

store.set(count$, 20);
// 输出: Count: 20

与 React 集成

CCState 通过 ccstate-react 提供 React 绑定,让组件能够响应式地订阅状态变化。

注入 Store

使用 StoreProvider 在应用根组件注入 Store,子组件通过 hooks 访问状态:

import { createStore } from "ccstate";
import { StoreProvider } from "ccstate-react";

function App() {
  const store = createStore();

  return (
    <StoreProvider value={store}>
      <Counter />
    </StoreProvider>
  );
}

注入 Store 后,子组件无需直接访问 store 对象,所有操作通过 hooks 完成。

重要特性:CCState 中的所有状态(State、Computed、Command)都是全局状态。Store 通过 React 的 useContext 全局注入。所以 signal 可以在任何组件中使用,不受组件层级限制

useGet

useGet 读取状态并自动订阅变化。它实现了细粒度的增量更新:组件只订阅实际访问的状态,只有这些状态变化时才会重新渲染。

import { useGet } from "ccstate-react";

const count$ = state(0);
const double$ = computed((get) => get(count$) * 2);
const message$ = state("Hello");

function Counter() {
  const count = useGet(count$);
  const double = useGet(double$);
  // 注意:这里没有使用 message$

  return (
    <div>
      <p>Count: {count}</p>
      <p>Double: {double}</p>
    </div>
  );
}

// 修改 count$,Counter 会重新渲染
store.set(count$, 10);

// 修改 message$,Counter 不会重新渲染(未订阅)
store.set(message$, "World");

实现原理useGet 内部使用 React 18 的 useSyncExternalStore API,通过 store.watch 订阅状态变化。当订阅的 Signal 发生变化时,watch 回调触发组件重新渲染,实现了增量更新。由于 watch 会自动追踪依赖,useGet 无需手动指定依赖数组,不会遗漏或过度订阅。

useSet

useSet 返回一个函数,用于修改 State 或执行 Command。对于 State,返回 setter 函数;对于 Command,返回执行器函数:

import { useGet, useSet } from "ccstate-react";

const count$ = state(0);
const increment$ = command(({ get, set }) => {
  set(count$, get(count$) + 1);
});

function Counter() {
  const count = useGet(count$);
  const setCount = useSet(count$); // 返回修改 State 的函数
  const increment = useSet(increment$); // 返回执行 Command 的函数

  return (
    <div>
      <p>Count: {count}</p>
      {/* 在事件处理器中调用 setCount */}
      <button onClick={() => setCount(count + 1)}>+1</button>
      {/* Command 执行器可以直接作为事件处理器 */}
      <button onClick={increment}>Increment</button>
    </div>
  );
}

重要提示:不要在组件渲染期间直接调用 useSet 返回的函数,应该在事件处理器、useEffect 或其他副作用中调用。

useSet 返回的函数在组件生命周期内保持稳定,可以安全地传递给子组件或作为 useEffect 依赖。

异步状态的统一管理

CCState 提供了四个 hooks 来处理异步状态,它们以不同的方式管理 loading 状态。

useResolved:返回异步结果,loading 时返回 undefined

import { useResolved } from "ccstate-react";

const userId$ = state("123");
const user$ = computed(async (get) => {
  const id = get(userId$);
  const resp = await fetch(`/api/users/${id}`);
  return resp.json();
});

function UserProfile() {
  const user = useResolved(user$);

  // loading 时 user 为 undefined
  if (user === undefined) {
    return <div>Loading...</div>;
  }

  return <div>User: {user.name}</div>;
}

useLastResolved:当依赖变化触发重新计算时,保留上一次成功的结果,避免闪烁:

import { useLastResolved } from "ccstate-react";

const userId$ = state("123");
const user$ = computed(async (get) => {
  const id = get(userId$);
  const resp = await fetch(`/api/users/${id}`);
  return resp.json();
});

function UserProfile() {
  const user = useLastResolved(user$);

  // 首次 loading 时为 undefined
  return <div>User: {user?.name ?? "Loading..."}</div>;
}

// 使用场景:用户切换 userId 时,保留旧用户信息直到新用户加载完成
store.set(userId$, "123"); // 加载用户 123
// 显示: "User: Alice"

store.set(userId$, "456"); // 触发 user$ 重新计算
// useResolved 会返回 undefined,显示 "Loading..."
// useLastResolved 仍返回 Alice(上一次的结果),显示 "User: Alice"
// 等新数据加载完成后,才更新为 "User: Bob"

useLoadable:手动处理 loading、hasData、hasError 三种状态:

import { useLoadable } from "ccstate-react";

function UserProfile() {
  const userLoadable = useLoadable(user$);

  if (userLoadable.state === "loading") {
    return <div>Loading...</div>;
  }

  if (userLoadable.state === "hasError") {
    return <div>Error: {userLoadable.error.message}</div>;
  }

  return <div>User: {userLoadable.data.name}</div>;
}

useLastLoadable:类似 useLoadable,但保留上一次成功的数据:

对比总结

HookLoading 时显示重新加载时适用场景
useResolvedundefined返回 undefined简单场景,可接受 loading 闪烁
useLastResolvedundefined保留旧值避免 UI 闪烁,如分页、筛选
useLoadableloading 状态回退到 loading需要明确展示加载状态
useLastLoadableloading 状态保持 hasData 状态避免 UI 闪烁,同时需要状态信息

状态管理的核心挑战

状态管理是现代 Web 应用的基础设施,它需要解决派生状态计算、性能优化、异步处理、异常管理、测试和调试等一系列挑战。

派生状态计算

派生状态(Derived State)指基于其他状态计算得出的状态。比如购物车商品列表是原始状态,总价就是派生状态。状态管理需要解决派生状态的以下问题:

  1. 如何收集依赖:派生状态可能根据条件依赖不同的原始状态(如 condition ? a : b),如何自动追踪这些动态变化的依赖?
  2. 是否需要重新计算:当原始状态变化时,如何高效判断哪些派生状态的缓存已失效,需要重新计算?
  3. 重新计算的时机:什么时候重新计算?
  4. 循环依赖检测:如何检测并处理 A 依赖 B、B 依赖 A 的循环依赖?
  5. 菱形依赖的一致性保证:在菱形依赖结构中(A 依赖 B 和 C,B 和 C 都依赖 D),当 D 变化时,如何保证 A 不会读取到 B 和 C 的不一致状态?

CCState 采用 动态收集依赖 + 版本号机制 的策略,重新计算的时机根据 Computed 是否被订阅有所不同,下一节介绍

1. 动态收集依赖: 在执行计算时,通过 get 回调自动收集依赖关系;每次重新计算都会重新收集依赖:

import { state, computed, createStore } from "ccstate";

const useDiscount$ = state(false);
const originalPrice$ = state(100);
const discountPrice$ = state(80);

// 根据条件动态依赖不同的状态
const finalPrice$ = computed((get) => {
  return get(useDiscount$) ? get(discountPrice$) : get(originalPrice$);
});

const store = createStore();
store.get(finalPrice$); // 100(当前依赖 useDiscount$ 和 originalPrice$)

// 修改 discountPrice$ 不会触发重新计算(未被依赖)
store.set(discountPrice$, 70);
store.get(finalPrice$); // 100(使用缓存)

// 切换折扣后,依赖关系自动更新
store.set(useDiscount$, true);
store.get(finalPrice$); // 70(重新计算,现在依赖 useDiscount$ 和 discountPrice$)

2. 版本号判断是否需要重新计算:CCState 为每个状态维护版本号,派生状态记录依赖的版本快照:

const cartItems$ = state([
  { id: 1, price: 100, quantity: 2 },
  { id: 2, price: 50, quantity: 3 },
]);

const totalPrice$ = computed((get) => {
  const items = get(cartItems$);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});

const store = createStore();
// totalPrice$ 记录 cartItems$ 的版本号
store.get(totalPrice$);

// 修改 cartItems$ 时,版本号递增
store.set(cartItems$, [{ id: 1, price: 100, quantity: 3 }]);

// 下次访问时,比对版本号发现不一致,重新计算
store.get(totalPrice$); // 300

只有值真正变化时才递增版本号,避免不必要的下游更新。

3. 循环依赖检测

CCState 通过缓存机制处理循环依赖。当出现循环依赖时,CCState 的行为分为两个阶段:

// eslint-disable-next-line prefer-const
let a$: Computed<number>;
const b$ = computed((get): number => {
  return get(a$) + 1;
});

a$ = computed((get): number => get(b$) + 1);

const store = createStore();

// 首次获取时,没有形成依赖链条。直接返回原始值。
// get(a$) 返回 undefined。get(b$) 返回 NaN(undefined + 1 = NaN)
expect(store.get(b$)).toBeNaN();

try {
  // 再次获取时,根据依赖的 epoch 判断是否为脏。
  // 因为有循环依赖,所以会循环判断,导致抛出 RangeError
  store.get(b$);
  expect.fail("Expected to throw an error");
} catch (error) {
  expect(error).toBeInstanceOf(RangeError);
}

循环依赖的两个阶段:

  • 首次计算阶段:当首次获取循环依赖的 Computed 时,还没有建立完整的依赖链条。此时 CCState 会直接执行计算,遇到未初始化的依赖会返回 undefined,导致计算结果为 NaN(undefined + 1 = NaN)。
  • 后续访问阶段:当依赖链条建立后,再次访问时 CCState 会根据依赖的版本号(epoch)判断是否需要重新计算。由于循环依赖的存在,版本号检查会陷入无限循环,最终抛出 RangeError

注意:CCState 不会在创建时主动检测循环依赖,而是在运行时通过栈溢出错误暴露问题。开发者需要自己避免创建循环依赖,否则会在首次获取时得到 NaN,在后续访问时抛出 RangeError

4. 菱形依赖的一致性保证

CCState 在 get(computed) 时,会计算所有的依赖;在通过 watch 订阅 Signal 时,没有 set 操作。所以,这个问题不会发生

派生状态的性能优化

派生状态会带来以下性能问题

  • 不必要的计算:即使状态未被使用,也会触发计算,浪费 CPU 资源
  • 级联重新计算:要读取一个派生状态的值,需要重新计算它依赖的所有派生状态
  • 立即更新订阅:每一次状态的修改,都会触发订阅的回调。会导致订阅者被重复通知,造成计算冗余

CCState 的解决方案

1. 按需计算Computed 只在被订阅(mounted)时才会响应式更新,未订阅时采用懒计算:

const base$ = state(0);
const expensive$ = computed((get) => {
  const value = get(base$);
  // 复杂计算
  return heavyComputation(value);
});

store.set(base$, 1); // expensive$ 不会立即计算

// 只有在 watch 订阅后,才会响应式更新
store.watch((get) => {
  console.log(get(expensive$)); // 此时 expensive$ 进入 mounted 状态
});

store.set(base$, 2); // 现在会触发 expensive$ 的计算

2. 级联计算优化

在级联 Computed 依赖链中(如 A 依赖 B,B 依赖 C),读取 A 的值时,需要先判断 B 是否需要重新计算,而判断 B 又需要检查 C。这种层层检查会带来性能开销。CCState 针对这个问题的优化策略取决于读取方式

通过 Store.get、Computed.get、Command.get 读取:没有优化:需要遍历整个依赖链,逐个检查依赖的版本号来判断是否需要重新计算。

const a$ = state(1);
const b$ = computed((get) => get(a$) * 2);
const c$ = computed((get) => get(b$) + 10);

const store = createStore();

// 通过 store.get 读取时,仍需要遍历 c$ -> b$ -> a$ 的依赖链
store.get(c$); // 需要检查整个依赖链
store.get(c$); // 再次读取,仍需要遍历整个依赖链

通过 Watch.get 读取:可能有优化

  • set 修改 State 时,触发依赖该 State 的 watch 回调
  • 在执行 watch 回调之前,CCState 会沿着反向依赖链,提前计算部分 Computed(具体哪些 Computed 会被提前计算,取决于反向依赖关系)
  • watch.get 读取 Computed 时,如果某个 Computed 被提前计算了,则不需要再次计算,直接返回即可
const a$ = state(1);
const b$ = computed((get) => {
  console.log("Computing b$");
  return get(a$) * 2;
});
const c$ = computed((get) => {
  console.log("Computing c$");
  return get(b$) + 10;
});

const store = createStore();

// 订阅 c$,建立反向依赖链:a$ -> b$ -> c$ -> watch
store.watch((get) => {
  console.log("Result:", get(c$));
});
// 输出:
// Computing b$
// Computing c$
// Result: 12

// 修改 a$,触发 watch
store.set(a$, 2);
// 输出:
// Computing b$ ← set 时,沿反向依赖链提前计算
// Computing c$ ← set 时,沿反向依赖链提前计算
// Result: 14 ← watch.get 读取时,b$ 和 c$ 已被提前计算,直接返回

3. 批量更新订阅:批量更新是指在一次操作中修改多个原子状态时,只触发一次副作用回调。这里 CCState 没有优化,在一次 Command 中会触发多次 Watch 回调:

const updateName$ = command(({ set }) => {
  set(firstName$, "John"); // 第一次修改
  set(lastName$, "Doe"); // 第二次修改
  // 批量更新:只触发一次 watch
  // 非批量更新:触发两次 watch
});

CCState 每次状态修改都会立即触发 watch 回调。原因有以下几个:

  1. 更简单的心智模型:状态变化即生效,便于理解和调试,开发者可以准确预期每次修改的影响
  2. CCState 不推荐使用 订阅更新。当前只在视图层使用,比如 React 使用 useGet 订阅状态的变化去修改视图,这里 React 本身有增量更新的优化,所以对性能影响较小。
  3. 异步 Command 的批量边界不明确:在异步操作中,很难确定批量更新的边界。如果做批量更新,需要在 await 前触发一次更新,还是等整个 command 完成后再触发?这会让行为变得难以预测。
const asyncCommand$ = command(async ({ set }) => {
  set(a$, 1);
  await delay(100); // 这里算不算 command 结束?
  set(b$, 2);
});

当然,作者认为可以出一个只有 同步 回调的批量更新 comamnd 。也能优化一部分订阅更新的性能。

异步处理

CCState 通过原生支持异步,Computed 自动处理竞态让异步变得简单

1. 异步控制流程:CCState 原生支持 async/await,无需额外概念:

// 异步获取用户信息
const userId$ = state("");
const user$ = computed(async (get) => {
  const userId = get(userId$);
  if (!userId) return null;

  // 直接使用 async/await
  const resp = await fetch(`/api/users/${userId}`);
  return resp.json();
});

const store = createStore();
store.set(userId$, "user123");

// get 返回 Promise
const user = await store.get(user$);

2. 处理竞态:CCState 在 Computed 中内置了 AbortSignal,自动处理竞态:

const searchQuery$ = state("");
const searchResults$ = computed(async (get, { signal }) => {
  const query = get(searchQuery$);
  if (!query) return [];

  // signal 会在新计算开始时自动 abort
  const resp = await fetch(`/api/search?q=${query}`, { signal });
  return resp.json();
});

// 快速输入 "a" -> "ab" -> "abc"
store.set(searchQuery$, "a"); // 发起请求 1
store.set(searchQuery$, "ab"); // 请求 1 被 abort,发起请求 2
store.set(searchQuery$, "abc"); // 请求 2 被 abort,发起请求 3
// 只有请求 3 的结果会被使用

异常管理

在复杂的状态管理中,异常处理面临以下挑战:

  • 派生状态异常传播:派生状态中的错误如何传递到上层调用者?
  • 错误恢复:错误发生后如何重置状态或重试?

CCState 的解决方案

1. 派生状态异常传播:Computed 中抛出的异常会自动传播到调用方

const userId$ = state("invalid-id");

const user$ = computed(async (get) => {
  const userId = get(userId$);
  const resp = await fetch(`/api/users/${userId}`);

  if (!resp.ok) {
    throw new Error(`Failed to fetch user: ${resp.status}`);
  }

  return resp.json();
});

// 在其他 Computed 中,异常会继续传播
const userName$ = computed(async (get) => {
  const user = await get(user$); // 如果 user$ 抛出异常,这里会直接抛出
  return user.name;
});

// 调用方可以使用 try/catch 捕获异常
try {
  const user = await store.get(user$);
} catch (error) {
  console.error("Error loading user:", error);
}

CCState 使用与正常值相同的依赖追踪机制处理异常。当 user$ 抛出异常时,异常会被缓存;依赖它的 userName$ 读取时会得到同样的异常。

2. 错误恢复

通过修改上游 State 来触发重新计算,实现错误恢复或重试:

const retryCount$ = state(0);

const data$ = computed(async (get) => {
  get(retryCount$); // 依赖 retryCount$,修改它会触发重新计算
  const resp = await fetch("/api/data");
  if (!resp.ok) throw new Error("Failed");
  return resp.json();
});

// 重试:修改 retryCount$ 触发重新计算
store.set(retryCount$, (x) => x + 1);

CCState 让异常处理与常规 JavaScript 代码保持一致,无需学习特殊的错误处理模式。异常和正常值使用统一的依赖追踪和缓存机制。

可测试性

CCState 通过 状态隔离、原生支持异步、视图与状态分离三个特点,让测试变得简单。

状态隔离:每个测试创建独立的 Store,天然隔离

import { test, expect } from "vitest";
import { state, computed, createStore } from "ccstate";

test("测试 1", () => {
  const store = createStore(); // 独立的 store
  const count$ = state(0);
  store.set(count$, 10);
  expect(store.get(count$)).toBe(10);
});

test("测试 2", () => {
  const store = createStore(); // 另一个独立的 store
  const count$ = state(0); // 同名 signal,但完全隔离
  expect(store.get(count$)).toBe(0); // 不受测试 1 影响
});

原生支持异步:异步操作直接使用同步逻辑或简单的 mock 数据,无需复杂的异步模拟

test("异步用户加载", async () => {
  const store = createStore();

  const userId$ = state("123");
  const user$ = computed(async (get) => {
    const userId = await get(userId$);
    // 测试中直接返回 mock 数据,无需 mock fetch
    return { id: userId, name: "Test User" };
  });

  const user = await store.get(user$);
  expect(user.name).toBe("Test User");
});

// 或者测试实际的 fetch 逻辑,使用标准的 mock 工具
test("实际 fetch 测试", async () => {
  const store = createStore();

  // 使用标准的 fetch mock
  global.fetch = vi.fn().mockResolvedValue({
    json: async () => ({ id: "123", name: "Test User" }),
  });

  const user$ = computed(async () => {
    const resp = await fetch("/api/user");
    return resp.json();
  });

  const user = await store.get(user$);
  expect(user.name).toBe("Test User");
});

视图与状态分离:业务逻辑独立于视图,可直接测试

// 业务逻辑:独立于任何 UI 框架
const user$ = computed(async (get) => {
  const id = get(userId$);
  return await fetchUser(id);
});

// 测试:无需渲染组件
test("加载用户", async () => {
  const store = createStore();
  store.set(userId$, "123");
  const user = await store.get(user$);
  expect(user.id).toBe("123");
});

可调试性

CCState 提供了 DebugStore 来展示状态行为

1. 状态变化追踪:CCState 提供 createDebugStore 用于开发调试

import { createDebugStore, state, computed } from "ccstate";

const count$ = state(0, { debugLabel: "count$" });
const double$ = computed((get) => get(count$) * 2, { debugLabel: "double$" });

// 创建调试 Store,记录 set、get、computed 等操作
const store = createDebugStore([count$, double$], ["set", "computed"]);

store.set(count$, 10);
// Console 输出: [R][SET] S0:count$ (10)

store.get(double$);
// Console 输出: [R][CPT] C1:double$ ret: 20

createDebugStore 可以记录:

  • set:所有状态修改操作
  • get:所有状态读取操作
  • computed:所有 Computed 的计算过程
  • mount/unmount:订阅状态的挂载和卸载

2. 依赖关系可视化DebugStore 提供了完整的依赖图查询 API

import { createDebugStore, state, computed } from "ccstate";

const a$ = state(1, { debugLabel: "a$" });
const b$ = computed((get) => get(a$) * 2, { debugLabel: "b$" });
const c$ = computed((get) => get(b$) + 10, { debugLabel: "c$" });

const store = createDebugStore();

// 获取 c$ 依赖了哪些 Signal(依赖树)
const deps = store.getReadDependencies(c$);
// 返回: [c$, [b$, [a$]]]

// 获取哪些 Signal 依赖了 a$(反向依赖树)
const dependents = store.getReadDependents(a$);
// 返回: [a$, [b$, [c$]]]

// 获取完整的依赖图(包含值和版本号)
const graph = store.getDependenciesGraph(c$);
// 返回: [
//   [{ signal: c$, val: 12, epoch: 1 }, { signal: b$, val: 2, epoch: 1 }, 1],
//   [{ signal: b$, val: 2, epoch: 1 }, { signal: a$, val: 1, epoch: 1 }, 1]
// ]

// 检查是否处于订阅状态
store.isMounted(c$); // false

通过 debugLabel 给信号命名,让日志更易读。在生产环境使用 createStore(),在开发环境使用 createDebugStore(),轻松切换。

设计理念 - 大型 Web 应用的状态管理库

CCState 从设计之初就针对大型 Web 应用的痛点,提出了一套完整的解决方案,让复杂应用的状态管理变得简单可控。CCState 的设计哲学是:

  • 显式优于隐式,避免魔法操作:声明式状态管理;副作用必须明确标记(通过 Command);没有 onMount、loadable 等隐式行为;严格控制异步
  • 少即是多:提供了够用的 API 能力,让项目迭代、测试、重构变的简单
  • 鼓励无副作用的计算:尽可能用 Computed
  • 对 测试 和 Debug 友好:避免使用响应式副作用;状态视图分离;Store 独立

接下来讲述,CCState 为什么这么做以及做了什么

大型 Web 应用的特点

在讨论 CCState 的设计理念之前,我们需要先理解大型 Web 应用面临的核心挑战。这些挑战不是凭空产生的,而是源于 Web 应用的本质特性,理解这些特点,才能理解 CCState 为什么要做出这样的设计选择。

状态种类繁多

Web 应用的状态可以分为三类:

  • 浏览器状态:URL、LocalStorage、Cookie 等平台提供的状态
  • 业务状态:用户信息、商品列表、订单数据等服务端数据
  • UI 状态:模态框开关、Loading 状态、表单输入等前端交互状态

随着业务增长,状态数量会越来越庞大。以一个中型 Web 项目为例,原子状态可能高达上千个,而派生状态的规模往往是原子状态的 2-3 倍,总体状态数量可达数千级。

更重要的是,这些状态之间存在复杂的依赖关系。以购物车为例:当 cartItems (商品列表) 更新时,totalPrice (总价)、discount (折扣)、finalPrice (最终价格) 等多个派生状态都需要重新计算。

如果采用命令式的方式,每次修改 cartItems,都要手动重新计算所有派生状态:

// ❌ 命令式:手动同步派生状态
function addToCart(item) {
  cartItems.push(item);

  // 手动更新所有派生状态
  totalPrice = sum(cartItems.map((i) => i.price));
  discount = totalPrice > 100 ? totalPrice * 0.1 : 0;
  finalPrice = totalPrice - discount;
}

function removeFromCart(itemId) {
  cartItems = cartItems.filter((i) => i.id !== itemId);

  // 又要手动更新一遍,容易遗漏
  totalPrice = sum(cartItems.map((i) => i.price));
  discount = totalPrice > 100 ? totalPrice * 0.1 : 0;
  finalPrice = totalPrice - discount;
}

命令式同步会带来一些问题:

  1. 重复代码:每次修改原子状态,都要手动重新计算派生状态
  2. 容易遗漏:新增派生状态后,需要在所有修改点添加同步逻辑
  3. 执行顺序错误:如果 finalPrice 的计算放在 discount 之前,会得到错误结果
  4. 代码难以维护:派生状态的计算逻辑散落在各处,修改时需要找到所有相关代码

所以 CCState 采用声明式状态管理,通过声明"状态是什么"而非"如何同步状态",自动处理派生状态的计算和更新。

逻辑复杂度渐进式增长

大型 Web 应用的复杂度不是一开始就很高,而是随着业务迭代渐进式增长的。以一个在线文档编辑器的自动保存功能为例:

初始版本:用户输入后延迟 1 秒保存 → 十几行代码 迭代 1:需要检查用户是否有编辑权限 → 依赖权限状态 迭代 2:网络断开时暂停保存,恢复后继续 → 依赖网络状态 迭代 3:多人协作时,需要解决冲突 → 依赖协作者状态和版本号 迭代 4:保存失败时需要重试,但要避免过度重试 → 依赖重试计数和错误状态 迭代 5:用户切换到另一个文档时,要取消当前保存请求 → 依赖路由状态 迭代 6:离线模式下保存到本地,上线后同步 → 依赖离线状态和同步队列

最终版本:需要协调 10+ 个状态多个异步请求复杂的条件分支 → 几百行代码

这种渐进式增长会暴露出几个核心问题:


问题 1:难以测试

当自动保存逻辑增长到几百行后,QA 报告了一个 bug:在网络断开又恢复的情况下,偶尔会重复保存同一个版本。

开发者想写单元测试来复现和修复这个 bug,但发现测试成本太高:必须渲染整个组件、Mock 十几个 Hook、手动触发 UI 事件、等待 useEffect 执行。整个测试用例写了 100 多行,却只测试了一个边缘情况。最终开发者放弃了写测试,直接在代码里加了几个条件判断,"看起来应该能修复"。

// 业务逻辑写在组件的 useEffect 中
function DocumentEditor() {
  useEffect(() => {
    // 几百行逻辑依赖十几个状态
    if (!hasPermission || !isOnline) return
    // ... 复杂的保存逻辑
  }, [content, hasPermission, isOnline, collaborators, version, ...])

  return <textarea />
}

没有测试带来的恶性循环:

  1. 代码难以理解:新同事接手时,只能通过阅读 useEffect 中的几百行代码来理解逻辑,但状态依赖关系复杂,经常看不懂"为什么要这样写"
  2. 不敢重构:想要优化代码结构,但没有测试保障,担心改了会出 bug,只能不断往上堆新逻辑
  3. 边缘情况无法覆盖:网络恢复、多人冲突、路由切换等复杂场景无法测试,只能等用户报 bug 后再手动修复
  4. 技术债累积:因为不敢重构,代码越来越混乱,新功能越来越难加,最终变成"屎山"

所以在 CCState 中极其重视可测试性。通过状态与视图分离和 Store 隔离,使业务逻辑能够以低成本、独立地进行测试。


问题 2:开发者无法区分代码是否有副作用

几个月后,性能问题开始暴露。产品经理抱怨:输入时页面卡顿。开发者发现是因为每次输入都会触发大量重复计算,决定用 useMemo 优化性能:但优化后发现了一个奇怪的现象:有些时候 useMemo 会被意外地多次执行,导致一些"看起来是纯计算"的代码产生了副作用。

原来,在大型项目中,很多函数看起来是纯计算,实际上隐藏着副作用

// ❌ 看起来是纯计算,实际上会修改状态
const needsSave = useMemo(() => {
  const result = content !== lastSavedContent;
  if (result) {
    setSaveStatus("pending"); // 隐藏的副作用:修改状态
  }
  return result;
}, [content, lastSavedContent]);

// ❌ 看起来是读取属性,实际上会触发网络请求
const userData = useMemo(() => {
  return currentUser.profile; // 隐藏的副作用:getter 中发起 API 请求
}, [currentUser]);

在传统的状态库(如 Zustand、RxJS、Signals)中,状态对象同时支持读和写,同样开发者无法在框架和类型层面区分读操作和写操作

// Zustand:状态对象同时支持读和写
const useStore = create((set) => ({
  count: 0,
  updateCount: () => set({ count: (x) => x + 1 }),
}));
useStore.getState().count; // 读取:无副作用
useStore.getState().updateCount(); // 写入:有副作用
// 问题:无法在类型层面限制某个函数只能读取状态

// Signals:value 属性同时支持读和写
const counter = signal(0);
counter.value; // 读取:无副作用
counter.value = 1; // 写入:有副作用
// 问题:无法在类型层面限制某个函数只能读取 value

这导致开发者在写代码时,完全依赖约定来判断一个函数是否有副作用。难以区分有副作用和无副作用代码会带来一些问题:

  1. 无法一眼识别:看到一个函数调用,不知道它会不会修改状态、发起请求、上报日志
  2. 优化困难:不确定能否安全地缓存结果、并发执行、重复调用
  3. 调试困难:状态被意外修改时,不知道是哪个"看起来无害"的函数干的
  4. 测试困难:无副作用的代码应该可以随时重复执行,但混在一起后,测试时必须小心控制执行次数
  5. 代码审查困难:审查时需要深入每个函数,检查是否藏了副作用

所以在 CCState 中采用了 读写分离类型系统约束 来隔离无副作用的代码,让代码的行为变得可预测、可优化、可维护


问题 3:响应式副作用难以控制

又过了几个月,产品经理提出:保存成功后,要更新浏览器标签页的标题。开发者用 useEffect 自动监听 lastSavedContent 的变化:

// 使用响应式副作用自动更新标题
useEffect(() => {
  if (lastSavedContent) {
    document.title = content.split("\n")[0];
  }
}, [lastSavedContent, content]);

几天后,又有新需求:保存成功后上报分析数据。开发者继续用 useEffect

// 保存成功后上报
useEffect(() => {
  if (lastSavedContent) {
    trackEvent("document_saved", { length: content.length });
  }
}, [lastSavedContent]);

再过一周,需要在网络恢复后自动保存。开发者又加了一个 useEffect

// 网络恢复后自动保存
useEffect(() => {
  if (isOnline && needsSave) {
    handleSave();
  }
}, [isOnline, needsSave]);

代码看起来很简洁,每个 useEffect 都在"自动响应状态变化"。但问题很快暴露了:

测试困难:不知道副作用何时触发,必须模拟整个响应式系统

QA 报告了一个 bug:标题有时候显示的是旧内容。开发者想写测试复现,但发现:

// 测试响应式副作用需要:
test("should update title after save", async () => {
  // 1. 渲染组件,触发所有 useEffect
  render(<DocumentEditor />);

  // 2. 修改 content,等待渲染
  userEvent.type(screen.getByRole("textbox"), "new content");
  await waitFor(() => {});

  // 3. 触发保存,等待异步完成
  userEvent.click(screen.getByText("Save"));
  await waitFor(() => {});

  // 4. 等待 lastSavedContent 更新,触发 useEffect
  await waitFor(() => {});

  // 5. 检查标题是否更新
  expect(document.title).toBe("new content");
});

测试代码充满了 waitFor,因为不知道副作用何时触发。更糟糕的是,三个 useEffect 的执行顺序是不确定的,可能产生竞态问题。

调试困难:不知道副作用是谁触发的,调用栈不清晰

标题更新的 bug 难以复现,开发者在 useEffect 中打断点,发现:

useEffect(() => {
  debugger; // 断点命中了,但不知道是谁触发的
  document.title = content.split("\n")[0];
}, [lastSavedContent, content]);

断点触发时,调用栈显示的是 React 内部的调度逻辑,看不到是哪个业务操作导致了 lastSavedContentcontent 的变化。开发者需要逐一排查所有修改这两个状态的地方,才能定位问题。

约束困难:到处都可以写响应式副作用,副作用遍地开花

随着项目迭代,响应式副作用散落在各处:

// 组件 A 中
useEffect(() => {
  /* 响应 lastSavedContent */
}, [lastSavedContent]);

// 组件 B 中
useEffect(() => {
  /* 响应 lastSavedContent */
}, [lastSavedContent]);

// 自定义 Hook 中
useEffect(() => {
  /* 响应 lastSavedContent */
}, [lastSavedContent]);

// Store 中(如 MobX、Vue)
autorun(() => {
  /* 响应 lastSavedContent */
});

lastSavedContent 变化时,会触发多少个副作用?执行顺序是什么?会不会产生循环依赖? 没人能回答这些问题,因为响应式副作用可以写在任何地方。

重构困难:改一个状态,不知道会触发哪些副作用

产品经理要求优化保存逻辑:改为"先保存到本地,成功后再同步到服务器"。开发者修改了 lastSavedContent 的更新时机,结果发现:

  • ❌ 标题更新的时机错了(因为依赖 lastSavedContent
  • ❌ 上报数据不准确(因为 lastSavedContent 变化就触发)
  • ❌ 网络恢复后的保存逻辑出错(因为 lastSavedContent 提前更新了)

开发者需要逐一检查所有监听 lastSavedContentuseEffect,确认是否需要调整逻辑。这种隐式的依赖关系让重构变得战战兢兢,如履薄冰

响应式副作用带来的核心问题:

  1. 测试困难:不知道何时触发,必须等待、Mock 整个响应式系统
  2. 调试困难:调用栈不清晰,看不到是谁触发的副作用
  3. 约束困难:到处都可以写,副作用遍地开花,难以管理。不知道状态的变更造成了哪些副作用,他们又怎么被清除。
  4. 重构困难:改一个状态,不知道会触发哪些副作用,容易引发连锁反应

所以在 CCState 中不推荐使用响应式副作用

// ❌ 不推荐:响应式副作用
useEffect(() => {
  if (lastSavedContent) {
    document.title = content.split("\n")[0];
  }
}, [lastSavedContent]);

// ✅ 推荐:在 Command 中显式调用
const save$ = command(({ get, set }) => {
  const content = get(content$);
  set(lastSavedContent$, content);

  // 显式更新标题
  document.title = content.split("\n")[0];
  // 显式上报数据
  trackEvent("document_saved", { length: content.length });
});

异步密集且难以预料

现代 Web 应用是异步密集型的:一个中等规模的页面可能同时管理着几十个异步操作(API 请求、定时器、WebSocket 消息、文件上传下载)。更麻烦的是,这些异步操作的执行时机和完成顺序是难以预料的。

继续自动保存功能的迭代。产品经理提出:网络断开时暂停保存,恢复后自动保存。开发者写下这样的代码:

// 网络恢复后自动保存
useEffect(() => {
  if (isOnline && needsSave) {
    handleSave(); // 发起异步保存请求
  }
}, [isOnline, needsSave]);

看起来没问题。但测试时发现了一个严重 bug:用户在文档 A 编辑时网络断开,切换到文档 B 后网络恢复,结果文档 A 的内容被保存到了文档 B

问题出在哪?原来是预期外的异步回调

// 用户在文档 A 编辑
currentDoc = "A";
content = "A 的内容";
isOnline = false; // 网络断开

// 用户切换到文档 B
currentDoc = "B";
content = "B 的内容";

// 网络恢复,触发保存
isOnline = true;
handleSave(); // 发起请求保存 "B 的内容"

// 但是!如果此时文档 A 之前的保存请求完成了
// 它会尝试更新状态,导致混乱

类似的竞态问题(Race Condition)出现在各种场景:

  • 路由切换:从页面 A 跳转到页面 B 后,页面 A 的异步请求仍在继续
  • 搜索输入:用户快速输入 "a" → "ab" → "abc",三个请求同时返回,显示哪个结果?
  • 组件卸载:弹窗关闭后,弹窗中发起的异步操作仍在执行,导致内存泄漏
  • 条件变化:用户取消了某个操作,但该操作的异步流程还在继续

松散的异步控制带来的核心问题:

  1. 竞态条件难以发现:异步操作可能在任何时候完成,导致状态更新顺序错乱
  2. 取消逻辑容易遗漏:开发者需要手动管理每个异步操作的生命周期,很容易忘记
  3. 调试困难:异步问题往往难以复现,只在特定的时序条件下出现
  4. 内存泄漏:组件卸载或条件变化后,异步操作仍在继续,可能导致内存泄漏

CCState 原生支持 async/await,推荐使用严格的异步策略,让异步变的可控。

声明式状态管理

在「状态种类繁多」一节中,我们看到命令式同步派生状态会带来重复代码、容易遗漏、执行顺序错误、代码难以维护等问题。CCState 通过声明式状态管理来解决这些问题。

CCState 通过 State、Computed、Command 三种信号类型实现完整的声明式状态管理:

  • State:声明原子状态
  • Computed:声明派生状态的计算规则("派生状态是什么")
  • Command:声明状态修改的业务逻辑("修改操作做什么")

开发者只需声明"是什么"和"做什么",而不需要关心"如何同步"和"如何通知":

import { state, computed, command, createStore } from "ccstate";

// 只定义源状态
const cartItems$ = state<CartItem[]>([]);

// 声明派生状态的计算规则
const totalPrice$ = computed((get) => {
  const items = get(cartItems$);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});

const itemCount$ = computed((get) => {
  return get(cartItems$).length;
});

// Command 只负责修改原子状态
const addItem$ = command(({ get, set }, item: CartItem) => {
  const items = get(cartItems$);
  set(cartItems$, [...items, item]);
  // totalPrice$ 和 itemCount$ 会自动更新,无需手动计算
});

const removeItem$ = command(({ get, set }, id: string) => {
  const items = get(cartItems$);
  set(
    cartItems$,
    items.filter((item) => item.id !== id)
  );
  // 对比命令式:无需手动调用 updateTotalPrice() 和 updateItemCount()
});

const updateQuantity$ = command(({ get, set }, id: string, quantity: number) => {
  const items = get(cartItems$);
  set(
    cartItems$,
    items.map((item) => (item.id === id ? { ...item, quantity } : item))
  );
  // 所有派生状态都会自动更新,不会遗漏,执行顺序正确
});

const store = createStore();

// 使用
store.set(addItem$, { id: "1", price: 100, quantity: 2 });
store.get(totalPrice$); // 200 - 自动计算
store.get(itemCount$); // 1 - 自动计算

store.set(updateQuantity$, "1", 3);
store.get(totalPrice$); // 300 - 自动更新
store.get(itemCount$); // 1 - 保持一致

声明式状态管理的好处

  1. 无需手动同步:派生状态的计算逻辑只需声明一次,CCState 自动处理依赖追踪和更新
  2. 不会遗漏更新:修改原子状态时,所有依赖它的派生状态都会自动重新计算
  3. 执行顺序正确:CCState 保证派生状态按照依赖关系正确计算,不会出现顺序错误
  4. 易于维护:派生状态的计算逻辑集中在 Computed 中,修改时只需改一处

需要注意的是,声明式状态管理会带来额外的计算。但由于 Web 项目,状态规模一是可控的,大多数场景不会超过 10k;而且,派生状态相互之前的依赖深度一般不会超过 20。所以,声明式状态管理一般不会带来性能的问题。

通过读写分离隔离无副作用的代码

在「逻辑复杂度渐进式增长」一节的「问题 2:无法区分副作用」中,我们看到传统状态库无法在框架和类型层面区分读操作和写操作,导致开发者完全依赖约定来判断一个函数是否有副作用。这带来了无法一眼识别、优化困难、调试困难、测试困难、代码审查困难等问题。

CCState 通过读写分离来隔离无副作用的代码。核心机制是:

  • Computed 只能读:回调函数只有 get 参数,类型系统保证无法写入状态
  • Command 可以读写:回调函数同时有 getset 参数,显式封装副作用
  • 类型安全store.get() 只接受 State 和 Computed;store.set() 只接受 State 和 Command
import { state, computed, command, createStore } from "ccstate";

// State: 原子状态
const user$ = state({ name: "Alice", viewCount: 0 });
const viewLog$ = state<string[]>([]);

// Computed: 只读,类型系统保证无副作用
const userName$ = computed((get) => {
  const user = get(user$);
  // Computed 的 get 回调中没有 set 参数,类型层面保证只能读
  // set(user$, ...) // ❌ 编译错误:Computed 中没有 set 函数
  return user.name;
});

const viewCount$ = computed((get) => {
  return get(user$).viewCount;
  // 可以安全地缓存、并发执行、重复调用,因为没有副作用
});

// Command: 可读可写,显式封装副作用
const incrementViewCount$ = command(({ get, set }) => {
  const user = get(user$);
  set(user$, { ...user, viewCount: user.viewCount + 1 });

  const logs = get(viewLog$);
  set(viewLog$, [...logs, `User viewed at ${Date.now()}`]);
  // 副作用集中在 Command 中,调用时一眼就能看出会修改状态
});

const store = createStore();

// 读操作:类型系统保证无副作用
const name = store.get(userName$); // 多次调用结果一致
const name2 = store.get(userName$); // 不会产生任何副作用
// 开发者可以安全地使用 useMemo 缓存、并发执行、重复调用

// 写操作:显式可见
store.set(incrementViewCount$); // 一眼看出这会修改状态
// store.get(incrementViewCount$); // ❌ 编译错误:Command 不能被 get

// 类型安全:Computed 不能被修改
// store.set(userName$, "Bob"); // ❌ 编译错误:Computed 不能被 set

读写分离的好处

  1. 一眼识别副作用:看到 store.get()Computed,就知道没有副作用;看到 store.set()Command,就知道有副作用
  2. 可以安全优化:Computed 保证无副作用,可以安全地缓存、并发执行、重复调用
  3. 易于调试:状态修改只能通过 store.set()Command,缩小排查范围
  4. 易于测试:无副作用的代码可以随时重复执行,测试时无需小心控制执行次数
  5. 易于代码审查:看类型就知道有没有副作用,无需深入每个函数检查

避免使用响应式副作用

在「逻辑复杂度渐进式增长」一节的「问题 3:响应式副作用难以控制」中,我们看到响应式副作用(如 useEffectwatch)会带来测试困难、调试困难、约束困难、重构困难等问题。核心原因是:响应式副作用是隐式触发的,开发者无法通过代码调用栈清晰地看到副作用是如何被触发的。

CCState 提供了 store.watch API 用于订阅状态变化,但不推荐在业务代码中使用

业务代码推荐的做法

// ✅ 推荐:在 Command 中显式调用副作用
const save$ = command(({ get, set }) => {
  const content = get(content$);
  set(lastSavedContent$, content);

  // 显式更新标题
  document.title = content.split("\n")[0];
  // 显式上报数据
  trackEvent("document_saved", { length: content.length });
});

// 调用时,一眼就能看出会执行哪些副作用
store.set(save$);

不推荐的做法

// ❌ 不推荐:在业务代码中使用 watch
store.watch((get) => {
  const lastSaved = get(lastSavedContent$);
  if (lastSaved) {
    document.title = get(content$).split("\n")[0];
    trackEvent("document_saved", { length: get(content$).length });
  }
});
// 问题:不知道何时触发、调用栈不清晰、难以测试和调试

store.watch 可以在框架集成层使用。例如在 ccstate-react 中,useGetuseResolveduseLastResolved 等 hooks 内部使用 store.watch 来实现视图的自动更新:

// ccstate-react 内部实现(简化版)
function useGet<T>(atom: State<T> | Computed<T>): T {
  const store = useStore();

  // 订阅函数:当状态变化时,通知 React 重新渲染
  const subscribe = useRef((onStoreChange: () => void) => {
    const controller = new AbortController();
    store.watch(
      (get) => {
        get(atom); // 订阅这个状态
        onStoreChange(); // 通知 React 重新渲染
      },
      { signal: controller.signal }
    );
    return () => controller.abort(); // 取消订阅
  });

  // 使用 React 18 的 useSyncExternalStore 连接状态和视图
  return useSyncExternalStore(subscribe.current, () => store.get(atom));
}

这样做的好处:

  1. 业务逻辑清晰:所有副作用都在 Command 中显式调用,调用栈清晰
  2. 易于测试:业务逻辑不依赖响应式系统,可以直接测试
  3. 易于调试:通过调用栈可以清楚地看到副作用是如何被触发的
  4. 副作用集中管理:响应式副作用只在框架集成层使用,业务代码不需要关心

状态与视图分离

在「逻辑复杂度渐进式增长」一节的「问题 1:难以测试」中,我们看到业务逻辑写在组件的 useEffect 中会导致测试成本极高:必须渲染整个组件、Mock 十几个 Hook、手动触发 UI 事件、等待 useEffect 执行。核心问题是:业务逻辑与视图层耦合,无法独立测试

CCState 通过状态与视图分离来解决这个问题。CCState 是框架无关的状态管理库,业务逻辑完全独立于 UI 框架,用纯粹的 TypeScript 编写:

// 业务逻辑层:完全独立于任何 UI 框架
const userId$ = state("");
const user$ = computed(async (get) => {
  const userId = get(userId$);
  if (!userId) return null;

  const resp = await fetch(`/api/users/${userId}`);
  return resp.json();
});

const loadUser$ = command(async ({ set }, id: string) => {
  set(userId$, id);
  // 可以直接使用 async/await,代码从第一行顺序执行
  await delay(100);
  console.log("User loaded");
});

同样的业务逻辑,可以在不同框架中使用

在 React 中使用:

// React 组件:只负责渲染
function UserProfile({ id }: { id: string }) {
  const user = useGet(user$);
  const loadUser = useSet(loadUser$);

  useEffect(() => {
    loadUser(id);
  }, [id, loadUser]);

  return <div>{user?.name}</div>;
}

在 Vue 中使用(同样的业务逻辑):

<template>
  <div>{{ user?.name }}</div>
</template>

<script setup lang="ts">
import { useGet, useSet } from "ccstate-vue";
import { user$, loadUser$ } from "./user-logic"; // 复用相同的业务逻辑

const props = defineProps<{ id: string }>();
const user = useGet(user$);
const loadUser = useSet(loadUser$);

watch(
  () => props.id,
  (id) => {
    loadUser(id);
  },
  { immediate: true }
);
</script>

状态与视图分离的好处

  1. 易于测试:业务逻辑可以脱离 UI 框架单独测试,无需渲染组件、Mock Hook、等待生命周期
  2. 易于重构:切换 UI 框架时,业务逻辑无需改动。例如从 React 迁移到 Vue,只需更换视图层代码
  3. 逻辑清晰:业务代码从第一行顺序执行,无需在多个 useEffect 中跳转理解逻辑
  4. 框架无关:同样的业务逻辑可以在 React、Vue、Solid.js、Svelte 等任何框架中使用,甚至可以在 Node.js 服务端使用
  5. 开发体验好:可以在没有 UI 的情况下先写业务逻辑,无需等待 UI

Signal 作为标识符,Store 作为状态容器

在「逻辑复杂度渐进式增长」一节的「问题 1:难以测试」中,我们看到传统状态管理使用全局状态,导致测试之间相互影响,必须手动清理状态。CCState 通过 Signal 和 Store 的分离架构 来解决这个问题。

CCState 的核心设计是:

  • Signal 是标识符:轻量级的描述对象,只是声明"状态的存在",本身不存储值
  • Store 是状态容器:实际存储所有 Signal 的值,可以创建多个独立的 Store

这种架构的关键在于:Signal 可以复用,但每个 Store 维护自己独立的值

import { state, computed, createStore } from "ccstate";

// Signal:状态标识符(轻量级描述对象)
const count$ = state(0);
const double$ = computed((get) => get(count$) * 2);

// Signal 创建成本极低,可以在模块顶层声明
console.log(count$); // 只是一个轻量级对象,不包含实际值

// Store 1:第一个状态容器
const store1 = createStore();
store1.set(count$, 10);
console.log(store1.get(count$)); // 10
console.log(store1.get(double$)); // 20

// Store 2:第二个状态容器
const store2 = createStore();
console.log(store2.get(count$)); // 0(使用 Signal 的初始值)
console.log(store2.get(double$)); // 0

// Store 1 和 Store 2 完全隔离
store1.set(count$, 20);
console.log(store1.get(count$)); // 20
console.log(store2.get(count$)); // 0(不受 Store 1 影响)

Signal 可以在任何地方声明,通过 import/export 组织和复用:

// user-state.ts - 状态定义模块
export const userId$ = state("");
export const user$ = computed(async (get) => {
  const id = get(userId$);
  if (!id) return null;
  const resp = await fetch(`/api/users/${id}`);
  return resp.json();
});

// components/UserProfile.tsx - 在组件中使用
import { user$ } from "./user-state";
const user = useGet(user$); // 使用默认的 Store

// tests/user.test.ts - 在测试中使用
import { user$, userId$ } from "./user-state";
const store = createStore(); // 创建独立的测试 Store
store.set(userId$, "123");
const user = await store.get(user$);

在测试中,每个测试创建独立的 Store,完全隔离:

import { test, expect } from "vitest";
import { state, createStore } from "ccstate";

const count$ = state(0); // Signal:状态标识符

test("测试 1", () => {
  const store = createStore(); // 创建独立的 Store
  store.set(count$, 10);
  expect(store.get(count$)).toBe(10);
});

test("测试 2", () => {
  const store = createStore(); // 创建另一个独立的 Store
  expect(store.get(count$)).toBe(0); // 不受测试 1 影响
  // 无需手动清理状态
});

Signal 作为标识符,Store 作为状态容器的好处

  1. 轻量级标识符:Signal 只是描述对象,创建成本极低,便于组织和复用
  2. 独立存储:每个 Store 维护独立的状态值,不会相互影响
  3. 测试天然隔离:每个测试创建独立 Store,无需手动清理状态,测试代码更简洁
  4. 并行测试:测试之间完全独立,可以安全地并行执行,大幅提升测试速度
  5. 多实例支持:同一个应用可以创建多个 Store,支持复杂场景
  6. 更好的内存管理:Store 可以按需创建和销毁,不需要的 Store 可以被垃圾回收

异步处理策略

在「异步密集且难以预料」一节中,我们看到异步操作的执行时机和完成顺序难以预料,导致竞态条件、取消逻辑遗漏、调试困难、内存泄漏等问题。CCState 通过完善的异步处理策略来解决这些问题。

CCState 的异步处理策略包括三个方面:

1. 内置 API 都支持异步操作,推荐直接使用 async/await 来表达异步逻辑

CCState 的所有内置 API 都支持异步操作。无论是 store.get()store.set(),还是 Computed、Command 的回调函数,都可以直接使用 async/await 来表达异步逻辑:

const userId$ = state("");

// Computed 支持异步
const user$ = computed(async (get) => {
  const id = get(userId$);
  // 直接使用 async/await
  const resp = await fetch(`/api/users/${id}`);
  return resp.json();
});

// Command 支持异步
const loadUser$ = command(async ({ get, set }, userId: string) => {
  set(userId$, userId);

  // 代码从第一行顺序执行,清晰易懂
  const resp = await fetch(`/api/users/${userId}`);
  const user = await resp.json();

  set(user$, user);
  console.log("User loaded");
});

// store.get 支持异步
const user = await store.get(user$); // 等待异步 Computed 完成

// store.set 支持异步
await store.set(loadUser$, "123"); // 等待异步 Command 完成

异步和同步使用相同的 API,无需区分:

// 同步 Computed
const count$ = state(0);
const double$ = computed((get) => get(count$) * 2);
const doubleValue = store.get(double$); // 同步返回

// 异步 Computed
const user$ = computed(async (get) => {
  const resp = await fetch("/api/user");
  return resp.json();
});
const user = await store.get(user$); // 异步返回

// API 一致,心智模型统一

2. 使用 AbortController 管理异步生命周期

CCState 要求开发者严格管理异步操作的生命周期,使用 AbortController 来取消不需要的异步操作:

// Command 可以接收 AbortSignal 参数
const loadData$ = command(async ({ get, set }, signal: AbortSignal) => {
  const query = get(searchQuery$);

  // 将 signal 传递给 fetch
  const resp = await fetch(`/api/data?q=${query}`, { signal });

  // 在 await 后检查是否已取消
  if (signal.aborted) return;

  const data = await resp.json();
  set(dataState$, data);
});

// 使用时传入 AbortSignal
const controller = new AbortController();
store.set(loadData$, controller.signal);

// 可以随时取消
controller.abort();

CCState 禁止 Floating Promise。推荐每次 await 后都应该检查 signal.aborted,避免在已取消的情况下继续执行

// ❌ 不好的实践:Floating Promise
const fetchData$ = command(({ set }) => {
  fetch("/api/data").then((resp) => {
    set(data$, resp); // 这个 Promise 没有被管理,可能在组件卸载后仍在执行
  });
});

// ✅ 好的实践:严格管理异步
const fetchData$ = command(async ({ set }, signal: AbortSignal) => {
  const resp = await fetch("/api/data", { signal });
  if (signal.aborted) return; // 检查取消状态

  const data = await resp.json();
  if (signal.aborted) return; // 再次检查

  set(data$, data);
});

3. Computed 内置 AbortSignal 自动处理竞态

Computed 内置了 AbortSignal,当依赖变化触发重新计算时,会自动取消上一次的计算:

const searchQuery$ = state("");

const searchResults$ = computed(async (get, { signal }) => {
  const query = get(searchQuery$);
  if (!query) return [];

  // signal 会在新计算开始时自动 abort
  const resp = await fetch(`/api/search?q=${query}`, { signal });
  return resp.json();
});

// 用户快速输入
store.set(searchQuery$, "a"); // 发起请求 1
store.set(searchQuery$, "ab"); // 请求 1 被 abort,发起请求 2
store.set(searchQuery$, "abc"); // 请求 2 被 abort,发起请求 3
// 只有请求 3 的结果会被使用,自动避免了竞态条件

异步处理策略的好处

  1. 代码清晰:使用 async/await 顺序编写异步代码,代码从第一行顺序执行,清晰易懂
  2. 避免竞态条件:通过 AbortSignal 自动取消过期的异步操作,避免状态更新顺序错误
  3. 防止内存泄漏:严格要求管理异步操作的生命周期,组件卸载或条件变化时正确取消
  4. 易于调试:异步操作的取消是显式的,可以通过断点清楚地看到何时取消

通过完善的异步处理策略,CCState 确保异步操作在正确的时机被取消,让代码行为可预测、可调试、不会产生内存泄漏。

避免在 React 中使用 useEffect

在「逻辑复杂度渐进式增长」一节中,我们看到了两个核心问题:「问题 3:响应式副作用难以控制」和「异步密集且难以预料」。React 的 useEffect 恰好同时具备这两个问题的特征:它是响应式副作用,且无法较好地处理异步。

useEffect 的两个核心问题

问题 1:引入响应式副作用

useEffect 是响应式的,当依赖变化时自动执行。这会带来测试困难、调试困难、约束困难、重构困难等问题:

// ❌ 使用 useEffect 的响应式副作用
function DocumentEditor() {
  const [content, setContent] = useState("");
  const [lastSavedContent, setLastSavedContent] = useState("");

  // 副作用 1:保存成功后更新标题
  useEffect(() => {
    if (lastSavedContent) {
      document.title = content.split("\n")[0];
    }
  }, [lastSavedContent, content]);

  // 副作用 2:保存成功后上报数据
  useEffect(() => {
    if (lastSavedContent) {
      trackEvent("document_saved", { length: content.length });
    }
  }, [lastSavedContent]);

  // 问题:
  // 1. 不知道何时触发
  // 2. 调用栈不清晰
  // 3. 副作用散落各处,难以管理
  // 4. 测试时需要模拟整个响应式系统
}

问题 2:无法较好处理异步

useEffect 不支持 async/await,处理异步逻辑非常繁琐:

// ❌ 在 useEffect 中处理异步
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then((resp) => resp.json())
      .then((data) => {
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true; // 手动管理取消,容易遗漏
    };
  }, [userId]);

  // 问题:
  // 1. 不能使用 async/await,必须用 Promise 链
  // 2. 需要手动管理 cancelled 标志
  // 3. loading 和 error 状态需要手动维护
  // 4. 代码被拆分到多个地方,难以理解
}

CCState 的推荐做法

CCState 推荐将业务逻辑写在 Command 中,在组件中只通过 useSet 触发:

// ✅ CCState 推荐:业务逻辑在 Command 中
const content$ = state("");
const lastSavedContent$ = state("");

const save$ = command(async ({ get, set }) => {
  const content = get(content$);
  set(lastSavedContent$, content);

  // 显式调用副作用,调用栈清晰
  document.title = content.split("\n")[0];
  trackEvent("document_saved", { length: content.length });
});

// 组件中只负责触发
function DocumentEditor() {
  const content = useGet(content$);
  const setContent = useSet(content$);
  const save = useSet(save$);

  return (
    <div>
      <textarea value={content} onChange={(e) => setContent(e.target.value)} />
      <button onClick={save}>保存</button>
    </div>
  );
}

开发体验

CCState 提供 Babel 插件来提升开发体验。reactRefreshPlugin 支持热模块替换(HMR),避免刷新时重复创建 Signal;debugLabelPlugin 自动为 Signal 添加调试标签,并将匿名函数转换为具名函数,方便调试和性能分析。

reactRefreshPlugin

在 React 开发中,热模块替换(Hot Module Replacement, HMR)让开发者修改代码后无需手动刷新浏览器即可看到效果。但对于 CCState 的 Signal,HMR 会导致状态丢失的问题。reactRefreshPlugin 通过全局缓存机制解决了这个问题。

为什么 HMR 会导致状态丢失

HMR 的工作原理是:当代码修改时,重新执行模块代码,替换旧模块。这对于 CCState 会产生以下问题:

Signal 是对象引用,而不是字符串标识符。每次执行 state(0) 都会创建一个新的对象:

// 第一次执行
const count$ = state(0); // 创建对象 A(内存地址 0x001)

// HMR 触发,代码重新执行
const count$ = state(0); // 创建对象 B(内存地址 0x002)

// A !== B,它们是两个不同的对象

Store 使用对象引用作为 key。Store 内部用 Map<Signal, Value> 存储状态:

// 用户操作流程
const count$ = state(0); // 对象 A (0x001)
const store = createStore();

// 用户点击 5 次,状态存储在 Store 中
store.set(count$, 5);
// Store 内部:Map { 0x001 => 5 }

// HMR 触发,代码重新执行
const count$ = state(0); // 对象 B (0x002)

// 组件读取
useGet(count$); // count$ 现在是对象 B (0x002)
// Store 查找:Map.get(0x002)
// 找不到!返回初始值 0
// 显示 Count: 0,用户的 5 次点击丢失了

核心问题:HMR 后,Signal 对象变了(从 A 变成 B),但 Store 中存的是旧对象 A 的值,新对象 B 找不到对应的值,只能返回初始值。

插件如何解决这个问题

reactRefreshPlugin 通过全局缓存 + 字符串 key的机制,确保 HMR 前后 Signal 对象保持不变。

核心思路:给每个 Signal 分配一个唯一的字符串 key(基于文件路径和变量名),将 Signal 对象缓存到全局 Map 中。HMR 重新执行代码时,通过字符串 key 查找缓存,如果已存在则返回缓存的对象,而不是创建新对象。

使用方式

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        presets: ["ccstate-babel/preset"],
      },
    }),
  ],
});

转换效果

转换前:

// src/atoms/counter.ts
export const count$ = state(0);
export const double$ = computed((get) => get(count$) * 2);

转换后:

// 插件自动添加全局缓存
globalThis.ccsAtomCache = globalThis.ccsAtomCache || {
  cache: new Map(),
  get(name, inst) {
    if (this.cache.has(name)) {
      return this.cache.get(name); // 返回缓存的实例
    }
    this.cache.set(name, inst);
    return inst;
  },
};

// Signal 通过缓存获取
export const count$ = globalThis.ccsAtomCache.get(
  "/src/atoms/counter.ts/count$", // 字符串 key
  state(0) // 如果缓存已存在,这个表达式不会被使用
);
export const double$ = globalThis.ccsAtomCache.get(
  "/src/atoms/counter.ts/double$",
  computed((get) => get(count$) * 2)
);

工作流程

// 第一次加载
const count$ = globalThis.ccsAtomCache.get(
  "/src/atoms/counter.ts/count$", // key
  state(0) // 创建对象 A (0x001)
);
// 缓存为空,创建对象 A,存入缓存
// cache: Map { "/src/atoms/counter.ts/count$" => 0x001 }
// 返回:0x001

// 用户点击 5 次
store.set(count$, 5);
// Store: Map { 0x001 => 5 }

// HMR 触发,代码重新执行
const count$ = globalThis.ccsAtomCache.get(
  "/src/atoms/counter.ts/count$", // 同样的 key
  state(0) // 虽然写了 state(0),但不会执行
);
// 检查缓存:已有 "/src/atoms/counter.ts/count$"
// 直接返回缓存的对象:0x001(还是原来的对象!)

// 组件读取
useGet(count$); // count$ 还是 0x001
// Store 查找:Map.get(0x001)
// 找到了!返回 5
// 显示 Count: 5,状态保留

关键机制

  1. 字符串 key 不变"/src/atoms/counter.ts/count$" 是固定的字符串,HMR 前后不变
  2. 对象引用不变:通过字符串 key 查找缓存,返回同一个对象(0x001)
  3. Store 能找到值:对象引用不变,Store 中的 Map<Signal, Value> 能找到对应的值

每个 Signal 的 key 由文件路径 + 变量名组成,确保全局唯一。对于默认导出,key 为 文件路径/defaultExport

配置选项

reactRefreshPlugin 支持两个配置选项:

  • projectRoot:项目根目录路径,用于过滤本地环境信息。例如,设置 projectRoot: '/Users/username/project' 后,Signal key 中不会包含 /Users/username/project 前缀,避免暴露本地路径。
  • customAtomNames:自定义 Signal 方法名。默认为 ['state', 'computed', 'command']。在实际项目中,开发者可能会封装自己的 Signal 创建函数(如 stateWithLog)或使用更短的别名(如 $action 代替 command)。插件只识别配置中指定的函数名,对于自定义函数,需要通过 customAtomNames 告诉插件"这些函数也是创建 Signal 的",确保它们也能被缓存和添加 debugLabel。
// 项目中封装了自定义 Signal 创建函数
function stateWithLog<T>(initialValue: T) {
  console.log("Creating state:", initialValue);
  return state(initialValue);
}

function $action(fn: Function) {
  return command(fn);
}

// vite.config.ts - 告诉插件识别自定义函数
export default defineConfig({
  plugins: [
    react({
      babel: {
        presets: [
          [
            "ccstate-babel/preset",
            {
              projectRoot: __dirname,
              customAtomNames: ["state", "computed", "command", "stateWithLog", "$action"],
            },
          ],
        ],
      },
    }),
  ],
});

debugLabelPlugin

在调试和性能分析时,匿名函数会显示为 anonymous(anonymous function),难以识别具体是哪个函数。debugLabelPlugin 自动为 Signal 添加调试标签,并将 Computed 和 Command 中的匿名函数转换为具名函数,方便调试和性能分析。

使用方式

reactRefreshPlugin 相同,通过 ccstate-babel/preset 启用:

// vite.config.ts
export default defineConfig({
  plugins: [
    react({
      babel: {
        presets: ["ccstate-babel/preset"],
      },
    }),
  ],
});

转换效果 1:自动添加 debugLabel

转换前:

const count$ = state(0);
const double$ = computed((get) => get(count$) * 2);

转换后:

const count$ = state(0, { debugLabel: "count$" });
const double$ = computed((get) => get(count$) * 2, { debugLabel: "double$" });

插件会自动为所有 Signal 添加 debugLabel 选项,使用变量名作为标签。这样在使用 createDebugStore 时,日志会显示可读的标签名称。

转换效果 2:匿名函数转换为具名函数

转换前:

const double$ = computed((get) => get(count$) * 2);

const increment$ = command(({ get, set }) => {
  set(count$, get(count$) + 1);
});

转换后:

const double$ = computed(function __ccs_cmpt_double$(get) {
  return get(count$) * 2;
});

const increment$ = command(function __ccs_cmd_increment$({ get, set }) {
  set(count$, get(count$) + 1);
});

插件将匿名箭头函数转换为具名函数:

  • Computed 的函数名前缀为 __ccs_cmpt_
  • Command 的函数名前缀为 __ccs_cmd_
  • 函数名后缀为变量名

这样在调试时,堆栈跟踪和性能分析工具会显示具名函数,快速定位到具体的 Computed 或 Command。

转换效果 3:处理默认导出

转换前:

// atoms/counter.ts
export default state(0);

转换后:

// atoms/counter.ts
const counter = state(0, { debugLabel: "counter" });
export default counter;

对于默认导出的 Signal,插件会根据文件名生成变量名和 debugLabel。如果文件名是 index.ts,则使用父目录名作为变量名。

转换规则

debugLabelPlugin 只对模块作用域的 Signal 声明生效,不处理以下情况:

  • 函数内部创建的 Signal(如工厂函数返回的 Signal)
  • 对象属性中的 Signal(如 const obj = { atom: state(0) }
  • 数组元素中的 Signal(如 const arr = [state(0)]

示例:

// ✅ 会被转换(模块作用域)
const count$ = state(0);
export const double$ = computed((get) => get(count$) * 2);

// ❌ 不会被转换(函数内部)
function createAtom() {
  const internal = state(0); // 不会添加 debugLabel
  return internal;
}

// ❌ 不会被转换(对象属性)
const obj = {
  atom: state(0), // 不会添加 debugLabel
};

配置选项

reactRefreshPlugin 相同,支持 projectRootcustomAtomNames 配置。

注意事项

  • 如果已经手动指定了 debugLabel,插件不会覆盖
  • 如果 Computed 或 Command 的回调函数中使用了 this,插件不会将其转换为具名函数(因为箭头函数和普通函数的 this 绑定不同)
  • 如果回调函数已经是具名函数,插件不会重命名