You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Vite+React-Router-Dom搭建React SSR时的水化错误排查

React + Vite SSR 水化不匹配错误排查与解决

问题背景

使用Vite为React应用搭建SSR,搭配react-router-dom实现路由。服务端与客户端单独渲染均正常,但客户端水化服务端生成的HTML时,出现多处水化错误。

项目结构

Router.tsx

import { Routes, Route } from 'react-router-dom';
import Card from './Card';
import Home from './Home';

export const Router = () => {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/card" element={<Card />} />
    </Routes>
  );
};

Home.tsx

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

export default Home;

Card.tsx

import { useState } from 'react';

function Card() {
  const [count, setCount] = useState(0);

  return (
    <div className="card">
      <button onClick={() => setCount(count + 1)}>
        count is {count}
      </button>
      <p>
        Edit <code>src/App.tsx</code> and save to test HMR
      </p>
    </div>
  );
}

export default Card;

entry-client.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';

import './index.css';
import { Router } from './Router';

ReactDOM.hydrateRoot(
  document.getElementById('root') as HTMLElement,
  <React.StrictMode>
    <BrowserRouter>
      <Router />
    </BrowserRouter>
  </React.StrictMode>
);

entry-server.tsx

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { Router } from './Router';

interface IRenderProps {
  path: string;
}

export function render({ path }: IRenderProps) {
  const html = ReactDOMServer.renderToString(
    <React.StrictMode>
      <StaticRouter location={path}>
        <Router />
      </StaticRouter>
    </React.StrictMode>
  );
  return { html };
}

server.js

import fs from 'node:fs/promises';
import express from 'express';

const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 5173;
const base = process.env.BASE || '/';

const templateHtml = isProduction
  ? await fs.readFile('./dist/client/index.html', 'utf-8')
  : '';
const ssrManifest = isProduction
  ? await fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8')
  : undefined;

const app = express();

let vite;
if (!isProduction) {
  const { createServer } = await import('vite');
  vite = await createServer({
    server: { middlewareMode: 'ssr' },
    appType: 'custom',
    base,
  });
  app.use(vite.middlewares);
} else {
  const compression = (await import('compression')).default;
  const sirv = (await import('sirv')).default;
  app.use(compression());
  app.use(base, sirv('./dist/client', { extensions: [] }));
}

app.use('*', async (req, res) => {
  try {
    const url = req.originalUrl.replace(base, '');

    let template;
    let render;
    if (!isProduction) {
      template = await fs.readFile('./index.html', 'utf-8');
      template = await vite.transformIndexHtml(url, template);
      render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render;
    } else {
      template = templateHtml;
      render = (await import('./dist/server/entry-server.js')).render;
    }

    const rendered = await render({ path: url });

    const html = template
      .replace(`<!--app-head-->`, '')
      .replace(`<!--app-html-->`, rendered.html);

    res.status(200).set({ 'Content-Type': 'text/html' }).send(html);
  } catch (e) {
    vite?.ssrFixStacktrace(e);
    console.log(e.stack);
    res.status(500).end(e.stack);
  }
});

app.listen(port, () => {
  console.log(`Server started at http://localhost:${port}`);
});

vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin/react';

export default defineConfig({
  plugins: [react()],
});

错误信息

chunk-M324AGAM.js?v=5551cca4:519 Warning: Expected server HTML to contain a matching <button> in <div>.
chunk-M324AGAM.js?v=5551cca4:9471 Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
chunk-M324AGAM.js?v=5551cca4:519 Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.
chunk-M324AGAM.js?v=5551cca4:9471 Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
chunk-M324AGAM.js?v=5551cca4:14758 Uncaught Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.

咨询问题

  1. 这些水化错误的原因是什么?
  2. 如何确保服务端与客户端渲染一致的HTML来解决问题?
  3. 使用react-router-dom结合Vite实现SSR有哪些注意事项?

解答

一、水化错误的核心原因

  1. 路由上下文不匹配:服务端用StaticRouter传入当前请求路径,渲染对应组件;但客户端BrowserRouter初始化时未指定初始路径,默认使用/,导致客户端水化时渲染的组件和服务端不一致(比如服务端渲染Card,客户端却渲染Home),DOM结构完全不匹配。
  2. StrictMode执行逻辑差异:服务端StrictMode仅执行一次组件渲染,客户端水化时StrictMode会重复初始化组件,放大了路由不匹配导致的DOM差异。
  3. 模板注入位置问题:若index.html中<!--app-html-->的位置不在根容器内,可能导致服务端HTML插入后DOM结构嵌套错误。

二、解决步骤

1. 对齐客户端路由初始路径

修改entry-client.tsx,从服务端传递的全局变量中获取初始路径,让BrowserRouter用该路径初始化:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';

import './index.css';
import { Router } from './Router';

// 读取服务端注入的初始路径
const initialPath = (window as any).__INITIAL_PATH__;

ReactDOM.hydrateRoot(
  document.getElementById('root') as HTMLElement,
  <React.StrictMode>
    <BrowserRouter initialEntries={[initialPath]}>
      <Router />
    </BrowserRouter>
  </React.StrictMode>
);

2. 在服务端注入初始路径

修改server.js,生成HTML时在<!--app-head-->位置注入全局变量:

const html = template
  .replace(`<!--app-head-->`, `<script>window.__INITIAL_PATH__ = "${url}";</script>`)
  .replace(`<!--app-html-->`, rendered.html);

3. 验证根容器匹配

确保index.html中服务端HTML插入到正确的根容器内:

<body>
  <div id="root"><!--app-html--></div>
  <script type="module" src="/src/entry-client.tsx"></script>
</body>

4. 保持StrictMode一致性

服务端和客户端都保留React.StrictMode,避免模式差异导致的细微渲染不一致。

三、react-router-dom + Vite SSR 注意事项

  • 路由上下文必须严格对齐:服务端StaticRouter的location参数必须和客户端BrowserRouter的initialEntries一致,否则必然出现组件不匹配。
  • 隔离浏览器环境代码:所有依赖window、document等浏览器API的代码,必须放在useEffect/useLayoutEffect中,或者用typeof window !== 'undefined'做环境判断,避免服务端渲染时执行报错。
  • 使用Suspense处理异步内容:若组件包含异步数据请求,必须用Suspense包裹,否则水化时会因数据未就绪导致DOM结构不匹配。
  • Vite SSR配置要点:
    • 保持appType: 'custom'配置,避免Vite默认SPA逻辑干扰SSR。
    • 开发模式用vite.ssrLoadModule加载服务端入口,生产模式直接导入编译后的文件。
    • 正确配置base路径,避免路由跳转或资源加载时的路径错误。
  • 状态一致性:服务端渲染时的组件初始状态(如useState的初始值)必须和客户端一致,禁止在服务端渲染时生成客户端无法复现的状态。

内容的提问来源于stack exchange,提问作者TedRed

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 14:24:55