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.
咨询问题
- 这些水化错误的原因是什么?
- 如何确保服务端与客户端渲染一致的HTML来解决问题?
- 使用react-router-dom结合Vite实现SSR有哪些注意事项?
解答
一、水化错误的核心原因
- 路由上下文不匹配:服务端用
StaticRouter传入当前请求路径,渲染对应组件;但客户端BrowserRouter初始化时未指定初始路径,默认使用/,导致客户端水化时渲染的组件和服务端不一致(比如服务端渲染Card,客户端却渲染Home),DOM结构完全不匹配。 - StrictMode执行逻辑差异:服务端
StrictMode仅执行一次组件渲染,客户端水化时StrictMode会重复初始化组件,放大了路由不匹配导致的DOM差异。 - 模板注入位置问题:若
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
相关产品推荐
相关产品推荐

