定義資料儲存區
本節包含一些在 Mendix 使用 MobX 時,我們發現構建大型可維護專案的最佳實務。本節帶有主觀意見,您絕非被迫應用這些實務。使用 MobX 和 React 有很多方法,而這只是其中之一。
本節著重於一種不唐突的 MobX 使用方式,它適用於現有程式碼庫或經典的 MVC 模式。其他更具主觀意見的儲存區組織方式是 mobx-state-tree 和 mobx-keystone。兩者都內建了諸如結構共享快照、動作中介軟體、JSON patch 支援等酷炫功能。
儲存區
儲存區可以在任何 Flux 架構中找到,並且可以與 MVC 模式中的控制器進行比較。儲存區的主要職責是將*邏輯*和*狀態*從您的組件移到一個獨立的可測試單元,該單元可以在前端和後端 JavaScript 中使用。
大多數應用程式都受益於至少擁有兩個儲存區:一個用於*領域狀態*,另一個用於*UI 狀態*。將這兩者分開的優點是您可以普遍地重複使用和測試*領域狀態*,並且您很可能在其他應用程式中重複使用它。
領域儲存區
您的應用程式將包含一個或多個*領域*儲存區。這些儲存區儲存您的應用程式所關聯的資料。待辦事項、使用者、書籍、電影、訂單等等。您的應用程式很可能至少有一個領域儲存區。
單個領域儲存區應負責應用程式中的單個概念。單個儲存區通常組織為樹狀結構,其中包含多個領域物件。
例如:一個領域儲存區用於您的產品,另一個用於您的訂單和訂單明細。根據經驗:如果兩個項目之間關係的本質是包含,則它們通常應位於同一個儲存區中。因此,儲存區僅管理*領域物件*。
以下是儲存區的職責
- 實例化領域物件。確保領域物件知道它們所屬的儲存區。
- 確保每個領域物件只有一個實例。相同的使用者、訂單或待辦事項不應在記憶體中儲存兩次。這樣您可以安全地使用參考,並且還可以確保您正在查看最新實例,而無需解析參考。這在除錯時快速、直接且方便。
- 提供後端整合。在需要時儲存資料。
- 如果從後端收到更新,則更新現有實例。
- 提供應用程式的獨立、通用、可測試組件。
- 為了確保您的儲存區是可測試的並且可以在伺服器端運行,您可能會將實際的 websocket / http 請求移到一個單獨的物件,以便您可以抽象化您的通訊層。
- 儲存區應該只有一個實例。
領域物件
每個領域物件都應該使用它自己的類別(或建構函式)來表達。不需要將您的客戶端應用程式狀態視為某種資料庫。 真實參考、循環資料結構和實例方法是 JavaScript 中強大的概念。 領域物件允許直接參考來自其他儲存區的領域物件。 請記住:我們希望盡可能簡化我們的動作和視圖,而需要自己管理參考和進行垃圾收集可能是一種倒退。 與許多 Flux 架構(例如 Redux)不同,使用 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 的資訊
- 視窗尺寸
- 輔助功能資訊
- 目前語言
- 目前啟用的主題
- 只要它影響多個進一步不相關的組件,使用者介面狀態
- 目前選取
- 工具列的可見性等
- 精靈的狀態
- 全局覆蓋的狀態
很可能這些資訊最初是特定組件的內部狀態(例如工具列的可見性),但過了一段時間後,您發現您在應用程式的其他地方需要此資訊。在這種情況下,您只需將該狀態移至 *ui-state-store*,而不是像在純 React 應用程式中那樣將狀態向上推送到組件樹中。
對於 isomorphic 應用程式,您可能還希望提供此儲存區的 stub 實現以及合理的預設值,以便所有組件都能按預期呈現。您可以通過將 *ui-state-store* 作為 React context 傳遞來將其分佈到您的應用程式中。
儲存區範例(使用 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
}
}
組合多個 store
一個常見的問題是如何在不使用單例模式的情況下組合多個 store。它們要如何互相感知?
一個有效的模式是創建一個 RootStore
來實例化所有 store,並共享引用。這個模式的優點是:
- 設定簡單。
- 良好地支援強類型。
- 讓複雜的單元測試變得容易,因為您只需要實例化一個 root store。
範例
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 時,這個 root store 通常會使用 React context 插入到組件樹中。