Skip to content

服务端渲染原理 ​

一、服务端渲染简介 ​

客户端渲染:单页面应用中,index.html 文件只有一个<div id="XXX"></div>DOM结构,其余页面内容都是js代码控制

服务端渲染:服务端直接返回完整的html文档让浏览器直接渲染

1. 一个简易SSR ​

新建react-ssr文件夹,运行npm init -y 初始化 package.json文件

安装

bash
npm i express

根目录下新建index.js

js
var express = require("express");
var app = express();

app.get("/", (req, res) => {
  res.send(
    `

   <html>
     <head>
       <title>hello</title>
     </head>
     <body>
       <h1>hello</h1>
       <p>world</p>
     </body>
   </html>
 `,
  );
});

app.listen(4000, () => {
  console.log("listen:4000");
});

修改package.json

json
  "scripts": {
    "dev": "node index.js"
  },

运行npm run dev,打开localhost:4000

运行后在浏览器中查看 localhost:4000 及其响应 HTML

二、为什么使用服务端渲染 ​

如图:客户端渲染(左)和服务端渲染(右)的流程

CSR 与 SSR 的流程对比

服务端渲染的优点:

  1. 首屏加载快,无需等待JS下载且执行才显示内容,更快地看到完整渲染页面

  2. 更友好的SEO,爬虫可以直接抓取渲染之后的页面。客户端渲染首次返回的HTML文档中,是空节点,爬虫无法分析所以就无法给好的排名。服务端是返回渲染之后的HTML,内容完整所以能更好的被爬虫分析与索引

题外:客户端渲染可以通过 title 和 description 做简单的SEO优化

服务端渲染的缺点:

  1. 对服务器性能消耗较高,多了SSR机器的维护

  2. 项目复杂度变高,多个node层

三、在服务端编写React代码 ​

1. 安装babel和webpack ​

前提:Node.js不能识别ES6的modules语法,不能识别JSX语法

方法:先用webpack将服务端代码打包进行转义,再通过node运行打包后的服务端代码

安装babel

bash
npm i @babel/core @babel/plugin-transform-runtime @babel/preset-env @babel/preset-react babel-loader -D

安装webpack

bash
npm i webpack webpack-cli webpack-node-externals webpack-merge -D

服务端渲染第三方模块的代码不需要打包到最终的源码中的,因为node_modules中已经包含了这些包。可以使用 webpack-node-externals 解决这个问题

2. 进行webpack配置 ​

新建webpack.base.js

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js?$/,
        loader: "babel-loader",
        exclude: /node_modules/,
        options: {
          presets: ["@babel/preset-env", "@babel/preset-react"],
          plugins: ["@babel/plugin-transform-runtime"],
        },
      },
    ],
  },
};

新建webpack.server.js

js
const path = require("path"); //node的path模块
const nodeExternals = require("webpack-node-externals");
const { merge } = require("webpack-merge");
const config = require("./webpack.base.js");

module.exports = merge(config, {
  //node核心模块的代码不需要打包到最终源码中,指明target:'node'
  target: "node",
  mode: "development",
  entry: "./server/index.js",
  output: {
    filename: "server.js",
    path: path.resolve(__dirname, "build"),
  },
  externals: [nodeExternals()],
});

3. webpack自动打包和服务端自动重启 ​

webpack自动打包只需增加 --watch

服务端重启,安装nodemon

bash
npm install nodemon -g

同时启动webpack服务和node服务,安装npm-run-all

bash
npm install npm-run-all -D

配置package.json

json
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:build:server": "webpack --config webpack.server.js --watch",
    "dev:start": "nodemon --watch build --exec node \"./build/server.js\"",
  },

更换服务端引入方式(从 CommonJS 换成ES6 modules)

运行npm run dev,访问http://localhost:4000/,可以看到hello world页面

4. 编写React代码 ​

安装

bash
npm i react react-dom -S

新建pages/login.js

jsx
import React from "react";

const Login = () => {
  return <div>login页面</div>;
};

export default Login;

修改server/index.js

jsx
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import Login from "../pages/login";
var app = express();
const content = renderToString(<Login />);

app.get("/", (req, res) => {
  res.send(
    `
   <html>
     <head>
       <title>hello</title>
     </head>
     <body>
     ${content}
     </body>
   </html>
 `,
  );
});

app.listen(4000, () => {
  console.log("listen:4000");
});

启动服务:npm run dev;访问:http://localhost:4000/ ,可以看到login页面

六、同构 ​

上面如果在DOM元素上绑定React的监听事件,是不生效的。因为renderToString并没有做事件相关的处理。

同构:React代码在服务器上运行一遍,页面直接呈现内容;然后浏览器渲染时判断已有的DOM结构和即将渲染出的结构是否相同,若相同,则不重新渲染DOM渲染,只需进行事件绑定

1. 客户端 ​

新建 client/index.js

jsx
import React from "react";
import ReactDom from "react-dom";
import Login from "../pages/login";

ReactDom.hydrate(<Login />, document.getElementById("root"));

水合:在已有服务端渲染标记的节点上调用ReactDom.hydrate方法,React将会保留该节点且只进行事件处理绑定

修改 pages/login.js

jsx
// 新增
<button
  onClick={() => {
    alert("666");
  }}
>
  click
</button>

2. 服务端 ​

修改server/index.js

js
app.use(express.static("public"));

//修改body区域
<body>
  <div id="root">${content}</div>
  <script src="/index.js"></script>
</body>;

下面进行webpack配置,将客户端资源进行打包到public/index.js

3. webpack配置 ​

新建webpack.client.js

js
const path = require("path");
const { merge } = require("webpack-merge");
const config = require("./webpack.base.js");

module.exports = merge(config, {
  mode: "development",
  entry: path.resolve(__dirname, "./client/index.js"),
  output: {
    filename: "index.js",
    path: path.resolve(__dirname, "public"),
  },
});

修改package.josn

json
//在dev:build:server下新增

"dev:build:client": "webpack --config webpack.client.js --watch",

重启项目,运行npm run dev,访问http://localhost:4000/,发现按钮已经可以点击,同构成功

4. 流程 ​

  1. 在服务端中,通过renderToString方法,将react代码转为HTML字符串,拼接在HTML中。

  2. 服务端发送生成好的HTML给浏览器

  3. 浏览器接到内容并显示

  4. 浏览器加载JS文件

  5. JS代码通过hydrate方法,将React代码进行水合,绑定监听事件等,使页面具有交互性

七、在SSR中使用路由 ​

1. 创建组件 ​

安装

bash
npm install react-router-dom@5.3.0

新建pages/components/header.js

jsx
import React from "react";
import { Link } from "react-router-dom";

const Header = () => {
  return (
    <div>
      <Link to="/">Home </Link>
      <Link to="/login">Login</Link>
    </div>
  );
};

export default Header;

修改pages/login.js

jsx
import React from "react";
import Header from "./components/header";

const Login = () => {
  return (
    <div>
      <Header />
      login页面
      <button
        onClick={() => {
          alert("666");
        }}
      >
        click
      </button>
    </div>
  );
};

export default Login;

新建pages/home.js

jsx
import React from "react";
import Header from "./components/header";
const Home = () => {
  return (
    <div>
      <Header />
      <div>This is ssr</div>
    </div>
  );
};
export default Home;

2. 在客户端使用路由 ​

新建routes/index.js

jsx
import React from "react";
import { Route, Switch } from "react-router-dom";
import Home from "../pages/home";
import Login from "../pages/login";
export default (
  <Switch>
    <Route path="/" exact component={Home}></Route>
    <Route path="/login" exact component={Login}></Route>
  </Switch>
);

修改client/index.js

jsx
import React from "react";
import ReactDom from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Router from "../routes";

const App = () => {
  return <BrowserRouter>{Router}</BrowserRouter>;
};

ReactDom.hydrate(<App />, document.getElementById("root"));

由于使用Route组件之后,客户端和服务端的层级不一致。所以需要在服务端也配置路由

3. 在服务端使用路由 ​

安装

bash
npm i @babel/plugin-proposal-class-properties -D

修改webpack.base.js

js
//在plugins中新增"@babel/plugin-proposal-class-properties"
plugins: [
  "@babel/plugin-transform-runtime",
  "@babel/plugin-proposal-class-properties",
];

新建server/render.js

jsx
import { renderToString } from "react-dom/server"; //引入renderToString方法
import { StaticRouter } from "react-router-dom";
import React from "react"; //引入React以支持JSX的语法
import Router from "../routes";

export default function (req, res) {
  const content = renderToString(
    <StaticRouter context={{}} location={req.path}>
      {Router}
    </StaticRouter>,
  );
  res.send(`    <html>
        <head>
        <title>ssr</title>
        </head>
        <body>
            <div id="root">${content}</div>
            <script src="/index.js"></script>
        </body>
    </html>`);
}

StaticRouter是React-router针对服务端渲染专门提供的一个路由组件

location:根据当前路径分析出所需要的组件

context:用于服务端渲染时的参数传递

修改server/index.js

jsx
import express from "express";
import render from "./render";

const app = express();

app.use(express.static("public"));

app.get("*", (req, res) => {
  render(req, res);
});

app.listen(3001, () => console.log("Exampleapp listening on port 3001!"));

重新启动项目,访问http://localhost:4000/,可以看到有路由页面

4. 段落小结 ​

  1. 服务端路由是为了第一次进入页面时能够找到对应的网页文件

  2. 客户端路由是为了能让React路由接管页面实现无刷新跳转

  3. 服务端不写路由的话,页面内容(DOM结构)不统一而出现错误

第一次请求时,服务端根据请求地址返回相应HTML文件。再运行客户端的js文件进行水合,使页面具有交互作用。之后页面被客户端的React代码接管,进行路由跳转时,不再发送请求,可以当做spa 页面

八、在SSR中使用redux ​

1. 创建store仓库 ​

安装:

bash
npm install redux react-redux -S

新建store/action-types.js

js
/* 增加 */
export const INCREMENT = "INCREMENT";
/* 删除 */
export const DECREMENT = "DECREMENT";

新建store/action.js

js
import * as types from "./action-types";

export const INCREMENT = (value) => {
  return {
    type: types.INCREMENT,
    value,
  };
};

export const DECREMENT = (value) => {
  return {
    type: types.DECREMENT,
    value,
  };
};

新建store/reducer/count.js

js
import * as types from "../action-types";

const initState = {
  count: 0,
};

const reducer = (state = initState, action) => {
  switch (action.type) {
    case types.INCREMENT:
      return {
        count: state.count + action.value,
      };
    case types.DECREMENT:
      return {
        count: state.count - action.value,
      };
    default:
      return state;
  }
};

export default reducer;

新建store/reducer/index.js

js
import { combineReducers } from "redux";
import counter from "./count";

const rootReducer = combineReducers({
  counter,
});

export default rootReducer;

新建store/index.js

js
import { createStore } from "redux";
import rootReducer from "./reducer";

const getStore = () => {
  return createStore(rootReducer);
};

export default getStore;

createStore创建的store是单例的store,在服务器端这样的写法将导致所有用户共享一个store,所以我们将创建store这一步封装成getStore方法,每次调用都返回一个新的store

2. 引入store ​

通过Provider组件,将store注入全局

修改client/index.js

jsx
//新增
import { Provider } from "react-redux";
import getStore from "../store";

//修改
const App = () => {
  return (
    <Provider store={getStore()}>
      <BrowserRouter>{Router}</BrowserRouter>
    </Provider>
  );
};

修改server/render.js

jsx
//新增
import { Provider } from "react-redux";
import getStore from "../store";

//修改
export default function (req, res) {
  const store = getStore();
  console.log("store", store.getState());
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path} context={{}}>
        {Router}
      </StaticRouter>
    </Provider>,
  );
  res.send(`    <html>
        <head>
        <title>ssr</title>
        </head>
        <body>
            <div id="root">${content}</div>
            <script src="/index.js"></script>
        </body>
    </html>`);
}

这里打印的store,会在浏览器每次刷新重新调用。如果通过createStore直接创建仓库,刷新页面则不会重新调用

3. 在组件中使用store ​

修改pages/home.js

jsx
import React from "react";
import Header from "./components/header";
import { useSelector, useDispatch } from "react-redux";
import { INCREMENT, DECREMENT } from "../store/action";

const Home = () => {
  const { count } = useSelector((store) => store.counter);
  const dispatch = useDispatch();
  return (
    <div>
      <Header />
      <div>{count}</div>
      <button onClick={() => dispatch(INCREMENT(1))}>+1</button>
      <button onClick={() => dispatch(DECREMENT(1))}>-1</button>
    </div>
  );
};

export default Home;

重启服务,刷新页面查看效果,可以发现计时器功能已实现

4. 在redux中调用接口 ​

(1)新建一个模拟后台提供接口的node服务

安装cors,处理前端跨域问题

bash
npm i cors

新建 api/index.js

js
let express = require("express");
const cors = require("cors");

const app = express();
app.use(cors());

let users = [
  { id: 1, name: "user1" },
  { id: 2, name: "user2" },
];
app.get("/api/users", function (req, res) {
  res.json(users);
});

app.listen(4001);

修改package.json

json
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:build:server": "webpack --config webpack.server.js --watch",
    "dev:build:client": "webpack --config webpack.client.js --watch",
    "dev:start": "nodemon --watch build --exec node \"./build/server.js\"",
    "dev:api": "node api/index.js",
  },

(2)修改store/action-types.js

js
//新增
export const GETUSER = "GETUSER";

(3)安装axios

在服务端,没有fetch和axios,所以安装axios

安装

bash
npm i axios -S

修改store/action.js

js
//新增
import axios from "axios";

export const GETUSER = () => {
  return (dispatch) => {
    return axios.get("http://localhost:4001/api/users").then((res) => {
      dispatch({
        type: types.GETUSER,
        value: res.data,
      });
    });
  };
};

(4)新建store/reducer/user.js

js
import * as types from "../action-types";

const initState = {
  users: [],
};

const reducer = (state = initState, action) => {
  switch (action.type) {
    case types.GETUSER:
      return Object.assign({}, state, { users: action.value });
    default:
      return state;
  }
};

export default reducer;

(5)修改reducer/index.js

js
//新增
import user from "./user";

//修改
const rootReducer = combineReducers({
  counter,
  user,
});

export default rootReducer;

(6)安装thunk

安装

bash
npm i redux-thunk

修改store/index.js

js
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import rootReducer from "./reducer";

const getStore = () => {
  return createStore(rootReducer, {}, applyMiddleware(thunk));
};

export default getStore;

(7)在pages/homes.js中调用

jsx
//修改
import { INCREMENT, DECREMENT, GETUSER } from "../store/action";

const Home = () => {
  const {
    counter: { count },
    user: { users },
  } = useSelector((store) => store);
  const dispatch = useDispatch();
  return (
    <div>
      <Header />
      <ul>
        {users && users.map((item) => <li key={item.id}>{item.name}</li>)}
      </ul>
      <div>{count}</div>
      <button onClick={() => dispatch(INCREMENT(1))}>+1</button>
      <button onClick={() => dispatch(DECREMENT(1))}>-1</button>
      <button onClick={() => dispatch(GETUSER())}>获取接收数据</button>
    </div>
  );
};

export default Home;

重启服务,查看效果

九、异步数据服务端渲染 ​

上面请求异步数据,是点击按钮获取的,是在客户端生成的。但很多情况是需要在一开始就调用接口,并将接口内容呈现在页面

在home.js组件新增

jsx
useEffect(() => {
  dispatch(GETUSER());
}, []);

在生命周期中,调用接口数据,可以看到页面是被渲染了,但是服务端返回的HTML文档并没有这块内容。这是因为在客户端的生命周期,在服务端不生效

页面已渲染出接口数据,但服务端返回的 HTML 中不含该内容

如果希望在呈现之前加载数据,这样接口数据也能被SEO

思路:根据请求路径,加载路由所需数据,并调用接口,获取完数据之后再去渲染页面

1. 修改路由 ​

jsx
import Home from "../pages/home";
import Login from "../pages/login";

export default [
  {
    path: "/",
    exact: true,
    component: Home,
    loadData: Home.loadData, //该路由需调用数据
    key: "home",
  },
  {
    path: "/login",
    exact: true,
    component: Login,
    key: "login",
  },
];

2. 修改客户端引入路由方式 ​

修改client/index.js

jsx
//修改
import { BrowserRouter, Switch, Route } from "react-router-dom";
import routes from "../routes";

const App = () => {
  return (
    <Provider store={getStore()}>
      <BrowserRouter>
        <Switch>
          {routes.map((route) => (
            <Route {...route} />
          ))}
        </Switch>
      </BrowserRouter>
    </Provider>
  );
};

3. 修改服务端引入路由方式 ​

先通过matchPath方法,收集当前访问路由所对应的组件; 再判断该路由上是否有loadData属性,如果有说明需要异步加载数据; 通过Promise.all()来控制,先请求数据,后加载页面

jsx
//修改
import { StaticRouter, Switch, Route, matchPath } from "react-router-dom";
import routes from "../routes";

export default function (req, res) {
  const store = getStore();
  /* 收集匹配路由 */
  const matchRoutes = [];
  routes.some((route) => {
    matchPath(req.path, route) && matchRoutes.push(route);
  });
  /* 收集需要调用数据的方法 */
  const promises = [];
  matchRoutes.forEach((item) => {
    if (item.loadData) {
      const promise = new Promise((resolve, reject) => {
        //接口不管成功还是失败都返回resolve,防止堵塞页面
        item.loadData(store).then(resolve).catch(resolve);
      });
      promises.push(promise);
    }
  });
  /* 调用完数据再加载页面 */
  Promise.all(promises).then(() => {
    const content = renderToString(
      <Provider store={store}>
        <StaticRouter location={req.path} context={{}}>
          <Switch>
            {routes.map((route) => (
              <Route {...route} />
            ))}
          </Switch>
        </StaticRouter>
      </Provider>,
    );
    res.send(`    <html>
        <head>
        <title>ssr</title>
        </head>
        <body>
            <div id="root">${content}</div>
            <script src="/index.js"></script>
        </body>
    </html>`);
  });
}

4. 修改home.js ​

在Home组件赋值loadData属性,并将请求的方法赋值上去,代表在服务端渲染时,需要调用该方法去请求数据;

在useEffect中再调用接口,这样才能保持客户端和服务端渲染出来的页面保持一致

jsx
//修改
import React, { useEffect } from "react";

//在Home组件中新增
useEffect(() => {
  dispatch(GETUSER());
}, []);

//新增
Home.loadData = function (store) {
  return store.dispatch(GETUSER());
};

重启服务器,可以看到接口数据已经被渲染出来了,但是在浏览器控制台中却有个报错

控制台提示服务端渲染的 HTML 与客户端渲染结果不一致

这个错误说明服务端和客户端的store不同步。因为当服务端拿到store并获取数据后,客户端的js代码又执行了一遍,在客户端代码执行的时候又创建了一个空的store。此时一个有数据的store与一个空的store比较,导致渲染的DOM结构不同,所以报错。页面能渲染出来,是因为客户端空的store又通过接口数据填充和服务端的store才保持了一致。

通过数据的注水和脱水解决这问题

十、数据的注水和脱水 ​

1. 数据的注水 ​

在服务端获取数据后,将数据也加入到客户端的window对象上

修改server/render.js

html
<html>
  <head>
    <title>ssr</title>
  </head>
  <body>
    <div id="root">${content}</div>
    <script>
      window.context = {
        state: ${JSON.stringify(store.getState())}
      }
    </script>
    <script src="/index.js"></script>
  </body>
</html>

注意这里引入的script顺序不能错,先注入数据,后加载页面

2. 数据的脱水 ​

先修改store/index.js

客户端在创建store的时候,先去window对象上读取初始值

js
/* 服务端的store创建函数 */
export const getStore = () => {
  return createStore(rootReducer, {}, applyMiddleware(thunk));
};

/* 客户端的store创建函数 */
export const getClientStore = () => {
  const defaultState = window.context ? window.context.state : {};
  return createStore(rootReducer, defaultState, applyMiddleware(thunk));
};

修改server/render.js

js
import { getStore } from "../store";

修改client/index.js

jsx
import { getClientStore } from "../store";
jsx
<Provider store={getClientStore()}></Provider>

优化:由于服务端已获取数据并注入到客户端,客户端有了数据不用再请求接口数据 修改containers/Home.js

jsx
//如果有数据的话,不调用接口
useEffect(() => {
  if (!users.length) {
    dispatch(GETUSER());
  }
}, []);

十一、CSS服务端渲染 ​

安装

bash
npm install style-loader css-loader --D

修改webpack.client.js

js
//新增
 module: {
  rules: [{
    test: /\.css?$/,
    use: ['style-loader', {
      loader: 'css-loader',
      options: {
        modules: true
      }
    }]
  }]
},

安装

bash
npm install -D isomorphic-style-loader

修改webpack.server.js

js
//新增
module: {
rules: [
  {
    test: /\.css?$/,
    use: [
      "isomorphic-style-loader",
      {
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
  },
],
},

新建 pages/assets/style.css

css
body {
  background: gray;
}

修改login.js

jsx
//修改
import React, { useEffect } from "react";

//新增
import styles from "./assets/style.css";

//在Login中新增

//判断是否为服务端渲染环境
useEffect(() => {
  props.staticContext.css.push(styles._getCss());
}, []);

修改server/render.js

jsx
let context = {css:[]}

Promise.all(promises).then(() => {
    const content = renderToString(
      <Provider store={store}>
        <StaticRouter location={req.path} context={context}>
          <Switch>
            {routes.map((route) => (
              <Route {...route} />
            ))}
          </Switch>
        </StaticRouter>
      </Provider>
    );
    //拼接代码
    const cssStr = context.css.length ? context.css.join('\n') : '';
    res.send(`    <html>
        <head>
        <title>ssr</title>
        <style>${cssStr}</style>
        </head>
        <body>
            <div id="root">${content}</div>
            <script>
              window.context = {
                state: ${JSON.stringify(store.getState())}
              }
            </script>
            <script src="/index.js"></script>
        </body>
    </html>`);