返回博客归档

ISSUE / 011

了解 React 同构

本篇文章将介绍如何在 React 技术生态之上构建同构应用。也许在实际工作场景之中,我们并不需要开发同构应用,但是了解什么是同构应用并清楚其中的运行原理对我们的工作成长也是有很大帮助的。 示例代码仓库:https://github.com/bigbigbo/learning-react-ssr 什么是同…

了解 React 同构的文章题图

本篇文章将介绍如何在 React 技术生态之上构建同构应用。也许在实际工作场景之中,我们并不需要开发同构应用,但是了解什么是同构应用并清楚其中的运行原理对我们的工作成长也是有很大帮助的。

示例代码仓库:https://github.com/bigbigbo/learning-react-ssr

什么是同构应用

每一项技术在被推向广大开发者面前的背后,都是为了解决实际业务场景中碰到的问题,在明白什么是同构应用之前,我们不妨先来了解一下前后端架构的演进过程。

前后端架构的演进

在早期 Web 时代,网站信息多以静态内容为主,其功能只能满足人们的阅读需求,网站页面几乎不与后台进行动态的数据交互;随着时代的发展,人们对于互联网的需求越来越丰富,静态内容的网站已经很难满足人们的冲浪需求,随之孕育而生的就是借助PHP、JSP、ASP.NET为代表的动态页面技术。

动态页面技术

在这个时期的前后端架构中,前端开发所承担的工作,大多以静态页面开发为主,很少涉及数据交互;而后端开发所承担的工作内容就相对较多了,除了后端服务的开发以外,还要在用户请求页面的时候,负责输出完整的静态页面,后端开发在前端提供静态页面的基础之上,利用模板引擎等技术渲染完整的页面并返回给用户,我们称这种技术为服务端渲染(Serve Side Render, SSR)

在这种轻前端、重后端的技术背景之下,前后端工作耦合非常严重。前端高度依赖后端开发环境,后端同时又依赖前端开发的页面模板,显而易见的不好配合、效率低下。以至于很多公司甚至没有单独的前端开发岗位,后端开发即是全栈开发,效率反而更高效。

前后端分离

在上述前后端配合低效的问题之下,时间来到了 2008年9月2日,随着谷歌发布第一个版本的 Chrome,V8 Javascript 引擎的第一个版本随之一起发布。V8 引擎的发布引起了Ryan Dahl 的注意,Ryan 利用 Chrome 的 V8 引擎打造了基于事件循环的异步 I/O 框架 —— Node.js 诞生。2010 年 1 月,NPM 作为 Node.js 的包管理系统首次发布。随着 Node.js 的到来,给开发人员带来的无限想象,随之而来的是带动前后端分离的快速发展。

js-frameworks

在前后端分离的技术架构下,单页面应用(Single Page Application, SPA) 得以流行,当用户请求页面的时候,服务端返回了一个空白的页面,然后通过加载 JavaScript 资源,在客户端完成页面的渲染 (Client Side Render, CSR)。这也是我们当前阶段最常见的一种开发方式。

fqwbbebxnb

可以在 Chrome 浏览器中打开一个单页应用的网站,禁用掉 JavaScript,会发现页面根本渲染不出来。

同构应用

单页面应用虽然极大的提升了前后端的开发效率,但于此同时也会带来一些问题,比如:

  • 影响页面的 SEO(Search Engine Optimization)
  • 首屏等待时间过长

为了解决上述问题,服务端渲染又再一次粉墨登场,但同构!==服务端渲染,简单的讲,同构是利用服务端渲染技术,在用户请求页面,服务端将依据渲染好的页面进行返回,之后仍然由客户端渲染完成用户在这个页面上完成的其他路由导航的一种技术,使同构应用既能解决单页应用的问题又同时能够带来单页应用的体验。同构的含义所在为前后端共用一部分代码,和我们现在所接触的 react-nativetaro.jsuni-app有异曲同工之处。 wtme18nrhz

基于 React 构建同构应用

上面简单的介绍了下同构技术出现的背景,接下来我们将基于 React 来创建一个同构应用,并在此过程逐步了解 React 同构的实现原理。

示例代码并不能作为实际生产使用,代码为了演示需求也删减了一些实现细节。

1. 最简单的服务端渲染

我们先来看一下如何通过 node.js 和 ejs 模板引擎如何实现一个最简单的服务端渲染:

  1. 创建一个模板

    <!--part1/index.ejs-->
    <!DOCTYPE html>
    <html lang="en">
    <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta http-equiv="X-UA-Compatible" content="ie=edge" />
     <title><%= title %></title>
     <style>
       #root {
         font-size: 36px;
         color: red;
       }
     </style>
    </head>
    <body>
     <div id="root"><%= data %></div>
    </body>
    </html>
    
  2. 借助 ejs 模板引擎进行渲染

    // part1/index.js
    const path = require('path');
    const ejs = require('ejs');
    const http = require('http');
    
    

http .createServer((req, res) => {

if (req.url === '/') {
  res.writeHead(200, {
    'Content-Type': 'text/html',
  });
  
  ejs.renderFile(
    path.resolve(__dirname, './index.ejs'),
    {
      title: 'pure ssr',
      data: 'pure ssr',
    },
    (err, data) => {
      if (err) {
        console.log(err);
      } else {
        res.end(data);
      }
    }
  );
}

}) .listen(9981);

所以只要服务端输出`context-type: text/html`,响应内容是 html 字符串就能够完成一个简单的服务端直出页面。

### 2. React 中的服务端渲染
上边例子的 ejs 充当了一个将模板转换成 html 字符串的角色,同理,使用 React 实现服务端渲染,只要通过 React 渲染出相应的 html 字符串即可。

js // part2/index.js const path = require('path'); const http = require('http'); const ejs = require('ejs');

const React = require('react'); const { renderToString } = require('react-dom/server');

const Home = (props) => { const { count = 1 } = props; return (

<div>
  this is build by react ssr, count is {count};
  <br />
  <button onClick={() => console.log('click')}>按钮</button>
</div>

); };

// 模拟请求 const fetchData = () => { return new Promise((resolve) => {

setTimeout(() => {
  resolve(999);
}, 500);

}); };

http .createServer((req, res) => {

if (req.url === '/') {
  res.writeHead(200, {
    'Content-Type': 'text/html',
  });

  fetchData().then((data) => {
    const html = renderToString(<Home count={data} />);

    ejs.renderFile(
      path.resolve(__dirname, './index.ejs'),
      {
        title: 'react ssr',
        data: html,
      },
      (err, data) => {
        if (err) {
          console.log(err);
        } else {
          res.end(data);
        }
      }
    );
  });
}

}) .listen(9981);


我们先通过 babel 将 `Home`组件编译成以下内容:

js "use strict";

var Home = function Home(props) { var _props$count = props.count,

  count = _props$count === void 0 ? 1 : _props$count;

return /#PURE/React.createElement("div", null, "this is build by react ssr, count is ", count, ";", /#PURE/React.createElement("br", null), /#PURE/React.createElement("button", {

onClick: function onClick() {
  return console.log('click');
}

}, "\u6309\u94AE")); };

React 通过 `createElement` 将组件转换成一个对象(虚拟DOM),在 SPA 应用中,如果我们想将 `Home` 组件渲染到页面上,就得借助 `react-dom`:

jsx import { render } from 'react-dom';

render(, document.getElementById('root'))

render 方法会创建所需要的标签完成渲染,但在服务端,我们无法访问到 `document` 这个浏览器特有的对象,就自然无法调用 `document.createElement`,所以其实跟 ejs 这类模板引擎做的事一样,我们需要将组件渲染成 html 字符串,服务端才能直出页面,所以我们借助 `renderToString` 这个方法:

js const html = renderToString();


> ReactDOMServer 对象允许你将组件渲染成静态标记

除了`renderToString` 之外,ReactDomServer 还提供了`renderToStaticMarkup`、`renderToNodeStream`、`renderToStaticNodeStream` 另外三个 API,详细介绍请查看[官网说明](https://zh-hans.reactjs.org/docs/react-dom-server.html)。

以上是 React 服务端渲染的简单介绍,接着我们稍微改造一下示例代码,来演示一下 React 同构中客户端渲染的这一部分。

html // part2/index.ejs

<div id="root"><%- data %></div>
<script>
  'use strict';

  var Home = function Home(props) {
    var _props$count = props.count,
      count = _props$count === void 0 ? 1 : _props$count;
    return /*#__PURE__*/ React.createElement('div', null, 'this is build by react ssr, count is ', count);
  };

  setTimeout(() => {
    ReactDOM.hydrate(/*#__PURE__*/ React.createElement(Home, null), document.getElementById('root'));
  }, 500);
</script>

</body>

> 通过 setTimeout 500ms 模拟浏览器加载 js 所耗费的时间

打开页面,会在控制台看到这样一处警告:

bash Warning: Text content did not match. Server: "999" Client: "1"

in div (created by Home)
in Home
我们先忽略这个问题接着看。

在这段代码里边我们不再通过 `ReactDOM.render` 而是通过 `ReactDOM.hydrate` 方法来渲染页面,接着我们再来改造一下示例(将客户端渲染的部分注释掉):

html

<div id="root"><%- data %></div>
<!-- <script>
  'use strict';
  // ... 
  // var Home ...
</script> -->

</body>

这时候重新运行服务并查看页面,会发现,我们绑定在 `button` 上边的点击事件根本不生效。

以上发现三点问题:

1. 服务端和客户端渲染的节点内容不一样怎么解决?
2. 为什么服务端渲染的内容无法触发事件?
3. 为什么是 React.hydrate 而不是 React.render ?

第一个问题我们放到下面的章节在进行回答,这里先回答 2,3 两个问题。

其实第二个问题上面已经解释过了,服务端无法访问浏览器的特定对象,自然而然就无法进行事件绑定。

再来看看 `React.hydrate` 这个 API:

hydrate 英 [ˈhʌɪdreɪt] 美 [ˈhaɪˌdreɪt] n.水化物 v.使水化;使成水化物

通俗地讲 `hydrate` 就是注水的意思(给干巴巴的海绵加点水,又变成活力十足的海绵宝宝了)。而相对应注水的另外一个意思就是**脱水**,很显然 `renderToString` 就是脱水的过程,所以从[官网的文档](https://zh-hans.reactjs.org/docs/react-dom-server.html#rendertostring)我们也可以看到:

如果你在已有服务端渲染标记的节点上调用 ReactDOM.hydrate() 方法, React 将会保留该节点且只进行事件处理绑定,从而让你有一个非常高性能的首次加载体验。

所以 `hydrate` 和 `render` 的区别就在于 `hydrate` 在客户端执行的时候并不会再进行一次渲染,只是进行事件的处理绑定,而`render` 会在客户端再进行一次渲染,进而导致性能的浪费,如果我们在同构应用中使用 `render` 方法,会看到以下警告:

Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.

现在让我们带着 **服务端和客户端渲染的节点内容不一样怎么解决?** 问题进入下一章节。

### 3. 基于 webpack 构建同构应用
webpack 是前端工程化中的重要一环,在这里我们也将借助 webpack 来构建我们的同构应用。在开始之前,先简单介绍一下这其中的流程:
  1. 书写共用页面代码,也是同构的概念所在
    1.1 维护双端路由
    1.2 解决 SEO 的问题 1.3 数据预取实现一致保证两端渲染节点内容一致(上边提到的问题)
  2. 通过 webpack 构建客户端代码
  3. 通过 webpack 构建服务端代码
  4. 启动后端服务,用户请求页面,服务端准备直出页面:
    3.1 通过 renderToString 方法渲染中首屏 html
    3.2 通过 manifest.json 文件判断客户端所需资源并插入到 html 字符串中 (类似 html-webpack-plugin) ```

3.1 目录结构

先来了解示例中使用到的目录结构如下:

📦react-ssr
 ┣ 📂src
 ┃ ┣ 📂client
 ┃ ┃ ┗ 📜index.js
 ┃ ┣ 📂server
 ┃ ┃ ┣ 📂components
 ┃ ┃ ┃ ┗ 📜HTML.js
 ┃ ┃ ┣ 📂middlewares
 ┃ ┃ ┃ ┗ 📜reactSSR.js
 ┃ ┃ ┗ 📜index.js
 ┃ ┗ 📂shared
 ┃ ┃ ┣ 📂components
 ┃ ┃ ┃ ┗ 📜withSSR.js
 ┃ ┃ ┣ 📂pages
 ┃ ┃ ┃ ┣ 📂about
 ┃ ┃ ┃ ┃ ┣ 📜About.js
 ┃ ┃ ┃ ┃ ┗ 📜index.js
 ┃ ┃ ┃ ┗ 📂home
 ┃ ┃ ┃ ┃ ┣ 📜Home.js
 ┃ ┃ ┃ ┃ ┗ 📜index.js
 ┃ ┃ ┣ 📂utils
 ┃ ┃ ┃ ┗ 📜history.js
 ┃ ┃ ┣ 📜App.js
 ┃ ┃ ┗ 📜router.js
 ┣ 📂webpack
 ┃ ┣ 📜paths.js
 ┃ ┣ 📜webpack.client.js
 ┃ ┗ 📜webpack.server.js
 ┣ 📜.babelrc.js
 ┣ 📜package.json
 ┗ 📜yarn.lock

3.2 package.json

(dev)Dependencies

我们利用以下技术栈来实现一个同构应用:

  • webpack/webpack-cli/webpack-manifest-plugin
  • react/react-dom/react-router-dom/react-helmet-async
  • babel 等相关 package
  • express
scripts
"dev": "npm-run-all --parallel dev:server dev:build:*",
"dev:server": "nodemon --inspect build/server/server.bundle.js",
"dev:build:server": "webpack --config webpack/webpack.server.js --watch",
"dev:build:client": "webpack --config webpack/webpack.client.js --watch"

示例代码通过执行 npm run dev 启动我们的服务器。

3.3 webpack 的配置

我这边精简掉了很多配置,因为这篇文章的主要目的是了解 React 同构的原理。

webpack 提供几种可选方式,帮助你在代码发生变化后自动编译代码:

  1. webpack watch mode(webpack 观察模式)
  2. webpack-dev-server
  3. webpack-dev-middleware

这边为了精简配置使用 watch 观察模式来自动编译我们的代码,同时热更新也不在本篇文章讨论范围之内。

webpack.client.js
const paths = require('./paths');
const webpack = require('webpack');
const ManifestPlugin = require('webpack-manifest-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'development',
  target: 'web',
  entry: {
    main: paths.clientEntry,
  },
  output: {
    path: paths.clientBuild,
    filename: 'client.bundle.js',
    publicPath: paths.publicPath,
    chunkFilename: '[name].[chunkhash:8].chunk.js',
  },
  plugins: [
    new webpack.DefinePlugin({
      __SERVER__: 'false',
      __CLIENT__: 'true',
    }),
    new ManifestPlugin(),
    new CleanWebpackPlugin(),
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
    ],
  },
  optimization: {
    runtimeChunk: 'single',
  },
};

客户端的 webpack 配置,同我们平常开发的 SPA 应用没什么大不同,但有以下几点区别:

  • 新增了 webpack-manifest-plugin 插件,生成的 manifest.json 供服务端使用
  • 移除了 html-webpack-plugin 插件,因为客户端不在需要自己生成 html 文件,而是由服务端直出 html。
  • 设置 publicPath 为服务端配置 express.static 的地址
webpack.server.js
const paths = require('./paths');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'development',
  target: 'node',
  entry: {
    index: paths.serverEntry,
  },
  output: {
    path: paths.serverBuild,
    filename: 'server.bundle.js',
    publicPath: paths.publicPath,
  },
  externals: [nodeExternals()],
  plugins: [
    new webpack.DefinePlugin({
      __SERVER__: 'true',
      __CLIENT__: 'false',
    }),
    new CleanWebpackPlugin(),
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
    ],
  },
};

服务端的 webpack 配置注意以下几点:

  • target 记得设置为 node
  • 引入 webpack-node-externals,因为服务端不需要将 nodemodules 进行打包可以直接访问得到 nodemodules 文件夹

3.4 同构实现

我们先来看看服务端的入口文件:

// server/index.js
import express from 'express';
import chalk from 'chalk';
import reactSSR from './middlewares/reactSSR';
import paths from '../../webpack/paths';
const app = express();

app.use('/static', express.static(paths.clientBuild));

app.use(reactSSR());

app.listen(9981, () => {
  console.log(`[${new Date().toISOString()}]`, chalk.blue(`App is running: http://localhost:9981`));
});

export default app;

这边使用到两个中间件:

  1. 当访问 /static 路径时,使用 express.static 中间件,上边有提到,因为在直出 html 文件的时候需要注入客户端编译后的资源文件,需要配置静态服务才能正确访问到资源
  2. reactSSR 中间件,服务端配合客户端实现 react 同构的逻辑所在

我们在回顾一下前边提到的同构的实现流程:

1. 书写共用页面代码,也是同构的概念所在     
    1.1 维护双端路由   
    1.2 解决 SEO 的问题
    1.3 数据预取实现一致保证两端渲染节点内容一致(上边提到的问题)      
2. 通过 webpack 构建客户端代码
3. 通过 webpack 构建服务端代码
4. 启动后端服务,用户请求页面,服务端准备直出页面:       
    3.1 通过 renderToString 方法渲染中首屏 html     
    3.2 通过 manifest.json 文件判断客户端所需资源并插入到 html 字符串中 (类似 html-webpack-plugin)
1. 如何维护双端路由?

实际项目中不可能只有一个页面,那如何维护双端路由,使我们在访问页面的时候,服务端知道我们是访问哪个页面输出对应 html,之后客户端的路由也能正确跳转呢?

在这里我们借助 react-router-dom 来维护我们双端的路由。 首先我们在声明一份路由配置:

// shared/router.js
import HomePage from './pages/home';
import AboutPage from './pages/about';

const routes = [
  {
    path: '/',
    icon: 'home',
    name: '首页',
    component: HomePage,
  },
  {
    path: '/about',
    icon: 'appstore',
    name: '关于',
    component: AboutPage,
  },
];

export default routes;

并在同构入口 App.js 中使用它:

import React from 'react';
import { Switch, Route } from 'react-router-dom';

import routes from './router';

const NotFound = ({ staticContext }) => {
  if (staticContext) {
    staticContext.notFound = true;
  }

  return <h1>404 Not Found</h1>;
};

const App = () => {
  return (
    <Switch>
      {routes.map((item) => {
        return <Route key={item.path} path={item.path} exact sensitive component={item.component} />;
      })}
      <Route component={NotFound} />
    </Switch>
  );
};
export default App;

接着再来看两端如何维护路由,先看服务端,在 reactSSR 的代码中,可以看到以下这段代码

import { StaticRouter as Router } from 'react-router-dom';

const content = renderToString(
    <Router location={req.url} context={routerContext}>
      <HelmetProvider context={helmetContext}>
        <App />
      </HelmetProvider>
    </Router>
);

if (routerContext.notFound) {
    res.status(404);
    routerContext.notFound = false;
}

对于服务端来说,路由永远只有首屏这个页面,也就是说服务端打包的只会是一个页面的代码,所以这里我们通过 react-router-domStaticRouter 并配合请求地址 req.url 来渲染正确的页面。

在访问不存在页面的时候,应该设置正确的 http-code,这里我们通过 staticContext 我们解决 404 的问题。

客户端方面就和 SPA 应用没有任何区别了:

import { Router } from 'react-router-dom';
import createHistory from '../shared/utils/history';

const history = createHistory();

ReactDOM.hydrate(
  <Router history={history}>
    <App />
  </Router>,
  document.getElementById('root')
);
如何解决页面的 SEO 问题

解决页面的 SEO 问题,就是在服务端直出 html 的时候,在 html 的 header 标签中添加 titledescriptionkeywords(简称 tdk)。

这边我们借助 react-helmet-async,使其可以在书写页面代码的时候声明 tdk

// page/home.js
import { Helmet } from 'react-helmet-async';

const Home = () => {
    return <div>
        <Helmet>
            <title>React SSR - 首页</title>
            <meta name="description" content="首页的描述" />
            <meta name="keywords" content="首页,React,React SSR" />
        </Helmet>
    </div>
}

在看两端的实现:

// client/index.js
import { HelmetProvider } from 'react-helmet-async';

ReactDOM.hydrate(
  <Router history={history}>
    <HelmetProvider>
      <App />
    </HelmetProvider>
  </Router>,
  document.getElementById('root')
);
// server/reactSSR.js
import { HelmetProvider } from 'react-helmet-async';

const helmetContext = {};

const reactSSR = () => () => {
    const content = renderToString(
        <Router location={req.url} context={routerContext}>
          <HelmetProvider context={helmetContext}>
            <App />
          </HelmetProvider>
        </Router>
    );

    res.send(
        '<!doctype html>' +
          renderToString(
            <Html  helmetContext={helmetContext} >
              {content}
            </Html>
          )
      )
}

利用 React Context 的 API,在服务端渲染的时候,可以往 helmetContext 中写入我们在页面设置的 tdk,最后从 helmetContext 中渲染相关的内容:

// server/HTML.js
import React from 'react';

const HTML = ({ children, jsAssets, cssAssets = [], initialData = {}, helmetContext: { helmet } }) => (
  <html lang="">
    <head>
      <meta charSet="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      {helmet.base.toComponent()}
      {helmet.title.toComponent()}
      {helmet.meta.toComponent()}
      {helmet.link.toComponent()}
      {helmet.script.toComponent()}
      {cssAssets.map((chunk) => (
        <link key={chunk.name} rel="stylesheet" href={chunk.path} />
      ))}
    </head>
    <body>
      <div id="root" dangerouslySetInnerHTML={{ __html: children }} />
      <textarea id="initialData" style={{ display: 'none' }} readOnly value={JSON.stringify(initialData)} />
      {jsAssets.map((chunk) => (
        <script key={chunk.name} src={chunk.path} />
      ))}
    </body>
  </html>
);

讲到 HTML 组件,顺便讲下如何利用 manifest.json 文件正确插入客户端资源文件:

// 
const initialChunks = ['runtime', 'main'];
const jsAssets = initialChunks
  .filter((chunkName) => mainfest[chunkName + '.js'])
  .map((chunkName) => ({ name: chunkName, path: mainfest[chunkName + '.js'] }));
const cssAssets = initialChunks
  .filter((chunkName) => mainfest[chunkName + '.css'])
  .map((chunkName) => ({ name: chunkName, path: mainfest[chunkName + '.css'] }));

其中 initialChunks 的值由 webpack 配置所决定,以下配置所对应的 chunk 都是 initialChunks:

  • webpack 入口文件的 [name].js
  • optimization.runtimeChunk 会将 runtime.js 单独拆分出来
  • optimization.splitChunks.cacheGroups 配置中,设置 chunks: all 的所有 chunk
解决数据预期问题,保证双端渲染的节点一致

在第二个示例的时候演示两端节点不一致的问题,涉及到数据预取,有两个以下点要考虑:

  • 数据预取的代码写到哪里?
  • 什么时候服务端执行数据预取?什么时候客户端应该执行数据预取?

两个问题说开了其实也是一个问题,和上面解决 SEO 问题的思路一样,我们尽量在页面代码中把这个数据预取的逻辑给写了,避免双端再各自维护,我们期望使用以下的方式进行数据预取:

import React from 'react';

const Home = (props) => {
    const {data = []} = props;
    
    reutrn //...
};

Home.getInitialProps = async () => {
  await sleep(1000);
  return {
    data: ['测试数据1', '测试数据2', '测试数据3'],
  };
};

export default withSSR(Home);

并且 getInitialProps 应该在:

  • 服务端渲染的时候执行
  • 客户端路由跳转的时候执行

先看服务端如何处理组件的 getInitialProps 方法:

// server/reactSSR.js
const reactSSR = () => () => {
  const target = routes.find((i) => i.path === req.url);

  if (target.component.getInitialProps) {
    const initialData = await target.component.getInitialProps({ req, res });
    routerContext.initialData = initialData;
  }
  
  //...
  
  return res.send(
    '<!doctype html>' +
      renderToString(
        <Html
          initialData={routerContext.initialData}
        >
          {content}
        </Html>
      )
  );
}

// HTML.js
const HTML = ({initialData}) => {
    return <html>
    // ...
    <body>
      <textarea id="initialData" style={{ display: 'none' }} readOnly value={JSON.stringify(initialData)} />
    </body>
    </html>
}

使用 textarea 在避免被 XSS 的风险的同时,讲服务端预取到的数据设置到客户端中,客户端代码实现如下:

const initialData = JSON.parse(document.getElementById('initialData').value.replace(/\\n/g, ''));
window.__INITIAL_DATA__ = initialData || {};

最后来看下 withSSR.js 的实现:

/* eslint-disable no-invalid-this */
import React from 'react';

const withSSR = (WrappedComponent) => {
  return class extends React.Component {
    static async getInitialProps({ req, res }) {
      return WrappedComponent.getInitialProps ? WrappedComponent.getInitialProps({ req, res }) : {};
    }

    state = {
      initialData: {},
      needGetInitialPropsInClient: false,
    };

    async componentDidMount() {
      const needGetInitialPropsInClient = this.props.history && this.props.history.action === 'PUSH';
      if (__CLIENT__ && needGetInitialPropsInClient) {
        this._getInitialProps();
      }
    }

    _getInitialProps = async () => {
      const { match, location } = this.props;
      const initialData = WrappedComponent.getInitialProps
        ? await WrappedComponent.getInitialProps({ match, location })
        : {};

      this.setState({ initialData, needGetInitialPropsInClient: true });
    };

    get _props() {
      const props = { ...this.props };

      if (__SERVER__) {
        // 如果是服务端渲染,则直接从 staticContext 获取
        Object.assign(props, this.props.staticContext ? this.props.staticContext.initialData : {});
      } else {
        if (this.state.needGetInitialPropsInClient) {
          Object.assign(props, this.state.initialData);
        } else {
          Object.assign(props, window.__INITIAL_DATA__);
          window.__INITIAL_DATA__ = undefined; 
        }
      }

      return props;
    }

    render() {
      return <WrappedComponent {...this._props} />;
    }
  };
};

export default withSSR;

withSSR.js 是一个高阶组件,只能作用于页面组件,因为服务端无法获取到页面组件下的子组件,所以 getInitialProps 只能在页面组件中声明。

至此,我们就完成了一个 React 同构应用。

参考文章