- 前端
- CMS
【免费下载链接】wp-calypso
The JavaScript and API powered WordPress.com
导读
本文基于 WordPress.com 前端主仓库 wp-calypso 的 client/state/README.md 展开,系统讲解 Calypso 如何构建并维护全局应用状态:从创建 Redux Store、模块化(按需)加载 reducer,到使用keyedReducer组合集合型状态、用withSchemaValidation校验持久化状态。读完本文,你将掌握 Calypso 状态子树的标准目录组织、reducer 注册与依赖图自动加载机制,以及如何在真实业务代码中安全地使用状态工具函数。
client/state目录承载了 Calypso 全局状态树的所有行为:目录下的每个子目录对应全局状态树中的一个子树(sub-tree),各自拥有独立的 reducer、action 与 selector。根模块导出一个函数,调用后返回一个 Redux store 实例,该实例将所有 dispatch 的 action 分发给全部已知 reducer。
从单体状态到模块化状态
Calypso 最初遵循 Redux 官方指南采用单体(monolithic)状态方案:要把一个 reducer 挂进全局 store,只需在index.js的组合 reducer 中追加这个函数,所有 reducer 都会在应用启动前被一次性加载。
随着 Calypso 状态规模膨胀,把全部 reducer 提前加载的弊端越来越明显:reducer 本身不大,但它们往往依赖内外库和大块数据,且 reducer 是同步的、无法异步加载,于是全部进入了应用的启动关键路径(critical path),直接影响加载性能(详见 docs/modularized-state.md 中的问题分析)。因此 Calypso 转向**模块化状态(modularized state)**方案——reducer 不再全部预加载,而是随用户导航按需注册。
该方案遵循三条核心原则:
- 不需要显式声明"哪些页面用了哪些状态",这类声明既难确定又难维护;
- 不需要大范围改动既有代码(因此排除把状态改成异步的方案);
- 状态的使用方(组件/selector)无需感知模块化与否。
按需注册 reducer:init 文件与依赖图
模块化状态的核心机制是通过副作用完成注册。每个模块化状态子树都有一个init文件负责注册 reducer,例如reader的 client/state/reader/init.js:
import { registerReducer } from 'calypso/state/redux-store'; import reducer from './reducer'; registerReducer( [ 'reader' ], reducer );然后,所有用到reader状态的 selector 与 action creator 模块都会先导入这个init文件:
import 'calypso/state/reader/init'; function getStream( state, streamKey ) { return state.reader.streams[ streamKey ] || emptyStream; }真实代码中随处可见这种模式,例如 reader/conversations/actions.js 与 get-reader-conversation-follow-status.js 都以import 'calypso/state/reader/init'开头。
这样做的效果是:注册动作作为依赖图解析的一部分自动发生,无需任何手工输入;状态随常规构建流程被自动分发到不同 chunk,浏览器只在需要时加载对应代码。
registerReducer的底层实现在 client/state/redux-store.ts:如果 store 尚未就绪,注册请求会先进入一个队列(reducerRegistrationQueue);一旦通过setStore建立了全局 store,队列中的全部 reducer 会被同步补注册,之后的新注册立即生效。同一 key 重复注册不同的 reducer 会抛出Different reducers on multiple calls to addReducerToStore...错误(见 client/state/add-reducer.ts),防止模块化与静态注册相互冲突。
动态添加 reducer 的底层机制
动态注册由 store enhancer 支撑。client/state/utils/add-reducer-enhancer.js 给 store 附加了addReducer( keys, subReducer )方法:它把新 reducer 组装进当前组合 reducer 并调用replaceReducer热替换。而 client/state/utils/reducer-utils.ts 中的addReducer会沿着 keyPath 逐层深入组合 reducer 树,对尚未存在的 key 用reduceRight自动构建嵌套的combineReducers结构。
收拢状态:推荐的目录结构
为集中管理状态,官方建议:某一状态子树的全部 reducer、selector 与 action creator 放在client/state/<name>对应目录下;跨多个子树的 selector 则放在client/state/selectors。标准结构(见 docs/modularized-state.md):
client/state/ └── { subject }/ ├── init.js ├── reducer.js ├── actions/ | ├── index.js | ├── action1.js | └── action2.js └── selectors/ ├── index.js ├── selector1.js └── selector2.js创建与使用 Redux Store
在应用入口或测试中,通过 client/state/index.ts 导出的createReduxStore创建 store:
import { createReduxStore } from 'calypso/state'; const store = createReduxStore();从源码看,store 创建时装配了多层中间件与 enhancer(client/state/index.ts):
thunkMiddleware:支持异步 thunk action;wpcomApiMiddleware:数据层中间件,必须最早进入中间件链,因为它会在网络事件(成功/失败/进度)发生时重新 dispatch 带特殊 meta 的 action,若其他中间件抢先处理可能误触发;dynamicMiddlewares:支持运行时动态注入中间件;- 浏览器环境还按需追加 analytics、lib、desktop 中间件;
- enhancer 链包含
addReducerEnhancer(动态加 reducer 的能力来源)、调试环境下的consoleDispatcher与actionLogger,以及浏览器中的 Redux DevTools 扩展。
如果应用启动时已有浏览器持久化的旧状态,可以作为initialState传入(函数的第一个参数)。另外index.ts还重新导出了类型化的useSelector/useDispatch/useStoreReact Hooks,供组件在IAppState类型下使用。
keyedReducer:为集合状态编写简洁 reducer
问题:集合型 reducer 的样板代码
很多业务状态是"一组同类对象"的集合,比如按siteId组织的站点数据、按username组织的用户数据。若不用辅助工具,reducer 必须亲自处理集合的散列结构:
const widgetCount = ( state = {}, action ) => { if ( ADD_WIDGET === action.type ) { return { ...state, [ action.siteId ]: state[ action.siteId ] + 1, }; } return state; };reducer 明明只想操作一个整数,却因为要按siteId存放集合而被迫写出复杂的初始状态与返回语法。
用 keyedReducer 解耦
keyedReducer( keyName, reducer )(实现于 client/state/utils/keyed-reducer.ts)提供了"胶水":它把单个对象的 reducer 提升为一个按键管理的集合 reducer,自动从 action 中读取指定 key,并把更新只派发到集合中对应条目。于是上面的逻辑可以写成:
const widgetCount = ( state = 0, action ) => { if ( ADD_WIDGET === action.type ) { return state + 1; } return state; }; export default keyedReducer( 'siteId', widgetCount );完整的官方示例(age/title/userReducer组合 +keyedReducer( 'username', ... )):
const age = ( state = 0, action ) => ( GROW === action.type ? state + 1 : state ); const title = ( state = 'grunt', action ) => ( PROMOTION === action.type ? action.title : state ); const userReducer = combineReducers( { age, title, } ); export default keyedReducer( 'username', userReducer ); dispatch( { type: GROW, username: 'hunter02' } ); state.users === { hunter02: { age: 1, title: 'grunt', }, };使用该辅助函数的好处:
- 单个子 reducer 保持小而清晰,更新表达式与集合中其他条目完全解耦;
- 自动享受
combineReducers的不可变更新语义——若实际没有变化,则不会触发更新; - 测试简单,无需复杂的 mock。
删除条目:返回 undefined
某些场景需要响应 action 删除集合中的 key。此时让 reducer 返回undefined,keyedReducer会显式地从状态中移除该 key:
const deleteableUserReducer = ( state, action ) => DELETE === action.type ? undefined : userReducer( state, action ); export default keyedReducer( 'username', deleteableUserReducer ); state.users === { hunter02: { age: 1, title: 'grunt', }, }; dispatch( { type: DELETE, username: 'hunter02' } ); expect( state.users ).toEqual( {} );源码级细节与边界行为
从 keyed-reducer.ts 的实现可以确认几个值得注意的行为:
- key 校验:
keyPath必须是非空字符串,reducer 必须是函数,否则在构造时直接抛TypeError; - keyPath 支持点号路径:除
'siteId'这种单层 key 外,也支持meta.dataLayer.requestKey这类点分隔路径(不支持括号/引号下标语法),路径在构造时解析一次而不是每个 action 解析一遍; - 空 key 短路:若 action 中该路径的值为
null或undefined,整个 super-reducer 原样返回旧 state(杜绝null => 0这类隐式类型转换); - 引用比较优化:若子 reducer 返回的新状态与旧状态严格相等(
===),集合不变,直接返回原 state; - 初始态去重:新状态若为
undefined或与 reducer 的初始状态深度相等(isEqual),该 key 会被从集合中移除(已存在时);这同时保证了序列化时不会把无意义的初始条目写进持久化存储。
与持久化的配合
keyedReducer返回的 super-reducer 通过withPersistence实现了serialize/deserialize方法(keyed-reducer.ts):序列化时逐条目调用内层 reducer 的serialize,跳过与初始态相等的条目;反序列化时过滤掉undefined或等于初始态的条目。因此集合型状态也能正确接入 Calypso 的状态持久化机制。
withSchemaValidation:安全加载持久化状态
Calypso 启动时会从浏览器持久化存储加载上次保存的状态(见 client/state/utils/schema-utils.js)。若该状态由旧版本 reducer 写入,可能与新状态模型不兼容。withSchemaValidation( schema, reducer )解决此问题:它返回一个新 reducer,在加载持久化状态时自动做 JSON Schema 校验,校验失败则回退到初始状态。
const ageReducer = withPersistence( ( state = 0, action ) => GROW === action.type ? state + 1 : state ); const schema = { type: 'number', minimum: 0 }; export const age = withSchemaValidation( schema, ageReducer ); deserialize( ageReducer, -5 ) === -5; // 未包 schema,直接透传 deserialize( age, -5 ) === 0; // 校验失败,回退初始状态 deserialize( age, 23 ) === 23; // 校验通过实现要点(schema-utils.js):
- 校验使用
is-my-json-valid编译器;开发环境(NODE_ENV !== 'production')会开启greedy/verbose并输出详细的字段级错误警告,生产环境则关闭以省去校验开销; - 快速路径:若持久化状态与"序列化后的初始状态"深度相等,直接判定合法,跳过昂贵的完整 schema 校验;
- 包裹后的 reducer 通过
withPersistence附加自定义deserialize:持久化值为undefined或校验失败时返回初始状态,否则交给内层 reducer 的deserialize处理。
状态持久化的配套设施
withPersistence 与序列化
client/state/utils/with-persistence.ts 为 reducer 附加持久化能力:默认serialize为恒等映射(原样保存)、deserialize为原样还原;也可传入自定义的serialize/deserialize方法。keyedReducer与withSchemaValidation内部都依赖它,utils/index.ts一并导出了serialize/deserialize供自定义持久化逻辑使用。
withStorageKey:独立存储 key
模块化状态要求"每个子树的 reducer 单独持久化"。withStorageKey( 'reader', combinedReducer )(见 client/state/reader/reducer.ts)为 reducer 打上storageKey标记。在 reducer-utils.ts 的组合序列化逻辑中,带storageKey的子 reducer 会被序列化到独立 key 下,而不是塞进根对象——这正是模块化状态下各子树状态各自落盘、互不干扰的关键。
动态注册后回填持久化状态
add-reducer.ts 的addReducerToStore在把新 reducer 挂入 store 时,若该 reducer 带有storageKey且提供了getStoredState,会异步读取已持久化状态,并 dispatchAPPLY_STORED_STATEaction 将其注入;组合 reducer 在 reducer-utils.ts 中通过匹配storageKey定位并替换对应子树状态。这保证了"按需加载的 reducer 依然能恢复上次会话的状态"。
新增状态的标准流程
综合 client/state/README.md 与 docs/modularized-state.md 的操作指引,为 Calypso 添加一块新状态(或把旧状态模块化)的完整步骤如下:
编写常规代码:按上文目录结构写出 reducer、actions、selectors;测试时可暂时挂到 client/state/reducer.js 的根 reducer(即默认的非模块化方式),但注意该文件的 eslint 规则已禁止新增 legacy reducer(文件头部的
no-restricted-imports配置就是为了防止继续往这个名单里加人,见 reducer.js)。添加
init.js:在子树根部创建 init 文件,内容为registerReducer( [ 'subject' ], reducer )。添加
package.json声明副作用:init 文件有副作用,需要显式告知 webpack:
{ "sideEffects": [ "./init.js" ] }否则 webpack 在打包优化时可能误删 init 的注册逻辑。
用
withStorageKey独立持久化:把 reducer 的默认导出改为export default withStorageKey( 'subject', combinedReducer )。确保每个 selector / action creator 导入 init:凡访问该状态子树的模块都加上
import 'calypso/state/subject/init'(包括散落在 client/state/selectors 的跨子树 selector 和直接内联读取 state 的组件——后一种情况通常值得顺手重构为正式 selector)。从 legacy 根 reducer 移除:完成以上改造后,把该 reducer 从 client/state/reducer.js(以及 client/landing/login/store,登录入口有独立的 reducer 清单)中删除。
常见问题排查
"Reducer with key 'foo' is already registered"
通常意味着:忘记把该子树从client/state/reducer或登录入口的 reducer 清单中移除;或者 init 文件中用了错误的 key(例如复制粘贴串用了其他子树的名称)。底层抛错位置在 reducer-utils.ts:当沿 keyPath 递归到最终位置却发现已有 reducer 占用时会抛出该错误;模块化机制本身也会防止同一子树被初始化两次。
模块化后单元测试失败
某些测试在模块化后开始失败,往往是因为测试自行创建了 Redux store,却没有配置好模块化机制。创建 store 后需要调用setStore把它设为全局 store:
import { createReduxStore } from 'calypso/state'; import { setStore } from 'calypso/state/redux-store'; import Thing from '../'; describe( 'Thing', () => { test( 'renders correctly', () => { const store = createReduxStore(); setStore( store, currentUserId ); // Instantiate and test component } ); } );setStore(redux-store.ts)在替换既有 store 时会先清空已注册 reducer,再把注册队列中的全部 reducer 同步补挂到新 store 上,确保测试环境与生产环境的行为一致。
迁移现状与演进方向
截至当前仓库状态,client/state/reducer.js 中静态加载的 legacy reducer 只剩currentUser、dataRequests、sites三个,其余大量状态子树(reader、comments、domains、themes、sites之外的各种业务模块)均已走模块化注册路径。Calypso 正在持续迁移剩余状态:随着迁移推进,index.js中静态加载的 reducer 数量会逐步归零,最终全部状态加载都经由模块化方式完成——这正是 client/state/README.md 描述的演进终点。
总结
Calypso 的状态管理方案可以概括为三点:用模块化注册解决启动性能——init文件 +registerReducer让 reducer 随依赖图按需进包;用组合工具简化 reducer 编写——keyedReducer把集合型状态还原为单对象逻辑、withSchemaValidation守护持久化数据的安全回放;用统一目录与存储约定保障可维护性——子树代码集中于client/state/<name>,持久化通过withStorageKey各自独立落盘。无论你是要在 Calypso 中新增状态子树,还是想理解大型 Redux 应用如何做规模化演进,这套模式都值得直接借鉴。
- 前端
- CMS
【免费下载链接】wp-calypso
The JavaScript and API powered WordPress.com
相关推荐
终极指南:如何使用redux-persist实现Redux状态的模块化持久化
终极指南:如何使用redux persist实现Redux状态的模块化持久化 在现代Web应用开发中,Redux作为状态管理库被广泛使用,但页面刷新后状态丢失的
前端wp-calypso状态管理揭秘:Modularized State模块化状态设计原理深度解析
wp calypso状态管理揭秘:Modularized State模块化状态设计原理深度解析 wp calypso 是 WordPress.com 的前端应用
前端CMSFlutter-Notebook状态管理:Redux持久化实现
Flutter Notebook状态管理:Redux持久化实现 在移动应用开发中,状态管理是确保用户体验流畅的核心环节。当应用需要在重启后保留用户数据或界面状态
示例工程移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考