React SSR同构渲染方案是什么?

一、背景

目前主流的前端架构分为SSRCSRSSG,比较适合首屏直出的方案除了CSR都还不错,因为服务端会直接返回路由对应的html + css,浏览器直接解析DOM即可,而水合的作用是什么?服务端首次返回的是静态页面,页面需要"动"起来的话,则需要水合,即将页面所需的JS引入并加载、给DOM绑定交互等等,核心的API即ReactDOM.hydrateRoot

二、同构渲染

这样服务端渲染一次、客户端渲染一次的流程也称为同构渲染,一般情况是通过Nodejs实现文件服务,基于组件文件通过React Server API------renderToString,就像这样:

ts 复制代码
import { renderToString } from 'react-dom/server';
import App from './App';

console.log(renderToString(<App/>));

而在客户端接收到服务端渲染所生成的html string后,基于客户端水合API------react DOM API------hydrateRoot进行组件与静态标签的关联,就像这样:

ts 复制代码
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import './index.css';
import App from './App';

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

三、手写一个SSR

基于同构渲染的秘密,我们解开了,那手写一个基础的SSR服务其实很简单,我们可以把整个加载链路拆解成三步:

  1. 起一个本地express服务,用于按路由返回html
  2. 前端构建时约定式路由/pages,解析每个路由下的根组件;生成html模板;
  3. webpack构建工程代码,产出bundle.js
  4. 前端项目中引入bundle.js,进行同构渲染水合逻辑;

整体的项目工程如下:

less 复制代码
├── .next    // 生成文件服务html、构建结果
├── src
│ ├── pages // 页面
│ │ ├── a.jsx // 页面A
│ │ ├── b.jsx // 页面B
│ │ ├── c.jsx // 页面C
│ ├── client.js // 水合
├── generateHtml.js // 路由转换html能力
├── server.js // 文件服务
├── teamplate.html // html基础模板
├── webpack.config.js
└── package.json

3.1、文件服务搭建

我们起一个简单的工程项目,并安装基础依赖。

bash 复制代码
mkdir ssr-demo
cd ssr-demo
npm init -y
npm i express react react-dom webpack webpack-cli fs-extra

express服务代码如下:

js 复制代码
const express = require("express");
const path = require("path");

// 定义根目录
const rootDir = path.resolve(__dirname, "./");
const outputDir = path.join(rootDir, ".next");

const app = express();
const PORT = process.env.PORT || 3000;

// 提供 .next 目录的静态文件服务
app.use(express.static(outputDir));

// 启动服务器
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

代码中启用了静态文件服务,在路由解析服务实现后,每次项目构建阶段都会在.nest路由生成所有页面的html,用于在服务端直接返回。

3.2、路由解析服务

接下来我们实现服务端核心部分,将所有路由的组件解析成html模板,在此之前需要有一个基础的html模板,用于动态插入组件部分的标签,html模板如下:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>My App</title>
  </head>
  <body data-component="{{componentName}}">
    {{content}}
    <script src="/client.bundle.js" defer></script>
  </body>
</html>

其中content是实际渲染的组件标签、componentName用于在后续水合阶段定位组件、script用于执行水合逻辑。

遍历生成所有html文件的代码如下:

js 复制代码
// src/generate.js
require("@babel/register")({
  presets: ["@babel/preset-env", "@babel/preset-react"],
});

const fs = require("fs-extra"); // 使用 fs-extra 方便处理文件
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");

// 定义根目录
const rootDir = path.resolve(__dirname, "./");
const pagesDir = path.join(rootDir, "src/pages");
const outputDir = path.join(rootDir, ".next");

// 生成 HTML 文件
const generateHtmlFiles = () => {
  return new Promise((resolve, reject) => {
    fs.readdir(pagesDir, (err, files) => {
      if (err) {
        reject("Error reading pages directory");
        return;
      }

      const promises = files.map((file) => {
        const filePath = path.join(pagesDir, file);
        const fileExt = path.extname(file);

        // 只处理以 .jsx 结尾的文件
        if (fileExt === ".jsx") {
          const pageName = path.basename(file, fileExt);
          const PageComponent = require(filePath).default; // 导入组件
          const renderedContent = ReactDOMServer.renderToString(
            React.createElement(PageComponent)
          );

          const templatePath = path.resolve(__dirname, "template.html"); // Load HTML template
          return new Promise((resolve, reject) => {
            fs.readFile(templatePath, "utf8", (err, template) => {
              if (err) {
                console.error("Error loading template:", err);
                reject(err);
                return;
              }
              const html = template
                .replace("{{content}}", renderedContent)
                .replaceAll("{{componentName}}", pageName); // 注入组件名称

              // 构造输出路径,放在以页面名字为名的文件夹中
              const outputDirForPage = path.join(outputDir, pageName);
              const outputFilePath = path.join(outputDirForPage, "index.html");

              fs.ensureDirSync(outputDirForPage); // 确保页面目录存在
              fs.outputFileSync(outputFilePath, html, "utf8");
              console.log(`Generated HTML for ${pageName}: ${outputFilePath}`);
              resolve();
            });
          });
        }
        return Promise.resolve(); // 对于不是 .jsx 文件的情况
      });

      Promise.all(promises)
        .then(() => resolve("HTML generation completed!"))
        .catch(reject); // 处理生成过程中的异常
    });
  });
};

// 直接调用生成函数并输出结果
generateHtmlFiles()
  .then((message) => {
    console.log(message);
    process.exit(0); // 结束进程
  })
  .catch((err) => {
    console.error(err);
    process.exit(1); // 发生错误,结束进程
  });

3.3、webpack打包前端工程

这里比较好理解,整个前端客户端代码也需要打包,包括水合的代码,我们基于webpack构建,初始化一个基础的打包配置:

webpack.config.js 复制代码
const path = require("path");

module.exports = {
  entry: path.resolve(__dirname, 'src', 'client.js'),
  output: {
    path: path.resolve(__dirname, ".next"), // 输出到 .next 目录
    filename: "client.bundle.js", // 根据入口名称生成文件名
    publicPath: "/", // 公开路径
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env", "@babel/preset-react"],
          },
        },
      },
    ],
  },
  resolve: {
    extensions: [".js", ".jsx"],
  },
  mode: "production", // 可以根据需要设置为 'development'
};

配置完webpack之后,我们前三步的流程可以串起来了,这个效果和webpack dev server比较类似,我们基于流程顺序,配置对应的package.json scripts

json 复制代码
  "scripts": {
    "build": "webpack --config webpack.config.js",
    "generate": "node generateHtml.js",
    "server": "node server.js",
    "start": "npm run build && npm run generate && npm run server"
  },
  1. build负责构建前端工程;
  2. generate负责生成所有页面的html;
  3. server负责创建最终的文件服务;

3.4、渲染的终点------水合

这段代码运行时,服务端已经返回html文件,此时是同构渲染的终点,通过hydrateRoot API将服务端的标签在浏览器水合,让页面组件动起来即可。

实现代码:

js 复制代码
// // src/client.js
import React from "react";
import ReactDOM from "react-dom/client";

console.log(React);
const hydrateComponent = (componentName) => {
  // 动态 import 组件
  import(`./pages/${componentName}.jsx`)
    .then(({ default: Component }) => {
      const domContainer = document.getElementById(componentName);
      if (domContainer) {
        ReactDOM.hydrateRoot(<Component />, domContainer);
      } else {
        console.error(`No container found for component ${componentName}`);
      }
    })
    .catch((error) => {
      console.error("Error loading component:", error);
    });
};

// 从 HTML 中提取组件名称,然后进行水合
document.addEventListener("DOMContentLoaded", () => {
  const componentName = document.body.getAttribute("data-component");
  hydrateComponent(componentName);
});

至此整个demo就完成了。

大致的效果如下:

结尾

本文希望对于原本不是很了解SSR、SSG方案的同学可以快速的理解与传统单页应用的区别,并且可以基于这个demo,了解到服务端渲染、webpack dev server的工作原理。

对于实现同构渲染的方案有建议的同学,欢迎评论探讨。

相关推荐
老李笔记7 分钟前
VUE element table 列合并
javascript·vue.js·ecmascript
滿9 分钟前
Vue3 Element Plus 表格默认显示一行
javascript·vue.js·elementui
好了来看下一题10 分钟前
TypeScript 项目配置
前端·javascript·typescript
江城开朗的豌豆14 分钟前
Vue的双向绑定魔法:如何让数据与视图‘心有灵犀’?
前端·javascript·vue.js
江城开朗的豌豆16 分钟前
Vue权限控制小妙招:动态渲染列表的优雅实现
前端·javascript·vue.js
@菜菜_达1 小时前
CSS a标签内文本折行展示
前端·css
霸王蟹1 小时前
带你手写React中的useReducer函数。(底层实现)
前端·javascript·笔记·学习·react.js·typescript·前端框架
托尼沙滩裤1 小时前
【Vue3】实现屏幕共享惊艳亮相
前端·javascript·vue.js
啃火龙果的兔子1 小时前
前端八股文-vue篇
前端·javascript·vue.js
孜然卷k1 小时前
前端处理后端对象类型时间格式通用方法封装,前端JS处理JSON 序列化后的格式 java.time 包中的日期时间类
前端·json