标签

Next.js

next.js是一个基于React的通用JavaScript框架,next.js为React组件模型提供了扩展,支持基于服务器的组件渲染,同时也支持在客户端继续进行渲染 next.js是一个基于React的通用JavaScript框架,next.js为React组件模型提供了扩展,支持基于服务器的组件渲染,同时也支持在客户端继续进行渲染 next.js是一个基于React的通用JavaScript框架,next.js为React组件模型提供了扩展,支持基于服务器的组件渲染,同时也支持在客户端继续进行渲染

Next.js
查看更多相关内容
服务端6月19日 16:48
Next.js 性能优化该从哪些关键指标入手?Next.js 性能优化最容易踩的坑,是一上来就改配置、加缓存、拆组件,却没有先量化问题。真正有效的做法应该从指标开始:LCP 慢就看首屏图片、字体、服务端响应;INP 差就看客户端 JavaScript、长任务和交互组件;CLS 高就看图片尺寸、广告位、字体切换和异步内容插入。 下面这些优化手段不是越多越好,而是要和指标对应起来。先用 Lighthouse、Chrome DevTools、Web Vitals、真实用户监控确认瓶颈,再决定该动哪一块。 ## 先测量,再优化 性能优化不能只看本地开发环境。本地机器快、网络近、缓存热,很容易把问题藏起来。至少要同时关注两类数据: - **实验室数据**:Lighthouse、WebPageTest、Chrome Performance 面板,适合定位问题。 - **真实用户数据**:Web Vitals、RUM、日志平台,适合判断线上用户到底慢在哪里。 Next.js 可以上报 Web Vitals: ```javascript // pages/_app.js import { useReportWebVitals } from 'next/web-vitals'; export function reportWebVitals(metric) { console.log(metric); // 可以发送到 GA、Sentry、Datadog 或自建埋点服务 } export default function App({ Component, pageProps }) { useReportWebVitals(reportWebVitals); return <Component {...pageProps} />; } ``` 重点看这些指标: | 指标 | 主要反映 | 常见原因 | |---|---|---| | LCP | 首屏主要内容加载速度 | 大图、慢接口、字体阻塞、服务端响应慢 | | INP | 用户交互响应 | 客户端 JS 过重、长任务、重复渲染 | | CLS | 页面布局稳定性 | 图片无尺寸、字体切换、异步插入内容 | | TTFB | 服务端响应速度 | SSR 过重、缓存缺失、数据库慢查询 | 如果没有测量,所谓优化很可能只是把问题从一个地方挪到另一个地方。 ## 用动态导入减少首屏 JavaScript Next.js 会按页面自动做代码分割,但这不代表所有组件都应该进首屏包。图表、富文本编辑器、地图、弹窗、低频面板这类重组件,通常适合动态加载。 ```javascript import dynamic from 'next/dynamic'; const ChartPanel = dynamic(() => import('../components/ChartPanel'), { loading: () => <p>图表加载中...</p>, ssr: false, }); export default function Dashboard() { return ( <section> <h2>数据概览</h2> <ChartPanel /> </section> ); } ``` `ssr: false` 不是默认答案。它会让组件只在浏览器端渲染,适合强依赖 `window`、Canvas、地图 SDK 的组件;如果组件内容对 SEO 或首屏体验重要,就不要随手关掉 SSR。 更实用的判断方式是:这个组件首屏必须看到吗?用户不操作也会用到吗?如果答案是否定的,可以考虑动态导入。 ## 用 next/image 处理 LCP 图片 图片是 Next.js 项目里最常见的 LCP 问题。`next/image` 能自动处理尺寸、格式、懒加载和响应式图片,但首屏大图仍然需要认真配置。 ```javascript import Image from 'next/image'; export default function Hero() { return ( <Image src="/hero.jpg" alt="产品首页截图" width={1200} height={630} priority sizes="(max-width: 768px) 100vw, 1200px" /> ); } ``` 几个细节很关键: - 首屏最大图片通常是 LCP 元素,应该加 `priority`。 - 必须提供准确的 `width` 和 `height`,避免 CLS。 - `sizes` 不要省略,否则浏览器可能下载过大的图片。 - 非首屏图片保持默认懒加载,不要到处加 `priority`。 - 如果使用远程图片,记得在 `next.config.js` 里配置允许的图片域名。 `placeholder="blur"` 可以改善观感,但它不是性能银弹。真正决定 LCP 的,还是图片体积、尺寸、CDN、缓存和首屏请求链路。 ## 用 next/font 降低字体带来的抖动 字体加载慢会影响文本显示,也可能造成布局偏移。`next/font` 会在构建时优化字体加载,减少额外网络请求,并自动处理字体相关的性能问题。 ```javascript import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter', }); export default function RootLayout({ children }) { return ( <html lang="zh-CN" className={inter.variable}> <body>{children}</body> </html> ); } ``` 中文站点要特别小心字体文件体积。全量中文字体非常大,很多时候系统字体栈反而更稳。如果确实要使用品牌字体,建议做子集化,并观察字体对 LCP 和 CLS 的影响。 ## 选对 fetch 缓存和 ISR 策略 App Router 里,服务器组件可以直接 `fetch` 数据。性能好不好,很大程度取决于缓存策略是否匹配业务。 ```javascript async function Page() { const data = await fetch('https://api.example.com/data', { next: { revalidate: 60, tags: ['home-data'], }, }).then(res => res.json()); return <div>{data.title}</div>; } ``` 常见选择可以这样理解: - 内容不常变:用缓存或 ISR,减少重复渲染和接口压力。 - 内容按分钟更新:用 `revalidate`,让页面定期刷新。 - 内容必须实时:用 `cache: 'no-store'`,但要接受更高的 TTFB。 - 需要后台主动刷新:用 `tags` 配合按需重新验证。 不要把所有页面都做 SSR。很多列表页、详情页、营销页并不需要每次请求都重新渲染。对这类页面,ISR 往往比 SSR 更稳,也更省服务器资源。 ## 客户端数据用 SWR 或 React Query 管理 用户态数据、筛选结果、通知数量、管理后台表格这类内容,通常放在客户端请求更合适。直接用 `useEffect` 也能写,但缓存、去重、重试和重新验证很快会变得难维护。 ```javascript 'use client'; import useSWR from 'swr'; const fetcher = url => fetch(url).then(res => res.json()); export default function UserPanel() { const { data, error, isLoading } = useSWR('/api/user', fetcher, { revalidateOnFocus: false, dedupingInterval: 60000, }); if (isLoading) return <p>加载中...</p>; if (error) return <p>加载失败</p>; return <p>{data.name}</p>; } ``` React Query 也适合复杂后台场景,尤其是分页、筛选、乐观更新和失效刷新较多的页面。关键不是选哪个库,而是别让同一个接口在多个组件里重复请求。 ## 谨慎使用 Link prefetch Next.js 的 `Link` 会帮助预取路由资源,通常能提升页面切换速度。 ```javascript import Link from 'next/link'; export default function Navigation() { return ( <nav> <Link href="/about">关于我们</Link> <Link href="/reports" prefetch={false}>大型报表</Link> </nav> ); } ``` 预取不是越多越好。移动端网络差、列表链接很多、目标页面很重时,过度 prefetch 反而会抢占带宽。对低概率点击的链接、大型后台页面、无限列表里的链接,可以考虑关闭预取。 ## 用 next/script 控制第三方脚本 第三方脚本经常是性能问题的来源,比如统计、客服、广告、A/B 测试和热力图。`next/script` 的价值在于让脚本加载时机更可控。 ```javascript import Script from 'next/script'; export default function Page() { return ( <> <Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" /> <Script id="ga" strategy="afterInteractive"> {` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'GA_MEASUREMENT_ID'); `} </Script> </> ); } ``` 常见策略: - `beforeInteractive`:非常少用,只给真正影响页面初始化的脚本。 - `afterInteractive`:页面可交互后加载,适合大多数统计脚本。 - `lazyOnload`:浏览器空闲时加载,适合不影响核心流程的脚本。 如果一个第三方脚本让 INP 变差,先问它是否真的必须首屏加载。很多脚本延后几秒,业务上没有任何损失。 ## memo 和 useMemo 不是免费午餐 `React.memo`、`useMemo`、`useCallback` 可以减少重复计算和重复渲染,但它们也有成本。为了“看起来更专业”到处包一层,通常不会让页面更快。 ```javascript 'use client'; import { memo, useMemo } from 'react'; const ExpensiveList = memo(function ExpensiveList({ items }) { const visibleItems = useMemo(() => { return items.map(item => ({ ...item, label: expensiveFormat(item), })); }, [items]); return <div>{visibleItems.map(item => <p key={item.id}>{item.label}</p>)}</div>; }); ``` 适合使用它们的场景: - 计算真的昂贵,比如大数组处理、复杂格式化、图形计算。 - 子组件很重,而且 props 在多数渲染中保持稳定。 - 已经通过 React Profiler 看到重复渲染带来的开销。 如果 `items` 每次渲染都会被重新创建,`memo` 也救不了。先处理数据结构和状态位置,再考虑记忆化。 ## 长列表要虚拟化 几千条 DOM 节点同时渲染,浏览器会很吃力。后台表格、聊天记录、日志列表、搜索结果页,都可能需要虚拟化。 ```javascript 'use client'; import { useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; export default function VirtualList({ items }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, }); return ( <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}> {virtualizer.getVirtualItems().map(row => ( <div key={row.key} style={{ position: 'absolute', transform: `translateY(${row.start}px)`, height: row.size, width: '100%', }} > {items[row.index]} </div> ))} </div> </div> ); } ``` 虚拟化会增加实现复杂度,也可能影响浏览器搜索、无障碍和滚动定位。数据量不大时,分页或“加载更多”可能更简单。 ## 用服务器组件减少客户端边界 App Router 默认使用服务器组件。服务器组件不会把组件逻辑发送到浏览器,可以减少客户端 JavaScript,尤其适合纯展示内容、详情页、列表页和数据聚合页面。 ```javascript async function ProductInfo({ id }) { const product = await getProduct(id); return ( <section> <h2>{product.name}</h2> <p>{product.description}</p> </section> ); } ``` 需要交互的地方再切到客户端组件: ```javascript 'use client'; import { useState } from 'react'; export function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>; } ``` 客户端边界要尽量小。不要因为一个按钮需要交互,就把整个页面都标成 `'use client'`。边界越大,浏览器要下载和执行的 JavaScript 就越多。 ## 用 Streaming 和 Suspense 改善等待体验 有些接口就是慢,比如报表、推荐、权限聚合。与其让整页等最慢的接口,不如让重要内容先出来,慢内容放进 Suspense。 ```javascript import { Suspense } from 'react'; async function SlowPanel() { const data = await slowFetch(); return <div>{data.title}</div>; } export default function Page() { return ( <main> <h2>账户概览</h2> <Suspense fallback={<p>明细加载中...</p>}> <SlowPanel /> </Suspense> </main> ); } ``` Streaming 不能让慢接口变快,但能让用户更早看到可用内容。它适合“部分内容慢,但页面不应该整体卡住”的场景。 ## Redis 缓存适合放在服务端热点路径 Next.js 自带缓存能解决很多页面级和请求级问题,但有些业务缓存更适合放到 Redis:排行榜、权限结果、推荐结果、第三方接口响应、数据库聚合结果。 ```javascript import { Redis } from '@upstash/redis'; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN, }); export async function getCachedData(key) { const cached = await redis.get(key); if (cached) return cached; const data = await fetchData(); await redis.set(key, data, { ex: 3600 }); return data; } ``` 缓存要同时考虑失效策略。没有失效策略的缓存,后面很容易变成线上事故。可以按时间过期,也可以在内容更新时主动清理。 ## 用 Bundle Analyzer 找出真正的大包 包体积问题不要靠猜。`@next/bundle-analyzer` 可以直接看到哪些依赖进了客户端包。 ```javascript // next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({}); ``` 运行分析: ```bash ANALYZE=true npm run build ``` 常见优化方向: - 替换体积过大的工具库。 - 避免把服务端依赖带进客户端组件。 - 对图表、编辑器、地图做动态导入。 - 检查是否误引入整个组件库或整包图标库。 先看分析结果,再决定怎么拆。盲目拆包可能让请求数量变多,收益并不稳定。 ## 压缩和构建配置要看 Next.js 版本 `compress: true` 可以启用 Next.js 服务端 gzip 压缩,但如果前面已经有 Nginx、CDN、Vercel 或其他网关做压缩,就要避免重复配置带来的误判。实际线上通常还要确认 Brotli、gzip、缓存头是否生效。 ```javascript // next.config.js module.exports = { compress: true, productionBrowserSourceMaps: false, images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, }; ``` 旧文章里常见的 `swcMinify: true` 需要更新一下:现代 Next.js 已经默认使用 SWC 压缩,较新的版本中这个配置不再需要,甚至可能被提示为无效配置。不要为了“优化”保留过时选项,应该以当前 Next.js 版本文档和构建输出为准。 ## CDN 和缓存头别忽略 前端代码写得再好,如果静态资源没有 CDN、缓存头混乱,用户还是会慢。Next.js 构建后的静态资源文件名带 hash,适合长期缓存;HTML 和接口响应则要按业务设置缓存策略。 可以重点检查: - 静态资源是否命中 CDN。 - JS、CSS、字体、图片是否有合理的 `Cache-Control`。 - 接口是否重复请求,是否能被服务端缓存。 - 页面是否因为个性化内容过多而无法缓存。 性能优化经常不是某一个 React API 的问题,而是浏览器、网络、服务端和缓存一起决定的结果。 ## 一个实际排查顺序 如果线上 Next.js 页面“感觉很慢”,可以按这个顺序查: 1. 看 Web Vitals,确认是 LCP、INP、CLS 还是 TTFB 问题。 2. 如果 LCP 慢,先看首屏图片、字体、接口瀑布流和服务端响应。 3. 如果 INP 差,用 Performance 面板找长任务,再看客户端包和重复渲染。 4. 如果 CLS 高,检查图片尺寸、动态内容插入和字体切换。 5. 如果 TTFB 高,检查 SSR、数据库、外部接口、缓存和部署区域。 6. 用 Bundle Analyzer 确认客户端包体积,不要凭感觉删依赖。 7. 改完后重新测量,并对比真实用户数据。 Next.js 性能优化不是把所有功能都打开,而是让每个页面只加载它真正需要的东西。图片、字体、数据缓存、脚本、客户端边界、流式渲染、Redis、CDN、构建产物都值得看,但优先级应该由指标决定。能被测量验证的优化,才算真的优化。
服务端6月19日 16:48
Next.js 数据获取方法该怎么选才合适?很多人查 Next.js 数据获取方法时,最容易混在一起的是两件事:Pages Router 的 `getStaticProps` / `getServerSideProps`,和 App Router 里的服务器组件 `fetch`。它们不是同一套 API,缓存规则也不完全一样。选错了,轻则页面更新不及时,重则把本来能静态缓存的页面做成每次请求都跑服务器。 ## 先分清两套路由 如果项目还在 `pages/` 目录里,数据获取主要靠 Pages Router 的三个函数:`getStaticProps`、`getServerSideProps`、`getStaticPaths`。 如果项目使用 `app/` 目录,优先在 Server Component 里直接 `await fetch()`,再配合 `cache`、`next.revalidate`、`next.tags`、`revalidateTag` 和 `revalidatePath` 控制缓存。客户端交互数据再交给 `useEffect`、SWR 或 TanStack Query。 ## Pages Router:三个函数各管一类场景 ### getStaticProps:构建时生成,适合可公开缓存的数据 `getStaticProps` 只用于 `pages/` 下的页面文件。它在服务端运行,不会进浏览器 bundle;生产环境默认在 `next build` 时执行,返回的 `props` 会生成 HTML 和 JSON。用户通过 `next/link` 跳转时,Next.js 读取这份 JSON,不会在浏览器里重新执行 `getStaticProps`。 ```js export async function getStaticProps() { const post = await getPost() return { props: { post }, revalidate: 60, notFound: !post, } } export default function Page({ post }) { return <article>{post.title}</article> } ``` 它适合博客、文档、商品详情这类“不需要根据当前用户变化”的页面。`revalidate` 开启 ISR 后,旧页面会先继续可用,Next.js 在后台重新生成新版本。不要把 token、内部权限信息放进 `props`,因为这些数据会随初始 HTML 暴露给客户端。 ### getServerSideProps:每次请求执行,适合强实时或个性化页面 `getServerSideProps` 也只能从 `pages/` 页面导出。它在每次请求时运行,可以读取 `req`、`res`、cookies、headers、query、params,所以适合用户中心、权限页面、AB 实验、地理位置相关内容。 ```js export async function getServerSideProps({ req }) { const token = req.cookies.token const profile = await getProfile(token) return { props: { profile } } } ``` 代价也明显:每次访问都要等服务端取数和渲染。能用 `getStaticProps + revalidate` 解决的内容,不要习惯性上 SSR。确实要缓存 SSR 响应时,可以在 `res` 上设置 `Cache-Control`,但优先判断 ISR 是否已经够用。 ### getStaticPaths:给动态路由提前列路径 动态路由如果配合 `getStaticProps`,就需要 `getStaticPaths` 告诉 Next.js 哪些路径要预生成。 ```js export async function getStaticPaths() { const posts = await getPosts() return { paths: posts.map((post) => ({ params: { slug: post.slug } })), fallback: 'blocking', } } export async function getStaticProps({ params }) { const post = await getPost(params.slug) return { props: { post }, revalidate: 300 } } ``` `fallback` 的选择很关键: | fallback | 行为 | 适合场景 | |---|---|---| | `false` | 没在 `paths` 里的页面直接 404 | 页面数量少,构建时能全部生成 | | `true` | 先返回 fallback 页面,再在后台生成真实页面 | 有自定义骨架屏,能处理临时空状态 | | `'blocking'` | 首次访问时服务端生成,生成完再返回 HTML | 详情页很多,又希望首屏就是完整 HTML | SEO 页面通常更偏向 `false` 或 `'blocking'`。如果用 `true`,页面组件要处理 `router.isFallback`,否则用户可能先看到缺数据的内容。 ## App Router:默认在服务器组件里取数 App Router 不再使用 `getStaticProps` 和 `getServerSideProps`。在 `app/` 下,页面、布局和服务器组件可以直接写成 async 函数。 ```js export default async function Page() { const res = await fetch('https://api.example.com/posts', { next: { revalidate: 300, tags: ['posts'] }, }) if (!res.ok) throw new Error('Failed to fetch posts') const posts = await res.json() return <PostList posts={posts} /> } ``` 这段代码运行在服务端,浏览器拿到的是渲染结果,不会把取数逻辑直接打包给客户端。Server Component 里相同 URL、相同配置的 GET `fetch` 在一次服务端渲染过程中会被自动 memoize,同一个请求树里多处调用通常只会真正请求一次。 ### App Router 的 fetch 缓存别按旧印象理解 Next.js 扩展了服务端 `fetch`。这里的 `cache` 不是浏览器 HTTP 缓存,而是 Next.js 服务端缓存策略。 ```js await fetch(url, { cache: 'force-cache' }) await fetch(url, { cache: 'no-store' }) await fetch(url, { next: { revalidate: 60 } }) await fetch(url, { next: { tags: ['posts'] } }) ``` 常用规则可以这样记: - 默认是 `auto no cache`:开发环境通常每次请求源站;生产构建时,如果路由能静态预渲染,可能在 build 阶段取一次并进入静态结果;如果路由使用了 cookies、headers、searchParams 等请求时 API,通常会在每次请求时取数。 - `cache: 'force-cache'`:优先读 Next.js 服务端缓存;没有命中或已过期时再请求源站并更新缓存。 - `cache: 'no-store'`:每次都请求源站,适合强实时、用户私有、不能共享缓存的数据。 - `next.revalidate: false`:语义上接近长期缓存;`0` 表示不缓存;数字表示最多缓存多少秒。 - 不要同时写 `{ cache: 'no-store', next: { revalidate: 60 } }`,这是冲突配置,开发环境会警告,配置会被忽略。 - 同一路由里相同 URL 如果设置了不同的 `revalidate`,更小的时间会生效;单个 fetch 的 `revalidate` 低于路由默认值时,也会拉低整个路由的重新验证间隔。 如果项目启用了较新的 Cache Components 模型,`'use cache'`、`cacheLife()`、`cacheTag()` 会成为更明确的缓存表达方式;但在大量 App Router 项目里,`fetch` 的 `cache`、`next.revalidate`、`next.tags` 仍然是最常见的取数入口。 ## revalidate、tags、revalidateTag 和 revalidatePath 怎么配合 `next.revalidate` 解决的是“多久自动刷新一次”。如果内容由 CMS webhook、后台发布、管理端操作触发更新,就需要按需重新验证。 ```js // app/blog/page.js export default async function BlogPage() { const posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] }, }).then((res) => res.json()) return <PostList posts={posts} /> } ``` 然后在 Server Action 或 Route Handler 里触发: ```js 'use server' import { revalidatePath, revalidateTag } from 'next/cache' export async function publishPost() { await savePost() revalidateTag('posts', 'max') revalidatePath('/blog') } ``` 两者用途不同: - `revalidateTag('posts', 'max')`:让所有使用 `posts` 标签的数据变旧。推荐传第二个参数 `'max'`,它采用 stale-while-revalidate 语义:下次访问先可用旧内容,同时后台刷新。 - `revalidatePath('/blog')`:让指定页面或布局路径重新验证。传动态路由模式时要带类型,例如 `revalidatePath('/blog/[slug]', 'page')`。 - 只调用 `revalidatePath('/blog')` 不会自动刷新其他也用了 `posts` 标签的页面;只调用 `revalidateTag('posts', 'max')` 也不等于指定某个路径重新生成。需要一致性时两个一起用。 标签也有限制:自定义 tag 最长 256 个字符,一次最多 128 个。tag 名大小写敏感,最好用稳定的业务名,比如 `post:${id}`、`posts`、`category:${slug}`。 ## Suspense 和 Streaming:解决“慢数据拖慢整页” App Router 里可以把慢数据拆到子组件,用 `<Suspense>` 包起来。静态外壳先返回,慢组件完成后再流式补上。 ```js import { Suspense } from 'react' async function Comments({ postId }) { const comments = await fetch(`https://api.example.com/posts/${postId}/comments`, { cache: 'no-store', }).then((res) => res.json()) return <CommentList comments={comments} /> } export default function Page({ params }) { return ( <> <PostDetail id={params.id} /> <Suspense fallback={<p>评论加载中...</p>}> <Comments postId={params.id} /> </Suspense> </> ) } ``` 注意,`Suspense` 本身不是“强制动态渲染”的开关。它负责 fallback 和 streaming;是否缓存、是否每次请求取数,还要看组件里的数据访问方式,比如 `no-store`、请求时 API,或 Cache Components 下是否使用了 `'use cache'`。 ## 客户端数据获取:只在需要浏览器状态时使用 不是所有数据都应该放到客户端取。客户端取数会晚于 HTML,到达首屏时更容易出现 loading,也不利于 SEO。它适合这些场景:依赖浏览器状态、用户操作频繁、需要焦点重试、轮询、乐观更新,或者数据完全不影响搜索索引。 ### useEffect:够简单,但很多事要自己处理 ```js 'use client' import { useEffect, useState } from 'react' export default function Profile() { const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { let ignore = false fetch('/api/profile') .then((res) => { if (!res.ok) throw new Error('Request failed') return res.json() }) .then((json) => !ignore && setData(json)) .catch((err) => !ignore && setError(err)) .finally(() => !ignore && setLoading(false)) return () => { ignore = true } }, []) if (loading) return <p>加载中...</p> if (error) return <p>加载失败</p> return <pre>{JSON.stringify(data, null, 2)}</pre> } ``` `useEffect` 胜在没有依赖,缺点是缓存、去重、重试、竞态处理都要自己写。页面稍微复杂一点,SWR 或 TanStack Query 通常更稳。 ### SWR:适合轻量客户端缓存 ```js 'use client' import useSWR from 'swr' const fetcher = (url) => fetch(url).then((res) => res.json()) export default function UserCard() { const { data, error, isLoading } = useSWR('/api/user', fetcher, { revalidateOnFocus: false, dedupingInterval: 60000, }) if (isLoading) return <p>加载中...</p> if (error) return <p>加载失败</p> return <p>{data.name}</p> } ``` SWR 的心智模型很直接:先用缓存,后台重新验证。适合用户信息、通知数量、列表筛选这类客户端体验数据。 ### TanStack Query:适合复杂交互和服务端状态 ```js 'use client' import { useQuery } from '@tanstack/react-query' function getUser() { return fetch('/api/user').then((res) => { if (!res.ok) throw new Error('Request failed') return res.json() }) } export default function UserPanel() { const { data, error, isLoading } = useQuery({ queryKey: ['user'], queryFn: getUser, staleTime: 60_000, gcTime: 300_000, }) if (isLoading) return <p>加载中...</p> if (error) return <p>加载失败</p> return <p>{data.name}</p> } ``` TanStack Query 更适合有分页、筛选、mutation、乐观更新、失效联动的后台系统。用 v5 时是 `gcTime`,老文章里的 `cacheTime` 是 v4 叫法。 ## 错误和加载状态要按路由类型处理 Pages Router 里,`getStaticProps` 可以返回 `notFound` 或 `redirect`,`getServerSideProps` 抛错会走 500 页面。页面组件里的客户端请求还要自己渲染 loading 和 error。 App Router 里,常用文件约定更清楚: - `loading.js` / `loading.tsx`:当前路由段的加载 UI。 - `error.js` / `error.tsx`:当前路由段的错误边界,必须是 Client Component。 - `not-found.js` / `not-found.tsx`:配合 `notFound()` 渲染 404。 - `<Suspense fallback={...}>`:给局部慢组件提供 fallback,并支持 streaming。 取数代码里不要只写 `return res.json()`。至少检查 `res.ok`,否则 404、500 返回的错误 HTML 也可能被当成 JSON 解析,最后报一个很绕的异常。 ## 并行数据获取:别无意中写成瀑布流 如果两个请求互不依赖,不要先 await A 再 await B。 ```js export default async function Dashboard() { const postsPromise = getPosts() const statsPromise = getStats() const userPromise = getUser() const [posts, stats, user] = await Promise.all([ postsPromise, statsPromise, userPromise, ]) return <DashboardView posts={posts} stats={stats} user={user} /> } ``` 如果 B 依赖 A 的结果,顺序请求没问题;如果只是写起来顺手,就会把 300ms + 500ms 变成 800ms。App Router 里拆组件时也一样,能并行就把 promise 提前创建,慢组件再交给 Suspense。 ## 到底该选哪种方法? | 需求 | Pages Router | App Router | |---|---|---| | 内容公开、变化不频繁、重视 SEO | `getStaticProps` + `revalidate` | Server Component `fetch` + `next.revalidate` 或 `force-cache` | | 动态详情页很多 | `getStaticPaths` + `'blocking'` | `generateStaticParams` 预生成热门路径,其余按需处理 | | 每个用户看到的数据不同 | `getServerSideProps` | 使用请求时 API,或 `cache: 'no-store'` 的服务端取数 | | 由后台发布触发刷新 | ISR / 按需 revalidate | `next.tags` + `revalidateTag`,必要时加 `revalidatePath` | | 浏览器交互驱动的数据 | 客户端请求、SWR、TanStack Query | Client Component + SWR / TanStack Query | | 慢数据不想挡住整页 | 自定义 loading 或拆客户端请求 | `<Suspense>` + Streaming | 一句话判断:能静态就别 SSR,能在服务器取就别放客户端,能按标签失效就别全站刷新。Next.js 的数据获取并不难,难的是把“数据什么时候变、给谁看、能不能缓存”这三个问题先想清楚。
服务端6月19日 16:44
Next.js SEO 优化有哪些关键做法?Next.js 的 SEO 优化,重点不是在页面里堆 `keywords`,而是让搜索引擎稳定拿到可理解、可分享、加载快的 HTML。老项目可能还在 Pages Router,用 `next/head` 管页面头部;新项目更建议用 App Router 的 Metadata API、`generateMetadata`、`sitemap.ts` 和 `robots.ts` 统一处理。 如果只做一件事,先保证每个可索引页面都有准确的标题、描述、canonical、结构化数据和可预渲染的正文。搜索引擎能读懂页面,用户点进来后也能快速打开,SEO 才有基础。 ## 元数据:新项目优先用 Metadata API App Router 里不再建议到处手写 `<Head>`。站点级信息放在 `app/layout.tsx`,页面级信息由各页面覆盖。这样标题模板、OG、Twitter、robots、canonical 等配置更集中,也不容易漏。 ```tsx // app/layout.tsx import type { Metadata } from 'next'; export const metadata: Metadata = { metadataBase: new URL('https://example.com'), title: { default: 'Example', template: '%s | Example', }, description: 'Example 提供前端工程与 Web 开发内容', openGraph: { type: 'website', siteName: 'Example', locale: 'zh_CN', url: '/', images: [{ url: '/og.png', width: 1200, height: 630, alt: 'Example' }], }, twitter: { card: 'summary_large_image', images: ['/og.png'], }, robots: { index: true, follow: true, googleBot: { index: true, follow: true, 'max-image-preview': 'large', 'max-snippet': -1, }, }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="zh-CN"> <body>{children}</body> </html> ); } ``` 这里故意没有把 `keywords` 当重点。现代搜索引擎不会因为 meta keywords 写得多就给更高排名,甚至会把堆词看成低质量信号。关键词应该自然出现在标题、首段、正文小标题、图片 alt 和链接上下文里;如果团队内部需要标签,可以保留少量 `keywords`,但不要指望它解决 SEO。 ## Pages Router 旧项目仍可用 Head 如果项目还在 `pages/` 目录,`next/head` 仍然能用。重点是每个页面只输出一组准确的 title、description、canonical、OG 和 Twitter 信息,避免多个组件重复写同一类标签。 ```tsx import Head from 'next/head'; export default function BlogPost({ post }) { const url = `https://example.com/blog/${post.slug}`; return ( <> <Head> <title>{post.title} | Example</title> <meta name="description" content={post.excerpt} /> <link rel="canonical" href={url} /> <meta property="og:title" content={post.title} /> <meta property="og:description" content={post.excerpt} /> <meta property="og:image" content={post.image} /> <meta property="og:type" content="article" /> <meta property="og:url" content={url} /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content={post.title} /> <meta name="twitter:description" content={post.excerpt} /> <meta name="twitter:image" content={post.image} /> </Head> <article>{post.content}</article> </> ); } ``` ## 动态页面用 generateMetadata 补全搜索结果信息 博客详情、商品详情、文档详情这类动态路由,不能只依赖默认 metadata。搜索结果里显示什么,社交软件里展开什么,通常都来自当前数据。 ```tsx // app/blog/[slug]/page.tsx import type { Metadata } from 'next'; async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`, { next: { revalidate: 3600 }, }); if (!res.ok) return null; return res.json(); } export async function generateMetadata( { params }: { params: { slug: string } } ): Promise<Metadata> { const post = await getPost(params.slug); if (!post) return { title: '文章不存在' }; const url = `/blog/${post.slug}`; return { title: post.title, description: post.excerpt, alternates: { canonical: url }, openGraph: { title: post.title, description: post.excerpt, url, type: 'article', publishedTime: post.publishedAt, modifiedTime: post.updatedAt, authors: [post.author.name], images: [{ url: post.cover, width: 1200, height: 630, alt: post.title }], }, twitter: { card: 'summary_large_image', title: post.title, description: post.excerpt, images: [post.cover], }, }; } ``` `description` 不一定直接影响排名,但会影响搜索结果摘要和点击率。写法上要像一句正常的内容摘要,别塞一串同义词。 ## JSON-LD 让搜索引擎更容易理解页面 结构化数据适合文章、产品、课程、面包屑、组织信息等页面。Next.js 里常见做法是在页面组件中输出 JSON-LD。不要虚构评分、作者、发布时间;结构化数据必须和页面可见内容一致。 ```tsx // app/blog/[slug]/page.tsx export default async function BlogPostPage({ params }: { params: { slug: string } }) { const post = await getPost(params.slug); const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, description: post.excerpt, image: post.cover, author: { '@type': 'Person', name: post.author.name }, datePublished: post.publishedAt, dateModified: post.updatedAt, mainEntityOfPage: `https://example.com/blog/${post.slug}`, }; return ( <article> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <h1>{post.title}</h1> <p>{post.excerpt}</p> </article> ); } ``` JSON-LD 不是排名捷径,它更像给搜索引擎的说明书。内容本身薄,结构化数据写得再完整也救不了。 ## SSR、SSG、ISR 要按内容变化频率选 Next.js SEO 的优势在于预渲染。搜索引擎拿到的是完整 HTML,而不是等待浏览器执行一堆客户端 JavaScript 后才出现正文。 - **SSG**:适合文档、博客、营销页。内容构建时已知,访问速度快,缓存友好。 - **ISR**:适合会更新但不要求秒级实时的内容,比如文章、商品列表、公开资料页。 - **SSR**:适合强实时内容,比如库存、价格、个性化页面,但要控制接口耗时,否则 TTFB 会拖累体验。 App Router 中可以用 `revalidate` 或 fetch 的 `next.revalidate` 控制 ISR: ```tsx export const revalidate = 3600; export default async function Page() { const posts = await fetch('https://api.example.com/posts', { next: { revalidate: 3600 }, }).then((res) => res.json()); return <PostList posts={posts} />; } ``` Pages Router 里的 `getStaticProps`、`getServerSideProps`、`getStaticPaths` 仍然是旧项目的核心选择。迁移到 App Router 后,对应思路变成服务器组件、fetch 缓存、`generateStaticParams` 和路由段配置。 ## 动态路由要让重要页面提前可访问 列表页能被抓到,不代表详情页一定能被抓到。动态路由需要稳定的 URL、可发现的内部链接,以及可生成的静态参数。 ```tsx // app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then((res) => res.json()); return posts.map((post) => ({ slug: post.slug })); } export const dynamicParams = true; ``` 对于内容量很大的网站,不必一次生成所有详情页。可以先生成核心页面,再用 ISR 补齐长尾页面。需要注意的是,低价值、重复或空内容页面不要放进 sitemap,也不要让站内到处链接过去。 ## 语义化 HTML 比花哨 DOM 更有用 搜索引擎和辅助技术都依赖清晰结构。文章页应使用 `article`、`header`、`time`、`section`、`nav`、`footer` 等语义标签,标题层级不要跳来跳去。页面只有一个 H1,正文区域从 H2 往下组织更稳。 ```tsx <article> <header> <h1>{post.title}</h1> <time dateTime={post.publishedAt}>{post.publishedAtText}</time> </header> <section aria-labelledby="summary-title"> <h2 id="summary-title">核心观点</h2> <p>{post.summary}</p> </section> <footer> <p>作者:{post.author.name}</p> </footer> </article> ``` 站内链接也要写清楚。`了解更多` 这种锚文本信息量太低,`查看 Next.js Metadata API 示例` 会更明确。 ## 图片用 next/image,同时写好 alt 和尺寸 图片会影响 LCP、CLS 和图片搜索流量。`next/image` 能处理懒加载、响应式尺寸和格式优化,但前提是你给出正确的宽高、`sizes` 和描述性 `alt`。 ```tsx import Image from 'next/image'; export function CoverImage({ post }) { return ( <Image src={post.cover} alt={`${post.title} 封面图`} width={1200} height={630} sizes="(max-width: 768px) 100vw, 768px" priority /> ); } ``` 首屏主图可以用 `priority`,正文里的普通图片交给默认懒加载。不要为了 SEO 在 alt 里重复堆关键词,描述图片本身就够了。 ## sitemap.ts 和 robots.ts 要跟页面策略一致 App Router 可以直接在 `app/` 下生成 sitemap 和 robots。它们不是装饰文件,应该和实际索引策略一致:重要页面放进 sitemap,不想被抓取的后台、搜索结果页、重复参数页用 robots 或 noindex 控制。 ```tsx // app/sitemap.ts import type { MetadataRoute } from 'next'; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await getPosts(); const baseUrl = 'https://example.com'; return [ { url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1 }, ...posts.map((post) => ({ url: `${baseUrl}/blog/${post.slug}`, lastModified: new Date(post.updatedAt), changeFrequency: 'weekly' as const, priority: 0.8, })), ]; } ``` ```tsx // app/robots.ts import type { MetadataRoute } from 'next'; export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] }, ], sitemap: 'https://example.com/sitemap.xml', }; } ``` 如果某些页面已经 canonical 到主页面,通常就不该再把大量重复 URL 塞进 sitemap。 ## Core Web Vitals 会影响真实搜索表现 SEO 不只看内容,也看体验。Core Web Vitals 里最常见的问题是 LCP 慢、CLS 抖动、INP 差。 - **LCP**:首屏主图太大、接口太慢、字体阻塞都会拖慢。用 `next/image`、服务端渲染、CDN 和合理缓存处理。 - **CLS**:图片没写宽高、广告位突然插入、字体切换都会造成布局偏移。 - **INP**:客户端 JavaScript 太重、长任务太多,会让交互变钝。能放服务器组件的逻辑别搬到客户端。 Pages Router 可以用 `reportWebVitals` 上报指标;App Router 项目也可以放一个客户端组件专门采集指标,再发送到分析服务。 ```tsx 'use client'; import { useReportWebVitals } from 'next/web-vitals'; export function WebVitalsReporter() { useReportWebVitals((metric) => { navigator.sendBeacon('/analytics/web-vitals', JSON.stringify(metric)); }); return null; } ``` 数据要按页面类型看。首页、文章页、商品页的问题往往不一样,把所有页面混在一起看平均值,很容易误判。 ## 多语言页面要处理 hreflang、canonical 和路径 国际化站点最容易出问题的是重复内容。中文、英文、西语页面如果只是 URL 不同,metadata 却全一样,搜索引擎很难判断该给哪个地区用户展示。 ```tsx export async function generateMetadata({ params }: { params: { locale: string } }) { const t = await getTranslations(params.locale); return { title: t.title, description: t.description, alternates: { canonical: `https://example.com/${params.locale}`, languages: { 'zh-CN': 'https://example.com/zh-CN', en: 'https://example.com/en', es: 'https://example.com/es', }, }, }; } ``` `hreflang` 的核心是互相指向:中文页声明英文页,英文页也要声明中文页。canonical 则指向当前语言的主 URL,不要所有语言都 canonical 到英文页。 ## OG 和 Twitter 决定分享时像不像一个正常页面 社交分享本身不是传统排名因素,但会影响点击和传播。文章页至少准备标题、描述、封面图、类型和 URL。封面图建议使用 1200×630,标题不要截断,描述不要和全站默认文案一模一样。 App Router 的 metadata 已经能覆盖大多数 OG/Twitter 场景。如果页面有特殊分享图,可以用 `opengraph-image.tsx` 动态生成,但要注意生成耗时和缓存。 ## 一份更贴近项目的检查顺序 做 Next.js SEO 时,可以按这个顺序排查: 1. 页面是否能在禁用 JavaScript 后看到核心内容。 2. 标题、描述、canonical、OG/Twitter 是否按页面动态生成。 3. App Router 是否使用 Metadata API 和 `generateMetadata`,旧 Pages Router 是否正确使用 `Head`。 4. 详情页是否有 JSON-LD,且数据和页面可见内容一致。 5. 重要动态路由是否能通过内部链接和 sitemap 被发现。 6. 渲染策略是否合理:静态内容用 SSG/ISR,强实时内容才用 SSR。 7. 图片是否使用 `next/image`,首屏图是否优化 LCP,尺寸是否避免 CLS。 8. `sitemap.ts`、`robots.ts`、i18n hreflang、canonical 是否互相一致。 9. Web Vitals 是否按页面类型监控,而不是只看全站平均值。 Next.js 已经把很多 SEO 基础能力放进框架里,但框架不会替你判断页面值不值得索引,也不会替你写出有用内容。真正有效的做法,是让 App Router 的元数据、预渲染、结构化数据、语义化 HTML 和性能优化服务于同一个目标:用户打开页面后,看到的正是搜索结果承诺的内容。
服务端6月19日 16:44
Next.js 生产环境部署怎么选,Vercel、Docker 和自托管差在哪?Next.js 部署到生产环境,真正难的不是敲哪条命令,而是先判断项目需要哪种运行时。只要页面里有 SSR、Route Handler、Server Action、ISR、默认图片优化或读 cookies,就不能把它当成普通静态站扔到 CDN 上完事;如果只是文档、营销页、博客归档,静态导出反而更省钱、更稳定。 下面按生产环境里最常见的几条路来选:Vercel 适合想少管运维的团队;Docker standalone 适合自托管和云容器;PM2 + Nginx 适合传统服务器;`output: 'export'` 适合纯静态站。选对路线,比后面补十个配置都重要。 ## 先按功能选择部署方式 | 部署方式 | 适合项目 | Next.js 功能支持 | 主要代价 | |---|---|---|---| | Vercel | App Router、SSR、API、预览环境都要省心 | 支持最好 | 成本和平台绑定更明显 | | Node.js 服务 | 普通服务器、PaaS、需要完整 Next.js 能力 | 完整支持 | 要自己管进程、日志、发布回滚 | | Docker standalone | Kubernetes、Cloud Run、ECS、Fly.io、Render、自建机器 | 完整支持 | 要维护镜像、环境变量和健康检查 | | 静态导出 | 静态页面、SPA、文档站、营销页 | 有限制 | 不支持依赖服务器的特性 | | 平台适配器 | Cloudflare、Netlify、AWS Amplify 等 | 取决于平台 | ISR、图片、Edge、缓存行为要逐项确认 | 一句话:**需要服务端能力就选 Vercel、Node.js 或 Docker;不需要服务端能力才选静态导出。** ## Vercel:最省心的默认选择 Vercel 是 Next.js 官方团队维护的平台,GitHub 仓库一连,通常就能自动识别框架、安装依赖、构建并发布。它的价值不只是“能部署”,而是把预览环境、HTTPS、CDN、Serverless/Edge Runtime、环境变量、回滚这些杂事都收在一个流程里。 最小化的项目脚本一般这样写: ```json { "scripts": { "dev": "next dev", "build": "next build", "start": "next start" } } ``` CLI 部署也很直接: ```bash npm i -g vercel vercel login vercel vercel --prod ``` 大多数项目不需要手写 `vercel.json`。只有在你要改构建命令、设置安全响应头、调整函数超时或指定特殊路由行为时,才建议加配置。例如: ```json { "framework": "nextjs", "buildCommand": "npm run build", "headers": [ { "source": "/(.*)", "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-Frame-Options", "value": "DENY" } ] } ] } ``` 上 Vercel 前要重点检查两件事。第一,生产环境变量和预览环境变量要分开,不要把测试库地址带到 Production。第二,项目如果依赖特定区域的数据库,函数区域、数据库区域和用户主要访问区域要尽量靠近,否则页面渲染很快,数据库往返却把 TTFB 拖慢。 ## Docker standalone:自托管最推荐的打包方式 如果你要部署到自己的服务器、Kubernetes、Google Cloud Run、AWS ECS、Fly.io 或 Render,优先用 Next.js 的 standalone output。它会在构建时追踪运行所需文件,把最小运行集输出到 `.next/standalone`,镜像会比“把整个项目和 node_modules 全塞进去”干净很多。 先在 `next.config.js` 里启用: ```js /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone' } module.exports = nextConfig ``` 再用多阶段 Dockerfile: ```dockerfile FROM node:22-alpine AS base WORKDIR /app FROM base AS deps COPY package.json package-lock.json ./ RUN npm ci FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM base AS runner ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 CMD ["node", "server.js"] ``` 这里有个容易踩的坑:`.next/standalone` 默认不会自动带上 `public` 和 `.next/static`。如果这些资源不交给 CDN,就要像上面的 Dockerfile 一样手动复制,否则页面能打开,静态资源却可能 404。 如果是 monorepo,Next.js 默认只从项目目录追踪文件。服务端代码如果读取了工作区上层的共享文件,要配置 `outputFileTracingRoot`,否则本地构建正常,容器里可能找不到文件: ```js const path = require('path') module.exports = { output: 'standalone', outputFileTracingRoot: path.join(__dirname, '../../') } ``` 用 Docker Compose 跑单机也可以: ```yaml services: web: build: . ports: - "3000:3000" environment: NODE_ENV: production DATABASE_URL: ${DATABASE_URL} restart: unless-stopped ``` ## PM2 + Nginx:传统服务器仍然可用 如果项目部署在一台固定 Linux 服务器上,PM2 负责守护 Node 进程,Nginx 负责 HTTPS、反向代理、静态缓存和访问日志,是一套很常见的组合。 不用 standalone 时,可以直接跑 `next start`: ```js // ecosystem.config.js module.exports = { apps: [ { name: 'nextjs-app', script: 'node_modules/next/dist/bin/next', args: 'start -p 3000', instances: 'max', exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: 3000 }, error_file: './logs/err.log', out_file: './logs/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z' } ] } ``` 常用命令: ```bash npm ci npm run build pm2 start ecosystem.config.js pm2 status pm2 logs nextjs-app pm2 reload nextjs-app ``` Nginx 反向代理可以这样写: ```nginx server { listen 80; server_name example.com; location /_next/static/ { proxy_pass http://127.0.0.1:3000; add_header Cache-Control "public, max-age=31536000, immutable"; } location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } ``` 生产环境还要补 HTTPS、日志轮转、磁盘告警和回滚策略。PM2 能把进程拉起来,但它不等于完整发布系统;构建产物、环境变量、数据库迁移和回滚包仍然要自己管。 ## 静态导出:只适合不依赖服务器的页面 纯静态站可以用 `output: 'export'`。从 Next.js 14 开始,老的 `next export` 已移除,应该通过配置启用静态导出,然后运行 `next build`。 ```js /** @type {import('next').NextConfig} */ const nextConfig = { output: 'export', images: { unoptimized: true } } module.exports = nextConfig ``` 构建后会生成 `out/` 目录,可以放到 GitHub Pages、AWS S3 + CloudFront、Cloudflare Pages、Netlify、Firebase Hosting 或普通 Nginx 静态目录。 但静态导出不是“免费获得所有 Next.js 能力”。下面这些能力不适合静态导出: - 运行时 SSR - 依赖请求对象的 Route Handler - cookies、headers、rewrites、redirects - ISR 和按需重新验证 - 默认 `next/image` 图片优化 - Server Actions - 未通过 `generateStaticParams()` 固定好的动态路由 如果项目现在是静态站,但半年后可能要登录态、支付回调、后台预览或个性化渲染,最好一开始就评估迁移成本。静态导出很稳,但边界也很硬。 ## 云平台怎么选 云平台的选择通常不是技术优劣,而是谁来承担运维成本。 - **AWS Amplify Hosting**:适合已经在 AWS 上的团队,Next.js 支持较完整,但要留意 App Router、SSR、图片优化和缓存能力的版本说明。 - **Google Cloud Run**:很适合 Docker standalone,按容器扩缩容,发布模型清晰。 - **AWS ECS / Kubernetes**:适合已有容器平台的公司,能力强,但发布、扩缩容、日志和监控都要工程化。 - **Fly.io / Render / Railway**:比自建机器省心,适合中小项目快速上线。 - **Cloudflare / Netlify**:可以跑部分 Next.js 能力,但依赖各自适配器;用到 ISR、Edge Runtime、图片优化、Streaming SSR 时要先做验证。 - **Azure Static Web Apps**:更适合静态导出或前后端分离形态。 选择云平台时,不要只看“能不能部署成功”。要拿你的真实页面测一次:动态路由、API、图片、缓存、预览环境、环境变量、日志、回滚,这些全过了才算生产可用。 ## CI/CD:先验证,再发布 CI/CD 的底线是:依赖安装、类型检查、Lint、测试、构建必须先过,部署只是最后一步。GitHub Actions 可以从这个版本开始: ```yaml name: build-and-deploy on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint --if-present - run: npm test --if-present - run: npm run build ``` 如果部署到 Vercel,可以在构建验证后增加 Vercel CLI 的预构建部署;如果部署 Docker,则在构建后推镜像,再由平台滚动更新。无论哪条路,都不要把密钥写在 YAML 里,统一放到 GitHub Secrets 或平台自己的 secret manager。 ## 环境变量:区分构建时和运行时 Next.js 里环境变量最容易混的地方是 `NEXT_PUBLIC_`。带这个前缀的变量会被打进浏览器 bundle,适合公开的 API base URL、埋点 ID,不适合数据库密码、JWT secret、第三方私钥。 常见做法: ```bash # .env.local:本地开发,不提交 DATABASE_URL=postgresql://localhost/app NEXT_PUBLIC_SITE_URL=http://localhost:3000 # 生产环境:放到平台环境变量或 secret manager DATABASE_URL=postgresql://prod/app NEXT_PUBLIC_SITE_URL=https://example.com ``` Docker 场景还要注意:如果变量在 `next build` 时就被读取,构建镜像时必须提供;如果只在服务端运行时读取,可以在容器启动时注入。两类变量混在一起,会出现“本地没问题,生产配置没生效”的怪问题。 ## 性能配置:别再写 swcMinify 老文章里常见的 `swcMinify: true` 已经不适合新项目。Next.js 13 开始 SWC minify 默认开启;Next.js 15 之后继续把 `swcMinify` 留在 `next.config.js` 里,可能会出现 `Unrecognized key(s) in object: 'swcMinify'` 警告。现在应该直接删掉这项,而不是继续复制旧配置。 更值得保留的是这些配置和检查: ```js /** @type {import('next').NextConfig} */ const nextConfig = { poweredByHeader: false, productionBrowserSourceMaps: false, images: { formats: ['image/avif', 'image/webp'] } } module.exports = nextConfig ``` 还可以做几件更实际的事: - 用 `@next/bundle-analyzer` 定期看 bundle,别让后台图表库跑进首页。 - 首屏图片用 `next/image` 并设置合理的 `priority`,不要所有图片都抢优先级。 - `_next/static` 走长期缓存,HTML 和接口按业务设置缓存。 - Node 自托管时可以开 `compress`,但如果 Nginx 或 CDN 已经负责 gzip/br,就不要重复压缩。 - 数据库和服务端渲染区域尽量靠近,很多慢页面不是 JS 慢,而是跨区域查询慢。 ## 监控和日志:上线后才知道真问题 生产部署至少要能回答三个问题:现在挂没挂、哪里慢、用户报错在哪里。 Vercel 项目可以接 Vercel Analytics 和 Speed Insights;自托管项目可以接 Sentry、OpenTelemetry、Prometheus + Grafana 或云厂商 APM。错误监控建议在服务端和浏览器端都接入,尤其是 Route Handler、Server Actions、支付回调这类不容易从页面发现的问题。 一个简单的健康检查接口也很有用: ```ts // app/api/health/route.ts export async function GET() { return Response.json({ ok: true, ts: Date.now() }) } ``` 容器平台可以用它做 readiness/liveness probe。真正的生产检查还应包含数据库、缓存、对象存储等依赖,否则“应用进程活着”和“业务可用”不是一回事。 ## 上线前检查清单 - `npm run build` 在干净环境里能通过,不依赖本机缓存。 - Node.js 版本与 Next.js 当前版本要求一致,CI、Docker、服务器不要各用各的版本。 - 生产环境变量已配置,且没有把 secret 打进客户端 bundle。 - SSR、API、ISR、图片优化、动态路由在目标平台逐项验证过。 - Docker standalone 已复制 `public` 和 `.next/static`,monorepo 已检查文件追踪范围。 - Nginx/CDN 已配置 HTTPS、缓存、请求体大小、代理头和日志。 - CI/CD 有测试、构建、回滚和密钥管理,不只是一条 deploy 命令。 - Sentry/APM/日志/健康检查已接入,告警能找到负责人。 - 静态导出项目确认没有使用 cookies、Server Actions、运行时 API 等服务器特性。 Next.js 生产部署没有唯一答案。小团队想省心,Vercel 是最短路径;有容器基础设施,standalone Docker 更容易标准化;只是静态页面,就别硬上 Node 服务。先让部署方式匹配项目功能,再谈优化和自动化,后面的坑会少很多。
服务端6月5日 22:01
Next.js 状态管理该用哪个方案?Server Component 到 Zustand 怎么选Next.js App Router 引入了 Server Components 后,状态管理的思路和纯 SPA 完全不同。很多数据根本不需要客户端状态——服务端直接获取、渲染、返回 HTML,客户端零 JS 开销。只有真正需要交互的数据才用客户端状态管理。 ## 先问:这个状态真的需要客户端管理吗? | 场景 | 方案 | 需要 JS 吗 | |------|------|-----------| | 页面初始数据 | Server Component 直接 fetch | 不需要 | | 用户个人信息 | Server Component + cookies | 不需要 | | 表单输入值 | useState | 需要 | | 弹窗开关 | useState | 需要 | | 跨页面共享的购物车 | URL 参数 / Cookie / 外部状态库 | 看场景 | **原则**:能用 Server Component 解决的不用客户端状态。每多一个客户端状态,就多一份 JS 发送到浏览器。 ## 第一选择:URL 状态 搜索关键词、分页页码、筛选条件——这些状态天然属于 URL: ```typescript // app/products/page.tsx export default async function ProductsPage({ searchParams, }: { searchParams: { q?: string; page?: string } }) { const products = await searchProducts({ query: searchParams.q, page: Number(searchParams.page) || 1, }) return <ProductList products={products} /> } ``` URL 状态的优势: - **可分享**:用户复制链接给别人,状态不丢 - **浏览器后退/前进**天然支持 - **SEO 友好**:搜索引擎能抓到分页和筛选状态 - **不需要 JS**:Server Component 直接读取 searchParams ### Client Component 中更新 URL 状态 ```typescript 'use client' import { useSearchParams, useRouter } from 'next/navigation' function SearchBar() { const searchParams = useSearchParams() const router = useRouter() function handleSearch(query: string) { const params = new URLSearchParams(searchParams) params.set('q', query) params.delete('page') // 搜索时重置分页 router.push(`/products?${params.toString()}`) } return <input defaultValue={searchParams.get('q') || ''} onChange={(e) => handleSearch(e.target.value)} /> } ``` ## 第二选择:Server Component 传递 props 父组件获取数据,通过 props 传给子组件——最简单的状态传递方式: ```typescript // app/dashboard/page.tsx export default async function DashboardPage() { const [stats, recentOrders] = await Promise.all([ fetchStats(), fetchRecentOrders(), ]) return ( <div> <StatsCard stats={stats} /> <RecentOrders orders={recentOrders} /> </div> ) } ``` `Promise.all` 并行获取,不需要任何状态库。子组件拿到的是渲染好的数据,不需要 loading 状态。 ## 第三选择:React Context 跨组件共享数据,但不需要跨页面共享: ```typescript // providers/theme-provider.tsx 'use client' import { createContext, useContext, useState } from 'react' type Theme = 'light' | 'dark' const ThemeContext = createContext<{ theme: Theme toggleTheme: () => void }>({ theme: 'light', toggleTheme: () => {} }) export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<Theme>('light') return ( <ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'), }}> {children} </ThemeContext.Provider> ) } export function useTheme() { return useContext(ThemeContext) } ``` 在根 layout 中注册: ```typescript // app/layout.tsx import { ThemeProvider } from '@/providers/theme-provider' export default function RootLayout({ children }) { return ( <html> <body> <ThemeProvider>{children}</ThemeProvider> </body> </html> ) } ``` **注意**:Context Provider 必须是 Client Component,包在 Server Component 的 layout 里使用。这会导致 Provider 下面的所有子组件在客户端渲染——所以只放必要的 Provider,不要把整个应用包在一个巨大的 Provider 树里。 ## 第四选择:轻量外部状态库 当 Context 不够用时(跨页面、需要 devtools、需要中间件),用 Zustand——最小最轻的 React 状态库: ```bash npm install zustand ``` ```typescript // store/cart-store.ts import { create } from 'zustand' interface CartItem { id: string name: string price: number quantity: number } interface CartStore { items: CartItem[] addItem: (item: Omit<CartItem, 'quantity'>) => void removeItem: (id: string) => void clearCart: () => void total: () => number } export const useCartStore = create<CartStore>((set, get) => ({ items: [], addItem: (item) => set((state) => { const existing = state.items.find(i => i.id === item.id) if (existing) { return { items: state.items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i ), } } return { items: [...state.items, { ...item, quantity: 1 }] } }), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id), })), clearCart: () => set({ items: [] }), total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0), })) ``` ```typescript // components/cart-button.tsx 'use client' import { useCartStore } from '@/store/cart-store' export function CartButton() { const items = useCartStore((s) => s.items) const total = useCartStore((s) => s.total) return ( <button>购物车 ({items.length}) ¥{total()}</button> ) } ``` Zustand 的优势:不需要 Provider 包裹,任何 Client Component 里直接 `useCartStore()`。 ### Zustand 持久化到 localStorage ```typescript import { create } from 'zustand' import { persist } from 'zustand/middleware' export const useCartStore = create( persist<CartStore>( (set, get) => ({ // ... store 定义 }), { name: 'cart-storage', // localStorage key } ) ) ``` 刷新页面后购物车数据不丢失。但注意 SSR hydration 不匹配的问题——首次渲染用空状态,hydration 后从 localStorage 读取: ```typescript // 解决 hydration 不匹配 function CartButton() { const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) if (!mounted) return <button>购物车</button> const count = useCartStore((s) => s.items.length) return <button>购物车 ({count})</button> } ``` ## Server Actions 替代表单状态 传统方式:表单输入 → 客户端状态 → 提交 → API 调用。Server Actions 简化了这整个流程: ```typescript // app/products/actions.ts 'use server' import { revalidateTag } from 'next/cache' export async function createProduct(formData: FormData) { const name = formData.get('name') as string const price = Number(formData.get('price')) await db.product.create({ data: { name, price } }) revalidateTag('products') } ``` ```typescript // app/products/page.tsx import { createProduct } from './actions' export function AddProductForm() { return ( <form action={createProduct}> <input name="name" required /> <input name="price" type="number" required /> <button type="submit">添加</button> </form> ) } ``` 不需要 `useState` 管理表单值,不需要 `fetch` 调 API,提交后自动重新验证缓存。这是 App Router 推荐的表单处理方式。 ## 状态管理选择流程 ``` 需要跨页面共享吗? ├── 不需要 → 页面级用 Server Component,组件级用 useState └── 需要 → 能放 URL 吗? ├── 能 → URL 参数(searchParams) └── 不能 → 需要持久化吗? ├── 不需要 → React Context └── 需要 → Zustand + persist ``` ## 各方案对比 | 方案 | 跨页面 | 持久化 | 需要 JS | 复杂度 | |------|--------|--------|---------|--------| | Server Component | 否 | - | 不需要 | 最低 | | URL 参数 | 是 | 是 | 最少 | 低 | | useState | 否 | 否 | 需要 | 低 | | React Context | 是(同一 Provider 树) | 否 | 需要 | 中 | | Zustand | 是 | 可选 | 需要 | 中 | | Redux | 是 | 可选 | 需要 | 高 | Next.js 项目中,**80% 的状态可以用 Server Component + URL 参数解决**。只有真正需要在客户端管理交互状态时,才用 Context 或 Zustand。不要上来就装 Redux——那是 SPA 时代的思维。
服务端6月5日 21:52
Next.js 错误处理全指南:error.tsx、global-error 和 API 错误分层方案Next.js 应用出错时,用户看到的不能是一个白屏或一堆报错代码。App Router 提供了分层错误处理机制——从组件级到全局级,每一层都有专门的文件处理。搞清这些层级,就能让错误发生时用户仍然能看到有意义的提示,而不是整个应用崩溃。 ## 错误处理层级 ``` 组件内 try/catch → 处理可预期的业务错误 ↓ 未捕获 error.tsx → 路由级 Error Boundary ↓ 仍未捕获 global-error.tsx → 全局兜底(根 layout 也崩了) ``` 从内到外,每一层兜住上一层没处理的错误。 ## error.tsx:路由级错误边界 App Router 中,每个路由段可以有一个 `error.tsx`,捕获该路由段及其子组件的运行时错误: ```typescript // app/dashboard/error.tsx 'use client' // 必须是 Client Component export default function DashboardError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div className="error-container"> <h2>出错了</h2> <p>{error.message}</p> <button onClick={reset}>重试</button> </div> ) } ``` **关键点**: - `error.tsx` 必须是 Client Component(`'use client'`)——Error Boundary 是客户端概念 - `reset` 函数重新执行渲染该路由段的尝试——适合临时性错误(网络波动) - `error.digest` 是 Next.js 生成的错误哈希,可以在日志中追踪 ### error.tsx 捕获什么 - 子组件的运行时错误 - 子组件中 Server Component 的数据获取失败 - 子路由的 `page.tsx` 抛出的错误 ### error.tsx 不捕获什么 - 同级 `layout.tsx` 的错误——layout 在 error boundary 外面 - `loading.tsx` 不会影响 error.tsx - 根 `app/layout.tsx` 的错误——需要 `global-error.tsx` ## global-error.tsx:最后的兜底 当根 layout 崩溃时,`error.tsx` 也渲染不了(因为 layout 本身都挂了)。这时需要 `global-error.tsx`: ```typescript // app/global-error.tsx 'use client' export default function GlobalError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <html> <body> <h2>应用发生了严重错误</h2> <button onClick={reset}>重新加载</button> </body> </html> ) } ``` `global-error.tsx` 必须自带 `<html>` 和 `<body>`——因为根 layout 已经崩溃,什么都复用不了。这个文件应该尽量简单,不要引入任何可能也出错的组件。 ## Server Component 中的错误处理 Server Component 里用 `try/catch` 处理可预期的错误: ```typescript // app/dashboard/page.tsx export default async function DashboardPage() { let stats try { stats = await fetchDashboardStats() } catch { stats = { activeUsers: 0, revenue: 0 } // 降级数据 } return ( <div> <h1>仪表盘</h1> <StatsCard data={stats} /> </div> ) } ``` 可预期的错误(API 超时、数据不存在)用 try/catch 降级处理,不让它冒泡到 error.tsx。只有真正无法恢复的错误才让 Error Boundary 接管。 ## API Route 的错误处理 ### 基本错误响应 ```typescript // app/api/users/route.ts import { NextResponse } from 'next/server' export async function GET(request: Request) { try { const users = await fetchUsers() return NextResponse.json(users) } catch (error) { console.error('Failed to fetch users:', error) return NextResponse.json( { error: '获取用户列表失败' }, { status: 500 } ) } } ``` ### 业务异常封装 ```typescript // lib/errors.ts export class AppError extends Error { constructor( public statusCode: number, public code: string, message: string, ) { super(message) } } // app/api/users/route.ts export async function POST(request: Request) { const body = await request.json() if (!body.email) { throw new AppError(400, 'MISSING_EMAIL', '邮箱不能为空') } if (await isEmailTaken(body.email)) { throw new AppError(409, 'EMAIL_TAKEN', '邮箱已被注册') } const user = await createUser(body) return NextResponse.json(user, { status: 201 }) } ``` ### 全局错误中间件 在 middleware 或单独的 error handler 中统一处理 AppError: ```typescript // 在 route handler 外层包一个 wrapper function withErrorHandler(handler: Function) { return async (...args: any[]) => { try { return await handler(...args) } catch (error) { if (error instanceof AppError) { return NextResponse.json( { error: error.code, message: error.message }, { status: error.statusCode } ) } console.error('Unexpected error:', error) return NextResponse.json( { error: 'INTERNAL_ERROR', message: '服务器内部错误' }, { status: 500 } ) } } } // 使用 export const POST = withErrorHandler(async (request: Request) => { const body = await request.json() // ... 业务逻辑,直接 throw AppError }) ``` ## notFound:比 Error Boundary 更优雅的"错误" "找不到"不是错误,是一种正常状态。用 `notFound()` 比抛异常更语义化: ```typescript // app/products/[id]/page.tsx import { notFound } from 'next/navigation' export default async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id) if (!product) { notFound() // 触发 not-found.tsx,不触发 error.tsx } return <ProductDetail product={product} /> } ``` 自定义 not-found 页面: ```typescript // app/not-found.tsx export default function NotFound() { return ( <div className="not-found"> <h2>页面不存在</h2> <a href="/">返回首页</a> </div> ) } ``` `notFound()` 的好处:它不会在日志中记录为错误,不会触发 Error Boundary,返回 404 状态码——语义更准确。 ## Client Component 中的错误处理 Client Component 里可以用 React 的 Error Boundary class 组件做更细粒度的捕获: ```typescript // components/error-boundary.tsx 'use client' import { Component } from 'react' type Props = { fallback: React.ReactNode children: React.ReactNode } export class ErrorBoundary extends Component<Props, { hasError: boolean }> { state = { hasError: false } static getDerivedStateFromError() { return { hasError: true } } render() { if (this.state.hasError) { return this.props.fallback } return this.props.children } } // 使用 <ErrorBoundary fallback={<p>加载失败</p>}> <Chart data={data} /> </ErrorBoundary> ``` 这比 error.tsx 更灵活——可以包在单个组件外面,不影响整个页面。 ## 错误监控和日志 生产环境中,错误信息不能只在前端打印——需要上报到监控系统: ```typescript // app/error.tsx 'use client' import { useEffect } from 'react' export default function Error({ error, reset }: { error: Error; reset: () => void }) { useEffect(() => { // 上报错误到监控系统 reportError(error) }, [error]) return ( <div> <h2>出了点问题</h2> <button onClick={reset}>重试</button> </div> ) } ``` Server Component 中的错误上报: ```typescript // app/dashboard/page.tsx export default async function DashboardPage() { try { const data = await fetchData() return <Dashboard data={data} /> } catch (error) { reportError(error) // 上报 throw error // 让 error.tsx 处理 UI } } ``` ## 错误处理策略速查 | 错误类型 | 处理方式 | 文件位置 | |---------|---------|---------| | 数据获取失败 | try/catch 降级 | Server Component 内 | | 路由级运行时错误 | error.tsx | app/[segment]/error.tsx | | 根 layout 崩溃 | global-error.tsx | app/global-error.tsx | | 页面不存在 | notFound() + not-found.tsx | app/not-found.tsx | | API 错误 | try/catch + 状态码 | route.ts 内 | | 单组件错误 | ErrorBoundary 包裹 | 任意 Client Component | | 未预期错误 | 上报监控 + 兜底 UI | error.tsx + 监控 |
服务端6月5日 21:51
Next.js 缓存全解析:四层缓存机制和失效策略实战Next.js 的缓存机制是它的性能杀手锏,也是最容易让人困惑的部分。一个 `fetch` 请求可能被缓存 5 秒也可能永久缓存,取决于一行配置。搞不清缓存层级,就会出现"数据改了但页面没更新"的诡异问题。 ## 四层缓存,各有各的失效机制 ``` 请求进来 → 请求记忆(同一渲染周期内的去重) → 数据缓存(fetch 结果持久化) → 路由缓存(Router Cache,客户端缓存) → 完整路由缓存(构建时静态化) ``` 从上到下,缓存粒度从细到粗。任何一层命中都不会继续往下查。 ## 第一层:请求记忆(Request Memoization) 同一个 Server Component 渲染周期内,多次 `fetch` 同一个 URL 只会真正发一次请求: ```typescript // 这两个 fetch 在同一个渲染周期只会发一次网络请求 async function Layout() { const user = await fetch('/api/user') // 真正请求 // ... } async function Page() { const user = await fetch('/api/user') // 命中记忆,不请求 // ... } ``` 这是 React 的自动去重机制,不需要你做任何配置。**只在 Server Component 的单次渲染内有效**——渲染完成后记忆清除,下次请求重新获取。 ## 第二层:数据缓存(Data Cache) 这是最容易出问题的缓存层。Next.js 扩展了 `fetch` API,默认行为是**永久缓存**: ```typescript // 默认:永久缓存(等效 force-cache) const data = await fetch('https://api.example.com/data') // 和上面等价 const data = await fetch('https://api.example.com/data', { cache: 'force-cache' }) ``` **是的,你没看错——一次 fetch 的结果会被永久缓存,除非你主动让它失效。** ### 控制缓存行为 ```typescript // 不缓存,每次都请求 const data = await fetch('https://api.example.com/data', { cache: 'no-store' }) // 缓存 60 秒后重新验证 const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 } }) ``` ### 三种策略对比 | 策略 | 配置 | 行为 | 适用场景 | |------|------|------|---------| | 静态 | `cache: 'force-cache'` | 构建时获取,永久缓存 | 不变的数据(配置、字典) | | 动态 | `cache: 'no-store'` | 每次请求都获取 | 实时数据(用户信息、支付) | | ISR | `next: { revalidate: 60 }` | 缓存 60 秒,到期后台重新验证 | 经常更新但不需实时的数据 | ### ISR 的工作原理 `revalidate: 60` 不是到期立刻更新——而是到期后第一个请求仍然返回缓存旧数据,同时后台触发重新验证。下一个请求就能拿到新数据。这叫 **stale-while-revalidate**,用户永远不会等到过期数据的重新获取。 ### 按路由段配置 revalidate 不想在每个 fetch 里写 revalidate?在页面级统一设置: ```typescript // app/products/page.tsx export const revalidate = 3600 // 整个页面 1 小时重新验证 export default async function ProductsPage() { const products = await fetch('https://api.example.com/products') // ... } ``` 页面级 revalidate 会覆盖 fetch 级别的设置。 ### 手动失效缓存 ```typescript import { revalidatePath, revalidateTag } from 'next/cache' // 失效整个路由 revalidatePath('/products') // 按 tag 失效(更精确) const data = await fetch('https://api.example.com/products', { next: { tags: ['products'] } }) // 数据变更时清除 tag async function updateProduct() { await db.updateProduct(...) revalidateTag('products') // 所有带 products tag 的缓存失效 } ``` `revalidateTag` 比 `revalidatePath` 更灵活——一个 tag 可以跨多个页面和组件,清除一次全部失效。 ## 第三层:路由缓存(Router Cache) 这是**客户端缓存**,存在浏览器内存中。用户在页面间导航时,Next.js 会缓存已访问路由的 RSC Payload,返回时不需要重新请求服务端。 ```typescript // 在 layout.tsx 中配置 export const experimental = { staleTimes: { dynamic: 30, // 动态路由缓存 30 秒 static: 300, // 静态路由缓存 5 分钟 }, } ``` ### 路由缓存的失效 - 用户刷新页面 → 缓存失效 - 调用 `router.refresh()` → 缓存失效 - `revalidatePath` / `revalidateTag` → 对应缓存失效 **常见问题**:用户在 A 页面修改了数据,切到 B 页面,再切回 A 页面——看到的还是旧数据。这就是路由缓存在作怪。解决:修改数据后调用 `router.refresh()`。 ```typescript 'use client' import { useRouter } from 'next/navigation' function UpdateButton() { const router = useRouter() async function handleClick() { await updateData() router.refresh() // 刷新当前路由,清除客户端缓存 } return <button onClick={handleClick}>更新</button> } ``` ## 第四层:完整路由缓存(Full Route Cache) 构建时,Next.js 会把静态路由的 RSC Payload 和 HTML 都生成好,部署后直接从 CDN 返回——根本不走 Node.js 服务器。 ```typescript // 默认行为:构建时静态生成 export default async function Page() { const data = await fetch('https://api.example.com/static-data') // force-cache return <div>{data}</div> } ``` ### 什么时候路由变成动态的 以下任一条件满足,路由就不会被静态生成: - `fetch` 使用了 `cache: 'no-store'` - `fetch` 使用了 `next: { revalidate: 0 }` - 使用了 `cookies()`、`headers()` 等动态 API - 页面导出了 `export const dynamic = 'force-dynamic'` ```typescript // 强制动态渲染 export const dynamic = 'force-dynamic' ``` ## Server Actions 与缓存失效 Server Actions 是触发缓存失效的最佳位置——数据变更和缓存失效在同一处代码: ```typescript // app/products/actions.ts 'use server' import { revalidateTag } from 'next/cache' export async function createProduct(formData: FormData) { await db.product.create({ data: { name: formData.get('name') } }) revalidateTag('products') // 创建后立刻失效 } ``` ## 调试缓存 开发模式和生产模式的缓存行为完全不同——开发模式下几乎所有缓存都被禁用。**缓存问题只在生产环境中复现。** ```bash # 生产构建 npm run build && npm start # 查看每个路由的渲染策略 # 构建输出会显示: # ○ /products (静态) # ● /products/[id] (动态) # ƒ /api/products (动态) ``` 符号含义:`○` 静态生成,`●` 服务端渲染,`ƒ` 动态路由。 ## 缓存策略速查 | 数据特征 | fetch 配置 | 路由类型 | |---------|-----------|---------| | 不变数据 | `cache: 'force-cache'` | 静态 | | 偶尔更新 | `next: { revalidate: 3600, tags: ['xxx'] }` | ISR | | 频繁更新 | `next: { revalidate: 60 }` | ISR | | 实时数据 | `cache: 'no-store'` | 动态 | | 用户相关 | `cookies()` + `cache: 'no-store'` | 动态 | | 问题 | 原因 | 解决 | |------|------|------| | 数据更新了页面没变 | 数据缓存未失效 | `revalidateTag` / `revalidatePath` | | 返回上一页看到旧数据 | 路由缓存 | `router.refresh()` | | 开发环境正常生产不对 | 开发模式禁用缓存 | 生产构建调试 | | fetch 没有执行 | 请求记忆去重 | 正常行为,同一周期只请求一次 |
服务端6月5日 21:49
Next.js App Router vs Pages Router:核心区别和渐进式迁移指南Next.js 13 引入的 App Router 不是 Pages Router 的替代品——它是一套全新的架构,基于 React Server Components,改变了数据获取、渲染和路由的整个思维方式。迁移不是改个目录名那么简单。 ## 核心区别一表看懂 | 维度 | Pages Router | App Router | |------|-------------|------------| | 目录 | `pages/` | `app/` | | 路由文件 | `pages/about.js` | `app/about/page.tsx` | | API 路由 | `pages/api/users.js` | `app/api/users/route.ts` | | 布局 | `_app.js` + 唯一 layout | 嵌套 layout(每个目录可以有) | | 数据获取 | `getServerSideProps` / `getStaticProps` | 组件内直接 `async/await` | | 渲染模型 | 客户端组件为主 | 服务端组件为主(RSC) | | 状态 | 天然客户端 | 服务端组件无状态 | ## 数据获取:最大的变化 ### Pages Router 方式 ```typescript // pages/users.tsx export async function getServerSideProps() { const users = await fetchUsers() return { props: { users } } } export default function UsersPage({ users }) { return <UserList users={users} /> } ``` 每个页面需要单独的 `getServerSideProps` 或 `getStaticProps` 函数,数据只能在页面级获取,子组件不能自己获取。 ### App Router 方式 ```typescript // app/users/page.tsx export default async function UsersPage() { const users = await fetchUsers() // 直接在组件里 await return <UserList users={users} /> } ``` 不需要特殊的 `getXXXProps` 函数——组件本身就是 `async` 函数,直接 `await` 获取数据。子组件也能自己获取: ```typescript // components/user-stats.tsx export default async function UserStats() { const stats = await fetchStats() // 子组件自己获取数据 return <div>活跃用户:{stats.active}</div> } ``` 这是 RSC 的核心优势:**组件级数据获取**,不再需要在页面顶层把所有数据攒齐再一层层传 props。 ## 布局系统:从全局到嵌套 ### Pages Router:一个布局管所有页面 ```typescript // pages/_app.tsx - 全局唯一的布局 export default function App({ Component, pageProps }) { return ( <Layout> <Component {...pageProps} /> </Layout> ) } ``` 所有页面共享同一个 `_app.js`。想做"某些页面有侧边栏,某些没有"很别扭——要靠条件判断或把布局塞进每个页面。 ### App Router:嵌套布局,每个目录可以有 ``` app/ layout.tsx # 根布局(必须有 html + body) page.tsx # 首页 dashboard/ layout.tsx # dashboard 专属布局(侧边栏) page.tsx # dashboard 首页 settings/ page.tsx # settings 页(继承 dashboard 布局) ``` ```typescript // app/dashboard/layout.tsx export default function DashboardLayout({ children }) { return ( <div className="flex"> <Sidebar /> <main className="flex-1">{children}</main> </div> ) } ``` `/dashboard/settings` 会渲染三层布局:根 layout → dashboard layout → settings page。切换 settings 页面时,dashboard layout 不会重新渲染——侧边栏状态保留。 **这是 App Router 布局的核心:嵌套且持久化**。Pages Router 做不到这一点——每次路由切换都重新挂载整个页面。 ## Server Components vs Client Components 这是迁移时最容易搞混的概念: ### Server Components(默认) ```typescript // app/page.tsx - 自动是 Server Component export default async function Page() { const data = await fetch('https://api.example.com/data') return <div>{data.title}</div> } ``` - 在服务端渲染,不会发送 JS 到客户端 - 可以直接访问数据库、文件系统 - 不能用 `useState`、`useEffect`、浏览器 API - 不能绑定事件处理函数(`onClick` 等) ### Client Components(需要显式声明) ```typescript 'use client' // 这行声明是 Client Component import { useState } from 'react' export default function Counter() { const [count, setCount] = useState(0) return <button onClick={() => setCount(count + 1)}>{count}</button> } ``` - 在客户端渲染(可以 hydration) - 可以用所有 React Hooks - 可以绑定事件、使用浏览器 API - 不能直接 `await` 异步数据 ### 选择原则 | 需要 | 选择 | |------|------| | 获取数据 | Server Component | | 访问后端资源 | Server Component | | useState/useEffect | Client Component | | 事件处理(onClick) | Client Component | | 浏览器 API(window) | Client Component | **默认用 Server Component,只在需要交互时才用 Client Component。** 这和 Pages Router 的思维完全相反——Pages Router 默认全是客户端组件。 ## API 路由的变化 ### Pages Router ```typescript // pages/api/users.ts import type { NextApiRequest, NextApiResponse } from 'next' export default function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { res.status(200).json({ users: [] }) } } ``` ### App Router ```typescript // app/api/users/route.ts import { NextResponse } from 'next/server' export async function GET() { const users = await fetchUsers() return NextResponse.json({ users }) } export async function POST(request: Request) { const body = await request.json() const user = await createUser(body) return NextResponse.json(user, { status: 201 }) } ``` 变化:从单个 handler 函数 + `if (req.method)` 拆成独立的方法函数。每个 HTTP 方法一个导出函数,更清晰。 ## 迁移策略 不要一次性全迁移——两个 Router 可以共存: ### 1. 渐进式迁移 ``` pages/ old-page.tsx # 还在用 Pages Router app/ new-page/ page.tsx # 新页面用 App Router ``` Next.js 同时支持两种 Router。新页面用 App Router,旧页面保持不动,逐步迁移。 ### 2. 迁移优先级 1. **API 路由**:最简单,改动最小 2. **纯展示页面**:不需要交互,直接改成 Server Component 3. **有交互的页面**:拆成 Server + Client 组件混合 ### 3. 常见迁移坑 - **`useRouter` 不一样了**:Pages Router 用 `next/router`,App Router 用 `next/navigation` - **`getServerSideProps` 变成组件内 async**:不再需要序列化/反序列化 props - **`_app.js` 里的 Provider**:移到 `app/layout.tsx`,但要包在 Client Component 里 - **图片组件**:`next/image` API 有变化,注意 `fill` 属性替代了 `layout="fill"` ## 什么时候还该用 Pages Router 不是所有项目都需要迁移: - **老项目稳定运行**:没有性能问题,不需要 RSC,不必迁移 - **团队不熟悉 RSC**:App Router 的思维模式完全不同,迁移成本不只是改代码 - **重度依赖客户端状态**:如果你的页面几乎全是客户端交互,RSC 的优势体现不出来 App Router 的核心价值是**服务端渲染优先 + 嵌套布局**。如果你的项目确实受益于这两点,值得迁移;否则 Pages Router 继续用就好——Next.js 没有废弃它的计划。
服务端6月5日 21:48
Next.js App Router 国际化实战:路由、翻译、SEO 和语言切换多语言网站不是翻译几个字符串就完事——路由怎么设计、翻译文件怎么组织、SEO 的 hreflang 怎么配、切换语言时状态怎么保持,每个环节都有坑。Next.js App Router 提供了灵活的路由机制,但国际化方案需要自己搭。这篇给出一个完整的实战方案。 ## 路由结构设计 App Router 推荐用动态路由段 `[lang]` 承载语言前缀: ``` app/ [lang]/ layout.tsx # 语言布局(加载翻译、设置 lang 属性) page.tsx # 首页 about/ page.tsx # 关于页 layout.tsx # 根布局 ``` URL 效果:`/zh/about`、`/en/about`、`/ja/about` ### layout.tsx 加载翻译并设置语言 ```typescript // app/[lang]/layout.tsx import { notFound } from 'next/navigation' import { dictionaries } from '@/lib/dictionaries' export const supportedLocales = ['zh', 'en', 'ja'] as const export type Locale = typeof supportedLocales[number] export function generateStaticParams() { return supportedLocales.map((lang) => ({ lang })) } export default async function LangLayout({ children, params: { lang }, }: { children: React.ReactNode params: { lang: string } }) { if (!supportedLocales.includes(lang as Locale)) { notFound() } return ( <html lang={lang}> <body>{children}</body> </html> ) } ``` `generateStaticParams` 让 Next.js 在构建时为每种语言生成静态页面。`notFound()` 处理非法语言前缀。 ## 翻译文件组织 ### 按语言分文件 ``` dictionaries/ zh.json en.json ja.json ``` ```json // dictionaries/zh.json { "nav": { "home": "首页", "about": "关于", "contact": "联系我们" }, "home": { "title": "欢迎使用我们的产品", "description": "一站式解决方案" } } ``` ### 加载翻译的工具函数 ```typescript // lib/dictionaries.ts const dictionaries = { zh: () => import('@/dictionaries/zh.json').then((m) => m.default), en: () => import('@/dictionaries/en.json').then((m) => m.default), ja: () => import('@/dictionaries/ja.json').then((m) => m.default), } export type Dictionary = Awaited<ReturnType<typeof dictionaries.zh>> export async function getDictionary(lang: string): Promise<Dictionary> { if (!(lang in dictionaries)) { return dictionaries.zh() // 回退到默认语言 } return dictionaries[lang]() } ``` 用动态 `import()` 按需加载——用户访问 `/en/about` 时只加载 `en.json`,不会把所有语言的翻译都打到一个包里。 ### 在 Server Component 中使用 ```typescript // app/[lang]/page.tsx import { getDictionary } from '@/lib/dictionaries' export default async function Home({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return ( <main> <h1>{t.home.title}</h1> <p>{t.home.description}</p> </main> ) } ``` Server Component 里直接用 `await`,不需要状态管理。翻译在服务端完成,客户端拿到的就是渲染好的 HTML。 ## 语言切换组件 ```typescript // components/lang-switcher.tsx 'use client' import { usePathname, useRouter } from 'next/navigation' import { supportedLocales, type Locale } from '@/app/[lang]/layout' const localeNames: Record<Locale, string> = { zh: '中文', en: 'English', ja: '日本語', } export function LangSwitcher({ currentLang }: { currentLang: Locale }) { const pathname = usePathname() const router = useRouter() function switchLang(newLang: Locale) { // 替换 URL 中的语言段 const segments = pathname.split('/') segments[1] = newLang router.push(segments.join('/')) } return ( <select value={currentLang} onChange={(e) => switchLang(e.target.value as Locale)}> {supportedLocales.map((locale) => ( <option key={locale} value={locale}>{localeNames[locale]}</option> ))} </select> ) } ``` 切换语言就是替换 URL 的第一段——`/zh/about` 变 `/en/about`。Next.js 会重新加载对应语言的页面。 ## SEO:hreflang 标签 搜索引擎需要知道不同语言版本的对应关系: ```typescript // app/[lang]/layout.tsx 中添加 metadata export async function generateMetadata({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return { title: t.home.title, alternates: { canonical: `https://example.com/${lang}`, languages: { 'zh': 'https://example.com/zh', 'en': 'https://example.com/en', 'ja': 'https://example.com/ja', 'x-default': 'https://example.com/en', // 默认语言 }, }, } } ``` `x-default` 告诉搜索引擎:无法匹配用户语言时,显示这个版本。通常选英语或主要目标语言。 ## 中间件:自动重定向到用户首选语言 ```typescript // middleware.ts import { NextRequest, NextResponse } from 'next/server' import { supportedLocales } from '@/app/[lang]/layout' function getLocale(request: NextRequest): string { const acceptLanguage = request.headers.get('accept-language') if (!acceptLanguage) return 'zh' // 解析 Accept-Language 头,匹配支持的语言 const preferred = acceptLanguage .split(',') .map((lang) => lang.split(';')[0].trim().substring(0, 2).toLowerCase()) return preferred.find((lang) => supportedLocales.includes(lang as any)) || 'zh' } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // 已有语言前缀,不处理 if (supportedLocales.some((locale) => pathname.startsWith(`/${locale}`))) { return } // 根目录访问,重定向到首选语言 const locale = getLocale(request) request.nextUrl.pathname = `/${locale}${pathname}` return NextResponse.redirect(request.nextUrl) } export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'], } ``` 用户首次访问 `example.com/about` 时,中间件检测到 URL 没有语言前缀,根据浏览器 Accept-Language 重定向到 `/en/about` 或 `/zh/about`。 ## Client Component 中使用翻译 Client Component 不能直接 `await` 翻译,需要通过 props 传入或用 Context: ```typescript // providers/dictionary-provider.tsx 'use client' import { createContext, useContext } from 'react' import type { Dictionary } from '@/lib/dictionaries' const DictionaryContext = createContext<Dictionary | null>(null) export function DictionaryProvider({ dictionary, children, }: { dictionary: Dictionary children: React.ReactNode }) { return ( <DictionaryContext.Provider value={dictionary}> {children} </DictionaryContext.Provider> ) } export function useDictionary() { const dictionary = useContext(DictionaryContext) if (!dictionary) throw new Error('useDictionary must be used within DictionaryProvider') return dictionary } ``` 在 layout 里提供: ```typescript // app/[lang]/layout.tsx import { DictionaryProvider } from '@/providers/dictionary-provider' export default async function LangLayout({ children, params: { lang } }) { const dictionary = await getDictionary(lang) return ( <DictionaryProvider dictionary={dictionary}> {children} </DictionaryProvider> ) } ``` Client Component 里直接 `useDictionary()` 取翻译,不需要每次 prop drilling。 ## 翻译中的插值和复数 纯 JSON 翻译文件不支持插值,需要一个小工具函数: ```typescript // lib/i18n.ts export function interpolate(template: string, params: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`)) } // 复数处理(简单版) export function pluralize(count: number, singular: string, plural: string): string { return count === 1 ? singular : plural } ``` ```json { "cart": { "items": "购物车中有 {count} 件商品", "item_single": "1 件商品", "item_plural": "{count} 件商品" } } ``` ```typescript const text = interpolate(t.cart.items, { count: 3 }) // "购物车中有 3 件商品" ``` 复杂的复数规则(阿拉伯语、俄语等)建议用 `intl-messageformat` 库。 ## 常见问题 ### 翻译键找不到怎么办 开发时加一个 fallback 机制: ```typescript export function t(dictionary: Dictionary, path: string): string { const keys = path.split('.') let result: any = dictionary for (const key of keys) { result = result?.[key] } return result ?? path // 找不到翻译时返回键名,而不是报错 } ``` ### 翻译文件太大怎么办 按页面拆分翻译文件,按需加载: ``` dictionaries/ zh/ common.json home.json about.json en/ common.json home.json about.json ``` ### SEO 的 hreflang 和 canonical 同时存在冲突吗 不冲突。`canonical` 指向当前语言版本的规范 URL,`hreflang` 指向其他语言版本。两者配合告诉搜索引擎:这些 URL 是同一个内容的不同语言版本。 ## 完整方案清单 | 检查项 | 配置 | |--------|------| | 路由结构 | `app/[lang]/` 动态路由 | | 翻译加载 | 动态 `import()` 按需加载 | | 语言切换 | 替换 URL 语言段 | | 自动重定向 | middleware 检测 Accept-Language | | SEO | generateMetadata 配置 hreflang + canonical | | Client Component | DictionaryProvider + useDictionary | | 非法语言 | notFound() 处理 | | 翻译插值 | interpolate 工具函数 |
服务端6月5日 14:00
Next.js测试策略:服务端组件、API路由、Server Actions和E2ENext.js 应用的测试比纯 React 复杂——它有服务端组件、客户端组件、API 路由、Server Actions、还有 SSR/SSG 渲染模式。照搬 React 的测试方法会踩很多坑。这篇文章按组件类型分类,把每类该测什么、用什么工具、怎么 Mock 讲清楚。 ## 测试工具选择 | 工具 | 用途 | 替代方案 | |------|------|----------| | Jest + React Testing Library | 组件单元测试 | Vitest(更快,配置类似) | | MSW (Mock Service Worker) | Mock API 请求 | nock(只 Node 环境) | | Playwright | E2E 测试 | Cypress | | @testing-library/user-event | 模拟用户交互 | fireEvent(不如 user-event 贴近真实) | Next.js 项目推荐用 Vitest 代替 Jest——Vite 生态集成更好,速度快 3-5 倍: ```bash npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-event ``` ### Vitest 配置 ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['**/*.{test,spec}.{js,jsx,ts,tsx}'], }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, }); ``` ```typescript // vitest.setup.ts import '@testing-library/jest-dom'; ``` ## 客户端组件测试 客户端组件(文件顶部有 `'use client'`)的测试和普通 React 组件一样: ```typescript // components/Counter.tsx 'use client'; import { useState } from 'react'; export function Counter() { const [count, setCount] = useState(0); return ( <div> <span data-testid="count">{count}</span> <button onClick={() => setCount(c => c + 1)}>+1</button> </div> ); } ``` ```typescript // components/__tests__/Counter.test.tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Counter } from '../Counter'; describe('Counter', () => { it('increments count on click', async () => { const user = userEvent.setup(); render(<Counter />); expect(screen.getByTestId('count')).toHaveTextContent('0'); await user.click(screen.getByRole('button', { name: '+1' })); expect(screen.getByTestId('count')).toHaveTextContent('1'); }); }); ``` ### Mock hooks 测试依赖 `useSearchParams` 等 Next.js hooks 的组件: ```typescript // Mock next/navigation vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams('tab=settings'), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => '/dashboard', })); ``` Next.js 的 hooks 在测试环境里不工作——它们依赖 Next.js 的路由上下文。Mock 是唯一的方式。 ## 服务端组件测试 服务端组件(默认,没有 `'use client'`)不能直接用 React Testing Library 渲染——它们是异步函数,返回的不是标准 JSX。 ### 方案一:抽取逻辑为纯函数 最佳做法——把业务逻辑从服务端组件里抽出来,单独测试: ```typescript // lib/filterProducts.ts export function filterProducts(products: Product[], category: string) { return products.filter(p => p.category === category); } // lib/__tests__/filterProducts.test.ts import { filterProducts } from '../filterProducts'; describe('filterProducts', () => { it('filters by category', () => { const products = [ { id: 1, name: 'Widget', category: 'tools' }, { id: 2, name: 'Book', category: 'books' }, ]; expect(filterProducts(products, 'tools')).toHaveLength(1); }); }); ``` 服务端组件本身只是数据获取 + 渲染的组合,逻辑都在纯函数里——纯函数好测、快、不依赖任何框架。 ### 方案二:测试渲染输出 如果必须测服务端组件的渲染结果,用 `renderToString`: ```typescript import { renderToString } from 'react-dom/server'; import { ProductList } from '../components/ProductList'; it('renders product names', async () => { const products = [{ id: 1, name: 'Widget' }]; const html = renderToString(await ProductList({ products })); expect(html).toContain('Widget'); }); ``` 这种方式比较粗糙——只能断言 HTML 字符串包含什么,不能用 `screen.getByRole` 等 DOM 查询 API。适合快速验证组件不会报错、渲染了关键内容。 ## API 路由测试 Next.js App Router 的 API 路由是 `route.ts` 文件,导出 `GET`、`POST` 等函数。测试时直接调用这些函数: ```typescript // app/api/users/route.ts import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const page = searchParams.get('page') || '1'; const users = await fetchUsers(Number(page)); return NextResponse.json({ users, page: Number(page) }); } ``` ```typescript // __tests__/api/users.test.ts import { GET } from '@/app/api/users/route'; // Mock 数据获取 vi.mock('@/lib/data', () => ({ fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]), })); describe('GET /api/users', () => { it('returns users with page number', async () => { const request = new Request('http://localhost/api/users?page=2'); const response = await GET(request); const data = await response.json(); expect(response.status).toBe(200); expect(data.page).toBe(2); expect(data.users).toHaveLength(1); }); }); ``` 直接构造 `Request` 对象传入——不需要启动服务器,测试跑在 Node 环境里,速度极快。 ### Mock 外部 API API 路由通常要调外部服务。用 MSW 拦截请求: ```typescript import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; const server = setupServer( http.get('https://api.example.com/users', () => { return HttpResponse.json([{ id: 1, name: 'Mocked User' }]); }), ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); ``` MSW 在 Node 层面拦截 HTTP 请求——不需要改业务代码,测试完自动恢复。 ## Server Actions 测试 Server Actions 是 Next.js 13+ 的服务端函数,在客户端通过 `useServer` 调用。测试方式和 API 路由类似——直接调用函数: ```typescript // app/actions/createPost.ts 'use server'; export async function createPost(formData: FormData) { const title = formData.get('title') as string; if (!title || title.length < 3) { return { error: '标题至少 3 个字符' }; } await db.post.create({ data: { title } }); return { success: true }; } ``` ```typescript import { createPost } from '@/app/actions/createPost'; vi.mock('@/lib/db', () => ({ db: { post: { create: vi.fn().mockResolvedValue({ id: 1 }) } }, })); describe('createPost', () => { it('rejects short titles', async () => { const formData = new FormData(); formData.set('title', 'ab'); const result = await createPost(formData); expect(result.error).toBeDefined(); }); it('creates post with valid title', async () => { const formData = new FormData(); formData.set('title', 'My Post'); const result = await createPost(formData); expect(result.success).toBe(true); }); }); ``` ## E2E 测试(Playwright) 单元测试验证组件逻辑,E2E 测试验证完整用户流程——从打开页面到完成操作。 ### 基础配置 ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './e2e', baseURL: 'http://localhost:3000', use: { locale: 'zh-CN', }, }); ``` ### 页面测试 ```typescript // e2e/home.spec.ts import { test, expect } from '@playwright/test'; test('homepage shows products', async ({ page }) => { await page.goto('/'); await expect(page.getByText('热门商品')).toBeVisible(); await expect(page.getByTestId('product-card')).toHaveCount(10); }); test('can add product to cart', async ({ page }) => { await page.goto('/'); await page.getByTestId('product-card').first().getByRole('button', { name: '加入购物车' }).click(); await expect(page.getByTestId('cart-count')).toHaveText('1'); }); ``` E2E 测试需要 Next.js 服务器在跑。Playwright 的 `webServer` 配置可以自动启动: ```typescript export default defineConfig({ webServer: { command: 'npm run dev', port: 3000, reuseExistingServer: !process.env.CI, }, }); ``` ## 测试策略总结 | 层级 | 测试什么 | 工具 | 占比 | |------|----------|------|------| | 纯函数/工具 | 业务逻辑、数据转换 | Vitest | 40% | | 客户端组件 | 交互、状态、渲染 | RTL + Vitest | 25% | | API 路由/Actions | 请求处理、验证、错误 | Vitest + MSW | 20% | | E2E | 关键用户流程 | Playwright | 15% | 服务端组件不直接测——逻辑抽到纯函数,渲染验证留给 E2E。这样 85% 的测试跑在 Vitest 里(< 100ms/个),只有 15% 需要启动浏览器。
服务端6月2日 23:16
Next.js App Router 怎么定义路由?动态路由、布局嵌套和 loading 详解Next.js App Router 用文件系统定义路由——文件夹结构就是 URL 结构。每个路由三要素:page.tsx(页面)、layout.tsx(布局)、loading.tsx(加载状态)。 ## 基本路由映射 ``` app/ ├── page.tsx → / ├── about/ │ └── page.tsx → /about ├── blog/ │ ├── page.tsx → /blog │ └── [slug]/ │ └── page.tsx → /blog/:slug(动态路由) ├── dashboard/ │ ├── layout.tsx → dashboard 共享布局 │ ├── page.tsx → /dashboard │ └── settings/ │ └── page.tsx → /dashboard/settings ``` `page.tsx` 是必须的——没有 page.tsx 的文件夹不构成路由。其他文件(layout、loading、error)都是可选的。 ## 动态路由 ```tsx // app/blog/[slug]/page.tsx export default function BlogPost({ params }: { params: { slug: string } }) { return <h1>文章: {params.slug}</h1>; } ``` 访问 `/blog/hello-world` 时,`params.slug` = `"hello-world"`。 Catch-all 路由用 `[...slug]`:匹配 `/shop/clothes/shirts` 这样的多级路径,`params.slug` = `["clothes", "shirts"]`。 ## 路由组 用 `(groupName)` 创建不反映在 URL 中的分组: ``` app/ ├── (marketing)/ │ ├── about/page.tsx → /about(URL 里没有 marketing) │ └── pricing/page.tsx → /pricing ├── (shop)/ │ ├── products/page.tsx → /products │ └── cart/page.tsx → /cart ``` 路由组的用途:给一组页面共享 layout 而不影响 URL。比如 `(marketing)` 组用营销页布局,`(shop)` 组用商店布局。 ## 布局嵌套 layout.tsx 会嵌套——子路由的 layout 包在父 layout 里面: ```tsx // app/layout.tsx — 根布局(必有,包含 html/body) export default function RootLayout({ children }) { return ( <html> <body> <nav>全局导航</nav> {children} </body> </html> ); } // app/dashboard/layout.tsx — dashboard 布局 export default function DashboardLayout({ children }) { return ( <div className="flex"> <aside>侧边栏</aside> <main>{children}</main> </div> ); } ``` 从 /dashboard 切换到 /dashboard/settings 时,根布局和 dashboard 布局都不会重新渲染。只有 `children` 对应的 page.tsx 更新。 ## loading.tsx:自动 Suspense ```tsx // app/dashboard/loading.tsx export default function Loading() { return <div className="animate-pulse">加载中...</div>; } ``` Next.js 自动用 Suspense 包裹 page.tsx,加载时显示 loading.tsx。不需要手写 useState 管理加载状态。 ## error.tsx:错误边界 ```tsx // app/error.tsx — 必须是 Client Component 'use client' export default function Error({ error, reset }) { return ( <div> <p>出错了: {error.message}</p> <button onClick={reset}>重试</button> </div> ); } ``` `error.tsx` 捕获子组件的运行时错误,显示错误界面。`reset` 函数重新渲染出错的组件。 ## 程序化导航 ```tsx 'use client' import { useRouter } from 'next/navigation'; function LoginButton() { const router = useRouter(); return <button onClick={() => router.push('/dashboard')}>登录</button>; } ``` `router.push()` 客户端跳转,`router.replace()` 替换当前历史记录(不能回退),`router.back()` 返回上一页。 Server Component 里用 `redirect()`: ```tsx import { redirect } from 'next/navigation'; export default async function Page() { const session = await getSession(); if (!session) redirect('/login'); // ... } ``` `redirect` 在服务端执行,用户看不到中间页面。
服务端6月2日 23:15
Next.js SSR、SSG 和 ISR 有什么区别?怎么选?SSR、SSG、ISR 是三种不同的页面渲染策略,区别在于 HTML 什么时候生成。选哪个取决于数据的更新频率和页面的实时性要求。 ## 三种策略对比 | 策略 | HTML 生成时机 | 适合场景 | 性能 | |------|-------------|----------|------| | SSG | 构建时 | 博客、文档、营销页 | 最快(CDN 缓存) | | SSR | 每次请求时 | 仪表盘、个人主页 | 中等(服务端计算) | | ISR | 构建时 + 定时更新 | 商品列表、新闻 | 接近 SSG 的速度 | ## SSG(Static Site Generation) 构建时生成 HTML,部署后不变化。速度最快——CDN 直接返回静态文件,零服务端计算。 ```tsx // App Router 默认就是 SSG // 没有 dynamic 数据获取的页面自动静态生成 export default function AboutPage() { return <h1>关于我们</h1>; } // 带数据的 SSG:构建时获取 async function BlogList() { const posts = await db.post.findMany(); // 构建时执行 return posts.map(p => <article key={p.id}>{p.title}</article>); } ``` 局限:数据变化后需要重新构建部署。适合不常变的内容。 ## SSR(Server-Side Rendering) 每次请求时生成 HTML。数据始终最新,但每次请求都有服务端计算开销。 ```tsx // App Router: 使用动态数据获取自动触发 SSR async function Dashboard() { const stats = await fetch('https://api.example.com/stats', { cache: 'no-store' // 不缓存,每次请求重新获取 }).then(r => r.json()); return <div>{stats.users} 用户</div>; } ``` `cache: 'no-store'` 告诉 Next.js 这个请求不能缓存,必须每次执行。适合实时数据。 ## ISR(Incremental Static Regeneration) SSG 的升级版——静态生成 HTML,但后台定时重新生成。兼具 SSG 的速度和数据的新鲜度。 ```tsx async function ProductList() { const products = await fetch('https://api.example.com/products', { next: { revalidate: 3600 } // 每 3600 秒(1 小时)重新验证 }).then(r => r.json()); return products.map(p => <div key={p.id}>{p.name}</div>); } ``` ISR 的工作流程: 1. 第一个用户请求 → 返回缓存的静态 HTML(快) 2. 后台检查 revalidate 时间是否到期 3. 到期后重新生成 HTML,替换旧缓存 4. 下一个用户请求 → 返回新生成的 HTML 关键点:用户永远看到的是缓存的页面(快),后台异步更新。最坏情况数据延迟 `revalidate` 秒。 ## 怎么选 **选 SSG**:内容几乎不变(文档、博客、营销页) **选 ISR**:内容定期更新(商品列表、新闻、排行榜),能接受短暂延迟 **选 SSR**:内容必须实时(仪表盘、用户个人页、搜索结果) **常见错误**:所有页面都用 SSR。大部分页面用 ISR 就够了——1-5 分钟的数据延迟用户感知不到,但性能提升显著。 ## App Router 中的 revalidate 策略 ```tsx // 定时重新验证(ISR) fetch(url, { next: { revalidate: 60 } }); // 60 秒 // 按需重新验证:修改数据后手动触发 import { revalidateTag, revalidatePath } from 'next/cache'; // 在 Server Action 里触发 async function updateProduct(formData: FormData) { await db.product.update({ ... }); revalidateTag('products'); // 刷新所有 products 标记的缓存 revalidatePath('/products'); // 刷新 /products 页面 } ``` 按需重新验证(On-Demand Revalidation)比定时更精准——数据变了立即刷新,没变就不浪费资源。
服务端6月2日 23:14
Next.js Pages Router 和 App Router 有什么区别?该不该迁移?Pages Router 是 Next.js 的原始路由系统,App Router 是 13+ 引入的新系统。核心区别:App Router 基于 React Server Components,默认在服务端渲染;Pages Router 默认在客户端渲染。新项目用 App Router,老项目不急迁移。 ## 架构对比 | 维度 | Pages Router | App Router | |------|-------------|------------| | 目录 | `pages/` | `app/` | | 默认渲染 | 客户端 | 服务端(RSC) | | 数据获取 | getServerSideProps / getStaticProps | async 组件 + fetch | | 布局 | _app.tsx + 全局 Layout | 嵌套 layout.tsx | | 路由 | 文件即路由 | 文件夹 + page.tsx | | API | pages/api/ | app/ + route.ts | | Loading | 手动管理 | loading.tsx 自动 | ## 数据获取的变化 Pages Router 用特殊函数获取数据: ```tsx // Pages Router export async function getServerSideProps() { const data = await fetchData(); return { props: { data } }; } export default function Page({ data }) { return <div>{data}</div>; } ``` App Router 直接在组件里 async/await: ```tsx // App Router export default async function Page() { const data = await fetchData(); // 服务端直接执行 return <div>{data}</div>; } ``` App Router 的方式更直观——不需要记特殊函数名,数据获取就是普通的函数调用。 ## 布局系统 Pages Router 的布局是全局的(`_app.tsx`),切换页面时整个布局重新渲染。 App Router 支持嵌套布局——每个目录可以有 `layout.tsx`,子路由切换时父布局不重新渲染: ``` app/ ├── layout.tsx # 根布局(导航栏、页脚)— 永远不重新渲染 ├── dashboard/ │ ├── layout.tsx # 仪表盘布局(侧边栏)— 路由切换不重新渲染 │ ├── page.tsx # /dashboard │ └── settings/ │ └── page.tsx # /dashboard/settings ``` 用户从 /dashboard 切换到 /dashboard/settings 时,根布局和仪表盘布局都保持不变,只有 settings 的 page.tsx 重新渲染。这在 Pages Router 里做不到。 ## 何时迁移 **不急迁移的情况**: - 项目稳定运行,没有性能问题 - 团队不熟悉 RSC,迁移风险高 - 大量自定义 `_document.tsx` / `_app.tsx` 逻辑 **应该迁移的情况**: - 需要更好的首屏性能(RSC 显著减少 JS 体积) - 嵌套布局能解决当前的布局闪烁问题 - 项目刚开始或处于早期 迁移可以渐进式——两个路由可以共存。先把新页面用 App Router 写,老页面逐步迁移。 ## 两者可以共存 ``` pages/ ├── index.tsx # Pages Router 处理 ├── about.tsx # Pages Router 处理 app/ ├── dashboard/ │ └── page.tsx # App Router 处理 ``` Next.js 13+ 同时支持两套路由,同一个项目里渐进迁移。但要注意:同一路径不能两边都定义(`pages/dashboard.tsx` 和 `app/dashboard/page.tsx` 会冲突)。
服务端6月2日 23:13
Next.js 认证怎么做?NextAuth.js 配置 OAuth 和凭证登录实战Next.js 认证最主流的方案是 NextAuth.js(v5 改名 Auth.js)。它处理了 OAuth、JWT、Session 管理等所有细节,30 分钟就能搭好 Google/GitHub 登录。 ## 最快上手:NextAuth.js ```bash npm install next-auth@beta ``` ```typescript // app/api/auth/[...nextauth]/route.ts import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, }), ], pages: { signIn: '/login', // 自定义登录页 }, }); export const { GET, POST } = handlers; ``` ```typescript // middleware.ts — 保护路由 export { auth as middleware } from './app/api/auth/[...nextauth]/route'; export const config = { matcher: ['/dashboard/:path*'], }; ``` 三步:配置 provider → 创建 API 路由 → 加中间件保护。用户访问 /dashboard 时如果没有登录,自动跳转到 /login。 ## 在 Server Component 里获取 Session ```tsx import { auth } from './app/api/auth/[...nextauth]/route'; export default async function Dashboard() { const session = await auth(); if (!session) { redirect('/login'); } return <h1>欢迎, {session.user.name}</h1>; } ``` `auth()` 在服务端获取 session,不需要客户端 JavaScript。这是 RSC 的优势——认证逻辑完全在服务端,客户端零开销。 ## 在 Client Component 里获取 Session ```tsx 'use client' import { useSession } from 'next-auth/react'; export default function Profile() { const { data: session, status } = useSession(); if (status === 'loading') return <p>加载中...</p>; if (!session) return <p>请先登录</p>; return <p>{session.user.name}</p>; } ``` `useSession` 通过轮询 `/api/auth/session` 获取 session。更高效的方式是用 `SessionProvider`: ```tsx // app/providers.tsx 'use client' import { SessionProvider } from 'next-auth/react'; export function Providers({ children }) { return <SessionProvider>{children}</SessionProvider>; } ``` 包在 layout 里后,`useSession` 不再轮询,而是通过 Context 共享 session 数据。 ## 自定义登录页 默认登录页太简陋。自定义页面: ```tsx // app/login/page.tsx import { signIn } from '@/auth'; export default function LoginPage() { return ( <form action={async () => { 'use server' await signIn('github'); }}> <button type="submit">用 GitHub 登录</button> </form> ); } ``` 用 Server Action 触发 `signIn`,比客户端 `signIn()` 更简洁。 ## 凭证登录(用户名密码) OAuth 不够时,加 Credentials provider: ```typescript import Credentials from 'next-auth/providers/credentials'; import bcrypt from 'bcryptjs'; export const { handlers, auth } = NextAuth({ providers: [ Credentials({ credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials.email }, }); if (!user) return null; const valid = await bcrypt.compare(credentials.password, user.passwordHash); if (!valid) return null; return { id: user.id, name: user.name, email: user.email }; }, }), ], }); ``` `authorize` 返回 null 表示认证失败,返回 user 对象表示成功。密码必须用 bcrypt 哈希,不要存明文。 ## 常见问题 **Session 过期后页面不刷新**:用 `SessionProvider` 的 `refetchInterval` 定时刷新:`<SessionProvider refetchInterval={300}>` 每 5 分钟检查一次。 **OAuth 回调 404**:确保回调 URL 在 OAuth provider 里配置正确。GitHub 在 Settings > Developer > OAuth Apps 里配。 **部署后 Cookie 不工作**:`NEXTAUTH_URL` 环境变量必须设为生产域名。Vercel 部署时自动设置,其他平台需要手动配。
服务端6月2日 23:11
React Server Components 是什么?和 Client Components 怎么配合?React Server Components(RSC)是只在服务端渲染的组件——它们的代码不会发送到浏览器。这是 React 架构的根本变化:组件不再默认跑在客户端,而是默认跑在服务端。 ## RSC 解决什么问题 传统 React 应用把所有组件代码打包成 JS 发给浏览器。一个列表页可能有 200KB 的 JS,但大部分是数据获取和渲染逻辑,用户交互只占一小部分。用户要等 JS 下载、解析、执行完才能看到页面。 RSC 的解决方案:数据获取和渲染在服务端完成,只把 HTML 和少量交互代码发给浏览器。结果:更快的首屏、更小的 JS 包、更简单的数据获取。 ## Server Component vs Client Component ```tsx // Server Component(默认)— 不发 JS 给浏览器 async function ArticleList() { const articles = await db.article.findMany(); // 直接查数据库 return ( <ul> {articles.map(a => <li key={a.id}>{a.title}</li>)} </ul> ); } // Client Component — JS 会发给浏览器 'use client' import { useState } from 'react'; function SearchBox() { const [query, setQuery] = useState(''); // 需要状态 return <input value={query} onChange={e => setQuery(e.target.value)} />; } ``` 判断标准很简单:需要 `useState`、`useEffect`、`onClick` 等 React hooks/事件的就是 Client Component,否则就是 Server Component。 ## 数据获取方式的变化 Pages Router 时代,客户端获取数据: ```tsx // Pages Router — 客户端获取 useEffect(() => { fetch('/api/articles').then(r => r.json()).then(setArticles); }, []); ``` App Router + RSC,服务端直接获取: ```tsx // App Router — 服务端获取 async function Page() { const articles = await db.article.findMany(); return <ArticleList articles={articles} />; } ``` 不需要 API 路由,不需要 loading 状态管理,不需要客户端缓存。服务端拿到数据直接渲染成 HTML。 ## 组合模式 Server Component 可以渲染 Client Component,但反过来不行: ```tsx // Server Component import SearchBox from './SearchBox'; // Client Component async function Page() { const data = await fetchData(); return ( <div> <SearchBox /> {/* Client Component:交互 */} <ArticleList data={data} /> {/* Server Component:展示 */} </div> ); } ``` 关键规则: - Server Component 可以 import 和渲染 Client Component - Client Component 不能 import Server Component - Server Component 可以通过 props 把数据传给 Client Component(必须是可序列化的数据) ## 什么时候用 Client Component 只有这四种情况需要 `'use client'`: 1. 需要交互(onClick、onChange) 2. 需要状态(useState、useReducer) 3. 需要生命周期(useEffect) 4. 需要浏览器 API(window、localStorage) 其他都用 Server Component。一个常见错误:因为不熟悉 RSC 而给所有组件加 `'use client'`——这样 App Router 就退化成了 Pages Router,失去了 RSC 的性能优势。 ## RSC 的局限 - Server Component 不能用 hooks(useState、useEffect) - Server Component 不能用浏览器 API(window、document) - Server Component 传给 Client Component 的 props 必须可序列化(不能传函数、类实例) - 调试更难——错误堆栈跨服务端和客户端 RSC 仍然在快速演进,API 可能在未来版本变化。但方向是明确的:服务端渲染更多,客户端 JS 更少。
服务端6月2日 23:10
Next.js Server Actions 怎么用?表单提交、状态管理和安全验证Server Actions 是 Next.js 的服务端函数——在服务端执行,客户端直接调用,不需要手写 API 路由。底层是 POST 请求 + 加密签名,比传统 fetch + API Route 更简洁。 ## 基本用法 ```tsx // app/actions.ts 'use server' export async function createPost(formData: FormData) { const title = formData.get('title') as string; const content = formData.get('content') as string; await db.post.create({ data: { title, content } }); revalidatePath('/posts'); // 刷新缓存 } ``` ```tsx // app/posts/page.tsx import { createPost } from './actions'; export default function NewPost() { return ( <form action={createPost}> <input name="title" /> <textarea name="content" /> <button type="submit">发布</button> </form> ); } ``` 没有 API 路由,没有 fetch,没有 JSON 序列化。表单提交直接触发服务端函数。 ## Server Action 的本质 Server Action 编译后变成一个 POST 请求: ``` POST /_next/data/... HTTP/1.1 Content-Type: text/x-component Next-Action: hashed-action-id FormData body ``` Next.js 自动给 action 生成加密 ID,客户端调用时带这个 ID。请求到达服务端后,Next.js 根据 ID 找到对应函数执行。所以即使有人猜到 URL,没有正确的 action ID 也无法调用。 ## useActionState 管理状态 ```tsx 'use client' import { useActionState } from 'react'; import { createPost } from './actions'; export default function NewPost() { const [state, formAction, isPending] = useActionState( async (prevState, formData) => { const result = await createPost(formData); return result; }, null ); return ( <form action={formAction}> <input name="title" /> <button type="submit" disabled={isPending}> {isPending ? '提交中...' : '发布'} </button> {state?.error && <p>{state.error}</p>} </form> ); } ``` `isPending` 在请求期间为 true,可以显示加载状态。`state` 保存上一次 action 的返回值。 ## 输入验证 永远不要信任客户端数据。Server Action 里必须验证: ```typescript 'use server' import { z } from 'zod'; const schema = z.object({ title: z.string().min(1).max(200), content: z.string().min(10), }); export async function createPost(formData: FormData) { const parsed = schema.safeParse({ title: formData.get('title'), content: formData.get('content'), }); if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors }; } await db.post.create({ data: parsed.data }); revalidatePath('/posts'); } ``` Zod 验证比手写 if-else 更可靠,错误信息也结构化。 ## revalidatePath 和 revalidateTag Server Action 修改数据后,需要告诉 Next.js 刷新缓存: - `revalidatePath('/posts')`:刷新指定路径的缓存 - `revalidateTag('posts')`:刷新所有带 `fetch(..., { next: { tags: ['posts'] } })` 标记的请求缓存 `revalidateTag` 更灵活——一个 tag 可以对应多个页面,改一次全部刷新。 ## 安全注意事项 Server Action 虽然有加密 ID 保护,但仍然是公开的 HTTP 端点: - 必须在 action 内做认证检查(`getServerSession()`) - 必须验证输入(Zod) - 不要在 action 里返回敏感信息(返回值会发给客户端) ```typescript 'use server' export async function deleteAccount(formData: FormData) { const session = await getServerSession(); if (!session) throw new Error('Unauthorized'); // ... } ``` ## 和 API Route 的区别 - **Server Action**:适合表单提交、数据变更,自动处理 loading/error 状态 - **API Route**:适合第三方回调(Webhook)、文件上传、需要自定义响应头 简单场景用 Server Action 更简洁。需要精细控制 HTTP 响应时用 API Route。
服务端6月2日 23:10
Next.js 中间件怎么用?认证重定向、A/B 测试和 Edge Runtime 限制Next.js 中间件在请求到达页面之前执行,适合做认证检查、路由重写、A/B 测试等。它跑在 Edge Runtime 上,冷启动快但功能有限——不能用 Node.js API。 ## 基本用法 在项目根目录创建 `middleware.ts`: ```typescript import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { // 请求到达页面之前执行 console.log(request.nextUrl.pathname); return NextResponse.next(); // 放行,继续处理请求 } // 限制中间件只对匹配的路径生效 export const config = { matcher: ['/dashboard/:path*', '/api/:path*'], }; ``` `matcher` 很重要——不设的话每个请求(包括静态资源)都经过中间件,拖慢性能。 ## 认证重定向 最常见的用例:未登录用户跳转到登录页: ```typescript export function middleware(request: NextRequest) { const token = request.cookies.get('session-token'); if (!token) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname); return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { matcher: ['/dashboard/:path*'], }; ``` ## 修改请求头 中间件可以给请求加 header,页面里用 `headers()` 读取: ```typescript export function middleware(request: NextRequest) { const requestHeaders = new Headers(request.headers); requestHeaders.set('x-pathname', request.nextUrl.pathname); return NextResponse.next({ request: { headers: requestHeaders }, }); } ``` 页面里: ```typescript import { headers } from 'next/headers'; export default function Page() { const pathname = headers().get('x-pathname'); // ... } ``` ## A/B 测试 根据 Cookie 或随机分配给用户不同版本: ```typescript export function middleware(request: NextRequest) { let variant = request.cookies.get('ab-variant')?.value; if (!variant) { variant = Math.random() > 0.5 ? 'A' : 'B'; } const response = NextResponse.next(); response.cookies.set('ab-variant', variant); // 重写到不同页面 if (variant === 'B' && request.nextUrl.pathname === '/pricing') { return NextResponse.rewrite(new URL('/pricing-b', request.url)); } return response; } ``` `NextResponse.rewrite` 在服务端切换到另一个页面,用户看到的 URL 不变。 ## 限制:Edge Runtime 中间件跑在 Edge Runtime,不是 Node.js。这意味着: - 不能用 `fs`、`path`、`crypto`(Node.js 内置模块) - 不能用 `prisma`、`mongoose`(数据库客户端) - 不能用 `jsonwebtoken`(需要 crypto) - 执行时间限制 30 秒(Vercel 上是 25 秒) 简单的 token 验证、Cookie 操作没问题。复杂的认证逻辑(查数据库验证 token)不应该放中间件——放在页面组件或 API 路由里。 ## 常见问题 **中间件死循环**:中间件重定向到 /login,但 /login 也匹配了 matcher。解决:matcher 排除 /login,或在中间件里判断路径。 **cookies 在 Server Component 里读不到**:中间件 `response.cookies.set()` 设置的 Cookie 在同一请求的 Server Component 里读不到——因为中间件的 response 还没返回给浏览器。需要通过 request headers 传递。
服务端6月2日 23:08
Next.js 性能怎么优化?Server Components、图片和缓存策略实战Next.js 性能优化从三个方向入手:减少客户端 JavaScript 体积、加快页面加载速度、优化数据获取策略。App Router 的 Server Components 天然比 Pages Router 快——大部分代码不发给浏览器。 ## 1. Server Components 优先 App Router 默认所有组件都是 Server Component。只在需要交互(useState、useEffect、onClick)时才加 `'use client'`: ```tsx // Server Component(默认)— 不发 JS 给浏览器 export default function Page() { return ( <div> <h1>标题</h1> {/* 静态内容 → Server Component */} <SearchBox /> {/* 需要交互 → Client Component */} <ArticleList articles={articles} /> {/* 纯展示 → Server Component */} </div> ); } ``` 常见错误:整个页面都标 `'use client'`。正确做法是把交互部分抽成小的 Client Component,外层保持 Server Component。 ## 2. 图片优化 `next/image` 自动做三件事:懒加载、按设备尺寸返回合适分辨率、WebP 格式转换。 ```tsx import Image from 'next/image'; <Image src="/hero.jpg" width={1200} height={600} alt="描述" priority // 首屏图片加这个,跳过懒加载 placeholder="blur" // 模糊占位,加载时不会闪白 /> ``` 必须填 width 和 height——防止布局偏移(CLS)。`priority` 只给首屏可见图片用,多了反而拖慢 LCP。 外部图片需要配置域名白名单: ```typescript // next.config.js images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com' }, ], }, ``` ## 3. 字体优化 `next/font` 自动内联字体文件,消除 FOUT(字体闪烁): ```tsx import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'] }); export default function Layout({ children }) { return <body className={inter.className}>{children}</body>; } ``` 不要用 CSS `@import` 加载 Google Fonts——它会阻塞渲染。`next/font` 在构建时下载字体文件,零网络请求。 ## 4. 动态导入减少首屏 JS 非首屏需要的组件用 `dynamic` 懒加载: ```tsx import dynamic from 'next/dynamic'; const HeavyChart = dynamic(() => import('./HeavyChart'), { loading: () => <div>加载中...</div>, ssr: false, // 纯客户端组件不需要 SSR }); ``` `ssr: false` 跳过服务端渲染——适合图表、编辑器这类重交互、不需要 SEO 的组件。 ## 5. 数据获取策略 App Router 的 `fetch` 默认有缓存: ```tsx // 缓存(默认)— 适合不常变的数据 const data = await fetch('https://api.example.com/data'); // 不缓存 — 每次请求都重新获取 const data = await fetch('https://api.example.com/data', { cache: 'no-store' }); // 定时重新验证 — 适合有一定延迟容忍的数据 const data = await fetch('https://api.example.com/data', { next: { revalidate: 3600 } // 1 小时后重新验证 }); ``` ISR(Incremental Static Regeneration)是 Next.js 的杀手锏:静态页面生成后定时更新,不需要每次请求都渲染。`revalidate` 的时间根据数据变化频率设置——新闻列表 60 秒,配置数据 3600 秒。 ## 6. Layout 防止重复渲染 App Router 的 `layout.tsx` 在导航时不会重新渲染。把不会变的 UI(导航栏、页脚)放在 layout 里: ```tsx // app/layout.tsx — 只渲染一次 export default function RootLayout({ children }) { return ( <html> <body> <nav>导航栏</nav> {children} {/* 只有这部分会随路由变化 */} <footer>页脚</footer> </body> </html> ); } ``` ## 7. 分析打包体积 ```bash npx @next/bundle-analyzer ``` 生成可视化报告,找出最大的包。常见问题:整个 lodash 被 import(改用 `lodash-es` 的按需导入)、moment.js 太大(改用 dayjs)、客户端不必要的包。 ## 性能优化检查清单 - Server Component 优先,'use client' 只在需要交互时用 - 图片用 next/image + width/height + priority(首屏) - 字体用 next/font,不用 CSS @import - 非首屏组件 dynamic 导入 - fetch 设置合理的 cache/revalidate 策略 - 静态 UI 放 layout,不放 page - bundle-analyzer 检查大包
服务端6月2日 23:07
Next.js 应用有哪些安全风险?环境变量泄露、XSS 和 CSRF 防护实战Next.js 应用的安全风险主要来自三方面:服务端渲染(SSR)泄露敏感数据、API 路由缺乏认证、客户端代码暴露过多信息。逐个堵住就行。 ## 1. 环境变量:服务端 vs 客户端 Next.js 的环境变量默认只在服务端可用。以 `NEXT_PUBLIC_` 开头的才会暴露给浏览器。最常见的错误:把数据库密码、API Key 加了 `NEXT_PUBLIC_` 前缀。 ```bash # .env.local — 只在服务端可用 DATABASE_URL=postgresql://... STRIPE_SECRET_KEY=sk_live_... # .env.local — 暴露给浏览器(谨慎使用) NEXT_PUBLIC_API_URL=https://api.example.com NEXT_PUBLIC_GA_ID=G-XXXXXXX ``` 检查方法:浏览器 F12 > Sources > 搜索你的密钥。如果搜到了,说明 `NEXT_PUBLIC_` 用错了。 ## 2. API 路由必须加认证 App Router 的 Route Handlers 默认没有任何认证: ```typescript // app/api/users/route.ts — 裸奔的 API,任何人都能访问 export async function GET() { const users = await db.user.findMany(); return Response.json(users); } ``` 加上认证中间件: ```typescript import { getServerSession } from 'next-auth'; export async function GET(request: Request) { const session = await getServerSession(); if (!session) { return new Response('Unauthorized', { status: 401 }); } const users = await db.user.findMany(); return Response.json(users); } ``` 更高效的方式:用 Next.js 中间件统一拦截,不用每个路由单独写: ```typescript // middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const token = request.cookies.get('session-token'); if (!token && request.nextUrl.pathname.startsWith('/api/')) { return new Response('Unauthorized', { status: 401 }); } return NextResponse.next(); } export const config = { matcher: '/api/:path*', }; ``` ## 3. 防止 XSS React 默认转义 HTML,XSS 风险不大。但 `dangerouslySetInnerHTML` 是例外: ```tsx // 危险:用户输入的 HTML 直接渲染 <div dangerouslySetInnerHTML={{ __html: userContent }} /> // 安全:先用 DOMPurify 清洗 import DOMPurify from 'isomorphic-dompurify'; <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} /> ``` 另一个 XSS 来源:URL 参数直接插入页面。Next.js 的 `useSearchParams()` 读取的值如果不转义就渲染,可能被注入。 ## 4. CSRF 防护 SameSite Cookie 是最简单的 CSRF 防护: ```typescript // 设置 Cookie 时加 SameSite cookies().set('session-token', token, { httpOnly: true, secure: true, sameSite: 'lax', // 阻止跨站请求携带 Cookie path: '/', maxAge: 60 * 60 * 24 * 7, }); ``` `sameSite: 'lax'` 允许顶层导航携带 Cookie(用户点链接跳转正常),但阻止跨站 POST 请求携带。对大部分应用够用。 ## 5. Content Security Policy CSP 限制页面能加载哪些外部资源,防止恶意脚本注入: ```typescript // middleware.ts export function middleware(request: NextRequest) { const response = NextResponse.next(); response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.example.com; style-src 'self' 'unsafe-inline';" ); return response; } ``` CSP 配置比较繁琐,建议用 `next-safe` 库简化。先从 `default-src 'self'` 开始,逐步放宽需要的域名。 ## 6. 依赖安全审计 ```bash npm audit # 检查已知漏洞 npm audit fix # 自动修复 npx better-npm-audit audit # 更详细的审计 ``` 定期跑 `npm audit`,高危漏洞必须修。Next.js 自身也有安全更新——保持版本最新。 ## 安全检查清单 - 环境变量没有敏感信息泄露到客户端 - API 路由都有认证 - Cookie 设置了 httpOnly + secure + sameSite - dangerouslySetInnerHTML 用了 DOMPurify - 有 CSP 头 - npm audit 无高危漏洞