React服务端渲染(项目搭建)

10,303 阅读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,一份由浏览器执行,大部分代码都可以在服务端客户端执行

目录结构

+---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查看服务端返回的内容 可以看到是最终渲染后的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面板

看到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实现热更新

本章节源码

下一节:前后端路由同构