返回博客

Electron Preload 注入的 window 全局变量在渲染路径里触发 Next.js Hydration Mismatch

某天把一个 Next.js App Router 页面嵌进 Electron 的 WebContentsView 里,一切看起来都正常,直到打开 DevTools——控制台里挂着一条红色警告:

Hydration failed because the server rendered text didn't match the client.
As a result this tree will be regenerated on the client.

+ 读取中…
- Web 预览

翻 git log,改动只是在页面顶部加了一行 const desktop = isDesktop(),仅此而已。然而这一行,足以把整个子树炸掉。

现象

页面是 'use client',有一块 UI 需要根据「当前是否运行在桌面客户端里」来切换文案:桌面端显示版本号或加载状态,浏览器端降级显示「Web 预览」。代码大致如下:

'use client'
 
import { isDesktop } from '@/lib/desktop'
 
export default function StatusChip() {
  const desktop = isDesktop()   // ← 直接在渲染路径调用
  return (
    <Chip>
      {!desktop ? 'Web 预览' : currentVersion ? `v${currentVersion}` : '读取中…'}
    </Chip>
  )
}

在浏览器里直接访问,正常;嵌进 Electron 之后,React 报 Hydration Mismatch,并强制重新生成整个子树。

根因

isDesktop() 的实现大致是:

export function isDesktop(): boolean {
  if (typeof window === 'undefined') return false
  return Boolean(window.electronAPI)  // 或 window.__TAURI__ 等 Preload 注入的 bridge
}

问题出在两个运行时的时间差:

SSR 阶段(Node.js,服务器端)typeof window === 'undefined' 为真,函数返回 false,服务器输出的 HTML 包含「Web 预览」这段文字。

Hydration 阶段(Electron WebContentsView,客户端首帧):Preload 脚本在页面脚本执行之前已经同步运行完毕,window.electronAPI 已注入,isDesktop() 返回 true,React 计算出来的 VDOM 是「读取中…」。

两者一对比——服务器说一件事,客户端首帧说另一件事——React 18+ 认为这是开发者的 bug,React 19 更是直接重新生成整棵不匹配的子树并抛出可见错误。

坑:`'use client'` 不等于「只在客户端渲染」

这是踩这个坑最常见的认知误区。'use client' 的含义是「从这个边界起使用客户端 React(可以用 hooks、事件处理等)」,但 Next.js App Router 仍然会对这些组件做 SSR——生成初始 HTML 时在 Node.js 里执行一遍,Hydration 时在浏览器里再执行一遍,两次输出必须完全一致。

真正要禁用 SSR,需要用 dynamic(() => import('...'), { ssr: false })

另一个常见误判来自代码审查:"SSR 时 typeof window === 'undefined',函数安全返回 false,不会崩溃,所以没问题。"——不崩溃 ≠ 输出一致。函数在 Node.js 里不抛错,只能说明它「SSR 安全」,但它返回 false,而客户端首帧返回 true,这恰好就是 Mismatch 的定义。

正解

绝不在渲染路径直接调用依赖 Preload bridge 的函数。 把检测结果提升到 useState,在 useEffect(hydration 之后)里同步真实值:

'use client'
 
import { useEffect, useState } from 'react'
import { isDesktop } from '@/lib/desktop'
 
export default function StatusChip() {
  // SSR 和 hydration 首帧都使用这个初始值,保证两侧输出一致
  const [desktop, setDesktop] = useState(true)
 
  // Hydration 完成后,读取真实运行环境
  useEffect(() => {
    setDesktop(isDesktop())
  }, [])
 
  return (
    <Chip>
      {!desktop ? 'Web 预览' : currentVersion ? `v${currentVersion}` : '读取中…'}
    </Chip>
  )
}
正解:初始值选「乐观 true」,effect 里同步真实值

初始值有三种选法:

| 初始值 | 适合场景 | 闪烁情况 | |--------|----------|---------| | useState(true) | 桌面客户端为主,浏览器访问是调试边界 | Electron 零闪烁;浏览器首帧短暂显示桌面 UI 再切换 | | useState(false) | Web 优先,Electron 是次要包装 | 浏览器零闪烁;Electron 首帧短暂显示降级 UI | | useState<boolean \| null>(null) | 两侧都是主要目标,宁可显示骨架 | 两侧各有一帧 loading 占位 |

桌面客户端优先的项目选 useState(true):Electron 用户体验零闪烁,少数从浏览器打开的场景接受一帧切换是合理取舍。

若整个组件根本没有浏览器降级意义(纯桌面功能),可以用 dynamic 彻底关掉 SSR:

import dynamic from 'next/dynamic'
 
const DesktopOnlyPanel = dynamic(() => import('./desktop-only-panel'), { ssr: false })

这样 SSR 输出空占位,组件只在客户端挂载,Mismatch 从根本上不可能发生。代价是首帧无内容——对内部桌面工具而言 SEO 和慢网络都不是问题,可以接受。

排查一网打尽

修完一处,用这条命令扫全仓库,找出所有可能的同类问题:

git grep -nE 'isDesktop\(\)|isElectron\(\)|isTauri\(\)|window\.(electronAPI|__TAURI__|__TAURI_INTERNALS__)' \
  -- '*.tsx' '*.jsx' \
  | grep -v 'useEffect\|useMemo\|useCallback'

过滤后还剩下的行,逐一确认是否在渲染路径上。在 useEffect、事件处理函数、useMemo/useCallback 内部调用这些函数是安全的——危险只来自组件函数体的直接执行路径。

一句话外卖

'use client' 只是划定了 React 边界,不是关掉 SSR 的开关;任何依赖 Preload 注入 window.* 的检测,都必须在 useEffect 里做,而不是写进渲染路径。