当前位置: 首页 > news >正文

React SSR 水合问题

原文链接https://www.castamerego.com/blog/Hydration

前端真是噩梦啊

新做的首页展示博客和项目的组件,悄悄的在 F12 中报错,没有影响到使用,也就没发现,但一看到就受不了了

研究发现是 SSR 水合问题(Server Side Rendering Hydration Problem),遂写一篇记录一下历程

SSR 水合问题(Server Side Rendering Hydration Problem)是指在服务端渲染(SSR)应用中,服务端生成的 HTML 与客户端 JavaScript 接管后的状态不一致,导致 React 需要重新渲染整个组件树的问题

至于为什么叫水合问题,请往下看

缘起

起因是按开了 F12 突然看到了一大堆报错,但之前使用时却没有任何感觉

error

然后研究过程中爆出了几个关键字,遂觉得有必要写一篇文章记录一下

分析

首先去 React 查了一下这个报错 #418

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:

  • A server/client branch if (typeof window !== 'undefined').
  • Variable input such as Date.now() or Math.random() which changes each time it's called.
  • Date formatting in a user's locale which doesn't match the server.
  • External changing data without sending a snapshot of it along with the HTML.
  • Invalid HTML tag nesting.

It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.

博客里逛了一圈发现只有主页会报这个错,那就好办了,这个页面就四个组件,二分法找问题

src/pages/index.tsx

<div className="flex flex-col mt-10 gap-4"><HomepageHeader /><SiteNavigation /><Articles /><ProjectShowcase />
</div>

结果发现下面两个组件都有问题

首先来说,这个问题就是 SSR 水合问题,即服务端渲染后,客户端发现 HTML 和服务端渲染的 HTML 不一致,导致 React 重新渲染了整个页面。虽然说用起来没什么感觉(也是为什么我一直没发现),但还是想解决一下

Docusaurus 官方提供了 Browser Only 组件的解决方案,但使用过后,发现和我的问题不太一样

接下来先讲一下什么是 SSR 水合问题,再讲具体 bug 和解决方法

SSR 水合问题

SSR

先说什么是 SSR。在笔者之前写的 React 系列文章中介绍过,点击这里可以跳转查看,这里也会简单介绍一下

SSR (Server Side Rendering) 即 服务端渲染。是指在服务器端将动态页面内容和数据渲染成完整的 HTML 页面,然后将整个页面发送给客户端。客户端收到的是已经包含完整内容的 HTML 页面

SSR 优点有很多,这里重点讲一下 SSR 的缺点:

  • 无法监听浏览器事件
  • 无法访问浏览器 API
  • 无法维护状态(Maintain state)
  • 无法使用 effects

水合

水合,即 Hydration,叫这个名字主要是类比化学中的水合。在化学中,水合是指干燥的物质与水分子结合,形成含水化合物的过程。比如:

  • 无水硫酸铜(白色) + 水 → 五水硫酸铜(蓝色)

SSR 水合的过程如下:

  • "干燥"状态:服务端渲染的静态 HTML(无交互能力)
  • "水合"过程:客户端 JavaScript 为这些静态 DOM 节点添加事件监听器、状态管理等
  • "活跃"状态:完全交互的 React 应用

服务端渲染可以直接渲染好整个页面,但有些组件是要在客户端渲染出交互能力的,比如按钮点击事件、输入框输入事件等

用人话就是: 水合就是动态的组件(水),插入到静态的 HTML 中(干燥的物质),使其变成可以交互的组件(活跃状态)

水合问题

水合问题,就是服务端渲染出的 HTML 和客户端渲染出的 HTML 不一致,导致 React 重新渲染了整个页面

常见的原因,React 在上面那个报错都给我们列出来了

  • 服务器/客户端分支 if (typeof window !== ‘undefined’)
  • 变量输入,如 Date.now() 或 Math.random(),每次调用时都会改变
  • 用户本地化设置中的日期格式与服务器不匹配
  • 外部数据发生变化但未随 HTML 一起发送快照
  • HTML 标签嵌套无效

很幸运的,笔者一个页面中的两个组件,就各犯了一条... (天降 blog 话题)

下面就是两个组件的具体问题和解决方法了

ProjectShowcase 组件

先处理 ProjectShowcase 组件

结果最后发现是,在 tsx 组件中静态注入了 <style> 标签导致的

/src/components/Homepage/ProjectShowcase.tsx

return (<section className="mt-4 mb-16">...{/* 注入高级悬浮动效样式 */}<style>{`.project-card {box-shadow: 0 4px 24px 0 #a259e6a0, 0 1.5px 8px 0 #3b82f680;}...`}</style></section>
)

但路上又发现了别的问题: 在首次加载时,项目的展示图片会先 '填满' 整个 div,再突变为正常 Size

首先,初始的 div 长这样,可以在这里找到之前版本的文件

/src/components/Homepage/ProjectShowcase.tsx

<div className="..."><imgsrc={proj.img}alt={proj.title}className="w-full h-full object-contain rounded-lg"/>
</div>

笔者首先了尝试这样的解决方法。即添加一个状态变量,在 img 加载好之前不透明度为 0,加载好后变为 100%,还可以添加一个过渡动画

/src/components/Homepage/ProjectShowcase.tsx

+ const [isImageLoaded, setIsImageLoaded] = useState(false);...<div className=".."><imgsrc={proj.img}alt={proj.title}className={`w-full h-full object-contain rounded-lg 
+               ${isImageLoaded ? 'opacity-100' : 'opacity-0'}`}
+     onLoad={() => setIsImageLoaded(true)}/></div>

感觉没什么问题,但是仅限 dev 版本,build 好之后还是有问题,甚至 build 好之后,图片直接不显示了。F12 发现他的 opacity 还是 0, 而且没有任何报错

分析了好一阵,大致原因如下:

  1. 服务端渲染时 useState(false) 初始化,图片的 opacity 为 0
  2. 客户端水合时,图片可能已经被浏览器缓存,导致 img.complete 为 true
  3. 此时 onLoad 事件不会触发,因为图片已经加载完成
  4. 结果 isImageLoaded 永远是 false,导致图片的 opacity 一直是 0,就隐形了...

和 copilot 研究了半天,最后用了这种方式解决:

给图片添加一个 ref,这样不管是如何渲染,以及不管是否有缓存,都可以确保图片加载完成后正常显示

/src/components/Homepage/ProjectShowcase.tsx

  const [isImageLoaded, setIsImageLoaded] = useState(false);+ useEffect(() => {
+   const img = imgRef.current;
+   if (img) {
+     if (img.complete && img.naturalWidth > 0) {
+       setIsImageLoaded(true);
+     } else {
+       const timer = setTimeout(() => {
+         if (img.complete && img.naturalWidth > 0) {
+           setIsImageLoaded(true);
+         }
+       }, 100);
+       return () => clearTimeout(timer);
+     }
+   }
+ }, [proj.title]);...<div className=".."><img
+     ref={imgRef}src={proj.img}alt={proj.title}className={`w-full h-full object-contain rounded-lg ${isImageLoaded ? 'opacity-100' : 'opacity-0'}`}onLoad={() => setIsImageLoaded(true)}/></div>

然后经过观察又发现了一个小 bug,慢放之后看得出来,在图片从 opacity 0 变为 100% 之后,不透明度又突变了一下,突然变暗了一点

display error

搞了很久才明白原因,虽然一般不太会注意这种地方。我在整个 card 外面是有一个 glass 的效果,但和图片位于了一个图层,只要把 glass 的层级提升到图片上面就好了

/src/components/Homepage/ProjectShowcase.tsx

<div className="... z-0 project-card-glass"></div>
<div className="... z-10 project-card-glass"></div>
{proj.img && (<div className="..."><img.../></div>
)}

前端真是噩梦

Articles 组件

然后是 Articles 组件,神奇的是两个组件报同样的错,却是不同的原因

这个组件主要是用来展示推荐文章的,中间有个逻辑是这样,会在所有打了 recommended 标签的文章中随机抽取 5 篇进行展示

/src/components/Homepage/Articles.tsx

const recommendedArticles = shuffleArray(allRecommendedArticles).slice(0, 5).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

问题就出在这里,服务端渲染时随机抽取了 5 篇文章,客户端水合时又随机抽取了 5 篇文章,结果就不一样了

解决方法也很简单,直接用一个状态变量标记是否是客户端渲染就好了

/src/components/Homepage/Articles.tsx

import { useEffect, useState } from "react";const [isClient, setIsClient] = useState(false);// 只在客户端渲染时才会执行
useEffect(() => {setIsClient(true);
}, []);...const recommendedArticles = isClient? shuffleArray(allRecommendedArticles).slice(0, 5).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()): allRecommendedArticles.slice(0, 5).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

后记

给自己几个提醒吧

首先,没报错不代表没问题,还好这次是没经过多少次 commit 就发现了这个 bug

然后尽量避免在 tsx 组件中直接写 <style> 标签,改成 css 文件或者 css-in-js 的方式

知其所以然确实很有意思

http://www.dtcms.com/a/320437.html

相关文章:

  • Spark在什么情况下CBO才会判断失误,如何避免
  • 零成本建站:将 Windows 电脑变身为个人网站服务器
  • ubuntu alias命令使用详解
  • AI赋能SEO关键词优化策略
  • 润乾报表、帆软报表的开源替代品—JimuReport(积木报表)
  • 从大数据视角理解时序数据库选型:为何选择 Apache IoTDB?
  • 【Mybatis入门】配置Mybatis(IDEA)
  • OpenAI 开源模型 GPT-OSS MCP服务器深度解密:从工具集成到系统提示全自动化,浏览器+Python无缝协同的底层逻辑
  • 服务器快照与备份的本质区别及正确使用指南 (2025)
  • 腾讯iOA:数据安全的港湾
  • apiSQL网关调优:释放单节点的最大潜能
  • 运维系统构建
  • 实现一个进程池(精讲)
  • Java 虚拟机之双亲委派机制
  • 动手学深度学习(pytorch版):第一章节——引言
  • 力扣300:最长递增子序列
  • pytorch入门3:使用pytorch进行多输出手写数据集模型预测
  • 2025 年最佳no-code和open-source AI Agents
  • java - 深拷贝 浅拷贝
  • 对比学习(Contrastive Learning)面试基础
  • Python 深入浅出装饰器
  • 2026计算机毕业设计选题推荐:如何通过项目实用性来选择创新且高通过率的课题
  • Dify-16: 开发环境配置
  • 【MySQL】SQL优化
  • Linux Shell为文件添加BOM并自动转换为unix格式
  • C++之队列浅析
  • 每日算法刷题Day58:8.7:leetcode 单调栈5道题,用时2h
  • 零基础-动手学深度学习-9.3. 深度循环神经网络
  • Langchain入门:对话式RAG
  • Tool Learning的基本概念及应用