React 服务端渲染(SSR)详解

发布于:2025-07-31 ⋅ 阅读:(17) ⋅ 点赞:(0)

在讨论如何从零到一实现一个 React 服务端渲染(SSR)之前,我们需要明确 SSR 的核心流程。SSR 的主要目标是优化首屏渲染体验,将页面内容在服务器端生成 HTML 后返回给浏览器。我们将通过 Vite 和 Zustand 这两项技术来讲解其具体的实现方式。

1. 核心流程

1. 创建 React 实例服务器接收到请求后,通过 React 组件生成对应的 React 实例。

2. 渲染为 HTML 服务器端使用 ReactDOMServer.renderToString 或者 renderToPipeableStream 等方法,将 React 组件渲染为 HTML 字符串。

3. 注入状态在 SSR 过程中,可能需要将应用的初始状态(通过 Zustand 管理的状态)注入到页面中,以便客户端渲染时可以获得相同的初始数据。

4. 客户端激活客户端加载完 JS 代码后,通过 React 将页面激活,即绑定事件、状态等,从而形成完整的交互式应用。

2. 实现过程

以下以一个基本的Demo来说明服务端渲染的实现,下图是项目的基本结构:

2.1. 安装项目依赖

在package.json中添加如下依赖并完成安装。

{
    "name": "react-ssr",
    "version": "1.0.0",
    "main": "index.js",
    "type": "module",
    "scripts": {
        "dev": "node server/index.js"
    },
    "keywords": [],
    "author": "Heyi",
    "license": "ISC",
    "description": "",
    "dependencies": {
        "react": "18.3.1",
        "react-dom": "18.3.1",
        "react-router-dom": "6.30.0",
        "styled-components": "6.1.13",
        "zustand": "4.5.5"
    },
    "devDependencies": {
        "@vitejs/plugin-react": "^4.7.0",
        "express": "4.21.0",
        "vite": "5.4.7"
    }
}

2.2. 配置脚手架

在 vite.config.js 文件中,我们需要配置 Vite 支持 SSR,并且将生成的 HTML 与服务端渲染的内容结合。

// vite.config.js

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
    // 插件配置
    plugins: [react()], // 启用React热更新和JSX支持
    
    // 模块解析配置
    resolve: {
        alias: {
            "@": path.resolve(__dirname, "./src"), // 路径别名,@指向src目录
        },
    },

    // CSS相关配置
    css: {
        modules: {
            localsConvention: "camelCaseOnly", // 只使用驼峰式命名
            generateScopedName: "[name]__[local]___[hash:base64:5]", // CSS模块类名生成规则
        },
    },
    
    // 构建配置
    build: {
        ssr: true,    // 启用服务端渲染构建
        minify: false, // 禁用代码压缩(开发阶段保持可读性)
    },
    
    // SSR专属配置
    ssr: {
        format: "esm", // 输出ES模块格式
        // 强制打包这些依赖(默认情况下SSR会排除node_modules)
        noExternal: [
            "react-router-dom",   // 需要同构的路由库
            "styled-components"  // 需要同构的样式库
        ],
    },
});

Vite 的 SSR 流程相对简单,主要是在生产环境下,输出构建结果时需要额外的配置来区分客户端和服务端的构建。

2.3. 服务端渲染入口

// /server/index.js

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
import { createServer as createViteServer } from "vite";

// 获取当前文件目录路径(ESM模块规范)
const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function createServer() {
    const app = express();

    // 创建Vite服务实例(中间件模式)
    const vite = await createViteServer({
        server: { middlewareMode: true }, // 启用中间件模式
        appType: "custom",                // 使用自定义SSR处理
    });

    // 使用Vite的中间件处理资源请求
    app.use(vite.middlewares);

    // 处理所有路由的SSR请求
    app.use("*", async (req, res, next) => {
        const url = req.originalUrl;

        try {
            // 1. 读取HTML模板文件
            let template = fs.readFileSync(
                path.resolve(__dirname, "../", "index.html"), // 项目根目录的index.html
                "utf-8"
            );

            // 2. 应用Vite的HTML转换(客户端资源注入/HMR)
            template = await vite.transformIndexHtml(url, template);

            // 3. 加载服务端入口模块
            const { render } = await vite.ssrLoadModule(
                path.resolve(__dirname, "../src/entry-server.jsx") // SSR入口文件
            );
            
            // 4. 执行服务端渲染逻辑
            const { appHtml, styleTags } = await render(url);

            // 5. 替换HTML模板中的占位内容
            const html = template
                .replace(`<!--app-css-->`, styleTags)   // 插入样式标签
                .replace(`<!--app-html-->`, appHtml);    // 插入应用HTML

            // 返回最终HTML
            res.status(200).set({ "Content-Type": "text/html" }).end(html);

        } catch (e) {
            // 修正错误堆栈信息(适配Vite的SSR环境)
            vite.ssrFixStackTrace(e);
            next(e);
        }
    });

    // 启动服务器监听3000端口
    app.listen(3000, () => {
        console.log("Server is running on http://localhost:3000");
    });
}

createServer();

首页加载模板文件:

<!--index.html-->

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React服务端渲染</title>
    <!--app-css-->
</head>

<body>
    <!-- 指定 React 应用的挂载点 -->
    <div id="root"><!--app-html--></div>
    <!-- 引入完整前端入口文件 -->
    <script type="module" src="/src/entry-client.jsx"></script>
</body>

</html>

服务端入口模块文件:

// /src/entry-server.jsx

// 负责渲染工作
import React from "react";
import { renderToString } from "react-dom/server";

import { ServerStyleSheet } from "styled-components";
import { StaticRouter } from "react-router-dom/server";

import App from "./App.jsx";

// 渲染函数
export function render(url) {
    const sheet = new ServerStyleSheet();
    // 渲染组件
    const appHtml = renderToString(
        sheet.collectStyles(
            <StaticRouter location={url}>
                <App />
            </StaticRouter>
        )
    );
    // 提取样式
    const styleTags = sheet.getStyleTags();
    return {
        appHtml,
        styleTags,
    };
}

2.4. 客户端激活

// /src/entry-client.jsx

// 客户端渲染,React 入口的写法
// import { createRoot } from 'react-dom/client'
// import './index.css'
// import App from './App.tsx'

// createRoot(document.getElementById('root')!).render(
//     <App />
// )


// 服务端渲染,React 入口的写法
import React from "react";
import { hydrateRoot } from "react-dom/client";
// 使用路由,浏览器历史记录栈路由
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";


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

引入的App组件代码:

// /src/App.jsx

import React from "react";
import Header from "./components/Header";
import { Routes, Route } from "react-router-dom";

const Home = () => {
    return (
        <div>
            <h1>Home</h1>
        </div>
    );
};

const About = () => {
    return (
        <div>
            <h1>About</h1>
        </div>
    );
};

function App() {
    return (
        <>
            <Header />
            <Routes>
                <Route path="/" element={<Home />} />
                <Route path="/about" element={<About />} />
            </Routes>
        </>
    );
}

export default App;

2.5. 组件及状态管理

示例中用到的组件:

// /src/components/Header.jsx

import React, { useEffect, useState } from "react";
import { useCountStore } from "../stores/count";
import { styled } from "styled-components";
import { Link } from "react-router-dom";

const PageHeader = styled.div`
    width: 100%;
    height: 100px;
    background-color: #000;
    color: #fff;
`;
const PageHeaderButton = styled.button`
    background-color: #4caf50;
    border: none;
    color: white;
    padding: 15px 32px;
    border-radius: 8px;
`;

export default function Header() {
    const [num, setNum] = useState(0);
    const count = useCountStore((state) => state.count);
    const increment = useCountStore((state) => state.increment);
    
    const handleClick = () => {
        console.log("click");
        increment();
    };

    useEffect(() => {
        console.log("Header useEffect");
    });

    return (
        <div>
            <PageHeader>
                num---{num}
                <button onClick={()=>setNum(num + 1)}>+1</button>
                <br/>
                count---{count}
                <PageHeaderButton onClick={handleClick}>+1</PageHeaderButton>
                <Link to="/">Home</Link>
                <Link to="/about">About</Link>
            </PageHeader>
        </div>
    );
}

上面这个组件中的状态管理:

// /src/stores/count.js
import { create } from "zustand";

export const useCountStore = create((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
}));

3. 效果预览

观察控制台返回的结果,可以清楚的看到文件不再只是一个空壳文件,而是带有样式的页面,在浏览器上点击按钮数字均有变化,说明事件和状态已经被客户端激活了。


网站公告

今日签到

点亮在社区的每一天
去签到