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 改成 loadingB 组件在某个监听里又把 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 3MobX 会按每次赋值触发一轮反应。放到 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 组件重新 renderRN 算出新的属性通过桥接消息传给原生侧更新。所以 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 转换正常Decoratoraction/observable是否可编译优先用 makeObservable / makeAutoObservable2.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方法变成 actiongetter 变成 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 之后的修改包进 runInActionimport { 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): GeneratorPromiseItem[], 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 再传给 RootStorefunction 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 启动还原要当成一个 actionApp 冷启动从本地缓存恢复登录态或列表数据本质也是一个异步动作。请把它写成 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而在桥接层。常见三类诱因组件没有用 observer 包裹把 observable 数组直接传给了 OpenHarmony 原生列表组件原生侧期望普通数组数据格式和代理对象的差异导致 diff 判断异常对象属性在原位修改比如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 注入 storeProvider 加 hook 的写法是现成的const RootStoreContext createContextRootStore | 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 useRefRootStore | null(null) if (!storeRef.current) { storeRef.current getRootStore(initParams) } return ( StoreProvider store{storeRef.current} SearchScreen / /StoreProvider ) }业务页面像这样消费 storeconst 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其余问题都会自然而然被这套约束逼到正确的位置上。