在此分享一個我們自己開發的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框架裡的幾個主要角色包括:
- Dispatcher:串起整個Flux的靈魂人物(但在開發過程中最不重要,因為dispatcher只需宣告一次,從此你再也不需要改它了)
- Action:事情就是從action開始的,dispatcher負責把action派發給感興趣的store,而action有兩種:
- Server action:與server有關的action,如page init或web API結果回傳
- View action:與UI/使用者行為有關的action,如link onClick或表單元件的onChange
- Store:存放應用程式資料與商業邏輯的地方;store處理完它感興趣的action之後,通常會發出一個change event
- Controller-view:各個React view tree的根,會聽取相關store的change event;通常controller-view收到change event之後,會去一些store重新取得應用程式的狀態,並將相關資料以props的形式pass給底下的view,讓React重新render畫面
(圖: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
- 每個action creators模組應該包含一個擴充自
Fluent.Actions
的actions類別,而它的類別方法(class methods)就定義了各個action creators。 - 在每個action creator方法中,通常會呼叫
this.dispatch(...)
,以將此action分派給對這個action有興趣的各個store。 - 每個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
- 每個store模組應該包含一個擴充自
Fluent.Store
的store類別,和一個包含以下action handlers的物件:- 一個view action handlers物件
viewActionHandlers
,及/或 - 一個server action handlers物件
serverActionHandlers
。
- 一個view action handlers物件
- 關於action handler的幾點說明:
- 在
viewActionHandlers
或serverActionHandlers
中定義的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。)
- 在
- 每個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
- 每個controller-view模組應該包含一個擴充自
React.Component
的controller-view類別。 - 每個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()
會被自動呼叫)
- 參數1:
(說明: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的方法如下:
- 定義一個server action creator
init(payload)
,這個payload物件包含所有stores初始化需要的資料。 - 為每個store定義一個server action handler
init(payload)
,它必須是同步的(synchronous)。每個store都從這個payload物件拿出各自所需的資料,初始化它的local資料結構。 - 伺服器端:
- 呼叫server action creator
init(payload)
,再呼叫renderToString
。 - 除了原有的HTML之外,還要再輸出
<script id="setup" type="application/json">${jsonString}</script>
,其中jsonString = JSON.stringify(payload)
,這是為了讓client端能使用與server端一模一樣的初始化資料。
- 呼叫server action creator
- 瀏覽器端:
- 呼叫server action creator
init(setup)
,其中setup = JSON.parse(document.getElementById('setup').innerHTML)
。 - 呼叫
ReactDOM.render
(在更早版本的React則是呼叫React.render
)。
- 呼叫server action creator
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的pushState
、replaceState
與popstate
event做到的。
關於HTML5的pushState
,已經有很多文章討論,這裡假設你已經瞭解相關技術了。那麼,我們是怎麼將應用程式的整個state保存起來的?其實這跟前面談到的server action creator init(payload)
有直接關係。因為render某個特定page(根據網址得來)的所有資料,都在該page的payload物件裡面,所以我們只要保存一個從URL對應到payload的hash table,就能輕易的根據網址,隨時render出整個React頁面。當然實做中還有一些小問題要處理,例如Back/Forward切換到其它網站時,這時的行為與在同一網站裡是不一樣的。這些實做的問題也很有趣,以後如果有機會,我再談一談這方面的經驗。
三、幾個Flux開發的要訣
在開發React/Flux的過程中,我們認為以下幾件事特別重要:
-
不可以讓store提供setter方法給外部使用。
- 要做甚麼事,由store在收到dispatcher分派來的action時,自己決定。
- 其他人可藉由呼叫action creator以「建議」store做一些事,但不可以、也不應該直接操作store的資料。
-
將controller-view與view清楚區分開來。
- Controller-view:聽取store的change event,從store取得新的應用程式狀態。
- View:屬於整個view tree的一部份,這個tree的root是一個controller-view;而view通常可以藉著呼叫view action creator的方式,對UI event做出回應。
-
將server action與view action清楚區分開來。
- View action:由view元件觸發(例如在onClick handler中);而且在view action creators中,經常會進一步觸發server action。
- Server action:由web API callback觸發(通常出現在view action creator的程式代碼中)
-
關於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,能夠幫到一些人。
有任何問題歡迎提出來,我們會盡力為大家解答!