- 前端
【免费下载链接】rematch
The Redux Framework
在 Rematch 应用中,effects 往往对应着异步请求(拉取数据、提交表单等),而"加载中 / 成功 / 失败"这类 UI 状态如果全靠手动维护loading: true,不仅繁琐还容易在 effect 提前抛错时漏掉复位。@rematch/loading插件正是为此而生:它会自动为 effects 生成三层粒度的加载状态(全局、按 model、按具体 effect),你只需要在 store 中挂载插件,即可通过useSelector直接消费状态。本文基于官方文档 docs/plugins/loading.md 展开,并结合 packages/loading/src/index.ts 的源码实现与测试用例,讲清它的配置项、状态结构、TypeScript 类型接入方式,以及底层"计数器 + effect 包装"的工作原理。
插件定位与版本兼容性
@rematch/loading的官方定位是"为 Rematch 添加自动化的 loading 指示器,让你无需自己维护loading: true之类的状态",其设计灵感来源于 dva-loading(见 packages/loading/package.json 中的 description 字段与 docs/plugins/loading.md)。
安装时需注意插件版本必须与 core 版本匹配:
| @rematch/core | @rematch/loading |
|---|---|
| 1.x.x | 1.x.x |
| 2.x.x | 2.x.x |
@rematch/loading的 package.json 中声明了peerDependencies: { "@rematch/core": ">=2" },即 2.x 的 loading 插件要求 core 至少为 2.x。当前仓库中该包版本为 2.1.2。
安装
npm install @rematch/loadingloadingPlugin([config]) 配置项详解
插件接收一个可选的config对象,全部配置项在源码 LoadingConfig 接口 中定义:
export type LoadingPluginType = 'number' | 'boolean' | 'full' export interface LoadingConfig { name?: string whitelist?: string[] blacklist?: string[] type?: LoadingPluginType /** * @deprecated Use `type: 'number'` instead */ asNumber?: boolean }各参数说明:
name(string?):loading 模型在 store 中的键名。命名为"custom"后,加载状态就从state.custom读取。默认值为'loading'。asNumber(boolean?):默认情况下插件用布尔值跟踪运行中的 effects(如state.loading.global === true);设为true后改为记录 effect 被执行了多少次(如state.loading.global === 5)。已废弃,请改用type: 'number'。type("number" | "boolean" | "full"):'boolean'(默认):只表示"是否有 effect 正在运行";'number':记录每个 effect 被调用的次数,适合并发去重或展示"进行中数量";'full':同时跟踪 effect 是否 loading、是否以 Error 结束、是否正确 resolve,即{ loading, success, error }三元组。
whitelist(string[]?):白名单。指定后,插件只为列表中的 effects 工作。blacklist(string[]?):黑名单。指定后,插件对所有 effects 工作,但排除列表中的。
whitelist与blacklist都接受"modelName/effectFunctionName"格式的完整 effect 名,例如'count/addOne'。两者都未提供时,插件对所有 effects 生效。
从源码的 validateConfig 可以看到这些约束在开发环境下会被强制校验(NODE_ENV !== 'production'时):
name必须是字符串,否则抛错;asNumber传入时控制台会打印 deprecation warning,提示替换为type: 'number';whitelist/blacklist必须是字符串数组;- 两者不能同时提供,同时传入会直接抛错
loading plugin config cannot have both a whitelist & a blacklist。
上述每条约束在 packages/loading/test/loading-asBoolean.test.ts 中都有对应的测试用例覆盖(如should throw if contains both a whitelist & blacklist)。
Loading 状态结构
以 store 中有一个count模型为例,默认(boolean)模式下插件注入的状态结构为:
{ "global": true, // true when any effect in any model is loading "models": { "count": true // true when any effect in 'count' model is loading }, "effects": { "count": { "addOne": true // true when effect 'addOne' in model 'count' is loading } } }三层粒度的含义:
loading.global:任意模型的任意 effect 正在运行即为true;loading.models.count:count模型内任一 effect 正在运行即为true;loading.effects.count.addOne:精确到"count 模型下的 addOne effect"是否正在运行。
这套结构在源码的类型定义 LoadingState 接口 中可以直接对应,其中effects一层甚至基于ExtractRematchDispatchersFromEffects自动推导每个模型的具体 effect 名——这意味着只要 store 配置正确,rootState.loading.effects.count.addOne这样的访问在 TypeScript 中是完整的,写错 effect 名会直接报类型错误。
将插件接入 Store(TypeScript)
假设已有如下 model(来自官方文档的示例,docs/plugins/loading.md 的countModel):
// @filename: count.ts import { createModel } from '@rematch/core' import { RootModel } from "./models" export const count = createModel<RootModel>()({ state: 0, reducers: { increment(state, payload: number) { return state + payload }, }, effects: (dispatch) => ({ async incrementAsync(payload: number, state) { dispatch.count.increment(payload) }, }), })// @filename: models.ts import { Models } from "@rematch/core" import { count } from "./count" export interface RootModel extends Models<RootModel> { count: typeof count } export const models: RootModel = { count }默认(boolean 模式)
// @filename: store.ts import loadingPlugin, { ExtraModelsFromLoading } from "@rematch/loading" import { init, RematchDispatch, RematchRootState } from "@rematch/core" import { models, RootModel } from "./models" type FullModel = ExtraModelsFromLoading<RootModel> export const store = init<RootModel, FullModel>({ models, plugins: [loadingPlugin()], }) export type Store = typeof store export type Dispatch = RematchDispatch<RootModel> export type RootState = RematchRootState<RootModel, FullModel>关键点:init的第二个泛型参数FullModel声明了"插件额外注入的模型",而ExtraModelsFromLoading<RootModel>正是 packages/loading/src/index.ts 导出的类型,它会在原有RootModel的基础上追加一个loading模型声明,使RematchRootState能推导出state.loading的完整形状:
export interface ExtraModelsFromLoading< TModels extends Models<TModels>, TConfig extends LoadingConfig = { type: 'boolean' } > extends Models<TModels> { loading: LoadingModel<TModels, ...> }注意它的第二个泛型参数默认就是{ type: 'boolean' },所以不传任何配置时类型系统默认按布尔模式推导。
number 模式
// @filename: storeAsNumber.ts import loadingPlugin, { ExtraModelsFromLoading } from "@rematch/loading" import { init, RematchDispatch, RematchRootState } from "@rematch/core" import { models, RootModel } from "./models" type FullModel = ExtraModelsFromLoading<RootModel, { type: 'number' }> export const store = init<RootModel, FullModel>({ models, plugins: [loadingPlugin({ type: 'number' })], })此时state.loading.global、state.loading.models.count等变为数字(被调用的次数),类型上由 PickLoadingPluginType 映射:'number'→number、'full'→DetailedPayload、其余 →boolean。
full 模式
// @filename: storeAsFull.ts import loadingPlugin, { ExtraModelsFromLoading } from "@rematch/loading" import { init, RematchDispatch, RematchRootState } from "@rematch/core" import { models, RootModel } from "./models" type FullModel = ExtraModelsFromLoading<RootModel, { type: 'full' }> export const store = init<RootModel, FullModel>({ models, plugins: [loadingPlugin({ type: 'full' })], })full模式下每个加载点都是{ loading, success, error }三元组,类型定义见 DetailedPayload:
type DetailedPayload = { error: unknown success: boolean loading?: boolean }packages/loading/test/loading-asFull.test.ts 验证了完整状态流转:effect 开始时为{ loading: true, success: false, error: false },resolve 后变为{ loading: false, success: true, error: false };若 effect 抛出异常,则error字段直接存放该 Error 对象(测试中用 inline snapshot 断言error: [Error: effect error])。
React 中使用
下面分三种模式给出消费方式。
默认(boolean)
import React from 'react' import { useSelector } from 'react-redux' import { RootState } from './store' export const App = () => { const isCountLoading = useSelector( (rootState: RootState) => rootState.loading.models.count ) if (isCountLoading) return <div>LOADING...</div> return <div>Data succesfully loaded</div> }full 模式
import React from 'react' import { useSelector } from 'react-redux' import { RootState } from './storeAsFull' export const App = () => { const { loading, success, error } = useSelector( (rootState: RootState) => rootState.loading.models.count ) if (loading) return <div>LOADING...</div> if (error) return <div>{(error as Error).name}</div> return <div>Data succesfully loaded</div> }full 模式让"失败原因"也成为可消费状态——上例直接把error.name渲染出来,无需在业务层额外捕获。
number 模式
import React from 'react' import { useSelector } from 'react-redux' import { RootState } from './storeAsNumber' export const App = () => { const countCalledTimes = useSelector( (rootState: RootState) => rootState.loading.models.count ) if (countCalledTimes > 0) return <div>LOADING...</div> return <div>Data succesfully loaded</div> }源码深潜:插件是如何实现的
理解 packages/loading/src/index.ts 的实现后,很多行为细节(比如计数器为何不会漂移、同步 effect 为何不会报错)都能找到答案。
1. 内部永远用计数器,输出时再"翻译"
插件内部维护了一个cntState(InitialState<'number'>类型,即纯数字结构),而真正暴露给用户的 state 由一个converter函数翻译而成(源码 L195-L204):
const converter: Converter<LoadingPluginType> = (cnt, detailedPayload) => { if (isAsNumber) return cnt if (isAsDetailed && detailedPayload) { return { ...detailedPayload, loading: cnt > 0 } as DetailedPayload } if (isAsDetailed) { return { loading: cnt > 0, success: false, error: false } } return cnt > 0 }这就是三种输出模式共用同一套计数逻辑的原因:boolean模式判断cnt > 0,number模式原样返回cnt,full模式把计数是否大于 0 与成功/失败信息合并为DetailedPayload。
2. show / hide 两个 reducer 驱动状态
loading 模型只有两个 reducer——show与hide,它们由 createLoadingAction 工厂函数生成,参数i分别为+1和-1:
const loading: LoadingModel<TModels, LoadingPluginType> = { name: loadingModelName, reducers: { hide: createLoadingAction(converter, -1, cntState), show: createLoadingAction(converter, 1, cntState), }, state: loadingInitialState, }createLoadingAction每次被调用时,会同步修改cntState中global、models[name]、effects[name][action]三处计数,然后用converter把新计数翻译成对外 state 的三个对应位置。由于"内部计数 + 对外转换"是分离的,即使boolean模式下多个 effect 并发(测试用例should capture all model and global loading for simultaneous effects验证了这一点:两个 effect 同时跑时计数为 2,第一个结束后计数回落为 1,models.count仍保持true,直到全部结束才变为false),状态也绝不会错误地提前翻转。
3. onModel 钩子:包装每个 effect
插件返回对象的onModel回调在 core 注册每个模型时被触发,核心逻辑(源码 L234-L338)分四步:
- 跳过自身:模型名等于
loadingModelName(默认'loading')时直接 return,避免无限递归;同时为cntState和初始状态预留该模型的计数槽位。 - 识别 effect:遍历
rematch.dispatch[name]的每个 action,依赖 core 为 dispatcher 打上的isEffect标记(packages/core/src/dispatcher.ts 注释说明该属性专门用于区分 effect 与 reducer 的 dispatcher),只对 effect 做处理。 - 黑白名单过滤:拼出
modelName/actionName形式的 actionType,若不在whitelist中或在blacklist中,则跳过包装——这就是为什么白/黑名单要写成'count/addOne'这种完整格式。 - 包装并替换:保存原始 effect 引用,生成
effectWrapper后写回rematch.dispatch[name][action] = effectWrapper,并保留effectWrapper.isEffect = true标记,确保后续依赖isEffect的插件/类型推导不受影响。
4. effectWrapper:show → 执行 → hide 的生命周期
包装器的行为可以概括为一个 try 包裹的三段式流程:
dispatch loading/show(计数 +1) ↓ 执行原 effect ↓ 若返回值是 Promise: resolve → dispatch loading/hide(计数 -1,success=true)后 return 结果 reject → dispatch loading/hide(计数 -1,error=err)后重新 throw 否则(同步返回): 立即 dispatch loading/hide 并 return 结果 catch:hide 后重新 throw几个值得注意的细节:
- 错误不吞没:无论 effect 内部 reject、还是抛出非 Promise 异常,包装器都会在
hide之后throw err重新抛出。测试 should allow the propagation of the error 与should handle "hide" if effect throws分别验证了"错误照常向外传播"与"异常路径下 loading 也会被正确复位"。 - 结果原样透传:
should allow the propagation of the effect result测试确认包装后的 effect 返回值(如'foo')不会被插件篡改。 - 完整的 action 序列可观察:
should trigger four actions测试通过 redux middleware 记录了完整序列——loading/show→count/timeout→count/addOne→loading/hide,即一次 effect 调用最少产生"show 一次、hide 一次"两个 action。这个序列在 Redux DevTools 中直接可见,也是排查"loading 卡住"问题的抓手(show 与 hide 计数不配对即可定位)。
5. 自定义 name 与 deprecation 处理
const loadingModelName = config.name || 'loading'决定了状态键名与 action 前缀(自定义为foobar后即为foobar/show、foobar/hide)。而asNumber: true会在运行时被映射为config.type = 'number'(源码 L189-L191),并触发控制台 deprecation 警告——这是官方明确给出的迁移路径。
实战示例:examples/loading-react
仓库中的 examples/loading-react 演示了一个典型的异步提交场景。模型定义(src/models.js)中submiteffect 模拟了 3 秒的网络延迟:
export const count = { state: 0, reducers: { addOne(s) { return s + 1 }, }, effects: { async submit() { // mocking the delay of an effect await asyncDelay(3000) this.addOne() }, }, }store 初始化(src/index.js)使用 number 模式:
const loadingPlugin = createLoadingPlugin({ asNumber: true }) const store = init({ models, plugins: [loadingPlugin], })视图层(src/App.js)用connect同时映射三层状态,直观展示了粒度选择:
const mapState = (state) => ({ count: state.count, loading: { global: state.loading.global, model: state.loading.models.count, effect: state.loading.effects.count.submit, }, })页面中三个Loading组件分别绑定loading.global、loading.models.count、loading.effects.count.submit,点击 "Submit Async" 后 3 秒内三个指示器同时出现,submitresolve 后同时消失。该示例基于旧版connectAPI 编写(asNumber: true写法),新项目建议按前文 TypeScript 章节的type: 'number'+useSelector方式接入。
小结
@rematch/loading把 effects 的加载状态变成了"声明即可得"的 store 状态,粒度分为global/models.*/effects.*三层;- 通过
type在boolean(是否加载中)、number(调用次数)、full({ loading, success, error })三种语义间切换,类型系统通过ExtraModelsFromLoading<RootModel, { type: 'xxx' }>同步感知; whitelist/blacklist以'modelName/effectName'精确控制作用范围,二者互斥;- 从源码看,插件靠"内部纯计数器 + converter 翻译 + effect 包装(show/hide 配对)"实现,异常路径保证 hide 必被调用且错误照常向外传播,行为均有 packages/loading/test 下的测试用例兜底。
- 前端
【免费下载链接】rematch
The Redux Framework
相关推荐
7个实用技巧:Rematch状态管理库的完整测试指南
7个实用技巧:Rematch状态管理库的完整测试指南 Rematch是基于Redux的状态管理框架,它简化了Redux的使用流程,让状态管理变得更加直观和高效。
前端微信聊天记录的数字永生:WeChatMsg如何让你的对话记忆永不褪色
微信聊天记录的数字永生:WeChatMsg如何让你的对话记忆永不褪色 在数字时代,我们每天通过微信交换的信息量相当于一本中等篇幅的书籍,然而这些珍贵的对话记忆往
Rematch插件生态终极指南:Immer、Loading与Persist的实战应用
Rematch插件生态终极指南:Immer、Loading与Persist的实战应用 Rematch是一个基于Redux的轻量级状态管理框架,它通过减少样板代码
前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考