官网博客那套页面之前是纯前端 React 打包出来的,SEO 一直不太行,年初把它换成了 Next.js(App Router),顺带把公司另一个后台管理页也迁了过去。踩了几个坑,写出来给以后的自己提个醒。

第一个坑是服务端组件和客户端组件的边界没搞明白,一上来把所有组件都当成之前 CRA 项目那样随便写,结果一堆报错说不能在 Server Component 里用 useStateuseEffect。App Router 默认所有组件都是 Server Component,想用 hooks、浏览器 API、事件处理这些,得显式加上 "use client"

1
2
3
4
5
6
7
8
"use client"

import { useState } from "react"

export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}

道理都懂,但真正写起来还是会不自觉地把交互逻辑和展示逻辑写在一个组件里,然后被迫整个文件加 "use client",最后发现好多本可以纯服务端渲染的东西全变成客户端组件了,等于没享受到 RSC 的好处。后来强迫自己把有状态交互的部分拆成小的叶子组件单独标 "use client",父组件保持 Server Component,数据获取尽量放在服务端做,这样打出来的 JS 包才真的小了。

第二个坑是 fetch 的缓存行为,这个是真的反直觉。Next.js 默认给 fetch 加了一层缓存,同一个请求默认会被当成静态数据处理,构建的时候就固化下来了:

1
2
3
4
async function getData() {
const res = await fetch("https://api.example.com/posts")
return res.json()
}

后台管理页面那边数据是要实时的,上线之后发现管理员改了数据,页面刷新看到的还是旧的,一开始怀疑是不是接口返回缓存头有问题,折腾了一圈才想起来是 Next.js 自己在 fetch 层面做了缓存。得显式告诉它不要缓存:

1
const res = await fetch("https://api.example.com/posts", { cache: "no-store" })

或者用 revalidate 设一个合理的重新验证时间。这个默认行为跟以前用 axios/fetch 的直觉完全不一样,之前哪见过一个普通 fetch 调用自己偷偷把结果缓存起来的,第一次遇到这个算是被 Next.js 的”聪明”坑了一把。

第三个是环境变量,NEXT_PUBLIC_ 前缀这块容易犯迷糊。没加前缀的环境变量只能在服务端代码里访问,客户端组件里读出来是 undefined,加了 NEXT_PUBLIC_ 前缀的会在构建时被打进客户端 bundle。犯的错是把一个本该是服务端专用的 API key 手滑加了 NEXT_PUBLIC_ 前缀,结果这个 key 直接被打进了浏览器能看到的 JS 文件里,虽然发现得早及时撤了,但也算是提了个醒:凡是敏感信息,命名的时候多看一眼前缀,构建产物也顺手拿浏览器开发者工具查一下 network 和 source 有没有不该出现的东西。

图片这块 next/image 用起来省心,自动做了懒加载和尺寸优化,但线上图床是自己搭的,域名没有加到 next.config.jsimages.remotePatterns 里,直接报错说这个域名不被信任,加一下配置就好,就是第一次遇到这种”图片来源要白名单”的设计有点意外,毕竟以前 <img> 标签什么域名都能用。

路由这块 App Router 用文件夹套文件夹的方式组织,layout.tsxpage.tsxloading.tsx 这套约定一开始记混过,比如把该放 loading.tsx 的内容写进了 page.tsx 里手动判断 loading 状态,多此一举。后来算是摸清楚了这套文件命名约定背后的意图:能交给框架按约定处理的(loading、error 边界、并行路由)尽量用约定的文件名,不要自己在组件里手写这些通用逻辑。

总的来说 App Router 这套东西学习曲线比 Pages Router 陡不少,网上很多老文章讲的还是 Pages Router 那一套,查资料的时候得留意版本,不然很容易被过时的写法带偏。踩完这几个坑之后现在写起来倒是顺畅了,SEO 那块效果也确实立竿见影,首屏内容直接服务端吐出来,比之前纯客户端渲染等半天白屏强多了。