一、Zustand在SSR项目里踩的坑
1.1 报错的根本原因
你可以把SSR(服务端渲染)理解成外卖平台的流程:服务端就像后台的中央厨房,用户(浏览器)点单后,厨房要提前把做好的饭菜(完整HTML页面)送到用户手里,而不是等用户拿到手机后再现炒现做(客户端渲染)。这时候,厨房(服务端)没有你手机里的那些东西(比如window这个全局变量),如果代码在厨房阶段就想拿手机里的信息,肯定会出错。
而Zustand是一个轻量的前端状态管理库,很多人用它做全局状态时,会在create函数里直接访问window,比如想获取当前浏览器的用户代理(UserAgent),就会写出这样的代码:
import { create } from 'zustand';
// ❌ 错误示例:Zustand store初始化时直接访问window
const useAgentStore = create((set) => ({
userAgent: window.navigator.userAgent, // 服务端跑这里时,window根本不存在,直接报错
setAgent: (ua) => set({ userAgent: ua })
}));
export default useAgentStore;
这段代码在纯客户端项目里没问题,但放到Next.js、Remix这类SSR框架里,服务端渲染时会直接抛出window is not defined的错误,整个页面都会崩掉。
二、解决问题的核心方法
2.1 先搞懂:服务端和客户端的本质区别
解决这个问题的核心,就是不让服务端碰只有客户端才有的东西。怎么判断当前是服务端还是客户端?很简单,用typeof window:服务端运行时,typeof window的值是'undefined';客户端运行时,typeof window的值是'object',利用这个判断就能避免直接访问不存在的变量。
2.2 方法一:初始化时做环境判断(简单快速)
这种方法适合需要给依赖window的状态设置默认值的场景,只需要在Zustand的create函数里,把window的访问包在环境判断里就行,代码改动量很小:
import { create } from 'zustand';
// ✅ 正确示例:初始化时做环境判断
const useAgentStore = create((set) => ({
// 服务端返回空字符串(或自定义默认值),客户端才取window的真实值
userAgent: typeof window !== 'undefined' ? window.navigator.userAgent : '',
setAgent: (ua) => set({ userAgent: ua })
}));
export default useAgentStore;
这个方法的好处是代码少,适合刚接触SSR的开发者快速修复问题,但缺点是如果服务端需要特定的UserAgent(比如做多端适配时必须是手机端的UA),这个方法返回的默认值就会不符合要求。
2.3 方法二:延迟初始化(灵活适配复杂场景)
如果服务端需要正确的UA值,或者不想在初始化时就占用客户端的资源,就用延迟初始化的方法:把依赖window的操作放到组件使用时才执行,而不是Zustand store初始化时。比如把获取UA的逻辑做成一个函数,只在组件挂载(客户端环境)后调用:
import { create } from 'zustand';
import { useState, useEffect } from 'react';
// ✅ 正确示例:延迟初始化,只在需要时才访问window
const useAgentStore = create((set) => ({
// 把获取UA的逻辑做成函数,调用时才判断环境
getUserAgent: () => {
if (typeof window === 'undefined') return '服务端环境';
return window.navigator.userAgent;
},
setAgent: (ua) => set({ userAgent: ua })
}));
// 组件中使用这个store的示例
function AgentDisplay() {
const [currentAgent, setCurrentAgent] = useState('');
// 组件挂载后(客户端环境)再调用,确保window存在
useEffect(() => {
const ua = useAgentStore.getState().getUserAgent();
setCurrentAgent(ua);
}, []);
return <div>当前运行环境:{currentAgent}</div>;
}
export default AgentDisplay;
这种方法的好处是灵活性高,服务端返回的是明确的默认值,客户端再替换成真实值,而且不会在服务端做不必要的window访问,缺点是需要在组件里额外写调用逻辑,多几行代码,但适配性更强。
三、方案对比和注意事项
3.1 两种方法的优缺点
- 环境判断法:优点是改动量极小,适合简单场景;缺点是服务端无法获取准确的window相关状态,灵活性差。
- 延迟初始化法:优点是适配复杂场景,服务端和客户端的状态可以分开处理;缺点是需要额外的组件逻辑,代码稍微多一点。
3.2 必须注意的坑
- 绝对不要在Zustand的
create函数顶级作用域(不用函数包裹的位置)直接写window.xxx,必须加环境判断; - 如果服务端需要UserAgent这类状态,最好从请求头里拿(比如Next.js里的
req.headers.get('user-agent')),不要依赖客户端传递的window值,保证服务端的可靠性; - 延迟初始化时,一定要用
useEffect而不是组件同步代码,因为组件的同步代码在服务端也会执行,容易导致额外的错误; - 不要在Zustand的store里存依赖浏览器本地存储(比如localStorage)的状态,这类状态也要延迟初始化,或者直接用服务端的状态同步。
四、总结
Zustand在SSR项目里报错的本质,是开发者没区分服务端和客户端的运行环境,不小心让服务端碰了只有客户端才有的全局变量window。解决这个问题的核心思路很简单:服务端不碰客户端特有的东西,客户端再处理需要的状态,无论是用环境判断还是延迟初始化,都符合这个核心原则。只要记住这个原则,不管是Zustand还是其他前端库,只要用到浏览器全局变量,都能快速适配SSR项目,避免踩坑。
评论
围绕“Zustand的store初始化函数里访问window导致SSR报错,通过环境判断与延迟初始化兼容服务端构建”参与讨论