2015年11月1日 星期日

Lantern 專案:快速打造屬於自己的 Isomorphic 網站服務

Standard

話說,Isomorphic 一直是 Node.js 開發者的夢想,期望同一套程式碼前後端都可以使用,大幅簡化程式碼和加速開發。此外,動態網頁的 SEO 問題也可以同時獲得解決,許多效能問題也可以得到改善。但是,要實現 Isomorphic 的架構,有很多的問題得先解決,會花大量時間在前期工作上,往往讓許多開發者頭痛。

儘管頭痛,仍然阻止不了大家往 Isomorphic 的世界前進,我也因此建立了一個專案「Lantern」,希望能讓更多人能以 Isomorphic 架構,快速建構出自己的網站服務,省去許多前期工作的時間。該專案是一個網站服務的樣板,實作了會員系統、權限管理、第三方登入、多國語系和送信機制等功能,在使用者介面上也做了一個還算美觀的介面。基本上,開發者只要 clone 下來,然後修改設定檔或改改介面、增加點功能,就可以快速完成一個屬於自己的全新網站服務。

最特別的是,「Lantern」整合了現今所有最新的技術和概念,包括了 Koa、React、FLUX、ES6/7+、Webpack 以及 Semantic UI,大量運用了 Generator、class 及 decorator 等最新 JavaScript 語言特性來簡化設計。所以,如果你想要接觸最新的技術,完全可以透過修改「Lantern」專案來學習和熟悉。

目前「Lantern」支援 Facebook 剛發佈的最新 React v0.14+ 和 react-router 1.0.0+,也避免使用像 redux 這類反 FLUX 原始設計的框架,讓原本熟悉 React 和 FLUX 架構的開發者,可以快速上手。也提供一些常見的 Extension,方便開發者寫出前後端通用的程式碼,大多數情況下,開發者不需思考程式碼運行在前端還是後端。

快速安裝使用

若想要使用「Lantern」,方式很簡單,先從 Github 取得程式碼:
git clone git@github.com:cfsghost/lantern.git

安裝必要之 NPM 模組:
npm install

使用 webpack 編譯專案(若要正式上線,可加上 -p 選項來編譯):
webpack

運行網站服務:
node app.js

最後可以使用瀏覽器開啟網址,確認是否成功:
http://localhost:3001/

修改設定檔

一般情況,你無需做任何設定就可以把服務跑起來,但如果你需要修改網站名稱、使用自己的第三方登入設定以及電子郵件伺服器,可以修改 Lantern 的設定檔。設定檔是 JSON 的格式,相當容易修改。


  1. 只要進入到「configs」目錄
  2. 把「general.json.default」複製一份並更名為「general.json」
  3. 修改「general.json」內的設定
  4. 重啟服務

目錄架構

如果你想要開始客製化網站服務,需要先簡單理解「Lantern」的目錄架構。
  • src - 主要程式
    • js - 頁面部分的程式
    • img - 存放圖片
    • less - CSS 原始碼
    • translations - 存放多國語言的對應表
  • routes - 主要為 Restful API
  • lib - 後端的相關函式庫(資料庫、第三方認證、發送電子郵件等功能)
  • models - 資料庫 Schema

快速上手開發

首先記得,只要你修改了「src」底下的任何檔案,你必須重新執行「webpack」來進行編譯。或是可以跑一個「webpack -w」在背景,讓 webpack 在檔案有變更的時候自動重新編譯程式碼:
webpack -w

一般來說,我們會從頁面修改和增減開始進行客製化工作。由於「Lantern」是採用 React 來繪製頁面,所有的頁面程式都將放在「src/js/components」底下,只要看到副檔名為「.jsx」的檔案,就分別是各種畫面上的元件。

建立新的頁面

建立頁面需要修改「src/js/routes.js」,加入一個網址及對應的頁面元件(以 Chatroom.jsx 為例):
module.exports = [
    // 省略 ...
    {
        path: '/chatroom',
        handler: require('./components/Chatroom.jsx')
    }
];

接著可以建立「src/js/components/Chatroom.jsx」檔案,開始設計你的頁面。如果需要使用 FLUX 的機制,可以載入並引入「Lantern」所提供之 decorator 到你的 React 元件上:
import React from 'react';
import { flux } from 'Decorator';

@flux
class MyComponent extends React.Component {
    constructor() {
        super();

        this.state = {
            messages: []
        };
    }

    componentWillMount() {
        this.flux.on('state.Chatroom', this.flux.bindListener(this.onChange));
    }

    componentWillUnmount() {
        this.flux.off('state.Chatroom', this.onChange);
    }

    onChange = () => {
        var store = this.flux.getState('Chatroom');

        this.setState({
            messages: store.messages
        });
    }

    render() {
        return <div>{this.state.messages}</div>;
    }
}

export default MyComponent;

開發自己的 Actions 和 Stores

假設你已經很了解 FLUX 的開發模式,你可以直接開始設計 Action 和 Store。對「Lantern」而言,無論是 Action 和 Store 都是一樣的東西,只不過執行的順序不一樣。

建立 Action(放在 src/js/actions/chatroom.js):
export default function *() {
    this.on('action.Chatroom.say', function *(name, message) {
        this.dispatch('store.Chatroom.addMessage', name + ':' + message);
    });
}; 

建立 Store(放在 src/js/stores/chatroom.js):
export default function *() {
    // 初始化一個 state 用來存放 store 的資料
    var store = this.getState('Chatroom', {
        messages: []
    });

    this.on('store.Chatroom.say', function *(msg) {

        // 加入新訊息到 store
        store.messages.push(msg);

        // State(Store) 已經更新,React 元件會被觸發更新
        this.dispatch('state.Chatroom');
    });
}; 

最後在「actions/index.js」和「stores/index.js」分別載入新建立的 Action 和 Store:
export default {
    // ...省略
    chatroom: require('./chatroom')
}; 

存取 Restful API

「Lantern」提供了統一的方法呼叫 Restful API,無論前端還是後端都可以使用(在 Store 或 Action 中),此外,如果在後端使用呼叫,該方法會自動接續使用者的 Session (登入)狀態,進行 Restful API 存取。使某些使用者登入後才可存取的 API,更為容易被存取。
export default function *() {
    this.on('store.Chatroom.getMessages', function *() {
        var store = this.getState('Chatroom');

        try {
            var res = yield this.request
                .get('/apis/messages')
                .query();

            // 取得聊天室訊息,並更新到 store
            store.messages = res.body;

            // State(Store) 已經更新,React 元件會被觸發更新
            this.dispatch('state.Chatroom');
        } catch(e) {
            switch(e.status) {
            case 500:
            case 400:
                console.log('Something\' wrong');
                break;
            }
        }
    });
};

在畫 React 元件前先預載資料

後端要把畫面送到瀏覽器前,有時需要先資料庫的資料載入,預先植入畫面之中,前端有時也需要預先載入一些資料,以便畫面宣染時有實質內容。我們可以透過載入「@preAction」這個 decorator 來達成這個需求。「@preAction」會在元件初始化前,先去執行一些工作。

底下範例是利用「@preAction」去跑 FLUX 裡的 Action - 「Chatroom.fetchMessages」:
import { preAction } from 'Decorator';

// 相當於 this.flux.dispatch('action.Chatroom.fetchMessages')
@preAction('Chatroom.fetchMessages')
class MyComponent extends React.Component {
    // ...
}

當然可能要預先做的工作不只一項,而且可能要帶入 React 元件的 props 或更多資訊到 Action 中。「@preAction」可以被帶入函數,作更複雜的設計:
@preAction((handle) => {
    handle.doAction('Chatroom.fetchMessages');
    handle.doAction('Chatroom.doSomething', handle.props.blah, 123);
})

因為 Store 會因為「@preAction」而被更新、有資料,這時就可以理所當然地在元件初始化時直接取用 State(Store)的內容。
class MyComponent extends React.Component {
    constructor(props, context) {
        super();

        this.state = {
            messages: context.flux.getState('Chatroom').messages;
        };
    }
    // 省略 ...
}

動態載入 JavaScript 或 CSS 檔案


很多 JavaScript 或 CSS 檔案是隨著 React Component 的載入,才會被動態載入,有時甚至需要照順序載入。此外,通常這樣的機制比較多會被使用在前端瀏覽器的頁面上,同樣的載入程式碼工作,在後端 Rendering 時往往會壞掉而無法通用,這在 Isomorphic 的架構中往往需要特別處理,像是判斷執行期是在前端還是後端,相當麻煩。

為此,「Lantern」提供了「@loader」這個 Decorator,使開發者可以容易引入動態載入的機制,而且不用思考前後端的問題,也可以控制載入順序,或是等待檔案載入完成。

以下範例就是一個載入地圖 API 的範例,載入工作只會在前端執行,不會在後端執行:
import { loader } from 'Decorator';

@loader
class MyMap extends React.Component {

    componentWillMount() {
        // Loader 在後端不會有任何作用
        this.loader.css('https://example.com/css/test.css');
    }

    // componentDidMount 只會在前端觸發
    componentDidMount() {
        this.loader.css('https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.5/leaflet.css');
        this.loader.css('https://api.tiles.mapbox.com/mapbox.js/plugins/leaflet-minimap/v1.0.0/Control.MiniMap.css');

        this.loader.script([
            'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.5/leaflet.js',
            'https://api.tiles.mapbox.com/mapbox.js/plugins/leaflet-minimap/v1.0.0/Control.MiniMap.js'
        ], function() {
            // 初始化地圖 ...
        });
    }

    render() {
        return 
; } }

取得和監聽視窗資訊


為了更方便前端排版,尤其是需要滿版的設計時,我們往往需要得知或監控瀏覽器視窗的大小,通常做法是存取瀏覽器中的「window」物件,並監聽事件來達成。但「window」物件只在瀏覽器上存在,在後端如果存取該物件,會失敗而且有錯誤發生,在以往 Isomorphic 架構中,每次都要特別處理,相當麻煩。因此「Lantern」預設提供了一個名為「Window」的 Store,將這類資訊包裝起來,使 React Component 能輕易存取又不會因在後端或前端而出現問題。

下面範例就是存取 Window 的例子,以及監聽視窗大小改變的事件。
@flux
class MyPage extends React.Component {
    constructor(props, context) {
        super();

        var win = context.flux.getState('Window');
        this.state = {
            winWidth: win.width,
            winHeight: win.height
        };
    }

    componentWillMount() {
        this.flux.on('state.Window', this.flux.bindListener(this.updateDimensions));
    }

    componentWillUnmount() {
        this.flux.off('state.Window', this.updateDimensions);
    }

    updateDimensions = () => {
        var win = this.flux.getState('Window');
        this.setState({
            winWidth: win.width,
            winHeight: win.height
        });
    }

    render() {
        return 
{this.state.winWidth}x{this.state.winHeight}
; } }

看不懂很多 ES6 和 ES7 的東西?

這邊已經整理了一些常用的對應表「ES6 and ES7」,方便開發者理解其中的語法。

更多文件和說明

更多資訊可以參考 Github 上的 Wiki:

後記

其實「Lantern」已經改版了幾次,因為之前在好幾個要上線的專案上,每次都發現有些許不足之處,所以就不斷翻新架構和改進,甚至是優化效能。到目前為止,大致已經算是穩定的狀態,未來的開發方向不外乎是繼續寫 Isomorphic 的 Extension,以及效能優化。

如果你有興趣,歡迎加入並共同改善這個專案。:-)