定义数据存储
本节包含我们在 Mendix 与 MobX 合作时发现的一些用于构建大型可维护项目的最佳实践。本节内容带有主观意见,您无需强制应用这些实践。使用 MobX 和 React 有很多方法,这只是其中之一。
本节重点介绍一种非侵入式使用 MobX 的方法,它适用于现有代码库或经典的 MVC 模式。其他更具主观意见的组织存储方法是 mobx-state-tree 和 mobx-keystone。两者都附带了一些很酷的功能,例如结构共享快照、动作中间件、JSON 修补支持等。
存储
存储可以在任何 Flux 架构中找到,可以与 MVC 模式中的控制器进行比较。存储的主要职责是将逻辑和状态从您的组件移到一个独立的可测试单元中,该单元可以在前端和后端 JavaScript 中使用。
大多数应用程序都受益于至少两个存储:一个用于域状态,另一个用于UI 状态。将这两个存储分开的好处是,您可以普遍地重用和测试域状态,而且您很可能在其他应用程序中重用它。
域存储
您的应用程序将包含一个或多个域存储。这些存储存储您的应用程序所包含的所有数据。待办事项、用户、书籍、电影、订单,等等。您的应用程序很可能至少有一个域存储。
单个域存储应该负责应用程序中的单个概念。单个存储通常组织为树形结构,其中包含多个域对象。
例如:一个用于产品的域存储,另一个用于订单和订单行。作为经验法则:如果两个项目之间关系的本质是包含关系,则它们通常应该在同一个存储中。因此,一个存储只管理域对象。
以下是存储的职责:
- 实例化域对象。确保域对象知道它们所属的存储。
- 确保每个域对象只有一个实例。同一个用户、订单或待办事项不应该在内存中存储两次。这样,您可以安全地使用引用,并确保您查看的是最新实例,而无需解析引用。这在调试时速度快、简单方便。
- 提供后端集成。在需要时存储数据。
- 如果从后端收到更新,则更新现有实例。
- 提供应用程序的独立、通用、可测试组件。
- 为了确保您的存储可测试,并且可以在服务器端运行,您可能需要将实际的 Websocket/HTTP 请求移到一个单独的对象中,以便您可以抽象掉您的通信层。
- 存储应该只有一个实例。
域对象
每个域对象都应该使用它自己的类(或构造函数)来表达。无需将客户端应用程序状态视为某种数据库。真正的引用、循环数据结构和实例方法是 JavaScript 中强大的概念。域对象可以自由地引用来自其他存储的域对象。请记住:我们希望使我们的动作和视图尽可能简单,而需要自己管理引用和执行垃圾回收可能会成为一个倒退。与 Redux 等许多 Flux 架构不同,使用 MobX 无需规范化您的数据,这使得构建应用程序的本质上复杂的部分变得更简单:您的业务规则、动作和用户界面。
域对象可以将它们的所有逻辑委托给它们所属的存储,如果这适合您的应用程序。可以使用普通对象来表达您的域对象,但类在普通对象方面有一些重要的优势。
- 它们可以有方法。这使得您的域概念更容易独立使用,并减少了应用程序中所需的上下文感知。只需传递对象即可。您无需传递存储,也不必弄清楚哪些动作可以应用于对象,如果这些动作只是作为实例方法可用。这在大型应用程序中尤为重要。
- 它们提供了对属性和方法的可见性的细粒度控制。
- 使用构造函数创建的对象可以自由地混合可观察属性和方法,以及不可观察属性和方法。
- 它们易于识别,并且可以进行严格的类型检查。
域存储示例
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()
}
}
UI 存储
ui-state-store 通常对您的应用程序非常具体,但也通常非常简单。该存储通常没有太多逻辑,但会存储大量关于 UI 的松散耦合的信息。这非常理想,因为大多数应用程序在开发过程中会经常更改 UI 状态。
您通常会在 UI 存储中找到以下内容:
- 会话信息
- 关于您的应用程序加载进度的信息
- 不会存储在后端的信息
- 影响 UI 全局的信息
- 窗口尺寸
- 可访问性信息
- 当前语言
- 当前激活的主题
- 用户界面状态,只要它影响多个、更无关的组件
- 当前选择
- 工具栏的可见性等
- 向导的状态
- 全局叠加层的状态
很可能这些信息最初是特定组件的内部状态(例如,工具栏的可见性),但一段时间后,您发现需要在应用程序的其他地方使用这些信息。在这种情况下,您无需将状态向上推入组件树中,就像在纯 React 应用程序中所做的那样,而是将该状态移到ui-state-store中。
对于同构应用程序,您可能还需要使用具有合理的默认值的存根实现来提供此存储,以便所有组件都能按预期呈现。您可以通过将ui-state-store作为 React 上下文传递来将其分发到您的应用程序中。
存储示例(使用 ES6 语法)
import { makeAutoObservable, observable, computed } from "mobx"
export class UiState {
language = "en_US"
pendingRequestCount = 0
// .struct makes sure observer won't 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
}
}
组合多个存储
一个经常被问到的问题是如何在不使用单例的情况下组合多个存储。它们如何相互了解呢?
一个有效的模式是创建一个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
}
}
使用 React 时,此根存储通常通过使用 React 上下文插入到组件树中。