面试官:Next.js 常见错误 Hydration Failed 该如何解决?

发布于:2024-05-09 ⋅ 阅读:(23) ⋅ 点赞:(0)

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

开发 Next.js 项目的时候常会遇到水合错误提示,比如:

image.png

image.png

image.png

具体的错误提示可能不一样,但都会告诉你是水合(Hydration)的时候出现错误。遇到这种错误该怎么解决呢?

此外,这还是一道考察 Next.js 的常见面试题。

通过考察这类常见问题的解决方法,判断面试者是否对日常开发中遇到的问题进行过系统总结和思考。

所以开始前先点赞收藏下这篇文章吧。

PS:系统学习 Next.js,欢迎入手小册。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

错误分析

所谓水合(Hydration),指的是 React 为预渲染的 HTML 添加事件处理程序,将其转为完全可交互的应用程序的过程。

React 提供了 客户端 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>
  )
}

这就会导致水合错误:

image.png

不过一般 React 还会给出具体的错误提示和导致错误的 HTML 元素位置:

image.png

这样我们就可以快速定位出现错误的地方。

除了 <p>嵌套错误,其他的可能还有:

  1. <p> 嵌套在另一个 <p> 元素中
  2. <div> 嵌套在 <p> 元素中
  3. <ul><ol> 嵌套在 <p> 元素中
  4. 交互式内容()不能嵌套

所谓交互式内容,指的是专门用于用户交互的内容,其实 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>
}

此时会出现错误提示:

image.png

这个错误只会出现在客户端组件中,因为 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")):

image.png image.png

其实是原因 2 差不多,服务端没有 localSorage,导致渲染空字符串,客户端有 localStorage,如果成功获取到值,两端渲染不一致,从而出现水合错误。

原因 4:使用时间相关的 API,如 Date

使用时间相关的 API 可能也会导致水合错误,举个例子:

'use client'

export default function App() {
  return <h1>{+new Date()}</h1>
}

此时会出现错误:

image.png

原因在于服务端渲染和客户端渲染的时间不一致。客户端组件它会先在服务端进行一次预渲染,传给客户端后还要进行一次水合,添加事件处理程序,最后根据客户端事件进行更新。所以客户端组件你可以简单粗暴的理解为“SSR + 水合 + CSR”。

原因 5:浏览器插件导致

React 有一个 讨论了这个问题,根本原因是有些插件会在页面加载之前修改页面结构,导致 DOM 渲染不一致。这个问题虽然经常被提起,但目前还是没有确切的解决方案。

能做的方案有:

  1. 将不匹配的部分放到 Suspense 中,虽然不能解决问题,但会避免整个应用都变成客户端渲染(React 18 以后,如果客户端水合失败,它将丢弃原本渲染的 HTML 并重新开始客户端渲染)。
  2. 降级 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 的代码都可。此时浏览器都可以正常渲染:

image.png

解决 3:使用 suppressHydrationWarning 取消错误提示

如果实在无法避免,比如时间戳的展示,那可以添加 suppressHydrationWarning={true} 属性取消错误提示,我们以时间戳为例:

'use client'

export default function App() {
  return <h1>{+new Date()}</h1>
}

此时会出现水合错误:

image.png

当添加 suppressHydrationWarning 属性后:

'use client'

export default function App() {
  return <h1 suppressHydrationWarning>{+new Date()}</h1>
}

此时页面正常渲染:

image.png

使用 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>
  );
}

浏览器效果如下:

react-7.gif

因为本质是在 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>
  );
}

浏览器效果如下:

react-8.gif

随着点击,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>
  )
}

此时浏览器效果如下:

image.png

服务端和客户端初始渲染为空,两端一致,所以也不会出现水合错误。

参考链接