聊一聊前端「同構」

1、什麼是同構

同構是指同開發一個能夠跑在不一樣的平臺上的程序。例如開發一段 js 代碼能夠同時被基於 node.js 開發的 web server 和瀏覽器使用。本文中咱們就要聊聊這種場景下,爲何以及怎麼樣開發一個同構的 web 應用。html

2、同構帶來的好處

咱們不會無緣無故地作出任何決策,你們使用同構確定是由於同構可以帶來一些好處:前端

  • 減小代碼開發量, 提升代碼複用量。由於一份代碼能同時跑在瀏覽器和服務器,所以不只代碼量減小了,並且不少業務邏輯不須要在瀏覽器和服務端兩邊同時維護,於是同時減少了程序出錯的可能。
  • 能夠以較小的成本完成 SSR (Server-Side Render)的功能。而 SSR 能帶來至少如下兩點好處。
    • 首屏性能,讓用戶更早看到頁面內容。
    • SEO (Search Engine Optimization), 對爬蟲友好。

3、同構帶來的問題

  • 性能損失,客戶端服務端都要渲染頁面, 存在必定的性能浪費(能夠經過客戶端 dom 反收集和 virtual-dom 等手段儘可能優化,但不可避免)。
  • 一個能夠同構的模塊必須同時兼容客戶端和 Node.js 環境,所以會帶來額外的一些開發成本。特別是習慣客戶端開發的人要注意 window,document,DOM 等是客戶端才存在的對象。
  • 服務端內存溢出的風險,客戶端代碼運行環境隨着瀏覽器刷新會從新創建,所以不須要太注意內存溢出的問題,而服務端則不一樣。
  • 要特別注意異步操做,習慣於客戶端開發的同窗可能很習慣在前端隨意發起異步數據請求和操做,由於全部的操做都會引發頁面重繪。而服務端則不一樣,服務端的組件只能調用一次或有限次的 render,因此全部用於服務端渲染的異步請求必須所有都調用 render 返回 html 前完成。
  • 全部在服務端預取的狀態都應該有途徑能讓客戶端獲取,以避免客戶端和服務端渲染結果不一樣致使閃屏。由於不管如何客戶端都會渲染一次頁面,若服務端用來渲染的數據和客戶端不同,那麼渲染出來的 dom 也會不同,致使閃屏。

4、應用的哪些部分能夠同構

  1. 單頁應用的路由能夠被同構,這樣訪問任意單頁應用的子頁面均可以享受 SSR 帶來的好處。
  2. 模板,先後端共用一個渲染引擎就能夠作到先後端共用模板,這樣相似於因同一份數據要用於先後端渲染而須要開發兩套模板的日子就一去不復返了。
  3. 數據請求,開發支持同構的 httpClient,那麼先後端請求數據的代碼也能夠同構了。須要注意的是服務端沒有 cookie,所以會話相關的請求代碼須要極其當心。
  4. 其餘平臺不相關的代碼,例如 react 和 vue 都有的全局狀態管理模塊、數據處理過程和一些平臺無關的純函數。

5、哪些東西不能同構

  • 平臺相關代碼,如只能在瀏覽器端執行 DOM、BOM 相關的操做,只能在服務端執行文件讀寫,數據庫操做等。

6、咱們到底需不須要同構

6.1 同構帶來的好處能夠經過別的途徑來獲取嗎?

  • SSRvue

    SSR 固然不是必須經過同構來實現的,但使用同構來實現 SSR 能夠減小大量重複的代碼開發。node

  • 減小由於先後端使用兩份代碼同時維護一份邏輯而出錯的可能性react

    我沒有想到比同構能更好地解決這個問題的方案了。webpack

在 SSR 是必需的時候,我感受同構仍是有必要的。ios

6.2 支持同構,但不濫用 SSR

所以我以爲一個比較良好的方案是開發一個支持同構的應用,但不強制使用 SSR,由於 SSR 帶來必定的性能浪費。git

  • 支持同構,一份代碼便可以跑在客戶端又能夠跑在服務端,但具體要不要在服務端跑這段代碼由具體業務來決定。
  • 僅在首屏性能需求高和有 SEO 需求時使用 SSR,其餘狀況使用單純的客戶端渲染,這看起來是一個比較好的折中方案。

7、從零開始寫一個支持同構的多頁應用

例子代碼倉庫地址
啓動例子的同時閱讀下面的段落體驗會更好。例子中使用了 express 做爲 web server 框架,所以讀者如有一些 express 基礎會更容易理解例子。github

7.1 先後端代碼的職責

  • 前端:[(單頁應用)處理路由-> ] 請求數據 -> 渲染 -> 綁定事件
  • 後端:[ 處理路由 -> ] 請求數據 -> 渲染

7.2 先後端代碼做用的差別

  • 前端:沒有輸出,代碼直接做用於頁面元素
  • 後端:輸出 html 字符串

7.3 判斷代碼執行環境

最簡單的方式就是經過 window 對象的存在與否來判斷當前的代碼執行環境,只有在瀏覽器執行環境 window 對象才存在web

const isBrowser = typeof window !== 'undefined';
複製代碼

7.4 同構應用基本設計

基本設計

7.5 同構的組件基類

7.5.1 生命週期規劃

一個同構的組件,它的生命週期在服務端和客戶端的執行狀況是不一樣的。在 mount 操做前的生命週期能夠跑在服務端。

  • 客戶端:
    beforeMount -> render -> mounted
  • 服務端:
    preFetch -> beforeMount -> render

beforeMount 和 render 生命週期 在服務端和客戶端都會被執行,所以這兩個生命週期內也不該該寫平臺相關的代碼。

下面是此次 demo 使用的同構組件基類:

// ./lib/Component.js
const {isBrowser} = require('../utils');

module.exports = class Component {
    constructor (props = {}, options) {
        this.props = props;
        this.options = options;
        this.beforeMount();
        if (isBrowser) {
            // 瀏覽器端才執行的生命週期
            this.options.mount.innerHTML = this.render();
            this.mounted();
            this.bind();
        }
    }
    // 生命週期
    async preFetch() {}
    // 生命週期
    beforeMount() {}
    // 生命週期
    mounted() {}
    // 綁定事件時使用
    bind() {}
    // 從新渲染時調用
    setState() {
        this.options.mount.innerHTML = this.render();
        this.bind();
    }
    render() {
        return '';
    }
};
複製代碼

全部的業務組件都繼承這個基類,例如一個實際業務組件以下:

// ./pages/index.js
const Component = require('../lib/Component');

module.exports = class Index extends Component {
    render() {
        return `
            <h1>我是首頁</h1>
            <a href="/list">列表頁</a>
        `;
    }
}
複製代碼

啓動例子後能夠訪問 http://localhost:3000/ 來訪問這個頁面,能夠觀察一下 SSR 的狀況。

7.6 服務端處理

7.6.1 使用 ServerRenderer 來渲染組件

一個簡單的 ServerRenderer 實現以下:

// ./lib/ServerRenderer.js
const path = require('path');
const fs = require('fs');

module.exports = async (mod) => {
    // 獲取組件
    const Component = require(path.resolve(__dirname, '../', mod));
    // 獲取頁面模板
    const template = fs.readFileSync(path.resolve(__dirname, '../index.html'), 'utf8');
    // 初始化業務組件
    const com = new Component()
    // 數據預取
    await com.preFetch();
    // 將組件渲染的字符串輸出到頁面模板
    return template.replace(
        '<!-- ssr -->', 
        com.render() +
            // 把後端獲取的數據放到全局變量中供前端代碼初始化
            '<script>window.__initial_props__ = ' + 
            JSON.stringify(com.props) +
            '</script>'
    )
    // 替換插入靜態資源標籤
    .replace('${modName}', mod);
}
複製代碼
7.6.2 頁面模板

此次 demo 的全部頁面都使用同一份 html 模板:

<!-- ./index.html -->
<html>
    <head>
        <title>test</title>
    </head>
    <body>
        <div id="app">
            <!-- 下面是 ssr 渲染後內容填充的佔位符 -->
            <!-- ssr -->
        </div>
        <!-- 插入實際業務的前端 js 代碼 -->
        <script src="http://localhost:9000/build/${modName}"></script>
    </body>
</html>
複製代碼
7.6.3 服務端路由和 controller

這次的 demo 是基於 express 框架開發的,下面的代碼使用 ServerRenderer 渲染同構的組件,而後輸出 頁面 html 給瀏覽器。

// ./routes/index.js
var express = require('express');
var router = express.Router();
var ServerRenderer = require('../lib/ServerRenderer');

router.get('/', function(req, res, next) {
  ServerRenderer('pages/index.js').then((html) => {
    res.send(html); 
  });
}); 
複製代碼

7.7 客戶端處理器 ClientLoader

demo 中的 ClientLoader 是一個 webpack loader,該 loader 代碼以下:

// ./lib/ClientLoader.js
module.exports = function(source) {
    return `
        ${source}
        // 入口文件 export 的是主組件
        const Com = module.exports;
        // 獲取後端渲染時使用的初始狀態 window.__initial_props__,保證先後端渲染結果一致。
        new Com(window.__initial_props__, {
            mount: document.querySelector('#app')
        });
    `;
};
複製代碼

在 webpack.config.js 中使用這個插件(僅做用於頁面入口組件)

// webpack.config.js
module.exports = {
    ...,
    module: {
        rules: [
        ...,
            {
                test: /pages\/.+\.js$/,
                use: [
                    {loader: path.resolve(__dirname, './lib/ClientLoader.js')}
                ]
            }
        ],
    }
};
複製代碼

7.8 一個業務組件

有了以上的基礎之後,咱們能夠輕易的寫一個支持同構的組件。下面是一個列表頁。

7.8.1 代碼
// ./routes/index.js
/* GET list page. */
router.get('/list', function(req, res, next) {
  ServerRenderer('pages/list.js').then((html) => {
    res.send(html); 
  });
});

// ./pages/list.js
const Component = require('../lib/Component');
const {
    getList,
    addToList
} = require('../api/list.api');

module.exports = class Index extends Component {
    constructor (props, options) {
        super(props, options);
    }
    // 服務端執行,預取列表數據
    async preFetch() {
        await this.getList();
    }
    async getList() {
        const list = (await getList()).data;
        this.props.list = list;
    }
    ...,
    render() {
        return `
            <h1>我是列表頁</h1>
            <button class="add-btn">add</button>
            <button class="save-btn">save</button>
            <ul>
                ${
                    this.props.list.length ? 
                    this.props.list.map((val, index) => `
                        <li>
                            ${val.name}
                            <button class="del-btn">刪除</button>
                        </li>
                    `).join('') :
                    '列表爲空'
                }
            </ul>
        `;
    }
}
複製代碼
7.8.2 服務端渲染結果

若已啓動 demo 服務器,訪問 http://localhost:3000/list 能夠看到服務端的渲染結果以下。window.__initial_props__ 的存在保證先後端渲染的結果一致。

<html>
    <head>
        <title>test</title>
    </head>
    <body>
        <div id="app">
            <h1>我是列表頁</h1>
            <button class="add-btn">add</button>
            <button class="save-btn">save</button>
            <ul>
                <!-- 服務端渲染預取的列表數據 -->
                <li>
                    1
                    <button class="del-btn">刪除</button>
                </li>
                <li>
                    2
                    <button class="del-btn">刪除</button>
                </li>
                <li>
                    3
                    <button class="del-btn">刪除</button>
                </li>
                <li>
                    4
                    <button class="del-btn">刪除</button>
                </li>
            </ul>
            <script>
                // 預取的列表數據, 用來客戶端渲染
                // 客戶端第一次渲染和服務端渲染結果相同,所以用戶看不到客戶端渲染的效果。
                window.__initial_props__ = {
                    "list": [{
                        "name": 1
                    }, {
                        "name": 2
                    }, {
                        "name": 3
                    }, {
                        "name": 4
                    }]
                }
            </script>
        </div>
        <script src="http://localhost:9000/build/pages/list.js"></script>
    </body>
</html>
複製代碼

8、一個單頁面同構應用。

比起多頁面應用,單頁面應用須要多同構前端路由的部分。

8.1 服務端處理

初始化組件時帶上路由信息:

// ./lib/ServerRenderer.js
module.exports = async (mod, url) => {
    ...
    // 初始化業務組件
    const com = new Component({
        url
    });
    ...
}
複製代碼

書寫 controller 時把路由輸入 ServerRenderer:

/* GET single page. */
router.get('/single/:type', function(req, res, next) {
  ServerRender('pages/single.js', req.url).then((html) => {
    res.send(html); 
  });
});
複製代碼

8.2 客戶端代碼

下面是一個單頁應用組件,點擊切換按鈕就能夠純前端的切換路由並改變視圖:

// ./pages/single.js
const Component = require('../lib/Component');

module.exports = class Index extends Component {
    switchUrl() {
        const isYou = this.props.url === '/single/you';
        const newUrl = `/single/${isYou ? 'me' : 'you'}`;
        this.props.url = newUrl;
        window.history.pushState({}, 'hahha', newUrl);
        this.setState();
    }
    bind() {
        this.options.mount.getElementsByClassName('switch-btn')[0].onclick = this.switchUrl.bind(this);
    }
    render() {
        ;
        return `
            <h1>${this.props.url}</h1>
            <button class="switch-btn">切換</button>
        `;
    }
}
複製代碼

訪問 /single/you 服務端返回的內容爲:

<html>
    <head>
        <title>test</title>
    </head>
    <body>
        <div id="app">
            <h1>/single/you</h1>
            <button class="switch-btn">切換</button>
            <script>
                window.__initial_props__ = {
                    "url": "/single/you"
                }
            </script>
        </div>
        <script src="http://localhost:9000/build/pages/single.js"></script>
    </body>
</html>
複製代碼

9、公共狀態管理的同構

公共狀態管理的同構和組件的 props 同構其實很是相似,都須要把後端預取數據之後的整棵狀態樹渲染到頁面上而後前端初始化狀態管理器 store 的時候使用這棵樹來作爲初始狀態,以此來保證前端渲染的結果和後端一致。

10、特殊的 HttpClient

上面使用的 demo 中使用的 httpClient 是 axios,這個庫自己就已經支持同構。但仍是有一個問題,這個問題以前也提到過。
當涉及到會話相關的請求時,通常狀況下瀏覽器發送請求時會帶上 cookie 信息,可是服務端發起的請求並不會。所以,服務端發起請求時,須要手動地把 cookie 加到請求頭中去。

知識共享許可協議
本做品採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。
相關文章
相關標籤/搜索