K
Next.js 踩坑指南
创建于:2025-11-24 10:00:00
|
更新于:2026-01-06 16:53:06

useSearchParams

该 hook 只能在客户端组件中使用,且需要包裹在 Suspense 中,否则 build 会产生警告。

Middleware set coockie

由于浏览器安全策略,middleware 中 cookie-set 操作,需要在 https、域名 下才能正常执行。

setCookie.options 配置 httpOnly、secure 为 false,可以规避该问题,但是存在安全风险。

除了本地开发环境,localhost 域被认作为安全域。

AuthCenterMiddleware.ts
/* 检查 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 问题。

useApp.ts
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

@/utils/cookie.ts
/* 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 路由的渲染机制

默认情况,所有组件都会在服务端执行一次。

  1. server-component 直接在服务端执行,不发送到客户端
  2. client-component 会经历两次执行:
  • 服务端渲染(SSR):生成初始 HTML
  • 客户端水合(Hydration):在浏览器中重新执行并接管交互

所以很多时候可能子组件顶层用了 use client,但还是无法获取到 window 对象。

注意:边界考虑都建在模块树层面,而不是组件层面。

解决方案

  1. 服务端组件传递
  2. 纯客户端组件
  3. useEffect 中获取 window 对象,suppressHydrationWarning 避免水合不匹配
  4. 使用 dynamic import, 禁用服务端渲染

useEffect

layout.tsx
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

layout.tsx
import dynamic from 'next/dynamic';
 
const Header = dynamic(() => import('./components/header'), {
  ssr: false,
});
我也是有底线的 🫠