- 前端
【免费下载链接】easy-peasy
Vegetarian friendly state for React
本篇指南围绕 Easy Peasy 仓库中的examples/nextjs-app-router示例展开,讲解 Easy Peasy v7 如何通过新增的easy-peasy/server子路径,与 React Server Components(RSC)及 Next.js App Router 协作:服务端组件用瞬态只读 Store 推导 computed 值,客户端组件用真正的交互式 Store 完成 UI 状态管理。读完本文,你将掌握共享 Model 的组织方式、'use client'边界的位置、initialState快照传递技巧,以及服务端/客户端双 Store 架构下的常见陷阱。
一、背景:App Router 给状态管理提出的新问题
Next.js App Router 默认将组件视为 Server Component:它们在服务端执行、不携带 React 状态与交互逻辑。这意味着 Easy Peasy 这一类依赖 React 上下文(Context)与 hooks 的状态库,无法直接在服务端组件中使用 ——StoreProvider、useStoreState等 API 在服务端渲染阶段导入会直接失败。
examples/nextjs-app-router示例正是为回答"App Router 下 Store 到底该放在哪边"而设计。它演示了两件事:
- 服务端:利用
easy-peasy/server子路径构建一个瞬态(transient)Store,纯粹用于在 HTML 输出前推导商品的totalValue计算属性,不引入 React、不挂 Provider、也不做 hydration; - 客户端:在
'use client'边界内用useRef创建真正的交互式 Store,并通过StoreProvider包裹子树,服务端取回的数据以initialState传入作为初始状态。
该示例对应的正是官方 RSC / App Router 配方 中的Pattern 2(服务端准备快照 + 客户端 Store)与Pattern 3(服务端组件内的只读 Store)的组合。
二、示例项目结构一览
示例是一个独立的 Next.js 应用,位于examples/nextjs-app-router,核心文件如下:
| 文件 | 角色 |
|---|---|
| app/layout.tsx | 根布局(Server Component),仅渲染children |
| app/page.tsx | 首页 Server Component:取数 + 服务端推导总额 |
| app/products-client.tsx | 'use client'边界:创建交互式 Store 并挂 Provider |
| app/products-view.tsx | 客户端组件:通过类型化 hooks 消费 Store |
| lib/model.ts | 共享 Model:服务端与客户端共同导入 |
| lib/hooks.ts | 类型化 hooks(createTypedHooks) |
| lib/data.ts | 模拟异步取数(种子商品数据) |
依赖环境见 examples/nextjs-app-router/package.json:easy-peasy@^7.0.0-beta.2、next@^15.5.15、react/react-dom@19.0.0,并提供dev/build/start三个脚本。此外 tsconfig.json 配置了@/*指向项目根的路径别名,示例中@/lib/model即映射到lib/model。
三、核心基础:easy-peasy/serverReact-free 子路径
3.1 为什么需要独立子路径
easy-peasy/server是 v7 新增的无 React 子路径导出,专用于"导入 React 反而是错误"的环境:React Server Components、Edge Runtime、以及需要在无组件树条件下构建 Store 快照的 Node.js 脚本。它的设计目标是:完整导出 Store 与 Model API,但完全不导出任何 React hooks 与StoreProvider——这样你可以在服务端构建 Store、dispatch action、读取 state、序列化结果,而不会把 React 打包进服务端 bundle。
完整的导出清单与说明见 easy-peasy-server API 文档,要点如下:
- 导出的 Store 工厂:
createStore、createTransform; - 导出的辅助函数:
action、actionOn、computed、debug、effectOn、generic、persist、reducer、thunk、thunkOn; - 导出的类型:
Store、EasyPeasyConfig、Action、ActionOn、Thunk、ThunkOn、Computed、EffectOn、Reducer、Dispose、Listeners、Actions、State、Dispatch、ReduxAction、MockedAction、AddModelResult、PersistStorage、PersistConfig、TransformConfig、Transformer、Generic; - 刻意不导出:
StoreProvider、全部use*hooks(useStoreState、useStoreActions、useStoreDispatch、useStore、useStoreRehydrated、useStoreTransition、useStoreDeferredState、useStoreOptimistic)、createContextStore、useLocalStore、createTypedHooks。
这些 React 绑定 API 只能在标注了'use client'的组件里从主入口easy-peasy导入。
3.2 源码级验证
从仓库源码可以确认这一设计确实落实到了打包与类型层面:
- src/server.js 是子路径的实现入口,仅
export了createStore、createTransform与 helpers,没有引入任何 hooks/Provider; - server.d.ts 为子路径提供完整类型声明,把上表中的类型从主入口
./index重新导出; - rollup.config.js 显示构建时主入口(
dist/index.js)会注入'use client';banner,而服务端入口(dist/server.js)的 banner 为空——这正好与服务端组件"不得标记 use client"的要求吻合; - package.json 的
exports字段声明了"./server"子路径及其类型入口./server.d.ts; - tests/server-export.test.js 从测试层面锁定契约:
server入口必须暴露createStore/createTransform/各 helper,且server.useStoreState、server.StoreProvider、server.createTypedHooks等必须为undefined,同时验证了从easy-peasy/server创建的 Store 可以正常getState()与 dispatch action。
四、共享 Model:一份模型,两端使用
示例最关键的设计决策是把 Model 放在 React 树之外:lib/model.ts不依赖任何 React 代码,因此既可以被服务端组件导入,也可以被客户端组件导入,甚至可以从easy-peasy/server与主入口分别导入(Model 本体只是纯数据 + 辅助函数)。
lib/model.ts 完整内容如下:
import { Action, Computed, action, computed, } from 'easy-peasy/server'; export type Product = { id: string; name: string; category: string; price: number; }; export interface ProductsModel { items: Product[]; query: string; setQuery: Action<ProductsModel, string>; addItem: Action<ProductsModel, Product>; totalValue: Computed<ProductsModel, number>; filtered: Computed<ProductsModel, Product[]>; } export const productsModel: ProductsModel = { items: [], query: '', setQuery: action((state, payload) => { state.query = payload; }), addItem: action((state, payload) => { state.items.push(payload); }), totalValue: computed((state) => state.items.reduce((sum, item) => sum + item.price, 0), ), filtered: computed((state) => { const q = state.query.toLowerCase(); if (!q) return state.items; return state.items.filter((item) => item.name.toLowerCase().includes(q), ); }), };两个值得注意的细节:
action使用 Immer 语义:setQuery直接对state.query赋值、addItem直接state.items.push,无需手动返回新对象。Easy Peasy v7 内部默认集成 Immer(createStore的disableImmer选项默认为false,参见 src/create-store.js);computed承担派生逻辑:totalValue汇总所有商品价格,filtered根据query过滤商品名。服务端复用模型逻辑(计算属性、数据归一化)而无需任何交互性,正是 Pattern 3 的典型场景。
模拟数据 lib/data.ts 提供了 6 条商品(Bananas、Sourdough Loaf、Almond Milk、Carrots、Olive Oil、Apples),并通过fetchProducts()模拟 50ms 的异步取数。
五、Pattern 3:服务端组件内的瞬态只读 Store
app/page.tsx 是一个 async Server Component,完整代码如下:
import { createStore } from 'easy-peasy/server'; import { fetchProducts } from '@/lib/data'; import { productsModel } from '@/lib/model'; import { ProductsClient } from './products-client'; export default async function HomePage() { const products = await fetchProducts(); const serverStore = createStore(productsModel, { initialState: { items: products, query: '' }, }); const { totalValue } = serverStore.getState(); return ( <main> <h1>Easy Peasy v7 — App Router + RSC</h1> <p> This server component fetches products, builds a transient store via <code> easy-peasy/server</code> to derive a total via the model's computed property, then hands the data to a client component which owns the interactive store. </p> <p> <strong>Server-rendered total:</strong>{' '} ${totalValue.toFixed(2)} </p> <ProductsClient initialItems={products} /> </main> ); }这段代码清晰展示了 Pattern 3 的核心动作:
- 从
easy-peasy/server导入createStore(服务端 bundle 不会拖入 React); await fetchProducts()获取数据;- 用
createStore(productsModel, { initialState: { items: products, query: '' } })构建一个瞬态 Store——它只服务于这一次请求,用于触发totalValue计算属性的求值; - 通过
serverStore.getState()读取推导结果,直接渲染成 HTML 里的Server-rendered total: $X.XX; - 把原始商品数组以
initialItemsprop 交给客户端组件。
服务端这里不会共享这个 Store 给客户端,客户端会构建自己的 Store(见下节)。createStore的initialState选项在 src/create-store.js 被解构取出,并在bindStoreInternals(initialState || {})(src/create-store.js)中作为模型初始数据灌入 Redux Store——这就是initialState能够直接"预填"整个 state 树的底层机制。
六、Pattern 2:客户端真正的交互式 Store
6.1'use client'边界与 useRef 单例
app/products-client.tsx 是服务端与客户端的分界线,完整代码如下:
'use client'; import { useRef } from 'react'; import { Store, StoreProvider, createStore } from 'easy-peasy'; import { productsModel } from '@/lib/model'; import type { Product, ProductsModel } from '@/lib/model'; import { ProductsView } from './products-view'; export function ProductsClient({ initialItems }: { initialItems: Product[] }) { const storeRef = useRef<Store<ProductsModel> | null>(null); if (storeRef.current === null) { storeRef.current = createStore(productsModel, { initialState: { items: initialItems, query: '' }, }); } return ( <StoreProvider store={storeRef.current}> <ProductsView /> </StoreProvider> ); }要点解析:
'use client'指令:这里从主入口easy-peasy导入createStore与StoreProvider,因此必须是客户端组件;useRef惰性单例:if (storeRef.current === null)只在首次渲染时执行createStore,保证每个 Provider 实例只创建一个 Store。绝不能在模块作用域(module scope)创建 Store——在 RSC/App Router 下,同一模块实例会被跨请求、跨用户复用,一个用户的 action 会污染另一个用户看到的 state。Store 要么绑定在客户端组件实例的生命周期内(本示例),要么绑定在服务端组件函数体的一次请求内(上一节);- 快照即初值:
initialState: { items: initialItems, query: '' }把服务端取好的商品直接灌入客户端 Store,避免重复取数和空 UI 闪烁。
6.2 类型化 hooks 消费 Store
lib/hooks.ts 通过createTypedHooks<ProductsModel>()生成与模型类型绑定的 hooks:
import { createTypedHooks } from 'easy-peasy'; import type { ProductsModel } from './model'; export const { useStoreActions, useStoreState, } = createTypedHooks<ProductsModel>();之后 app/products-view.tsx 便能用带完整类型推断的 hooks 读取状态、派发 action:
'use client'; import { ChangeEvent } from 'react'; import { useStoreActions, useStoreState } from '@/lib/hooks'; export function ProductsView() { const query = useStoreState((state) => state.query); const filtered = useStoreState((state) => state.filtered); const total = useStoreState((state) => state.totalValue); const setQuery = useStoreActions((actions) => actions.setQuery); function handleQueryChange(event: ChangeEvent<HTMLInputElement>) { setQuery(event.target.value); } return ( <section> <p> <strong>Client total:</strong> ${total.toFixed(2)} </p> <p> <input onChange={handleQueryChange} placeholder="Filter by name…" type="text" value={query} /> </p> <ul> {filtered.map((product) => ( <li key={product.id}> {product.name} — {product.category} — ${product.price.toFixed(2)} </li> ))} </ul> </section> ); }输入框的值绑定query,onChange触发setQueryaction,filtered计算属性随之重算,列表实时响应——这正是 Pattern 2 交互式 Store 的工作方式。在底层,useStoreState基于 React 的useSyncExternalStore实现订阅与选择器(参见 src/hooks.js),并通过useDebugValue暴露调试信息。
七、运行示例
在examples/nextjs-app-router目录下执行:
yarn install yarn dev然后打开 http://localhost:3000。页面上会同时出现两处合计金额:
- Server-rendered total:由服务端组件通过
easy-peasy/server构建的瞬态 Store 推导,直接渲染进 HTML; - Client total:由客户端交互式 Store 中的同一
totalValuecomputed 属性得出。
两者数值一致(种子数据合计为 29.25),但来自两条完全独立的 Store 实例。你可以在输入框中输入 "a" 之类的字符,观察filtered列表实时过滤。
八、集成要点与常见陷阱
8.1 禁止在模块作用域共享 Store
// ❌ 错误:每个请求都会复用这一个实例 import { createStore } from 'easy-peasy/server'; import { model } from './model'; export const sharedStore = createStore(model);在传统 Node 风格 SPA 中模块作用域 Store 没问题,但在 RSC/App Router 应用中,同一模块实例会被跨请求、跨并发用户复用。务必在客户端组件实例内(Pattern 1/2)或服务端组件函数体内(Pattern 3)创建 Store。
8.2 hooks 只能存在于客户端子树
StoreProvider、useStoreState、useStoreActions、useStoreTransition、useStoreRehydrated等都需要 React 与 Provider,从服务端组件导入会在构建/渲染期失败。服务端需要模型逻辑时,一律从easy-peasy/server导入。
8.3 服务端 → 客户端的 props 必须可 JSON 序列化
从服务端组件传给客户端组件的任何 prop 都会穿越服务端/客户端边界被序列化。Date、Map、Set、BigInt、函数、类实例以及undefined要么抛错、要么往返后值不正确。模型持有非序列化值时,应在边界转换——例如把Date序列化为 ISO 字符串后在客户端再恢复,或利用createTransform管道统一处理(persist 场景同理):
// 服务端组件内 return <ProductsClient initialItems={products.map((p) => ({ ...p, createdAt: p.createdAt.toISOString(), // 规范化为字符串 }))} />;8.4 服务端不要依赖 persist 的浏览器存储
默认的sessionStorage/localStorage存储引擎在服务端并不存在。如果 Model 使用了persist,又在easy-peasy/server上构建瞬态 Store,需要为服务端场景配置 no-op 或内存存储引擎(或干脆在该代码路径跳过持久化),否则 rehydration 会抛错或挂起。
8.5useStoreRehydrated与流式渲染
App Router 支持通过<Suspense>边界与loading.tsx流式渲染,useStoreRehydrated会在持久化状态 rehydrate 期间挂起,天然与之配合:rehydration 只发生在客户端,所以服务端渲染的 HTML 会先输出 Suspense fallback,客户端 Store 读取浏览器存储后 fallback 被替换。如果希望服务端直接渲染真实内容而非 fallback(如 SEO/预览场景),应改用 Pattern 2——由服务端通过initialState快照喂数据,Store 首次渲染即已就绪,useStoreRehydrated在第一帧便解析完成。
九、总结
examples/nextjs-app-router展示的"服务端瞬态 Store 推导 + 客户端交互 Store 接管"组合,是 Easy Peasy v7 在 RSC/App Router 时代的推荐集成方式:
- 一份共享 Model(
lib/model.ts)服务两端,computed 逻辑(totalValue、filtered)在服务端与客户端复用同一份实现; easy-peasy/server保证服务端 bundle 零 React 依赖,构建瞬态 Store 只为推导,不挂 Provider、不 hydration;'use client'边界 +useRef+initialState让客户端 Store 既能接收服务端快照,又能提供完整的交互体验;- 底层契约由 src/server.js、server.d.ts 与 tests/server-export.test.js 共同锁定,可放心在项目中使用。
若需了解更多端到端模式('use client'放置位置、避免模块作用域 Store 陷阱、快照后水合等),可继续阅读 RSC / App Router 配方 与 easy-peasy/server API 文档。
- 前端
【免费下载链接】easy-peasy
Vegetarian friendly state for React
相关推荐
10分钟学会使用PotatoNV:华为Kirin设备Bootloader解锁快速入门
10分钟学会使用PotatoNV:华为Kirin设备Bootloader解锁快速入门 PotatoNV是一款专门为华为Kirin系列芯片设备设计的Bootloa
开发工具嵌入式固件Convex + Next.js App Router SSR 实战:在 Server Components 中预加载数据
Convex + Next.js App Router SSR 实战:在 Server Components 中预加载数据 导读 本文基于开源仓库 convex
数据库后端Lingui 在 Next.js App Router 中实现 React Server Components 国际化:RSC i18n 完整实战指南
Lingui 在 Next.js App Router 中实现 React Server Components 国际化:RSC i18n 完整实战指南 Ling
开发工具前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考