Webpack實戰-構建同構應用

同構應用是指寫一份代碼但可同時在瀏覽器和服務器中運行的應用。css

認識同構應用

如今大多數單頁應用的視圖都是經過 JavaScript 代碼在瀏覽器端渲染出來的,但在瀏覽器端渲染的壞處有:html

  • 搜索引擎沒法收錄你的網頁,由於展現出的數據都是在瀏覽器端異步渲染出來的,大部分爬蟲沒法獲取到這些數據。
  • 對於複雜的單頁應用,渲染過程計算量大,對低端移動設備來講可能會有性能問題,用戶能明顯感知到首屏的渲染延遲。

爲了解決以上問題,有人提出可否將本來只運行在瀏覽器中的 JavaScript 渲染代碼也在服務器端運行,在服務器端渲染出帶內容的 HTML 後再返回。
這樣就能讓搜索引擎爬蟲直接抓取到帶數據的 HTML,同時也能下降首屏渲染時間。
因爲 Node.js 的流行和成熟,以及虛擬 DOM 提出與實現,使這個假設成爲可能。前端

實際上如今主流的前端框架都支持同構,包括 React、Vue二、Angular2,其中最早支持也是最成熟的同構方案是 React。
因爲 React 使用者更多,它們之間又很類似,本節只介紹如何用 Webpack 構建 React 同構應用。node

同構應用運行原理的核心在於虛擬 DOM,虛擬 DOM 的意思是不直接操做 DOM 而是經過 JavaScript Object 去描述本來的 DOM 結構。
在須要更新 DOM 時不直接操做 DOM 樹,而是經過更新 JavaScript Object 後再映射成 DOM 操做。react

虛擬 DOM 的優勢在於:webpack

  • 由於操做 DOM 樹是高耗時的操做,儘可能減小 DOM 樹操做能優化網頁性能。而 DOM Diff 算法能找出2個不一樣 Object 的最小差別,得出最小 DOM 操做;
  • 虛擬 DOM 的在渲染的時候不只僅能夠經過操做 DOM 樹來表示出結果,也能有其它的表示方式,例如把虛擬 DOM 渲染成字符串(服務器端渲染),或者渲染成手機 App 原生的 UI 組件( React Native)。

以 React 爲例,核心模塊 react 負責管理 React 組件的生命週期,而具體的渲染工做能夠交給 react-dom 模塊來負責。git

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

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

構建同構應用的最終目的是從一份項目源碼中構建出2份 JavaScript 代碼,一份用於在瀏覽器端運行,一份用於在 Node.js 環境中運行渲染出 HTML。
其中用於在 Node.js 環境中運行的 JavaScript 代碼須要注意如下幾點:web

  • 不能包含瀏覽器環境提供的 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 模版,這說明同構應用的改造完成。

本實例 提供項目完整代碼

閱讀原文

相關文章
相關標籤/搜索