修复服务端渲染应用中的 “window is not defined” 错误
通过挂载钩子、typeof window 检查和仅客户端导入,修复服务端渲染应用中的 window is not defined 错误。
window is not defined 这个错误意味着你的代码在 Node.js 中执行了,而那里并不存在 window 对象:服务端渲染框架会先在服务器上运行你的组件,此时浏览器还没有参与进来。
这个错误通常出现在两种情况之后:你给一个原本正常工作的应用加上了服务端渲染,或者把一个在客户端运行良好的组件迁移到了 Next、Nuxt、SvelteKit、Astro 或 React Router 中。组件本身没有变,变的是它运行的位置——而堆栈跟踪会告诉你,下面三种修复方案中你需要的是哪一个。
关键要点
window is not defined意味着代码在 Node.js 中运行,而在 Node.js 中,window在任何生命周期的任何时刻都不存在;这不是时序问题。- 默认的修复方式是把访问移入挂载钩子(
useEffect、onMounted、onMount),因为挂载钩子永远不会在服务端运行。 typeof window !== 'undefined'守卫适用于模块级代码和共享工具函数;如果放在组件的渲染过程中,会导致服务端与客户端生成的 HTML 出现分歧。- 仅客户端渲染是最后的手段:它会把该组件从服务端 HTML 中彻底移除。
- 同样的崩溃也可能发生在构建阶段,因为静态生成会在 Node 中运行组件以产出 HTML。
为什么服务端渲染应用中会出现 “window is not defined”?
服务端渲染应用会执行你的组件两次:先在 Node.js 中执行以产出 HTML,然后在浏览器中再执行一次。Node.js 全局作用域中既没有 window 也没有 document,因此任何在服务端阶段触碰它们的代码都会抛出 ReferenceError。这个对象不是“暂时还不可用”;在 Node 中它根本从不存在。
function ThemeBadge() {
// ReferenceError: window is not defined (thrown during the server render)
const theme = window.localStorage.getItem('theme');
return <span>{theme}</span>;
}
即使完全没有请求发生,情况也一样。静态生成会在构建时在 Node 中运行你的组件以产出 HTML,因此对 window 的访问可能在 next build 或预渲染期间失败,堆栈跟踪会出现在构建输出中,而不是服务器日志里。SvelteKit 甚至把这个阶段暴露为 building 常量,它在预渲染期间为 true。因此,一个在开发环境中只会在客户端渲染的组件可能通过本地测试,却仍然让生产构建失败。
如果崩溃发生在依赖包里怎么办?
如果堆栈跟踪的顶部帧指向 node_modules,说明某个依赖在导入时就读取了 window,它会在你的任何组件代码运行之前抛出错误。图表库、嵌入式 SDK,以及任何在模块作用域探测 DOM 的东西,都是常见的“嫌疑人”。
ReferenceError: window is not defined
at node_modules/some-chart-lib/dist/index.js:12:3
at Module._compile (node:internal/modules/cjs/loader:1358:14)
这个区分决定了修复方式。导入时抛出的错误发生在模块加载时,因此把你自己的用法包进挂载钩子毫无帮助;崩溃发生在组件存在之前。对于这类包,直接跳到第三种修复方案中的仅客户端导入。
修复 1:把访问移入挂载钩子
默认的修复方式是把对 window 的访问移入框架的挂载钩子,因为挂载钩子只会在浏览器中运行。React 的 useEffect 参考文档对此有明确说明:服务端渲染会跳过 Effect,只有当组件到达浏览器后它们才会触发。各框架的对应写法:Vue 和 Nuxt 使用 onMounted,Svelte 和 SvelteKit 使用 onMount(在服务端渲染的组件永远不会调用它),React Router 使用 React 的 useEffect,而 Astro 组件则把浏览器代码放在框架 island 的生命周期钩子中。
import { useState, useEffect } from 'react';
function ThemeBadge() {
const [theme, setTheme] = useState(null);
useEffect(() => {
setTheme(window.localStorage.getItem('theme')); // browser only
}, []);
return <span>{theme ?? 'default'}</span>;
}
服务端渲染回退状态,浏览器完成挂载,effect 运行,真实值随后填入。这种方式保留了组件其余部分的服务端 HTML,这也是它优于另外两种方案、可作为默认选择的原因。
修复 2:用 typeof window !== ‘undefined’ 守卫
typeof window !== 'undefined' 守卫是模块级代码和共享工具函数的正确工具,因为那里没有可用的生命周期钩子。
// theme.js — a shared utility, no component lifecycle to lean on
export function getStoredTheme() {
if (typeof window === 'undefined') return 'light'; // server fallback
return window.localStorage.getItem('theme') ?? 'light';
}
SvelteKit 通过 browser 常量提供了更简洁的等价写法,它的客户端库相关 FAQ 把这个常量视为隔离任何触碰 document 或 window 的代码的标准做法。
不过在组件的渲染过程中,这种守卫并不合适:它会让服务端和浏览器为同一个组件生成不同的 HTML,用一次不匹配(mismatch)换掉了一次崩溃,问题会在客户端接管时暴露。请把守卫留在普通函数和模块作用域里;在组件内部使用修复方案一。
修复 3:跳过该组件的服务端渲染
最后的手段是使用仅客户端的动态导入,它会把组件完全排除在服务端渲染之外。在 Next.js 中,next/dynamic 配合 ssr: false 可以在 Client Component 内做到这一点(在 Server Component 中会报错,因此需要加一层薄薄的 'use client' 包装组件)。Nuxt 提供了 <ClientOnly>,Astro 则提供了 client:only 指令。
'use client';
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'), {
ssr: false,
loading: () => <div style={{ height: 320 }} aria-hidden="true" />,
});
在采用这种方案之前,先明确它的代价:服务器不会为该子树发送任何 HTML,因此组件在初始 HTML 中缺失,这可能损害 SEO 并延迟可交互性。请把它留给你无法修改的组件,主要是那些在导入时就抛错的依赖。
用同尺寸占位符避免内容突然弹出
只有当占位符占据与它所替代的组件相同的尺寸时,才能真正防止布局偏移。在服务端渲染 null 意味着一旦 JavaScript 运行,组件就会凭空出现,把它下方的所有内容往下推。一个固定占位面积的骨架屏(比如上面那个 320px 的 div)会一直占住空间,直到真实标记到达。至于究竟该渲染占位符还是干脆渲染 null,其背后的权衡与许多 hydration 不匹配问题是同一回事,我们在关于修复 Next.js hydration 错误的指南中做了深入讨论。对仅客户端回退内容进行会话回放(session replay),可以让占位符到真实内容的切换以布局跳动的形式可视化呈现,这是检查占位符是否真正匹配它所替代的标记的最快方式。
哪种修复方案适合你的情况?
- 你的组件在自己的代码中读取
window: 把访问移入挂载钩子。默认选择。 - 共享工具函数或模块级语句触碰了
window: 添加typeof window守卫,并给出服务端的回退值。 - 堆栈跟踪在导入时就指向
node_modules: 使用仅客户端的动态导入,并配上同尺寸占位符。 - 错误只出现在构建输出中: 排查思路与上面相同;静态生成在 Node 中运行的是完全相同的代码路径。
先读堆栈跟踪
这个错误是环境问题,而不是时序问题:某行代码在 Node 中运行了,而那里从来就没有 window。先读堆栈跟踪。如果顶部帧是你自己的代码,挂载钩子或守卫就能修复它,同时保留服务端 HTML。如果它指向 node_modules,就把该依赖隔离在仅客户端导入之后,并给它一个能撑住布局的占位符。
常见问题
“document is not defined” 和 “window is not defined” 是同一个问题吗?
是的。这两个错误的原因相同:代码在 Node.js 中运行,而 Node.js 的全局作用域中既没有 window 也没有 document。同样的排查思路和同样的三种修复方案都适用,因此可以把访问移入挂载钩子、用 typeof 检查守卫模块级代码,或者在依赖于导入时触碰 DOM 的情况下让组件仅在客户端渲染。
我可以通过在服务端定义一个全局 window 对象来修复这个错误吗?
不建议这么做。把一个伪造的 window 赋给 globalThis 确实能让 ReferenceError 消失,但服务端随后会基于伪造的值渲染标记,而且存储在这个 polyfill 上的任何东西都会被服务器处理的所有请求共享。它还会掩盖依赖中导入时的崩溃,而不是把问题暴露出来。请改为把访问移入挂载钩子,或放在 typeof window 守卫之后。
为什么在 Next.js 中设置了 ssr: false 之后仍然出现 “window is not defined”?
有两个常见原因。在 App Router 中,next/dynamic 只接受来自 Client Component 的 ssr: false,当这个选项出现在 Server Component 中时 Next.js 会报错,因此需要把它包在一个薄薄的 'use client' 组件里。另外,ssr: false 只影响那个动态导入本身:如果另一个在服务端执行的文件静态导入了同一个库,它在模块级对 window 的访问仍然会在 Node 中运行。
Node.js 中存在 localStorage 吗?
部分存在。Node 从 v22.4.0 起提供了 localStorage 全局对象,自 v25.0.0 起不再需要标志位启用,它会把最多 10 MB 的数据持久化到通过 --localstorage-file 标志传入的文件中;在 v26 中,不带该标志访问它会抛出 DOMException。在服务器上,它背后是整个进程共用的一个存储,而不是每个访客或每个请求各有一个,因此它与浏览器中按用户隔离的存储完全不同,而且 window.localStorage 仍然会抛错,因为在 Node 中 window 本身从不存在。