各位好,欢迎来到今天的讲座。我是你们的“React 避坑指南”讲师。
今天我们要聊的是一个听起来很高大上,但实际操作起来像是在走钢丝的主题——React 混合渲染模式:Window/Document 对象的安全访问策略。
如果你刚从“全栈开发”的梦里醒来,手里还攥着服务器和浏览器,那请坐。今天我们不聊那些虚无缥缈的概念,我们聊聊那个让你在深夜里对着屏幕抓狂、甚至想把键盘砸了的元凶——window is not defined。
准备好了吗?让我们开始这场关于“同构”的冒险。
第一部分:同构渲染是个什么鬼?
首先,我们要搞清楚我们在干什么。所谓的“混合渲染”,说白了就是服务端渲染(SSR)和客户端渲染(CSR)的联姻。
想象一下,传统的 React 应用就像是在餐厅里现做现吃。用户点单,后厨(Node.js)把菜做好,端上来,用户吃。这叫“服务端渲染”。但是,这有个问题:如果用户点了一百份菜,后厨就得做一百次,太慢了,用户体验不好。
于是,聪明的工程师们发明了“预制菜”模式——客户端渲染。用户点单,后厨把做好的半成品菜(HTML)端上来,用户到家了,自己用微波炉(浏览器)热一下。这很快,但有个问题:用户刚到家,微波炉还没热好,菜是凉的,他看到的就是一片空白,甚至可能看到“正在加载”的转圈圈。这叫“白屏”。
为了两全其美,我们发明了同构渲染。
在这套模式下,代码像是一个身怀绝技的魔术师。它既能像后厨大厨一样在服务端炒菜(生成 HTML),又能像家庭主妇一样在客户端热菜(处理交互)。代码是同一份,逻辑是同一套,但这把“刀”在服务端和客户端却是不同的。
问题来了:
在服务端,我们运行在 Node.js 环境里。Node.js 是一个没有眼睛、没有耳朵、没有 DOM 的“盲人”和尚。它根本不知道什么是 window,什么是 document,什么是 navigator。它只有 process,global,还有 Buffer。
而在客户端,我们运行在浏览器里。浏览器就是一个把所有东西都堆在桌子上的乱室之家,window 就挂在墙角,随时待命。
当你的魔术师代码试图在服务端去摸一下墙角的 window 时,会发生什么?就像你在沙漠里试图用勺子挖井一样——你会得到一个 ReferenceError: window is not defined。
这就是混合渲染的噩梦。
第二部分:window 和 document 的那些坑
让我们直面现实。window 对象不仅仅是一个简单的变量,它是一个庞大的家族。
- 核心 DOM API:
document.getElementById,window.innerWidth,document.querySelector。这些是 DOM 操作的基石。服务端没有 DOM。 - 浏览器特有对象:
navigator.userAgent,localStorage,sessionStorage。这些是浏览器用来“监视”你的工具。服务端没有浏览器。 - 全局变量:
Array,Object,Promise。这些虽然服务端有,但在某些构建工具(如 Webpack)的环境变量配置下,它们可能会被替换或者隔离。
最经典的崩溃现场:
假设你写了一个漂亮的轮播图组件。在客户端,你用 ResizeObserver 监听窗口大小变化来调整图片尺寸。
// 这是一个典型的客户端组件
import React, { useEffect, useState } from 'react';
const ResponsiveImage = () => {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
// 这里使用了 window,只有浏览器才有
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <img src={`https://via.placeholder.com/${width}`} alt="Responsive" />;
};
export default ResponsiveImage;
这段代码在浏览器里运行得完美无缺。但是,当你把它扔进 Next.js 的服务端渲染流程时,Next.js 会在服务器上启动你的 React 应用。
当你调用 <ResponsiveImage /> 时,React 会在服务器上尝试执行这段代码。就在那一瞬间——啪! 报错了。ReferenceError: window is not defined。
服务器上的日志会瞬间爆炸,用户看到的页面是一张白纸,或者一个红色的报错堆栈。
第三部分:防御性编程——typeof window !== 'undefined'
既然 window 不存在,那我们是不是只要检查一下它存不存在就行了?
是的,这是最原始、最笨拙,但也是最有效的办法。我们称之为“防御性编程”。这是一种“只要我不伸手,就没人能打到我”的心态。
const ResponsiveImage = () => {
// 等等,这里写什么?
// if (typeof window !== 'undefined') { ... }
// 但是 window 下面还有 document!document 也有可能不存在!
// 那还要不要检查 document.createElement?
// 甚至还要检查 navigator.userAgent?
// 这段代码会变得极其丑陋,充满了 if-else 堆砌。
// 而且每次访问 window 下的属性都要写一遍检查。
// 这简直就是代码的癌症。
};
这种写法的后果是:代码可读性下降 1000%,维护成本上升 10000%。
你会在代码的每一个角落看到这种味道:
const myWidth = (typeof window !== 'undefined' && window.innerWidth) || 0;
const myHeight = (typeof window !== 'undefined' && window.innerHeight) || 0;
const myLocalStorage = (typeof window !== 'undefined' && window.localStorage) || null;
这就好比你去菜市场买菜,每买一根葱都要先问老板:“老板,你有葱吗?有我就买。哦,你还有大蒜吗?有吗?有吗?” 菜场老板会把你赶出去的。
在 React 的组件里,这种写法会让你的逻辑变得支离破碎。
第四部分:React Hooks 的解决方案——useEffect vs useLayoutEffect
既然直接访问 window 会导致崩溃,React 社区是怎么解决这个问题的?
答案是:利用 Hooks 的执行时机差异。
1. useEffect —— 客户端的独行侠
useEffect 是 React 的“懒汉”。它的设计初衷就是只运行在客户端。
当你使用 useEffect 时,React 会把你的副作用代码放在一个队列里。只有当组件在浏览器中真正渲染(DOM 已经挂载)之后,useEffect 才会执行。
这意味着,如果 useEffect 里写了 window.innerWidth,服务端渲染时根本不会执行这段代码,所以服务端不会崩溃。
正确的写法示例:
import React, { useEffect, useState } from 'react';
const WindowSizeComponent = () => {
const [windowSize, setWindowSize] = useState({
width: 0,
height: 0,
});
useEffect(() => {
// 安全!因为这段代码只在浏览器里跑
if (typeof window !== 'undefined') {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
// 初始化一次
handleResize();
return () => window.removeEventListener('resize', handleResize);
}
}, []); // 空依赖数组确保只运行一次
return (
<div>
<p>当前窗口宽度: {windowSize.width}px</p>
<p>当前窗口高度: {windowSize.height}px</p>
</div>
);
};
export default WindowSizeComponent;
为什么这样安全?
因为服务端渲染(SSR)时,useEffect 的回调函数根本不会被调用。React 只是把它标记为“待执行”。只有当 HTML 发送到浏览器,浏览器解析完 HTML,React 开始“水合”时,useEffect 才会跑起来。那时候,window 已经存在了。
2. useLayoutEffect —— 客户端的强迫症
useLayoutEffect 和 useEffect 长得很像,唯一的区别是执行时机。
useEffect 是异步的,浏览器绘制完界面后,下一帧才执行。
useLayoutEffect 是同步的,浏览器绘制完界面立即执行,阻塞绘制,直到你的代码跑完。
为什么这很危险?
因为 useLayoutEffect 在服务端也会执行!
import React, { useLayoutEffect, useState } from 'react';
const BadComponent = () => {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
// 危险!这里在服务端会崩溃!
console.log(window.innerWidth);
setCount(window.innerWidth);
}, []);
return <div>Count: {count}</div>;
};
当你在 Next.js 中使用 useLayoutEffect 时,服务端渲染会尝试运行它,导致 window is not defined 崩溃。虽然 React 后来在 Next.js 9+ 版本中引入了自动的 useEffect polyfill(将 useLayoutEffect 替换为 useEffect),但在自定义 Hook 或者其他库中,你依然需要小心。
最佳实践:创建一个 useIsomorphicLayoutEffect Hook。
这是 React 社区最经典的“招式”。
import { useEffect, useLayoutEffect as useLayoutEffectNative } from 'react';
// 定义一个安全的 Hook
const useIsomorphicLayoutEffect = typeof window !== 'undefined'
? useLayoutEffectNative
: useEffect;
export const MyComponent = () => {
// 使用自定义 Hook
useIsomorphicLayoutEffect(() => {
console.log('This runs on both server and client safely (or only client)');
// 你的逻辑...
}, []);
return <div>Hello</div>;
};
这段代码利用了 JavaScript 的条件编译特性。如果 window 存在,就用原生的 useLayoutEffect;如果不存在,就用 useEffect。这就好比给你的代码穿了一层防弹衣。
第五部分:深入探讨——ResizeObserver 的悲剧
让我们看一个更具体的例子,一个现代前端开发中非常常用的库——ResizeObserver。
ResizeObserver 是一个极其强大的 API,它可以监听 DOM 元素尺寸的变化。它通常用于实现“响应式布局”或者“无限滚动加载”。
但是,ResizeObserver 也是浏览器专属的 API! Node.js 里没有它!
场景: 你在做一个卡片列表,当卡片高度变化时,触发加载更多。
// 这是一个典型的同构陷阱
import React, { useEffect, useRef } from 'react';
const InfiniteScrollList = () => {
const observerRef = useRef(null);
const listRef = useRef(null);
useEffect(() => {
// 这里会崩溃!服务端没有 ResizeObserver!
observerRef.current = new ResizeObserver((entries) => {
console.log('Size changed');
// 处理滚动逻辑...
});
if (listRef.current) {
observerRef.current.observe(listRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
return <div ref={listRef}>List Items...</div>;
};
如果你直接这么写,服务端渲染就会挂掉。
解决方案 A:检查 API 存在性
useEffect(() => {
// 检查浏览器是否有这个 API
if (typeof ResizeObserver !== 'undefined') {
observerRef.current = new ResizeObserver((entries) => {
console.log('Size changed');
});
if (listRef.current) {
observerRef.current.observe(listRef.current);
}
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
解决方案 B:使用第三方库
React 社区有很多聪明的家伙已经帮你处理了这个问题。比如 react-intersection-observer,它内部就封装了 IntersectionObserver 的安全访问。
但是,当你使用第三方 UI 库(如 react-window,react-select)时,问题会变得复杂。
这些库内部可能直接使用了 window.innerWidth 或 document.body。如果它们没有做安全检查,你一旦在服务端渲染使用了它们,整个应用就会崩盘。
如何排查第三方库导致的崩溃?
这是最让人头疼的。当你看到“window is not defined”报错,但你的代码里明明没写 window 时,你要知道:一定有一个第三方库写了它。
- 查看堆栈信息: 找到报错的第一行,不要看 React 的代码,看是哪个 npm 包触发的。
- 隔离测试: 在组件里注释掉那个库的引入,看看报错是否消失。
- 检查文档: 看看该库是否支持 SSR。如果不支持,就不要在服务端渲染的组件里使用它。
第六部分:global vs window —— Node.js 的伪装
有时候,报错可能不是 window is not defined,而是 ReferenceError: global is not defined。
这是因为在 Node.js 中,全局变量是 global,而在浏览器中是 window。有些老旧的代码或者某些构建配置(比如 Webpack 的 DefinePlugin),可能会把 global 挂载到 window 上,或者反之。
如果你在代码里直接写了 global.innerWidth,在浏览器里可能没问题(因为浏览器把 window 的属性也挂到了 global 上),但在 Node.js 环境下可能会报错。
最佳实践:永远不要直接使用 global 或 window。
如果需要访问全局变量,请使用 globalThis。这是 ES2020 引入的标准化 API,它在浏览器中是 window,在 Node.js 中是 global,在 Web Workers 中是 self。
// 现代且安全的做法
const width = globalThis.innerWidth;
虽然 globalThis 的兼容性在现代浏览器中已经很好了,但在非常老的 Node.js 版本(v12 以下)中可能不支持。但通常情况下,它比直接写 window 或 global 要安全得多。
第七部分:进阶策略——模块化与包装器
如果你在一个大型项目中,到处都是 if (typeof window !== 'undefined'),那你的代码就彻底废了。
我们需要一种更优雅的策略:封装。
我们可以创建一个通用的工具文件,用来处理所有与 DOM 相关的操作。
1. 创建一个 isBrowser 检查器
// utils/dom.js
export const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
export const getWindow = () => {
return isBrowser ? window : undefined;
};
export const getDocument = () => {
return isBrowser ? document : undefined;
};
然后,在你的组件里使用它:
import { isBrowser, getWindow } from '@/utils/dom';
const MyComponent = () => {
const handleClick = () => {
if (!isBrowser) return; // 提前退出
// 这里可以放心使用 window,不需要再次检查
const win = getWindow();
win.scrollTo(0, 0);
};
return <button onClick={handleClick}>Go Top</button>;
};
2. 服务端友好的组件包装器
如果你写了一个非常复杂的客户端组件(比如包含大量 Canvas 绘图、WebGL 或者复杂的 ResizeObserver 逻辑),并且你确定它无法在服务端运行,你可以给它加个“安检门”。
// components/ClientOnly.js
import React, { useEffect, useState } from 'react';
const ClientOnly = ({ children }) => {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null; // 或者返回一个 Loading Spinner
}
return children;
};
// 使用
export default () => (
<ClientOnly>
<HeavyChartComponent />
</ClientOnly>
);
这种模式非常实用。它告诉 React:“兄弟,这玩意儿只有浏览器才搞得定,服务器别碰它。”
第八部分:环境变量——构建时的秘密武器
除了运行时检查,我们还可以在构建时解决问题。
在 Next.js 或其他支持 SSR 的框架中,你可以通过环境变量来区分环境。
// .env.local (本地开发)
NEXT_PUBLIC_IS_BROWSER=true
// .env.production (生产环境)
NEXT_PUBLIC_IS_BROWSER=true
实际上,Next.js 会自动注入 window 对象。如果你在 .env 里定义了 window.innerWidth,Next.js 在构建时会直接把这段代码替换成具体的数字(比如 1000)。
但是,如果你在代码里动态使用了 window,比如 window.innerWidth,Next.js 就无法静态优化它。它必须在运行时检查。
所以,对于不需要 SSR 的页面,你可以直接禁用 SSR。
// pages/index.js
export default function Index() {
return <div>Client Side Rendered Only</div>;
}
// 添加此行
export const config = {
runtime: 'edge', // 或者 'experimental-edge',或者直接不导出 config 默认为 client
};
或者,在 Next.js 13+ 的 App Router 中:
// app/page.js
export const dynamic = 'force-dynamic'; // 强制客户端渲染,不进行 SSR
这就像是你不想让厨师做这道菜,你就直接告诉服务员:“这道菜不要在厨房做,端上来的时候直接给我。”
第九部分:总结与心态建设
好了,同学们,我们已经讲了这么多。
React 混合渲染,本质上是在服务端的“无头”环境和客户端的“全功能”环境之间架起一座桥梁。
处理 window 和 document 的安全访问,其实就是管理代码的执行环境。
核心原则回顾:
- 永远不要假设
window存在: 除非你 100% 确定这段代码只在浏览器跑。 useEffect是你的好朋友: 它是 React 官方为你提供的“浏览器环境安全网”。- 警惕
useLayoutEffect: 它在服务端也会跑,除非你用useIsomorphicLayoutEffect包裹。 - 第三方库是定时炸弹: 使用它们时要小心,或者用
ClientOnly包裹。 - 封装优于重复: 写一个
isBrowser工具函数,而不是到处写if。
最后,给一点建议:
当你遇到 window is not defined 时,不要慌。深呼吸,打开控制台,看看是谁干的。是某个你熟悉的库?还是你自己不小心写的一行代码?
混合渲染虽然难,但一旦你掌握了这个技巧,你就掌握了全栈开发的钥匙。你可以写出加载速度极快、SEO 极佳、交互体验又好的应用。这就像学会了开车,虽然路上有坑(window is not defined),但风景(用户体验)是值得的。
好了,今天的讲座就到这里。希望大家在未来的项目中,都能写出既能在服务器跑,又能在浏览器跑的优雅代码。
下课!
(讲师擦了擦额头的汗,心想:幸好这段文字是在本地生成的,没有真的在 Node.js 环境里运行。)