先交代一下背景。我最近在做项目里的 React Native 模块向 OpenHarmony 平台的适配,业务侧状态管理用的是一套 MobX,期望是“一套业务代码,两端都能跑”。最初以为工作量主要在样式兼容和原生能力替换上,真动手之后才发现,最磨人的反而是 MobX action 这一层:异步回调里顺手改 state 会被严格模式拦下来、日志里全是 anonymous action 根本没法排查、每个页面都要重复写 loading 状态和错误处理。后来我把 action 统一封装了一层,这套方案在 OpenHarmony 上跑得很稳,也顺手解决了跨端生命周期带来的状态不同步问题。这篇把封装思路、核心实现和踩坑记录都摊开讲,适合正在做 RN 鸿蒙化、或者想优化 MobX 状态管理写法的团队参考。
1. 为什么到 OpenHarmony 才觉得 MobX 的 action 该封装一层
1.1 MobX 那三条规则在实际项目里有多重要
MobX 的核心模型从概念上讲特别简单:observable 负责定义可观察状态,action 负责修改状态,computed 负责根据状态派生新值。由于前端团队里很多人是从 React 的 setState 习惯过来的,一开始并不适应“任意函数都可以修改状态”这种自由度,MobX 6 也因此在默认配置里把enforceActions打开,要求所有修改可观测量状态的行为必须包在 action 里。
这个约束不是为了折腾开发者,而是为了两件事:批量更新和变更可追踪。action 内部的所有状态修改结束后才会触发响应,相当于把“开关灯、拉窗帘、调空调”这几个动作合并成一次“全屋模式切换”,避免每个细节变化都通知界面重新渲染。你如果绕过 action 直接改 observable,调试时就说不清这个状态到底是哪一路代码改的、和上一个版本相比为什么多渲染了一次。项目越小越不觉得,一旦跨端适配、页面生命周期变得复杂,没有 action 兜底会非常痛。
1.2 跨端适配时冒出来的三个具体痛点
第一个痛点是生命周期不一致。OpenHarmony 应用侧以 UIAbility 的 onPageShow / onPageHide 这类生命周期为主,而 React Native 组件侧是 componentDidMount / componentWillUnmount 那套。两端周期互相嵌套之后,页面进入后台时组件可能还没卸载,原生侧已经切到后台,这时在回调里修改 store 数据,时序完全对不上。没有统一的 action 封装,只能在每个页面里写一堆生命周期钩子,代码重复率高到离谱。
第二个痛点是原生模块回调时机不可控。RN 在 OpenHarmony 上运行时,JS 引擎线程与 OpenHarmony 原生侧的各种能力回调不在同一个执行上下文中。调用系统能力、获取设备信息、拉起系统服务,这些异步回调回来之后你很难保证当前还在某个明确的 action 上下文里,于是经常出现“明明写对了代码,却在回调里修改 state 时被 strict-mode 报错”的情况。反复尝试后我意识到,问题不在单次回调,而在“缺少一个固定的、可重用的 action 容器”。
第三个痛点是业务侧样板代码爆炸。每次请求数据、提交表单,都要手动维护一个 loading 标志、一个 error 字段、一个请求取消标志,这些逻辑散落在各页面组件里,UI 层和状态层耦合得很深。适配 OpenHarmony 时需要把部分逻辑挪到原生线程,结果发现这些状态字段根本没法跨端共享,因为它们没有被收拢到统一的 store 方法里。
综合来看,问题直接指向同一个答案:在 action 外面再包一层统一的管理机制,把异步边界、loading、错误处理和生命周期感知全部内置进去。
2. action 封装的整体设计与选型
2.1 封装前我给自己定了四个约束
动手写封装层之前,我先列了四个设计目标,后续所有取舍都以它们为准。
第一是统一收口:所有会修改 store 的入口,不论同步异步,必须经过同一个包装函数,这样才有机会统一加日志、错误上报和 loading 状态。
第二是最小侵入:业务代码不太愿意大规模重写。封装之后,原有函数签名、返回值和 this 指向最好保持不变,相当于给每个 action 函数“套了一层壳”,而不是要求你重写一套管理方式。
第三是类型友好:项目是 TypeScript 写的,封装器的参数类型、返回值类型都必须能正确推导。否则为了一个状态管理方案放弃类型安全,代价太大。
第四是可插拔:表格里那些 loading、命名、错误回调、防重入,全部设计成可选配置。不需要的团队可以传空对象,需要的项目可以逐个打开,不让封装层变成重框架。
2.2 为什么我放弃了装饰器方案
MobX 官方提供了action装饰器,也能配合makeAutoObservable自动推断 action。如果项目里已经铺了装饰器,看起来确实是零成本方案。但我这次没有在封装层用装饰器,原因很实际。
React Native 跑在 OpenHarmony 上,工程侧需要经过多级编译和转译,装饰器语法在不同版本编译器下的处理策略不完全一致。为了一个状态管理封装去约束团队的编译链配置,属于给自己挖坑。更关键的是,装饰器对异步 action 的语义表达并不清晰:@action装饰一个 async 方法时,只有第一个 await 之前的代码处于事务性 action 上下文中,await 之后恢复执行的代码并不算 action,必须靠runInAction手动补齐。如果把这个理解偏差带到团队里,后续踩坑会非常隐蔽。
所以我选择了高阶函数包装,即createAsyncAction(fn, options)这种形式。类字段配合箭头函数直接在类内部声明,既不需要绑定 this,也不用元编程,读起来一目了然。
2.3 对外 API 设计成什么样
封装器的核心是两个函数:createAction和createAsyncAction。前者处理同步状态修改,后者处理异步流程。两者都接收两个参数:第一个是业务函数,第二个是可选配置项。配置项统一设计如下表:
| 配置项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
name | string | 函数名 | 设置 action 名称,用于日志和行为追踪 |
onStart | (args) => void | 无 | action 开始时回调,常用来打开 loading |
onSuccess | (result, args) => void | 无 | 成功结束后的回调 |
onError | (error, args) => void | 无 | 异常时回调,常用来上报监控平台 |
onFinal | (args) => void | 无 | 无论成败都会执行,常用来关闭 loading |
isLoadingKey | string | 无 | 指定 store 上哪个布尔字段用于自动切 loading |
errorKey | string | 无 | 指定 store 上哪个字段用于自动记录错误信息 |
disabledDuringPending | boolean | false | 在同一个 action 未结束前拒绝重复执行 |
snapshotState | boolean | false | 执行前保存状态快照,出错时可回滚 |
这两个函数返回的是一个与原始函数同签名的函数,业务代码调用时的书写习惯完全不用变。后面我会给出完整的实现代码。
3. 核心实现拆解:一个可用的 action 封装器
3.1 最小的同步封装器,先把壳搭起来
先写一个最精简版本,把 action 命名、this 绑定和基础生命周期处理搞清楚。
import { action } from 'mobx'; interface SyncActionOptions { name?: string; onStart?: (args: unknown[]) => void; onError?: (error: Error, args: unknown[]) => void; onSuccess?: (result: unknown, args: unknown[]) => void; } export function createAction<P extends unknown[], R>( fn: (...args: P) => R, options: SyncActionOptions = {} ): (...args: P) => R { const actionName = options.name || fn.name || 'anonymousAction'; const wrappedFn = action(actionName, (...args: P): R => { options.onStart?.(args); try { const result = fn(...args); options.onSuccess?.(result, args); return result; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); options.onError?.(error, args); throw error; } }); return function (this: unknown, ...args: P): R { return (wrappedFn as (...a: P) => R).apply(this, args); }; }这段代码有四个关键点,我逐一说明。
第一,mobx的action函数本身可以接收两个参数:第一个是 action 名称,第二个是要包裹的函数。这里的命名必须显式设置,否则 MobX 默认为空名,排查时满屏 anonymousAction,谁看了都头痛。
第二,回调函数通过apply传递 this。因为 class 字段箭头函数本身不依赖动态 this,但非箭头函数或对象中的普通方法仍可能需要读自身实例字段,所以保留 this 传递能力是必要的。
第三,同步版本里try / catch包裹了原始函数的执行。这里没有额外做错误吞掉,而是先触发 onError 回调,再重新抛出,保证异常语义不被吞没。
第四,注意wrappedFn中调用了options.onStart?.,因为options是可选参数,需要兜底为空对象,避免访问空指针。
提示:如果业务函数本身就是箭头函数,return 时 this 不绑定也不会有问题。这样写主要为了兼容对象方法、普通函数风格,是低成本保险,建议保留。
3.2 异步封装器,把 runInAction 的语义搞清楚
同步版解决基础问题,但项目里大量逻辑是异步的。最典型的场景是调用远程接口、调用原生能力,拿到结果后再把数据写进 observable state。这个场景下需要单独写createAsyncAction。
import { action, runInAction } from 'mobx'; interface AsyncActionOptions { name?: string; onStart?: (args: unknown[]) => void; onError?: (error: Error, args: unknown[]) => void; onSuccess?: (result: unknown, args: unknown[]) => void; onFinal?: (args: unknown[]) => void; isLoadingKey?: keyof object & string; errorKey?: keyof object & string; disabledDuringPending?: boolean; } export function createAsyncAction<P extends unknown[], R>( fn: (...args: P) => Promise<R>, options: AsyncActionOptions = {} ): (...args: P) => Promise<R> { const actionName = options.name || fn.name || 'anonymousAsyncAction'; let pending = false; const wrappedFn = action(actionName, async function (this: unknown, ...args: P): Promise<R> { const self = this; if (options.disabledDuringPending && pending) { throw new Error(`Action "${actionName}" is already running.`); } pending = true; // 打开 loading 时必须在 action 里修改状态 if (options.isLoadingKey && this && typeof this === 'object') { try { runInAction(() => { (this as Record<string, boolean>)[options.isLoadingKey as string] = true; }); } catch { // 只做 loading 提示,不因为 loading 设置失败阻断主流程 } } options.onStart?.(args); try { const result = await fn.apply(self, args); options.onSuccess?.(result, args); return result; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); if (options.errorKey && self && typeof self === 'object') { runInAction(() => { (self as Record<string, unknown>)[options.errorKey as string] = error.message; }); } options.onError?.(error, args); throw error; } finally { pending = false; if (options.isLoadingKey && self && typeof self === 'object') { runInAction(() => { (self as Record<string, boolean>)[options.isLoadingKey as string] = false; }); } options.onFinal?.(args); } }); return function (this: unknown, ...args: P): Promise<R> { return wrappedFn.apply(this, args); }; }这段实现里最重要的是理解 MobX 6 下异步 action 的语义边界。action包裹一个 async 函数时,只有从函数开始执行到第一个await之前这段代码处于“事务性 action”上下文中。await之后的代码恢复运行时,执行上下文已经不属于之前的 action,此时修改 observable 状态需要重新用runInAction包裹。
这也是为什么我在finally里关 loading 时不敢直接写this.isLoading = false,而是必须包在runInAction里。代码里可能有人会疑惑:fn.apply(self, args)内部如果也修改了 store,那部分算不算 action?答案是:要看业务函数内部是否显式包了 runInAction,如果没包,严格模式下会直接报错。封装器负责的是外层生命周期状态的修改,业务函数自己的状态提交仍需自觉在 runInAction 或嵌套 action 里完成。
3.3 防重入与 loading 状态自动管理
表格里有个disabledDuringPending配置,这个选项在移动端实际场景中非常重要。用户快速点击“提交”按钮两次,或者列表下拉刷新和上滑加载并发,很容易让同一个异步 action 同时执行多次,导致接口重复请求、数据被覆盖。
封装器用一个闭包变量pending来维护执行状态。当disabledDuringPending为 true 时,如果上一次调用尚未结束,新的调用会直接抛错。但这里有一个需要想清楚的问题:这个“抛错”是面向开发的,还是面向用户的?我的做法是:在内层函数里抛错,业务层捕获后可以选择静默。UI 上通常已经由 loading 状态遮挡了按钮,第二次点击根本到不了 action 层,属于双保险。
loading 状态我设计成通过isLoadingKey配置自动绑定。给 store 传入一个字段名,比如fetching,封装器就会在 action 开始时把fetching设为 true,结束后设为 false。这一项省掉了每个业务 action 里重复写状态开关的样板代码,也和 MobX 的响应机制天然契合——loading 一旦变化,依赖它的组件自动重新渲染。
3.4 快照回滚,把“状态中间态”的伤害降到最低
异步流程最怕的情况是:请求失败了,但是部分状态已经被修改,页面停留在一个错误的中间态。要处理这个问题,我加了snapshotState选项。
原理很简单:在执行 action 之前,先保存 store 当前状态的快照。如果执行过程中出现异常,且配置了错误恢复,就利用快照把状态恢复到执行前。代码片段如下:
function createSnapshot<T extends object>(source: T): T { // 生产环境建议用更可靠的结构化克隆方案, // 演示场景先用 JSON 方式,注意它无法处理 Date、Map、循环引用。 return JSON.parse(JSON.stringify(source)) as T; }在createAsyncAction内部,如果options.snapshotState为 true,且检测到函数运行时的 this 是一个对象,就在 onStart 阶段保存快照,catch 阶段做恢复:
let snapshot: unknown; const enableSnapshot = options.snapshotState && !!this && typeof this === 'object'; if (enableSnapshot) { snapshot = createSnapshot(this); } try { // 执行过程 } catch (e) { if (enableSnapshot) { runInAction(() => { Object.assign(this, snapshot); }); } throw e; }快照方案不是全场景通用的。如果你的 store 里放的是大列表,每次 action 前都做全量深拷贝,性能和内存占用都很感人。我实际用的策略是:只有涉及关键状态流的 action 才开启snapshotState,普通查询接口不开。它解决的是“绝对不能接受中间状态”的那类业务,比如跨端账户状态同步、本地数据库迁移。
4. 在业务 Store 里怎么落地这套封装
4.1 一个跨端任务 List 的完整示例
拿一个实际业务场景举例:任务列表模块,需要支持加载、完成任务、切换筛选条件。用封装器改造后,store 可以写成这样。
import { makeAutoObservable, runInAction } from 'mobx'; import { createAsyncAction } from '../core/actionWrapper'; class TaskStore { tasks: Task[] = []; filter: 'all' | 'done' = 'all'; loading = false; errorMap: Record<string, string> = {}; constructor() { makeAutoObservable(this); } setFilter = (next: 'all' | 'done') => { this.filter = next; }; loadTasks = createAsyncAction( async (force: boolean) => { const data = await fetchTaskList({ force }); runInAction(() => { this.tasks = data; }); }, { name: 'loadTasks', isLoadingKey: 'loading', errorKey: 'loadError', disabledDuringPending: true, onError: (e) => { console.warn('loadTasks failed', e); } } ); completeTask = createAsyncAction( async (taskId: string) => { await completeTaskRequest(taskId); runInAction(() => { const target = this.tasks.find((t) => t.id === taskId); if (target) { target.completed = true; } }); }, { name: 'completeTask', isLoadingKey: 'loading', disabledDuringPending: true } ); }在这段代码里,makeAutoObservable已经自动把类里的箭头函数推断为 action。但注意:loadTasks和completeTask本身是由createAsyncAction返回的普通函数,makeAutoObservable看到它们时不会强制把它们当成 observable 状态。这样处理反而是对的:封装器内部已经用 MobX 原生action完成了命名和事务化,不需要再做一层推断。
4.2 页面生命周期联动,解决后台回前台状态不同步
OpenHarmony 侧应用从后台切回前台时,JS 侧组件通常不会重新挂载,因此之前 store 里的数据可能是过期的。常规做法是在每个页面的生命周期回调里手动重新请求,但页面一多代码就成了重复劳动。
我用封装器顺手做了一个生命周期联动 HOC:
export function withPageRefresh<T extends object>( store: T, refreshAction: () => Promise<void>, onPageShow?: () => void ) { return function connectPage(Component: React.ComponentType) { return class PageLifecycleWrapper extends React.Component { componentDidMount() { if (onPageShow) { onPageShow(); } } onShowFromBackground = () => { refreshAction(); }; render() { // 通过 provider/injection 把 onShowFromBackground 注入到页面, // OpenHarmony 侧可以在必要的位置调用它。 return <Component {...this.props} onPageShow={this.onShowFromBackground} />; } }; }; }这个 HOC 本身并不依赖具体平台,只约定注入onPageShow回调。在原生侧把后台回前台的信号映射到这个回调上,这样“页面重新可见 -> 自动刷新数据”的过程被统一收口到了 action 层。即便不开disabledDuringPending,因为自动刷新发生在固定的生命周期节点,也很少出现并发冲突。
4.3 与持久化层的搭配,写盘操作不要每个 action 都做
状态管理到持久化,最容易犯的错误是把每次 action 执行都变成一次全量写盘。我把持久化设计成可插拔的中间件,借助onSuccess回调触发保存,并且加了简单的节流。
const persist = throttle((raw: unknown) => { // 这里写实际的存储逻辑,按 key 粒度拆分 nativeStorage.setItem('taskStore', JSON.stringify(raw)); }, 1000); export const taskStore = new TaskStore(); export function persistTrigger() { persist(taskStore); }持久化挂在成功回调后面,数据以“只读派生”的身份落到磁盘,不反过来影响响应式状态流。这一点在跨端场景尤其重要:OpenHarmony 侧的原生存储写入通常走的不是 JS 线程,如果让 UI 更新等持久化结果,会有明显卡顿。
5. 踩坑记录:五个最容易翻车的地方
5.1 一览表
| 现象 | 根因 | 排查思路 |
|---|---|---|
| 严格模式下报错 “Since strict-mode is enabled, changing observable values outside actions is not allowed” | 异步回调里直接修改 observable state,未包 runInAction | 检查所有 await 之后的赋值语句,用 runInAction 包裹 |
| action 事件在 MobX devtools 里名字全是 anonymous | 没有用 action 命名参数,或者被 makeAutoObservable 自动推断后覆盖 | 显式传入 name |
| 执行封装后的 action,this 指向 undefined | 直接用解构出来的方法调用,丢失了 store 实例绑定 | 用类字段箭头函数声明,或用 bind / apply 显式传入 store |
| loading 状态一直卡在 true | 某个异步 action 中途抛错未走完 finally,或者 finally 里修改 loading 没有包 runInAction | 检查 finally 里的状态修改,确认所有分支都会走到 finally |
| 同一 action 被并发调用,出现重复请求和数据覆盖 | 缺少防重入机制 | 开启 disabledDuringPending,或在 UI 层用 loading 控制按钮禁用 |
5.2 几个值得展开说明的现场
第一个现场是“MobX strict mode 不生效”。我排查时发现,有人在configure({ enforceActions: 'never' })之后,全局关了严格模式,然后所有 action 内部也都是裸赋值,想改哪就改哪。表面上没有人再报错,但批量更新、可追踪性全部丢失。项目到后期根本没人能说清某个状态在哪一行被改的。这里建议是:严格模式按开发环境开启、按需关掉个别不需要的 store,不要全局禁用它。
第二个现场是“事件循环错位”。OpenHarmony 侧原生回调的线程和 JS 侧的 MobX 响应式系统不共享同一个事件上下文,导致在原生回调里直接改 observable 时,React 组件侧完全感知不到变化。这个问题前两版一直以为是组件订阅写错了,后来在 action 封装层统一用 runInAction 接管所有原生回调写 store 的入口,才彻底稳定。这个教训说明:跨端项目里,状态变更必须收敛到唯一入口,不能依赖“恰好所在的上下文正确”。
第三个现场是“防重入误伤正常场景”。我最初给所有 action 默认开启disabledDuringPending,结果有一个页面需要支持“切换筛选条件时立即取消上一个请求并发起新请求”,结果新请求一直被 pending 标记挡住。后来我把配置改成默认不开启,只在明确要防止重复提交的 action 上手动开启,或者通过取消旧请求来允许新请求进入,问题就没有了。
6. 封装层还能往哪些方向扩展
6.1 完整的撤销与重做
利用快照机制可以继续扩展出撤销重做栈。每次 action 执行前把快照压入栈,撤销操作 pop 出上一个快照,再以普通 action 的方式交还给 store。注意栈的大小要限制在可接受内存范围内,并且快照要考虑结构克隆的代价。
6.2 与组件层的加载态自动映射
目前 loading 状态只是绑定在 store 字段上,组件仍需要自己读取。如果团队有统一的 Hooks 封装,可以更进一步:根据isLoadingKey自动生成useLoading('xxx')的 Hook,把 loading 状态直接从 store 映射到组件 props。封装器侧只需要把 loading 字段改成observable的 Map,用 action 名做 key,就能很容易实现。
6.3 action 调用时间线回放
我在封装器里给每个 action 都打了 name,这带来一个额外好处:可以在 onStart 和 onFinal 时把调用栈、参数摘要、耗时写入一个环形日志结构。出问题时不需要打断点,直接从日志里回放调用时间线。线上环境可以只记录 name 和耗时,不上报业务参数,兼顾隐私。
6.4 和开发调试面板结合
如果项目接入了远程调试或性能监控面板,action 封装层可以成为一个标准的探针点。所有 action 的执行耗时、成功率和异常信息都能在这个探针点统一采集。比在业务代码里手动埋点强太多,业务侧也不用关心监控 SDK 的版本迭代。
我个人在实际操作中的体会是:MobX 本身不复杂,但在 RN 与 OpenHarmony 这种双运行时、跨线程场景下,状态变更的边界比想象中模糊得多。action 封装层的价值不在于多炫酷的技巧,而是给团队划定了一条明确规则——“所有状态变更从这里进出”。配合名字、loading、错误处理和防重入,几乎可以消灭掉大半的状态管理隐性 bug。最后再分享一个小技巧:封装层的代码单独放在一个 core 目录,不掺任何业务逻辑,这样不管是 React Native 还是未来的新框架适配,这套规则都能原样迁移复用。