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

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

快速上手

Signal

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

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

typescript 复制代码
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 - 只读信号,读取时执行计算逻辑:

typescript 复制代码
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 - 只写信号,写入时执行业务逻辑:

typescript 复制代码
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 的值:

typescript 复制代码
import { createStore } from "ccstate";

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

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

基础使用

通过 Store 读取和修改状态:

typescript 复制代码
// 读取状态
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 订阅状态变化,当依赖的信号变化时自动执行

typescript 复制代码
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 访问状态:

typescript 复制代码
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 读取状态并自动订阅变化。它实现了细粒度的增量更新:组件只订阅实际访问的状态,只有这些状态变化时才会重新渲染。

typescript 复制代码
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,返回执行器函数:

typescript 复制代码
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

typescript 复制代码
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:当依赖变化触发重新计算时,保留上一次成功的结果,避免闪烁:

typescript 复制代码
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 三种状态:

typescript 复制代码
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,但保留上一次成功的数据:

对比总结

Hook Loading 时显示 重新加载时 适用场景
useResolved undefined 返回 undefined 简单场景,可接受 loading 闪烁
useLastResolved undefined 保留旧值 避免 UI 闪烁,如分页、筛选
useLoadable loading 状态 回退到 loading 需要明确展示加载状态
useLastLoadable loading 状态 保持 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 回调自动收集依赖关系;每次重新计算都会重新收集依赖:

typescript 复制代码
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 为每个状态维护版本号,派生状态记录依赖的版本快照:

typescript 复制代码
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 通过缓存机制处理循环依赖。在未订阅(unmounted)状态下,循环依赖会返回缓存值,可能是 undefine,这是一个问题;在订阅(mounted)状态下会抛出错误:

typescript 复制代码
// 创建循环依赖
const a$ = computed((get) => get(b$) + 1);
const b$ = computed((get) => get(a$) + 1);

const store = createStore();

// 未订阅时:使用缓存机制,返回 NaN,不会抛出错误
const result = store.get(a$); // 返回数值(基于缓存)

// 订阅时:检测到循环依赖,抛出错误
store.watch((get) => {
  get(a$); // 这里会抛出 RangeError: Maximum call stack size exceeded
});

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

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

派生状态的性能优化

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

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

CCState 的解决方案

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

typescript 复制代码
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 读取:没有优化:需要遍历整个依赖链,逐个检查依赖的版本号来判断是否需要重新计算。

typescript 复制代码
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 被提前计算了,则不需要再次计算,直接返回即可
typescript 复制代码
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 回调:

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

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

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

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

异步处理

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

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

typescript 复制代码
// 异步获取用户信息
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,自动处理竞态:

typescript 复制代码
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 中抛出的异常会自动传播到调用方

typescript 复制代码
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 来触发重新计算,实现错误恢复或重试:

typescript 复制代码
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,天然隔离

typescript 复制代码
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 数据,无需复杂的异步模拟

typescript 复制代码
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");
});

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

typescript 复制代码
// 业务逻辑:独立于任何 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 用于开发调试

typescript 复制代码
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

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

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

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

tsx 复制代码
// ❌ 命令式:手动同步派生状态
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 多行 ,却只测试了一个边缘情况。最终开发者放弃了写测试,直接在代码里加了几个条件判断,"看起来应该能修复"。

tsx 复制代码
// 业务逻辑写在组件的 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 会被意外地多次执行,导致一些"看起来是纯计算"的代码产生了副作用。

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

tsx 复制代码
// ❌ 看起来是纯计算,实际上会修改状态
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)中,状态对象同时支持读和写,同样开发者无法在框架和类型层面区分读操作和写操作

tsx 复制代码
// 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 的变化:

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

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

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

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

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

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

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

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

tsx 复制代码
// 测试响应式副作用需要:
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 中打断点,发现:

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

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

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

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

tsx 复制代码
// 组件 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 中不推荐使用响应式副作用

tsx 复制代码
// ❌ 不推荐:响应式副作用
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 消息、文件上传下载)。更麻烦的是,这些异步操作的执行时机和完成顺序是难以预料的。

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

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

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

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

tsx 复制代码
// 用户在文档 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:声明状态修改的业务逻辑("修改操作做什么")

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

typescript 复制代码
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
typescript 复制代码
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 用于订阅状态变化,但不推荐在业务代码中使用

业务代码推荐的做法

typescript 复制代码
// ✅ 推荐:在 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$);

不推荐的做法

typescript 复制代码
// ❌ 不推荐:在业务代码中使用 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 来实现视图的自动更新:

typescript 复制代码
// 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 编写:

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 中使用:

typescript 复制代码
// 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 中使用(同样的业务逻辑):

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

typescript 复制代码
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 组织和复用:

typescript 复制代码
// 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,完全隔离:

typescript 复制代码
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 来表达异步逻辑:

typescript 复制代码
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,无需区分:

typescript 复制代码
// 同步 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 来取消不需要的异步操作:

typescript 复制代码
// 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,避免在已取消的情况下继续执行

typescript 复制代码
// ❌ 不好的实践: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,当依赖变化触发重新计算时,会自动取消上一次的计算:

typescript 复制代码
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 是响应式的,当依赖变化时自动执行。这会带来测试困难、调试困难、约束困难、重构困难等问题:

typescript 复制代码
// ❌ 使用 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,处理异步逻辑非常繁琐:

typescript 复制代码
// ❌ 在 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 触发:

typescript 复制代码
// ✅ 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>
  );
}
相关推荐
r0ad2 小时前
读诗的时候我却使用了自己研发的Chrome元素截图插件
前端·javascript·chrome
IT_陈寒3 小时前
React性能优化实战:这5个Hooks技巧让我的应用快了40%
前端·人工智能·后端
江天澄3 小时前
HTML5 中常用的语义化标签及其简要说明
前端·html·html5
知识分享小能手3 小时前
jQuery 入门学习教程,从入门到精通, jQuery在HTML5中的应用(16)
前端·javascript·学习·ui·jquery·html5·1024程序员节
美摄科技3 小时前
H5短视频SDK,赋能Web端视频创作革命
前端·音视频
黄毛火烧雪下4 小时前
React Native (RN)项目在web、Android和IOS上运行
android·前端·react native
fruge4 小时前
前端正则表达式实战合集:表单验证与字符串处理高频场景
前端·正则表达式
baozj4 小时前
🚀 手动改 500 个文件?不存在的!我用 AST 撸了个 Vue 国际化神器
前端·javascript·vue.js
用户4099322502124 小时前
为什么Vue 3的计算属性能解决模板臃肿、性能优化和双向同步三大痛点?
前端·ai编程·trae