讀Mobx官方文檔中的最佳實踐有感,并結合一些自己項目經驗。總結一下遇到的坑,和預備的解決方案。
用了半年的redux,依稀記得剛開始用Redux的時候到處查找文檔想知道組織state結構的最佳實踐,因為在todo list的demo中用list代表todos,并不知道這個狀態下的todos是純數據,還是頁面中展示數據的緩存。意思是,不太明白:
- 是store為頁面服務(將頁面中需要的可能會變更的數據另外存儲起來)
- 還是數據就是數據,頁面只是取得數據(類似于數據庫)
最后搜到了作者的一句話,大概就是,隨便你怎么用,用出花來都行,鼓勵大家自主創新。所以在前期寫頁面時我也主要使用的一種比較流行的方式,就是上面的方式1,store為頁面服務。
0x00 store為頁面服務,以及遇到的坑
在前期使用這種方案真心不要太爽:
- 看著設計圖想想所需要的數據
- 按照這些數據構建每個頁面的state樹節點
- 寫可能的action處理每一個用戶行為
- 構建好用戶請求用到的異步action
- 寫reducer處理請求到的數據
- 接入現實數據
- 測試
完美,action寫法統一,管理方便,reducer寫法統一,管理方便,數據流下來,刷新不用自己處理(setState)。但是,最終發現,自己的項目,單頁面這樣寫寫很不錯,但是并不適合寫一個完整的app或者前后端同構的web app。(遇到啥問題下面說,先看看這種store的組織方式怎么做)
0x01 什么是store為頁面服務
即根節點往下是各個頁面,頁面中的組件對應著state子樹中的節點。
在React中,一個頁面是由若干個嵌套的組件構成,每個組件都有相應的數據輸入,最終這些數據輸入可以反映成一個樹狀結構,最后我們直觀的使用這個樹狀結構到state樹上,就如下圖所示。
0x02 遇到了什么問題
- 重復頁面倒退
- 數據同步
重復頁面倒退是指如下這種情況:
假設這種情況:在React Native和Redux構建的一個App中,我們從首頁feed流進入某個id為1的文章的詳情頁,從詳情頁進入了某個推薦列表,然后又從這個推薦列表進入了另一個id為2的文章的詳情頁。
現在問題來了,無論是id為1的詳情頁,還是id為2的詳情頁,它們用的都是同一個state樹的分支(可能叫state.detailPage
),所以當頁面瀏覽到id為2的詳情頁的時候,數據請求完畢之后設置到state.detailPage
分支,id為1的數據就被替換掉了。如果這個時候要回退到id為1的詳情頁,就必須得重新獲取數據。否則就還是id為2的數據。
在web應用里,用戶比較習慣在白頁中重新加載數據(當回退的時候),可是在app中,這是不符合習慣的,而且在app中頁面發生了變化,只是向一個頁面的堆棧中壓入一個新的頁面,之前的頁面并不會釋放調,我們習慣性將獲取頁面數據放入componentWillMount或者constructor或者任一個生命周期函數中,都不會執行(也就是說不會重新獲取數據)。
第二個問題是數據同步。
還記得flux的引入是為了解決什么問題嗎?
facebook右上角的消息提醒總是莫名其妙的出現,因為同是消息提醒的數據在不同的數據源中可能重復存有多份。使用flux可以讓數據的源頭只有一個,所有的展示都是通過一個源頭流下來的數據產生的,某個頁面中我讀取了當前的所有消息,發送一個action告訴數據源,現在未讀消息變為0了,然后所有地方的未讀消息,受這個數據的改變都變成0了。
然而如果采用頁面即數據的這種方案,即使數據源只有一個,但是同一種數據也有可能在多個地方存儲過。例如上面的例子:列表頁中可能有某個文章的標題(在列表頁樹分支的某個節點上),這個文章的詳情頁也有這個文章的標題,假設我在某個地方(假設是詳情頁)更改了這個樹分支上的標題,其他樹分支上標題并不會改變(例如列表頁,因為是不同的數據),依然沒有解決flux根本想解決的問題。
0x10 尋求解決方案
兩個問題都有其各自單獨的解決方案。
要解決相同頁面會退的問題,就必須區分id為1和id為2的數據,例如,我們可以在state.detailPage[1]
和state.detailPage[2]
中分別存放id為1的詳情頁的數據和id為2的詳情頁的數據,然而redux的combine并不能動態的增加分支,分之節點都是事先預置好的,要實現這種,我們只能自己寫中間件,或者自己實現插入分支(我是這樣做的)。
如果要解決數據同步的問題,有兩種方案:第一種,使用事件機制,所有要跟著變動的地方,建一個變更的事件,當變更的時候觸發這個事件,讓所有相關的地方發生改變;第二種,不管是列表的標題還是詳情的標題,都只存一次,存在一個地方,那么不同地方取的都是同一個數據,就可以自然同步了。
方案一讓人感覺redux并沒有幫上什么忙,第二種方法不太好實現,在實際中,我們混合使用了兩種方案。
這兩個問題看下來,讓人第一感受是:數據(按照ID區分,會同時出現在多處的那種)和頁面需要分離,數據以表的形式存在,并且只存一次。
如果解決這個問題呢?還是以上面可能的app為例:
我們建立一個叫文章的state下的子樹,其是一個id, value的map,用id區分(當然,得自己實現),當然也會有一個叫首頁的子樹,但是首頁只有一個,所以它可以正常來,但是首頁的feed流list只是一個id的list,其并不包含具體數據,具體數據都在叫文章的子樹里。
在reducer獲取的時候,先將列表接口獲取的已有的數據賦給文章map對應id的各個文章,然后向列表頁(首頁feed)返回一個id的列表。列表頁要取詳情,就去文章的map中自己取。到了詳情頁,向后端接口獲取詳情數據,再將文章map中,讓正在訪問的這條的信息更新的更完備。
0x11 另一個構建store的方法 - Mobx
其實總得來說flux應該是一套從后端到前端一路向下的數據解決方案,而不應該僅僅只是用在react的前端這塊的數據處理,要是這樣的話,可能它方便之處并不在于單一數據源。而應該在于前端開發和調試的時候,能規整代碼結構,讓數據可追溯,并且可以很方便的緩存數據。而如果用上面提交的方案來處理前端數據,首先id動態生成數據redux是天然支持的,我們得用其提供的方法自行實現。
另外,有很多reducers其實并沒有做數據處理,只是簡單的把數據做了轉發,而獲取數據由往往是通過異步的action來實現的,那這樣的reducer是否有必要存在?
Mobx實際上是為了解決這樣麻煩的reducer而產生的,直接讓action改動數據,然后用雙向綁定的方式將數據直接映射到界面中。用來簡化前端的數據流程。他和MVVM的不同處在于數據是單獨出來的作為store的存在。在react組件中綁定store中的數據,類似于以前打模板的方式,當store變化時自動就會映射到界面中,所有的數據操作都在action中進行。
https://mobxjs.github.io/mobx/
如此,我們就不必在意頁面取什么數據了,store就看成數據庫,使用mobx提供的observable.map生成按照id -> value的鍵值對來處理不同id的同種數據。
0x12 最佳實踐給的靈感
官方文檔給的給出了建議的構建store的方式:https://mobxjs.github.io/mobx/best/store.html
Most applications benefit from having at least two stores. One for the UI state and one or more for the domain state.
建議我們至少新建兩個store(實際上應該是兩種),一個UI state一個domain state:
- UI state是指當前UI的狀態,比如:窗口尺寸、當前展示的頁面、渲染狀態、網絡狀態等等
- Domain state則主要包含頁面所需的各種數據(一般是需要從后端獲取的)。例如:
- 文章詳情(id為索引的數據表)
- 首頁feed(只有一個,不需要列表)
- 推薦列表(推薦id索引的數據表,每一項的內容又是一個文章id的列表)
其新建store的方式也并不和redux一樣,在mobx中,一個store是一個類,而具體的state則是它的實例。
另外,所有需要按照id區分,多處會用到或者修改的數據,應單獨抽象成一個domain state。某store內部自己需要的,按照id區分的數據,可單獨以map的形式存在某store內部。
在它官方給的例子中,只有一個domain state,就是TodoStore,用來存儲todo list和相應的操作(這些操作可以聲明成action)如果整個app中只有一個todo list的話,那整個state就是一個TodoStore的實例了。
官方代碼略
class TodoStore
這樣抽象下來的話,todo也可以抽象成一個類,而每個todo item都是todo的實例,多個todo存儲在todoStore中,也滿足我們對整個數據的抽象。
官方代碼略
class Todo
假設我們以后要新增查看todo item的詳情(例如:里面有具體計劃之類的)。我們也都是對同一個todo的對象進行操作。而具體我們展現的是那個todo item的頁面,我們可以放到ui state中。
不知道有沒有比較好理解,可以留言反饋下,或者實際操作下Mobx
0x20 效果
還是以上面的例子來說明,頁面有:首頁feed、詳情頁、推薦列表
0x21 創建store
import {
observable, action, extendObservable
} from 'mobx'
// ui state
export const ui = observable({
pendingRequests: 0,
})
// 首頁feed流數據
class HomeStore {
@observable feed: string[] = []
@action('獲取feed流') async fetchFeed() {
const data = await requestFromServer()
// 請求接口并且獲得了數據 data
this.feed = data.list.map(item => {
const id = item.id
if(!detail.has(id))
detail.set(id, new Detail(item))
return id
})
}
}
// 需要是一個map的store,比如文章詳情,推薦列表等等
class mapStore<T> {
@observable data = observable.map<T>()
get(id: string) { return this.data.get(id) }
set(id: string, value: Detail) { this.data.set(id, value) }
has(id: string) { return this.data.has(id) }
}
// 文章詳情
class Detail {
id: string
// ...其他屬性
constructor(item: any) {
extendObservable(this, item)
}
@action('獲取詳情') async fetch() {
const data = await requestFromServer(this.id)
extendObservable(this, data)
}
@action('保存編輯') async save(data) {
extendObservable(this, data)
await submitToServer(data)
await this.fetch()
}
}
// 推薦列表
class Recommend {
@observable id: string = null
@observable list: any[] = []
constructor (id: string) {
this.id = id
this.fetch()
}
@action('獲取推薦列表') async fetch() {
this.list = await requestFromServer()
}
}
export const detail = new mapStore<Detail>()
export const home = new HomeStore
export const recommend = new mapStore<Recommend>()
0x22 頁面取數據
就單獨以一個首頁為例子吧,現在首頁的feed流中只有id,而具體數據都充detailStore中取
import * as React from 'react'
import { View, Text, ListView } from 'react-native'
import { observer } from 'mobx-react/native'
import { detail, home } from './stores'
const ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
})
export const Home = observer((props: any) => {
const list = home.feed.map(id => detail.get(id))
return <ListView
dataSource={ds.cloneWithRows(list)}
renderRow={(item) => <Text>{item.content}</Text>}
/>
})
假設我們現在進入詳情頁,修改了某個文章
import { detail } from './stores'
// ...
detail.get(id).save(changedData)
列表頁會實時變動。