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)指基于其他状态计算得出的状态。比如购物车商品列表是原始状态,总价就是派生状态。状态管理需要解决派生状态的以下问题:
- 如何收集依赖 :派生状态可能根据条件依赖不同的原始状态(如
condition ? a : b),如何自动追踪这些动态变化的依赖? - 是否需要重新计算:当原始状态变化时,如何高效判断哪些派生状态的缓存已失效,需要重新计算?
- 重新计算的时机:什么时候重新计算?
- 循环依赖检测:如何检测并处理 A 依赖 B、B 依赖 A 的循环依赖?
- 菱形依赖的一致性保证:在菱形依赖结构中(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 回调。原因有以下几个:
- 更简单的心智模型:状态变化即生效,便于理解和调试,开发者可以准确预期每次修改的影响
- CCState 不推荐使用 订阅更新。当前只在视图层使用,比如 React 使用 useGet 订阅状态的变化去修改视图,这里 React 本身有增量更新的优化,所以对性能影响较小。
- 异步 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;
}
命令式同步会带来一些问题:
- 重复代码:每次修改原子状态,都要手动重新计算派生状态
- 容易遗漏:新增派生状态后,需要在所有修改点添加同步逻辑
- 执行顺序错误 :如果
finalPrice的计算放在discount之前,会得到错误结果 - 代码难以维护:派生状态的计算逻辑散落在各处,修改时需要找到所有相关代码
所以 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 />
}
没有测试带来的恶性循环:
- 代码难以理解 :新同事接手时,只能通过阅读
useEffect中的几百行代码来理解逻辑,但状态依赖关系复杂,经常看不懂"为什么要这样写" - 不敢重构:想要优化代码结构,但没有测试保障,担心改了会出 bug,只能不断往上堆新逻辑
- 边缘情况无法覆盖:网络恢复、多人冲突、路由切换等复杂场景无法测试,只能等用户报 bug 后再手动修复
- 技术债累积:因为不敢重构,代码越来越混乱,新功能越来越难加,最终变成"屎山"
所以在 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
这导致开发者在写代码时,完全依赖约定 来判断一个函数是否有副作用。难以区分有副作用和无副作用代码会带来一些问题:
- 无法一眼识别:看到一个函数调用,不知道它会不会修改状态、发起请求、上报日志
- 优化困难:不确定能否安全地缓存结果、并发执行、重复调用
- 调试困难:状态被意外修改时,不知道是哪个"看起来无害"的函数干的
- 测试困难:无副作用的代码应该可以随时重复执行,但混在一起后,测试时必须小心控制执行次数
- 代码审查困难:审查时需要深入每个函数,检查是否藏了副作用
所以在 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 内部的调度逻辑,看不到是哪个业务操作导致了 lastSavedContent 或 content 的变化。开发者需要逐一排查所有修改这两个状态的地方,才能定位问题。
约束困难:到处都可以写响应式副作用,副作用遍地开花
随着项目迭代,响应式副作用散落在各处:
tsx
// 组件 A 中
useEffect(() => {
/* 响应 lastSavedContent */
}, [lastSavedContent]);
// 组件 B 中
useEffect(() => {
/* 响应 lastSavedContent */
}, [lastSavedContent]);
// 自定义 Hook 中
useEffect(() => {
/* 响应 lastSavedContent */
}, [lastSavedContent]);
// Store 中(如 MobX、Vue)
autorun(() => {
/* 响应 lastSavedContent */
});
当 lastSavedContent 变化时,会触发多少个副作用?执行顺序是什么?会不会产生循环依赖? 没人能回答这些问题,因为响应式副作用可以写在任何地方。
重构困难:改一个状态,不知道会触发哪些副作用
产品经理要求优化保存逻辑:改为"先保存到本地,成功后再同步到服务器"。开发者修改了 lastSavedContent 的更新时机,结果发现:
- ❌ 标题更新的时机错了(因为依赖
lastSavedContent) - ❌ 上报数据不准确(因为
lastSavedContent变化就触发) - ❌ 网络恢复后的保存逻辑出错(因为
lastSavedContent提前更新了)
开发者需要逐一检查所有监听 lastSavedContent 的 useEffect,确认是否需要调整逻辑。这种隐式的依赖关系让重构变得战战兢兢,如履薄冰。
响应式副作用带来的核心问题:
- 测试困难:不知道何时触发,必须等待、Mock 整个响应式系统
- 调试困难:调用栈不清晰,看不到是谁触发的副作用
- 约束困难:到处都可以写,副作用遍地开花,难以管理。不知道状态的变更造成了哪些副作用,他们又怎么被清除。
- 重构困难:改一个状态,不知道会触发哪些副作用,容易引发连锁反应
所以在 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",三个请求同时返回,显示哪个结果?
- 组件卸载:弹窗关闭后,弹窗中发起的异步操作仍在执行,导致内存泄漏
- 条件变化:用户取消了某个操作,但该操作的异步流程还在继续
松散的异步控制带来的核心问题:
- 竞态条件难以发现:异步操作可能在任何时候完成,导致状态更新顺序错乱
- 取消逻辑容易遗漏:开发者需要手动管理每个异步操作的生命周期,很容易忘记
- 调试困难:异步问题往往难以复现,只在特定的时序条件下出现
- 内存泄漏:组件卸载或条件变化后,异步操作仍在继续,可能导致内存泄漏
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 - 保持一致
声明式状态管理的好处:
- 无需手动同步:派生状态的计算逻辑只需声明一次,CCState 自动处理依赖追踪和更新
- 不会遗漏更新:修改原子状态时,所有依赖它的派生状态都会自动重新计算
- 执行顺序正确:CCState 保证派生状态按照依赖关系正确计算,不会出现顺序错误
- 易于维护:派生状态的计算逻辑集中在 Computed 中,修改时只需改一处
需要注意的是,声明式状态管理会带来额外的计算。但由于 Web 项目,状态规模一是可控的,大多数场景不会超过 10k;而且,派生状态相互之前的依赖深度一般不会超过 20。所以,声明式状态管理一般不会带来性能的问题。
通过读写分离隔离无副作用的代码
在「逻辑复杂度渐进式增长」一节的「问题 2:无法区分副作用」中,我们看到传统状态库无法在框架和类型层面区分读操作和写操作,导致开发者完全依赖约定来判断一个函数是否有副作用。这带来了无法一眼识别、优化困难、调试困难、测试困难、代码审查困难等问题。
CCState 通过读写分离来隔离无副作用的代码。核心机制是:
- Computed 只能读 :回调函数只有
get参数,类型系统保证无法写入状态 - Command 可以读写 :回调函数同时有
get和set参数,显式封装副作用 - 类型安全 :
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
读写分离的好处:
- 一眼识别副作用 :看到
store.get()或Computed,就知道没有副作用;看到store.set()或Command,就知道有副作用 - 可以安全优化:Computed 保证无副作用,可以安全地缓存、并发执行、重复调用
- 易于调试 :状态修改只能通过
store.set()和Command,缩小排查范围 - 易于测试:无副作用的代码可以随时重复执行,测试时无需小心控制执行次数
- 易于代码审查:看类型就知道有没有副作用,无需深入每个函数检查
避免使用响应式副作用
在「逻辑复杂度渐进式增长」一节的「问题 3:响应式副作用难以控制」中,我们看到响应式副作用(如 useEffect、watch)会带来测试困难、调试困难、约束困难、重构困难等问题。核心原因是:响应式副作用是隐式触发的,开发者无法通过代码调用栈清晰地看到副作用是如何被触发的。
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 中,useGet、useResolved、useLastResolved 等 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));
}
这样做的好处:
- 业务逻辑清晰:所有副作用都在 Command 中显式调用,调用栈清晰
- 易于测试:业务逻辑不依赖响应式系统,可以直接测试
- 易于调试:通过调用栈可以清楚地看到副作用是如何被触发的
- 副作用集中管理:响应式副作用只在框架集成层使用,业务代码不需要关心
状态与视图分离
在「逻辑复杂度渐进式增长」一节的「问题 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>
状态与视图分离的好处:
- 易于测试:业务逻辑可以脱离 UI 框架单独测试,无需渲染组件、Mock Hook、等待生命周期
- 易于重构:切换 UI 框架时,业务逻辑无需改动。例如从 React 迁移到 Vue,只需更换视图层代码
- 逻辑清晰 :业务代码从第一行顺序执行,无需在多个
useEffect中跳转理解逻辑 - 框架无关:同样的业务逻辑可以在 React、Vue、Solid.js、Svelte 等任何框架中使用,甚至可以在 Node.js 服务端使用
- 开发体验好:可以在没有 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 作为状态容器的好处:
- 轻量级标识符:Signal 只是描述对象,创建成本极低,便于组织和复用
- 独立存储:每个 Store 维护独立的状态值,不会相互影响
- 测试天然隔离:每个测试创建独立 Store,无需手动清理状态,测试代码更简洁
- 并行测试:测试之间完全独立,可以安全地并行执行,大幅提升测试速度
- 多实例支持:同一个应用可以创建多个 Store,支持复杂场景
- 更好的内存管理: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 的结果会被使用,自动避免了竞态条件
异步处理策略的好处
- 代码清晰:使用 async/await 顺序编写异步代码,代码从第一行顺序执行,清晰易懂
- 避免竞态条件:通过 AbortSignal 自动取消过期的异步操作,避免状态更新顺序错误
- 防止内存泄漏:严格要求管理异步操作的生命周期,组件卸载或条件变化时正确取消
- 易于调试:异步操作的取消是显式的,可以通过断点清楚地看到何时取消
通过完善的异步处理策略,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>
);
}