React服務端渲染(項目搭建)

前言

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

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

前端html

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

後端前端

  • Express(瞭解)

源碼地址見文章末尾java

若是你使用webpack4,babel7完整源碼戳這裏node

Webpack配置

注:版本3.xreact

服務端渲染就是讓服務端生成html字符串,而後把生成好的html字符串發送給瀏覽器,瀏覽器收到html後進行渲染,而客戶端只作DOM的事件綁定。至此咱們須要打包出兩份代碼,一份由服務端執行渲染html,一份由瀏覽器執行,大部分代碼均可以在服務端客戶端執行webpack

目錄結構git

+---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插件定義不一樣環境下的配置es6

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包含es201五、es201六、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查看服務端返回的內容 能夠看到是最終渲染後的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](https://www.npmjs.com/package/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面板

看到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 面板看到警告

服務端從新打包後發送通知給瀏覽器,瀏覽器端已經收到通知,可是更新的模塊./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實現熱更新

本章節源碼

下一節:先後端路由同構

相關文章
相關標籤/搜索