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

React SSR问题排查:Express环境下HTML标签匹配警告

React SSR 标签不匹配问题排查(自定义Express+EJS+Node.js环境)

问题描述

使用Express、EJS、Node.js和React搭建自定义SSR(未使用Next.js),控制台抛出「期望服务端HTML包含

内匹配的
」警告,随后自动回退到客户端渲染(CSR)。已验证服务端生成的HTML结构无语法错误,删除部分组件(包括初始加载无数据变更的搜索栏)后仍出现类似标签匹配错误。

相关代码

server.jsx

app.get("/campus/:id/locations", async (req, res) => {
  const reactComponent = renderToString(<SchoolPage />);
  const filePath = path.join(__dirname, "dist", "school-page.ejs");
  ejs.renderFile(filePath, { reactComponent }, (err, html) => {
    if (err) {
      console.error("Error rendering template:", err);
      return res.status(500).end();
    }
    res.send(html);
  });
});

SchoolPage组件

export default function SchoolPage() {
  return (
    <>
      <header>
        <picture title="Campus Eats">
          <source
            media="(min-width: 400px)"
            srcSet="/images/campus-eats-logo-black.svg"
          />
          <img src="/images/campus-eats-logo-mini.svg" alt="campus-eats-logo" />
        </picture>
        <nav className="places-at">
          <h2>Places</h2>
          <h2>at</h2>
          <div className="search-container">
            <MiniSearchBar></MiniSearchBar>
          </div>
        </nav>
        <nav className="login-signup">
          <button className="login">Log in</button>
          <button className="signup">Sign up</button>
        </nav>
      </header>
      <section>
        <ContentContainer></ContentContainer>
      </section>
    </>
  );
}

EJS模板

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link
      href="https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="/styles/reset.css" />
    <link rel="stylesheet" href="/styles/school-page.css" />
    <script src="/school-page.js" defer></script>
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
    <link rel="manifest" href="/site.webmanifest" />
    <meta name="msapplication-TileColor" content="#da532c" />
    <meta name="theme-color" content="#ffffff" />
    <title>Document</title>
  </head>
  <body>
    <div class="root">
      <!-- Rendered React component will be injected here -->
      <%- reactComponent %>
    </div>
  </body>
</html>

服务端返回的HTML

<!DOCTYPE html>
<html lang="en">
   <head>
      <meta charset="UTF-8" />
      <meta http-equiv="X-UA-Compatible" content="IE=edge" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <link
         href="https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
         rel="stylesheet"
         />
      <link rel="stylesheet" href="/styles/reset.css" />
      <link rel="stylesheet" href="/styles/school-page.css" />
      <script src="/school-page.js" defer></script>
      <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
      <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
      <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
      <link rel="manifest" href="/site.webmanifest" />
      <meta name="msapplication-TileColor" content="#da532c" />
      <meta name="theme-color" content="#ffffff" />
      <title>Document</title>
   </head>
   <body>
      <div class="root">
         <!-- Rendered React component will be injected here -->
         <header>
            <picture title="Campus Eats">
               <source media="(min-width: 400px)" srcSet="/images/campus-eats-logo-black.svg"/>
               <img src="/images/campus-eats-logo-mini.svg" alt="campus-eats-logo"/>
            </picture>
            <nav class="places-at">
               <h2>Places</h2>
               <h2>at</h2>
               <div class="search-container">
                  <input type="search" placeholder="Find my school!" value=""/>
                  <div class="suggestions">
                     <ul></ul>
                  </div>
               </div>
            </nav>
            <nav class="login-signup"><button class="login">Log in</button><button class="signup">Sign up</button></nav>
         </header>
         <section>
            <div class="locations"></div>
         </section>
      </div>
   </body>
</html>

原因分析

  1. 额外DOM节点导致结构不匹配:EJS模板中.root容器内的注释会被保留在服务端生成的HTML中,但客户端渲染的虚拟DOM里没有该注释节点,React在hydrate时检测到结构差异,触发警告并回退到CSR。
  2. 客户端挂载方法错误:若客户端使用ReactDOM.render而非hydrateRoot(React 18+)或hydrate(旧版本),会直接重新渲染整个组件树,而非对比服务端已生成的DOM,导致结构不匹配。
  3. 组件渲染逻辑不一致:服务端与客户端的组件可能因环境差异(如服务端无浏览器API)输出不同结构,引发hydrate时的匹配错误。

解决方法

1. 移除EJS模板中的多余注释

修改EJS模板,删除.root容器内的注释,确保服务端生成的HTML结构与客户端虚拟DOM完全一致:

<div class="root">
  <%- reactComponent %>
</div>

2. 使用正确的客户端hydrate方法

根据React版本选择对应的挂载方式:

  • React 18+:使用hydrateRoot
import { hydrateRoot } from 'react-dom/client';
import SchoolPage from './SchoolPage';

const rootElement = document.querySelector('.root');
hydrateRoot(rootElement, <SchoolPage />);
  • React 17及以下:使用hydrate
import ReactDOM from 'react-dom';
import SchoolPage from './SchoolPage';

const rootElement = document.querySelector('.root');
ReactDOM.hydrate(<SchoolPage />, rootElement);

3. 统一服务端与客户端组件渲染逻辑

  • 确保所有组件在服务端和客户端的初始渲染结构完全一致,避免在组件初始渲染时使用仅浏览器环境的API(如window、document),可将此类逻辑移至useEffect或componentDidMount生命周期钩子中。
  • 验证服务端渲染时传入组件的props与客户端完全相同,比如SchoolPage在服务端无props渲染,客户端也需保证不传入额外props。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.20 00:07:35