useSearchParams
该 hook 只能在客户端组件中使用,且需要包裹在 Suspense 中,否则 build 会产生警告。
Middleware set coockie
由于浏览器安全策略,middleware 中 cookie-set 操作,需要在 https、域名 下才能正常执行。
setCookie.options 配置 httpOnly、secure 为 false,可以规避该问题,但是存在安全风险。
除了本地开发环境,localhost 域被认作为安全域。
/* 检查 sessionId 是否存在 并存储 cookie */
const AuthCenterMiddleware = (req: NextRequest) => {
const { searchParams } = req.nextUrl;
const sessionId = searchParams.get(SESSION_KEY_IN_URL);
/* 如果 URL 带 sessionId,则存 Cookie */
if (sessionId) {
const url = req.nextUrl;
/* 删除 sessionId 参数 */
url.searchParams.delete(SESSION_KEY_IN_URL);
const res = NextResponse.redirect(url);
res.cookies.set(SESSION_KEY_IN_COOKIE, sessionId, {
httpOnly: false,
secure: false,
path: '/',
sameSite: 'lax',
expires: new Date(Date.now() + 1000 * 60 * 60 * 6), // 6 hours
// expires: new Date(Date.now() + 1000 * 10),
});
return res;
}
return NextResponse.next();
};fetcher 封装
fetcher.ts
interceptor.ts
import {
DownloadPostOptions,
interceptorsRequest,
interceptorsResponse,
parseFilenameFromHeaders,
RequestOptions,
RequestProps,
isBrowser
} from './interceptor';
import { saveAs } from 'file-saver';
/**
* 主请求函数
*/
export const request = async <T>({ url = '', params = {}, method, options }: RequestProps): Promise<T> => {
const req = interceptorsRequest({ url, method, params, options });
const res = await fetch(req.url, req.options);
return interceptorsResponse<T>(res);
};
export const get = <T>(url: string, params?: any, options?: RequestOptions) => {
return request<T>({ url, method: 'GET', params, options });
};
export const post = <T>(url: string, params?: any, options?: RequestOptions) => {
return request<T>({ url, method: 'POST', params, options });
};
export const put = <T>(url: string, params?: any, options?: RequestOptions) => {
return request<T>({ url, method: 'PUT', params, options });
};
export const del = <T>(url: string, params?: any, options?: RequestOptions) => {
return request<T>({ url, method: 'DELETE', params, options });
};
export const patch = <T>(url: string, params?: any, options?: RequestOptions) => {
return request<T>({ url, method: 'PATCH', params, options });
};
export const download = async (url: string, params?: any, options?: DownloadPostOptions): Promise<void> => {
if (!isBrowser) throw new Error('downloadPost 只能在浏览器环境中使用');
const { method = 'POST', filename: defaultFilename = 'download' } = options || {};
const req = interceptorsRequest({ url, method, params, options });
const res = await fetch(req.url, req.options);
// 检查响应状态
if (!res.ok) {
const text = await res.text();
try {
const errorData = JSON.parse(text);
throw new Error(errorData?.message || errorData || '下载失败');
} catch {
throw new Error(text || '下载失败');
}
}
// 获取 blob
const blob = await res.blob();
// 解析文件名
const filename = parseFilenameFromHeaders(res.headers, defaultFilename);
// 使用 file-saver 下载文件
saveAs(blob, filename);
};zustand localstorage 持久化
'use client' 不会阻止 SSR,Next.js 仍会在服务端渲染一次。
当状态依赖 localStorage、sessionStorage 或浏览器 API 时,服务端和客户端初始状态可能不同。
使用 persist 中间件,会自动处理 ssr 与 hydration 问题。
import { UserInfo } from '@/services/type';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface AppStore {
user: UserInfo | null;
}
export const useApp = create<AppStore>()(
persist(
(set) => ({
user: null,
}),
{
name: 'app-storage', // localStorage key
// persist 会自动处理 SSR,只在客户端读取
}
)
);
const set = useApp.setState;
export const appActions = {
setUser: (user: UserInfo) => {
set({ user });
},
clearUser: () => {
set({ user: null });
},
};服务端渲染(SSR)阶段:
1. Next.js 服务端开始渲染页面
↓
2. 解析 layout.tsx,发现需要渲染 <Header />
↓
3. 加载 header.tsx 模块
↓
4. header.tsx 导入 useApp: import { useApp } from '@/stores/useApp'
↓
5. 首次加载 useApp.ts 模块
↓
6. 执行 create() → store 在服务端创建
↓
7. persist middleware 执行 hydrate()
↓
8. 检查 storage → typeof window === 'undefined' → 提前返回
↓
9. hasHydrated = false, user = null
↓
10. 渲染 HTML,发送到客户端客户端 Hydration 阶段:
1. 浏览器接收 HTML,开始 hydration
↓
2. React 加载客户端 bundle
↓
3. 再次加载 header.tsx 模块(客户端版本)
↓
4. 再次导入 useApp(客户端模块)
↓
5. 再次执行 create() → store 在客户端创建(新的实例)
↓
6. persist middleware 执行 hydrate()
↓
7. 检查 storage → window 存在 → 继续执行
↓
8. 异步从 localStorage 读取数据
↓
9. 首次渲染时:hasHydrated = false, user = null
↓
10. Hydration 完成后:hasHydrated = true, user = {...}
↓
11. 触发组件重新渲染简单来说,读取 localStorage 是在水合之后执行的,这也就能保证服务端与客户端的数据一致性,也就不会导致水合失败的问题。
remove cookie
/* session key in cookie */
export const SESSION_KEY_IN_COOKIE = 'SESSIONID';
export const SESSION_KEY_IN_URL = 'sessionId';
/**
* 删除 session cookie
* 确保使用与设置时相同的属性
*/
export const deleteSessionCookie = (): void => {
if (typeof document === 'undefined') return;
// 获取当前域名
const domain = window.location.hostname;
// 删除 cookie 的多种方式,确保兼容性
const deleteOptions = [
`${SESSION_KEY_IN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; sameSite=Lax;`,
`${SESSION_KEY_IN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}; sameSite=Lax;`,
`${SESSION_KEY_IN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`, // 备用
];
deleteOptions.forEach(option => {
document.cookie = option;
});
};docker 部署脚本
dockerfile
docker-compose.yml
# ============================
# 1. Builder Stage
# ============================
FROM node:22-alpine AS builder
WORKDIR /app
# 先复制依赖文件,利用缓存机制
COPY package.json package-lock.json* ./
# 安装依赖(包含 devDependencies)
RUN npm install --registry=https://registry.npmmirror.com
# 复制所有源代码
COPY . .
# 构建 Next.js(生成 .next)
RUN npm run build
# ============================
# 2. Runner Stage(生产镜像)
# ============================
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=4000
# 拷贝依赖(dev 不需要,节省空间)
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
# 拷贝 Next.js 编译后的产物
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.js ./next.config.js
EXPOSE 4000
# Next.js 官方生产启动命令
CMD ["npm", "start"]use client 下无法获取 window 对象
app 路由的渲染机制
默认情况,所有组件都会在服务端执行一次。
- server-component 直接在服务端执行,不发送到客户端
- client-component 会经历两次执行:
- 服务端渲染(SSR):生成初始 HTML
- 客户端水合(Hydration):在浏览器中重新执行并接管交互
所以很多时候可能子组件顶层用了 use client,但还是无法获取到 window 对象。
注意:边界考虑都建在
模块树层面,而不是组件层面。
解决方案
- 服务端组件传递
- 纯客户端组件
useEffect中获取 window 对象,suppressHydrationWarning避免水合不匹配- 使用 dynamic import, 禁用服务端渲染
useEffect
const [origin, setOrigin] = useState<string>('');
useEffect(() => {
if (typeof window !== 'undefined') {
setOrigin(window.location.origin);
}
}, []);
// ... existing code ...
<a
href={origin ? `/oauth2-login?redirectUrl=${origin}/user-center` : '/oauth2-login'}
{/* 避免水合不匹配 */}
suppressHydrationWarning
>
登录
</a>dynamic import
import dynamic from 'next/dynamic';
const Header = dynamic(() => import('./components/header'), {
ssr: false,
});