- 状态管理
- 前端
【免费下载链接】mobx-state-tree
Full-featured reactive state management without the boilerplate
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);工作流程是:
- 先执行
optimisticUpdate():用todoStore.merge(...)在客户端本地创建一个临时的 Todo(id用随机值),立刻push进todoListQuery.data,让 UI 立即呈现用户操作的结果,不必等待网络; - 网络请求完成后拿到
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.reference | identifier.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
相关推荐
Relay Query Retention 指南:使用 environment.retain 手动保留查询数据与垃圾回收控制
Relay Query Retention 指南:使用 environment.retain 手动保留查询数据与垃圾回收控制 本指南讲解在 Relay 应用中如
前端开发工具Relay 查询数据保留指南:用 environment.retain 手动防止查询数据被垃圾回收
Relay 查询数据保留指南:用 environment.retain 手动防止查询数据被垃圾回收 本文围绕 Relay(JavaScript 数据驱动 Rea
前端开发工具Relay 查询数据保活指南:深入理解 environment.retain 与垃圾回收机制
Relay 查询数据保活指南:深入理解 environment.retain 与垃圾回收机制 本指南基于 Relay 官方文档( retaining queri
前端开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考