一、先聊两句:为什么SSR老是在开发模式里整幺蛾子
如果你写过带服务端渲染的项目,大概率遇到过这样的场景:本地开发环境跑得好好的,页面一刷新,偶发一个报错,注释掉某行代码又好了,再改回来又报错。更离谱的是,同样的代码,放到线上构建却一切正常。这种"开发模式特有的邪门问题",往往不是你的业务逻辑写错了,而是开发模式下的渲染链路和客户端差异被放大了。咱们今天不扯太深的理论,就从一个实际案例出发,看看服务器端渲染和 Turbopack 配合时,那些让人抓狂的环境差异到底从哪来。
先说一个核心感觉:SSR 报错之所以"邪门",是因为你的代码在开发模式下被不同环境执行了两次——一次在服务器进程里,一次在浏览器里。而 Turbopack 作为底层的打包器,在开发模式下会做很多缓存、热更新、按需编译的事情,这些机制和 Node.js 的服务端渲染结合在一起,就会产生一些莫名其妙的"环境割裂"问题。
二、先把场景搭出来:一个最简单的 SSR + Turbopack 应用
为了让大家有体感,我先展示一个极简的 React + Turbopack 服务端渲染项目。这里统一技术栈选用 React + Turbopack(Next.js 的底层打包器),但咱们不会直接用 Next.js 的脚手架,而是手动搭一个最精简的 SSR 服务,这样可以更清楚地看到问题根源。
2.1 项目结构
my-ssr-app/
├── package.json
├── server.js
├── turbopack.config.js
└── src/
├── App.jsx
└── entry-server.jsx
2.2 package.json
{
"name": "my-ssr-app",
"scripts": {
"dev": "node server.js"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@turbopack/core": "^0.1.0",
"@turbopack/node": "^0.1.0",
"express": "^4.19.2"
}
}
2.3 一个带"坑"的组件
为了复现典型问题,我故意在组件里使用了一个只在浏览器里存在的全局对象 window。很多新手会直接写进渲染函数里,这在 SSR 时就会崩。但我这里不是想讲这个老生常谈,而是想模拟一个更隐蔽的场景:window 是否存在的判断依赖了某个环境变量,而这个环境变量在开发模式下被 Turbopack 做了替换。
// src/App.jsx
import React from 'react';
// 这个条件判断在服务器和浏览器里可能得到不一样的结果
// 因为 Turbopack 在开发模式下会按模块的方式注入一些全局定义
function getInitialState() {
if (typeof window !== 'undefined' && window.__INITIAL_DATA__) {
// 浏览器环境下,读 window 上的数据
return window.__INITIAL_DATA__;
}
// 服务器环境下,返回默认数据
return { theme: 'light', userAgent: 'server' };
}
export default function App() {
const state = getInitialState();
return (
<div>
<h1>当前主题:{state.theme}</h1>
<p>标识:{state.userAgent}</p>
</div>
);
}
大家注意,这段代码本身没有明显的语法错误,逻辑也很常见。但它在开发模式下的 SSR 渲染时,有可能报出一个让人摸不着头脑的错误:window is not defined。奇怪的是,如果你直接在 Node.js 里跑同样的代码,typeof window 会安全返回 'undefined',不会报错。那问题出在哪?这就牵扯到 Turbopack 的模块编译以及 window 被当成裸标识符处理的可能性。
2.4 服务端入口
// src/entry-server.jsx
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';
export function render() {
return renderToString(<App />);
}
2.5 服务器和 Turbopack 配置
服务器这边,我用 Express 搭一个简单服务,请求任何路径时,触发 Turbopack 编译服务端模块,然后执行渲染。
// server.js
const express = require('express');
const path = require('path');
const { createServerPipeline } = require('@turbopack/node');
const app = express();
// 注意:开发模式下,我们不想每次请求都重新编译,
// 所以 Turbopack 会维护一个长期运行的编译缓存。
const pipeline = createServerPipeline({
configFile: path.join(__dirname, 'turbopack.config.js'),
mode: 'development',
});
app.get('*', async (req, res) => {
try {
// 通过 Turbopack 加载并执行服务端入口模块
const module = await pipeline.loadEntry('./src/entry-server.jsx');
const html = module.render();
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
</body>
</html>
`);
} catch (err) {
console.error('SSR 渲染出错:', err);
res.status(500).send('服务端渲染失败');
}
});
app.listen(3000, () => {
console.log('开发服务器已启动:http://localhost:3000');
});
这个示例虽然代码量不多,但足够用来复现开发模式下的环境差异问题。接下来,我们一步步把"邪门"的现象拆开看。
三、Turbopack 在开发模式下到底做了什么"手脚"
很多同学一听到 Turbopack 就想起"快",但它的快不仅仅是编译快,更重要的是它有一套"按需编译 + 模块级缓存"的机制。这套机制在生产构建时是没问题的,但在开发模式下,它默认会把 Node.js 环境当作一种"平台",同时又把浏览器环境当作另外一种"平台"。你的代码在服务端 SSR 时,Turbopack 会把源码转成 Node.js 能执行的 CommonJS 或 ESM 模块,并且会注入一些环境相关的辅助代码。
3.1 开发模式下的模块缓存
Turbopack 会为每个被加载过的模块保留一份编译结果,存在内存里。这个缓存本身是为了提速,但有一个副作用:如果你的代码在模块顶层执行了一些副作用操作(比如读取环境变量、检测 fs 模块、监听事件),那么在第一次被加载时执行一次,后面就不会再执行了。而在 SSR 场景下,每次用户请求其实期望拿到新的渲染结果,可模块却是被复用的。
举个典型案例:
// src/db.js
// 这个模块在顶层创建数据库连接
const dbConnection = createConnection(process.env.DATABASE_URL);
export function query(sql) {
return dbConnection.run(sql);
}
如果 DATABASE_URL 在开发过程中被修改了,或者某个测试用例临时改了 process.env,由于 Turbopack 的模块缓存,dbConnection 仍然指向旧连接。这个问题在纯客户端开发中不会被察觉,因为客户端没有长期运行的 Node.js 进程;但在 SSR 服务里,这个进程是常驻的,于是就会出现"代码改了,行为没变"的诡异现象。
3.2 环境变量和全局变量的替换
Turbopack 为了支持浏览器端的 tree shaking 和死代码消除,会在编译时内联某些环境变量。比如你写 process.env.NODE_ENV,Turbopack 会把它替换成 "development"。但问题来了,在 SSR 的 Node.js 执行环境里,process.env 是真实的运行时对象,如果 Turbopack 把某些自定义变量也做了替换,那就可能造成服务端拿到的值跟代码字面上看到的不一致。
看这个例子:
// src/config.js
export const API_BASE = process.env.API_BASE_URL || 'http://localhost:8080';
如果一个开发者忘了在 .env 文件里定义 API_BASE_URL,而 Turbopack 恰好定义了一个空的默认值,那么客户端构建时可能得到空字符串,而服务端运行时得到的是 'http://localhost:8080'。于是 SSR 出来的页面和后端 API 请求的地址就不统一,报错信息自然千奇百怪。
3.3 模块解析的差异
在浏览器环境中,Turbopack 会优先按浏览器兼容的字段(例如 browser、module)去解析 package.json。而在 Node.js 环境,会按 main、exports 等字段解析。如果某个第三方库针对浏览器和服务端导出了不同的实现,那么在 SSR 时加载到的模块可能不是你在开发时预料的那一份。
举个例子,一个叫 cool-lib 的包,它的 package.json 是:
{
"name": "cool-lib",
"main": "index.node.js",
"browser": "index.browser.js"
}
Turbopack 在服务端加载时使用 index.node.js,在客户端加载时使用 index.browser.js。如果 index.node.js 里引用了 Node.js 内置模块 fs,而你在 SSR 组件里调用了它,整体行为是正常的;但如果你不小心在浏览器环境相关代码里 import 了 cool-lib 并被 SSR 引用,那么服务端就会加载一个包含 Node API 的文件,从而让一些纯浏览器代码意外执行了 Node 特有的操作。
这种差异在纯粹的客户端项目中感觉不到,因为整个代码库都按浏览器平台解析。但 SSR 是"一套代码,两个平台",Turbopack 虽然聪明,却无法保证所有依赖都完美适配两种平台的语义。于是,报错就变得"邪门"起来。
四、环境差异的四个具体根源
下面我按个人经验,把最常见的四个根源单独拎出来,每一条都配上可复现的场景和解决思路。
4.1 根源一:全局对象不存在
这是最经典的问题。浏览器里有 window、document、navigator,Node.js 里没有。你在组件顶层直接判断 if (window),在 SSR 时就会抛 ReferenceError: window is not defined。但为什么有些开发者发现自己的代码明明写了 typeof window === 'undefined' 也报错?这就很邪门了。
原因在于:某些代码被工具或库转换后,把 typeof window 变成了 window 的直接访问。比如你用了一些 babel 插件或者 Turbopack 的某些转换逻辑,可能把条件判断优化掉了。另外,如果你在模块加载之前,某个全局变量被 webpack 或 Turbopack 的 polyfill 注入为 undefined,但类型却是对象,那么 typeof window === 'undefined' 会返回 false,而 window.xxx 仍然会抛错,因为 window 实际上不存在。
看个具体例子:
// 这段代码看似安全,但在某些 Turbopack 版本中会报错
if (window !== undefined) {
// 这里如果 window 没有被定义,上面条件判断本身就抛错
}
要正确判断,必须用 typeof window !== 'undefined' 或者在模块顶层通过 globalThis 安全访问。globalThis 在 Node.js 和浏览器中都存在,可以在 SSR 中作为安全桥梁。
// 安全的写法
const isBrowser = typeof globalThis.window !== 'undefined';
if (isBrowser) {
// 此时可以安全访问 window
}
4.2 根源二:时间/随机数/无序遍历
服务端渲染和客户端渲染的另一个天然差异,是时间、随机数、对象属性的遍历顺序。开发模式下,Turbopack 会对模块做懒编译,导致某些代码的执行顺序和线上构建不同。比如你在组件里用了 Math.random() 作为 key,服务端渲染一次,客户端再渲染一次,结果两边生成的 DOM 不一样,React 在比较时就会报警告甚至报错。
不过这种错误通常在控制台是 "Hydration failed" 之类的提示,真正让人头大的是它不稳定:刷新一次能过,刷新一次过不了,因为随机数变了。这类问题不是 Turbopack 独有的,但它会加剧,因为在开发模式下 Turbopack 的热更新会让模块状态残留,导致随机数生成器好像在"同一个世界"里重复工作。
解决思路很简单:给服务端和客户端提供一致的确定性数据源。比如使用 Date.now() 做时间戳,但要把时间戳通过 props 传递给组件,而不是在渲染过程中直接调用。另外,对象遍历顺序在 JavaScript 里虽然有规范,但数字键的遍历顺序和字符串键不一样,服务端和客户端如果生成的对象结构不同,也可能导致渲染偏差。建议不要依赖遍历顺序。
4.3 根源三:polyfill 行为不一致
有些浏览器 API 在 Node.js 中也有实现,但实现方式不一样。比如 fetch,Node.js 18 以后原生支持了 fetch,但它的行为(比如对 Set-Cookie 的处理)跟浏览器中的 fetch 有细微差别。Turbopack 在开发模式下可能为服务端和客户端注入不同的 polyfill 策略。如果你在 SSR 里发了一个请求,服务端拿到的是 Node.js 的响应对象,而客户端重新渲染时用的是浏览器里的 fetch,这两者的 header 大小写、重定向处理可能不同,于是出现"服务端渲染的 HTML 里包含的数据",和"客户端补齐后的数据"对不上,报错就是各种奇怪的 Hydration failed。
更恶心的例子是 crypto 对象。在浏览器里,crypto.getRandomValues 是异步加密安全的;在 Node.js 里,crypto 是全局对象,但它的方法来自 OpenSSL,API 和浏览器不完全一致。Turbopack 在服务端编译时,如果检测到代码里用了 crypto,它可能会保留 Node.js 的全局 crypto,而不会像客户端那样去 polyfill。结果就是,你在同构代码里写 crypto.randomUUID(),两边都能跑,但生成的 UUID 格式或抛错时机可能不同。
解决办法就是:避免在渲染生命周期中直接使用平台差异大的 API。如果必须用,把它放在 useEffect 或服务端的事件处理函数里,而不是在 render 期间调用。
4.4 根源四:异步数据与缓存失效
SSR 最常见的流程是:服务器在渲染前先去请求数据,然后把数据嵌入 HTML,客户端拿到后直接复用。在开发模式下,Turbopack 会监控文件变化,如果你改了代码,它会重新编译,但已经被编译过的模块缓存仍然存在。此时你启动了一个新的请求,期望拿到新代码,但 Turbopack 的某个依赖模块还是旧的,于是渲染出来的数据和新代码不匹配。
我的一个朋友曾经遇到这样一个问题:他在一个接口里改了返回值,从 { ok: true } 改成 { ok: false },但 SSR 页面始终显示 true。他以为是浏览器缓存,清了缓存没用;后来发现是服务端内存里保留了旧模块。这种问题特别容易在开发模式中出现,因为 Turbopack 默认对文件变化是增量更新的,但如果有模块被 Node.js 的原生 require.cache 捕获了,Turbopack 没法完全控制。
Turbopack 提供了强制失效缓存的方法,比如重启开发服务器,或者在代码里加入版本号。但更推荐的做法是:在开发模式下,主动把关键的全局数据存到一个统一的地方,并且每次请求都重新读取,而不是依赖模块顶层的单例对象。
五、用一套统一的排查方法论来"驱魔"
遇到 SSR 报错,先不要慌,不要乱试代码。我总结了一个实用的排查顺序。
5.1 第一步:区分客户端报错还是服务端报错
在开发模式的控制台里,如果报错信息同时出现在终端和浏览器 DevTools 里,那很可能是同构代码在两边都执行出了问题。如果只有终端报错,那是服务端独有的问题;如果只有浏览器控制台报错,那是客户端水合或事件绑定问题。
一个快捷技巧:在组件里加入临时的判断,输出当前环境:
// 临时调试代码
const env = typeof window === 'undefined' ? 'server' : 'client';
console.log(`[渲染环境] ${env}`);
看看控制台,确认哪边先崩。
5.2 第二步:检查代码顶层作用域的副作用
很多 SSR 报错都源于模块顶层的代码。每当你看到 "Cannot read properties of undefined" 或 "window is not defined",优先检查你 import 的每个模块的顶层有没有访问 document、window、navigator 等浏览器对象,或者有没有调用 process.cwd()、require('fs') 等 Node 特有 API。
// 错误的顶层写法
const theme = window.localStorage.getItem('theme'); // SSR 必崩
// 安全的顶层写法(延迟到函数内访问)
function getTheme() {
if (typeof window === 'undefined') return 'light';
return window.localStorage.getItem('theme');
}
5.3 第三步:检查热更新残留缓存
如果你改完代码后依然跑出旧结果,最先怀疑的就是 Turbopack 的缓存。试试在终端里执行:
# 删除 Turbopack 的缓存目录(如果有)
rm -rf .turbopack-cache
# 或者直接重启开发服务器
kill -9 $(lsof -t -i:3000)
npm run dev
这个方法虽然粗暴,但能解决大约一半的"邪门"问题。当然更好的办法是理解模块缓存机制,在开发时不要依赖容易被缓存的副作用。
5.4 第四步:让 Turbopack 的构建配置变得透明
Turbopack 默认会把环境变量和平台相关的东西自动处理,但我们可以主动关掉一些过度优化,让行为更可控。比如在 turbopack.config.js 里显式指定环境变量是否内联。
// turbopack.config.js
module.exports = {
mode: 'development',
// 让 process.env 在服务端保持真实的运行时行为
env: {
include: ['API_BASE_URL'], // 只内联这个变量,其他保留原始 process.env
},
// 关闭对某些 Node 原生模块的 polyfill,避免混淆
server: {
externalModules: ['fs', 'path', 'http'],
},
};
这个配置的意思是,告诉 Turbopack:服务端环境中,fs、path、http 这些模块不要做浏览器端替换,直接用 Node.js 的。这样你就避免了第三方库在 SSR 时错误地加载了浏览器版本。
六、一个完整的复现与修复示例
为了让你彻底理解,我结合上面的原因,给出一个完整的"有病"示例,再给出修复后的版本。这个示例技术栈依然是 React + Turbopack。我们故意让它出现一个"客户端正常、SSR 间歇性报错"的问题。
6.1 有问题的代码
// src/App.jsx
import React from 'react';
// 在模块顶层调用了 navigator,这在 SSR 时直接崩溃
const userAgent = navigator.userAgent;
function getRandomId() {
// 每次渲染都生成随机数,导致服务端和客户端 HTML 不一致
return Math.floor(Math.random() * 100000).toString();
}
export default function App() {
return (
<div>
<p>你的浏览器:{userAgent}</p>
<p>本次随机ID:{getRandomId()}</p>
</div>
);
}
运行 npm run dev,打开浏览器,你会看到终端出现 ReferenceError: navigator is not defined。即使你生成随机ID的报错被 React 水合警告覆盖,也会出现 "Text content does not match server-rendered HTML"。
6.2 修复后的代码
我们修复所有问题:
// src/App.jsx
import React from 'react';
// 安全地获取 userAgent,不在顶层执行
function getUserAgent() {
if (typeof navigator !== 'undefined') {
return navigator.userAgent;
}
return 'server-unknown';
}
// 使用固定的种子值,保证服务端和客户端第一次渲染一致
function getStableId() {
// 在服务端渲染时,这个值由全局注入;在客户端使用时,读取 window 上的数据
if (typeof window !== 'undefined' && window.__STABLE_ID__) {
return window.__STABLE_ID__;
}
return 'server-id';
}
export default function App() {
const userAgent = getUserAgent();
const stableId = getStableId();
return (
<div>
<p>你的浏览器:{userAgent}</p>
<p>稳定ID:{stableId}</p>
</div>
);
}
然后在服务端入口里,为组件渲染提供一个稳定的 ID:
// src/entry-server.jsx
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';
export function render() {
// 服务端渲染时,向全局对象注入一个固定值。
// 这里用 globalThis 而不是 window,因为 Node.js 环境也有 globalThis。
globalThis.__STABLE_ID__ = 'server-generated-id';
return renderToString(<App />);
}
在客户端入口(假如你也有)负责把服务端的稳定 ID 注入到 window,这样两边就能对上:
// src/entry-client.js
// 此文件在浏览器中执行
window.__STABLE_ID__ = document.getElementById('root').dataset.stableId;
当然,更优雅的做法是组件完全使用 props,由外层传入数据,这里为了演示环境差异的解决思路,就采用全局变量注入的方式。修复后,SSR 不再报错,页面也能正常水合。
七、应用场景与好坏的权衡
7.1 什么场景最需要用 SSR + Turbopack
内容型站点、电商详情页、博客、文档站,这类对首次内容加载速度和 SEO 敏感的应用,特别适合 SSR。Turbopack 作为新一代打包器,在开发模式下的启动速度和热更新速度远快于传统 Webpack,所以在大型项目里非常受欢迎。但如果你只是一个内部管理系统,不要求 SEO,也不在意首屏体验,那么纯客户端渲染加 Turbopack 开发环境会更省心,不需要考虑服务器端的各种环境差异。
7.2 优点
- SSR 对首屏渲染友好,用户不用等所有 JS 下载完就能看到页面内容。
- 对 SEO 极其重要,搜索引擎能直接读到 HTML 中的正文。
- 配合 Turbopack 的快速编译,开发调试效率明显提升,尤其是改动组件后的热更新。
7.3 缺点
- 服务器端和浏览器端环境不一致导致问题难排查。
- 服务器需要承担额外的渲染开销,高并发下压力大。
- 缓存机制复杂,模块缓存、HTTP 缓存、浏览器缓存三层叠加,出错时很难定位。
- Turbopack 目前生态相对较新,有些三方库还没有针对性地适配,可能与 Node.js 平台有细微行为差异。
7.4 注意事项
- 尽量避免在模块顶层访问平台特定 API。
- 尽量把组件的渲染数据从入口处注入,用 props 传给组件,避免在渲染过程中动态生成随机值。
- 开发模式下,遇到奇怪问题先重启服务器,再删缓存。
- 多看看 Turbopack 的编译日志,里面可能会有模块路径和解析字段的提示,能帮你快速发现平台差异。
- 如果某个第三方库出现 SSR 兼容问题,可以尝试在服务端配置中把该库标记为 external,让 Node.js 直接加载原始模块。
八、总结:SRR 和 Turbopack 搭配的正确姿势
服务器端渲染报错之所以"邪门",是因为你在开发模式下实际上维护着两个世界:一个在 Node.js 进程中,一个在浏览器标签页里。Turbopack 把两台世界的编译逻辑整合到了一套流程中,但世界的底层协议仍然是不同的。你要做的,不是在它报错时抱怨"为什么这么蠢",而是理解这些差异的根源,然后用统一的约定去规避它们。
记住,代码的编写方式决定了你的调试体验。尽量让模块保持"无副作用"状态,尽量把动态数据放到请求级或组件状态中,尽量不要依赖全局变量以及平台特有的对象。如果你能做到这几点,那么无论是 Turbopack 还是其他打包器,SSR 报错都会变得不那么邪门,甚至完全可以预测。最后,如果实在被某个问题卡住,不要犹豫,重启开发服务器,或者把这个页面单独抽出来用纯 Node.js 跑一遍,通常很快就能定位到是环境差异还是真实逻辑问题。搞开发的路上,多踩坑不是坏事,但踩进去之后能爬出来,并且写下笔记,才算真正成长。
评论
围绕“服务器端渲染与Turbopack的配合:开发模式下SSR报错为何比客户端更邪门?深入排查环境差异根源”参与讨论