[譯] 我們如何拋棄了 Redux 而選用 MobX

lihaobhsfer發表於2019-12-27

我們如何拋棄了 Redux 而選用 MobX

在 Skillshare 我們擁抱改變;不僅因為把它寫在公司的前景宣言中很酷,也因為改變確實有必要。這是我們近期將整個平臺遷移至 React 並利用其所有優勢這一決定背後的前提。執行這個任務的小組僅僅是我們工程師團隊的一小部分。儘早做出正確的決定對於讓團隊其他成員儘可能快而順暢地切換平臺來說至關重要。

順暢的開發體驗就是一切。

一切。

然後,在將 React 引入我們的程式碼庫時,我們遇到了前端開發最有挑戰的一部分:狀態管理

唉…接下來就有意思了。

設定

一切都開始於簡單的任務:“將 Skillshare 的頁頭遷移至 React。”

小菜一碟!”我們立下了 flag —— 這個頁頭只是訪客檢視,只包含幾個連結和一個簡單的搜尋框。沒有授權邏輯,沒有 session 管理,沒有什麼特別神奇的東西。

好的,來敲點程式碼吧:

interface HeaderProps {
    searchBoxProps: SearchBoxProps;
}

class Header extends Component<HeaderProps> {
    render() {
        return (
            <div>
                <SearchBox {...this.props.searchBoxProps} />
            </div>
        );
    }
}
    
interface SearchBoxProps {
    query?: string;
    isLoading: string;
    data: string[];
    onSearch?: (query: string) => void;
}

class SearchBox extends Component<SearchBoxProps> {
    render() {
        // 渲染元件…
    }
}
複製程式碼

沒錯,我們用 TypeScript —— 它是最簡潔、最直觀、對所有開發者最友好的語言。怎能不愛它呢?我們還使用 Storybook 來做 UI 開發,所以我們希望讓元件越傻瓜越好,在越高層級將其拼接起來越好。由於我們用 Next 做服務端渲染,那個層級就是頁面元件,它們最終就僅僅是廣為人知的位於指定 pages 目錄中的元件,並在執行時自動對映到 URL 請求中。所以,如果你有一個 home.tsx 檔案,它就會被自動對映到 /home 路由 —— 和 renderToString() 說再見吧。

好的,元件就講到這裡吧…但等一下!實現搜尋框功能還需要制定一個狀態管理策略,本地狀態不會給我們帶來什麼長足發展。

對抗:Redux

在 React 中,提到狀態管理時,Redux 就是黃金法則 —— 它在 Github 上有 4w+ Star(截至英文原文釋出時。截至本譯文釋出時已有 5w+ Star。),完全支援 TypeScript,並且像 Instagram 這樣的大公司也用它。

下圖描述了它的原理:

圖片來自 [@abhayg772](http://twitter.com/abhayg772)

不像傳統的 MVW 樣式,Redux 管理一個覆蓋整個應用的狀態樹。UI 觸發 actions,actions 將資料傳遞給 reducer,reducer 更新狀態樹,並最終更新 UI。

非常簡單,對不?再來敲點程式碼!

這裡涉及到的實體是標籤。因此,當使用者在搜尋框中輸入時,搜尋框搜尋的是標籤

/* 這裡有三個 action:
 *   - 標籤搜尋: 當使用者進行輸入時,觸發新的搜尋。
 *   - 標籤搜尋更新: 當搜尋結果準備好,必須進行更新。
 *   - 標籤搜尋報錯:發生了不好的事情。
 */

enum TagActions {
    Search = 'TAGS_SEARCH',
    SearchUpdate = 'TAGS_SEARCH_UPDATE',
    SearchError = 'TAGS_SEARCH_ERROR',
}

interface TagsSearchAction extends Action {
    type: TagActions.Search;
    query: string;
}

interface TagsSearchUpdateAction extends Action {
    type: TagActions.SearchUpdate;
    results: string[];
}

interface TagsSearchErrorAction extends Action {
    type: TagActions.Search;
    err: any;
}

type TagsSearchActions = TagsSearchAction | TagsSearchUpdateAction | TagsSearchErrorAction;
複製程式碼

還挺簡單的。現在我們需要一些幫助函式,基於輸入引數來動態建立 actions:

const search: ActionCreator<TagsSearchAction> =
    (query: string) => ({
        type: TagActions.Search,
        query,
    });

const searchUpdate: ActionCreator<TagsSearchUpdateAction> =
    (results: string[]) => ({
        type: TagActions.SearchUpdate,
        results,
    });

const searchError: ActionCreator<TagsSearchErrorAction> =
    (err: any) => ({
        type: TagActions.SearchError,
        err,
    });

複製程式碼

搞定!接下來是負責基於 action 更新 state 的 reducer:

interface State {
    query: string;
    isLoading: boolean;
    results: string[];
}

const initialState: State = {
    query: '',
    isLoading: false,
    results: [],
};

const tagSearchReducer: Reducer<State> =
    (state: State = initialState, action: TagsSearchActions) => {
        switch ((action as TagsSearchActions).type) {
            case TagActions.Search:
                return {
                    ...state,
                    isLoading: true,
                    query: (action as TagsSearchAction).query,
                };

            case TagActions.SearchUpdate:
                return {
                    ...state,
                    isLoading: false,
                    results: (action as TagsSearchUpdateAction).tags,
                };

            case TagActions.SearchError:
                return {
                    ...state,
                    isLoading: false,
                    results: (action as TagsSearchErrorAction).err,
                };

            default:
                return state;
        }
    };
複製程式碼

這段程式碼還挺長的,但是我們正在取得進展!所有的拼接都在最頂層進行,即我們的頁面元件。

interface HomePageProps {
    headerProps?: HeaderProps;
}

class HomePage extends Component<IndexPageProps> {
    render() {
        return (
            <Header {...this.props.headerProps} />
            <!-- the rest of the page .. -->
        );
    }
}

const mapStateToProps = (state: State) => ({
    headerProps: {
        searchBoxProps: {
            isLoading: state.isLoading,
            results: state.results,
            query: state.query,
        }
    }
});
    
const mapDispatchToProps = (dispatch: Dispatch) => ({
    headerProps: {
        searchBoxProps: {
            onSearch: (query: string) => dispatch(TagActions.search(query)),
        }
    }
});
    
const connectedPage = connect(mapStateToProps, mapDispatchToProps)(HomePage);
    
const reducers = combineReducers({
    tagSearch: tagSearchReducer,
});

const makeStore = (initialState: State) => {
    return createStore(reducers, initialState);
}

// 所有都匯聚於此 —— 看吧:拼接完成的首頁!
export default withRedux(makeStore)(connectedPage);
複製程式碼

任務完成!撣撣手上的灰來瓶啤酒吧。我們已經有了 UI 元件,一個頁面,所有的部分都完好地組接在一起。

Emmm…等一下。

這只是本地狀態。

我們仍需要從真正的 API 獲取資料。Redux 要求 actions 為純函式;他們必須立即可執行。什麼不會立即執行?像從API獲取資料這樣的非同步操作。因此,Redux 必須與其他庫配合來實現此功能。有不少可選用的庫,比如 thunkseffectsloopssagas,每一個都有些差別。這不僅僅意味著在原本就陡峭的學習曲線上又增加坡度,並且意味著更多的模板。

[譯] 我們如何拋棄了 Redux 而選用 MobX

當我們在泥濘中艱難前行中,那個顯而易見的問題不停地迴響在我們的腦海中:這麼多行程式碼,就為了繫結一個搜尋框?我們確信,任何一個有勇氣檢視我們程式碼庫的人都會問同樣的問題。

我們不能 Diss Redux;它是這個領域的先鋒,也是一個優雅的概念。然而,我們發現它過於“低階”,需要你親自定義一切。它一直由於有非常明確的思想,能避免你在強制一種風格的時候搬起石頭砸自己的腳而受到好評,但這些所有都有代價,代價就是大量的模板程式碼和一個巨大的學習障礙。

這我們就忍不了了。

我們怎麼忍心告訴我們的團隊,他們假期要來加班,就因為這些模板程式碼?

肯定有別的工具。

更加友好的工具。

你並不一定需要 Redux

解決方案:MobX

起初,我們想過建立一些幫助函式和裝飾器來解決程式碼重複。而這意味著需要維護更多程式碼。並且,當核心幫助函式出問題,或者需要新的功能,在修改它們時可能會迫使整個團隊停止工作。三年前寫的、整個應用都在用的幫助函式程式碼,你也不想再碰,對不?

然後我們有了一個大膽的想法…

“如果我們根本不用 Redux 呢?”

“還有啥別的可以用?”

點了一下“我今天感覺很幸運”按鈕,我們的到了答案:MobX

MobX 保證了一件事:保證你做你的工作。它將響應式程式設計的原則應用於 React 元件 —— 沒錯,諷刺的是,React 並不是開箱即具備響應式特點的。不像 Redux,你可以有很多個 store(比如 TagsStoreUsersStore 等等),或者一個總的 store,將它們繫結於元件的 props 上。它幫助你管理狀態,但是如何構建它,決定權在你手裡。

圖片來自 [Hanno.co](https://hanno.co/blog/mobx-redux-alternative/)

所以我們現在整合了 React,完整的 TypeScript 支援,還有極簡的模板。

還是讓程式碼為自己代言吧。

我們首先定義 store:

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

export class TagsStore {
    private static defaultState: any = {
        query: '',
        isLoading: false,
        results: [],
    };

    @observable public results: string[];

    @observable public isLoading: boolean;

    @observable public query: string;

    constructor(initialState: any) {
        extendObservable(this, {...defaultState, ...initialState});
    }

    @action public loadTags = (query: string) => {
        this.query = query;

        // 一些業務程式碼…
    }
}

export interface StoreMap {
    tags: TagsStore,
}
複製程式碼

然後拼接一下頁面:

import React, { Component } from 'react';
import { inject, Provider } from 'mobx-react';

import { Header, HeaderProps } from './header';


export interface HomePageProps {
    headerProps?: HeaderProps;
}

export class HomePage extends Component<IndexPageProps> {
    render() {
        return (
            <Header {...this.props.headerProps} />
            <!-- the rest of the page .. -->
        );
    }
}

export interface StoreMap {
    tags: TagsStore;
}
    
export const ConnectedHomePage = inject(({ tags }: StoreMap) => ({
    headerProps: {
        searchBoxProps: {
            query: tags.query,
            isLoading: tags.isLoading,
            data: tags.data,
            onSearch: tags.loadTags,
        }
    }
}));

export const tagsStore = new TagsStore();
        
export default () => {
    return (
        <Provider tags={tagsStore}>
            <ConnectedHomePage>
        </Provider>
    );
}
複製程式碼

就這樣搞定了!我們已經實現了所有在 Redux 例子中有的功能,不過我們這次只用了幾分鐘。

程式碼相當清晰了,不過為了說明白,inject 幫助函式來自於 MobX React;它與 Redux 的 connect 幫助函式對標,只不過它的 mapStateToPropsmapDispatchToProps 在一個函式當中。 Provider 元件也來自於 MobX,可以在裡面放任意多個 store,它們都會被傳遞至 inject 幫助函式中。並且,快看看那些迷人的,迷人的裝飾器 —— 就這樣配置 store 就對了。所有用 @observable 裝飾的實體都會通知被繫結的元件在發生改變後重新渲染。

這才叫“直觀”。

還需多說什麼?

然後,關於訪問 API,是否還記得 Redux 不能直接處理非同步操作?是否還記得你為了實現非同步操作不得不使用 thunks(它們非常不好測試)或者 sagas(非常不易理解)?那麼,有了 MobX,你可以用普普通通的類,在建構函式裡注入你選擇的 API 訪問庫,然後在 action 裡執行。還想念 sagas 和 generator 函式嗎?

請看吧,這就是 flow 幫助函式!

import { action, flow } from 'mobx';

export class TagsStore {

    // ..

    @action public loadTags = flow(function * (query: string) {
        this.query = query;
        this.isLoading = true;

        try {
            const tags = yield fetch('http://somewhere.com/api/tags');
            this.tags = tags;
        } catch (err) {
            this.err = err;
        }

        this.isLoading = false;
    })
}   
複製程式碼

這個 flow 幫助函式用 generator 函式來產出步驟 —— 響應資料,記錄呼叫,報錯等等。它是可以漸進執行或在需要時暫停的一系列步驟。

一個流程! 懂了不?

那些需要解釋為什麼sagas要這叫這名字的時光結束了。感謝上蒼,就連 generator 函式都顯得不那麼可怕了。

Javascript (ES6) Generators — 第一部分: 瞭解 Generators

結果

雖然到目前為止一切都顯得那麼美好,但不知為何還是有一種令人不安的感覺 —— 總感覺逆流而上總會遭到命運的報復。或許我們仍舊需要那一堆模板程式碼來強制一些標準。或許我們仍舊需要一個有明確思想的框架。或許我們仍舊需要一個清晰定義的狀態樹。

如果我們想要的是一個看上去像 Redux 但是和 MobX 一樣方便的工具呢?

如果是這樣,來看看 MobX State Tree 吧。

通過 MST,我們通過一個專門的 API 來定義狀態樹,並且是不可修改的,允許你回滾,序列化或者再組合,以及所有你希望一個有明確思想的狀態管理庫所擁有的東西。

多說無用,來看程式碼!

import { flow } from 'mobx';
import { types } from 'mobx-state-tree';

export const TagsStoreModel = types
    .model('TagsStore', {
        results: types.array(types.string),
        isLoading: types.boolean,
        query: types.string,
    })
    .actions((self) => ({
        loadTags: flow(function * (query: string) {
            self.query = query;
            self.isLoading = true;

            try {
                const tags = yield fetch('http://somewhere.com/api/tags');
                self.tags = tags;
            } catch (err) {
                self.err = err;
            }

            self.isLoading = false;
        }
    }));

export const StoreModel = types
    .model('Store', {
        tags: TagsStoreModel,
    });

export type Store = typeof StoreModel.Type;
複製程式碼

與其讓你在狀態管理上為所欲為,MST 通過要求你用它的規定的方式定義狀態樹。有人可能回想,這就是有了鏈式函式而不是類的 MobX,但是還有更多。這個狀態樹無法被修改,並且每一次修改都會建立一個新的“快照”,從而允許了回滾,序列化,再組合,以及所有你想念的功能。

再來看遺留下來的問題,唯一的低分項是,這對 MobX 來講僅僅是一個部分可用的方法,這意味著它拋棄了類和裝飾器,意味著 TypeScript 支援只能是盡力而為了。

但即便如此,它還是很棒!

好的我們繼續來構造整個頁面。

import { Header, HeaderProps } from './header';
import { Provider, inject } from 'mobx-react';

export interface HomePageProps {
    headerProps?: HeaderProps;
}

export class HomePage extends Component<IndexPageProps> {
    render() {
        return (
            <Header {...this.props.headerProps} />
            <!-- the rest of the page .. -->
        );
    }
}

export const ConnectedHomePage = inject(({ tags }: Store) => ({
    headerProps: {
        searchBoxProps: {
            query: tags.query,
            isLoading: tags.isLoading,
            data: tags.data,
            onSearch: tags.loadTags,
        }
    }
}));

export const tagsStore = new TagsStore();
        
export default () => {
    return (
        <Provider tags={tagsStore}>
            <ConnectedHomePage>
        </Provider>
    );
}
複製程式碼

看到了吧?連線元件還是通過同樣的方式,所以花在從 MobX 遷移到 MST 的精力遠小於編寫 Redux 模板程式碼。

那為啥我們沒一步到底選 MST 呢?

其實,MST 對於我們的具體例子來說有點殺雞用牛刀了。我們考慮使用它是因為回滾操作是一個非常不錯的附加功能,但是當我們發現有 Delorean 這麼個東西的時候就覺得沒必要再費力氣遷移了。以後我們可能會遇到 MobX 對付不了的情況,但是因為 MobX 很謙遜隨和,即便返回去重新用上 Redux 也變得不再令人頭大。

總之,MobX,我們愛你。

願你一直優秀下去。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章