☰
使用 mst-query 管理 MobX-State-Tree 异步数据:查询、归一化、失效与垃圾回收实战指南
2026/10/7 2:34:55 网站建设 项目流程
  • 状态管理
  • 前端

【免费下载链接】mobx-state-tree

Full-featured reactive state management without the boilerplate

项目地址:https://gitcode.com/gh_mirrors/mo/mobx-state-tree
点击查看免费下载

mst-query 是一个专为 MobX-State-Tree(MST)设计的查询库,功能定位类似 react-query,但以一层薄薄的封装运行在 MST store 之上。本篇食谱将围绕docs/recipes/mst-query.md展开,逐一讲解它的六项核心能力——React hooks 异步数据管理、自动归一化(normalization)、陈旧数据失效、命令式 API、乐观更新与垃圾回收,并结合本仓库的reference、identifier与identifierCache实现,解释这些能力为何能在 MST 上自然成立。读完本文,你将能够用 mst-query 替换手写的 load/error/loading 样板代码,写出自带缓存、自动建表、可失效刷新、可乐观更新的查询层。

mst-query 是什么

mst-query 是一个面向 MobX-State-Tree 的查询库,与 react-query 的思路类似,但它不是独立于状态树之外再开一套缓存,而是直接运行在 MST store 之上,作为 store 的一层薄封装。它的核心特性包括:

  • 通过 React hooks 管理异步数据
  • 自动归一化(把服务端返回的数据自动落库并建立引用关系)
  • 数据陈旧(stale)时的查询失效与自动刷新
  • 命令式 API(不依赖 hooks 也能完成同样的工作)
  • 乐观更新(optimistic update)
  • 垃圾回收(对不再被查询引用的模型进行清理)

下面的小节会分别说明每一项能力,以及它们解决了 MST 开发中的哪些常见痛点。

用 React hooks 管理异步数据

在组件里手写数据获取的 hook 并不容易:loading、error、竞态、重复请求、缓存……所有潜在边界情况都要自己处理,既复杂又容易出错。改用第三方 hook 虽然更可靠,但常常造成数据重复存储——数据既缓存在 hook 里,又存在于你的模型里。

mst-query 提供了一种在组件内直接取数、又与 MST 无缝集成的方案。对比以下两种写法:

// 常规 MST: const Todo = observer(({ id }) => { useEffect(() => { store.loadTodo(id); }, [id]); if (store.todoError) return <div>Got an error...</div>; if (store.todoIsLoading) return <div>Is loading...</div>; return <Todo todo={store.todo} />; }); // 使用 mst-query: const Todo = observer(({ id }) => { const { data, error, isLoading } = useQuery(store.todoQuery, { request: { id } }) if (error) return <div>Got an error...</div>; if (isLoading) return <div>Is loading...</div>; return <Todo todo={data} />; });

useQuery接收一个查询模型和请求参数,返回data、error、isLoading等状态,数据与模型存储在同一棵树里,不再有第二份缓存。

创建查询:查询即模型

在 mst-query 中,查询本身就是模型(model),因此你可以像观察和更新普通模型一样观察和更新它们。用createQuery定义查询模型:

const LoadTodoQuery = createQuery("LoadTodoQuery", { data: t.reference(Todo), request: t.model({ id: t.string }), async endpoint({ request }) { return todoApi.get(request.id) } });

三个配置项的含义:

  • data:endpoint 返回数据的形状,这里是t.reference(Todo),即返回的是对 Todo 模型的引用;
  • request:传给 endpoint 函数的参数形状,这里是t.model({ id: t.string });
  • endpoint:异步取数函数,接收{ request }解构参数,返回 Promise。

其中data和request都会经过运行时类型检查(runtime type checking)——这是 MST 类型系统的固有能力,mst-query 复用了它,保证请求参数和响应数据在运行时都符合声明的形状。

自动归一化:引用、标识符与 identifierCache

mst-query 的独特之处在于:服务端返回的数据会被自动归一化。因为查询已经知道自己消费的 API 返回什么形状的数据,所以可以自动化地"按标识符创建并更新模型"这一过程。

先看常规 MST 写法有多繁琐:

import { t, flow } from "mobx-state-tree" const User = t.model("User", { id: t.identifier, name: t.string }) const Todo = t.model("Todo", { id: t.identifier, title: t.string, message: t.string, done: t.boolean, createdBy: t.reference(User) }) // 常规 MST: const TodoStore = t .model("RootStore", { todos: t.map(Todo) }) .actions((self) => ({ loadTodo: flow(function* loadTodo(todoId: string) { const todo = yield todoApi.getTodo(todoId); const root = getRoot(self); const user = root.userStore.createOrUpdateUser(todo.createdBy); todo.createdBy = user; const oldTodo = self.todos.get(todoId); if (!oldTodo) { self.todos.put({ todo }); } else { self.todos.put({ ...getSnapshot(oldTodo), ...todo }); } }) }))

要手动处理引用对象(createdBy的 User 要先建好再回填)、手动去重、手动合并旧快照。而在 mst-query 中:

const UserStore = createModelStore('UserStore', User); const TodoStore = createModelStore("TodoStore", Todo).props({ todoQuery: createQuery("TodoQuery", { data: t.reference(Todo), request: t.model({ id: t.string }), async endpoint({ request }) { return todoApi.getTodo(request.id) } }) }) const RootStore = createRootStore({ userStore: t.optional(UserStore, {}), todoStore: t.optional(TodoStore, {}) });

createRootStore和createModelStore让 mst-query 知道哪些模型需要被归一化。注意:你完全不需要手动更新 todo 上的createdBy属性——这是自动完成的。

底层原理:为什么归一化能成立

归一化的根基是 MST 的 identifier 与 reference 机制,本仓库中可以找到完整的实现证据:

  • types.identifier是标识符类型,只能作为模型的直接属性使用,且声明后不可修改——见 src/types/utility-types/identifier.ts 的类型注释:Inside a state tree, for each type can exist only one instance for each given identifier.(每个类型在整棵树中,同一标识符只能对应一个实例);其reconcile逻辑会直接拒绝标识符变更(Tried to change identifier from '${current.storedValue}' to '${newValue}')。
  • types.reference是对另一类型的引用,目标类型必须定义了标识符;引用在底层存的是标识符而非对象,取值时才解析——见 src/types/utility-types/reference.ts 中StoredReference的实现。
  • 解析引用依赖IdentifierCache——一棵树内所有带标识符的节点都会注册进缓存(src/core/node/identifier-cache.ts 的addNodeToCache),引用按type + 归一化后的标识符在缓存中查找唯一目标(resolve,见同文件 L104-L125),因此只要把返回的数据"带标识符地"放进树中,引用就能被正确解析。

正是这套机制让 mst-query 的归一化"零成本"成立:把服务端 JSON 里的createdBy连同 User 模型一起入树,Todo 上的createdBy引用就会自动指向新入树的 User 节点,不需要任何手动接线代码。

上例只有一层嵌套数据模型;而在真实场景(比如查询 GraphQL endpoint)中,一个响应可能包含几十个类似属性,mst-query 会不加任何额外代码地把它们全部归一化。

数据陈旧时的查询失效与自动更新

与 react-query 类似,你可以给useQuery传staleTime选项,让数据在用户浏览应用时周期性刷新。staleTime的默认值是 0,意味着用户始终看到的是新数据。

另外,当你使用createMutation配合mutate时,模型也会被自动更新——前提只有两个:你的 API 返回了新数据,并且data属性是引用类型:

const TodoRequestModel = t.model({ id: t.string, done: t.boolean, title: t.string }); const TodoUpdateMutation = createMutation("TodoUpdateMutation", { data: t.reference(Todo), request: TodoRequestModel, async endpoint({ request }) { return todoApi.update(request) } }); const TodoStore = createModelStore("TodoStore", Todo) .props({ todoQuery: TodoQuery, todoUpdateMutation: TodoUpdateMutation }) .actions(self => ({ update(data) { // 当 mutate 成功 resolve 时,Todo 会被自动更新。 self.todoUpdateMutation.mutate({ request: data }); } }))

因为 mutation 返回的data是引用,而归一化的模型存储(createModelStore)知道这些数据属于哪个标识符,所以 resolve 后可以直接覆盖树中对应节点。

invalidate 与 onMutate:手动刷新列表

你还可以调用invalidate手动重新拉取某个查询。它与createMutation以及新的监听器onMutate配合得非常好,最常见的场景是刷新列表:

const TodoListQuery = createQuery("TodoListQuery", { data: t.array(t.reference(Todo)), async endpoint() { return todoApi.getList(); } }); const TodoAddMutation = createMutation("TodoAddMutation", { data: t.reference(Todo), request: TodoRequestModel, async endpoint({ request }) { return todoApi.update(request) } }); const TodoStore = createModelStore("TodoStore", Todo) .props({ todoListQuery: TodoListQuery, todoAddMutation: todoAddMutation }) .actions(self => ({ afterCreate() { onMutate(self.todoAdd, (result) => { // 调用 invalidate 重新拉取列表... self.todoListQuery.invalidate(); // ...或者不刷新,直接把新条目 push 进查询数据里 self.todoListQuery.data.push(result); }); } })); const TodoListContainer = observer(() => { const { data } = useQuery(store.todoListQuery); const [addTodo, { isLoading }] = useMutation(store.todoAddMutation); return <TodoList todos={data} onAdd={addTodo} isAdding={isLoading} />; });

onMutate在 mutation 成功后触发回调:可以走invalidate()走"重新拉取"路线保证绝对一致,也可以直接data.push(result)走"本地补增"路线省一次请求。两条路都只依赖你已经在 store 里声明的查询与 mutation 模型。

命令式 API

hooks 很方便,但有时你的取数逻辑会变得更复杂,导致组件里堆积大量业务逻辑。mst-query 允许你用命令式 API 完成 hooks 能做的大部分事情:

const TodoStore = createModelStore("TodoStore", Todo) .props({ todoQuery: TodoQuery, todoUpdateMutation: TodoUpdateMutation }) .volatile(self => ({ permssionError: '', updateResult: null })) .actions(self => ({ updateTodo: flow(function* (request) { const result = yield todoApi.checkPermissions(request.id); if (!result.ok) { self.permissionError = 'You are not allowed to edit this resource'; return; } const { error, result: updateResult } = yield self.todoUpdateMutation.mutate({ request }); if (error) { logApi.sendLog(error.message); } self.updateResult = updateResult; }); })); const TodoLoader = async (id) => { // 在路由 loader 里手动取数,这也是预取(prefetch)数据的方式。 const todo = await store.todoQuery.query({ request: { id } }); return <TodoContainer todo={todo} store={store} />; }; const TodoContainer = observer((props) => { const { todo, store } = props; return ( <Todo todo={todo} onUpdate={store.updateTodo} permissionError={store.permissionError} /> ); });

要点:

  • 可以在flow中编排完整的业务逻辑:先做权限检查、再执行mutate、再根据{ error, result }决定后续动作(如logApi.sendLog);
  • store.todoQuery.query({ request })返回 Promise,适合在路由 loader、导航守卫等非组件环境手动取数,同时也是预取数据的方式;
  • 命令式 API 支持 mst-query 的大部分功能,但有一个明确的限制:当数据陈旧时自动重新拉取——无论是通过staleTime还是调用invalidate——目前不支持命令式调用。这一点与原文档描述一致,属 mst-query 的已知边界。

乐观更新(Optimistic update)

乐观更新对 UI 的响应式体验很重要。在 mst-query 中,把更新逻辑传给mutate的optimisticUpdate选项即可实现。当 mutate 调用 resolve(无论成功与否),乐观更新会自动回滚:

const serverTodo = yield self.todoAddMutation.mutate({ request: data, optimisticUpdate() { // createModelStore 提供了一个 merge action,可以用它手动创建模型 const clientTodo = todoStore.merge({ id: `${Math.random()}`, title: data.title, done: data.done, createdBy: loggedInUserId }); todoStore.todoListQuery.push(clientTodo); } }); todoStore.todoListQuery.push(serverTodo);

工作流程是:

  1. 先执行optimisticUpdate():用todoStore.merge(...)在客户端本地创建一个临时的 Todo(id用随机值),立刻push进todoListQuery.data,让 UI 立即呈现用户操作的结果,不必等待网络;
  2. 网络请求完成后拿到serverTodo(服务端真实数据),再push进列表——此时临时项与真实项同时存在,直到后续刷新/归一化时被清理或覆盖。

merge是createModelStore提供的用于手动创建模型的 action,配合乐观更新可以做到"先渲染、后确认"的即时反馈体验。

垃圾回收:runGc

考虑一个场景:MST 应用从 API 拉取一个列表,随着时间推移,条目会被新增、更新或删除。在常规 MST 中,凡是拉取过的条目都会一直留在内存里,除非你手动删除;如果列表还是分页的,问题更大——每翻一页都累积一份永不释放的数据。

由于 mst-query 通过查询跟踪了所有模型,它可以安全地移除不再被任何查询引用的模型。做法是在根 store 上调用垃圾回收:

rootStore.runGc()

这正是 MST 引用机制与 mst-query 归一化模型存储结合的收益:因为模型以"带标识符的节点 + 引用"的形式存在于树中(由 IdentifierCache 统一登记、并在节点销毁时通过notifyDied移除登记,见 L56-L69),哪些模型仍被引用、哪些已经"孤儿化"是可以被确定的,因此可以安全回收。

小结:mst-query 与 MST 的组合定位

回顾 mst-query 解决的核心问题,其实都建立在 MST 自身两个基础能力之上:

mst-query 能力依赖的 MST 机制仓库证据
自动归一化types.identifier+types.referenceidentifier.ts、reference.ts
引用自动解析根树的IdentifierCache按类型+标识符查找唯一目标identifier-cache.ts
查询即模型MST 模型可观察、可订阅、可在 actions 中编排model.ts
乐观更新/自动回滚模型动作(actions)与快照/补丁机制mst-operations.ts

mst-query 不是替代 MST,而是把"取数、缓存、失效、归一化、回收"这些通用逻辑抽象成一套以模型为载体的声明式层。如果你的应用恰好大量使用t.reference+t.identifier来建模(MST 的推荐做法,可参考 docs/concepts/references.md),mst-query 的这些特性就能以几乎零额外成本直接生效。从原文档看,唯一值得注意的限制是:命令式 API 目前不支持staleTime/invalidate式的自动失效刷新,需要你在 hooks 场景之外自行安排刷新时机。

  • 状态管理
  • 前端

【免费下载链接】mobx-state-tree

Full-featured reactive state management without the boilerplate

项目地址:https://gitcode.com/gh_mirrors/mo/mobx-state-tree
点击查看免费下载
上一篇:如何免费解锁Wand专业版功能:Wand-Enhancer完整使用指南
下一篇:VueUse toReactive:将 Ref 转换为响应式对象的底层原理与实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询