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

React 18下Next Link结合自定义Button组件出现Hydration不匹配错误

解决React 18 + Next.js中Link结合自定义Button的Hydration错误

问题现象

在React 18环境下,使用next/link结合自定义Button组件实现带按钮样式的锚点链接时,触发以下错误:

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

控制台同时提示:

Expected server HTML to contain a matching in

注释相关链接后错误消失,确认问题源于链接渲染逻辑,且该组件在React 17中可正常运行。

自定义Button组件代码

const variants = {
  primary: "btn-primary",
  secondary: "btn-secondary",
  delete: "btn-red",
  white: "btn-white",
  none: "",
};

type IconProps =
  | { startIcon: React.ReactElement; endIcon?: never }
  | { endIcon: React.ReactElement; startIcon?: never }
  | { endIcon?: undefined; startIcon?: undefined };

export type ButtonProps = React.ButtonHTMLAttributes<
  HTMLButtonElement | HTMLAnchorElement
> & {
  variant?: keyof typeof variants;
  isLoading?: boolean;
  asAnchor?: boolean;
} & IconProps;

export const Button = React.forwardRef<
  HTMLButtonElement | HTMLAnchorElement,
  ButtonProps
>(
  (
    {
      type = "button",
      className = "",
      variant = "primary",
      isLoading = false,
      startIcon,
      endIcon,
      asAnchor,
      disabled,
      ...props
    },
    ref
  ) => {
    return (
      <>
        {asAnchor ? (
          <a
            ref={ref}
            className={clsx(
              className,
              variants[variant],
              "appearance-none",
              disabled && "pointer-events-none opacity-90"
            )}
            {...props}
          >
            {!isLoading && startIcon}
            {!isLoading && props.children}
            {!isLoading && endIcon}
          </a>
        ) : (
          <button
            ref={ref}
            type={type}
            className={clsx(
              className,
              variants[variant],
              "disabled:pointer-events-none disabled:opacity-60"
            )}
            disabled={disabled}
            {...props}
          >
            {isLoading && (
              <span className="flex items-center justify-center space-x-2">
                <Spinner />
                <span>Loading</span>
              </span>
            )}
            {!isLoading && startIcon}
            {!isLoading && <> {props.children} </>}
            {!isLoading && endIcon}
          </button>
        )}
      </>
    );
  }
);

Button.displayName = "Button";

组件使用方式

<Link passHref href="/login">
  <Button className="text-xs" asAnchor>
    Login
  </Button>
</Link>

问题原因分析

  1. React 18 Hydration校验更严格:React 18对服务端渲染(SSR)的DOM结构一致性要求更高,细微差异都会触发hydration错误。
  2. 无效属性传递:当asAnchor为true时,原代码将Button的disabled属性直接传递给<a>标签,但<a>原生不支持该属性,导致服务端渲染的HTML包含无效属性,客户端hydration时无法匹配。
  3. Ref未正确传递:原代码中<a>标签未接收forwardRef传递的ref,可能导致DOM节点引用不一致。
  4. 条件渲染潜在差异:如果isLoading状态在服务端和客户端初始值不同,会导致startIcon/endIcon的渲染结果不一致,触发DOM不匹配。

解决方案

  1. 过滤无效属性:将disabled从传递给<a>的props中分离,仅通过className控制禁用状态,避免给<a>添加原生不支持的属性。
  2. 传递Ref到目标元素:确保forwardRef的ref正确传递给<a>或<button>元素。
  3. 保证状态一致性:确保isLoading等状态在服务端和客户端的初始值一致,避免条件渲染导致的DOM差异。
  4. 优化Next.js Link使用:确保passHref正确传递href属性到Button组件内的<a>标签。

修改后的关键代码说明

  • 从props中解构出disabled,不再传递给<a>标签,仅通过clsx添加禁用样式。
  • 给<a>和<button>标签都添加ref={ref},确保ref正确传递。
  • 保持条件渲染逻辑的一致性,确保服务端和客户端渲染的内容一致。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.23 01:33:24