Webpack實戰-構建同構應用

浩麟發表於2017-12-19

同構應用是指寫一份程式碼但可同時在瀏覽器和伺服器中執行的應用。

認識同構應用

現在大多數單頁應用的檢視都是通過 JavaScript 程式碼在瀏覽器端渲染出來的,但在瀏覽器端渲染的壞處有:

  • 搜尋引擎無法收錄你的網頁,因為展示出的資料都是在瀏覽器端非同步渲染出來的,大部分爬蟲無法獲取到這些資料。
  • 對於複雜的單頁應用,渲染過程計算量大,對低端移動裝置來說可能會有效能問題,使用者能明顯感知到首屏的渲染延遲。

為了解決以上問題,有人提出能否將原本只執行在瀏覽器中的 JavaScript 渲染程式碼也在伺服器端執行,在伺服器端渲染出帶內容的 HTML 後再返回。 這樣就能讓搜尋引擎爬蟲直接抓取到帶資料的 HTML,同時也能降低首屏渲染時間。 由於 Node.js 的流行和成熟,以及虛擬 DOM 提出與實現,使這個假設成為可能。

實際上現在主流的前端框架都支援同構,包括 React、Vue2、Angular2,其中最先支援也是最成熟的同構方案是 React。 由於 React 使用者更多,它們之間又很相似,本節只介紹如何用 Webpack 構建 React 同構應用。

同構應用執行原理的核心在於虛擬 DOM,虛擬 DOM 的意思是不直接操作 DOM 而是通過 JavaScript Object 去描述原本的 DOM 結構。 在需要更新 DOM 時不直接操作 DOM 樹,而是通過更新 JavaScript Object 後再對映成 DOM 操作。

虛擬 DOM 的優點在於:

  • 因為操作 DOM 樹是高耗時的操作,儘量減少 DOM 樹操作能優化網頁效能。而 DOM Diff 演算法能找出2個不同 Object 的最小差異,得出最小 DOM 操作;
  • 虛擬 DOM 的在渲染的時候不僅僅可以通過操作 DOM 樹來表示出結果,也能有其它的表示方式,例如把虛擬 DOM 渲染成字串(伺服器端渲染),或者渲染成手機 App 原生的 UI 元件( React Native)。

以 React 為例,核心模組 react 負責管理 React 元件的生命週期,而具體的渲染工作可以交給 react-dom 模組來負責。

react-dom 在渲染虛擬 DOM 樹時有2中方式可選:

  • 通過 render() 函式去操作瀏覽器 DOM 樹來展示出結果。
  • 通過 renderToString() 計算出表示虛擬 DOM 的 HTML 形式的字串。

構建同構應用的最終目的是從一份專案原始碼中構建出2份 JavaScript 程式碼,一份用於在瀏覽器端執行,一份用於在 Node.js 環境中執行渲染出 HTML。 其中用於在 Node.js 環境中執行的 JavaScript 程式碼需要注意以下幾點:

  • 不能包含瀏覽器環境提供的 API,例如使用 document 進行 DOM 操作,  因為 Node.js 不支援這些 API;
  • 不能包含 CSS 程式碼,因為服務端渲染的目的是渲染出 HTML 內容,渲染出 CSS 程式碼會增加額外的計算量,影響服務端渲染效能;
  • 不能像用於瀏覽器環境的輸出程式碼那樣把 node_modules 裡的第三方模組和 Node.js 原生模組(例如 fs 模組)打包進去,而是需要通過 CommonJS 規範去引入這些模組。
  • 需要通過 CommonJS 規範匯出一個渲染函式,以用於在 HTTP 伺服器中去執行這個渲染函式,渲染出 HTML 內容返回。

解決方案

接下來改造在3-6使用 React 框架中介紹的 React 專案,為它增加構建同構應用的功能。

由於要從一份原始碼構建出2份不同的程式碼,需要有2份 Webpack 配置檔案分別與之對應。 構建用於瀏覽器環境的配置和前面講的沒有差別,本節側重於講如何構建用於服務端渲染的程式碼。

用於構建瀏覽器環境程式碼的 webpack.config.js 配置檔案保留不變,新建一個專門用於構建服務端渲染程式碼的配置檔案 webpack_server.config.js,內容如下:

const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  // JS 執行入口檔案
  entry: './main_server.js',
  // 為了不把 Node.js 內建的模組打包進輸出檔案中,例如 fs net 模組等
  target: 'node',
  // 為了不把 node_modules 目錄下的第三方模組打包進輸出檔案中
  externals: [nodeExternals()],
  output: {
    // 為了以 CommonJS2 規範匯出渲染函式,以給採用 Node.js 編寫的 HTTP 服務呼叫
    libraryTarget: 'commonjs2',
    // 把最終可在 Node.js 中執行的程式碼輸出到一個 bundle_server.js 檔案
    filename: 'bundle_server.js',
    // 輸出檔案都放到 dist 目錄下
    path: path.resolve(__dirname, './dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: path.resolve(__dirname, 'node_modules'),
      },
      {
        // CSS 程式碼不能被打包進用於服務端的程式碼中去,忽略掉 CSS 檔案
        test: /\.css/,
        use: ['ignore-loader'],
      },
    ]
  },
  devtool: 'source-map' // 輸出 source-map 方便直接除錯 ES6 原始碼
};
複製程式碼

以上程式碼有幾個關鍵的地方,分別是:

  • target: 'node' 由於輸出程式碼的執行環境是 Node.js,原始碼中依賴的 Node.js 原生模組沒必要打包進去;
  • externals: [nodeExternals()] webpack-node-externals 的目的是為了防止 node_modules 目錄下的第三方模組被打包進去,因為 Node.js 預設會去 node_modules 目錄下尋找和使用第三方模組;
  • {test: /\.css/, use: ['ignore-loader']} 忽略掉依賴的 CSS 檔案,CSS 會影響服務端渲染效能,又是做服務端渲不重要的部分;
  • libraryTarget: 'commonjs2' 以 CommonJS2 規範匯出渲染函式,以供給採用 Node.js 編寫的 HTTP 伺服器程式碼呼叫。

為了最大限度的複用程式碼,需要調整下目錄結構:

把頁面的根元件放到一個單獨的檔案 AppComponent.js,該檔案只能包含根元件的程式碼,不能包含渲染入口的程式碼,而且需要匯出根元件以供給渲染入口呼叫,AppComponent.js 內容如下:

import React, { Component } from 'react';
import './main.css';

export class AppComponent extends Component {
  render() {
    return <h1>Hello,Webpack</h1>
  }
}
複製程式碼

分別為不同環境的渲染入口寫兩份不同的檔案,分別是用於瀏覽器端渲染 DOM 的 main_browser.js 檔案,和用於服務端渲染 HTML 字串的 main_server.js 檔案。

main_browser.js 檔案內容如下:

import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';

// 把根元件渲染到 DOM 樹上
render(<AppComponent/>, window.document.getElementById('app'));
複製程式碼

main_server.js 檔案內容如下:

import React from 'react';
import { renderToString } from 'react-dom/server';
import { AppComponent } from './AppComponent';

// 匯出渲染函式,以給採用 Node.js 編寫的 HTTP 伺服器程式碼呼叫
export function render() {
  // 把根元件渲染成 HTML 字串
  return renderToString(<AppComponent/>)
}
複製程式碼

為了能把渲染的完整 HTML 檔案通過 HTTP 服務返回給請求端,還需要通過用 Node.js 編寫一個 HTTP 伺服器。 由於本節不專注於將 HTTP 伺服器的實現,就採用了 ExpressJS 來實現,http_server.js 檔案內容如下:

const express = require('express');
const { render } = require('./dist/bundle_server');
const app = express();

// 呼叫構建出的 bundle_server.js 中暴露出的渲染函式,再拼接下 HTML 模版,形成完整的 HTML 檔案
app.get('/', function (req, res) {
  res.send(`
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<div id="app">${render()}</div>
<!--匯入 Webpack 輸出的用於瀏覽器端渲染的 JS 檔案-->
<script src="./dist/bundle_browser.js"></script>
</body>
</html>
  `);
});

// 其它請求路徑返回對應的本地檔案
app.use(express.static('.'));

app.listen(3000, function () {
  console.log('app listening on port 3000!')
});
複製程式碼

再安裝新引入的第三方依賴:

# 安裝 Webpack 構建依賴
npm i -D css-loader style-loader ignore-loader webpack-node-externals
# 安裝 HTTP 伺服器依賴
npm i -S express
複製程式碼

以上所有準備工作已經完成,接下來執行構建,編譯出目標檔案:

  • 執行命令 webpack --config webpack_server.config.js 構建出用於服務端渲染的 ./dist/bundle_server.js 檔案。
  • 執行命令 webpack 構建出用於瀏覽器環境執行的 ./dist/bundle_browser.js 檔案,預設的配置檔案為 webpack.config.js

構建執行完成後,執行 node ./http_server.js 啟動 HTTP 伺服器後,再用瀏覽器去訪問 http://localhost:3000 就能看到 Hello,Webpack 了。 但是為了驗證服務端渲染的結果,你需要開啟瀏覽器的開發工具中的網路抓包一欄,再重新重新整理瀏覽器後,就能抓到請求 HTML 的包了,抓包效果圖如下:

圖3.9.1 服務端渲染抓包

可以看到伺服器返回的是渲染出內容後的 HTML 而不是 HTML 模版,這說明同構應用的改造完成。

本例項提供專案完整程式碼

Webpack實戰-構建同構應用

《深入淺出Webpack》全書線上閱讀連結

閱讀原文

相關文章