12k
All articles

修复服务端渲染应用中的 “window is not defined” 错误

通过挂载钩子、typeof window 检查和仅客户端导入,修复服务端渲染应用中的 window is not defined 错误。

OpenReplay Team
OpenReplay Team
修复服务端渲染应用中的 “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 在任何生命周期的任何时刻都不存在;这不是时序问题。
  • 默认的修复方式是把访问移入挂载钩子(useEffectonMountedonMount),因为挂载钩子永远不会在服务端运行。
  • 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 把这个常量视为隔离任何触碰 documentwindow 的代码的标准做法。

不过在组件的渲染过程中,这种守卫并不合适:它会让服务端和浏览器为同一个组件生成不同的 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),可以让占位符到真实内容的切换以布局跳动的形式可视化呈现,这是检查占位符是否真正匹配它所替代的标记的最快方式。

哪种修复方案适合你的情况?

  1. 你的组件在自己的代码中读取 window 把访问移入挂载钩子。默认选择。
  2. 共享工具函数或模块级语句触碰了 window 添加 typeof window 守卫,并给出服务端的回退值。
  3. 堆栈跟踪在导入时就指向 node_modules 使用仅客户端的动态导入,并配上同尺寸占位符。
  4. 错误只出现在构建输出中: 排查思路与上面相同;静态生成在 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 本身从不存在。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.