App Router 是 Next.js 13 以来最大的架构变化,我们在近两年交付的十几个企业官网与业务系统中,几乎每个项目都会踩到同类问题。这篇文章把最高频的三类坑——Hydration 报错、动态路由参数、缓存策略——逐一拆解,全部来自真实项目。

一、Hydration 错误:三大根因与修复

控制台出现 Hydration failed because the server rendered HTML didn't match the client,本质是"服务端渲染的 HTML"与"客户端首次渲染的 HTML"不一致。常见根因只有三种:

1. 渲染逻辑中使用了仅浏览器才有的数据

典型写法:typeof window !== 'undefined' && window.innerWidth > 768 && ... 直接写在 JSX 条件里。服务端永远是 false,客户端可能是 true,首帧必炸。

修复原则:动态数据必须经过 useEffect + useState 两段式,保证服务端与客户端首帧输出一致:

'use client';
import { useEffect, useState } from 'react';

export function DesktopOnly({ children }: { children: React.ReactNode }) {
  const [isDesktop, setIsDesktop] = useState(false); // 首帧与服务端一致
  useEffect(() => {
    setIsDesktop(window.innerWidth >= 1024); // 挂载后才写真实值
  }, []);
  return isDesktop ? <>{children}</> : null;
}

同理,Date.now()、Math.random()、crypto.randomUUID() 出现在渲染路径都会触发该问题。倒计时类组件务必把目标时间作为 props 传入,客户端 effect 中再计算差值。

2. 非法 HTML 嵌套被浏览器"自动纠正"

<p> 里嵌 <div>、<a> 嵌 <a>,浏览器解析时会拆开标签,导致客户端树与服务端 HTML 对不上。这类报错的提示文本往往不直接指出嵌套问题,非常隐蔽。

修复原则:块级内容一律不要放进 <p>;列表项里放块级元素用 <div> 包裹而不是 <p>。

3. 浏览器插件污染 DOM

划词翻译、暗色模式插件会往 <body> 里注入节点。开发时可以用 <body suppressHydrationWarning>,但要明确:它只屏蔽当前节点一层,不是万能开关。

二、动态路由参数:Next.js 15 起是 Promise

从 Next.js 15 开始,params 与 searchParams 变成了异步对象,同步解构会拿到 Promise,运行时所有字段都是 undefined:

// ❌ Next.js 14 及以前的写法,在 15+ 中拿不到值
export default async function Page({ params }: { params: { id: string } }) {
  const { id } = params;
}

// ✅ 正确:await 解包
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
}

升级框架版本时,这一条会让所有动态路由页面静默失效(不报错但数据为空),务必全局检索 params 用法。客户端组件里则用 useParams(),它始终同步。

三、缓存策略:fetch 的四档语义必须分清

App Router 的 fetch 默认不再是"无缓存",语义由参数决定,我们总结成一张表:

写法行为适用场景
fetch(url)构建期或首访时缓存几乎不变的数据(配置、菜单)
fetch(url, { cache: 'no-store' })每次请求都透传后台管理列表、实时库存
fetch(url, { next: { revalidate: 60 } })60 秒后台再生成新闻列表、商品页等准实时内容
fetch(url, { next: { tags: ['news'] } })按标签手动失效后台改完数据立即刷新前台

后台"明明改了数据前台不更新",九成是第一档误用。配合服务端操作失效缓存:

import { revalidateTag } from 'next/cache';

// 后台保存新闻成功后调用
revalidateTag('news');

四、流式渲染:用 Suspense 拆分快慢内容

页面里混着"秒出的静态框架"和"要查库的动态区块"时,不要让整页等待慢查询。把慢区块拆成异步子组件并包一层 Suspense:

export default function NewsPage() {
  return (
    <>
      <SiteHeader /> {/* 静态,立即返回 */}
      <Suspense fallback={<NewsSkeleton />}>
        <AsyncNewsList /> {/* 内部 await fetch,独立流式注入 */}
      </Suspense>
    </>
  );
}

用户先看到骨架屏框架,数据块到达后原地填充,TTFB 与 FCP 同时受益。这一招在我们项目中普遍把首屏可交互时间提前 30% 以上。

小结

App Router 的心智模型可以压缩成三句话:服务端是唯一渲染真相、异步对象要 await、缓存显式声明。把这三条内化后,绝大多数迁移问题都能在写代码阶段被预判。