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

作者:温宇飞日期:2025/11/9

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

快速上手

Signal

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

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

1import { state } from "ccstate";
2
3// State 是一个信号,只是声明了状态的存在
4const count$ = state(0);
5const user$ = state({ name: "Alice", age: 30 });
6
7// 值存储在 Store 中
8const store = createStore();
9store.set(count$, 10); // 将值 10 写入 Store
10const value = store.get(count$); // 从 Store 读取值
11

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

1import { computed } from "ccstate";
2
3// Computed 是一个信号,声明了计算逻辑
4// 它本身不存储值,只在被读取时执行计算
5const double$ = computed((get) => get(count$) * 2);
6const userName$ = computed((get) => get(user$).name);
7
8// 读取 Computed 时,执行计算逻辑,计算结果缓存在 Store 中
9const doubled = store.get(double$); // 执行计算: get(count$) * 2
10
11// Computed 内只能读取信号,不能写入
12const invalid$ = computed(({ get, set }) => {
13  // ❌ 编译错误: Computed 的 get 回调中没有 set 参数
14  set(count$, 10);
15});
16

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

1import { command } from "ccstate";
2
3// Command 是一个信号,声明了业务逻辑
4// 它本身不存储值,只在被写入时执行逻辑
5const increment$ = command(({ get, set }) => {
6  // Command 内可以读取和写入信号
7  set(count$, get(count$) + 1);
8});
9
10// 写入 Command 时,执行业务逻辑
11store.set(increment$); // 执行: set(count$, get(count$) + 1)
12
13// Command 不能被读取
14// store.get(increment$);  // ❌ 编译错误: Command 不能被读取
15

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

1import { createStore } from "ccstate";
2
3// Store 是实际存储值的地方
4const store = createStore();
5
6// Signal 只是标识符,值存储在 Store 中
7store.set(count$, 10); // 将值 10 存入 Store
8const value = store.get(count$); // 从 Store 读取值 10
9

基础使用

通过 Store 读取和修改状态:

1// 读取状态
2const count = store.get(count$); // 0
3const double = store.get(double$); // 0
4
5// 修改 State
6store.set(count$, 10);
7console.log(store.get(count$)); // 10
8console.log(store.get(double$)); // 20(自动重新计算)
9
10// 执行 Command
11store.set(increment$);
12console.log(store.get(count$)); // 11
13

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

1store.watch((get) => {
2  console.log("Count:", get(count$));
3});
4// 输出: Count: 11
5
6store.set(count$, 20);
7// 输出: Count: 20
8

与 React 集成

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

注入 Store

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

1import { createStore } from "ccstate";
2import { StoreProvider } from "ccstate-react";
3
4function App() {
5  const store = createStore();
6
7  return (
8    <StoreProvider value={store}>
9      <Counter />
10    </StoreProvider>
11  );
12}
13

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

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

useGet

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

1import { useGet } from "ccstate-react";
2
3const count$ = state(0);
4const double$ = computed((get) => get(count$) * 2);
5const message$ = state("Hello");
6
7function Counter() {
8  const count = useGet(count$);
9  const double = useGet(double$);
10  // 注意:这里没有使用 message$
11
12  return (
13    <div>
14      <p>Count: {count}</p>
15      <p>Double: {double}</p>
16    </div>
17  );
18}
19
20// 修改 count$,Counter 会重新渲染
21store.set(count$, 10);
22
23// 修改 message$,Counter 不会重新渲染(未订阅)
24store.set(message$, "World");
25

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

useSet

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

1import { useGet, useSet } from "ccstate-react";
2
3const count$ = state(0);
4const increment$ = command(({ get, set }) => {
5  set(count$, get(count$) + 1);
6});
7
8function Counter() {
9  const count = useGet(count$);
10  const setCount = useSet(count$); // 返回修改 State 的函数
11  const increment = useSet(increment$); // 返回执行 Command 的函数
12
13  return (
14    <div>
15      <p>Count: {count}</p>
16      {/* 在事件处理器中调用 setCount */}
17      <button onClick={() => setCount(count + 1)}>+1</button>
18      {/* Command 执行器可以直接作为事件处理器 */}
19      <button onClick={increment}>Increment</button>
20    </div>
21  );
22}
23

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

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

异步状态的统一管理

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

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

1import { useResolved } from "ccstate-react";
2
3const userId$ = state("123");
4const user$ = computed(async (get) => {
5  const id = get(userId$);
6  const resp = await fetch([`/api/users/${id}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.id.md));
7  return resp.json();
8});
9
10function UserProfile() {
11  const user = useResolved(user$);
12
13  // loading 时 user 为 undefined
14  if (user === undefined) {
15    return <div>Loading...</div>;
16  }
17
18  return <div>User: {user.name}</div>;
19}
20

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

1import { useLastResolved } from "ccstate-react";
2
3const userId$ = state("123");
4const user$ = computed(async (get) => {
5  const id = get(userId$);
6  const resp = await fetch([`/api/users/${id}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.id.md));
7  return resp.json();
8});
9
10function UserProfile() {
11  const user = useLastResolved(user$);
12
13  // 首次 loading 时为 undefined
14  return <div>User: {user?.name ?? "Loading..."}</div>;
15}
16
17// 使用场景:用户切换 userId 时,保留旧用户信息直到新用户加载完成
18store.set(userId$, "123"); // 加载用户 123
19// 显示: "User: Alice"
20
21store.set(userId$, "456"); // 触发 user$ 重新计算
22// useResolved 会返回 undefined,显示 "Loading..."
23// useLastResolved 仍返回 Alice(上一次的结果),显示 "User: Alice"
24// 等新数据加载完成后,才更新为 "User: Bob"
25

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

1import { useLoadable } from "ccstate-react";
2
3function UserProfile() {
4  const userLoadable = useLoadable(user$);
5
6  if (userLoadable.state === "loading") {
7    return <div>Loading...</div>;
8  }
9
10  if (userLoadable.state === "hasError") {
11    return <div>Error: {userLoadable.error.message}</div>;
12  }
13
14  return <div>User: {userLoadable.data.name}</div>;
15}
16

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 回调自动收集依赖关系;每次重新计算都会重新收集依赖:

1import { state, computed, createStore } from "ccstate";
2
3const useDiscount$ = state(false);
4const originalPrice$ = state(100);
5const discountPrice$ = state(80);
6
7// 根据条件动态依赖不同的状态
8const finalPrice$ = computed((get) => {
9  return get(useDiscount$) ? get(discountPrice$) : get(originalPrice$);
10});
11
12const store = createStore();
13store.get(finalPrice$); // 100(当前依赖 useDiscount$ 和 originalPrice$)
14
15// 修改 discountPrice$ 不会触发重新计算(未被依赖)
16store.set(discountPrice$, 70);
17store.get(finalPrice$); // 100(使用缓存)
18
19// 切换折扣后,依赖关系自动更新
20store.set(useDiscount$, true);
21store.get(finalPrice$); // 70(重新计算,现在依赖 useDiscount$ 和 discountPrice$)
22

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

1const cartItems$ = state([
2  { id: 1, price: 100, quantity: 2 },
3  { id: 2, price: 50, quantity: 3 },
4]);
5
6const totalPrice$ = computed((get) => {
7  const items = get(cartItems$);
8  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
9});
10
11const store = createStore();
12// totalPrice$ 记录 cartItems$ 的版本号
13store.get(totalPrice$);
14
15// 修改 cartItems$ 时,版本号递增
16store.set(cartItems$, [{ id: 1, price: 100, quantity: 3 }]);
17
18// 下次访问时,比对版本号发现不一致,重新计算
19store.get(totalPrice$); // 300
20

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

3. 循环依赖检测

CCState 通过缓存机制处理循环依赖。在未订阅(unmounted)状态下,循环依赖会返回缓存值,可能是 undefine,这是一个问题;在订阅(mounted)状态下会抛出错误:

1// 创建循环依赖
2const a$ = computed((get) => get(b$) + 1);
3const b$ = computed((get) => get(a$) + 1);
4
5const store = createStore();
6
7// 未订阅时:使用缓存机制,返回 NaN,不会抛出错误
8const result = store.get(a$); // 返回数值(基于缓存)
9
10// 订阅时:检测到循环依赖,抛出错误
11store.watch((get) => {
12  get(a$); // 这里会抛出 RangeError: Maximum call stack size exceeded
13});
14

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

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

派生状态的性能优化

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

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

CCState 的解决方案

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

1const base$ = state(0);
2const expensive$ = computed((get) => {
3  const value = get(base$);
4  // 复杂计算
5  return heavyComputation(value);
6});
7
8store.set(base$, 1); // expensive$ 不会立即计算
9
10// 只有在 watch 订阅后,才会响应式更新
11store.watch((get) => {
12  console.log(get(expensive$)); // 此时 expensive$ 进入 mounted 状态
13});
14
15store.set(base$, 2); // 现在会触发 expensive$ 的计算
16

2. 级联计算优化

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

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

1const a$ = state(1);
2const b$ = computed((get) => get(a$) * 2);
3const c$ = computed((get) => get(b$) + 10);
4
5const store = createStore();
6
7// 通过 store.get 读取时,仍需要遍历 c$ -> b$ -> a$ 的依赖链
8store.get(c$); // 需要检查整个依赖链
9store.get(c$); // 再次读取,仍需要遍历整个依赖链
10

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

  • set 修改 State 时,触发依赖该 State 的 watch 回调
  • 在执行 watch 回调之前,CCState 会沿着反向依赖链,提前计算部分 Computed(具体哪些 Computed 会被提前计算,取决于反向依赖关系)
  • watch.get 读取 Computed 时,如果某个 Computed 被提前计算了,则不需要再次计算,直接返回即可
1const a$ = state(1);
2const b$ = computed((get) => {
3  console.log("Computing b$");
4  return get(a$) * 2;
5});
6const c$ = computed((get) => {
7  console.log("Computing c$");
8  return get(b$) + 10;
9});
10
11const store = createStore();
12
13// 订阅 c$,建立反向依赖链:a$ -> b$ -> c$ -> watch
14store.watch((get) => {
15  console.log("Result:", get(c$));
16});
17// 输出:
18// Computing b$
19// Computing c$
20// Result: 12
21
22// 修改 a$,触发 watch
23store.set(a$, 2);
24// 输出:
25// Computing b$ ← set 时,沿反向依赖链提前计算
26// Computing c$ ← set 时,沿反向依赖链提前计算
27// Result: 14 ← watch.get 读取时,b$ 和 c$ 已被提前计算,直接返回
28

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

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

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

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

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

异步处理

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

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

1// 异步获取用户信息
2const userId$ = state("");
3const user$ = computed(async (get) => {
4  const userId = get(userId$);
5  if (!userId) return null;
6
7  // 直接使用 async/await
8  const resp = await fetch(`/api/users/${userId}`);
9  return resp.json();
10});
11
12const store = createStore();
13store.set(userId$, "user123");
14
15// get 返回 Promise
16const user = await store.get(user$);
17

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

1const searchQuery$ = state("");
2const searchResults$ = computed(async (get, { signal }) => {
3  const query = get(searchQuery$);
4  if (!query) return [];
5
6  // signal 会在新计算开始时自动 abort
7  const resp = await fetch([`/api/search?q=${query}`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.q.md), { signal });
8  return resp.json();
9});
10
11// 快速输入 "a" -> "ab" -> "abc"
12store.set(searchQuery$, "a"); // 发起请求 1
13store.set(searchQuery$, "ab"); // 请求 1 被 abort,发起请求 2
14store.set(searchQuery$, "abc"); // 请求 2 被 abort,发起请求 3
15// 只有请求 3 的结果会被使用
16

异常管理

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

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

CCState 的解决方案

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

1const userId$ = state("invalid-id");
2
3const user$ = computed(async (get) => {
4  const userId = get(userId$);
5  const resp = await fetch(`/api/users/${userId}`);
6
7  if (!resp.ok) {
8    throw new Error(`Failed to fetch user: ${resp.status}`);
9  }
10
11  return resp.json();
12});
13
14// 在其他 Computed 中,异常会继续传播
15const userName$ = computed(async (get) => {
16  const user = await get(user$); // 如果 user$ 抛出异常,这里会直接抛出
17  return user.name;
18});
19
20// 调用方可以使用 try/catch 捕获异常
21try {
22  const user = await store.get(user$);
23} catch (error) {
24  console.error("Error loading user:", error);
25}
26

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

2. 错误恢复

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

1const retryCount$ = state(0);
2
3const data$ = computed(async (get) => {
4  get(retryCount$); // 依赖 retryCount$,修改它会触发重新计算
5  const resp = await fetch("/api/data");
6  if (!resp.ok) throw new Error("Failed");
7  return resp.json();
8});
9
10// 重试:修改 retryCount$ 触发重新计算
11store.set(retryCount$, (x) => x + 1);
12

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

可测试性

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

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

1import { test, expect } from "vitest";
2import { state, computed, createStore } from "ccstate";
3
4test("测试 1", () => {
5  const store = createStore(); // 独立的 store
6  const count$ = state(0);
7  store.set(count$, 10);
8  expect(store.get(count$)).toBe(10);
9});
10
11test("测试 2", () => {
12  const store = createStore(); // 另一个独立的 store
13  const count$ = state(0); // 同名 signal,但完全隔离
14  expect(store.get(count$)).toBe(0); // 不受测试 1 影响
15});
16

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

1test("异步用户加载", async () => {
2  const store = createStore();
3
4  const userId$ = state("123");
5  const user$ = computed(async (get) => {
6    const userId = await get(userId$);
7    // 测试中直接返回 mock 数据,无需 mock fetch
8    return { id: userId, name: "Test User" };
9  });
10
11  const user = await store.get(user$);
12  expect(user.name).toBe("Test User");
13});
14
15// 或者测试实际的 fetch 逻辑,使用标准的 mock 工具
16test("实际 fetch 测试", async () => {
17  const store = createStore();
18
19  // 使用标准的 fetch mock
20  global.fetch = vi.fn().mockResolvedValue({
21    json: async () => ({ id: "123", name: "Test User" }),
22  });
23
24  const user$ = computed(async () => {
25    const resp = await fetch("/api/user");
26    return resp.json();
27  });
28
29  const user = await store.get(user$);
30  expect(user.name).toBe("Test User");
31});
32

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

1// 业务逻辑:独立于任何 UI 框架
2const user$ = computed(async (get) => {
3  const id = get(userId$);
4  return await fetchUser(id);
5});
6
7// 测试:无需渲染组件
8test("加载用户", async () => {
9  const store = createStore();
10  store.set(userId$, "123");
11  const user = await store.get(user$);
12  expect(user.id).toBe("123");
13});
14

可调试性

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

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

1import { createDebugStore, state, computed } from "ccstate";
2
3const count$ = state(0, { debugLabel: "count$" });
4const double$ = computed((get) => get(count$) * 2, { debugLabel: "double$" });
5
6// 创建调试 Store,记录 set、get、computed 等操作
7const store = createDebugStore([count$, double$], ["set", "computed"]);
8
9store.set(count$, 10);
10// Console 输出: [R][SET] S0:count$ (10)
11
12store.get(double$);
13// Console 输出: [R][CPT] C1:double$ ret: 20
14

createDebugStore 可以记录:

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

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

1import { createDebugStore, state, computed } from "ccstate";
2
3const a$ = state(1, { debugLabel: "a$" });
4const b$ = computed((get) => get(a$) * 2, { debugLabel: "b$" });
5const c$ = computed((get) => get(b$) + 10, { debugLabel: "c$" });
6
7const store = createDebugStore();
8
9// 获取 c$ 依赖了哪些 Signal(依赖树)
10const deps = store.getReadDependencies(c$);
11// 返回: [c$, [b$, [a$]]]
12
13// 获取哪些 Signal 依赖了 a$(反向依赖树)
14const dependents = store.getReadDependents(a$);
15// 返回: [a$, [b$, [c$]]]
16
17// 获取完整的依赖图(包含值和版本号)
18const graph = store.getDependenciesGraph(c$);
19// 返回: [
20//   [{ signal: c$, val: 12, epoch: 1 }, { signal: b$, val: 2, epoch: 1 }, 1],
21//   [{ signal: b$, val: 2, epoch: 1 }, { signal: a$, val: 1, epoch: 1 }, 1]
22// ]
23
24// 检查是否处于订阅状态
25store.isMounted(c$); // false
26

通过 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 项目为例,可能包含约 1k 个原子状态2k 个派生状态(通过其他状态计算得出)。

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

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

1// ❌ 命令式:手动同步派生状态
2function addToCart(item) {
3  cartItems.push(item);
4
5  // 手动更新所有派生状态
6  totalPrice = sum(cartItems.map((i) => i.price));
7  discount = totalPrice > 100 ? totalPrice * 0.1 : 0;
8  finalPrice = totalPrice - discount;
9}
10
11function removeFromCart(itemId) {
12  cartItems = cartItems.filter((i) => i.id !== itemId);
13
14  // 又要手动更新一遍,容易遗漏
15  totalPrice = sum(cartItems.map((i) => i.price));
16  discount = totalPrice > 100 ? totalPrice * 0.1 : 0;
17  finalPrice = totalPrice - discount;
18}
19

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

  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 多行,却只测试了一个边缘情况。最终开发者放弃了写测试,直接在代码里加了几个条件判断,"看起来应该能修复"。

1// 业务逻辑写在组件的 useEffect 中
2function DocumentEditor() {
3  useEffect(() => {
4    // 几百行逻辑依赖十几个状态
5    if (!hasPermission || !isOnline) return
6    // ... 复杂的保存逻辑
7  }, [content, hasPermission, isOnline, collaborators, version, ...])
8
9  return <textarea />
10}
11

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

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

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


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

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

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

1// ❌ 看起来是纯计算,实际上会修改状态
2const needsSave = useMemo(() => {
3  const result = content !== lastSavedContent;
4  if (result) {
5    setSaveStatus("pending"); // 隐藏的副作用:修改状态
6  }
7  return result;
8}, [content, lastSavedContent]);
9
10// ❌ 看起来是读取属性,实际上会触发网络请求
11const userData = useMemo(() => {
12  return currentUser.profile; // 隐藏的副作用:getter 中发起 API 请求
13}, [currentUser]);
14

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

1// Zustand:状态对象同时支持读和写
2const useStore = create((set) => ({
3  count: 0,
4  updateCount: () => set({ count: (x) => x + 1 }),
5}));
6useStore.getState().count; // 读取:无副作用
7useStore.getState().updateCount(); // 写入:有副作用
8// 问题:无法在类型层面限制某个函数只能读取状态
9
10// Signals:value 属性同时支持读和写
11const counter = signal(0);
12counter.value; // 读取:无副作用
13counter.value = 1; // 写入:有副作用
14// 问题:无法在类型层面限制某个函数只能读取 value
15

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

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

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


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

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

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

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

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

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

1// 网络恢复后自动保存
2useEffect(() => {
3  if (isOnline && needsSave) {
4    handleSave();
5  }
6}, [isOnline, needsSave]);
7

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

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

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

1// 测试响应式副作用需要:
2test("should update title after save", async () => {
3  // 1. 渲染组件,触发所有 useEffect
4  render(<DocumentEditor />);
5
6  // 2. 修改 content,等待渲染
7  userEvent.type(screen.getByRole("textbox"), "new content");
8  await waitFor(() => {});
9
10  // 3. 触发保存,等待异步完成
11  userEvent.click(screen.getByText("Save"));
12  await waitFor(() => {});
13
14  // 4. 等待 lastSavedContent 更新,触发 useEffect
15  await waitFor(() => {});
16
17  // 5. 检查标题是否更新
18  expect(document.title).toBe("new content");
19});
20

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

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

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

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

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

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

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

1// 组件 A 中
2useEffect(() => {
3  /* 响应 lastSavedContent */
4}, [lastSavedContent]);
5
6// 组件 B 中
7useEffect(() => {
8  /* 响应 lastSavedContent */
9}, [lastSavedContent]);
10
11// 自定义 Hook 中
12useEffect(() => {
13  /* 响应 lastSavedContent */
14}, [lastSavedContent]);
15
16// Store 中(如 MobX、Vue)
17autorun(() => {
18  /* 响应 lastSavedContent */
19});
20

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

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

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

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

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

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

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

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

1// ❌ 不推荐:响应式副作用
2useEffect(() => {
3  if (lastSavedContent) {
4    document.title = content.split("\n")[0];
5  }
6}, [lastSavedContent]);
7
8// ✅ 推荐:在 Command 中显式调用
9const save$ = command(({ get, set }) => {
10  const content = get(content$);
11  set(lastSavedContent$, content);
12
13  // 显式更新标题
14  document.title = content.split("\n")[0];
15  // 显式上报数据
16  trackEvent("document_saved", { length: content.length });
17});
18

异步密集且难以预料

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

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

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

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

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

1// 用户在文档 A 编辑
2currentDoc = "A";
3content = "A 的内容";
4isOnline = false; // 网络断开
5
6// 用户切换到文档 B
7currentDoc = "B";
8content = "B 的内容";
9
10// 网络恢复,触发保存
11isOnline = true;
12handleSave(); // 发起请求保存 "B 的内容"
13
14// 但是!如果此时文档 A 之前的保存请求完成了
15// 它会尝试更新状态,导致混乱
16

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

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

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

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

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

声明式状态管理

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

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

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

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

1import { state, computed, command, createStore } from "ccstate";
2
3// 只定义源状态
4const cartItems$ = state<CartItem[]>([]);
5
6// 声明派生状态的计算规则
7const totalPrice$ = computed((get) => {
8  const items = get(cartItems$);
9  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
10});
11
12const itemCount$ = computed((get) => {
13  return get(cartItems$).length;
14});
15
16// Command 只负责修改原子状态
17const addItem$ = command(({ get, set }, item: CartItem) => {
18  const items = get(cartItems$);
19  set(cartItems$, [...items, item]);
20  // totalPrice$ 和 itemCount$ 会自动更新,无需手动计算
21});
22
23const removeItem$ = command(({ get, set }, id: string) => {
24  const items = get(cartItems$);
25  set(
26    cartItems$,
27    items.filter((item) => item.id !== id)
28  );
29  // 对比命令式:无需手动调用 updateTotalPrice() 和 updateItemCount()
30});
31
32const updateQuantity$ = command(({ get, set }, id: string, quantity: number) => {
33  const items = get(cartItems$);
34  set(
35    cartItems$,
36    items.map((item) => (item.id === id ? { ...item, quantity } : item))
37  );
38  // 所有派生状态都会自动更新,不会遗漏,执行顺序正确
39});
40
41const store = createStore();
42
43// 使用
44store.set(addItem$, { id: "1", price: 100, quantity: 2 });
45store.get(totalPrice$); // 200 - 自动计算
46store.get(itemCount$); // 1 - 自动计算
47
48store.set(updateQuantity$, "1", 3);
49store.get(totalPrice$); // 300 - 自动更新
50store.get(itemCount$); // 1 - 保持一致
51

声明式状态管理的好处

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

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

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

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

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

  • Computed 只能读:回调函数只有 get 参数,类型系统保证无法写入状态
  • Command 可以读写:回调函数同时有 getset 参数,显式封装副作用
  • 类型安全store.get() 只接受 State 和 Computed;store.set() 只接受 State 和 Command
1import { state, computed, command, createStore } from "ccstate";
2
3// State: 原子状态
4const user$ = state({ name: "Alice", viewCount: 0 });
5const viewLog$ = state<string[]>([]);
6
7// Computed: 只读,类型系统保证无副作用
8const userName$ = computed((get) => {
9  const user = get(user$);
10  // Computed 的 get 回调中没有 set 参数,类型层面保证只能读
11  // set(user$, ...) // ❌ 编译错误:Computed 中没有 set 函数
12  return user.name;
13});
14
15const viewCount$ = computed((get) => {
16  return get(user$).viewCount;
17  // 可以安全地缓存、并发执行、重复调用,因为没有副作用
18});
19
20// Command: 可读可写,显式封装副作用
21const incrementViewCount$ = command(({ get, set }) => {
22  const user = get(user$);
23  set(user$, { ...user, viewCount: user.viewCount + 1 });
24
25  const logs = get(viewLog$);
26  set(viewLog$, [...logs, `User viewed at ${Date.now()}`]);
27  // 副作用集中在 Command 中,调用时一眼就能看出会修改状态
28});
29
30const store = createStore();
31
32// 读操作:类型系统保证无副作用
33const name = store.get(userName$); // 多次调用结果一致
34const name2 = store.get(userName$); // 不会产生任何副作用
35// 开发者可以安全地使用 useMemo 缓存、并发执行、重复调用
36
37// 写操作:显式可见
38store.set(incrementViewCount$); // 一眼看出这会修改状态
39// store.get(incrementViewCount$); // ❌ 编译错误:Command 不能被 get
40
41// 类型安全:Computed 不能被修改
42// store.set(userName$, "Bob"); // ❌ 编译错误:Computed 不能被 set
43

读写分离的好处

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

避免使用响应式副作用

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

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

业务代码推荐的做法

1// ✅ 推荐:在 Command 中显式调用副作用
2const save$ = command(({ get, set }) => {
3  const content = get(content$);
4  set(lastSavedContent$, content);
5
6  // 显式更新标题
7  document.title = content.split("\n")[0];
8  // 显式上报数据
9  trackEvent("document_saved", { length: content.length });
10});
11
12// 调用时,一眼就能看出会执行哪些副作用
13store.set(save$);
14

不推荐的做法

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

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

1// ccstate-react 内部实现(简化版)
2function useGet<T>(atom: State<T> | Computed<T>): T {
3  const store = useStore();
4
5  // 订阅函数:当状态变化时,通知 React 重新渲染
6  const subscribe = useRef((onStoreChange: () => void) => {
7    const controller = new AbortController();
8    store.watch(
9      (get) => {
10        get(atom); // 订阅这个状态
11        onStoreChange(); // 通知 React 重新渲染
12      },
13      { signal: controller.signal }
14    );
15    return () => controller.abort(); // 取消订阅
16  });
17
18  // 使用 React 18 的 useSyncExternalStore 连接状态和视图
19  return useSyncExternalStore(subscribe.current, () => store.get(atom));
20}
21

这样做的好处:

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

状态与视图分离

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

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

1// 业务逻辑层:完全独立于任何 UI 框架
2const userId$ = state("");
3const user$ = computed(async (get) => {
4  const userId = get(userId$);
5  if (!userId) return null;
6
7  const resp = await fetch(`/api/users/${userId}`);
8  return resp.json();
9});
10
11const loadUser$ = command(async ({ set }, id: string) => {
12  set(userId$, id);
13  // 可以直接使用 async/await,代码从第一行顺序执行
14  await delay(100);
15  console.log("User loaded");
16});
17

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

在 React 中使用:

1// React 组件:只负责渲染
2function UserProfile({ id }: { id: string }) {
3  const user = useGet(user$);
4  const loadUser = useSet(loadUser$);
5
6  useEffect(() => {
7    loadUser(id);
8  }, [id, loadUser]);
9
10  return <div>{user?.name}</div>;
11}
12

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

1<template>
2  <div>{{ user?.name }}</div>
3</template>
4
5<script setup lang="ts">
6import { useGet, useSet } from "ccstate-vue";
7import { user$, loadUser$ } from "./user-logic"; // 复用相同的业务逻辑
8
9const props = defineProps<{ id: string }>();
10const user = useGet(user$);
11const loadUser = useSet(loadUser$);
12
13watch(
14  () => props.id,
15  (id) => {
16    loadUser(id);
17  },
18  { immediate: true }
19);
20</script>
21

状态与视图分离的好处

  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 维护自己独立的值

1import { state, computed, createStore } from "ccstate";
2
3// Signal:状态标识符(轻量级描述对象)
4const count$ = state(0);
5const double$ = computed((get) => get(count$) * 2);
6
7// Signal 创建成本极低,可以在模块顶层声明
8console.log(count$); // 只是一个轻量级对象,不包含实际值
9
10// Store 1:第一个状态容器
11const store1 = createStore();
12store1.set(count$, 10);
13console.log(store1.get(count$)); // 10
14console.log(store1.get(double$)); // 20
15
16// Store 2:第二个状态容器
17const store2 = createStore();
18console.log(store2.get(count$)); // 0(使用 Signal 的初始值)
19console.log(store2.get(double$)); // 0
20
21// Store 1 和 Store 2 完全隔离
22store1.set(count$, 20);
23console.log(store1.get(count$)); // 20
24console.log(store2.get(count$)); // 0(不受 Store 1 影响)
25

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

1// user-state.ts - 状态定义模块
2export const userId$ = state("");
3export const user$ = computed(async (get) => {
4  const id = get(userId$);
5  if (!id) return null;
6  const resp = await fetch([`/api/users/${id}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.id.md));
7  return resp.json();
8});
9
10// components/UserProfile.tsx - 在组件中使用
11import { user$ } from "./user-state";
12const user = useGet(user$); // 使用默认的 Store
13
14// tests/user.test.ts - 在测试中使用
15import { user$, userId$ } from "./user-state";
16const store = createStore(); // 创建独立的测试 Store
17store.set(userId$, "123");
18const user = await store.get(user$);
19

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

1import { test, expect } from "vitest";
2import { state, createStore } from "ccstate";
3
4const count$ = state(0); // Signal:状态标识符
5
6test("测试 1", () => {
7  const store = createStore(); // 创建独立的 Store
8  store.set(count$, 10);
9  expect(store.get(count$)).toBe(10);
10});
11
12test("测试 2", () => {
13  const store = createStore(); // 创建另一个独立的 Store
14  expect(store.get(count$)).toBe(0); // 不受测试 1 影响
15  // 无需手动清理状态
16});
17

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 来表达异步逻辑:

1const userId$ = state("");
2
3// Computed 支持异步
4const user$ = computed(async (get) => {
5  const id = get(userId$);
6  // 直接使用 async/await
7  const resp = await fetch([`/api/users/${id}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.id.md));
8  return resp.json();
9});
10
11// Command 支持异步
12const loadUser$ = command(async ({ get, set }, userId: string) => {
13  set(userId$, userId);
14
15  // 代码从第一行顺序执行,清晰易懂
16  const resp = await fetch(`/api/users/${userId}`);
17  const user = await resp.json();
18
19  set(user$, user);
20  console.log("User loaded");
21});
22
23// store.get 支持异步
24const user = await store.get(user$); // 等待异步 Computed 完成
25
26// store.set 支持异步
27await store.set(loadUser$, "123"); // 等待异步 Command 完成
28

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

1// 同步 Computed
2const count$ = state(0);
3const double$ = computed((get) => get(count$) * 2);
4const doubleValue = store.get(double$); // 同步返回
5
6// 异步 Computed
7const user$ = computed(async (get) => {
8  const resp = await fetch("/api/user");
9  return resp.json();
10});
11const user = await store.get(user$); // 异步返回
12
13// API 一致,心智模型统一
14

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

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

1// Command 可以接收 AbortSignal 参数
2const loadData$ = command(async ({ get, set }, signal: AbortSignal) => {
3  const query = get(searchQuery$);
4
5  // 将 signal 传递给 fetch
6  const resp = await fetch([`/api/data?q=${query}`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.data.md), { signal });
7
8  // 在 await 后检查是否已取消
9  if (signal.aborted) return;
10
11  const data = await resp.json();
12  set(dataState$, data);
13});
14
15// 使用时传入 AbortSignal
16const controller = new AbortController();
17store.set(loadData$, controller.signal);
18
19// 可以随时取消
20controller.abort();
21

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

1// ❌ 不好的实践:Floating Promise
2const fetchData$ = command(({ set }) => {
3  fetch("/api/data").then((resp) => {
4    set(data$, resp); // 这个 Promise 没有被管理,可能在组件卸载后仍在执行
5  });
6});
7
8// ✅ 好的实践:严格管理异步
9const fetchData$ = command(async ({ set }, signal: AbortSignal) => {
10  const resp = await fetch("/api/data", { signal });
11  if (signal.aborted) return; // 检查取消状态
12
13  const data = await resp.json();
14  if (signal.aborted) return; // 再次检查
15
16  set(data$, data);
17});
18

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

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

1const searchQuery$ = state("");
2
3const searchResults$ = computed(async (get, { signal }) => {
4  const query = get(searchQuery$);
5  if (!query) return [];
6
7  // signal 会在新计算开始时自动 abort
8  const resp = await fetch([`/api/search?q=${query}`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.q.md), { signal });
9  return resp.json();
10});
11
12// 用户快速输入
13store.set(searchQuery$, "a"); // 发起请求 1
14store.set(searchQuery$, "ab"); // 请求 1 被 abort,发起请求 2
15store.set(searchQuery$, "abc"); // 请求 2 被 abort,发起请求 3
16// 只有请求 3 的结果会被使用,自动避免了竞态条件
17

异步处理策略的好处

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

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

避免在 React 中使用 useEffect

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

useEffect 的两个核心问题

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

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

1// ❌ 使用 useEffect 的响应式副作用
2function DocumentEditor() {
3  const [content, setContent] = useState("");
4  const [lastSavedContent, setLastSavedContent] = useState("");
5
6  // 副作用 1:保存成功后更新标题
7  useEffect(() => {
8    if (lastSavedContent) {
9      document.title = content.split("\n")[0];
10    }
11  }, [lastSavedContent, content]);
12
13  // 副作用 2:保存成功后上报数据
14  useEffect(() => {
15    if (lastSavedContent) {
16      trackEvent("document_saved", { length: content.length });
17    }
18  }, [lastSavedContent]);
19
20  // 问题:
21  // 1. 不知道何时触发
22  // 2. 调用栈不清晰
23  // 3. 副作用散落各处,难以管理
24  // 4. 测试时需要模拟整个响应式系统
25}
26

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

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

1// ❌ 在 useEffect 中处理异步
2function UserProfile({ userId }) {
3  const [user, setUser] = useState(null);
4  const [loading, setLoading] = useState(false);
5  const [error, setError] = useState(null);
6
7  useEffect(() => {
8    let cancelled = false;
9    setLoading(true);
10    setError(null);
11
12    fetch(`/api/users/${userId}`)
13      .then((resp) => resp.json())
14      .then((data) => {
15        if (!cancelled) {
16          setUser(data);
17          setLoading(false);
18        }
19      })
20      .catch((err) => {
21        if (!cancelled) {
22          setError(err);
23          setLoading(false);
24        }
25      });
26
27    return () => {
28      cancelled = true; // 手动管理取消,容易遗漏
29    };
30  }, [userId]);
31
32  // 问题:
33  // 1. 不能使用 async/await,必须用 Promise 链
34  // 2. 需要手动管理 cancelled 标志
35  // 3. loading 和 error 状态需要手动维护
36  // 4. 代码被拆分到多个地方,难以理解
37}
38

CCState 的推荐做法

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

1// ✅ CCState 推荐:业务逻辑在 Command 中
2const content$ = state("");
3const lastSavedContent$ = state("");
4
5const save$ = command(async ({ get, set }) => {
6  const content = get(content$);
7  set(lastSavedContent$, content);
8
9  // 显式调用副作用,调用栈清晰
10  document.title = content.split("\n")[0];
11  trackEvent("document_saved", { length: content.length });
12});
13
14// 组件中只负责触发
15function DocumentEditor() {
16  const content = useGet(content$);
17  const setContent = useSet(content$);
18  const save = useSet(save$);
19
20  return (
21    <div>
22      <textarea value={content} onChange={(e) => setContent(e.target.value)} />
23      <button onClick={save}>保存</button>
24    </div>
25  );
26}
27

CCState:为大型 Web 应用设计的状态管理库》 是转载文章,点击查看原文


相关推荐


为什么你的JavaScript代码总是出bug?这5个隐藏陷阱太坑了!
良山有风来2025/11/7

你是不是经常遇到这样的情况:明明代码看起来没问题,一运行就各种报错?或者测试时好好的,上线后用户反馈bug不断?更气人的是,有时候改了一个小问题,结果引出了三个新问题…… 别担心,这绝对不是你的能力问题。经过多年的观察,我发现大多数JavaScript开发者都会掉进同样的陷阱里。今天我就来帮你揪出这些隐藏的bug制造机,让你的代码质量瞬间提升一个档次! 变量声明那些事儿 很多bug其实从变量声明的那一刻就开始埋下了隐患。看看这段代码,是不是很眼熟? // 反面教材:变量声明混乱 function


【基础算法】DFS中的剪枝与优化
让我们一起加油好吗2025/11/2

文章目录 上文链接一、剪枝与优化1. 排除等效冗余2. 可行性剪枝3. 最优性剪枝4. 优化搜索顺序5. 记忆化搜索 二、OJ 练习1. 数的划分(1) 解题思路(2) 代码实现 2. 小猫爬山(1) 解题思路(2) 代码实现 上文链接 【基础算法】DFS 一、剪枝与优化 剪枝,形象地看,就是剪掉搜索树的分支,从而减小搜索树的规模,排除掉搜索树中没有必要的分支,优化时间复杂度。 在深度优先遍历中,有几种常见的剪枝方法: 1. 排除等效冗余 如


Python 的内置函数 exec
IMPYLH2025/10/30

Python 内建函数列表 > Python 的内置函数 exec Python 的内置函数 exec 是一个强大的动态执行工具,它允许程序在运行时执行以字符串形式提供的 Python 代码。 def eval(source:str|codeobject, /, globals:dict=None, locals:mapping=None): ''' 执行表达式并返回结果 :param source: Python 表达式 :param globals :


面经分享——字节前端一面
Moment2025/10/28

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777 1. 使用的 React 版本? React 版本演进的趋势是怎样的? React 的版本迭代趋势体现了其向更高效、更简洁的开发体验不断发展的方向。从 React 16 开始,React 引入了许多新特性,如错误边界(Error Boundaries)和 Fiber 架构,显著提高了渲染效率。React 17 主要是稳定性的更新,并没有引入


Redis(83)Redis的缓存击穿是什么?
Victor3562025/10/25

缓存击穿的概念 缓存击穿(Cache Breakdown)指的是在某一个热点缓存数据过期的瞬间,有大量并发请求同时访问这个数据,而该数据在缓存中不存在,因此所有的请求都打到数据库上,导致数据库压力过大,可能引起系统性能问题。 解决缓存击穿的方法 为了解决缓存击穿问题,可以采取以下策略: 互斥锁(Mutex):在缓存失效时,只有一个线程去加载数据,其他线程等待。 永不过期:热点数据的缓存永不过期,只在数据更新时主动去更新缓存。 预加载:在缓存即将过期之前,提前加载数据到缓存。 以下是这几种解决


从入门到精通:JavaScript异步编程避坑指南
良山有风来2025/10/23

你是不是也遇到过这样的场景?页面上有个按钮,点击后需要先请求数据,然后根据数据更新界面,最后弹出提示框。结果代码写着写着就变成了“回调地狱”,一层套一层,自己都看不懂了。更可怕的是,有时候数据没加载完,页面就显示了,各种undefined错误让人抓狂。 别担心,这篇文章就是来拯救你的。我会带你从最基础的异步概念开始,一步步深入Promise、async/await,最后还会分享几个实战中超级好用的技巧。读完本文,你不仅能彻底理解JavaScript的异步机制,还能写出优雅高效的异步代码。 为什么


Swift 字符串与字符完全导读(三):比较、正则、性能与跨平台实战
unravel20252025/10/22

字符串比较的 3 个层次 比较方式API等价准则复杂度备注字符相等“==”扩展字形簇 canonically equivalentO(n)最常用前缀hasPrefix(:)UTF-8 字节逐段比较O(m)m=前缀长度后缀hasSuffix(:)同上,从后往前O(m)注意字形簇边界 示例 let precomposed = "café" // U+00E9 let decomposed = "


主流DDS实现简介及对比
^Moon^2025/10/20

DDS有多个团体进行过实现,这些实现各有侧重,适用于不同场景(如嵌入式、实时系统、大规模分布式系统等)。以下从开源属性、性能、功能、适用场景等维度进行对比分析: 一、主流DDS实现简介及对比 特性RTI Connext DDSFast DDSADLINK OpenSplice DDSCycloneDDS开发者Real-Time Innovations (RTI)eProsima(西班牙公司)ADLINK Technology(台湾凌华)Eclipse基金会(开源社区)开源属性商业闭源(提供免


Anthropic Haiku 4.5:这波AI性能,我愿称之为“超值”!
墨风如雪2025/10/19

嘿,各位AI圈的朋友们!最近,Anthropic又悄悄地扔出了一颗重磅炸弹——他们最新发布的Claude Haiku 4.5,可不是那种哗众取宠的“大而全”模型,它走的是一条“小、快、灵”的路线,但其带来的性价比和实用性,绝对能让你眼前一亮。在我看来,这不只是一次版本更新,更是AI普惠化进程中一个非常重要的里程碑。 想象一下,你用着一台小型跑车的钱,却买到了一辆豪华轿车的核心动力,甚至速度还更快——Claude Haiku 4.5给人的,就是这样一种惊喜。 小身材,大能量:性能直逼“老大哥” H


Docker快速入门——第四章Docker镜像
温柔一只鬼.2025/10/18

传送门: Docker快速入门——第一章Docker入门 Docker快速入门——第二章Docker基本概念 Docker快速入门——第三章Docker环境安装 一、搜索镜像 在Docker中,通过如下命令搜索镜像: docker search [OPTIONS] TERM 其中TERM是你要搜索的镜像关键词 常用选项(OPTIONS): --limit N:限制返回结果的数量(默认为25,最大为100) --filter"is-oddicial=true":只

首页编辑器站点地图

Copyright © 2025 聚合阅读

License: CC BY-SA 4.0