基于React的SSG静态站点渲染方案

基于React的SSG静态站点渲染方案

静态站点生成SSG - Static Site Generation是一种在构建时生成静态HTML等文件资源的方法,其可以完全不需要服务端的运行,通过预先生成静态文件,实现快速的内容加载和高度的安全性。由于其生成的是纯静态资源,便可以利用CDN等方案以更低的成本和更高的效率来构建和发布网站,在博客、知识库、API文档等场景有着广泛应用。

描述

在前段时间遇到了一个比较麻烦的问题,我们是主要做文档业务的团队,而由于对外的产品文档涉及到全球很多地域的用户,因此在CN以外地域的网站访问速度就成了比较大的问题。虽然我们有多区域部署的机房,但是每个地域机房的数据都是相互隔离的,而实际上很多产品并不会做很多特异化的定制,因此文档实际上是可以通用的,特别是提供了多语言文档支持的情况下,各地域共用一份文档也变得合理了起来。而即使对于CN和海外地区有着特异化的定制,但在海外本身的访问也会有比较大的局限,例如假设机房部署在US,那么在SG的访问速度同样也会成为一件棘手的事情。

那么问题来了,如果我们需要做到各地域访问的高效性,那么就必须要在各个地域的主要机房部署服务,而各个地域又存在数据隔离的要求,那么在这种情况下我们可能需要手动将文档复制到各个机房部署的服务上去,这必然就是一件很低效的事情,即使某个产品的文档不会经常更新,但是这种人工处理的方式依然是会耗费大量精力的,显然是不可取的。而且由于我们的业务是管理各个产品的文档,在加上在海外业务不断扩展的情况下,这类的反馈需求必然也会越来越多,那么解决这个问题就变成了比较重要的事情。

那么在这种情况下,我就忽然想到了我的博客站点的构建方式,为了方便我会将博客直接通过gh-pages分支部署在GitHub Pages上,而GitHub Pages本身是不支持服务端部署的,也就是说我的博客站全部都是静态资源。由此可以想到在业务中我们的文档站也可以用类似的方式来实现,也就是在发布文档的时候通过SSG编译的方式来生成静态资源,那么在全部的内容都是静态资源的情况下,我们就可以很轻松地基于CDN来实现跨地域访问的高效性。此外除了调度CDN的分发方式,我们还可以通过将静态资源发布到业务方申请的代码仓库中,然后业务方就可以自行部署服务与资源了,通过多机房部署同样可以解决跨地域访问的问题。

当然,因为要考虑到各种问题以及现有部署方式的兼容,在我们的业务中通过SSG来单独部署实现跨地域的高效访问并不太现实,最终大概率还是要走合规的各地域数据同步方案来保证数据的一致性与高效访问。但是在思考通过SSG来作为这个问题的解决方案时,我还是很好奇如何在React的基础上来实现SSG渲染的,毕竟我的博客就可以算是基于MdxSSG渲染。最开始我把这个问题想的特别复杂,但是在实现的时候发现只是实现基本原理的话还是很粗暴的解决方案,在渲染的时候并没有想象中要处理得那么精细,当然实际上要做完整的方案特别是要实现一个框架也不是那么容易的事情,对于数据的处理与渲染要做很多方面的考量。

在我们正式开始聊SSG的基本原理前,我们可以先来看一下通过SSG实现静态站点的特点:

  • 访问速度快: 静态网站只是一组预先生成的HTMLCSSJavaScriptImage等静态文件,没有运行在服务器上的动态语言程序,在部署于CDN的情况下,用户可以直接通过边缘节点高效获取资源,可以减少加载时间并增强用户体验。
  • 部署简单: 静态网站可以在任何托管服务上运行,例如GitHub PagesVercel等,我们只需要传输文件即可,无需处理服务器配置和数据库管理等,如果借助Git版本控制和CI/CD工具等,还可以比较轻松地实现自动化部署。
  • 资源占用低: 静态网站只需要非常少的服务器资源,这使得其可以在低配置的环境中运行,我们可以在较低配置的服务器上借助Nginx轻松支撑10k+QPS网站访问。
  • SEO优势: 静态网站通常对搜索引擎优化SEO更加友好,预渲染的页面可以拥有完整的HTML标签结构,并且通过编译可以使其尽可能符合语义化结构,这样使得搜索引擎的机器人更容易抓取和索引。

那么同样的,通过SSG生成的静态资源站点也有一些局限性:

  • 实时性不强: 由于静态站点需要提前生成,因此就无法像动态网站一样根据实时的请求生成对应的内容,例如当我们发布了新文档之后,就必须要重新进行增量编译甚至是全站全量编译,那么在编译期间就无法访问到最新的内容。
  • 不支持动态交互: 静态站点通常只是静态资源的集合,因此在一些动态交互的场景下就无法实现,例如用户登录、评论等功能,当然这些功能可以通过客户端渲染时动态支持,那么这种情况就不再是纯粹的静态站点,通常是借助SSG来实现更好的首屏和SEO效果。

综上所述,SSG更适用于生成内容较为固定、不需要频繁更新、且对于数据延迟敏感较低的的项目,并且实际上我们可能也只是选取部分能力来优化首屏等场景,最终还是会落到CSR来实现服务能力。因此当我们要选择渲染方式的时候,还是要充分考虑到业务场景,由此来确定究竟是CSR - Client Side RenderSSR - Server Side RenderSSG - Static Site Generation更适合我们的业务场景,甚至在一些需要额外优化的场景下,ISR - Incremental Static RegenerationDPR - Distributed Persistent RenderingESR - Edge Side Rendering等也可以考虑作为业务上的选择。

当然,回到最初我们提到的问题上,假如我们只是为了静态资源的同步,通过CDN来解决全球跨地域访问的问题,那么实际上并不是一定需要完全的SSG来解决问题。将CSR完全转变为SSR毕竟是一件改造范围比较大的事情,而我们的目标仅仅是一处生产、多处消费,因此我们可以转过来想一想实际上JSON文件也是属于静态资源的一种类型,我们可以直接在前端发起请求将JSON文件作为静态资源请求到浏览器并且借助SDK渲染即可,至于一些交互行为例如点赞等功能的速度问题我们也是可以接受的,文档站最的主要行为还是阅读文档。此外对于md文件我们同样可以如此处理,例如docsify就是通过动态请求,但是同样的对于搜索引擎来说这些需要执行Js来动态请求的内容并没有那么容易抓取,所以如果想比较好地实现这部分能力还是需要不断优化迭代。

那么接下来我们就从基本原理开始,优化组件编译的方式,进而基于模版渲染生成SSG,文中相关API的调用基于React17.0.2版本实现,内容相关的DEMO地址为https://github.com/WindrunnerMax/webpack-simple-environment/tree/master/packages/react-render-ssg

基本原理

通常当我们使用React进行客户端渲染CSR时,只需要在入口的index.html文件中置入

的独立DOM节点,然后在引入的xxx.js文件中通过ReactDOM.render方法将React组件渲染到这个DOM节点上即可。将内容渲染完成之后,我们就会在某些生命周期或者Hooks中发起请求,用以动态请求数据并且渲染到页面上,此时便完成了组件的渲染流程。

那么在前边我们已经聊了比较多的SSG内容,那么可以明确对于渲染的主要内容而言我们需要将其离线化,因此在这里就需要先解决第一个问题,如何将数据离线化,而不是在浏览器渲染页面之后再动态获取。很明显在前边我们提到的将数据从数据库请求出来之后写入json文件就是个可选的方式,我们可以在代码构建的时候请求数据,在此时将其写入文件,在最后一并上传到CDN即可。

在我们的离线数据请求问题解决后,我们就需要来看渲染问题了,前边也提到了类似的问题,如果依旧按照之前的渲染思路,而仅仅是将数据请求的地址从服务端接口替换成了静态资源地址,那么我们就无法做到SEO以及更快的首屏体验。其实说到这里还有一个比较有趣的事情,当我们用SSR的时候,假如我们的组件是dynamic引用的,那么Next在输出HTML的时候会将数据打到HTML标签里,在这种情况下实际上首屏的效率还是不错的,并且Google进行索引的时候是能够正常将动态执行Js渲染后的数据抓取,对于我们来说也可以算作一种离线化的渲染方案。

那么这种方式虽然可行但是并不是很好的方案,我们依然需要继续解决问题,那么接下来我们需要正常地来渲染完整的HTML结构。在ReactDOMServer API中存在存在两个相关的API,分别是renderToStaticMarkuprenderToString,这两个API都可以将React组件输出HTML标签的结构,只是区别是renderToStaticMarkup渲染的是不带data-reactid的纯HTML结构,当客户端进行React渲染时会完全重建DOM结构,因此可能会存在闪烁的情况,renderToString则渲染了带标记的HTML结构,React在客户端不会重新渲染DOM结构,那么在我们的场景下时需要通过renderToString来输出HTML结构的。

// packages/react-render-ssg/src/basic/index.ts
import ReactDOMServer from "react-dom/server";

const App = React.createElement(
  React.Fragment,
  null,
  React.createElement("div", null, "React HTML Render"),
  React.createElement(
    "button",
    {
      onClick: () => alert("On Click"),
    },
    "Button"
  )
);

const HTML = ReactDOMServer.renderToString(App);
// 
React HTML Render

当前我们已经得到组件渲染过后的完整HTML结构,紧接着从输出的内容我们可以看出来一个问题,我们定义的onClick函数并没有在渲染过后的HTML结构中体现出来,此时在我们的HTML结构中只是一些完整的标签,并没有任何事件的处理。当然这也是很合理的情况,我们是用React框架实现的事件处理,其并不太可能直接完整地映射到输出的HTML中,特别是在复杂应用中我们还是需要通过React来做后续事件交互处理的,那么很显然我们依旧需要在客户端处理相关的事件。

那么在React中我们常用的处理客户端渲染函数就是ReactDOM.render,那么当前我们实际上已经处理好了HTML结构,而并不需要再次将内容完整地渲染出来,或者换句话说我们现在需要的是将事件挂在相关DOM上来处理交互行为,将React附加到在服务端环境中已经由React渲染的现有HTML上,由React来接管有关的DOM的处理。那么对于我们来说,我们需要将同样的React组件在客户端一并定义,然后将其输出到页面的Js中,也就是说这部分内容是需要在客户端中执行的。

// packages/react-render-ssg/src/basic/index.ts
const PRESET = `
const App = React.createElement(
  React.Fragment,
  null,
  React.createElement("div", null, "React HTML Render"),
  React.createElement(
    "button",
    {
      onClick: () => alert("On Click"),
    },
    "Button"
  )
);
const _default = App;
ReactDOM.hydrate(_default, document.getElementById("root"));
`;

await fs.writeFile(`dist/${jsPathName}`, PRESET);

实际上这部分代码都是在服务端生成的,我们此时并没有在客户端运行的内容,或者说这是我们的编译过程,还没有到达运行时,所以我们生成的一系列内容都是在服务端执行的,那么很明显我们是需要拼装HTML等静态资源文件的。因此在这里我们可以通过预先定义一个HTML文件的模版,然后将构建过程中产生的内容放到模版以及新生成的文件里,产生的所有内容都将随着构建一并上传到CDN上并分发。




  
    
    Template
    
  
  
    
// packages/react-render-ssg/src/basic/index.ts
const template = await fs.readFile("./public/index.html", "utf-8");
await fs.mkdir("dist", { recursive: true });
const random = Math.random().toString(16).substring(7);
const jsPathName = `${random}.js`;
const html = template
.replace(//, HTML)
.replace(//, ``);
await fs.writeFile(`dist/${jsPathName}`, PRESET);
await fs.writeFile(`dist/index.html`, html);

至此我们完成了最基本的SSG构建流程,接下来就可以通过静态服务器访问资源了,在这部分DEMO可以直接通过ts-node构建以及anywhere预览静态资源地址。实际上当前很多开源的静态站点搭建框架例如VitePressRsPress等等都是采用类似的原理,都是在服务端生成HTMLJsCSS等等静态文件,然后在客户端由各自的框架重新接管DOM的行为,当然这些框架的集成度很高,对于相关库的复用程度也更高。而针对于更复杂的应用场景,还可以考虑NextGatsby等框架实现,这些框架在SSG的基础上还提供了更多的能力,对于更复杂的应用场景也有着更好的支持。

组件编译

虽然在前边我们已经实现了最基本的SSG原理,但是很明显我们为了最简化地实现原理人工处理了很多方面的内容,例如在上述我们输出到Js文件的代码中是通过PRESET变量定义的纯字符串实现的代码,而且我们对于同一个组件定义了两遍,相当于在服务端和客户端分开定义了运行的代码,那么很明显这样的方式并不太合理,接下来我们就需要解决这个问题。

那么我们首先需要定义一个公共的App组件,在该组件的代码实现中与前边的基本原理中一致,这个组件会共享在服务端的HTML生成和客户端的React Hydrate,而且为了方便外部的模块导入组件,我们通常都是通过export default的方式默认导出整个组件。

// packages/react-render-ssg/src/rollup/app.tsx
import React from "react";

const App = () => (
  
    
React Render SSG
); export default App;

紧接着我们先来处理客户端的React Hydrate,在先前我们是通过人工维护的编辑的字符串来定义的,而实际上我们同样可以打包工具在Node端将组建编译出来,以此来输出Js代码文件。在这里我们选择使用Rollup来打包Hydrate内容,我们以app.tsx作为入口,将整个组件作为iife打包,然后将输出的内容写入APP_NAME,然后将实际的hydrate置入footer,就可以完成在客户端的React接管DOM执行了。

// packages/react-render-ssg/rollup.config.js
const APP_NAME = "ReactSSG";
const random = Math.random().toString(16).substring(7);

export default async () => {
  return {
    input: "./src/rollup/app.tsx",
    output: {
      name: APP_NAME,
      file: `./dist/${random}.js`,
      format: "iife",
      globals: {
        "react": "React",
        "react-dom": "ReactDOM",
      },
      footer: `ReactDOM.hydrate(React.createElement(${APP_NAME}), document.getElementById("root"));`,
    },
    plugins: [
      // ...
    ],
    external: ["react", "react-dom"],
  };
};

接下来我们来处理服务端的HTML文件生成与资源的引用,这里的逻辑与先前的基本原理中服务端生成逻辑差别并不大,只是多了通过终端调用Rollup打包的逻辑,同样也是将HTML输出,并且将Js文件引入到HTML中,这里需要特殊关注的是我们的Rollup打包时的输出文件路径是在这里由--file参数覆盖原本的rollup.config.js内置的配置。

// packages/react-render-ssg/src/rollup/index.ts
const exec = promisify(child.exec);

(async () => {
  const HTML = ReactDOMServer.renderToString(React.createElement(App));
  const template = await fs.readFile("./public/index.html", "utf-8");

  const random = Math.random().toString(16).substring(7);
  const path = "./dist/";
  const { stdout } = await exec(`npx rollup -c --file=${path + random}.js`);
  console.log("Client Compile Complete", stdout);

  const jsFileName = `${random}.js`;
  const html = template
    .replace(//, HTML)
    .replace(//, ``);
  await fs.writeFile(`${path}index.html`, html);
})();

模版渲染

当前我们已经复用了组件的定义,并且通过Rollup打包了需要在客户端运行的Js文件,不需要再人工维护输出到客户端的内容。那么场景再复杂一些,假如此时我们的组件有着更加复杂的内容,例如引用了组件库来构建视图,以及引用了一些CSS样式预处理器来构建样式,那么我们的服务端输出HTML的程序就会变得更加复杂。

继续沿着前边的处理思路,我们在服务端的处理程序仅仅是需要将App组件的HTML内容渲染出来,那么假设此时我们的组件引用了@arco-design组件库,并且通常我们还需要引用其中的less文件或者css文件。

import "@arco-design/web-react/dist/css/arco.css";
import { Button } from "@arco-design/web-react";
// OR
import "@arco-design/web-react/es/Button/style/index";
import { Button } from "@arco-design/web-react/es/Button";

那么需要关注的是,当前我们运行组件的时候是在服务端环境中,那么在Node环境中显然我们是不认识.less文件以及.css文件的,实际上先不说这些样式文件,import语法本身在Node环境中也是不支持的,只不过我们通常是使用ts-node来执行整个运行程序,暂时这点不需要关注,那么对于样式文件我们在这里实际上是不需要的,所以我们就需要配置Node环境来处理这些样式文件的引用。

require.extensions[".css"] = () => undefined;
require.extensions[".less"] = () => undefined;

但是即使这样问题显然没有结束,熟悉arco-design的打包同学可能会清楚,当我们引入的样式文件是Button/style/index时,实际上是引入了一个js文件而不是.less文件,如果需要明确引入.less文件的话是需要明确Button/style/index.less文件指向的。那么此时如果我们是引入的.less文件,那么并不会出现什么问题,但是此时我们引用的是.js文件,而这个.js文件中内部的引用方式是import,因为此时我们是通过es而不是lib部分明确引用的,即使在tsconfig中配置了相关解析方式为commonjs也是没有用的。

{
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs",
      "esModuleInterop": true
    }
  }
}

因此我们可以看到,如果仅仅用ts-node来解析或者说执行服务端的数据生成是不够的,会导致我们平时实现组件的时候有着诸多限制,例如我们不能随便引用es的实现而需要借助包本身的package.json声明的内容来引入内容,如果包不能处理commonjs的引用那么还会束手无策。那么在这种情况下我们还是需要引入打包工具来打包commonjs的代码,然后再通过Node来执行输出HTML。通过打包工具,我们能够做的事情就很多了,在这里我们将资源文件例如.less.svg都通过null-loader加载,且相关的配置输出都以commonjs为基准,此时我们输出的文件为node-side-entry.js

// packages/react-render-ssg/rspack.server.ts
const config: Configuration = {
  context: __dirname,
  entry: {
    index: "./src/rspack/app.tsx",
  },
  externals: externals,
  externalsType: "commonjs",
  externalsPresets: {
    node: true,
  },
  // ...
  module: {
    rules: [
      { test: /.svg$/, use: "null-loader" },
      { test: /.less$/, use: "null-loader" },
    ],
  },
  devtool: false,
  output: {
    iife: false,
    libraryTarget: "commonjs",
    publicPath: isDev ? "" : "./",
    path: path.resolve(__dirname, ".temp"),
    filename: "node-side-entry.js",
  },
};

当前我们已经得到了可以在Node环境中运行的组件,那么紧接着,考虑到输出SSG时我们通常都需要预置静态数据,例如我们要渲染文档的话就需要首先在数据库中将相关数据表达查询出来,然后作为静态数据传入到组件中,然后在预输出的HTML中将内容直接渲染出来,那么此时我们的App组件的定义就需要多一个getStaticProps函数声明,并且我们还引用了一些样式文件。

// packages/react-render-ssg/src/rspack/app.tsx
import "./app.less";
import { Button } from "@arco-design/web-react";
import React from "react";

const App: React.FC = props => (
  
    
React Render SSG With {props.name}
); export const getStaticProps = () => { return Promise.resolve({ name: "Static Props", }); }; export default App;
/* packages/react-render-ssg/src/rspack/app.less */
body {
  padding: 20px;
}

同样的,我们也需要为客户端运行的Js文件打包,只不过在这里由于我们需要处理预置的静态数据,我们在打包的时候同样就需要预先生成模版代码,当我们在服务端执行打包功能的时候,就需要将从数据库查询或者从文件读取的数据放置于生成的模版文件中,然后以该文件为入口去再打包客户端执行的React Hydrate能力。在这里因为希望将模版文件看起来更加清晰,我们使用了JSON.parse来处理预置数据,实际上这里只需要将占位预留好,数据在编译的时候经过stringify直接写入到模版文件中即可。

// packages/react-render-ssg/src/rspack/entry.tsx
/* eslint-disable @typescript-eslint/no-var-requires */

const Index = require(``);
const props = JSON.parse(``);
ReactDOM.hydrate(React.createElement(Index.default, { ...props }), document.getElementById("root"));

在模版文件生成好之后,我们就需要以这个文件作为入口调度客户端资源文件的打包了,这里由于我们还引用了组件库,输出的内容自然不光是Js文件,还需要将CSS文件一并输出,并且我们还需要配置一些通过参数名可以控制的文件名生成、externals等等。这里需要注意的是,此处我们不需要使用html-pluginHTML文件输出,这部分调度我们会在最后统一处理。

// packages/react-render-ssg/rspack.config.ts

const args = process.argv.slice(2);
const map = args.reduce((acc, arg) => {
  const [key, value] = arg.split("=");
  acc[key] = value || "";
  return acc;
}, {} as Record);
const outputFileName = map["--output-filename"];

const config: Configuration = {
  context: __dirname,
  entry: {
    index: "./.temp/client-side-entry.tsx",
  },
  externals: {
    "react": "React",
    "react-dom": "ReactDOM",
  },
  // ...
  builtins: {
    // ...
    pluginImport: [
      {
        libraryName: "@arco-design/web-react",
        customName: "@arco-design/web-react/es/{{ member }}",
        style: true,
      },
      {
        libraryName: "@arco-design/web-react/icon",
        customName: "@arco-design/web-react/icon/react-icon/{{ member }}",
        style: false,
      },
    ],
  },
  // ...
  output: {
    chunkLoading: "jsonp",
    chunkFormat: "array-push",
    publicPath: isDev ? "" : "./",
    path: path.resolve(__dirname, "dist"),
    filename: isDev
      ? "[name].bundle.js"
      : outputFileName
      ? outputFileName + ".js"
      : "[name].[contenthash].js",
    // ...
  },
};

那么此时我们就需要调度所有文件的打包过程了,首先我们需要创建需要的输出和临时文件夹,然后启动服务端commonjs打包的流程,输出node-side-entry.js文件,并且读取其中定义的App组件以及预设数据读取方法,紧接着我们需要创建客户端入口的模版文件,并且通过调度预设数据读取方法将数据写入到入口模版文件中,此时我们就可以通过打包的commonjs组件执行并且输出HTML了,并且客户端运行的React Hydrate代码也可以在这里一并打包出来,最后将各类资源文件的引入一并在HTML中替换并且写入到输出文件中就可以了。至此当我们打包完成输出文件后,就可以使用静态资源服务器启动SSG的页面预览了。

const appPath = path.resolve(__dirname, "./app.tsx");
const entryPath = path.resolve(__dirname, "./entry.tsx");
require.extensions[".less"] = () => undefined;

(async () => {
  const distPath = path.resolve("./dist");
  const tempPath = path.resolve("./.temp");
  await fs.mkdir(distPath, { recursive: true });
  await fs.mkdir(tempPath, { recursive: true });

  const { stdout: serverStdout } = await exec(`npx rspack -c ./rspack.server.ts`);
  console.log("Server Compile", serverStdout);
  const nodeSideAppPath = path.resolve(tempPath, "node-side-entry.js");
  const nodeSideApp = require(nodeSideAppPath);
  const App = nodeSideApp.default;
  const getStaticProps = nodeSideApp.getStaticProps;
  let defaultProps = {};
  if (getStaticProps) {
    defaultProps = await getStaticProps();
  }

  const entry = await fs.readFile(entryPath, "utf-8");
  const tempEntry = entry
    .replace("", JSON.stringify(defaultProps))
    .replace("", appPath);
  await fs.writeFile(path.resolve(tempPath, "client-side-entry.tsx"), tempEntry);

  const HTML = ReactDOMServer.renderToString(React.createElement(App, defaultProps));
  const template = await fs.readFile("./public/index.html", "utf-8");
  const random = Math.random().toString(16).substring(7);
  const { stdout: clientStdout } = await exec(`npx rspack build -- --output-filename=${random}`);
  console.log("Client Compile", clientStdout);

  const jsFileName = `${random}.js`;
  const html = template
    .replace(//, HTML)
    .replace(//, ``)
    .replace(//, ``);
  await fs.writeFile(path.resolve(distPath, "index.html"), html);
})();

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://www.sanity.io/ssr-vs-ssg-guide
https://react.docschina.org/reference/react-dom
https://www.theanshuman.dev/articles/what-the-heck-is-ssg-static-site-generation-explained-with-nextjs-5cja
千百度
© 版权声明
THE END
喜欢就支持一下吧
点赞151 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容