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' 的含义是「从这个边界起使用客户端 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>
)
}初始值有三种选法:
| 初始值 | 适合场景 | 闪烁情况 |
|--------|----------|---------|
| 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里做,而不是写进渲染路径。