MobX action封装实战:解决RN鸿蒙化跨端状态管理痛点 先交代一下背景。我最近在做项目里的 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。前者处理同步状态修改后者处理异步流程。两者都接收两个参数第一个是业务函数第二个是可选配置项。配置项统一设计如下表配置项类型默认值作用namestring函数名设置 action 名称用于日志和行为追踪onStart(args) void无action 开始时回调常用来打开 loadingonSuccess(result, args) void无成功结束后的回调onError(error, args) void无异常时回调常用来上报监控平台onFinal(args) void无无论成败都会执行常用来关闭 loadingisLoadingKeystring无指定 store 上哪个布尔字段用于自动切 loadingerrorKeystring无指定 store 上哪个字段用于自动记录错误信息disabledDuringPendingbooleanfalse在同一个 action 未结束前拒绝重复执行snapshotStatebooleanfalse执行前保存状态快照出错时可回滚这两个函数返回的是一个与原始函数同签名的函数业务代码调用时的书写习惯完全不用变。后面我会给出完整的实现代码。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 createActionP 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 createAsyncActionP extends unknown[], R( fn: (...args: P) PromiseR, options: AsyncActionOptions {} ): (...args: P) PromiseR { const actionName options.name || fn.name || anonymousAsyncAction; let pending false; const wrappedFn action(actionName, async function (this: unknown, ...args: P): PromiseR { 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 Recordstring, 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 Recordstring, 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 Recordstring, boolean)[options.isLoadingKey as string] false; }); } options.onFinal?.(args); } }); return function (this: unknown, ...args: P): PromiseR { 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 createSnapshotT 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: Recordstring, 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 里的数据可能是过期的。常规做法是在每个页面的生命周期回调里手动重新请求但页面一多代码就成了重复劳动。我用封装器顺手做了一个生命周期联动 HOCexport function withPageRefreshT extends object( store: T, refreshAction: () Promisevoid, 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执行封装后的 actionthis 指向 undefined直接用解构出来的方法调用丢失了 store 实例绑定用类字段箭头函数声明或用 bind / apply 显式传入 storeloading 状态一直卡在 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 还是未来的新框架适配这套规则都能原样迁移复用。