Skip to content

astonishqft/react-ssr

Repository files navigation

前言

最近这段时间因为工作需要,实践了一下服务端渲染(Server Side Render,以下简称ssr)技术,在这个过程中遇到了很多问题,也参考了很多开源框架的解决方案,感觉受益匪浅,于是有了这篇文章,目的是从零开始,教会大家如何搭建一个属于自己的基于react的ssr框架,彻底弄明白ssr的原理。

什么是ssr(服务端渲染 )

首先我们要弄清楚什么是 ssr

ssr 是相对于 csr (客户端渲染)而言的,一般我们基于 Vue 或者 React 这类工程进行开发的时候,页面都是客户端渲染出来的,通常的的过程一般是这样的(这里以React为例):

用户在浏览器地址栏输入 url,浏览器首先会去服务器请求对应的 html 资源,服务器成功返回 html 页面,其中包含 jscss、图片等资源路径,浏览器根据资源路径再去请求对应的 jscss 图片等资源,资源加载成功后,浏览器开始执行 js,然后会调用 ReactDOM 提供的 render 方法,将虚拟 Dom 渲染到页面上,完成页面的渲染过程。

ssr 的过程稍微复杂一些,一般的流程是这样的:

在浏览器地址栏输入 url,请求发送到服务端,服务端根据请求的 pathname,找到对应要渲染的路由组件,调用 React 提供的 renderToString 或者 renderToStaticMarkup 方法,完成将 React Component 转换为字符串,最后返回给浏览器进行渲染。浏览器获取 html 之后,会再执行一遍 js 代码,来执行事件绑定等操作。

SSR的优势

从上面对 csrssr 的过程分析,我们可以看出,ssr 的优点很明显。

  • 避免白屏现象,提高首屏的加载速度。

服务端返回的字符串已经包含了页面的整个 Dom 结构,因此页面加载速度会很快,不需要等待浏览器执行完 js,才能看到页面效果。这点尤其针对比较大型的单页应用优势很明显,因为单页应用打包后的 js 体积通常比较大,加载并执行完 js 需要耗费一定的时间,这就会导致页面加载出现短暂白屏的现象,ssr 可以很好的避免这一现象的出现。

  • 更好的SEO

很多搜索引擎目前对单页应用的支持不是很好,因为网页中的很多数据需要通过执行完 js 才能获取到,这非常不利于爬虫分析与索引。ssr 很好的解决了这一痛点,因为通过 ssr 生成的页面是已经包含了完整数据的页面,再结合 htmlmeta 标签、titledescription 等,可以大大提高搜索的排名。

如何区分页面是服务端渲染还是客户端渲染的?

当你在访问一个页面的时候,肯定有个疑问,如何判断当前访问的页面是客户端渲染出来的还是服务端渲染出来的呢?

其实判断的方式还是有很多的。比如最简单的,可以选择鼠标放在网页的任意位置,点击鼠标右键,选择显示网页源代码,客户端渲染的页面是不会包含页面的具体内容的,如果是react的应用,通常会有一个空的 div 容器,比如 idroot 的空 div

查看网页源代码-客户端渲染效果

如果是服务端渲染,点击鼠标右键,选择显示网页源代码,能看到完整的页面内容,还是举上图中的例子,看下服务端渲染的效果。

查看网页源代码-服务端渲染效果

方案构想

为了能尽可能方便的支持 ssr 的使用,我想实现的 ssr 应当具备以下特性:

  1. 与服务端低耦合,无论是 Nodejs 还是 Serverless 模式,都能很方便的实现部署集成
  2. 支持页面级服务端加载数据
  3. 支持使用 css-modulesless

实现原理

客户端和服务端单独编译,服务端编译之后会生成一个 server.js 文件,此文件相当于是服务端的入口文件,nodejs 中通过引用该文件,执行该文件加载之后的 render 方法,根据传入的 pathname,生成对应的 html 片段,返回给前端。

node 层使用方式如下:

router.get('/*', async (ctx) => {
  const render = require('dist/server.js');
  const html = render({
    // 当前请求的路径(必选参数)
    pathname: ctx.req.pathname,
    // 可选
    initialData: {},
  });

  ctx.res.body = html;
});

客户端的实现

为了更好的方便大家理解整个实现的过程,这里我并不使用脚手架来实现 ssr 过程。

因为 ssrcsr 的代码是同构的,所以,我们先创建一个 react 工程,然后使用 webpack 编译客户端代码。

初始化工程

新建一个工程,然后执行 npm init,新建如下目录:

├── public
│   └── index.html
├── src
│   ├── index.js
│   └── pages
├── package.json
└── yarn.lock

其中 src 目录下的 index.js 文件作为入口文件:

src/index.js

import React, { Component } from 'react'
import ReactDOM from 'react-dom';

class App extends Component {
  render() {
    return (
      <div>Welcome</div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('root'));

webpack编译

为了能让浏览器执行 js 代码,还需要 webpack 来编译上述代码。

在工程根目录下建一个 webpack.cli.js 文件,用来编译客户端代码:

webpack.cli.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const DIST_PATH = path.resolve(__dirname, 'dist');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    chunkFilename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  resolve: {
    extensions: ['.jsx', '.js', '.json'],
    alias: {
      components: path.resolve(__dirname, 'src/components/'),
      pages: path.resolve(__dirname, 'src/pages/'),
    },
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
        },
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /\.(png|jpg|gif)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            },
          },
        ],
      },
      {
        test: /\.(eot|woff2?|ttf|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              name: '[name]-[hash:5].min.[ext]',
              limit: 5000,
              // fonts file size <= 5KB, use 'base64'; else, output svg file
              publicPath: 'fonts/',
              outputPath: 'fonts/',
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'webpack-start',
      filename: 'index.html',
      template: './public/index.html',
      inject: true,
      favicon: '',
      minify: false,
      hash: true,
    }),
  ],
  devServer: {
    contentBase: DIST_PATH,
    hot: true,
    historyApiFallback: true,
    compress: false,
    port: 9000,
    open: true,
  },
};

安装相关依赖:

$ yarn add react react-dom
$ yarn add webpack webpack-cli webpack-dev-server style-loader url-loader html-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/plugin-proposal-class-properties -D

package.jsonscripts 中增加一行命令:

 "scripts": {
 +  "start": "webpack-dev-server --config  webpack.cli.js"
 }

执行 $ npm run start, 打开浏览器输入 localhost:9000 就可以看到客户端渲染的效果了。

路由

现在我们已经实现了一个 React 工程的搭建,当然一个单页应用还缺少一个重要的部分——路由。

执行 $ yarn add react-router-dom,安装 react-router 相关依赖,新建一个 renderRoutes.js 文件,定义一个 renderRoutes 方法,该方法可以根据传入的路由配置文件生成路由,并且支持嵌套路由。

src/components/renderRoutes.js

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

const RouteWithProps = ({ path, exact, strict, render, location, sensitive, ...rest }) => (
  <Route
    path={path}
    exact={exact}
    strict={strict}
    location={location}
    sensitive={sensitive}
    render={(props) => render({ ...props, ...rest })}
  />
);

export default function renderRoutes(routes = [], extraProps = {}, switchProps = {}) {
  return routes ? (
    <Switch {...switchProps}>
      {routes.map((route, i) => {
        if (route.redirect) {
          return (
            <Redirect
              key={route.key || i}
              from={route.path}
              to={route.redirect}
              exact={route.exact}
              strict={route.strict}
            />
          );
        }
        return (
          <RouteWithProps
            key={route.key || i}
            path={route.path}
            exact={route.exact}
            strict={route.strict}
            sensitive={route.sensitive}
            render={(props) => {
              const childRoutes = renderRoutes(route.routes, extraProps, {
                location: props.location,
              });
              if (route.component) {
                const newProps = { route, ...props, ...extraProps, }
                let { component: Component } = route;
                return (
                  <Component {...newProps} route={route}>
                    {childRoutes}
                  </Component>
                );
              } else {
                return childRoutes;
              }
            }}
          />
        );
      })}
      <Route component={require('@/pages/404').default} />
    </Switch>
  ) : null;
}

改造下 index.js 文件,引入 react-router:

src/index.js

import React, { Component } from 'react'
import ReactDOM from 'react-dom';
import { Router } from 'react-router-dom';
import { createBrowserHistory } from 'history'
import renderRouters from '@/components/renderRoutes';
import routersConfig from '@/config/routes'

const history = createBrowserHistory();

class App extends Component {
  render() {
    return (
      <Router history={history}>{renderRouters(routersConfig)}</Router>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('root'));

路由配置文件:

config/routes.js

const routersConfig = [
  {
    path: '/',
    name: 'Home',
    exact: true,
    component: require('pages/Home').default,
  }, {
    path: '/login',
    name: 'Login',
    exact: true,
    component: require('pages/Login').default,
  },
];

export default routersConfig;

到这里,我们就已经搭建了一个最小化的 React 应用,包括 Webpack 编译打包、es6语法支持、前端路由的实现等。

服务端的实现

上面已经初步介绍过服务端实现的思路,首先是需要一个服务端编译的入口,提供给 Webpack 进行服务端编译使用。

在介绍具体的代码实现之前,首先要了解以下几个概念。

同构的概念

同构指的是客户端和服务端共用同一套代码,这也是 React 服务端渲染实现的最核心的思想。

renderToString

React 的虚拟 DomDom 在内存中的一种存在形式,这就为 React 在服务器环境上运行提供了可能。

React 提供了两个用于在服务端渲染组件的方法:renderToStringrenderToStaticMarkup。这两个方法的作用都是将虚拟 Dom 转换为 HTML 字符串进行输出。

经过 renderToString 方法渲染过后返回的 HTML 片段上会增加两个以 data- 为前缀的属性,其中 data-reactidReact 用于区分 Dom 节点,当组件的 propsstate 发生变化时,React 会识别该属性,快速的更新 Domdata-react-checksum 是对创建 Dom 的校验值,这可以让 React 客户端复用与服务端结构相同的代码。

客户端调用 ReactDOM.hydrate() 方法,react 将会保留该节点且只进行事件处理绑定,从而让你有一个非常高性能的首次加载体验。

renderToStaticMarkup

renderToStaticMarkup 方法和 renderToString 方法类似,但此方法不会在 React 内部创建以 data- 开头的属性。

前面已经说过,React 会利用 data-react-checksum 属性来检查客户端和服务端渲染的页面结构是否一致。如果检测到 data-react-checksum 值不一致,React 会舍弃服务端提供的 Dom 结构,然后重新渲染组件并将其挂载到页面中,这种情况下将不再拥有服务端渲染带来的性能优势。因此,这里我们选择 renderToString 方法。

StaticRouter

react-router 针对服务端渲染的场景专门提供了一个组件 StaticRouter,因为服务端的渲染都是无状态的,服务端根据请求的 url,传递给 StaticRouter 组件,以便能够匹配到路由。

代码实现

src 目录下创建一个 server.js 文件,作为服务端编译的入口文件。

定义一个 serverRender 方法,该方法接受一个参数 pathname, 当调用 serverRender 方法的时候,将 pathname 传递给 StaticRouter 组件,结合上面封装的 renderRouters 方法生成对应的组件,通过 react-dom/server 提供的 renderToString 方法将组件渲染成字符串,最后嵌入 html 片段中返回。

代码如下:

src/server.js

import React from 'react';
import { StaticRouter } from 'react-router-dom';
import { renderToString } from 'react-dom/server';
import renderRouters from '@/components/renderRoutes';
import routersConfig from '@/config/routes';

const serverRender = ({ pathname }) => {
  const RootComponent = () => (
    <StaticRouter location={pathname} static={{}}>
      {renderRouters(routersConfig)}
    </StaticRouter>
  );

  const bundleContent = renderToString(RootComponent());

  console.log('html content:', bundleContent)

  const html = `
    <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
        <meta name="theme-color" content="#000000" />
        <title>react ssr</title>
      </head>

      <body>
        <div id="root">${bundleContent}</div>
      </body>

    </html>
  `

  return {
    html,
  }
}

export default serverRender;

接着配置 Webpack 配置文件进行服务端打包:

webpack.server.js

const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/server.js',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'dist/server'),
    publicPath: '/',
    libraryTarget: 'commonjs2',
  },
  target: 'node',
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
    alias: {
      '@': path.resolve(__dirname, 'src/'),
      components: path.resolve(__dirname, 'src/components/'),
      pages: path.resolve(__dirname, 'src/pages/'),
    },
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
        },
      },
      {
        test: /\.(png|svg|jpg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'static/media/[name].[hash:8].[ext]',
            },
          },
        ],
      },
    ],
  },
};

可以看到,服务端打包的 Webpack 配置文件和客户端打包的配置文件还是很像的,但是有几个地方需要注意下:

  • 配置文件的 target 属性设置为 node,因为编译后代码的运行环境是nodejs
  • outputlibraryTarget 属性需要设置为 commonjs2,使打包后的代码兼容 commonjs 规范。
  • 指定打包的入口为 entry: './src/server.js'

package.json 文件的 srcipts 字段中增加一条命令:

 "scripts": {
 +  "build:ssr": "webpack --config webpack.server.js"
 }

在工程根目录下新建 server.js 文件,内容如下:

const express = require('express');

const app = express();

app.use(express.static('dist', { index: false }));

app.get('/*', async (req, res) => {
  const render = require('./dist/server/index.js').default; // eslint-disable-line
  const html = await render({
    // 当前请求的路径(必选参数)
    pathname: req.url,
  });

  res.send(html.html);
});

app.listen(3000);

console.log('the app is listening at port 3000');

先执行:

$ npm run build:ssr

再执行:

$ node server.js

打开浏览器访问 localhost:3000 即可看到服务端渲染的效果。

进一步思考:

这么做会存在一个问题,现在我们确实可以根据请求的 url 来返回相应的 html ,但是返回的 html 中并没有 jscss 资源。

前面我们介绍过,react 在服务端渲染时需要在客户端也执行一遍 js 代码,以执行绑定事件等操作。因此这里我们想到利用客户端编译之后的 html 文件。

webpack 在进行客户端编译时,借助于 html-webpack-plugin 插件,能够将打包后的 jscss 资源地址直接嵌入 html 文件中输出,类似于下面这样:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SSR</title>
</head>
<body>
  <div id="root"></div>
  <script src="main.js?44c0eed6a8d4ddce3c64"></script></body>
</html>

其中会包含客户端打包后的 jscss 等资源地址。

所以接下来我们改造的思路就是利用客户端编译后生成的 html 文件作为我们服务端编译的输出 html 模板。

为了方便使用 nodejs 来操作 DOM,我选择了 cheerio 这个包,其 apijquery 类似。现在改造一下 server.js:

const serverRender = ({ pathname }) => {
  const RootComponent = () => (
    <StaticRouter location={pathname} static={{}}>
      {renderRouters(routersConfig)}
    </StaticRouter>
  );
  const bundleContent = renderToString(RootComponent());
  const $ = cheerio.load('__SERVER_HTML_TEMPLATE__');
  $('#root').append(bundleContent)
  const html = $.html();
  return {
    html,
  }
}

这里可以看到,我使用了一个变量 __SERVER_HTML_TEMPLATE__ 来进行占位,执行服务端编译之后 server.js 代码是这样的(以下截取了服务端编译后的部分 server.js 代码片段)。

server.js代码片段

因此在执行完服务端的编译之后,我们需要写一个 Webpack 插件,使用客户端编译后生成的 html 字符串来替换 __SERVER_HTML_TEMPLATE__ 这个全局变量。

插件如下:

replaceHtmlTemplateWebpackPlugin

const minify = require('html-minifier').minify
const path = require('path');
const fs = require('fs');

class SSRCompileDonePlugin {
  apply(compiler) {
    compiler.hooks.afterEmit.tap('SSRCompileDone', (stats) => {
      const htmlFilePath = path.join(__dirname, 'dist', 'index.html');
      const ssrBuildFile = path.join(__dirname, 'dist', 'server', 'index.js');
      const bundle = fs.readFileSync(ssrBuildFile, 'utf-8');
      const html = fs.readFileSync(htmlFilePath, 'utf-8');
      const minifedHtml = minify(html, { collapseWhitespace: true, quoteCharacter: '\'' });
      const newBundle = bundle.replace(/__SERVER_HTML_TEMPLATE__/, minifedHtml);
      fs.writeFileSync(ssrBuildFile, newBundle, 'utf-8');
    });
  }
}

module.exports = SSRCompileDonePlugin;

写这个插件需要注意的是,必须要等到服务端编译的文件输出后才能执行字符串的替换工作,因此这里选择在 compiler.hooks.afterEmit 这个 hooks 里进行字符串替换操作。

webpack.server.js 配置文件中引入这个插件后,重新执行服务端编译,服务端编译输出的产物中的 __SERVER_HTML_TEMPLATE__ 字符串已经被替换为客户端编译输出的 html 模板,这样就能够满足我们的基本需求了。

经过插件之后ssr编译后的输出

OK,现在,我们的 SSR 框架已经算完成了一半了,我们先运行 $ npm run build 执行客户端编译,再执行 npm run build:ssr 进行服务端编译,最后执行 node server.js 就可以看到 SSR 的基本效果了。可以看到,客户端编译后的 js 资源也顺利引用到了,很完美!

需要注意的是,在执行服务端编译之前一定要先进行一遍客户端编译,这里的目的主要有两个:

  • 服务端编译时需要依赖客户端编译输出的模板 html 文件,关于这点上面已经详细介绍过了
  • SSR 的时候也会执行一遍 js 代码,因此需要利用客户端打包之后输出的 js 资源,同时 js 代码执行之后还会去加载图片、字体文件、样式等资源,这些都是需要依赖客户端编译的输出产物

在这里插入图片描述

样式问题

到目前为止,我们的 SSR 已经具备了基本的功能,能够直出 html 片段了,但是还有个棘手的问题等待我们解决,那就是样式问题。

在客户端编译时,一般使用 css-loader + style-loader 来处理样式,css-loader 负责解析 css 类型的文件,style-loader 负责将样式通过 style 标签的形式嵌入到 html 中。

对于服务端渲染,这么做就不行了,如果服务端使用上述方式进行编译,会提示 ReferenceError: window is not defined 报错,很显然,在服务端渲染时根本就不存在 window 对象。

查找了很多资料,处理服务端渲染样式用的比较多的是 isomorphic-style-loader 这个库。这个库的用法和 style-loader 类似,但是发现使用起来还是挺繁琐的,那么有没有什么更好的方式处理服务端渲染时的样式呢?

答案是肯定的

查看 css-loader 的文档,我们发现 css-loader 提供了一个参数 onlyLocals (注意,这是css-loader 3.x版本中提供的属性,在最新的4.x版本中已经改为了exportOnlyLocals),文档里是这么介绍的:

Useful when you use css modules for pre-rendering (for example SSR). For pre-rendering with mini-css-extract-plugin you should use this option instead of style-loader!css-loader in the pre-rendering bundle. It doesn't embed CSS but only exports the identifier mappings.

简单翻译下:这个属性就是为预渲染提供的(比如SSR),配合 mini-css-extract-plugin 插件一起使用,它不嵌入CSS,只导出标识符映射。

我们的服务端渲染的样式方案就依赖次选项。

客户端渲染时还是使用 css-loader 进行打包,配合 mini-css-extract-plugin 插件将 css 样式从 js 文件中提取到单独的 css 文件中,输出到 dist 目录中。服务端打包时只需要使用如下配置:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/i,
        loader: 'css-loader',
        options: {
          onlyLocals: true,
        },
      },
    ],
  },
};

此配置会将 className 打包嵌入到 html 中去,前面已经说过,我们服务端返回的 html 字符串中已经包含了客户端打包后的css资源路径,这样,服务端返回 html 后,有了样式的 className,通过网路请求获取到客户端打包后的样式文件,样式也就能生效了。

现在我们顺着这个思路,先改造下客户端的 Webpack 配置文件,为了做到更好的样式隔离,这里我选择了开启 css-module,并且支持 less 的使用。

安装相关依赖:

看下客户端编译样式处理的 Webpack 配置(完整的 Webpack 配置文件可以参考源码):

webpack.cli.prod.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.less/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]-[local]--[hash:base64:5]',
              },
              importLoaders: 1,
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              ident: 'postcss',
              plugins: () => [
                require('postcss-flexbugs-fixes'),
                require('postcss-preset-env')({
                  autoprefixer: {
                    flexbox: 'no-2009',
                  },
                  stage: 3,
                }),
              ],
            }
          },
          {
            loader: 'less-loader',
            options: {
              javascriptEnabled: true,
            },
          },
        ],
      },
    
    ],
  },
  plugins: [
    new MiniCssExtractPlugin(),
  ],
};

看下服务端编译样式处理的 Webpack 配置(完整的 webpack 配置文件可以参考源码):

webpack.server.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.less/,
        use: [
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]-[local]--[hash:base64:5]',
              },
              onlyLocals: true,
              importLoaders: 1,
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              ident: 'postcss',
              plugins: () => [
                require('postcss-flexbugs-fixes'),
                require('postcss-preset-env')({
                  autoprefixer: {
                    flexbox: 'no-2009',
                  },
                  stage: 3,
                }),
              ],
            }
          },
          {
            loader: 'less-loader',
            options: {
              javascriptEnabled: true,
            },
          },
        ],
      },
    ],
  },
};

使用:

Home/index.js

import React from 'react';
import styles from './index.less';

export default () => {
  return (
    <div className={styles.container}>
      <h2>Home page</h2>
    </div>
  );
}

Home/index.less

.container {
  background: red;
}

Webpack 配置文件配置成功后,我们重新编译一下客户端和服务端,启动 nodejs 服务后可以看到,我们想要的 SSR 时直出的 html 片段中已经包含了对应的 className 标识,同时加载到了客户端编译的 css 资源,于是服务端渲染时的样式问题到这里就完美解决了!

在这里插入图片描述

数据同构

服务端渲染另外一个不得不考虑的问题就是如何使用同一套代码去请求数据。

服务器直出 html 时,需要在服务端就完成数据的请求,并将数据携带回,等到浏览器接管页面的时候,需要能够判如果这个数据已经有了,就无需再去请求后台服务了。

React 中,在客户端渲染时,一般数据请求都会放在 componentDidMount 里面去做,但是服务端渲染时不会走这个生命周期,因此我们就要考虑通过其他方式来获取数据。通过比较几种方案,最终决定通过给需要请求数据的路由组件定义一个静态方法 getInitialProps,不管是客户端渲染还是服务端都通过这个静态方法来获取数据。

类似于下面这样:

Home/index.js

class Home extends React.Component {
  render() {
    return (
      <div className={styles.container}>
        <h2>Home page</h2>
      </div>
    );
  }
}

Home.getInitialProps = async () => {
  const entry = 'http:localhost:3000/api/list'
  const { list = [] } = await axios(entry);

  return {
    home: {
      list,
    },
  };
};

export default Home;

服务端需要根据前台传入的 pathname 来找到当前的 React 组件,然后调用该组件上定义的静态方法。

首选需要封装一个 getComponentByPath 方法,该方法利用 react-router 提供的 matchPath 方法,能够根据 pathname 匹配到路由所对应的组件。

function getComponentByPath(routes, currPath) {
  function findMatchRoute(routeList) {
    const matchedRoute = routeList.find(route => {
      return matchPath(currPath, route)
    })
    if (matchedRoute) {
      return matchedRoute.children
        ? findMatchRoute(matchedRoute.children)
        : matchedRoute
    }
    return null
  }
  const matchedRoute = findMatchRoute(routes)
  return matchedRoute && matchedRoute.component
}

改造一下 server.js 文件中的 serverRender 方法。为了更加方便的操作异步数据,我们将 serverRender 函数改为 async 函数。

const serverRender = async ({ pathname }) => {
  let pageInitialProps = {}
  try {
    const PageComponent = getComponentByPath(routersConfig, pathname);
    const getInitialProps = PageComponent && PageComponent.getInitialProps;

    if (getInitialProps) {
      console.log('[SSR]', 'getting initial props of page component')
      pageInitialProps = await getInitialProps();
    }
  } catch (error) {
    console.log('[SSR] generate html template error')
  }
  const RootComponent = () => (
    <StaticRouter location={pathname} static={{}}>
      {renderRouters(routersConfig, pageInitialProps)}
    </StaticRouter>
  );
  const bundleContent = renderToString(RootComponent());
 }

根据前台传过来的 pathname,通过封装的 getComponentByPath 方法,就可以匹配到当前路由所对应的组件,如果该组件上存在 getInitialProps 静态方法,就直接调用,这样在服务端就可以顺利获取到组件初始化的数据了。获取到数据会传给 renderRouters 方法,同时会注入到 window.__GLOBAL_PAGE_PROPS__ 这个全局变量上。

$('head').append(
  `<script>window.__GLOBAL_PAGE_PROPS__ = ${JSON.stringify(
     pageInitialProps,
)};</script>`);

改造一下 renderRouters 方法。

const RouteWithProps = ({ path, exact, strict, render, location, sensitive, ...rest }) => (
  <Route
    path={path}
    exact={exact}
    strict={strict}
    location={location}
    sensitive={sensitive}
    render={(props) => render({ ...props, ...rest })}
  />
);

function withRoutes(route) {
  const { component } = route;

  let Component = (args) => {
    const { render, ...props } = args;
    return render(props);
  };

  if (component) {
    const OldComponent = Component;
    Component = props => {
      const [data, setData] = useState(typeof window !== 'undefined' ? window.__GLOBAL_PAGE_PROPS__ : {});
      useEffect(() => {
        // When enter the page for the first time, need to use window.__ICE_PAGE_PROPS__ as props
        // And don't need to re-request to switch routes
        // Set the data to null after use, otherwise other pages will use
        async function fetchData() {
          if (typeof window !== 'undefined') {
            if (window.__GLOBAL_PAGE_PROPS__) {
              window.__GLOBAL_PAGE_PROPS__ = null;
            } else if (component.getInitialProps) {
              // When the server does not return data, the client calls getinitialprops
              (async () => {
                const pathname = window && window.location && window.location.pathname
                const ctx = { }
                const result = await component.getInitialProps({ pathname, ctx });
                setData(result);
              })();
            }
          }
        }
        fetchData();

      }, []);
      return (
        <OldComponent {...Object.assign({}, props, data)} />
      );
    }
  }

  const ret = (args) => {
    const { render, ...rest } = args;
    return (
      <RouteWithProps
        {...rest}
        render={(props) => {
          return <Component {...props} route={route} render={render} />;
        }}
      />
    );
  };
  return ret;
}

export default function renderRoutes(routes = [], extraProps = {}, switchProps = {}) {
  return routes ? (
    <Switch {...switchProps}>
      {routes.map((route, i) => {
        if (route.redirect) {
          return (
            <Redirect
              key={route.key || i}
              from={route.path}
              to={route.redirect}
              exact={route.exact}
              strict={route.strict}
            />
          );
        }
        const RouteRoute = withRoutes(route);
        return (
          <RouteRoute
            key={route.key || i}
            path={route.path}
            exact={route.exact}
            strict={route.strict}
            sensitive={route.sensitive}
            render={(props) => {
              const childRoutes = renderRoutes(route.routes, extraProps, {
                location: props.location,
              });
              if (route.component) {
                const newProps = { route, ...props, ...extraProps, }
                let { component: Component } = route;
                return (
                  <Component {...newProps} route={route}>
                    {childRoutes}
                  </Component>
                );
              } else {
                return childRoutes;
              }
            }}
          />
        );
      })}
      <Route component={require('@/pages/404').default} />
    </Switch>
  ) : null;
}

renderRoutes 方法会在服务端和客户端各执行一次。服务端执行的时候会将调用 getInitialProps 静态方法获取到的数据传到组件的 props 上,服务端渲染的时候就可以直接从组件的 props 上获取到数据完成组件的渲染工作了。

客户端执行的时候分两种情况:首选会先去判断 window.__GLOBAL_PAGE_PROPS__ 上是否存在服务端渲染时请求过的数据,如果存在就直接将 window.__GLOBAL_PAGE_PROPS__ 上的数据传递给组件的 props,如果没有就根据 pathname 去调用 component.getInitialProps 方法,去请求数据,请求到的数据同样会传递到组件的 props 上。

这样,不管是服务端渲染还是客户端渲染,只要将请求数据的逻辑写在组件的 getInitialProps 静态方法上就可以实现用同一套逻辑,既满足服务端请求又满足客户端请求。

最后我们看一下数据同构改造后的效果:

SSR效果 SSR效果

总结

到这里,整个 React SSR 核心的几个部分就介绍完毕了,当然还有些功能的集成没有介绍到,例如如何集成数据流管理(比如redux),如何支持国际化,如何通过 react-helmet 来更好的进行 SEO,这些环节在理解了上述 SSR 原理的基础上都是很容易就集成进来的,后续有时间我会继续更新demo。

最后附上代码仓库地址,欢迎大家star 😊 !

参考资料

umi 微前端方案 飞冰微前端方案

About

a simple react ssr framework.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published