☰
MobX action封装实战:解决RN鸿蒙化跨端状态管理痛点
2026/10/11 3:46:19 网站建设 项目流程

先交代一下背景。我最近在做项目里的 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 开始时回调,常用来打开 loading
onSuccess(result, args) => void无成功结束后的回调
onError(error, args) => void无异常时回调,常用来上报监控平台
onFinal(args) => void无无论成败都会执行,常用来关闭 loading
isLoadingKeystring无指定 store 上哪个布尔字段用于自动切 loading
errorKeystring无指定 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 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 还是未来的新框架适配,这套规则都能原样迁移复用。

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

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

立即咨询