cover
2023年7月30日

next.js - 如何关闭 SSR 及处理 hydration 错误

今天聊一聊上手 next.js 使用中常会出现的一类报错:hydration fail,估计大部分使用过 next.js 开发的同学对下面的报错信息一定都很熟悉:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

报错的原因报错信息中已经说的很清楚:由于 hydration 后的 UI 和服务端渲染的 UI 不一致导致 hydration 失败。不过虽然报错信息写着 Hydration failed,但实际上还是会以 Hydration 后的 UI 为准的,其实勉强算是 hydration 成功的。

实际场景

下面讲一个实际的场景。开发中我们经常会将一些不重要的或者不需要同步的数据存储在本地,在客户端我们可以获取到这些存储在本地的数据,而在服务端获取不到。比如我们有一些存储在 localStorage 中的配置信息,而页面会根据该配置信息来进行渲染,然而服务端是无法获取客户 localStorage 中的信息的,这就导致服务端渲染时与客户端渲染时的数据产生差异从而导致错误的发生。

比如如下代码:

export default function R() {
    const [expand, setExpand] = React.useState(() => localStorage.getItem(EXPAND_STORAGE_KEY) === '1');
    return (
        <div>
            <NavSidebar expand={expand} onExpand={setExpand} />
        </div>
    );
}

我们有一个 sidebar,用户可以对其进行展开或收起,为了用户体验我们经常会将它保存在本地,但是由于启用了 SSR,我们的页面代码会在服务端执行,然而由于我们在 state 初始化时使用了 localStorage,这就导致页面在服务端渲染时就报错了,因为 node 中可没有 localStorage。于是我们就修改了一下代码:

export default function R() {
    const [expand, setExpand] = React.useState(() =>
        typeof window === 'undefined' ? false : localStorage.getItem(EXPAND_STORAGE_KEY) === '1'
    );
    return (
        <div>
            <NavSidebar expand={expand} onExpand={setExpand} />
        </div>
    );
}

通过 window 检查是 node 环境还是 window 环境,然后再去按照环境采取不同的措施,然后,就会出现我们一开始提到的错误了。其实这段代码在逻辑上看是没有问题的,并且在纯 SSR 场景下也 OK,其实在 vueSSR 检查里经常会看到这样的代码,比如 vitepress 中的 NSSR 就是通过 window 来判断。但是在 SSR + hydration 的场景下,Reacthydration 会检查 UI 的一致性,前后数据不一致就会导致 hydration 错误的发生。

其实不是 next.js 的检查

其实这段检查并不是 next.js 中所做的,而是在 react-domhydration 中做的,我们可以简单看下 react-dom 中相关的源码:

if (!tryHydrate(fiber, nextInstance)) {
    if (shouldClientRenderOnMismatch(fiber)) {
        warnNonhydratedInstance(hydrationParentFiber, fiber);
        throwOnHydrationMismatch();
    }
}
function throwOnHydrationMismatch(fiber) {
    throw new Error('Hydration failed because the initial UI does not match what was ' + 'rendered on the server.');
}
function shouldClientRenderOnMismatch(fiber) {
    return (fiber.mode & ConcurrentMode) !== NoMode && (fiber.flags & DidCapture) === NoFlags;
}

react-dom 中的 hydration 会使用 tryHydrate 来尝试进行 hydrate 操作,如果尝试失败,将会进行模式和标志位的检查,然后抛出该错误。

解决方案

useEffect/componentDidMount

而要解决上面的问题,官方推荐的解决方案就是使用 useEffect

const [expand, setExpand] = React.useState(true);

// to avoid ssr error
useEffect(() => {
    setExpand(localStorage.getItem(EXPAND_STORAGE_KEY) === '1');
}, []);

由于在服务端渲染时,effect 并不会执行,所以并不会报错,当然,也可以使用类组件,然后在 componentDidMount 中进行 localStorage 的获取。

不过这个解决方案会带来一些问题,比如如果 sideBar 的展开收起存在动画,那用户进入页面就会看到一个多余的动画,会比较奇怪,解决方案的话就是我们在默认情况下不要渲染 sidebar。😂 然而这样 SSR 的效果就没预想的那么好,要么就是初始化时把动画关掉之类的,具体的就要视实际场景进行取舍了,颇有些鱼和熊掌不可兼得的味道。

开源库解决 react-no-ssr

还有一种常见的解决方案就是使用一些开源库,比如 react-no-ssr,其实 react-no-ssr 本身也是使用上面的方案来实现的,可以看下源码:

import React from 'react';

const DefaultOnSSR = () => <span></span>;

class NoSSR extends React.Component {
    constructor(...args) {
        super(...args);
        this.state = {
            canRender: false
        };
    }

    componentDidMount() {
        this.setState({ canRender: true });
    }

    render() {
        const { children, onSSR = <DefaultOnSSR /> } = this.props;
        const { canRender } = this.state;

        return canRender ? children : onSSR;
    }
}

export default NoSSR;

可以看到 NoSSRcomponentDidMount 才会设置 canRender,从而渲染包裹的内容,以此来确保内容导致的问题。

关闭 SSR

此外我们还可以通过关闭存在 hydration 问题的组件的 SSR 来解决问题,其实上面的 react-no-ssr 就是其中的一种,不过 next.js 官方还提供了一些自带的方案:通过 dynamic 加载组件并关闭 ssr,以上面的 sidebar 场景为例:

import dynamic from 'next/dynamic';

const DynamicSidebarWithNoSSR = dynamic(() => import('../components/Sidebar'), {
    ssr: false
});

export default function R() {
    return (
        <div>
            <DynamicSidebarWithNoSSR />
        </div>
    );
}

我们只需要将存在问题的组件进行抽离,然后使用 dynamic 来加载该组件并传入 ssr 参数为 false,即可关闭该组件的服务端渲染。

当然,为了方便我们可以进行一些简单的封装:

components/NoSSR.jsx

import dynamic from 'next/dynamic';
import React from 'react';

const NoSSR = props => <React.Fragment>{props.children}</React.Fragment>;

export default dynamic(() => Promise.resolve(NoSSR), {
    ssr: false
});

然后我们只需要在需要使用的时候在直接调用该组件将不兼容组件进行包裹即可:

import dynamic from 'next/dynamic';

import Sidebar from '../components/Sidebar';

export default function R() {
    return (
        <div>
            <NoSSR>
                <Sidebar />
            </NoSSR>
        </div>
    );
}

总结

从体验上来讲,SSR 确实会给我们的应用带来很大的提升,不过也会带来一些开发的问题。由于不同于 CSR 只需要在浏览器中运行,SSR 需要我们的代码初始化时在 node 中也能够运行,这就让我们在开发中会遇到一些做纯 CSR app 遇不到的一些问题和挑战。如果对 hydration fail 错误的解决有其它方案的同学欢迎留言。