React服務端渲染(專案搭建)

code_mcx發表於2018-10-10

前言

目前單頁面應用(SPA)很是流行,同時也帶了一些問題,如SEO不友好,首屏在網路較差的情況下載入慢。為了解決這些問題彷彿又回到了傳統web開發模式上去了,回去是不可能的,已經入坑了是不可能回去的。React作為一個SPA應用開發框架同時也支援服務端渲染,本系列文章將從以下幾點介紹如何搭建一個React服務端渲染的專案

如果你傾向於開箱即用的體驗,可以嘗試更高層次的解決方案Next.js,Next.js並且提供了一些額外的功能。本系列文章皆在讓你瞭解如何搭建服務端渲染,當你熟悉後能更直接地控制應用程式,在你閱讀之前,你需要具備以下技術能力

前端

  1. React全家桶(React、React-Router、Redux)(熟悉)
  2. Webpack (熟悉)
  3. Babel (瞭解)
  4. Eslint (瞭解)
  5. ES6 (瞭解)
  6. Promise (瞭解)

後端

  • Express(瞭解)

原始碼地址見文章末尾

如果你使用webpack4,babel7完整原始碼戳這裡

Webpack配置

注:版本3.x

服務端渲染就是讓服務端生成html字串,然後把生成好的html字串傳送給瀏覽器,瀏覽器收到html後進行渲染,而客戶端只做DOM的事件繫結。至此我們需要打包出兩份程式碼,一份由服務端執行渲染html,一份由瀏覽器執行,大部分程式碼都可以在服務端客戶端執行

目錄結構

React服務端渲染(專案搭建)

+---config                          配置目錄
|       dev.env.js                  開發環境配置
|       prod.env.js                 生產環境配置
|       util.js
|       webpack.config.base.js      公用打包配置
|       webpack.config.client.js    客戶端打包配置
|       webpack.config.server.js    服務端打包配置
+---public
|       favicon.ico
+---src                             原始碼目錄
|   +---assets                      資源目錄
|       App.jsx                     根元件
|       entry-client.js             客戶端打包入口
|       entry-server.js             服務端打包入口
|       server.js                   服務端啟動js
|       setup-dev-server.js         開發環境打包服務
|   .babelrc                        babel配置檔案
|   .eslintignore
|   .eslintrc.js                    eslint配置檔案
|   index.html                      模板html
|   package-lock.json
|   package.json
複製程式碼

公用配置

首先編寫服務端和客戶端通用的配置,包括js、字型、圖片、音訊等檔案對應的各種loader。在公用配置中區分是開發環境還是生產環境,如果是生產環境使用UglifyJsPlugin外掛進行js醜化,並且使用DefinePlugin外掛定義不同環境下的配置

webpack.config.base.js

const webpack = require("webpack");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");

let env = "dev";
let isProd = false;
let prodPlugins = [];
if (process.env.NODE_ENV === "production") {
  env = "prod";
  isProd = true;
  prodPlugins = [
    new UglifyJsPlugin({sourceMap: true})
  ];
}

const baseWebpackConfig = {
  devtool: isProd ? "#source-map" : "#cheap-module-source-map",
  resolve: {
    extensions: [".js", ".jsx", ".json"]
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        loader: ["babel-loader", "eslint-loader"],
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpe?g|gif|svg)$/,
        loader: "url-loader",
        options: {
          limit: 10000,
          name: "static/img/[name].[hash:7].[ext]"
        }
      },
      {
        test: /\.(woff2?|eot|ttf|otf)$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: "static/fonts/[name].[hash:7].[ext]"
        }
      }
    ]
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env": require("./" + env + ".env")
    }),
    ...prodPlugins
  ]
}

module.exports = baseWebpackConfig;

複製程式碼

客戶端配置

客戶端配置和普通單頁面應用配置一樣,使用HtmlWebpackPlugin外掛把打包後的樣式和js注入到模板index.html中,指定dist為打包後的根目錄,後續express會以該目錄作為靜態資源目錄做資源對映。util.js中的styleLoaders函式中編寫了css、postcss、sass、less、stylus等loader配置,在生產環境中使用ExtractTextPlugin外掛把樣式提取到css檔案中

webpack.config.client.js

const path = require("path");
const merge = require("webpack-merge");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const baseWebpackConfig = require("./webpack.config.base");
const util = require("./util");

const isProd = process.env.NODE_ENV === "production";

const webpackConfig = merge(baseWebpackConfig, {
  entry: {
    app: "./src/entry-client.js"
  },
  output: {
    path: path.resolve(__dirname, "../dist"),
    filename: "static/js/[name].[chunkhash].js",
    publicPath: "/dist/"  // 打包後輸出路徑以/dist/開頭
  },
  module: {
    rules: util.styleLoaders({
        sourceMap: isProd ? true : false,
        usePostCSS: true,
        extract: isProd ? true : false
      })
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: "index.html",
      template: "index.html"
    })
  ]
});

if (isProd) {
  webpackConfig.plugins.push(
    new ExtractTextPlugin({
      filename: "static/css/[name].[contenthash].css"
    })
  );
}

module.exports = webpackConfig;
複製程式碼

服務端配置

服務端配置不同於客戶端,服務端執行於node中,不支援babel,不支援樣式,同時也不支援一些瀏覽器全域性物件如window、document,對於babel使用babel-loader進行轉換,對於樣式使用外掛提取出來,服務端只執行js生成html片段,樣式由客戶端打包並供瀏覽器下載執行。有人會使用babel-register或babel-node,這兩者都是在node中用babel進行轉換,而且都是實時轉碼,因而效能上會有一定影響,建議在開發環境中使用,生產環境中應先預先轉換好程式碼

webpack.config.server.js

const path = require("path");
const webpack = require("webpack");
const merge = require("webpack-merge");
const baseWebpackConfig = require("./webpack.config.base");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const util = require("./util");

const webpackConfig = merge(baseWebpackConfig, {
  entry: {
    app: "./src/entry-server.js"
  },
  output: {
    path: path.resolve(__dirname, "../dist"),
    filename: "entry-server.js",
    libraryTarget: "commonjs2"  // 打包成commonjs2規範
  },
  target: "node",  // 指定node執行環境
  module: {
    rules: util.styleLoaders({
        sourceMap: true,
        usePostCSS: true,
        extract: true
      })
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.REACT_ENV": JSON.stringify("server")  // 指定React環境為服務端
    }),
    // 服務端不支援window document等物件,需將css外鏈
    new ExtractTextPlugin({
      filename: "static/css/[name].[contenthash].css"
    })
  ]
});

module.exports = webpackConfig;
複製程式碼

Babel和Eslint

React需要用babel外掛來轉換,安裝babel-core、babel-preset-env、babel-preset-react、babel-loader。babel配置如下

{
  "presets": ["env", "react"]
}
複製程式碼

env包含es2015、es2016、es2017及最新版本,react用於轉換React

注:babel使用的是6.x版本

良好的程式碼規範是合作開發的基礎,本文使用eslint進行程式碼規範檢查,安裝eslint、eslint-plugin-react、babel-eslint、eslint-loader。eslint配置如下

module.exports = {
    root: true,
    parser: "babel-eslint",
    env: {
      es6: true,
      browser: true,
      node: true
    },
    extends: [
      "eslint:recommended",
      "plugin:react/recommended"
    ],
    parserOptions: {
      sourceType: "module",
      ecmaFeatures: {
        jsx: true
      }
    },
    rules: {
      "no-unused-vars": 0,
      "react/display-name": 0,
      "react/prop-types": 0
    },
    settings: {
      react: {
        version: "16.4.2"
      }
    }
  }
複製程式碼

配置jsx:true啟用對jsx的支援,配置eslint:recommended啟用eslint核心規則,配置plugin:react/recommended啟用對react語義支援。

eslint中文官網:cn.eslint.org
eslint-plugin-react外掛:github.com/yannickcr/e…

入口

編寫入口元件App.jsx

import React from "react";
import "./assets/app.css";

class Root extends React.Component {
  render() {
    return (
      <div>
        <div className="title">This is a react ssr demo</div>
        <ul className="nav">
          <li>Bar</li>
          <li>Baz</li>
          <li>Foo</li>
          <li>TopList</li>
        </ul>
        <div className="view">
        </div>
      </div>
    );
  }
}

export default Root;
複製程式碼

在客戶端入口中獲取根元件然後進行掛載

entry-client.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.hydrate(<App />, document.getElementById("app"));
複製程式碼

React16提供了一個函式hydrate(),在服務端渲染時用來替代render,hydrate不會對dom進行修補只會對文字進行修補,如果文字不一樣使用客戶端的文字內容

伺服器端入口將App元件module.exports即可

entry-server.js

import React from "react";
import App from "./App";

module.exports = <App/>;
複製程式碼

在server.js中使用express啟動服務,處理任何get請求。從服務端打包後的js中獲取根元件,讀取打包後的index.html模板,將dist對映為express靜態資源目錄並以/dist作為url字首(和客戶端打包配置中output.publicPath保持一致)。React為服務端渲染提供了renderToString()函式,用來把元件渲染成html字串,呼叫此函式傳入根元件,將返回的html字串替換掉模板中佔位符

server.js

const express = require("express");
const fs = require("fs");
const path = require("path");
const ReactDOMServer = require("react-dom/server");
const app = express();

let serverEntry = require("../dist/entry-server");
let template = fs.readFileSync("./dist/index.html", "utf-8");

// 靜態資源對映到dist路徑下
app.use("/dist", express.static(path.join(__dirname, "../dist")));

app.use("/public", express.static(path.join(__dirname, "../public")));

/* eslint-disable no-console */
const render = (req, res) => {
  console.log("======enter server======");
  console.log("visit url: " + req.url);

  let html = ReactDOMServer.renderToString(serverEntry);
  let htmlStr = template.replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>`);
  // 將渲染後的html字串傳送給客戶端
  res.send(htmlStr);
}

app.get("*", render);

app.listen(3000, () => {
  console.log("Your app is running");
});
複製程式碼

打包之前的index.html如下

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <link rel="shortcut icon" href="/public/favicon.ico">
    <title>React SSR</title>
</head>
<body>
    <!--react-ssr-outlet-->
</body>
</html>
複製程式碼

執行

在package.json中編寫scripts

"scripts": {
    "start": "node src/server.js",
    "build": "rimraf dist && npm run build:client && npm run build:server",
    "build:client": "webpack --config config/webpack.config.client.js",
    "build:server": "webpack --config config/webpack.config.server.js"
}
複製程式碼

先執行npm run build打包客戶端和服務端,然後執行npm run start啟動服務,開啟瀏覽器輸入http://localhost:3000。開啟瀏覽器的network檢視服務端返回的內容

React服務端渲染(專案搭建)
可以看到是最終渲染後的html內容

開發環境熱更新

通過以上方式執行存在一些問題,每次更改程式碼後都需要打包客戶端和服務端,然後重新啟動服務。當重啟伺服器後需要刷瀏覽更改後的程式碼才會生效,這對於在開發模式下極大的影響了開發效率和體驗。在單頁面應用中React提供了腳手架create-react-app,其內部使用了webpack-dev-server作為開發環境的服務支援熱更新。有人會使用腳手架作為客戶端再使用express或koa作為服務端,這樣一來客戶端和服務端佔用了兩個服務埠,無法通用,如果客戶端不用腳手架使用webpack進行打包並加上watch功能,服務端把打包後的資源做資源對映,雖然服務端和客戶端公用了同一服務,但是無法做到瀏覽器熱更新,比較好的方法是使用webpack-dev-middleware和webpack-hot-middleware這兩個中介軟體。webpack-dev-middleware中介軟體不會把打包後的資源寫入磁碟而是在記憶體中處理,當檔案內容變動時會進行重新編譯,webpack-hot-middleware中介軟體就是做熱更新用的

先對package.json進行修改

"scripts": {
    "dev": "node src/server.js",
    "start": "cross-env NODE_ENV=production node src/server.js",
    "build": "rimraf dist && npm run build:client && npm run build:server",
    "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.config.client.js",
    "build:server": "cross-env NODE_ENV=production webpack --config config/webpack.config.server.js"
}
複製程式碼

將客戶端和服務端打包指令碼更改為生產環境,服務啟動指令碼同樣加上生產環境標識。在server.js中判斷當前環境是否是生產環境,生產環境保持原有的邏輯,非生產環境使用webpack-dev-middleware和webpack-hot-middleware進行熱更新

const isProd = process.env.NODE_ENV === "production";

let serverEntry;
let template;
if (isProd) {
  serverEntry = require("../dist/entry-server");
  template = fs.readFileSync("./dist/index.html", "utf-8");
  // 靜態資源對映到dist路徑下
  app.use("/dist", express.static(path.join(__dirname, "../dist")));
} else {
  require("./setup-dev-server")(app, (entry, htmlTemplate) => {
    serverEntry = entry;
    template = htmlTemplate;
  });
}
複製程式碼

上述程式碼呼叫了setup-dev-server.js中module.exports出的函式,傳入express例項app物件和一個打包成功後的回撥函式。在setup-dev-server.js中,客戶端使用webpack-dev-middleware和webpack-hot-middleware,服務端使用webpack打包並且進行watch

webpack-dev-middleware 注:webpack-dev-middleware使用的是1.x版本
webpack-hot-middleware

客戶端

使用webpack函式打包之前先將webpack-hot-middleware/client新增至entry中,再新增HotModuleReplacementPlugin外掛(該外掛用來啟用熱更新)。給clientCompiler物件設定打包完成後的回撥,webpack-dev-middleware是打包在記憶體中的,需要從檔案系統中讀取index.html然後呼叫傳入的回撥函式把模板傳出去

const webpack = require("webpack");
const clientConfig = require("../config/webpack.config.client");

// 修改入口檔案,增加熱更新檔案
clientConfig.entry.app = ["webpack-hot-middleware/client", clientConfig.entry.app];
clientConfig.output.filename = "static/js/[name].[hash].js";
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin());

// 客戶端打包
const clientCompiler = webpack(clientConfig);

const devMiddleware = require("webpack-dev-middleware")(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
});
// 使用webpack-dev-middleware中介軟體服務webpack打包後的資原始檔
app.use(devMiddleware);

clientCompiler.plugin("done", stats => {
  const info = stats.toJson();
  if (stats.hasWarnings()) {
    console.warn(info.warnings);
  }
    
  if (stats.hasErrors()) {
    console.error(info.errors);
    return;
  }
  // 從webpack-dev-middleware中介軟體儲存的記憶體中讀取打包後的inddex.html檔案模板
  template = readFile(devMiddleware.fileSystem, "index.html");
  update();
});

// 熱更新中介軟體
app.use(require("webpack-hot-middleware")(clientCompiler));
複製程式碼

服務端

webpack不僅可以打包到磁碟還可以打包到自定義的檔案系統如記憶體檔案系統,使用outputFileSystem屬性指定打包輸出的檔案系統。服務端打包時使用watch函式檢測檔案變動,打包完成後同樣從記憶體中獲取entry-server.js檔案內容,這裡讀取出來的是字串,而ReactDOMServer.renderToString(serverEntry)傳入的是一個元件物件,我們需要使用node中的module進行編譯,例項化一個module呼叫_compile方法,第一個引數是javascript程式碼,第二個自定義的名稱,最後獲取entry-server.js中module.exports出的物件

const webpack = require("webpack");
const MFS = require("memory-fs");
const serverConfig = require("../config/webpack.config.server");

// 監視服務端打包入口檔案,有更改就更新
const serverCompiler = webpack(serverConfig);
// 使用記憶體檔案系統
const mfs = new MFS();
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
  const info = stats.toJson();
  if (stats.hasWarnings()) {
    console.warn(info.warnings);
  }

  if (stats.hasErrors()) {
    console.error(info.errors);
    return;
  }

  // 讀取打包後的內容並編譯模組
  const bundle = readFile(mfs, "entry-server.js");
  const m = new module.constructor();
  m._compile(bundle, "entry-server.js");
  serverEntry = m.exports;
  update();
});
複製程式碼
module.exports = function setupDevServer(app, callback) {
  let serverEntry;
  let template;
  const update = () => {
    if (serverEntry && template) {
      callback(serverEntry, template);
    }
  }
  ...
}
複製程式碼

打包和訪問同步

執行npm run dev後終端輸出如下的時候開啟瀏覽器訪問http://localhost:3000

E:\react-ssr>npm run dev

> react-ssr@1.0.0 dev E:\react-ssr
> node src/server.js

Your app is running
複製程式碼

這個時候會出現如下錯誤

E:\react-ssr>npm run dev

> react-ssr@1.0.0 dev E:\react-ssr
> node src/server.js

Your app is running
======enter server======
visit url: /
TypeError: Cannot read property 'replace' of undefined
    at render (E:\react-ssr\src\server.js:32:26)
    at Layer.handle [as handle_request] (E:\react-ssr\node_modules\express\lib\r
outer\layer.js:95:5)
    at next (E:\react-ssr\node_modules\express\lib\router\route.js:137:13)
    at Route.dispatch (E:\react-ssr\node_modules\express\lib\router\route.js:112
:3)
    at Layer.handle [as handle_request] (E:\react-ssr\node_modules\express\lib\r
outer\layer.js:95:5)
    at E:\react-ssr\node_modules\express\lib\router\index.js:281:22
    at param (E:\react-ssr\node_modules\express\lib\router\index.js:354:14)
    at param (E:\react-ssr\node_modules\express\lib\router\index.js:365:14)
    at Function.process_params (E:\react-ssr\node_modules\express\lib\router\ind
ex.js:410:3)
複製程式碼

問題行所在程式碼

let htmlStr = template.replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>`);
複製程式碼

經過一段時間後輸出如下

    ...
    at next (E:\react-ssr\node_modules\express\lib\router\index.js:275:10)
    at middleware (E:\react-ssr\node_modules\webpack-hot-middleware\middleware.j
s:37:48)
    at Layer.handle [as handle_request] (E:\react-ssr\node_modules\express\lib\r
outer\layer.js:95:5)
    at trim_prefix (E:\react-ssr\node_modules\express\lib\router\index.js:317:13
)
    at E:\react-ssr\node_modules\express\lib\router\index.js:284:7
    at Function.process_params (E:\react-ssr\node_modules\express\lib\router\ind
ex.js:335:12)
    at next (E:\react-ssr\node_modules\express\lib\router\index.js:275:10)
webpack built 545c3865aff0cdac2a64 in 3570ms
複製程式碼

webpack built 545c3865aff0cdac2a64 in 3570ms表示webpack已經打包完成

這是因為webpack打包客戶端和服務端的時候是非同步的,當打包完成後呼叫回撥函式才給template賦值,在打包過程中express服務已經啟動,訪問伺服器的時候template是undefined。為了同步瀏覽器請求和webpack打包同步,這裡使用Promise

setup-dev-server.js

module.exports = function setupDevServer(app, callback) {
  let serverEntry;
  let template;
  let resolve;
  const readyPromise = new Promise(r => { resolve = r });
  const update = () => {
    if (serverEntry && template) {
      callback(serverEntry, template);
      resolve(); // resolve Promise讓服務端進行render
    }
  }
  
  ...
  
  return readyPromise;
}
複製程式碼

先建立一個Promise例項,將resolve函式賦值給外部變數resolve,最後返回readyPromise。在回撥函式中呼叫resolve使Promise變成fulfilled狀態

server.js

let serverEntry;
let template;
let readyPromise;
if (isProd) {
  serverEntry = require("../dist/entry-server");
  template = fs.readFileSync("./dist/index.html", "utf-8");
  // 靜態資源對映到dist路徑下
  app.use("/dist", express.static(path.join(__dirname, "../dist")));
} else {
  readyPromise = require("./setup-dev-server")(app, (entry, htmlTemplate) => {
    serverEntry = entry;
    template = htmlTemplate;
  });
}
複製程式碼
app.get("*", isProd ? render : (req, res) => {
  // 等待客戶端和服務端打包完成後進行render
  readyPromise.then(() => render(req, res));
});
複製程式碼

express接收get請求,當readyPromise變成fulfilled狀態才呼叫render函式。

編寫熱更新程式碼

執行npm run dev,瀏覽器訪問http://localhost:3000,開啟瀏覽器的network皮膚

React服務端渲染(專案搭建)

看到http://localhost:3000/__webpack_hmr請求和console中的[HMR] connected說明熱更新已生效,但是現在是否可以熱更新了?讓我們來試一下。開啟App.jsx修改<div className="title">This is a react ssr demo</div><div className="title">This is updated title</div>,在終端看到如下輸出

...
webpack building...
webpack built 6d23c952cd6c3bf01ed6 in 299ms
複製程式碼

在瀏覽器頁面上中並沒有看到任何變化,但是在console 皮膚看到警告

React服務端渲染(專案搭建)

服務端重新打包後傳送通知給瀏覽器,瀏覽器端已經收到通知,但是更新的模組./src/App.jsx沒有實現更新,所以無法進行熱更新。

webpack-hot-middleware外掛只是為瀏覽器和伺服器通訊架起了一座橋樑,服務端發生改變會通知客戶端,實際上熱更新並不是這個外掛做的事情,需要使用webpack's HMR API來編寫熱更新程式碼

webpack熱更新相關說明
webpack.js.org/concepts/ho…
webpack.js.org/guides/hot-…

實際上webpack很多loader外掛都是自己實現熱更新,下面是style-loader外掛的部分原始碼

style-loader/index.js

var hmr = [
	// Hot Module Replacement,
	"if(module.hot) {",
	// When the styles change, update the <style> tags
	"	module.hot.accept(" + loaderUtils.stringifyRequest(this, "!!" + request) + ", function() {",
	"		var newContent = require(" + loaderUtils.stringifyRequest(this, "!!" + request) + ");",
	"",
	"		if(typeof newContent === 'string') newContent = [[module.id, newContent, '']];",
	"",
	"		var locals = (function(a, b) {",
	"			var key, idx = 0;",
	"",
	"			for(key in a) {",
	"				if(!b || a[key] !== b[key]) return false;",
	"				idx++;",
	"			}",
	"",
	"			for(key in b) idx--;",
	"",
	"			return idx === 0;",
	"		}(content.locals, newContent.locals));",
	"",
	// This error is caught and not shown and causes a full reload
	"		if(!locals) throw new Error('Aborting CSS HMR due to changed css-modules locals.');",
	"",
	"		update(newContent);",
	"	});",
	"",
	// When the module is disposed, remove the <style> tags
	"	module.hot.dispose(function() { update(); });",
	"}"
].join("\n");
複製程式碼

我們在entry-client.js中編寫熱更新程式碼,以App.jsx作為熱更新依賴入口

// 熱更新
if (module.hot) {
  module.hot.accept("./App.jsx", () => {
    const NewApp = require("./App").default;
    ReactDOM.hydrate(<NewApp />, document.getElementById("app"));
  });
}
複製程式碼

此時開啟App.jsx修改<div className="title">This is a react ssr demo</div><div className="title">This is updated title</div>,瀏覽器自動更新頁面內容

總結

本節編寫了使用webpack打包時客戶端和服務端的配置。介紹瞭如何使用webpack結合express來做熱更新,以及如何使用webpack的HMR API實現熱更新

本章節原始碼

下一節:前後端路由同構

相關文章