定义数据存储
¥Defining data stores
本节包含我们在使用 MobX 时在 Mendix 发现的构建大型可维护项目的一些最佳实践。本节是有态度的,你绝不会被迫应用这些做法。使用 MobX 和 React 的方法有很多种,这只是其中之一。
¥This section contains some of the best practices for building large scale maintainable projects we discovered at Mendix while working with MobX. This section is opinionated and you are in no way forced to apply these practices. There are many ways of working with MobX and React, and this is just one of them.
本节重点介绍使用 MobX 的一种不引人注目的方式,该方式在现有代码库或经典 MVC 模式中运行良好。另一种更有态度的存储组织方式是 mobx-state-tree 和 mobx-keystone。两者都具有很酷的功能,例如结构共享快照、操作中间件、JSON 补丁支持等开箱即用的功能。
¥This section focuses on an unobtrusive way of working with MobX, which works well in existing codebases, or with classic MVC patterns. Alternative, more opinionated ways of organizing stores are mobx-state-tree and mobx-keystone. Both ship with cool features such as structurally shared snapshots, action middlewares, JSON patch support etc. out of the box.
存储
¥Stores
存储可以在任何 Flux 架构中找到,并且可以与 MVC 模式中的控制器进行比较。存储的主要职责是将逻辑和状态从组件中移出到可在前端和后端 JavaScript 中使用的独立可测试单元。
¥Stores can be found in any Flux architecture and can be compared a bit with controllers in the MVC pattern. The main responsibility of stores is to move logic and state out of your components into a standalone testable unit that can be used in both frontend and backend JavaScript.
大多数应用受益于至少两个存储:一个用于域状态,另一个用于 UI 状态。将这两者分开的优点是你可以普遍重用和测试域状态,并且你很可能在其他应用中重用它。
¥Most applications benefit from having at least two stores: one for the domain state and another one for the UI state. The advantage of separating those two is you can reuse and test domain state universally, and you might very well reuse it in other applications.
域存储
¥Domain Stores
你的应用将包含一个或多个域存储。这些存储存储你的应用的相关数据。待办事项、用户、书籍、电影、订单,应有尽有。你的应用很可能至少有一个域存储。
¥Your application will contain one or multiple domain stores. These stores store the data your application is all about. Todo items, users, books, movies, orders, you name it. Your application will most probably have at least one domain store.
单个域存储应该负责应用中的单个概念。单个存储通常被组织为树形结构,其中包含多个域对象。
¥A single domain store should be responsible for a single concept in your application. A single store is often organized as a tree structure with multiple domain objects inside.
例如:一个域存储用于你的产品,另一个域存储用于你的订单和订单行。根据经验:如果两个项目之间的关系本质是遏制,那么它们通常应该位于同一存储中。所以存储只管理域对象。
¥For example: one domain store for your products, and one for your orders and orderlines. As a rule of thumb: if the nature of the relationship between two items is containment, they should typically be in the same store. So a store just manages domain objects.
以下是存储的职责:
¥These are the responsibilities of a store:
实例化域对象。确保域对象知道它们所属的存储。
¥Instantiate domain objects. Make sure domain objects know the store they belong to.
确保每个域对象只有一个实例。相同的用户、订单或待办事项不应在内存中存储两次。这样你就可以安全地使用引用,并确保你正在查看最新的实例,而无需解析引用。这在调试时快速、直接且方便。
¥Make sure there is only one instance of each of your domain objects. The same user, order or todo should not be stored twice in memory. This way you can safely use references and also be sure you are looking at the latest instance, without ever having to resolve a reference. This is fast, straightforward and convenient when debugging.
提供后端集成。需要时存储数据。
¥Provide backend integration. Store data when needed.
如果从后端收到更新,则更新现有实例。
¥Update existing instances if updates are received from the backend.
为你的应用提供独立、通用、可测试的组件。
¥Provide a standalone, universal, testable component of your application.
为了确保你的存储是可测试的并且可以在服务器端运行,你可能会将实际的 websocket / http 请求移至单独的对象,以便你可以抽象通信层。
¥To make sure your store is testable and can be run server-side, you will probably move doing actual websocket / http requests to a separate object so that you can abstract over your communication layer.
存储应该只有一个实例。
¥There should be only one instance of a store.
域对象
¥Domain objects
每个域对象都应该使用自己的类(或构造函数)来表达。无需将客户端应用状态视为某种数据库。真实引用、循环数据结构和实例方法是 JavaScript 中强大的概念。域对象可以直接引用其他存储中的域对象。记住:我们希望使我们的操作和视图尽可能简单,并且需要自己管理引用和进行垃圾收集可能是一种倒退。与 Redux 等许多 Flux 架构不同,使用 MobX 无需规范化数据,这使得构建应用本质上复杂的部分变得更加简单:你的业务规则、操作和用户界面。
¥Each domain object should be expressed using its own class (or constructor function). There is no need to treat your client-side application state as some kind of database. Real references, cyclic data structures and instance methods are powerful concepts in JavaScript. Domain objects are allowed to refer directly to domain objects from other stores. Remember: we want to keep our actions and views as simple as possible and needing to manage references and doing garbage collection yourself might be a step backward. Unlike many Flux architectures such as Redux, with MobX there is no need to normalize your data, and this makes it a lot simpler to build the essentially complex parts of your application: your business rules, actions and user interface.
如果这很适合你的应用,域对象可以将其所有逻辑委托给它们所属的存储。可以将域对象表示为普通对象,但类比普通对象有一些重要的优点:
¥Domain objects can delegate all their logic to the store they belong to if that suits your application well. It is possible to express your domain objects as plain objects, but classes have some important advantages over plain objects:
他们可以有方法。这使得你的字段概念更易于独立使用,并减少应用中所需的上下文感知量。只需传递对象即可。你不必传递存储,或者必须弄清楚哪些操作可以应用于对象(如果它们仅作为实例方法可用)。这在大型应用中尤其重要。
¥They can have methods. This makes your domain concepts easier to use standalone and reduces the amount of contextual awareness that is needed in your application. Just pass objects around. You don't have to pass stores around, or have to figure out which actions can be applied to an object if they are just available as instance methods. This is especially important in large applications.
它们提供对属性和方法的可见性的细粒度控制。
¥They offer fine grained control over the visibility of attributes and methods.
使用构造函数创建的对象可以自由混合可观察的属性和方法以及不可观察的属性和方法。
¥Objects created using a constructor function can freely mix observable properties and methods, and non-observable properties and methods.
它们很容易识别,并且可以进行严格的类型检查。
¥They are easily recognizable and can be strictly type-checked.
域存储示例
¥Example domain store
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 stores
ui-state-store 通常非常适合你的应用,但通常也非常简单。该存储通常没有太多逻辑,但会存储大量有关 UI 的松散耦合的信息。这是理想的选择,因为大多数应用在开发过程中会经常更改 UI 状态。
¥The ui-state-store is often very specific for your application, but usually very simple as well. This store typically doesn't have much logic in it, but will store a plethora of loosely coupled pieces of information about the UI. This is ideal as most applications will change the UI state often during the development process.
你通常会在 UI 存储中找到的东西:
¥Things you will typically find in UI stores:
会议信息
¥Session information
有关应用加载程度的信息
¥Information about how far your application has loaded
后台不会保存的信息
¥Information that will not be stored in the backend
影响全局 UI 的信息
¥Information that affects the UI globally
窗户尺寸
¥Window dimensions
无障碍信息
¥Accessibility information
当前语言
¥Current language
当前活动主题
¥Currently active theme
用户界面状态一旦影响多个、进一步不相关的组件:
¥User interface state as soon as it affects multiple, further unrelated components:
当前选择
¥Current selection
工具栏等的可见性
¥Visibility of toolbars, etc.
巫师的状态
¥State of a wizard
全局覆盖的状态
¥State of a global overlay
这些信息很可能一开始是特定组件的内部状态(例如工具栏的可见性),但过了一段时间后,你发现应用中的其他地方需要这些信息。在这种情况下,你不必像在普通 React 应用中那样将状态在组件树中向上推送,只需将该状态移动到 ui-state-store 即可。
¥It might very well be that these pieces of information start as internal state of a specific component (for example the visibility of a toolbar), but after a while you discover that you need this information somewhere else in your application. Instead of pushing state in such a case upwards in the component tree, like you would do in plain React apps, you just move that state to the ui-state-store.
对于同构应用,你可能还希望提供具有合理默认值的此存储的存根实现,以便所有组件按预期渲染。你可以通过应用将 ui-state-store 作为 React 上下文传递来分发它。
¥For isomorphic applications you might also want to provide a stub implementation of this store with sane defaults so that all components render as expected. You might distribute the ui-state-store through your application by passing it as React context.
存储示例(使用 ES6 语法):
¥Example of a store (using ES6 syntax):
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
}
}
合并多个存储
¥Combining multiple stores
一个经常被问到的问题是如何在不使用单例的情况下组合多个存储。他们将如何了解彼此?
¥An often asked question is how to combine multiple stores without using singletons. How will they know about each other?
一种有效的模式是创建一个实例化所有存储并共享引用的 RootStore
。这种模式的优点是:
¥An effective pattern is to create a RootStore
that instantiates all stores, and share references. The advantage of this pattern is:
设置简单。
¥Simple to set up.
很好地支持强类型。
¥Supports strong typing well.
使复杂的单元测试变得容易,因为你只需实例化根存储即可。
¥Makes complex unit tests easy as you just have to instantiate a root store.
示例:
¥Example:
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 上下文将该根存储插入到组件树中。
¥When using React, this root store is typically inserted into the component tree by using React context.