The New Old Thing

Webpack 实现 React 预渲染

· Cheng

前言

由于一些原因,我们希望使用 React 或者是其它基于 Virtual DOM 实现的 Web 应用能够在用户访问的时候先输出静态版本的页面,等到整个应用加载完毕之后再切换到实时渲染的版本。这些原因包括性能因素:尽可能快地让用户访问到页面的内容,减少用户等待时间;SEO 因素:让搜索引擎爬虫能够更加容易解析页面的内容;无障碍访问:让辅助设备能够解析内容。

服务端渲染

针对使用 React 实现的应用,React 提供了 Server React DOM APIs 来实现 Server-Side Rendering (SSR)。但是这种解决方案存在着局限性:我们需要一个 Node.js 服务来调用这些 API,在前后端分离的大背景下,如果我们的后端服务本身是使用 Node.js 实现的,这种解决方式是很便捷的。但是如果服务端使用其它技术实现,那么还可能需要单独启动一个 Node.js 服务,或者是使用无头浏览器(Headless Browser)来辅助解决。那么有没有更加轻量的解决方案呢?

Webpack 预渲染

HtmlWebpackPlugin

答案是有的。在搭建 React 开发脚手架的时候,HtmlWebpackPlugin 是一个无法绕开的 webpack 插件,它能帮我们生成 HTML 文件并将编译生成的 JavaScriptCSS 放入到 HTML 文件中的相应位置,插入 titlemeta 等标签。最关键的是 HtmlWebpackPlugin 还支持模板功能。webpack 会在编译完时为我们渲染 ejs 模板。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
    />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
  </head>
  <body>
    <div id="app">
      <ul>
        <% for (let i = 0; i < menus.length; i += 1) { %>
          <% let menu = menus[i] %>
          <li><%= menu %></li>
        <% } %>
      </ul>
    </div>
  </body>
</html>
{
  plugins: [
    new HtmlWebpackPlugin({
      minify: false,
      template: "./path-of-the-template/index.ejs",
      templateParameters: {
        menus: ["Home", "About", "Contact"],
      },
    }),
  ];
}

webpack 会最终生成下面的页面。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
    />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <script defer src="/static/js/572.b4d74bdee5412c82202b.js"></script>
  </head>
  <body>
    <div id="app">
      <ul>
        <li>Home</li>

        <li>About</li>

        <li>Contact</li>
      </ul>
    </div>
  </body>
</html>

结合上边的例子,我们能想到的一个思路就是将首屏的结构写成模板,并在编译时将动态的内容通过变量传递给模板,最终生成接近 React 会为我们渲染的内容。这里存在着一个问题:如果我们模板中有需要向服务端请求的内容,那么如何能够在 webpack 编译模板之前请求接口将内容保存到模板变量呢?

编译期请求服务

HtmlWebpackPlugin 提供了 Events 让我们能够使用 webpacktapable 机制监听到插件的不同阶段、修改数据并返回给 HtmlWebpackPlugin

HtmlWebpackPlugin 执行流程

通过 HtmlWebpackPlugin 提供的流程图可以看出 HtmlWebpackPlugin 编译模板是在第 8 步。对应的 EventafterTemplateExecution。因此只需要监听 afterTemplateExecution 之前的几个 Events,在回调中请求数据,将变量写入到 templateParameters 即可。

插件编写

在理解了 HtmlWebpackPluginEvents 机制之后,结合文档提供的例子,我们可以编写如下插件,在模板的编译前期完成接口的请求、数据的封装和模板变量的植入。

const HtmlWebpackPlugin = require("html-webpack-plugin");

class HtmlWebpackTemplatePlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    compiler.hooks.compilation.tap("HtmlWebpackTemplatePlugin", (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync("HtmlWebpackTemplatePlugin", (data, cb) => {
        this.options
          .func(data)
          .then((resp) => {
            cb(null, resp);
          })
          .catch(() => {
            cb(null, data);
          });
      });
    });
  }
}

module.exports = HtmlWebpackTemplatePlugin;

再来看一下,回调函数里边的 data 有哪些内容。

HtmlWebpackPlugin 数据结构

data.plugin.options.templateParameters 对应着模板变量,只需要将变量写入到此处即可。

插件使用

{
  plugins: [
    new HtmlWebpackPlugin({
      minify: false,
      template: "./path-of-the-template/index.ejs",
      templateParameters: {},
    }),
    new HtmlWebpackTemplatePlugin({
      func: (data) =>
        new Promise((resolve) => {
          rxjs
            .from(fetch(`${process.env.API_URI}/categories`).then((response) => response.json()))
            .pipe(
              rxjs.switchMap((categories) =>
                rxjs
                  .forkJoin(
                    (categories || []).map((category) =>
                      fetch(`${process.env.API_URI}/category/${category.id}/sites`).then((response) => response.json())
                    )
                  )
                  .pipe(
                    rxjs.map((sites) => sites.map((site, index) => ({ ...categories[index], sites: site }))),
                    rxjs.map((sites) => [categories, sites])
                  )
              )
            )
            .subscribe(([categories, sections]) => {
              data.plugin.options.templateParameters.categories = categories;
              data.plugin.options.templateParameters.sections = sections;

              resolve(data);
            });
        }),
    }),
  ];
}

静态内容

静态内容预览

React 渲染的内容

通过上边的例子可以看出在访问页面时,浏览器首先请求到的是预渲染的内容,等到页面的 scripts 加载执行完毕之后,React 生成的代码会 mountroot 结点上,然后替换掉静态的内容。

结语

这种方法也有局限性,那就是只适合数据实时性不强的应用,这就意味着当数据变动时需要重新编译页面。一种更好的解决方案就是将 HTML 模板的静态内容由服务端进行实时输出,例如:Spring 可以利用 Thymeleaf 将内容输出。这样浏览器优先请求到的内容就是服务端生成的内容,等到 React 应用就绪后可以接管页面的渲染。