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