☰
Rematch @rematch/loading 插件详解:自动化的 Effects 加载状态管理
2026/9/25 3:05:11 网站建设 项目流程
  • 前端

【免费下载链接】rematch

The Redux Framework

项目地址:https://gitcode.com/gh_mirrors/re/rematch
点击查看免费下载

在 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.x1.x.x
2.x.x2.x.x

@rematch/loading的 package.json 中声明了peerDependencies: { "@rematch/core": ">=2" },即 2.x 的 loading 插件要求 core 至少为 2.x。当前仓库中该包版本为 2.1.2。

安装

npm install @rematch/loading

loadingPlugin([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)分四步:

  1. 跳过自身:模型名等于loadingModelName(默认'loading')时直接 return,避免无限递归;同时为cntState和初始状态预留该模型的计数槽位。
  2. 识别 effect:遍历rematch.dispatch[name]的每个 action,依赖 core 为 dispatcher 打上的isEffect标记(packages/core/src/dispatcher.ts 注释说明该属性专门用于区分 effect 与 reducer 的 dispatcher),只对 effect 做处理。
  3. 黑白名单过滤:拼出modelName/actionName形式的 actionType,若不在whitelist中或在blacklist中,则跳过包装——这就是为什么白/黑名单要写成'count/addOne'这种完整格式。
  4. 包装并替换:保存原始 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

项目地址:https://gitcode.com/gh_mirrors/re/rematch
点击查看免费下载

相关推荐

上一篇:Excalidraw 手绘白板:三步跑起来,画图和协作一次搞定
下一篇:AIOS快速上手指南:5分钟搭建个人AI代理操作系统

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询