4-0

FluentJS: 一個超輕量Flux框架實做(< 100行)

在此分享一個我們自己開發的Flux library: https://github.com/twincl/fluent。Flux是Facebook提出的、搭配React的JavaScript應用程式框架概念。

一、前言

我們自己用React開發一個網站(就是你現在正在看的https://tw.twincl.com網站)已經快一年了,這是一個前端用純ReactJS、後端用純NodeJS的網站。在我去年3月離開Yahoo之前,Yahoo就已經全面朝React邁進了,而且2014下半年我還在Yahoo內部推動過React的study group,所以我算是一位早期的React熱烈擁護者。 :-)

FluentJS(就是我現在分享的這個library),是我在深入研究各種Flux的implementation、並結合我那時在Yahoo實際遇到的各種問題之後,所開始的一個想法。在我離開Yahoo自行創業之後,這個library也經過了近一年的實戰與改善,我認為應該算是成熟了,所以把它open source出來。

FluentJS的重要優點如下:

  • 非常小(不到100行),而且忠於Facebook Flux的精神
  • 可以做server-side rendering(就是在NodeJS端可以將整個React網頁先render好)
  • 以ES2015(ES6)的class語法為基礎,並與Facebook的Flow type checker相容(例如你若用錯誤的參數呼叫action creator或store method,Flow type checker會偵測到錯誤)
  • 用法簡潔,code寫起來很簡單,但又不會感覺有許多black magic破壞邏輯透明性
  • 非侵入性(non-intrusive),只處理Flux框架的部份,不介入你的應用程式架構(例如你要用Express或Koa、用甚麼router或middle ware都可以)

如果你已經很熟悉Facebook Flux了,瞭解FluentJS用法的最快方式,就是直接看fluent-chat範例。這個範例是從Facebook的flux-chat直接改過來的,從這個commit可以看到程式碼改了哪些地方。

二、用法

要安裝FluentJS,請打:

npm install --save fluent-js

如果你還不清楚Facebook Flux是怎麼回事,建議你先花點時間研究一下。Flux框架最重要的精神,就是每個人都負責管好自己的事就好,千萬不要雞婆去管別人。Flux框架裡的幾個主要角色包括:

  1. Dispatcher:串起整個Flux的靈魂人物(但在開發過程中最不重要,因為dispatcher只需宣告一次,從此你再也不需要改它了)
  2. Action:事情就是從action開始的,dispatcher負責把action派發給感興趣的store,而action有兩種:
    • Server action:與server有關的action,如page init或web API結果回傳
    • View action:與UI/使用者行為有關的action,如link onClick或表單元件的onChange
  3. Store:存放應用程式資料與商業邏輯的地方;store處理完它感興趣的action之後,通常會發出一個change event
  4. Controller-view:各個React view tree的根,會聽取相關store的change event;通常controller-view收到change event之後,會去一些store重新取得應用程式的狀態,並將相關資料以props的形式pass給底下的view,讓React重新render畫面

Flux
(圖:Flux diagram - Facebook Flux官網)

1. Dispatcher

FluentJS已經包含Facebook Flux的Dispatcher了,所以你的專案不需再另外引用flux模組。你的dispatcher模組應該長這樣:

var Dispatcher = require('fluent-js').Dispatcher;
module.exports = new Dispatcher();

或是(採用ES2015的模組語法):

import {Dispatcher} from 'fluent-js';
export default new Dispatcher();

2. Actions

  1. 每個action creators模組應該包含一個擴充自Fluent.Actions的actions類別,而它的類別方法(class methods)就定義了各個action creators。
  2. 在每個action creator方法中,通常會呼叫this.dispatch(...),以將此action分派給對這個action有興趣的各個store。
  3. 每個action creators模組通常會輸出一個actions物件,此物件是由以上的actions類別生成的。在生成此物件時,應帶1至2個參數:
    • 參數1:dispatcher
    • 參數2(選擇性):這個類別是server actions或不是;true代表server actions,false(預設值)代表view actions

範例:

var Fluent = require('fluent-js');
var AppDispatcher = require('../dispatcher/AppDispatcher');

class MyViewActions extends Fluent.Actions {
  someAction(param1, param2) {
    this.dispatch(param1, param2);
    ...
  }
  anotherAction() {
    ...
  }
}

module.exports = new MyViewActions(AppDispatcher);

3. Stores

  1. 每個store模組應該包含一個擴充自Fluent.Store的store類別,和一個包含以下action handlers的物件:
    • 一個view action handlers物件viewActionHandlers,及/或
    • 一個server action handlers物件serverActionHandlers
  2. 關於action handler的幾點說明:
    • viewActionHandlersserverActionHandlers中定義的action handler函式名稱,應該與這個store感興趣的action creators模組中定義的action creator名稱一致。
    • action handler函式的參數,應該與action creator中呼叫this.dispatch(...)時的參數一致,但不需與action creator本身一致(當然要一致也可以)。
    • 它的this綁定(bind)為store物件。
    • 它可以呼叫this.waitFor([SomeStore, AnotherStore])以等待其它store完成對這個action的處理。
    • 它可以return false,以阻止change event的發出。(FluentJS的預設行為,是在每個action handler返回時,自動向所有聽取此store的controller-view發出change event。)
  3. 每個store模組通常會輸出一個store物件,此物件是由以上的store類別生成的。在生成此物件時,應帶2個參數:
    • 參數1:dispatcher
    • 參數2:action handlers物件

範例:

class MyStore extends Fluent.Store {
   ...
}

var actionHandlers = {
  viewActionHandlers: {
    someAction(param1, param2) { ... }
    anotherAction() { ... }
  },
  serverActionHandlers: {
    someServerAction(param) { ... }
  }
};

module.exports = new MyStore(AppDispatcher, actionHandlers);

4. Controller-Views

  1. 每個controller-view模組應該包含一個擴充自React.Component的controller-view類別。
  2. 每個controller-view模組通常會輸出一個React component類別,而此類別是呼叫Fluent.connectToStores所傳回的,呼叫時應帶3至4個參數:
    • 參數1:React模組
    • 參數2:controller-view類別
    • 參數3:一個陣列,列出要聽取的store模組
    • 參數4(選擇性):在這個controller-view的constructor中指派的onChange handler屬性名稱(若未帶此參數,則每次store change event發出時,this.forceUpdate()會被自動呼叫)

(說明:Fluent.connectToStores的做法,是參考Dan Abramov的部落格文章而來,這是為了解決新的class-based React元件不能使用mixin的問題。)

範例:

function getStateFromStores() {
  return { ... };
}

class MyControllerView extends React.Component {
  constructor(props) {
    super(props);
    this.state = getStateFromStores();
    this.onChange = this._onChange;
  }
  _onChange() { ... }
  render() { ... }
  ...
}

module.exports = Fluent.connectToStores(React, MyControllerView, [SomeStore, AnotherStore], 'onChange');

5. Server-Side Rendering

這個tw.twincl.com網站的任何網頁,你都可以在網址加上?ssr=1參數,就能看到傳回來的網頁原始碼裡,伺服器已經render好React元件了。要在FluentJS做到server-side rendering,有很多種做法,但最重要的一點,就是你在store初始化或React元件render的過程中,不可以做任何非同步呼叫(asynchronous call,例如web API或資料庫呼叫)。所有非同步的工作,都必須在你呼叫任何action creator或renderToString前就先做好。只要你嚴格遵守此一原則,你完全不需要用到任何React context(或其它context)。這個要求聽起來好像有點嚴格,但相信我,這樣做好處很多。它會使你的程式邏輯簡單明瞭,減少你日後頭痛的時間。

我們自己實做server-side rendering的方法如下:

  1. 定義一個server action creator init(payload),這個payload物件包含所有stores初始化需要的資料。
  2. 為每個store定義一個server action handler init(payload),它必須是同步的(synchronous)。每個store都從這個payload物件拿出各自所需的資料,初始化它的local資料結構。
  3. 伺服器端:
    • 呼叫server action creator init(payload),再呼叫renderToString
    • 除了原有的HTML之外,還要再輸出<script id="setup" type="application/json">${jsonString}</script>,其中jsonString = JSON.stringify(payload),這是為了讓client端能使用與server端一模一樣的初始化資料。
  4. 瀏覽器端:
    • 呼叫server action creator init(setup),其中setup = JSON.parse(document.getElementById('setup').innerHTML)
    • 呼叫ReactDOM.render(在更早版本的React則是呼叫React.render)。

6. Single-Page Application (SPA)

整個tw.twincl.com網站,是個單一網頁應用程式(Single-Page Application),也就是說當使用者進入這個網站之後,除了少數例外(例如登入網站),所有與這個網站的互動都不需再重新載入新的網頁,因此能帶來更好的使用者體驗。你可以打開瀏覽器的developer console,觀察你在使用Twincl網站的過程中,瀏覽器會向伺服器發送哪些network request?例如你給這篇文章+1(謝謝!記得要先登入喔),+1之後可以看到新的「留言」(推文)按鈕,留言之後新的留言就會出現在網頁上;以上這些動作,都沒有離開原來的網頁,只有背景發送一些Ajax call。

你也可以按左上角的Twincl logo回到首頁、按「程式設計」連結以打開相關討論區、或打開一些文章閱讀。這些在網站內的瀏覽行為,都沒有離開原來的網頁,但瀏覽器的網址列會顯示不同的對應網址;此外,你可以用瀏覽器的Back/Forward切換到不同網頁,此時瀏覽器甚至沒有向伺服器發送任何請求。這是利用HTML5的pushStatereplaceStatepopstate event做到的。

關於HTML5的pushState,已經有很多文章討論,這裡假設你已經瞭解相關技術了。那麼,我們是怎麼將應用程式的整個state保存起來的?其實這跟前面談到的server action creator init(payload)有直接關係。因為render某個特定page(根據網址得來)的所有資料,都在該page的payload物件裡面,所以我們只要保存一個從URL對應到payload的hash table,就能輕易的根據網址,隨時render出整個React頁面。當然實做中還有一些小問題要處理,例如Back/Forward切換到其它網站時,這時的行為與在同一網站裡是不一樣的。這些實做的問題也很有趣,以後如果有機會,我再談一談這方面的經驗。

三、幾個Flux開發的要訣

在開發React/Flux的過程中,我們認為以下幾件事特別重要:

  1. 不可以讓store提供setter方法給外部使用。

    • 要做甚麼事,由store在收到dispatcher分派來的action時,自己決定。
    • 其他人可藉由呼叫action creator以「建議」store做一些事,但不可以、也不應該直接操作store的資料。
  2. 將controller-view與view清楚區分開來。

    • Controller-view:聽取store的change event,從store取得新的應用程式狀態。
    • View:屬於整個view tree的一部份,這個tree的root是一個controller-view;而view通常可以藉著呼叫view action creator的方式,對UI event做出回應。
  3. 將server action與view action清楚區分開來。

    • View action:由view元件觸發(例如在onClick handler中);而且在view action creators中,經常會進一步觸發server action。
    • Server action:由web API callback觸發(通常出現在view action creator的程式代碼中)
  4. 關於web API call的幾個原則:

    • 與web API call有關的代碼,應該集中在一些web API utility函式庫中,並獨立於React/Flux代碼之外。
    • 每個web API函式應該帶兩個選擇性的callback參數,一個給on success用,一個給on failure用。
    • 通常web API都是由於view回應使用者的操作、在view action creator中被呼叫的(例如投票或參與討論)。在web API的callback中,server action creator可能會被進一步呼叫,以便相關的store可以接收/處理web API傳回的結果。

四、結論

React和Flux是Facebook送給web應用程式開發者的兩個大禮物。這兩樣東西加上Flow type checker(也是Facebook送的──感謝Facebook!)、ESLint與一些ES2015的新特性,使得用JavaScript開發應用程式成為一件令人愉快的事。

我們使用FluentJS已經有一段時間,相信它應該夠成熟了,所以把它open source分享出來給大家。希望這個小小的library,能夠幫到一些人。

有任何問題歡迎提出來,我們會盡力為大家解答!

0筆討論 回應文章