使用 RxJS 實現一個簡易的仿 Elm 架構應用


使用 RxJS 實現一個簡易的仿 Elm 架構應用

標簽(空格分隔): 前端


什么是 Elm 架構

Elm 架構是一種使用 Elm 語言編寫 Web 前端應用的簡單架構,在代碼模塊化、代碼重用以及測試方面都有較好的優勢。使用 Elm 架構,可以非常輕松的構建復雜的 Web 應用,無論是面對重構還是添加新功能,它都能使項目保持良好的健康狀態。

Elm 架構的應用通常由三部分組成——模型更新視圖。這三者之間使用 Message 來相互通信。

模型

模型通常是一個簡單的 POJO 對象,包含了需要展示的數據或者是界面顯示邏輯的狀態信息,在 Elm 語言中,通常是自定義的“記錄類型”,模型對象及其字段都是不可變的(immutable)。使用 TypeScript 的話,可以簡單的用接口來描述模型:

export interface IHabbitPresetsState {
    presets: IHabbitPreset[];
    isLoading: boolean;
    isOperating: boolean;
    isOperationSuccess: boolean;
    isEditing: boolean;
}

這時候,我們就需要在心中謹記,永遠不要去修改模型的字段!

Message

Message 用來定義應用程序在運行過程中可能會觸發的事件,例如,在一個秒表應用中,我們會定義“開始計時”、“暫停計時”、“重置”這三種事件。在 Elm 中,可以使用 Union Type 來定義 Message,如果使用 TypeScript 的話,可以定義多個消息類,然后再創建一個聯合類型定義:

export type HabbitPresetsMsg =
    Get | Receive
    | Add | AddResp
    | Delete | DeleteResp
    | Update | UpdateResp
    | BeginEdit | StopEdit;

export class Get {
}

export class Receive {
    constructor(public payload: IHabbitPreset[]) { }
}

export class Add {
    constructor(public payload: IHabbitPreset) { }
}

export class AddResp {
    constructor(public payload: IHabbitPreset) {
    }
}

export class Delete {
    constructor(public payload: number) {
    }
}

export class DeleteResp {
    constructor(public payload: number) { }
}

export class Update {

    constructor(public payload: IHabbitPreset) {
    }
}

export class UpdateResp {
    constructor(public payload: IHabbitPreset) {
    }
}

export class BeginEdit {
    constructor(public payload: number) { }
}

export class StopEdit {
}

我們的應用程序一般從視圖層來觸發 Message,比如,在頁面加載完畢后,就立即觸發“加載數據”這個 Message,被觸發的 Message 由更新模塊來處理。

更新

更新,即模型的更新方式,通常是一個函數,用 TypeScript 來描述這個函數就是:

update(state: IHabbitPresetsState, msg: HabbitPresetsMsg): IHabbitPresetsState

每當一個新的 Message 被觸發的時候,Elm 架構便會將應用程序當前的模型跟接受到 Message 傳入 update 函數,再把執行結果作為應用程序新的模型——這就是模型的更新。
在 Elm 程序中,視圖的渲染僅依賴模型中的數據,所以,模型的更新往往會導致視圖的更新。

視圖

Elm 語言自帶了一個前端的視圖庫,其特點是視圖的更新僅依賴模型的更新,幾乎所有的 Message 也都是由視圖來觸發。但在這篇文章里面,我將使用 Angular5 來演示效果,當然了,也可以使用 React 或者 jQuery 來實現視圖,這取決於個人愛好。

小結

至此,我們大致的了解了一下 Elm 架構的幾個要點:模型、更新、視圖以及 Message。一個 Elm 架構的程序,通常是視圖因為用戶的動作觸發特定 Message,然后由這個觸發的 Message 跟當前應用的模型計算得出新的模型,新的模型的產生使得視圖產生變化。

開始實現

首先讓我們寫出一個空的框架:

export class ElmArch<TState, TMsgType> {
}

TState 表示應用程序的模型類型,TMsgType 表示應用程序的消息聯合類型。

由上一節可以知道,Message 是應用程序能夠運行的關鍵,Message 在運行時要能夠手動產生,並且,Message 的觸發還要能被監聽,所以,可以使用 RxJS/Subject 來構建一個 Message 流。

export class ElmArch<TState, TMsgType> {
    private readonly $msg = new Subject<TMsgType>();
    send(msg: TMsgType) {
        this.$msg.next(msg);
    }
}

這里之所以定義一個 send 函數是為了更好的將代碼封裝起來,消息流對外只暴露一個觸發消息的接口。

接下來,我們可以考慮一下模型流的實現。他跟消息流很類似,首先要能被監聽,其次,還接收到消息后還要能手動產生,所以也可以使用 Subject 來實現。但是這里我用的是 BehaviorSubject ,因為 Behavior Subject 能夠保留最后產生的對象,這樣我們就可以隨時訪問模型里面的數據,而不需要使用 Subscribe。

$res = new BehaviorSubject<TState>(initState);

至此,1/3 的工作已經完成了,現在來按照我們的要求,使用 rxjs 讓消息流能正確的觸發模型流的更新。

this.$msg.scan(this.update, initState)
            .subscribe((s: TState) => {
                    $res.next(s);
            });

scan 是 rxjs 的一個操作符,類似於 JS 中的 reduce,LINQ 中的 Aggregate。因為設置了一個初始模型(initState),所以在消息流每次產生新的消息的時候,update 函數就可以接收到上一次計算出來的模型,以及最新接收到的消息,然后返回新的模型。也就是說,scan 將消息流轉化為了新的模型流。接着訂閱這個模型流,並用之前定義的 BehaviorSubject 來廣播新的模型。

這里就接近完成 1/2 的工作了,模型跟消息這兩個的東西已經實現好了,接下來就繼續實現更新。

Elm 是一門函數式語言,模式匹配的能力比 js 不知道高到哪里去了,既然要模仿 Elm 架構,那么這個地方也要仿出來。

type Pattern<TMsg, TState, TMsgType> =
    [new (...args: any[]) => TMsg, (acc: TState, msg: TMsg, $msg: Subject<TMsgType>) => TState];

    /**
     * Pattern matching syntax
     * @template TMsg
     * @param {new (...args: any[]) => TMsg} type constructor of Msg
     * @param {(acc: TState, msg: TMsg, $msg: Subject<TMsgType>) => TState} reducer method to compute new state
     * @returns {Pattern<TMsg, TState, TMsgType>}
     * @memberof ElmArch
     */
    caseOf<TMsg>(
        type: new (...args: any[]) => TMsg,
        reducer: (acc: TState, msg: TMsg, $msg: Subject<TMsgType>) => TState)
        : Pattern<TMsg, TState, TMsgType> {
        return [type, reducer];
    }

    matchWith<TMsg>($msg: Subject<TMsgType>, patterns: Pattern<TMsg, TState, TMsgType>[]) {
        return (acc: TState, msg: TMsg) => {
            const state = acc;
            for (const it of patterns) {
                if (msg instanceof it[0]) {
                    return it[1](state, msg, $msg);
                }
            }
            throw new Error('Invalid Message Type');
        };
    }

首先我們定義了一個元組類型 Pattern 用來表示模式匹配的語法,在這里面,主要需要實現的是基於類型的匹配,所以元組的第一個元素是消息類,第二個參數是當匹配成功時要執行的回調函數,用來計算新的模型,使用 caseOf 函數可以創建這種元組。matchWith 函數的返回值是一個函數,與 scan 的第一個參數的簽名相符合,第一個參數是最后被創建出來的模型,第二個參數是接收到的消息。在這個函數中,我們找到與接收到的消息相匹配的 pattern 元組,然后用這個元組的第二個元素計算出新的模型。

用上面的東西就可以比較好的模擬模式匹配的功能了,寫出來的樣子像這樣:

const newStateAcc = matchWith(msg, [
            caseOf(GetMonth, (s, m, $m) => {
                // blablabla
            }),
            caseOf(GetMonthRecv, (s, m) => {
                // blablabla
            }),
            caseOf(ChangeDate, (s, m) => {
                // blablabla
            }),
            caseOf(SaveRecord, (s, m, $m) => {
                // blablabla
            }),
            caseOf(SaveRecordRecv, (s, m) => {
                // blablabla
            })
        ])

這樣,之前用來構建模型流的地方就需要做一些改動:

this.$msg.scan(this.matchWith(this.$msg, patterns), initState)
            .subscribe((s: TState) => {
                    $res.next(s);
            });

現在構建模型流需要依賴一個初始狀態跟一個模式數組,那么就可以用一個函數封裝起來,將這兩個依賴項作為參數傳入:

begin(initState: TState, patterns: Pattern<any, TState, TMsgType>[]) {
        const $res = new BehaviorSubject<TState>(initState);
        this.$msg.scan(this.matchWith(this.$msg, patterns), initState)
            .subscribe((s: TState) => {
                    $res.next(s);
            });
        return $res;
    }

到目前為止,2/3 的工作就已經完成了,我們設計出了消息流、模型流以及處理消息的更新方法,做一個簡單的計數器是完全沒有問題的。點擊查看樣例

但是實際上,我們需要面對的問題遠不止一個計數器這么簡單,更多的情況是處理請求,有時候還需要處理消息的時候觸發新的消息。對於異步的請求,需要在請求的響應中觸發新的消息,可以直接調用 $msg.next() ,對於需要在更新的操作中觸發新的消息,也可以主動調用 $msg.next() 這個函數就好了。

不過,事情往往沒有這么簡單,因為模型流並不是從消息流直接通過 rxjs 的操作符轉換出來的,而更新函數中模式匹配部分執行時間長短不一,這可能導致消息與模型更新順序不一致的問題。我想出的解決方法是:對於同步的操作需要觸發新的消息,就必須要保證當前消息處理完成后,模型的更新被廣播出去后才能觸發新的消息。基於這一准則,我就又添加了一些代碼:

type UpdateResult<TState, TMsgType> = TState | [TState, TMsgType[]];

/**
* Generate a result of a new state with a sets of msgs, these msgs will be published after new state is published
* @param {TState} newState
* @param {...TMsgType[]} msgs
* @returns {UpdateResult<TState, TMsgType>}
* @memberof ElmArch
*/
nextWithCmds(newState: TState, ...msgs: TMsgType[]): UpdateResult<TState, TMsgType> {
    if (arguments.length === 1) {
        return newState;
    } else {
        return [newState, msgs];
    }
}

在這里,我添加了新的類型—— UpdateResult<TState, TMsgType>,這個類型表示模型類型或模型類型與消息數組類型的元組類型。這么說起來確實有些繞口,這個類型存在的意義就是:Update 函數除了返回新的模型之外,還可以選擇性的返回接下來要觸發的消息。這樣,單純的模型流就變成了模型消息流,接着在 subscribe 的地方,在原先的模型流產生新的模型的地方后面再去觸發新的消息流,如果返回結果中有需要觸發的消息的話。

完整代碼在此:https://gist.github.com/ZeekoZhu/c10b30815b711db909926c172789dfd2

使用樣例

在上面的 gits 中提到了一個樣例,但是不是很完整,之后會放出完整例子。

總結

看到這里,你可能已經發現了,本文實現的這個小工具看起來跟 redux 挺像的,確實,redux 也是 js 程序員對 Elm 架構的致敬。通過把 Web 應用的邏輯拆解成一個個狀態間改變的邏輯,可以幫助我們更好的理解所編寫的東西,同時,也讓 MV* 的思想得到進一步的展現,因為在編寫 update 相關的代碼的時候,可以在實現業務邏輯的同時而毫不碰觸 UI 層面的東西,所以,正如本文開頭提到的,視圖可以是任何東西:React、Angular、jQuery,這都沒關系,只要能夠對模型的 Observable 流的改變做出響應, DOM API 也是可以的,可能,這就是所謂的響應式編程吧。

對於普通的 Angular 應用來說意味這什么?

在我自己將這個小工具結合 Angular 的使用體驗來看,最大的改變就是代碼變得更加有規律了,特別是處理異步並改變 UI 的場景,變得更容易套路化,更容易套路化就意味着更方便生成代碼了。再一個,在 Angualr 中,如果組件依賴的所有輸入都是 Observable 對象,那么可以將默認的變更檢查策略改為:OnPush。這樣,Angular 就不用對這個組件進行“臟檢查”了,只有在 Observable 發生更新的時候,才會去重新改變組件,這個好處,不言而喻。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM