如何組織Mobx/Redux中的Store

讀Mobx官方文檔中的最佳實(shí)踐有感,并結(jié)合一些自己項(xiàng)目經(jīng)驗(yàn)。總結(jié)一下遇到的坑,和預(yù)備的解決方案。

用了半年的redux,依稀記得剛開(kāi)始用Redux的時(shí)候到處查找文檔想知道組織state結(jié)構(gòu)的最佳實(shí)踐,因?yàn)樵趖odo list的demo中用list代表todos,并不知道這個(gè)狀態(tài)下的todos是純數(shù)據(jù),還是頁(yè)面中展示數(shù)據(jù)的緩存。意思是,不太明白:

  1. 是store為頁(yè)面服務(wù)(將頁(yè)面中需要的可能會(huì)變更的數(shù)據(jù)另外存儲(chǔ)起來(lái))
  2. 還是數(shù)據(jù)就是數(shù)據(jù),頁(yè)面只是取得數(shù)據(jù)(類(lèi)似于數(shù)據(jù)庫(kù))

最后搜到了作者的一句話(huà),大概就是,隨便你怎么用,用出花來(lái)都行,鼓勵(lì)大家自主創(chuàng)新。所以在前期寫(xiě)頁(yè)面時(shí)我也主要使用的一種比較流行的方式,就是上面的方式1,store為頁(yè)面服務(wù)。

0x00 store為頁(yè)面服務(wù),以及遇到的坑

在前期使用這種方案真心不要太爽:

  1. 看著設(shè)計(jì)圖想想所需要的數(shù)據(jù)
  2. 按照這些數(shù)據(jù)構(gòu)建每個(gè)頁(yè)面的state樹(shù)節(jié)點(diǎn)
  3. 寫(xiě)可能的action處理每一個(gè)用戶(hù)行為
  4. 構(gòu)建好用戶(hù)請(qǐng)求用到的異步action
  5. 寫(xiě)reducer處理請(qǐng)求到的數(shù)據(jù)
  6. 接入現(xiàn)實(shí)數(shù)據(jù)
  7. 測(cè)試

完美,action寫(xiě)法統(tǒng)一,管理方便,reducer寫(xiě)法統(tǒng)一,管理方便,數(shù)據(jù)流下來(lái),刷新不用自己處理(setState)。但是,最終發(fā)現(xiàn),自己的項(xiàng)目,單頁(yè)面這樣寫(xiě)寫(xiě)很不錯(cuò),但是并不適合寫(xiě)一個(gè)完整的app或者前后端同構(gòu)的web app。(遇到啥問(wèn)題下面說(shuō),先看看這種store的組織方式怎么做)

0x01 什么是store為頁(yè)面服務(wù)

即根節(jié)點(diǎn)往下是各個(gè)頁(yè)面,頁(yè)面中的組件對(duì)應(yīng)著state子樹(shù)中的節(jié)點(diǎn)。

在React中,一個(gè)頁(yè)面是由若干個(gè)嵌套的組件構(gòu)成,每個(gè)組件都有相應(yīng)的數(shù)據(jù)輸入,最終這些數(shù)據(jù)輸入可以反映成一個(gè)樹(shù)狀結(jié)構(gòu),最后我們直觀(guān)的使用這個(gè)樹(shù)狀結(jié)構(gòu)到state樹(shù)上,就如下圖所示。

page based state.png

0x02 遇到了什么問(wèn)題

  1. 重復(fù)頁(yè)面倒退
  2. 數(shù)據(jù)同步

重復(fù)頁(yè)面倒退是指如下這種情況:

wenti1.png

假設(shè)這種情況:在React Native和Redux構(gòu)建的一個(gè)App中,我們從首頁(yè)feed流進(jìn)入某個(gè)id為1的文章的詳情頁(yè),從詳情頁(yè)進(jìn)入了某個(gè)推薦列表,然后又從這個(gè)推薦列表進(jìn)入了另一個(gè)id為2的文章的詳情頁(yè)。

現(xiàn)在問(wèn)題來(lái)了,無(wú)論是id為1的詳情頁(yè),還是id為2的詳情頁(yè),它們用的都是同一個(gè)state樹(shù)的分支(可能叫state.detailPage),所以當(dāng)頁(yè)面瀏覽到id為2的詳情頁(yè)的時(shí)候,數(shù)據(jù)請(qǐng)求完畢之后設(shè)置到state.detailPage分支,id為1的數(shù)據(jù)就被替換掉了。如果這個(gè)時(shí)候要回退到id為1的詳情頁(yè),就必須得重新獲取數(shù)據(jù)。否則就還是id為2的數(shù)據(jù)。

在web應(yīng)用里,用戶(hù)比較習(xí)慣在白頁(yè)中重新加載數(shù)據(jù)(當(dāng)回退的時(shí)候),可是在app中,這是不符合習(xí)慣的,而且在app中頁(yè)面發(fā)生了變化,只是向一個(gè)頁(yè)面的堆棧中壓入一個(gè)新的頁(yè)面,之前的頁(yè)面并不會(huì)釋放調(diào),我們習(xí)慣性將獲取頁(yè)面數(shù)據(jù)放入componentWillMount或者constructor或者任一個(gè)生命周期函數(shù)中,都不會(huì)執(zhí)行(也就是說(shuō)不會(huì)重新獲取數(shù)據(jù))。

第二個(gè)問(wèn)題是數(shù)據(jù)同步。

還記得flux的引入是為了解決什么問(wèn)題嗎?

facebook右上角的消息提醒總是莫名其妙的出現(xiàn),因?yàn)橥窍⑻嵝训臄?shù)據(jù)在不同的數(shù)據(jù)源中可能重復(fù)存有多份。使用flux可以讓數(shù)據(jù)的源頭只有一個(gè),所有的展示都是通過(guò)一個(gè)源頭流下來(lái)的數(shù)據(jù)產(chǎn)生的,某個(gè)頁(yè)面中我讀取了當(dāng)前的所有消息,發(fā)送一個(gè)action告訴數(shù)據(jù)源,現(xiàn)在未讀消息變?yōu)?了,然后所有地方的未讀消息,受這個(gè)數(shù)據(jù)的改變都變成0了。

然而如果采用頁(yè)面即數(shù)據(jù)的這種方案,即使數(shù)據(jù)源只有一個(gè),但是同一種數(shù)據(jù)也有可能在多個(gè)地方存儲(chǔ)過(guò)。例如上面的例子:列表頁(yè)中可能有某個(gè)文章的標(biāo)題(在列表頁(yè)樹(shù)分支的某個(gè)節(jié)點(diǎn)上),這個(gè)文章的詳情頁(yè)也有這個(gè)文章的標(biāo)題,假設(shè)我在某個(gè)地方(假設(shè)是詳情頁(yè))更改了這個(gè)樹(shù)分支上的標(biāo)題,其他樹(shù)分支上標(biāo)題并不會(huì)改變(例如列表頁(yè),因?yàn)槭遣煌臄?shù)據(jù)),依然沒(méi)有解決flux根本想解決的問(wèn)題。

0x10 尋求解決方案

兩個(gè)問(wèn)題都有其各自單獨(dú)的解決方案。

要解決相同頁(yè)面會(huì)退的問(wèn)題,就必須區(qū)分id為1和id為2的數(shù)據(jù),例如,我們可以在state.detailPage[1]state.detailPage[2]中分別存放id為1的詳情頁(yè)的數(shù)據(jù)和id為2的詳情頁(yè)的數(shù)據(jù),然而redux的combine并不能動(dòng)態(tài)的增加分支,分之節(jié)點(diǎn)都是事先預(yù)置好的,要實(shí)現(xiàn)這種,我們只能自己寫(xiě)中間件,或者自己實(shí)現(xiàn)插入分支(我是這樣做的)。

如果要解決數(shù)據(jù)同步的問(wèn)題,有兩種方案:第一種,使用事件機(jī)制,所有要跟著變動(dòng)的地方,建一個(gè)變更的事件,當(dāng)變更的時(shí)候觸發(fā)這個(gè)事件,讓所有相關(guān)的地方發(fā)生改變;第二種,不管是列表的標(biāo)題還是詳情的標(biāo)題,都只存一次,存在一個(gè)地方,那么不同地方取的都是同一個(gè)數(shù)據(jù),就可以自然同步了。

方案一讓人感覺(jué)redux并沒(méi)有幫上什么忙,第二種方法不太好實(shí)現(xiàn),在實(shí)際中,我們混合使用了兩種方案。

這兩個(gè)問(wèn)題看下來(lái),讓人第一感受是:數(shù)據(jù)(按照ID區(qū)分,會(huì)同時(shí)出現(xiàn)在多處的那種)和頁(yè)面需要分離,數(shù)據(jù)以表的形式存在,并且只存一次。

如果解決這個(gè)問(wèn)題呢?還是以上面可能的app為例:

我們建立一個(gè)叫文章的state下的子樹(shù),其是一個(gè)id, value的map,用id區(qū)分(當(dāng)然,得自己實(shí)現(xiàn)),當(dāng)然也會(huì)有一個(gè)叫首頁(yè)的子樹(shù),但是首頁(yè)只有一個(gè),所以它可以正常來(lái),但是首頁(yè)的feed流list只是一個(gè)id的list,其并不包含具體數(shù)據(jù),具體數(shù)據(jù)都在叫文章的子樹(shù)里。

在reducer獲取的時(shí)候,先將列表接口獲取的已有的數(shù)據(jù)賦給文章map對(duì)應(yīng)id的各個(gè)文章,然后向列表頁(yè)(首頁(yè)feed)返回一個(gè)id的列表。列表頁(yè)要取詳情,就去文章的map中自己取。到了詳情頁(yè),向后端接口獲取詳情數(shù)據(jù),再將文章map中,讓正在訪(fǎng)問(wèn)的這條的信息更新的更完備。

0x11 另一個(gè)構(gòu)建store的方法 - Mobx

其實(shí)總得來(lái)說(shuō)flux應(yīng)該是一套從后端到前端一路向下的數(shù)據(jù)解決方案,而不應(yīng)該僅僅只是用在react的前端這塊的數(shù)據(jù)處理,要是這樣的話(huà),可能它方便之處并不在于單一數(shù)據(jù)源。而應(yīng)該在于前端開(kāi)發(fā)和調(diào)試的時(shí)候,能規(guī)整代碼結(jié)構(gòu),讓數(shù)據(jù)可追溯,并且可以很方便的緩存數(shù)據(jù)。而如果用上面提交的方案來(lái)處理前端數(shù)據(jù),首先id動(dòng)態(tài)生成數(shù)據(jù)redux是天然支持的,我們得用其提供的方法自行實(shí)現(xiàn)。

另外,有很多reducers其實(shí)并沒(méi)有做數(shù)據(jù)處理,只是簡(jiǎn)單的把數(shù)據(jù)做了轉(zhuǎn)發(fā),而獲取數(shù)據(jù)由往往是通過(guò)異步的action來(lái)實(shí)現(xiàn)的,那這樣的reducer是否有必要存在?

Mobx實(shí)際上是為了解決這樣麻煩的reducer而產(chǎn)生的,直接讓action改動(dòng)數(shù)據(jù),然后用雙向綁定的方式將數(shù)據(jù)直接映射到界面中。用來(lái)簡(jiǎn)化前端的數(shù)據(jù)流程。他和MVVM的不同處在于數(shù)據(jù)是單獨(dú)出來(lái)的作為store的存在。在react組件中綁定store中的數(shù)據(jù),類(lèi)似于以前打模板的方式,當(dāng)store變化時(shí)自動(dòng)就會(huì)映射到界面中,所有的數(shù)據(jù)操作都在action中進(jìn)行。

https://mobxjs.github.io/mobx/

flow.png

如此,我們就不必在意頁(yè)面取什么數(shù)據(jù)了,store就看成數(shù)據(jù)庫(kù),使用mobx提供的observable.map生成按照id -> value的鍵值對(duì)來(lái)處理不同id的同種數(shù)據(jù)。

0x12 最佳實(shí)踐給的靈感

官方文檔給的給出了建議的構(gòu)建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.

建議我們至少新建兩個(gè)store(實(shí)際上應(yīng)該是兩種),一個(gè)UI state一個(gè)domain state:

  • UI state是指當(dāng)前UI的狀態(tài),比如:窗口尺寸、當(dāng)前展示的頁(yè)面、渲染狀態(tài)、網(wǎng)絡(luò)狀態(tài)等等
  • Domain state則主要包含頁(yè)面所需的各種數(shù)據(jù)(一般是需要從后端獲取的)。例如:
    • 文章詳情(id為索引的數(shù)據(jù)表)
    • 首頁(yè)feed(只有一個(gè),不需要列表)
    • 推薦列表(推薦id索引的數(shù)據(jù)表,每一項(xiàng)的內(nèi)容又是一個(gè)文章id的列表)

其新建store的方式也并不和redux一樣,在mobx中,一個(gè)store是一個(gè)類(lèi),而具體的state則是它的實(shí)例。

另外,所有需要按照id區(qū)分,多處會(huì)用到或者修改的數(shù)據(jù),應(yīng)單獨(dú)抽象成一個(gè)domain state。某store內(nèi)部自己需要的,按照id區(qū)分的數(shù)據(jù),可單獨(dú)以map的形式存在某store內(nèi)部。

在它官方給的例子中,只有一個(gè)domain state,就是TodoStore,用來(lái)存儲(chǔ)todo list和相應(yīng)的操作(這些操作可以聲明成action)如果整個(gè)app中只有一個(gè)todo list的話(huà),那整個(gè)state就是一個(gè)TodoStore的實(shí)例了。

官方代碼略class TodoStore

這樣抽象下來(lái)的話(huà),todo也可以抽象成一個(gè)類(lèi),而每個(gè)todo item都是todo的實(shí)例,多個(gè)todo存儲(chǔ)在todoStore中,也滿(mǎn)足我們對(duì)整個(gè)數(shù)據(jù)的抽象。

官方代碼略class Todo

假設(shè)我們以后要新增查看todo item的詳情(例如:里面有具體計(jì)劃之類(lèi)的)。我們也都是對(duì)同一個(gè)todo的對(duì)象進(jìn)行操作。而具體我們展現(xiàn)的是那個(gè)todo item的頁(yè)面,我們可以放到ui state中。

不知道有沒(méi)有比較好理解,可以留言反饋下,或者實(shí)際操作下Mobx

0x20 效果

還是以上面的例子來(lái)說(shuō)明,頁(yè)面有:首頁(yè)feed、詳情頁(yè)、推薦列表

0x21 創(chuàng)建store

import {
    observable, action, extendObservable
} from 'mobx'

// ui state
export const ui = observable({
    pendingRequests: 0,
})

// 首頁(yè)feed流數(shù)據(jù)
class HomeStore {
    @observable feed: string[] = []
    @action('獲取feed流') async fetchFeed() {
        const data = await requestFromServer()
        // 請(qǐng)求接口并且獲得了數(shù)據(jù) data
        this.feed = data.list.map(item => {
            const id = item.id
            if(!detail.has(id))
                detail.set(id, new Detail(item))
            return id
        })
    }
}

// 需要是一個(gè)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 頁(yè)面取數(shù)據(jù)

就單獨(dú)以一個(gè)首頁(yè)為例子吧,現(xiàn)在首頁(yè)的feed流中只有id,而具體數(shù)據(jù)都充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>}
    />
})

假設(shè)我們現(xiàn)在進(jìn)入詳情頁(yè),修改了某個(gè)文章

import { detail } from './stores'

// ...

detail.get(id).save(changedData)

列表頁(yè)會(huì)實(shí)時(shí)變動(dòng)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容