下一篇:
序
mobx
是一款基於觀察者模式的響應式資料管理框架,相對於redux
來說是後起之秀。
有一種觀點認為mobx
不適合構建大型專案,這源於mobx
過於靈活的特點。靈活即意味著隨意,這在開發日益複雜的大型專案是致命的弱點。redux
則不然,它的唯一資料來源、reducer
純函式、只能通過dispatch
修改狀態等幾個特性保證了程式碼書寫格式的高度統一。
本文不會討論mobx
的使用細節,只會在充分利用mobx
優勢的基礎上,對開發格式進行統一,保證開發大型專案的可維護性。
mobx
的優勢極其優秀,物件導向程式設計、響應式程式設計、mutable
的資料處理方式、精準更新元件的能力,這裡不過多討論。
mobx劣勢
- 0、資料可隨處定義。可以定義在元件內,來替代
state
的作用;也可以定義在單獨的store
內 - 1、使用者互動邏輯可以寫在元件宣告的方法內,也可以寫在
store
宣告的方法內。 - 2、使用者互動往往涉及多個
store
的資料處理,store
間可能形成交叉引用的網狀結構。 - 3、
store
往往按頁面和模組劃分,散落在各處,不好統一管理。 - 4、
store
例項化的時機和方式不可控。 - 5、當單例
store
因為業務變更需要支援多例項時,改造難度極大 - 6、對服務端渲染不友好。
node
端在讀取資料填充頁面時,還需要把資料儲存到頁面,供前端載入時從資料恢復到store
(redxu
的createStore
天然支援從initialState
恢復資料的能力)
面對以上的種種問題,大部分人都會持有mobx
不適合大型專案的觀點。
解決方案
在筆者用mobx
+react
做了諸多中大型的前端專案之後,對這些劣勢深惡痛絕,也逐漸摸索出了一些方案來解決上述的問題。
1、分層
為了解決資料定義,資料共享以及邏輯程式碼如何防止等問題,首先對專案結構進行分層。
- 專案按照頁面進行分割
- 頁面按照
stores
、actions
、views
分為三層 stores
定義頁面內各個資料模型及資料的操作方法,各個store之間互相獨立views
層作為檢視層,接收stores
注入的資料負責渲染actions
層處理互動邏輯,引用各個store
方法呼叫更新資料,又mobx
自動觸發檢視重新整理
以上是一個典型的mvc
分層結構,這種方式很大程度上解決了問題點0、1、2。
2、唯一資料來源
通過第一步的改造,專案的可維護性可謂上升一個臺階。
但是頁面的store
和action
需要手動例項化並手動注入到每個頁面元件,著實是一個負擔。並且store
例項化自由,管理起來較為混亂。並未解決3、4、5的問題。
所以需要開發一個狀態管理庫,主要實現如下功能
store
和action
的自動查詢載入。store
和action
分頁面放置,通過某種機制進行查詢- 查詢到的所有
store
和action
自動例項化,並形成全域性唯一資料來源 store
提供配置單例或多例項的配置項,減少因需求變更導致的程式碼改造工作量- 按需例項化
store
。比如訪問頁面A
,只需例項化A
頁面依賴的store
查詢機制
store
和action
的查詢方式簡單介紹兩種,一種是通過webpack
提供的require.context
動態的引入特定目錄下的store
和action
模組,第二種是通過裝飾器模式進行載入。
虛擬碼如下
//webpack
require.context(`./`,true,/^(.+/)*stores/(.+).(t|j)sx?$/i)
//裝飾器
@store({
path:`pageA.storeA`, //在全域性store中的訪問路徑
type:`singleton`|`multi` // 宣告單例還是多例項
})
class StoreA{
}
// store裝飾器的實現
let store = (config) => target => {
target[`__storeType`] = config.type //儲存
App[`__stores`] = App[`__stores`] || [] //App為狀態管理類
App[`__stores`].push({ target, path: config.path})
return target;
}
複製程式碼
拿到所有store
的資訊之後,就可以在管理類裡對stores
和actions
進行處理,組裝全域性唯一的rootStore
了,action
處理也是一樣。
按需例項化
如果為了追求效能,可以考慮實現這麼一個特性。實現方式可以用訪問器屬性,在訪問到store
屬性時,再進行動態的例項化。虛擬碼如下
Object.defineProperty(rootAction, `storeA`, {
configurable: true,
enumerable: true,
get() {
StoreA[`__instance`] = StoreA[`__instance`] || new StoreA()
return StoreA[`__instance`]
},
set() {
throw Error("can not set store")
}
})
複製程式碼
通過這麼一個狀態管理庫,我們解決了3、4、5,對於問題6 服務端渲染,也可以通過簡單的處理對rootStore
進行恢復。
3、開發體驗優化
(1)path自動宣告
上面的裝飾器@store
需要手動指定store
在rootStore
中所處的節點,能不能通過store
檔案所在的目錄名、檔名、store
類名等資訊直接對映到對應的結構呢?
答案是可以的,只需要編寫一個babel
轉換外掛,在編譯時對檔案的抽象語法樹進行分析替換,自動填充@store
的path
屬性就好了。(筆者專案用的是ts
,提供了一個ts transformer
完成同樣的功能)
(2)腳手架
- 由於頁面結構保持了高度統一,無論是
store
檔案、action
檔案,或是jsx
、css
檔案,都有或多或少的樣板程式碼。為了開發流程的自動化,可以開發腳手架工具,自動生成頁面骨架。一是為了提升開發效率,二可以規範開發流程。 - 如果專案中用到
ts
的話,這種全域性自動載入形成的store
會丟失型別資訊。所以需要自動的生成一份型別宣告檔案(.d.ts
)幫助有更好的開發體驗。
4、開發規範限制
最後一個話題,如何更嚴格的規範程式碼的書寫方式。
即使我們限定了業務邏輯只能在action
內處理,但終歸是口頭約定。老成員總有圖便利把邏輯寫到view
層的時候,新成員剛加入時的程式碼更可能如此。
所以我們需要提供一種機制來保證只能在action
內呼叫store
的方法進行邏輯處理,而在action
外的store
呼叫都無效,並在開發環境給以警告。
這個問題如果你認為很簡單,可能是因為你還沒理解到這個的關鍵點在哪。下面通過例子來討論解決方案。
//宣告一個store
class StoreA{
age = null;
setAge(age){
this.age = age;
}
}
//宣告一個action
class ActionA{
//呼叫store方法
setAge(age){
this.storeA.setAge(age); //有效
}
}
//元件內
storeA.setAge(age) //無效
複製程式碼
對於上述場景,處理方法比較簡單。只需要
- 宣告一個變數
flag
- 在例項化
store
和action
時對例項的方法分別進行包裝 action
的方法呼叫前設定flag
為true
,執行action
的方法,然後設定flag
為false
。- 這樣
store
的方法如果在action
內呼叫時訪問到的flag
為true
,在其他地方訪問到的flag
為false
。 - 對
store
方法的包裝比較簡單,判斷flag
,為true
執行資料操作,為false
進行友好提示
經過上述幾步,就完成了同步場景的限制處理。
但實際的專案中大量的存在非同步操作,如果action
如下所示,會如何呢?
class ActionA{
//呼叫store方法
async setAge(age){
await saveAge(url); //介面呼叫
this.storeA.setAge(age); //有效
}
}
複製程式碼
這時storeA.setAge
雖然處於action
內,但訪問到的flag
卻是false
,方案失效了。
對同步操作的處理如此簡單,非同步操作卻是一個巨大的難題。現在的課題可以抽象為如下描述
如何實現在同一個方法內的呼叫(包括同步操作, setTimeout、promise、rAF、各種事件等非同步操作的回撥內...)都能訪問到同一個上下文(true),而在這個方法外訪問到的是另一個(false)
複製程式碼
內心隱隱約約有一個答案,如果在action
呼叫時儲存這個上下文,並在各種非同步的回撥裡再取出這個上下文即可實現功能。但這是一個可怕的事情,意味著需要我們去代理所有的非同步呼叫,換句話說我們需要覆蓋原生的方法來做這麼一件事情!
這似乎是很難去實現的,直到我發現了zone.js
。
zone.js
簡單介紹一下,zone.js
是angular
框架的核心元件,angular
利用zone.js
監聽所有(可能導致資料變化)的非同步事件。
這跨度有點大,怎麼又扯到了angular
。
沒關係,重新介紹一下。zone.js
描述了JavaScript
執行過程的上下文,可以在非同步任務之間進行永續性傳遞。
重點就是這句話,我翻譯一下,zonejs
能保持同一個方法內的呼叫(無論同步還是非同步的)都能訪問到同一個上下文物件。這不正好解決了我們的問題嗎?
現在利用zonejs
來解決我們之前的問題。程式碼如下
//這裡並沒有闡述zone.js如何使用,如果看過zonejs文件應該很容易理解下面的程式碼所做的事情
const zone = Zone.root.fork({
name: `__mobx__zone`
});
//包裝action的setAge方法,使得action內的方法呼叫訪問到Zone.current都為zone
let oldFn = ActionA.setAge
ActionA.setAge = (...args) => {
return zone.run(oldFn, context, args)
}
//包裝store的方法,判斷Zone.current是否為zone,如果在action之外呼叫則為Zone.root
let oldFn = StoreA.setAge
StoreA.setAge = (...args) => {
if(Zone.current === zone){
return oldFn.apply(context,args)
}else{
//在action外呼叫store方法觸發警告
console.error(`invalid call`)
}
}
//以上的包裝方法均在內部處理,不暴露在業務程式碼中
複製程式碼
利用zone.js
可以很容易的實現我們想要的功能,通過粗略的原始碼瀏覽發現zone.js
正是暴力的代理了原生的api
。
通過上述幾步處理,我們就可以愉快的拿mobx
進行大型專案的構建和持續迭代了。
結尾
本文並未涉及過多的程式碼細節,對於mobx
如何使用也並未闡述。本文著重去解決在使用mobx
過程中可能引發的問題,並且在規範成員的程式碼風格方面做了嘗試,使得在用mobx
進行專案的開發時能最大限度的保證程式碼格式的統一,降低專案的維護成本。
關於如何開發和維護一個大型專案是一個很大的話題,應該在約定或者強制某些規範的基礎上,再根據所處的業務場景進行特定的設計才可能做好。