前阵子帮朋友调一个AI对话项目的前端,技术栈是React + TypeScript + MobX。功能本身不复杂,但他的初始化逻辑写得相当奔放:App组件的useEffect里请求配置,对话框组件里又套一个useEffect去加载模型,切换路由还重复拉,用户点发送的时候模型往往还没ready。整个页面loading闪个不停,状态乱成一锅粥。后面我把这些初始化全部收敛到store.ts里,让MobX统一管理,问题才算理顺。今天就把“状态管理mobx,store.ts调用初始化接口”这件事从头到尾讲清楚,包括为什么这么设计、代码怎么写、模型这种重资源如何只初始化一次,以及我踩过的一些坑。
这篇内容适合正在用MobX但总觉得状态写得很散的人,也适合想做AI对话工具、模型加载、CLI包装这一类场景的前端开发者。你会看到一个从基础到工程化的完整路径,直接抄作业也行。
1. 初始化接口为什么要放进store,而不是堆在组件里
1.1 组件内初始化面临的三个现实问题
很多项目的第一版都是这么写的:在App组件里useEffect(() => { fetchInit().then(setData) }, [])。页面少的时候没问题,页面一多,问题就来了。
第一个问题是状态不共享。你在App里拉到的配置,子组件拿不到,要么层层透传props,要么塞进Context。等组件层级深了,props链路长到看完就晕,Context又容易引发不必要的重渲染。第二个问题是接口可能被重复调用。如果每个页面都认为自己需要初始化,那用户每切换一个页面,后端就被打一次初始化接口。而且不同页面拿到的返回可能略有差异,最终界面上显示的配置到底以谁为准?说不清。第三个问题是状态不可观测。初始化到底进行到哪一步了,是pending、成功还是失败,组件里只有local state,完全看不到全局视图,出问题只能靠alert弹窗调试。
你细品,初始化这件事本质上就是“应用的启动状态”,它不是某个组件的私有状态,而是整个前端应用共享的公共状态。公共状态就该放进全局的store,而不是散落在组件里。
1.2 用MobX管初始化流程的底层优势
MobX处理这种场景,优势在于它把“状态”和“视图”分离得非常干净。store里只管数据结构和业务动作,组件通过observer订阅自己关心的状态,状态一变,用到它的组件自动更新,没用到的组件完全不受影响。这和Redux那套“全局状态树 + dispatch + selector”的写法相比,心智负担小很多,尤其在中小型项目里维护成本很低。
把初始化接口放到store里之后,核心价值体现在三个层面。第一,初始化状态全局唯一。无论哪个组件触发store.init(),拿到的都是同一个pendingPromise,接口绝不会因为页面切换而重复请求。第二,初始化流程可以被“编排”。先拉配置,再加载模型,最后建立会话,这些顺序逻辑写在一个文件里,读代码的人从头看到尾就能理解整个启动链路。第三,初始化的结果可以被任意模块复用。弹窗提示、路由守卫、业务组件、工具函数,都能直接读取store.status或store.config,不需要各自维护一份副本。
我经常跟人打比方:组件就像是公司前台的接待员,他可以替你打电话叫人,但公司通讯录不该放在前台桌上,应该放在行政系统里。初始化接口对应的就是公司通讯录这种基础资源,放store是它的正确归属。
2. 先把MobX的3个核心概念摆清楚
2.1 observable、action、runInAction分别管什么
在写store之前,必须先把MobX三件套搞清楚,否则代码报错你都不知道错在哪。
observable用来定义可观察状态。在MobX 6里,用makeAutoObservable(this)就能把class里的字段自动变成observable,方法自动变成action。这个设计省事多了,MobX 4/5时代还需要手动写@observable @action装饰器,现在基本不用了。
action用来修改状态。MobX的核心约束是:所有状态修改都应当发生在action里。这么设计不是为了折腾你,而是为了让状态变更可追踪、可批量。类似银行柜台,你存取款都得通过柜员窗口,柜员记录在案,系统才能算清楚账。如果你直接从后门翻进金库拿钱,账就对不上了。
runInAction是异步场景下的补救工具。初始化接口调用是典型的异步逻辑:先发请求,await回来之后写状态。问题在于,await之后的代码已经不在action上下文里了,直接修改observable会在严格模式下触发警告甚至报错。解决办法就是把“await之后修改状态”这段逻辑用runInAction包起来,相当于手动告诉MobX:这段修改也是action的一部分。
2.2 一个最容易踩的异步更新误区
我见过不少人写MobX,同步状态改得好好的,一遇到异步就懵。最常见的一种写法是:
const resp = await fetchInitConfig() this.config = resp this.status = 'fulfilled'这段代码在MobX 6非严格模式下可能不报错,但一旦开了严格模式(项目里常用configure({ enforceActions: 'always' })或'observed'),控制台就会飘红:MobX: Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed。
这里补充一个细节:makeAutoObservable会自动把class方法标记为action,但async函数里await之前的部分确实在action里,await之后的部分已经跳出action了。所以正确姿势是:
const resp = await fetchInitConfig() runInAction(() => { this.config = resp this.status = 'fulfilled' })理解了这三个概念,下面写store才不会出幺蛾子。
3. 实操:在store.ts里实现一套健壮的初始化流程
3.1 从“页面打开就要初始化”的需求写起
先定义一个最典型的应用场景:页面打开时拉取全局配置,配置拿到之后才能渲染主界面,配置失败则展示错误和重试按钮。我们直接在appStore.ts里实现。
// stores/appStore.ts import { makeAutoObservable, runInAction } from 'mobx' export interface InitConfig { appName: string version: string featureFlags: Record<string, boolean> } export type InitStatus = 'idle' | 'pending' | 'fulfilled' | 'rejected' export class AppStore { status: InitStatus = 'idle' config: InitConfig | null = null error: Error | null = null private pendingPromise: Promise<InitConfig> | null = null constructor() { makeAutoObservable(this, { pendingPromise: false, }) } init = () => { // 已经初始化过,直接返回已有结果 if (this.config) { return Promise.resolve(this.config) } // 正在初始化,返回同一个 promise,避免重复请求 if (this.pendingPromise) { return this.pendingPromise } this.status = 'pending' this.error = null this.pendingPromise = fetchInitConfig() .then((config) => { runInAction(() => { this.config = config this.status = 'fulfilled' }) return config }) .catch((error: Error) => { runInAction(() => { this.error = error this.status = 'rejected' }) throw error }) return this.pendingPromise } reset = () => { this.status = 'idle' this.config = null this.error = null this.pendingPromise = null } } export const appStore = new AppStore()代码里有两个细节值得逐行说。
第一,makeAutoObservable(this, { pendingPromise: false })。第二个参数里pendingPromise: false表示这个字段不转成observable。它是纯粹的内部实现细节,不需要被视图观察,更不应该被组件读取。如果漏了这个配置,MobX会给它做Proxy包装,性能有损耗,语义也不对。
第二,状态机只有四个值:idle表示从未初始化,pending表示进行中,fulfilled表示成功,rejected表示失败。这四态覆盖了初始化接口的全生命周期。组件可以根据status渲染四种完全不同的UI:正在加载的骨架屏、正常主界面、错误页加重试按钮、以及初始的空状态。
在组件里调用的方式非常简单:
import { appStore } from '@/stores/appStore' import { observer } from 'mobx-react-lite' export const AppRoot = observer(() => { useEffect(() => { appStore.init().catch(() => { // 错误已经被捕获到 store.error 里,这里只需避免 unhandled rejection }) }, []) if (appStore.status === 'pending' || appStore.status === 'idle') { return <LoadingScreen /> } if (appStore.status === 'rejected') { return <ErrorScreen error={appStore.error?.message} onRetry={() => appStore.reset().then(() => appStore.init())} /> } return <MainLayout /> })这里有个容易被忽略的坑:appStore.init()返回的是Promise,如果失败,调用方不接.catch,控制台会出现unhandled promise rejection。所以我在init调用处故意加了一个空的catch,真正的错误处理已经在store内部完成了。
3.2 并发保护与Promise去重:100个组件同时触发也只会请求一次
上面的代码里已经埋了并发保护的第一层:pendingPromise。这个设计非常关键,值得单独展开讲讲。
假设你项目里不止App组件会调初始化,路由守卫、登录模块、侧边栏、甚至某个工具函数都可能调用appStore.init()。如果没有pendingPromise,那么第一个组件触发了请求还没返回,第二个组件又触发一次,两个请求同时发到后端,后端打个日志你会看到初始化接口被连续请求了多次。
加了pendingPromise之后,这种行为被彻底杜绝。第一个调用者创建Promise并缓存到私有字段,第二个调用者进来时发现pendingPromise已经存在,直接返回同一个Promise。所有调用者共享同一个初始化结果。等到请求返回,pendingPromise本来可以置为null,但注意我的代码里没有清空它。因为一旦成功,后续再有人调用init,会先命中if (this.config)的分支,直接返回已有结果;如果失败,调用者会通过reset清空状态再重试。所以pendingPromise保留着反而无害,还能防止在成功边界处的竞态。
这个模式就是常说的单例Promise去重。它的威力在并发场景下特别明显,写单元测试时可以模拟同时调10次init,断言fetchInitConfig只被调用了一次。我建议任何初始化类逻辑都套用这个结构,不光是MobX,其他地方同样适用。
3.3 初始化失败后的重试,不能只靠刷新页面
接口总有失败的时候。要么后端没启动,要么网络抖动,要么token过期。初始化失败的处理,直接影响用户对这个应用的第一印象。
最简单的方案是给用户一个“重新加载”按钮,直接window.location.reload()。这个方法粗暴但有效。不过在单页应用里,更好的方案是提供“仅重试初始化”的按钮,不刷新页面、不做全量重启,只是把store重置到idle再走一遍init流程。这样用户之前的操作状态还能保留一部分,体验更细腻。
上面的reset()方法就是干这个的。它把status、config、error、pendingPromise全部清空,让store回到“从未初始化”的状态。然后再调用init(),相当于给初始化流程一个二次机会。注意reset里必须把pendingPromise也置空,否则上次失败遗留的Promise还会被init命中,永远等不到新请求。
有一点实际经验要提醒:重试前最好把error展示给用户看,而不是只给一个机械的“重试”按钮。用户看到具体原因(比如“后端返回401”)才知道自己该去登录还是该找运维。
4. 进阶实战:把CLI/模型封装成接口后,如何保证不重复初始化
4.1 最容易翻车的模型初始化场景
如果只是拉一个JSON配置,前面那套已经够用了。真实工程里更头疼的是这种场景:你的功能依赖一个重量级的东西——AI模型、本地推理引擎、CLI打包的服务、甚至串口设备。这些东西的初始化特别慢,可能要好几秒甚至几十秒,而且初始化一次就够了,不该每次请求都重新来。
有人会写成这样:
async function sendMessage(text: string) { const client = await createAIClient() // 每次调用都初始化一次 return client.chat(text) }这代码跑起来,第一次发消息要等模型加载,第二次发消息又等模型加载,简直灾难。更离谱的是,前端连续发三个并发请求,模型就被初始化三次,内存直接爆炸。
核心矛盾在于:模型的创建是异步的、昂贵的,而业务请求需要复用同一个实例。MobX在这里的责任不是帮你加载模型,而是帮你“记住”模型初始化是否完成,以及正在初始化的那个Promise是谁。
4.2 延迟加载 + Promise去重,让模型只初始化一次
我把模型初始化这类逻辑拆成两层。第一层是服务层,负责真正的资源和外部工具封装。第二层是store层,负责暴露给组件的状态和动作。
服务层示例:
// services/aiService.ts import { spawnModelProcess, type AIProcess } from './cliBridge' let instance: AIProcess | null = null let loadingPromise: Promise<AIProcess> | null = null export function getAIProcess(): Promise<AIProcess> { // 初始化过就直接返回 if (instance) { return Promise.resolve(instance) } // 正在初始化就复用同一个 Promise if (!loadingPromise) { loadingPromise = createProcess().then((proc) => { instance = proc return proc }) } return loadingPromise } async function createProcess(): Promise<AIProcess> { // CLI 功能在这里被包装成一个标准接口 return spawnModelProcess({ command: 'ai-engine', args: ['--wait-ready'], // ...其他配置 }) }这里的getAIProcess就是热词里说的“把CLI功能包装成一个接口”的落地实现。它对外隐藏了进程如何创建、何时创建、是否已创建这些细节,调用方永远只需要await getAIProcess(),然后拿到的都是同一个进程实例。
store层接着写:
// stores/chatStore.ts import { makeAutoObservable, runInAction } from 'mobx' import { getAIProcess } from '@/services/aiService' import { appStore } from './appStore' interface ChatMessage { id: string role: 'user' | 'assistant' content: string createdAt: number } type SendStatus = 'idle' | 'preparing' | 'sending' | 'failed' export class ChatStore { messages: ChatMessage[] = [] sendStatus: SendStatus = 'idle' error: string | null = null constructor() { makeAutoObservable(this) } async sendMessage(content: string) { if (this.sendStatus === 'preparing' || this.sendStatus === 'sending') { console.warn('消息还在处理中,请勿重复发送') return } this.sendStatus = 'preparing' this.error = null // 插入用户消息,它不属于任何异步回调 this.messages.push({ id: crypto.randomUUID(), role: 'user', content, createdAt: Date.now(), }) try { // 先确保全局配置到位,再确保模型进程到位 await appStore.init() const proc = await getAIProcess() this.sendStatus = 'sending' const reply = await proc.chat(content) runInAction(() => { this.messages.push({ id: crypto.randomUUID(), role: 'assistant', content: reply, createdAt: Date.now(), }) this.sendStatus = 'idle' }) } catch (error) { runInAction(() => { this.error = error instanceof Error ? error.message : String(error) this.sendStatus = 'failed' }) } } retryLast = () => { if (this.error) { this.sendStatus = 'idle' this.error = null } } } export const chatStore = new ChatStore()你注意看sendMessage里这行:await appStore.init()。它保证聊天功能被调用时,全局配置一定已经就绪。如果还未初始化,就走一遍初始化;如果已经初始化好,就立即返回。用户在任何时间点点发送按钮,都不需要关心底层有没有初始化完。这就是“方便调用模型时,如何保证不会每次请求都初始化模型”的标准答案——所有初始化都收敛到单例Promise里,调用方只面对一个异步方法,代价是第一次调用会稍慢,后面全部走缓存。
4.3 对话状态管理怎么和初始化流程串起来
上面这个ChatStore其实就是一套完整的对话状态管理。它管理了消息列表、发送状态、错误信息,并且把初始化逻辑无缝嵌入到链路里。我再补充一些工程里更常见的复杂形态,供你参考。
实际项目里,对话状态往往不止一个数组那么简单。常见的有:当前会话ID、会话历史映射、用户输入草稿、AI回复的流式文本、中断状态、历史消息分页加载状态等。这些如果全部用useState维护,组件能写出一千行。放进MobX store之后,每个状态都是独立的observable字段,组件按需订阅,清晰且高效。
流式回复场景下有一个常见的实践:AI输出是通过事件流一条条吐出来的,需要在MobX里实时更新正在生成的消息内容。这里我建议把“正在生成的消息”单独放在一个字段streamingMessage里,每次收到新片段就用runInAction追加内容。等流结束,再把它push进messages数组并清空streamingMessage。这样做的好处是,中间态和完成态是分开的,组件渲染时不会出现半条消息在列表里跳动的问题。
还有些项目会做一个“输入框禁用状态”的派生值:get canSend() { return this.sendStatus === 'idle' && this.input.trim().length > 0 }。这种派生值MobX是自动计算的,比手写同步逻辑靠谱得多。
5. 常见问题与排查技巧实录
5.1 MobX初始化场景报错速查表
我把实际开发中最常遇见的MobX问题和解决方案整理成一张表,碰到直接对照着看。
| 报错现象 | 根本原因 | 解决方案 |
|---|---|---|
| Since strict-mode is enabled, changing observable values without using an action is not allowed | 异步回调里直接改observable | 用runInAction包裹状态修改 |
| 初始化接口被请求了多次 | 没有用pendingPromise做并发去重 | 在store里缓存初始化Promise |
| 组件明明用了observer,UI却不更新 | 在组件外部解构了observable值,或读取时脱离了reaction上下文 | 确保在render函数里直接访问store.xxx |
| 页面刷新后store状态重置,初始化重跑 | store是模块级单例,刷新即重新执行 | 如果需要持久化,配合localStorage或IndexedDB |
| 重试按钮点了没反应 | reset()没把pendingPromise清空 | reset里把所有状态和Promise都重置 |
| 组件卸载后再挂载,初始化状态丢失 | 初始化写在组件内部而非全局store | 收敛到store,组件只负责调用,不负责存储 |
5.2 集成串口/外部设备时的状态管理边界
聊一下“mobx连不上串口”这类问题。其实MobX本身没有“连接”能力,它是一个状态管理库,不负责和任何硬件或外部进程通信。如果你在调用串口、CLI这类外部资源时发现连不上,问题通常不在MobX,而在职责边界没划清。
正确的分层是这样的:外部设备通信放在service层,MobX store只负责三件事——记录连接状态、缓存初始化Promise、暴露重试动作。service层封装串口或CLI的创建、连接、断开,store层通过调用getDevice()这样的方法拿到设备句柄。如果设备没连上,store里的status应该是rejected,组件渲染错误提示,用户点重试,store再调service层重新连接。
我见过不少失败的例子,都是把设备创建逻辑直接写进action里,或者试图让MobX去管理串口实例。串口是Node层的东西,状态管理库管不了它,强行耦合只会让测试变得极其困难。记住这个边界:MobX管你应用里的状态,service层管外部世界的资源。两者通过接口对接,互不越界。
5.3 线上问题排查流程分享
初始化相关的问题,在本地可能复现不了,线上却偶发。我自己沉淀了一套排查流程,每次都能快速定位。
第一步,打开Network面板,看初始化接口是否出现了重复请求。如果有重复,十有八九是并发去重没做好,检查pendingPromise有没有被意外重置。第二步,在控制台挂一个autorun观察store状态:
import { autorun } from 'mobx' import { appStore } from '@/stores/appStore' autorun(() => { console.log('appStore status:', appStore.status) console.log('appStore config:', appStore.config) })这段代码会在store状态每次变化时自动打印最新值。把它粘贴到控制台调试,能直接看到初始化链路走到哪一步、在哪里卡住。第三步,把初始化流程中所有await的地方都加上错误边界,不要省try/catch。尤其是init()之后还有getAIProcess(),这种链式依赖,任何一个环节抛错,后面全断。加日志能快速定位是哪一环挂了。
最后的一点实战心得
这套方案我自己在不同项目里用过好几轮,最大的感受是:状态管理的复杂度不会消失,但可以被收敛。把初始化放到MobX store之后,组件的职责变得非常单一,就是“根据状态渲染”,而业务的关键流程从组件里抽离成了可读可测的纯逻辑。如果你正准备改造一个状态很乱的旧项目,我建议不要一上来就全家桶式重构,就挑“初始化接口”这一个点先动手,把appStore写好、组件接上、跑通重试和并发场景,感受一下这种模式带来的节奏变化,再决定要不要继续铺开。
一个小技巧送给所有刚接触MobX的朋友:写完store之后先把UI搁一边,在浏览器控制台手动调用store.init()、模拟失败、模拟并发、模拟重试。一次把这些边界场景调稳了,再接组件往往一次就过。