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的工作原理。

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

相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼11 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax