设计最佳实践:Domain Store、UI Store 与 RootStore 组合模式)
MobX 数据仓库Store设计最佳实践Domain Store、UI Store 与 RootStore 组合模式【免费下载链接】mobxSimple, scalable state management.项目地址: https://gitcode.com/gh_mirrors/mo/mobxMobX 官方在 Mendix 大型项目实践中沉淀了一套非侵入式unobtrusive的 Store 组织方式主张把业务逻辑与状态从组件中抽离到独立、可测试、前后端通用的单元中。本文基于 docs/best/store.md 及其当前版本 docs/defining-data-stores.md 展开覆盖 Domain Store 的职责与唯一实例保证、Domain 对象的类化建模、UI Store 的轻量设计以及通过 RootStore 无单例地组合多个 Store并深入到 makeAutoObservable、observable.struct等源码实现层面。读完你将掌握一套可直接落地的大规模 React MobX 状态分层方案。为什么需要 Store把逻辑与状态移出组件在任何 Flux 架构中都能看到 Store 的身影它大致可以类比 MVC 模式中的 Controller。Store 的核心职责是把逻辑与状态从组件中移出放入一个独立的、可测试的单元中这个单元既能用于前端也能用于后端 JavaScript 环境。从 MobX 仓库的实践文档 看大多数应用至少需要两个 StoreDomain Store承载应用真正的业务数据Todo、用户、图书、电影、订单等UI Store承载界面相关的、通常不落库的轻量状态。两者分离的最大好处是Domain 状态可以被通用地复用与测试甚至可以被其他应用直接复用UI 状态则随开发过程频繁变动而不会污染业务层。这套做法不打扰既有代码库能很好兼容经典 MVC 模式也并非唯一答案——如果偏好更重、更约定化的组织方式社区还有 mobx-state-tree 与 mobx-keystone 等自带快照、action 中间件、JSON patch 能力的方案本文不展开仅作参照。Domain Store一个概念对应一个 StoreDomain Store 存储的是应用真正关心的数据。通常你会有一个或多个 Domain Store且至少有一个。设计规则是一个 Domain Store 只负责应用中的一个概念内部常组织成包含多个领域对象的树结构。如果两个条目之间是包含containment关系它们通常应放在同一个 Store 中。例如产品一个 Store订单与订单行一个 Store。Store 本身只负责管理领域对象domain objects。Store 的职责清单实例化领域对象并确保对象知道自己属于哪个 Store保证每个领域对象在内存中只有一份——同一个用户、订单或 Todo 绝不能被存储两次。这样你可以安全地使用引用且无需解析引用即可确信看到的是最新实例调试时快速、直接、方便提供后端集成在需要时持久化数据收到后端更新时更新已有实例提供独立、通用、可测试的应用组件为了让 Store 可测试且能在服务端运行通常把真正的 websocket/http 请求移到单独的对象中从而抽象通信层Store 本身全局只有一份实例。领域对象用类建模而非当作数据库每个领域对象都应该用自己的类或构造函数来表达。文档特别强调不要把客户端应用状态当成某种数据库来对待。JavaScript 中真实的引用、循环数据结构和实例方法都是强大的能力领域对象可以直接引用来自其他 Store 的领域对象。相比 Redux 等许多 Flux 架构使用 MobX无需对数据做归一化normalization这让应用中最本质复杂的部分——业务规则、action 和用户界面——变得简单得多。类相比普通对象的优势领域对象可以把全部逻辑委托给它所属的 Store也可以以类来承载。类相比普通对象plain object有四个关键优势可以有方法领域概念可独立使用减少应用所需的上下文意识。只需传递对象本身不必到处传递 Store也不必猜测对象上有哪些 action——它们就是实例方法。这在大型应用中尤为重要对属性与方法可见性的细粒度控制构造函数创建的对象可自由混合可观察属性/方法与不可观察属性/方法易于识别且可被严格类型检查。完整示例TodoStore 与 Todo文档给出了一个完整的、可运行的前后端集成示例可在 docs/defining-data-stores.md 中查看原文。下面是核心代码与逐段解读import { makeAutoObservable, runInAction, reaction } from mobx import uuid from node-uuid export class TodoStore { authorStore transportLayer todos [] isLoading true constructor(transportLayer, authorStore) { makeAutoObservable(this) this.authorStore authorStore // Store that can resolve authors. this.transportLayer transportLayer // Thing that can make server requests. this.transportLayer.onReceiveTodoUpdate(updatedTodo this.updateTodoFromServer(updatedTodo) ) this.loadTodos() } // Fetches all Todos from the server. loadTodos() { this.isLoading true this.transportLayer.fetchTodos().then(fetchedTodos { runInAction(() { fetchedTodos.forEach(json this.updateTodoFromServer(json)) this.isLoading false }) }) } // Update a Todo with information from the server. Guarantees a Todo only // exists once. Might either construct a new Todo, update an existing one, // or remove a Todo if it has been deleted on the server. updateTodoFromServer(json) { let todo this.todos.find(todo todo.id json.id) if (!todo) { todo new Todo(this, json.id) this.todos.push(todo) } if (json.isDeleted) { this.removeTodo(todo) } else { todo.updateFromJson(json) } } // Creates a fresh Todo on the client and the server. createTodo() { const todo new Todo(this) this.todos.push(todo) return todo } // A Todo was somehow deleted, clean it from the client memory. removeTodo(todo) { this.todos.splice(this.todos.indexOf(todo), 1) todo.dispose() } } // Domain object Todo. export class Todo { id null // Unique id of this Todo, immutable. completed false task author null // Reference to an Author object (from the authorStore). store null autoSave true // Indicator for submitting changes in this Todo to the server. saveHandler null // Disposer of the side effect auto-saving this Todo (dispose). constructor(store, id uuid.v4()) { makeAutoObservable(this, { id: false, store: false, autoSave: false, saveHandler: false, dispose: false }) this.store store this.id id this.saveHandler reaction( () this.asJson, // Observe everything that is used in the JSON. json { // If autoSave is true, send JSON to the server. if (this.autoSave) { this.store.transportLayer.saveTodo(json) } } ) } // Remove this Todo from the client and the server. delete() { this.store.transportLayer.deleteTodo(this.id) this.store.removeTodo(this) } get asJson() { return { id: this.id, completed: this.completed, task: this.task, authorId: this.author ? this.author.id : null } } // Update this Todo with information from the server. updateFromJson(json) { this.autoSave false // Prevent sending of our changes back to the server. this.completed json.completed this.task json.task this.author this.store.authorStore.resolveAuthor(json.authorId) this.autoSave true } // Clean up the observer. dispose() { this.saveHandler() } }代码拆解Store 与对象的协作构造注入TodoStore的构造函数接收transportLayer负责真实 HTTP/websocket 请求的通信对象和authorStore可解析 Author 的兄弟 Store这正是把通信层抽象出去以支持测试与服务端运行的落地方式唯一实例保证updateTodoFromServer先用json.id在this.todos中查找存在则更新不存在才新建从而保证同一 Todo 在内存中只有一份异步与 runInActionloadTodos在 promise 回调里用runInAction包裹状态修改。MobX 默认要求状态修改发生在 action 内可参考enforceActions相关配置runInAction是在异步回调中批量提交变更的标准手段自动保存副作用Todo构造函数中用reaction(() this.asJson, json ...)建立副作用——每当asJson读取到的任何字段变化就把 JSON 发给服务器autoSave标志用于避免本地修改 → 服务器回推 → 再触发保存的循环updateFromJson中先关闭再恢复该标志正是为了阻止自己的变更回写服务器资源清理removeTodo/delete/dispose负责在删除 Todo 时停掉saveHandlerreaction 的 disposer避免内存泄漏与多余的保存请求。makeAutoObservable 的覆盖规则makeAutoObservable(this)会自动把类字段与原型方法全部注解为可观察。Todo的构造中通过第二个参数overrides显式把id、store、autoSave、saveHandler、dispose设为false即这些字段不变为可观察——它们要么是不可变标识要么是普通引用无需进入响应式追踪。从源码看makeAutoObservable 的实现 会把实例自身的键与原型链上的键合并后逐一应用自动注解自动注解的分发逻辑位于 packages/mobx/src/types/autoannotation.tsgetter 视为 computed原型上的函数视为 action生成器函数则视为 flow其余字段视为 observable。这正是上面示例里asJsongetter自动成为派生值、loadTodos等方法自动成为 action 的底层原因。注意makeAutoObservable仅支持没有父类的类且同一对象只能调用一次开发模式下源码会直接报错提示见 packages/mobx/src/api/makeObservable.ts#L51-L58。UI Store轻量、易变、全局相关的界面状态UI Store 通常与应用高度耦合但逻辑非常简单它没有太多业务逻辑而是存储一堆松散耦合的界面信息片段。把这类状态集中存放是理想的因为大多数应用在开发过程中会频繁改动 UI 状态。UI Store 中常见的状态会话Session信息应用加载进度的信息不会存储在后端的信息全局影响 UI 的信息窗口尺寸Window dimensions无障碍Accessibility信息当前语言Current language当前主题Currently active theme一旦影响多个互不相关的组件就应提升到这里的 UI 状态当前选中项Current selection工具栏等的可见性向导wizard的进行状态全局浮层global overlay的状态一个常见演变路径是某信息起初只是某个组件的内部状态比如工具栏可见性后来你发现应用的其他地方也需要它。与普通 React 应用把 state 在组件树中向上提升的做法不同在 MobX 中你只需把它移到 UI Store 即可。对于同构isomorphic应用你或许还要提供一个带合理默认值的 UI Store 桩实现stub保证所有组件按预期渲染UI Store 可以通过 React context 在应用内分发。示例带 observable.struct 的 UiStateimport { makeAutoObservable, observable, computed } from mobx export class UiState { language en_US pendingRequestCount 0 // .struct makes sure observer wont be signaled unless the // dimensions object changed in a deepEqual manner. windowDimensions { width: window.innerWidth, height: window.innerHeight } constructor() { makeAutoObservable(this, { windowDimensions: observable.struct }) window.onresize () { this.windowDimensions getWindowDimensions() } } get appIsInSync() { return this.pendingRequestCount 0 } }这里有两个值得深入的点observable.struct结构比较windowDimensions每次onresize都会赋一个新对象尺寸数值可能没变。如果按默认方式引用比较观察observer会被反复无意义地通知。observable.struct会让比较变成深度相等deepEqual比较——只有尺寸真正变化才通知观察者。源码层面observable.struct 注解使用 refStructEnhancer其实现先做deepEqual(v, oldValue)判断相等时直接返回旧值packages/mobx/src/types/modifiers.ts#L90-L98从而让ObservableValue的比较结果落回UNCHANGEDpackages/mobx/src/types/observablevalue.ts#L131不再触发派生更新。注意该 modifier 不能用于可观察值本身源码会报错observable.struct should not be used with observable valuescomputed 派生值appIsInSync是 gettermakeAutoObservable自动将其视为 computedpendingRequestCount一旦归零依赖它的 observer 会自动刷新。组合多个 StoreRootStore 模式避免单例一个常见问题是不使用单例singleton时多个 Store 如何互相感知文档推荐RootStore模式——创建一个 RootStore 实例化所有子 Store并共享引用。其优点设置简单对强类型支持良好让复杂单元测试变得容易——你只需实例化一个 RootStore。class RootStore { constructor() { this.userStore new UserStore(this) this.todoStore new TodoStore(this) } } class UserStore { constructor(rootStore) { this.rootStore rootStore } getTodos(user) { // Access todoStore through the root store. return this.rootStore.todoStore.todos.filter(todo todo.author user) } } class TodoStore { todos [] rootStore constructor(rootStore) { makeAutoObservable(this) this.rootStore rootStore } }要点每个子 Store 的构造函数接收rootStore引用通过this.rootStore.xxxStore互相访问无需任何全局单例在 React 中使用时RootStore 通常通过React context注入组件树而非依赖模块级单例。落地建议与常见陷阱先分 Domain / UI 两层从最小的两个 Store 开始一个 Domain 一个 UI随业务增长再按概念拆分 Domain Store把通信层抽象出去Store 内不直接发 http/websocket 请求而是依赖注入的transportLayer这样单元测试可以传入 mock也能在 Node 服务端运行同一套 Store用类而非 plain object 建模领域对象获得方法、可见性控制、类型检查等收益大型应用中这能显著减少传参上下文注意异步更新用runInAction包裹避免破坏 action 约束reaction一定要在对象销毁时dispose()如示例中的saveHandler防止副作用泄漏observable.struct用在结构相等才需要通知的场景窗口尺寸、屏幕方向等不要用于已经可观察的值RootStore 通过 context 注入保持可测试性makeAutoObservable仅适用于无父类的类需要继承时改用显式的makeObservable注解。延伸阅读docs/defining-data-stores.md 与 docs/best/store.md本文对应的原始文档后者为迁移占位页实际内容见前者docs/observable-state.md可观察状态的完整概念docs/actions.mdaction、runInAction与严格模式docs/computeds.mdcomputed 派生值详解docs/reactions.mdreaction、autorun等响应式副作用packages/mobx/src/api/makeObservable.tsmakeObservable/makeAutoObservable实现packages/mobx/src/types/autoannotation.ts自动注解的字段分发规则packages/mobx/src/api/observable.tsobservable.struct等注解定义packages/mobx/tests/base/make-observable.tsmakeAutoObservable相关测试用例。【免费下载链接】mobxSimple, scalable state management.项目地址: https://gitcode.com/gh_mirrors/mo/mobx创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考