Next.js 常见错误 Hydration Failed 该如何解决?
前言
开发 Next.js 项目的时候常会遇到水合错误提示,比如:
具体的错误提示可能不一样,但都会告诉你是水合(Hydration)的时候出现错误。遇到这种错误该怎么解决呢?
此外,这还是一道考察 Next.js 的常见面试题。
通过考察这类常见问题的解决方法,判断面试者是否对日常开发中遇到的问题进行过系统总结和思考。
所以开始前先点赞收藏下这篇文章吧。
错误分析
所谓水合(Hydration),指的是 React 为预渲染的 HTML 添加事件处理程序,将其转为完全可交互的应用程序的过程。
React 提供了 hydrateRoot 客户端 API,通常搭配 react-dom/server
一起使用。先由 react-dom/server
生成 HTML,再调用 hydrateRoot 为生成的 HTML 进行水合,伪代码如下:
# 服务端
import { renderToString } from 'react-dom/server';
const html = renderToString(<App />);
# 客户端
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />);
与我们使用 React 时常用的 createRoot 不同,creatRoot 会重新创建 DOM 节点,而 hydrateRoot 会尽可能复用已有的 DOM 节点。
那具体是怎么进行水合的呢?
这就要说到 React 的渲染原理了。你可以这样简单粗暴的理解:
当调用 hydateRoot 的时候,会传入组件(例子中的 <App />
),React 会据此构建 React 组件树,并按照组件树的顺序遍历真实的 DOM 树,判断 DOM 树和组件树是否对应,如何对应,则跳过创建 DOM 节点的环节,复用当前 DOM 节点,添加事件并进行关联。
所以水合的前提是 DOM 树和组件树渲染一致。
而本篇要讲的水合错误就出现在浏览器首次渲染的时候,DOM 节点和水合时的 React 树不一致导致无法正确进行水合。所以这个错误虽然常会出现在 Next.js 项目中,但它本质是 React 的错误,具体是 React-Dom 报的错。
那你可能就好奇了,如果正确的传入组件代码,怎么会出现不一致的情况呢?有哪些情况会导致不一致?遇到这些问题该如何解决?为了避免此类错误,日常开发 Next.js 项目的时候要注意哪些点呢?
且让我们从常见原因开始说起。
原因 1:HTML 元素错误嵌套
第一种原因是 HTML 元素错误嵌套导致,比如在 <p>
标签中又嵌套了一个 <p>
标签。举个例子:
export default function App() {
return (
<p>
text1
<p>text2</p>
</p>
)
}
这就会导致水合错误:
不过一般 React 还会给出具体的错误提示和导致错误的 HTML 元素位置:
这样我们就可以快速定位出现错误的地方。
除了 <p>
嵌套错误,其他的可能还有:
<p>
嵌套在另一个<p>
元素中<div>
嵌套在<p>
元素中<ul>
或<ol>
嵌套在<p>
元素中- 交互式内容(interactive-content)不能嵌套
所谓交互式内容,指的是专门用于用户交互的内容,其实 HTML 中有很多这样的元素,比如 <a>
、<button>
、<img>
、<audio>
、<video>
、<input>
、<lable>
等等
交互式内容不能嵌套,指的是比如 <a>
不能嵌套在 <a>
标签中,<button>
不能嵌套在 <button>
标签中等等。
原因 2:渲染时使用 typeof window !== 'undefined' 等判断
如果在渲染逻辑中使用了诸如 typeof window !== 'undefined' 这样的判断逻辑可能也会导致水合错误,就比如:
'use client'
export default function App() {
const isClient = typeof window !== 'undefined';
return <h1>{isClient ? 'Client' : 'Server'}</h1>
}
此时会出现错误提示:
这个错误只会出现在客户端组件中,因为 Next.js v14 采用基于 React Server Components 的架构后,只有客户端组件才会在客户端进行水合,服务端组件直接在服务端进行渲染,并不会在客户端进行水合。所以如果你把上面代码中的 use client
指令取消,也不会出现水合错误。
服务端渲染的时候,因为在 Node 环境,isClient 为 false,返回 Server,而在客户端的时候,会渲染成 Client,渲染内容不一致导致出现水合错误。
至于解决的方法,我们会在本篇最后统一进行讲解。
原因 3:渲染时使用客户端 API 如 window、localStorage 等
如果在渲染时使用 window、localStorage 等客户端 API 也可能会导致水合错误。举个例子:
'use client'
export default function App() {
return <h1>{typeof localStorage !== 'undefined' ? localStorage.getItem("name") : ''}</h1>
}
此时会出现报错(客户端先运行 localStorage.setItem("name", "xxxx")
):
其实是原因 2 差不多,服务端没有 localSorage,导致渲染空字符串,客户端有 localStorage,如果成功获取到值,两端渲染不一致,从而出现水合错误。
原因 4:使用时间相关的 API,如 Date
使用时间相关的 API 可能也会导致水合错误,举个例子:
'use client'
export default function App() {
return <h1>{+new Date()}</h1>
}
此时会出现错误:
原因在于服务端渲染和客户端渲染的时间不一致。客户端组件它会先在服务端进行一次预渲染,传给客户端后还要进行一次水合,添加事件处理程序,最后根据客户端事件进行更新。所以客户端组件你可以简单粗暴的理解为“SSR + 水合 + CSR”。
原因 5:浏览器插件导致
React 有一个 Issue 讨论了这个问题,根本原因是有些插件会在页面加载之前修改页面结构,导致 DOM 渲染不一致。这个问题虽然经常被提起,但目前还是没有确切的解决方案。
能做的方案有:
- 将不匹配的部分放到 Suspense 中,虽然不能解决问题,但会避免整个应用都变成客户端渲染(React 18 以后,如果客户端水合失败,它将丢弃原本渲染的 HTML 并重新开始客户端渲染)。
- 降级 React 的版本,既然是 18 才有的错误,那就用 17……
不过所幸一般这个问题不会影响网站,只是会有错误提示,以及导致客户端重新渲染。属于影响不大,但解决起来又有点麻烦的问题,希望 React 未来会解决吧!
这对我们的提醒是,日常开发的时候,优先使用浏览器无痕模式进行测试,否则插件导致的问题可能无法及时发现。
其实不止浏览器插件,比如 IOS 的网页会尝试检测文本内容中的电话号码、邮箱等数据,将它们转为链接,方便用户交互,这也会导致水合错误。
如果遇到这个问题,可以使用 meta 标签禁用:
<meta
name="format-detection"
content="telephone=no, date=no, email=no, address=no"
/>
解决 1:使用 useEffect
使用 useEffect 可以有效的解决这个问题,因为 useEffect 并不会影响初始的渲染,如果要使用客户端的 API,应该都尽可能放在 useEffect 中使用。我们以原因 2 的错误为例,此时应该修改为:
'use client'
import { useState, useEffect } from 'react'
export default function App() {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
return <h1>{isClient ? 'Client' : 'Server'}</h1>
}
如果以原因 3 的错误为例,此时应该修改为:
'use client'
import { useState, useEffect } from 'react'
export default function App() {
const [name, setName] = useState('')
useEffect(() => {
setName(typeof localStorage !== 'undefined' ? localStorage.getItem("name") : '')
}, [])
return <h1>{name}</h1>
}
解决 2:禁用特定组件的 SSR 渲染
为什么会渲染不一致呢?本质上还是客户端组件既在服务端也在客户端渲染一份,干脆取消掉客户端组件的服务端渲染,为此需要借助 Next.js 提供的 dynamic 函数。
比如修改 app/page.js
,代码如下:
import dynamic from 'next/dynamic'
const NoSSR = dynamic(() => import('./no-ssr'), { ssr: false })
export default function Page() {
return (
<div>
<NoSSR />
</div>
)
}
新建 app/no-ssr.js
,代码如下:
'use client'
export default function App() {
const isClient = typeof window !== 'undefined';
return <h1>{isClient ? 'Client' : 'Server'}</h1>
}
改成原因 2、3、4 的代码都可。此时浏览器都可以正常渲染:
解决 3:使用 suppressHydrationWarning 取消错误提示
如果实在无法避免,比如时间戳的展示,那可以添加 suppressHydrationWarning={true} 属性取消错误提示,我们以时间戳为例:
'use client'
export default function App() {
return <h1>{+new Date()}</h1>
}
此时会出现水合错误:
当添加 suppressHydrationWarning 属性后:
'use client'
export default function App() {
return <h1 suppressHydrationWarning>{+new Date()}</h1>
}
此时页面正常渲染:
使用 suppressHydrationWarning 属性的时候要注意,suppressHydrationWarning 其实是 React 提供的方法。suppressHydrationWarning 只能用于一层深度。以刚才的代码为例,如果将代码修改成:
'use client'
export default function App() {
return <div suppressHydrationWarning>
<h1>{+new Date()}</h1>
</div>
}
这就是无效的,依然会出现水合错误提示。所以不要过度使用 suppressHydrationWarning。
解决 4:自定义 hook
1. useWindowSize
这本质上是基于解决 1 进行的封装,比如如果你要使用客户端的 API,比如获取 window 的尺寸,那你可以封装一个 useWindowSize hook。代码如下:
'use client'
import { useState, useEffect } from 'react'
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
const handleResize = () =>
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handleResize);
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return windowSize;
};
export default function App() {
const { width, height } = useWindowSize();
return (
<p>
Window size: ({width} x {height})
</p>
);
}
浏览器效果如下:
因为本质是在 useEffect 中使用,所以不会触发水合错误。
2. useLocalStorage
如果要使用 localStorage,可以封装一个 useLocalStorage,代码如下:
'use client'
import { useState, useEffect, useRef } from 'react'
function useLocalStorage(
key,
defaultValue
) {
const isMounted = useRef(false)
const [value, setValue] = useState(defaultValue)
useEffect(() => {
try {
const item = window.localStorage.getItem(key)
if (item) {
setValue(JSON.parse(item))
}
} catch (e) {
console.log(e)
}
return () => {
isMounted.current = false
}
}, [key])
useEffect(() => {
if (isMounted.current) {
window.localStorage.setItem(key, JSON.stringify(value))
} else {
isMounted.current = true
}
}, [key, value])
return [value, setValue]
}
export default function App() {
const [value, setValue] = useLocalStorage("name", "")
return (
<div onClick={() => {
setValue("yayu" + Math.random())
}}>
{value}
</div>
);
}
浏览器效果如下:
随着点击,localStorage 中的值也随之改变。
3. useMounted
也可以封装一个 useMounted hook,当挂载的时候再渲染内容:
'use client'
import { useState, useEffect } from 'react'
export function useMounted() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return mounted
}
export default function Page() {
const mounted = useMounted()
if (!mounted) return null
return (
<div>
<h1>{+new Date()}</h1>
<h1>{localStorage.getItem("name")}</h1>
</div>
)
}
此时浏览器效果如下:
服务端和客户端初始渲染为空,两端一致,所以也不会出现水合错误。
参考链接
转载自:https://juejin.cn/post/7365793739892228096