ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenHarmony上RN应用的MobX action封装与架构约束

OpenHarmony上RN应用的MobX action封装与架构约束

1. 为什么在 OpenHarmony 上做 RN 开发,还值得把 MobX 的 action 封装当成架构约束

把 MobX 接进跑在 OpenHarmony 上的 React Native 应用,初期一定会遇到同一个烦恼:store 里的状态到处被人直接改,谁改的、什么时候改的,根本查不出来。我在某个基于 RN 适配层的项目里前期图省事,所有组件都只管store.count += 1、store.status = 'loading',结果功能叠加到一半,页面状态就乱成团。后来把 MobX 的 action 封装真正当作架构约束来设计,整个状态层的可维护性才立起来。这篇文章把我在 React Native + OpenHarmony 环境下梳理出来的 action 封装思路完整写下来,包括为什么必须封装、异步 flow 怎么处理、跨 store 动作怎么组织,以及真机验证时踩到的几个坑。

1.1 状态失序是怎么一步步发生的

RN 应用接进 OpenHarmony 之后,最先让团队头疼的往往不是这套框架本身,而是整个 JS 侧的状态管理。

最初为了让页面尽快跑起来,store 会写成最朴素的样子:

import { observable } from 'mobx' export const searchStore = observable({ keyword: '', results: [], status: 'idle', })

组件里想改就改:

store.keyword = text store.status = 'loading' // ...请求回来后 store.results = data store.status = 'success'

这代码能在模拟器上跑,也能在 OpenHarmony 真机上跑,甚至前两周"体验良好"。但功能一多就露馅:A 页面在请求前把 status 改成 loading,B 组件在某个监听里又把 status 改回 idle;登录态、消息未读数、列表刷新状态互相踩踏。翻遍 git 也说不清一行store.status = 'xxx'当初是谁为了什么加的。这就是 MobX 自由度带来的必然结果——它不强迫你在任何地方收敛变更,所有 mutable 操作都是"合法"的,直到出问题那天。

1.2 action 封装解决的三个核心问题

第一个是可追踪性。状态变更全部收敛到 action 方法里以后,出问题看调用链就行:哪个页面调了logout、哪个流程触发了search,一眼就能定位。散改状态的版本里,你面对的是一个到处都可能改状态的黑洞。

第二个是更新性能。MobX 的 action 本身就是一个事务边界,执行期间发生的所有 observable 修改会被收集起来,等 action 结束统一派发通知。组件和 reaction 只需要响应一次变化。如果你在外面逐条写store.a = 1; store.b = 2; store.c = 3,MobX 会按每次赋值触发一轮反应。放到 OpenHarmony 的适配层里,每轮反应都可能导致 JS 到桥接、再到原生 ArkUI 组件的更新消息,无谓的更新多了,最终表现就是掉帧和列表闪烁。action 一次收拢,把多轮传播压缩成一轮,这是实打实的性能收益。

第三个是 API 表面收敛。组件看到的不再是一堆内部字段加一堆 setter,而是search(keyword)、logout()这样的业务动作。组件不关心 store 内部用哪几个字段存状态,也不需要知道更新顺序。后期重构内部实现的时候,组件代码可以一点都不动。

1.3 这和 Redux 的 reducer 有什么本质区别

Redux 用单一 reducer 中心化约束所有变更,MobX 则愿意把约束散到各个 store 的 action 方法里。在 RN + OpenHarmony 这种"JS → 桥接 → 原生渲染"链路比较长的场景,store 的 action 方法可以天然贴近业务对象:一个订单 store 的submit()就完整描述一笔下单动作,中间改哪些字段都是由 store 自己掌握的。对比 reducer 那种所有状态都过一道统一函数的方式,MobX 的封装在阅读成本和组织成本上都会低一些,尤其适合团队不大、业务模块相对独立的中小型项目。

2. 跑通之前的硬条件:JS 运行时能力、Proxy 与编译管线

2.1 MobX 到底跑在哪一层

先说清一件事:OpenHarmony 上的 RN 应用,界面最终由 ArkUI 那一套原生组件绘制,但你的 MobX store 只会活在 RN 的 JS 运行时里。

适配层做的事情,本质上是把 RN 的渲染指令翻译成 OpenHarmony 能理解的原生组件树。store 里的数据在 JS 侧发生变化,observer 组件重新 render,RN 算出新的属性,通过桥接消息传给原生侧更新。所以 MobX 本身不接触 ArkUI,它的 observable / computed / action 机制跟你在 Android 或 Web 上跑没有区别。真正被影响的是"变更传播的代价":每多一次不必要的 reaction,就多一轮 JS → 桥接 → 原生渲染的链路。这也是我前面强调 action 批量更新的原因,它在普通 Web 项目里是优化习惯,在这里直接关系到页面流畅度。

2.2 环境检查清单

接入阶段先做一轮能力检查,比后面踩坑再回头漫无目的排查高效得多。

检查项对 MobX 的影响处理方式
ES6 ProxyMobX 6 默认依赖 Proxy 实现 observable检查运行时是否支持;不支持用useProxies: 'never'降级
Generatorflow 异步场景需要确认构建产物包含 regenerator 运行时
async / awaitrunInAction 方案需要确认 Promise 和 async 转换正常
Decorator@action/@observable是否可编译优先用 makeObservable / makeAutoObservable

2.3 Proxy 支持这一条为什么要单独查

MobX 6 是基于 ES6 Proxy 实现观察的,绝大多数现代 JS 运行时都没问题,但 OpenHarmony 上不同版本的适配层所带的 JS 运行时新旧不一。我遇到过一台老固件测试机,store 正常实例化,但修改数组后页面死活不刷新,后来定位到就是 Proxy 行为不完全导致的。排查办法很简单,在入口文件里打印一段探测代码:

const probe = new Proxy({ a: 1 }, { get(target, key: string) { return target[key] }, }) console.log('[runtime check] proxy ok:', probe.a === 1)

如果输出false,初始化 MobX 时降级:

import { configure } from 'mobx' configure({ useProxies: 'never', })

但要记住降级有限制:动态往对象上新增可观察属性会失灵,数组的某些追踪行为也会受到限制。新项目、新固件不用管它;老设备的项目,建议一开始就在文档里写明运行时版本,并把 MobX 版本一并锁死。

2.4 编译管线:装饰器可能是第一个拦路虎

RN 默认的 Metro 管线对装饰器的支持依赖 Babel 插件的配置。你在 TypeScript 里写@action很顺眼,但编译之后能不能被正确处理,取决于@babel/plugin-proposal-decorators的 legacy 模式开启以及和 class-properties 插件的先后顺序。OpenHarmony 适配层如果自定义了打包配置,问题更容易暴露。

我的建议是,在 RN + OpenHarmony 场景不要依赖装饰器语法。MobX 6 本来就把 makeObservable / makeAutoObservable 作为一等公民,写法上稍微啰嗦一点,但能避开一整套 Babel 版本兼容问题。后面所有示例都按这个写法来。

3. 第一层封装:把“到处改 state”收敛成 Store 方法

3.1 严格模式的开关要放在一切实例化之前

封装的第一步不是写 action,而是先把约束打开。

import { configure } from 'mobx' configure({ enforceActions: 'always', })

MobX 提供两种严格程度:

  • enforceActions: 'observed':只约束"正在被别人读取的 observable"。它的好处是迁移友好,已有大量散改代码的项目可以先开这个模式,渐进式收敛。
  • enforceActions: 'always':所有可观察状态都必须在 action 中修改,任何地方漏改直接报错提醒。

新项目我建议直接上'always',早痛早治。具体放的位置也有讲究:要放到入口文件最前面,在任何 store 实例被创建之前。如果代码里有人在模块顶层就创建了 store 实例,配置要挪到最优先被加载的模块里,否则模块加载顺序会把你的严格要求"绕过去"。

提示:严格模式只影响开发期和调试期的代码质量,它不会明显拖慢生产运行。但正因为这个,很多人会觉得"不加也行",等到问题积累到不可收拾才开始后悔。

3.2 一个最小可用的 action store

用 makeAutoObservable 把类变成自带约束的状态容器:

import { makeAutoObservable } from 'mobx' export class CounterStore { count = 0 loading = false constructor() { makeAutoObservable(this) } increase(step = 1) { this.count += step } setLoading(flag: boolean) { this.loading = flag } reset() { this.count = 0 this.loading = false } }

makeAutoObservable 的推断规则很直接:非静态属性变成 observable,方法变成 action,getter 变成 computed。组件那边从此只能这样写:

store.increase() store.reset()

谁想再写store.count += 1,严格模式立刻就会在控制台给你脸色看。

这里有一个容易忽略的细节:如果构造器里传入了一些服务对象,记得要把它们排除出观察范围。

export class CounterStore { count = 0 constructor(private api: CounterApi) { makeAutoObservable(this, { api: false, }) } }

api这类服务引用不需要被 MobX 观察,观察一个普通服务对象除了浪费性能,还可能造成一些浅层代理把引用关系搞复杂。凡是"工具性质"的依赖,全部显式标成false。

3.3 action 的粒度由业务动作决定,不是由字段决定

初学者容易把 action 理解成"每个字段配一个 setter"。那确实合规,但价值不大。真正有价值的是把"业务动作"封装成一个方法,而不是让页面在外部自己按顺序调用 setKeyword、setLoading、setResults 三个方法。

以搜索为例,业务上一个动作应该是"写入关键字 + 进入加载态 + 发请求 + 写结果 + 切换状态"整条链路。页面只需要调用一个store.search(text)。封装之后,页面不知道内部字段,也不关心更新顺序,store 内部怎么改都可以随时调整。这种封装方式才是 action 真正要解决的问题——把"行为"暴露给外部,把"细节"关在门内。

4. 异步动作是封装成败的分水岭:runInAction 与 flow 的选择

4.1 为什么 action 在 await 之后会“断电”

MobX 的 action 包裹的是同步执行的代码段。async 方法里await之后的代码跑在微任务里,已经不在 action 的包裹范围内。严格模式下,你在 await 之后直接改 observable,控制台会出现类似下面的报错:

[MobX] Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed. Tried to modify: SearchStore@...status

第一次遇到会非常懵:明明整个方法都写在 store 里,也都是方法内部修改,怎么就违反了约束。原因就在于 action 的"作用域"在第一个 await 处就停了。这是异步封装里最容易踩的坑。

4.2 方案一:runInAction 小范围补包装

async / await 写法最贴近直觉,只需要把 await 之后的修改包进 runInAction:

import { makeAutoObservable, runInAction } from 'mobx' export class SearchStore { keyword = '' results: Item[] = [] status: 'idle' | 'loading' | 'success' | 'fail' = 'idle' error = '' constructor(private api: SearchApi) { makeAutoObservable(this, { api: false, }) } async search(keyword: string) { this.keyword = keyword this.status = 'loading' this.error = '' try { const items = await this.api.request(keyword) runInAction(() => { this.results = items this.status = 'success' }) } catch (e) { runInAction(() => { this.error = e instanceof Error ? e.message : String(e) this.status = 'fail' }) } } }

调用search后,一开始同步执行的 keyword 和 loading 修改自动处于 action 中。await 之后的两个分支用 runInAction 重新包起来就好。简单场景这个方案够用,写起来直白。

4.3 方案二(推荐):flow 生成器

如果异步流程比较复杂,比如"先拉 A,再并发拉 B 和 C,再根据结果改多个字段",runInAction 会散落多处,可读性很差。这种时候用 flow 更合适:

import { flow, makeObservable, observable } from 'mobx' export class SearchStore { keyword = '' results: Item[] = [] status: 'idle' | 'loading' | 'success' | 'fail' = 'idle' error = '' constructor(private api: SearchApi) { makeObservable(this, { keyword: observable, results: observable, status: observable, error: observable, search: flow, }) } *search(keyword: string): Generator<Promise<Item[]>, void, Item[]> { this.keyword = keyword this.status = 'loading' this.error = '' try { const items = yield this.api.request(keyword) this.results = items this.status = 'success' } catch (e) { this.error = e instanceof Error ? e.message : String(e) this.status = 'fail' } } }

flow 的机制:它在 generator 的每个 yield 之间自动把代码包在 action 事务里,等于把"整个异步业务流程"都一直置于 action 环境中。所以 yield 之后改 state 不需要再手动写 runInAction,整段代码都是天然的 action 上下文。

调用方式跟普通方法没区别:

void store.search('MobX')

不用管返回值的话,推荐加个void前缀,显式表达不需要这个 Promise,也能避免 TS 里常见的 unused promise 提醒。

4.4 两种方案怎么选

给你一张对照表,做决定的时候直接对号入座:

维度runInActionflow
适用场景异步函数里零散几处收尾修改完整异步业务流程需要整体包进 action
可读性时序自然,但每处都要手动包生成器写法需要先适应
错误处理try / catch 外还得包 runInActiontry / catch 直接写在 action 环境里
TS 类型简单直观生成器返回类型需要多写一点
运行时要求需要 async / await 支持额外需要 generator / regenerator 支持

我的倾向是:两三个字段的收尾用 runInAction;一条完整的业务流,比如"登录 → 拉资料 → 初始化画布"这种多段串行,用 flow。团队不熟悉 generator 可以先用 runInAction,一旦流程变长再迁移到 flow 也不难。

4.5 flow 在真机上可能遇到的运行时问题

生成器语法在老一点的 JS 运行时里不一定原生支持。如果打包后在真机上运行报regeneratorRuntime is not defined,说明构建产物里用到了 regenerator 运行时辅助代码,但运行环境里没有正确注入。

处理方式:在入口文件顶部手动引入regenerator-runtime并固定版本,同时确认 Babel preset-env 没有把 generator 转换得不伦不类。这个坑在 OpenHarmony 真机上特别常见,因为模拟器上跑的运行时往往比较新,问题不会暴露,一上真机立刻现形。开发阶段就养成交替做模拟器 / 真机验证的习惯,能省下不少排查时间。

5. 组合与隔离:跨 Store 动作、服务注入和启动还原

5.1 一个业务动作要动多个 store,谁来当发起者

真实业务里很少有单 store 动作。登录、退登、下单都会碰多个 store。这里的原则是:由最高一层的聚合 store 来发起跨 store 动作,子 store 之间不互相调用。

class RootStore { auth: AuthStore cart: CartStore order: OrderStore constructor(services: AppServices) { this.auth = new AuthStore(services.authApi) this.cart = new CartStore() this.order = new OrderStore(services.orderApi, this.cart) } logout = () => { this.auth.clear() this.cart.clearAll() this.order.reset() } }

页面只调rootStore.logout(),不用分别调用三个 store 的方法。好处是调用方永远只知道一个入口,后续加"退登时还要清埋点、断连接"的需求,也只需要改这一处。另一个附带的好处是避免循环依赖:子 store 之间不互相 import,组合关系全部收在 RootStore 这一层。

注意 RootStore 使用 makeAutoObservable 时要显式排除子 store 字段,避免对已经可观察的 store 实例再做一层无意义的包装:

makeAutoObservable(this, { auth: false, cart: false, order: false, })

除非你真的会在运行期整个替换某个子 store 实例,否则标成false就够了。

5.2 服务注入让 action 可测试,也让 OpenHarmony 参数可落地

api 直接 new 在 store 内部,会导致单测困难、跨平台参数难传。推荐构造器注入:

class OrderStore { status: 'idle' | 'loading' | 'success' | 'fail' = 'idle' constructor( private api: OrderApi, private cart: CartStore, ) { makeAutoObservable(this, { api: false, cart: false, }) } *submit() { this.status = 'loading' try { const id = yield this.api.submit(this.cart.getItems()) this.status = 'success' } catch (e) { this.status = 'fail' } } }

然后在 RN 入口拿到 OpenHarmony 宿主传入的初始化参数(比如设备标识、服务端地址、启动 token),统一组装成 services 再传给 RootStore:

function createRootStore(init: AppInitParams): RootStore { const searchApi = new SearchApi({ baseUrl: init.serverUrl }) return new RootStore({ searchApi, authApi: new AuthApi(init.token), }) }

这样宿主侧和 RN 侧的边界就非常清楚:OpenHarmony 只负责把参数递进来,业务动作的编排全部在 store 的 action 里完成。测试时想 mock 掉 API,直接替换构造参数就行,完全不需要碰组件。

5.3 启动还原要当成一个 action

App 冷启动从本地缓存恢复登录态或列表数据,本质也是一个异步动作。请把它写成 flow,不要散散地写在组件的 useEffect 里:

*hydrate() { const session = yield LocalStorage.get('session') if (session) { this.auth.setSession(session) } this.hydrated = true }

hydrate 完成之后再渲染业务页面,根组件可以用store.hydrated控制启动页面。状态还原逻辑如果散在多个组件里,非常容易复现"页面先闪一下空数据,再突然变成有数据"的体验问题,而且排查起来费劲。

提示:hydrated这个字段记得在 makeObservable 里声明为 observable,否则页面对启动态的响应会迟钝,甚至出现闪烁。

6. 真机实测踩到的坑:从报错信息到根因的完整排查链路

6.1 坑一:装饰器报错或静默失效

现象:代码里写了@action setXxx() {},编译时直接报语法错误,或者编译通过但运行时出现@action can only be used in combination with makeObservable/makeAutoObservable之类的提示。

排查链路:先看 Babel 配置里装饰器插件的 legacy 开关;如果是纯 TS 编译链路,看 tsconfig 的experimentalDecorators是否打开;再看打包器有没有对 mobx 所在模块做了特殊 transform。我在一个 OpenHarmony 适配层项目里查了半天,最后发现是自定义 Metro 配置把装饰器插件的顺序放到了 class-properties 之后,类字段先被解析,装饰器信息已经丢了。

规避方式最简单:不用装饰器,全部改 makeObservable / makeAutoObservable。这样打包配置上少一个依赖点,真机调试也少一类玄学问题。

6.2 坑二:strict-mode 报错定位不到人

现象:控制台刷[MobX] Since strict-mode is enabled...,但堆栈里只看到某个 render 或某个 autorun,完全看不出是谁先动的手。

根因:大概率是在 reaction 或 autorun 内部直接改了 observable,在组件 render 里改了 store 字段,或者在某个没被 observer 包裹的普通回调里改了 store。

修复方式:凡是"因为别的状态变化而需要改状态"的地方,统统改成调用 action 方法;如果确实需要在反应里产生新状态,把这段逻辑挪进 store 的 action,并检查是否存在循环依赖。排查时用注释法把 reaction 一个个注释掉排除,比盯着调试器看堆栈更快。

6.3 坑三:regeneratorRuntime is not defined

现象:真机上搜索页点了没反应,控制台报regeneratorRuntime is not defined,模拟器上却一切正常。

根因:模拟器 JS 运行时和真机不一致,真机引擎对 generator 支持不足,而 bundle 中生成的 regenerator 辅助代码没有被正确加载。

排查链路:在入口文件顶部手动引入regenerator-runtime并固定版本;检查构建产物里是否真的包含 regenerator 代码;如果项目里同时存在多个 polyfill,注意引入顺序和重复定义问题。基本三步走完就能解决九成情况。

6.4 坑四:state 变了,组件却不刷新

现象:列表 store 的 items 被 action 正确更新,控制台看状态已经变了,页面上却没反应,重启才恢复。

根因一般不在 MobX,而在桥接层。常见三类诱因:

  1. 组件没有用 observer 包裹;
  2. 把 observable 数组直接传给了 OpenHarmony 原生列表组件,原生侧期望普通数组,数据格式和代理对象的差异导致 diff 判断异常;
  3. 对象属性在原位修改,比如item.name = 'x',而外层组件被 memo 或其他优化机制保护得太严。

修复方式:

// 传给原生组件的 props,用 computed 或渲染时展开成普通数据 get itemList() { return this.items.slice() }

组件要统一写成observer(...);凡是跨桥接层传给原生组件的 props,一律不要直接塞 MobX 的代理对象,先转成普通数组、普通对象。更新对象时尽量走不可变方式,比如this.items = this.items.map(...),让引用变化成为明确的更新信号,桥接层更容易正确响应。

6.5 坑五:热重载后 store 被重复创建

现象:开发时改了几下代码,登录态正常,但列表状态"自己回来了"或者出现多份 store 并存,互不同步。

根因:热重载或宿主重新加载 bundle 时,模块重新执行,RootStore 被再 new 一次,但旧页面实例还持有旧 store。

规避方式是做弱单例:把 store 实例挂到 globalThis 上,缓存带版本号:

const g = globalThis as unknown as { __ROOT_STORE__?: RootStore __APP_VERSION__?: string } export function getRootStore(init: AppInitParams): RootStore { if (g.__ROOT_STORE__ && g.__APP_VERSION__ === APP_VERSION) { return g.__ROOT_STORE__ } const store = createRootStore(init) g.__ROOT_STORE__ = store g.__APP_VERSION__ = APP_VERSION return store }

注意这只是开发体验兜底,上线包还是要以宿主页面的生命周期为准,避免把单例状态带到不该共存的页面里去。

7. 可以直接落地的 store 模板与自查清单

7.1 目录结构

建议的代码组织方式:

src/ api/ # 数据访问层,只负责请求和响应转换 stores/ RootStore.ts AuthStore.ts SearchStore.ts components/ App.tsx # RN 入口

api 层和 stores 层严格分开,action 里只用 api 层的接口,不要在 store 文件里直接写 fetch 或封装的请求实现。这样单测和替换实现都方便。

7.2 通过 Context 注入 store

Provider 加 hook 的写法是现成的:

const RootStoreContext = createContext<RootStore | null>(null) export function StoreProvider({ children, store }: { children: ReactNode store: RootStore }) { return ( <RootStoreContext.Provider value={store}> {children} </RootStoreContext.Provider> ) } export function useRootStore(): RootStore { const store = useContext(RootStoreContext) if (!store) { throw new Error('useRootStore must be used within StoreProvider') } return store }

入口组件用 ref 保证只创建一次:

export default function App({ initParams }: { initParams: AppInitParams }) { const storeRef = useRef<RootStore | null>(null) if (!storeRef.current) { storeRef.current = getRootStore(initParams) } return ( <StoreProvider store={storeRef.current}> <SearchScreen /> </StoreProvider> ) }

业务页面像这样消费 store:

const SearchScreen = observer(() => { const root = useRootStore() const store = root.searchStore const [keyword, setKeyword] = useState('') const handleSearch = () => { if (keyword.trim()) { void store.search(keyword.trim()) } } return ( <View> <TextInput value={keyword} onChangeText={setKeyword} placeholder="输入关键字" /> <Button onPress={handleSearch} title="搜索" /> {store.status === 'loading' && <Text>加载中...</Text>} {store.status === 'fail' && <Text style={{ color: 'red' }}>{store.error}</Text>} {store.status === 'success' && store.results.map(item => <Text key={item.id}>{item.name}</Text>)} </View> ) })

页面只做两件事:读 store 暴露的状态,调用 store 暴露的 action,中间不越权改任何字段。

7.3 自查清单

每次写完一个 store,对照检查一遍,基本不会欠债:

  • 入口最先配置configure({ enforceActions: 'always' })
  • 所有 observable 字段只被 action 方法修改
  • 异步逻辑统一使用 flow 或 runInAction,不存在裸奔修改
  • 传给 OpenHarmony 原生组件的 props 是普通数据,不是 MobX 代理对象
  • 服务依赖在构造器注入,并在 makeObservable 里标成false
  • 组件只读 getter / computed,只调 action 方法
  • 跨 store 动作统一由 RootStore 编排,子 store 之间不互相调用
  • 真机回归时重点跑一遍 flow、generator 和列表刷新场景

7.4 一点个人经验

做完这套封装之后,再回头看那个"到处散改"的版本,最大的差异不是代码量,而是查问题的速度。以前定位一个状态异常,要翻六七处文件,现在第一反应就是去对应 store 的 action 方法里找。MobX 给的自由度很大,但自由要建立在约束上。RN 在 OpenHarmony 上多了一条桥接链路,状态变更越收敛,这条链路就越稳。后来我把 action 封装规则直接写进了团队规范,新页面从第一天就按这个写,状态失序的问题基本没有再出现过。如果让我给一个最直接的落地建议,那就是:先打开严格模式,再从第一个异步方法开始用 flow,其余问题都会自然而然被这套约束逼到正确的位置上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进