返回首页

说说 React 服务端渲染怎么做?原理是什么?

问题解析(面试官考察点)

面试官通过此问题主要考察:

  • 对服务端渲染(SSR)概念和优势的理解
  • 对 React SSR 实现方式的掌握
  • 对同构应用原理的了解
  • 对 Next.js 等 SSR 框架的熟悉程度
  • 对 hydration(注水)过程的理解

核心概念(基础知识点)

什么是服务端渲染(SSR)

服务端渲染(Server-Side Rendering)指由服务端完成页面的 HTML 结构拼接,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。

SSR 解决的问题

  1. SEO 优化: 搜索引擎爬虫可以直接查看完全渲染的页面
  2. 首屏加载: 解决首屏白屏问题,提升用户体验
  3. 社交分享: 社交媒体可以正确抓取页面内容

核心概念

  • 同构(Isomorphic/Universal): 同一套代码在服务端和客户端都能运行
  • Hydration(注水): 将服务端渲染的静态 HTML 转换为可交互的 React 应用
  • 脱水(Dehydration): 将客户端状态序列化传递给服务端

详细解答(代码示例)

手动搭建 SSR 框架

1. 基础服务端代码

// server.js
const express = require("express");
const app = express();

// 基础路由
app.get("/", (req, res) => {
  res.send(`
    <html>
      <head>
        <title>SSR Demo</title>
      </head>
      <body>
        <div id="root">Hello SSR</div>
      </body>
    </html>
  `);
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

2. 服务端渲染 React 组件

// src/components/Home.js
import React from "react";

const Home = () => {
  return (
    <div>
      <h1>首页</h1>
      <p>这是一个服务端渲染的页面</p>
    </div>
  );
};

export default Home;

// server.js
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import Home from "./src/components/Home";

const app = express();

app.get("/", (req, res) => {
  // 将 React 组件渲染为 HTML 字符串
  const content = renderToString(<Home />);

  res.send(`
    <html>
      <head>
        <title>SSR Demo</title>
      </head>
      <body>
        <div id="root">${content}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

3. 客户端 Hydration

// src/client.js
import React from "react";
import ReactDOM from "react-dom";
import Home from "./components/Home";

// 使用 hydrate 而不是 render
// hydrate 会复用服务端渲染的 HTML,只绑定事件
ReactDOM.hydrate(<Home />, document.getElementById("root"));

4. 同构数据获取

// src/components/UserList.js
import React, { useEffect, useState } from "react";

// 组件定义
const UserList = ({ users: initialUsers }) => {
  const [users, setUsers] = useState(initialUsers || []);

  // 客户端数据获取(仅在浏览器执行)
  useEffect(() => {
    if (!initialUsers) {
      fetchUsers().then((data) => setUsers(data));
    }
  }, [initialUsers]);

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

// 数据获取方法(服务端和客户端都能调用)
export async function fetchUsers() {
  const response = await fetch("https://api.example.com/users");
  return response.json();
}

// 服务端数据获取方法
UserList.getInitialProps = async () => {
  const users = await fetchUsers();
  return { users };
};

export default UserList;

// server.js
app.get("/users", async (req, res) => {
  const props = await UserList.getInitialProps();
  const content = renderToString(<UserList {...props} />);

  // 将数据注入页面,客户端可以获取
  res.send(`
    <html>
      <head><title>用户列表</title></head>
      <body>
        <div id="root">${content}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify(props)};
        </script>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

// client.js
const initialData = window.__INITIAL_DATA__;
ReactDOM.hydrate(
  <UserList {...initialData} />,
  document.getElementById("root")
);

5. 路由同构

// src/routes.js
import React from "react";
import { Route } from "react-router-dom";
import Home from "./components/Home";
import About from "./components/About";
import UserList from "./components/UserList";

export default (
  <div>
    <Route exact path="/" component={Home} />
    <Route path="/about" component={About} />
    <Route path="/users" component={UserList} />
  </div>
);

// server.js - 服务端路由
import { StaticRouter } from "react-router-dom";
import Routes from "./src/routes";

app.get("*", (req, res) => {
  const context = {};

  const content = renderToString(
    <StaticRouter location={req.url} context={context}>
      {Routes}
    </StaticRouter>
  );

  // 处理重定向
  if (context.url) {
    return res.redirect(301, context.url);
  }

  // 处理 404
  const status = context.status === 404 ? 404 : 200;

  res.status(status).send(`
    <html>
      <head><title>SSR</title></head>
      <body>
        <div id="root">${content}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

// client.js - 客户端路由
import { BrowserRouter } from "react-router-dom";
import Routes from "./routes";

ReactDOM.hydrate(
  <BrowserRouter>{Routes}</BrowserRouter>,
  document.getElementById("root")
);

使用 Next.js 实现 SSR

// pages/index.js - 页面组件
import Head from "next/head";

// 服务端数据获取
export async function getServerSideProps() {
  const res = await fetch("https://api.example.com/posts");
  const posts = await res.json();

  return {
    props: {
      posts
    }
  };
}

// 或静态生成
export async function getStaticProps() {
  const res = await fetch("https://api.example.com/posts");
  const posts = await res.json();

  return {
    props: {
      posts
    },
    revalidate: 60 // ISR:每60秒重新生成
  };
}

export default function Home({ posts }) {
  return (
    <div>
      <Head>
        <title>首页</title>
        <meta name="description" content="SSR 首页" />
      </Head>

      <h1>文章列表</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

// pages/posts/[id].js - 动态路由
export async function getStaticPaths() {
  // 预生成所有可能的路径
  const posts = await fetchPosts();
  const paths = posts.map((post) => ({
    params: { id: post.id.toString() }
  }));

  return {
    paths,
    fallback: true // 允许增量静态生成
  };
}

export async function getStaticProps({ params }) {
  const post = await fetchPost(params.id);

  return {
    props: { post },
    notFound: !post // 404 处理
  };
}

export default function Post({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

深入理解(原理剖析)

SSR 执行流程

┌─────────────────────────────────────────────────────────────┐
│                    SSR 执行流程                              │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  服务端:                                                      │
│  1. 接收请求                                                  │
│     │                                                        │
│  2. 获取数据(getServerSideProps / getInitialProps)        │
│     │                                                        │
│  3. 渲染 React 组件(renderToString)                        │
│     │                                                        │
│  4. 生成完整 HTML(包含数据脱水)                            │
│     │                                                        │
│  5. 发送给客户端                                              │
│                                                              │
│  客户端:                                                      │
│  1. 接收 HTML 并显示(首屏可见)                             │
│     │                                                        │
│  2. 加载 JavaScript                                          │
│     │                                                        │
│  3. 执行 ReactDOM.hydrate                                    │
│     │                                                        │
│  4. 复用服务端渲染的 DOM,绑定事件                           │
│     │                                                        │
│  5. 应用可交互                                                │
│                                                              │
└─────────────────────────────────────────────────────────────┘

renderToString vs renderToStaticMarkup

import { renderToString, renderToStaticMarkup } from "react-dom/server";

// renderToString: 包含 React 内部属性,用于 hydration
const htmlWithReactAttrs = renderToString(<Component />);
// 输出: <div data-reactroot="" data-reactid="1">...</div>

// renderToStaticMarkup: 纯净的 HTML,无 React 属性
const pureHtml = renderToStaticMarkup(<Component />);
// 输出: <div>...</div>

// 使用场景
// renderToString: 需要 hydration 的 SSR 页面
// renderToStaticMarkup: 邮件模板、静态页面生成

Hydration 过程

// Hydration 简化原理

function hydrate(element, container) {
  // 1. 检查服务端渲染的 DOM 结构
  const serverHTML = container.innerHTML;

  // 2. 创建 Virtual DOM
  const vdom = createVirtualDOM(element);

  // 3. 对比服务端 DOM 和 Virtual DOM
  // 如果结构一致,复用 DOM 节点
  // 如果结构不一致,打印警告并重新渲染

  // 4. 绑定事件监听器
  attachEventListeners(container, vdom);

  // 5. 执行 useEffect 等副作用
  runEffects(vdom);
}

// Hydration 警告的常见原因
// 1. 服务端和客户端渲染的内容不一致
// 2. 使用了浏览器特有的 API(window, document)
// 3. 时区、语言等环境差异
// 4. 随机数、时间戳等动态值

数据脱水和注水

// 脱水(Dehydration)- 服务端
function renderWithData(Component, data) {
  const html = renderToString(<Component {...data} />);

  return `
    <div id="root">${html}</div>
    <script>
      // 将数据注入全局变量
      window.__INITIAL_STATE__ = ${JSON.stringify(data).replace(
        /</g,
        "\\u003c"
      )};
    </script>
  `;
}

// 注水(Hydration)- 客户端
function hydrateWithData(Component) {
  const data = window.__INITIAL_STATE__;

  // 删除全局变量,避免污染
  delete window.__INITIAL_STATE__;

  ReactDOM.hydrate(
    <Component {...data} />,
    document.getElementById("root")
  );
}

最佳实践

1. 处理客户端/服务端差异

// 使用 dynamic import 禁用 SSR
import dynamic from "next/dynamic";

const Chart = dynamic(() => import("../components/Chart"), {
  ssr: false // 只在客户端渲染
});

// 或使用 useEffect 检测环境
function Component() {
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    setIsClient(true);
  }, []);

  if (!isClient) {
    return <div>Loading...</div>;
  }

  return <div>{window.innerWidth}</div>;
}

2. 避免 Hydration 不匹配

// 不好的做法:服务端和客户端渲染不同内容
function BadComponent() {
  const [count, setCount] = useState(0);

  // 错误:服务端和客户端的随机数不同
  const randomId = Math.random();

  return <div id={randomId}>{count}</div>;
}

// 好的做法:确保服务端和客户端一致
function GoodComponent() {
  const [count, setCount] = useState(0);
  const [randomId, setRandomId] = useState("");

  useEffect(() => {
    // 只在客户端生成随机数
    setRandomId(Math.random().toString());
  }, []);

  return <div id={randomId}>{count}</div>;
}

3. 性能优化

// 1. 使用 React.memo 避免重复渲染
const MemoizedComponent = React.memo(({ data }) => {
  return <div>{data}</div>;
});

// 2. 使用流式渲染
import { renderToPipeableStream } from "react-dom/server";

app.get("/", (req, res) => {
  const { pipe } = renderToPipeableStream(<App />,
    {
      onShellReady() {
        res.statusCode = 200;
        res.setHeader("Content-type", "text/html");
        pipe(res);
      }
    }
  );
});

// 3. 使用 SSG/ISR 减少服务端压力
// Next.js 的 getStaticProps + revalidate

4. 错误处理

// 错误边界
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 记录错误日志
    console.error("SSR Error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>出错了,请刷新页面重试</h1>;
    }

    return this.props.children;
  }
}

// 服务端错误处理
app.get("*", async (req, res) => {
  try {
    const html = await renderApp(req);
    res.send(html);
  } catch (error) {
    console.error("Render error:", error);
    // 降级到客户端渲染
    res.send(`
      <html>
        <body>
          <div id="root"></div>
          <script src="/client.js"></script>
        </body>
      </html>
    `);
  }
});

面试要点

  1. SSR 是什么: 服务端渲染 HTML,客户端 hydration 使页面可交互

  2. SSR 的优势:

    • SEO 友好
    • 首屏加载快
    • 社交分享友好
  3. 核心 API:

    • renderToString: 渲染为 HTML 字符串
    • ReactDOM.hydrate: 客户端注水
    • StaticRouter / BrowserRouter: 同构路由
  4. 注意事项:

    • 避免 hydration 不匹配
    • 处理客户端/服务端环境差异
    • 数据脱水和注水
  5. Next.js:

    • getServerSideProps: 服务端数据获取
    • getStaticProps: 静态生成
    • getStaticPaths: 动态路由静态生成
    • ISR: 增量静态再生
  6. hydration 原理: 复用服务端渲染的 DOM,绑定事件,执行副作用