刚从 Pages Router 切到 App Router 时,很多人写出来的代码看起来像 App Router,跑起来却像 Pages Router。问题往往不在语法,而在心智模型。
Pages Router 的核心抽象是「页面」:一个文件 = 一个 URL = 一次 SSR。App Router 的核心抽象是「请求树」:一次请求会触发一棵嵌套的 Server Component 树,layout 包 layout、layout 包 page。
「页面」是结果,「请求树」是过程
在 Pages 里你想着「这个 URL 渲染什么」。在 App Router 里你要想「这次请求要爬过哪几层 layout,每一层各拿哪些数据」。
举个例子:`/blog/[slug]` 这条路由,会先调用根 `layout.tsx` 取站点数据、再调用 `/blog/layout.tsx` 取专栏数据、最后才到 `page.tsx` 取文章本身。三层各自独立 fetch,互不阻塞。
Pages Router 里你必须把这三层 fetch 写在一个 `getServerSideProps` 里,靠手动拆开。App Router 把它写进了文件结构。
文件夹不是组织方式,是请求经过的层级。
Server Component 默认在服务端只跑一次
Server Component 在服务端运行,渲染后只把序列化的结果发到浏览器。浏览器拿到的不是 JSX 而是一种叫做 RSC payload 的中间格式。
因为它不去浏览器执行,所以它能直接 `await db.query(...)`、能读环境变量、能用 Node API。也因为它不去浏览器执行,所以你不能在它里面写 `useState` 或 `onClick`。这条界线很清楚,但它经常被违反——通常发生在「我只是想加个按钮」的时候。
需要交互的那一小块,单独抽成 Client Component。它会被发到浏览器,但只要保持小,整页的体积就不会膨胀。
缓存是 App Router 最有威力也最容易踩坑的部分
默认情况下,Server Component 渲染结果会被缓存到下次部署或显式 revalidate。这对静态内容是福音,对带用户态的内容是灾难。
三个常用刹车点:路由段加 `export const dynamic = 'force-dynamic'`、`fetch(url, { cache: 'no-store' })`、或在 mutation 后调用 `revalidatePath`/`revalidateTag`。
记住一条原则:能静态就静态;只有当一个页面真的因人/因时不同时,才让它退化为动态。
迁移建议:不要逐文件迁移
想从 Pages 迁到 App,最好是按「一整条业务链路」为单位整体重写,而不是逐文件搬。前者你能借机重新设计数据加载顺序,后者只是用新语法重做了一遍旧错误。