ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Next.js静态生成实战:SSG原理到ISR配置,解决性能与SEO优化难题

Next.js静态生成实战:SSG原理到ISR配置,解决性能与SEO优化难题 最近接了个优化个人博客的活儿首屏白屏五六秒文章页收录率低得可怜客户改后台配置改到头大。我最后把整站切到了 Next.js用静态生成SSG重写了核心页面效果立竿见影——构建产物里直接躺着一整套 HTML 文件CDN 一挂速度快得像打开本地文件。这篇东西不是照着文档念 API而是把我在实际项目里对 Next.js 静态生成的理解、踩坑和配置经验整理出来。不管你是刚接触 Next.js 的新人还是已经在用但没吃透 SSG 机制的老手看完应该都能搞明白静态生成到底解决什么问题getStaticProps 和 getStaticPaths 怎么用才不踩坑以及 fallback 和 revalidate 这些高级选项在真实场景里到底怎么配合。1. 静态生成到底在解决什么问题1.1 三种渲染方式一张表看明白很多人听到静态生成这个词第一反应是这不就是最古老的服务端输出 HTML 吗。这么理解不算错但容易把它的价值想小了。为了讲清楚我把现代前端主流的三种渲染方式拉出来对比一下。渲染方式渲染时机页面内容来源首屏体验SEO 友好度典型场景CSR客户端渲染浏览器加载 JS 后运行时请求 API白屏等待 JS 解析差爬虫可能看不到内容后台管理系统、强交互应用SSR服务端渲染每次请求时服务端实时取数据首屏由服务器返回但仍需等待接口好内容在 HTML 里个性化页面、实时数据要求高的页面SSG静态生成构建时一次性生成构建时取的静态数据极致HTML 文件直接给到浏览器最好HTML 完整博客、文档站、营销页、产品介绍页CSR 的问题在于爬虫和浏览器拿到的初始 HTML 往往只有一个空壳和一堆 script 标签。就算 Google 的爬虫也能执行 JavaScript但执行顺序、渲染成本始终是个不确定性因素而且用户侧白屏时间很难看。SSR 解决了首屏问题每次请求都在服务端凑齐数据再返回但代价是服务器压力大、响应时间长。一个请求要过一层 CDN还要穿透到源站去渲染一次热门页面人一多资源开销蹭蹭往上涨。SSG 是最省事的方案构建时拿到数据把页面固化成静态 HTML。发给用户的不是渲染指令而是渲染结果。第一次加载就快之后每次都快CDN 随便缓存服务器几乎不用做实时计算。1.2 SSG 没有被淘汰反而被 ISR 重新激活有人觉得静态生成只适合内容永远不变的小站。这个印象停留在很久以前了。Next.js 从 9.5 开始引入增量静态再生ISRIncremental Static Regeneration之后SSG 的适用范围被大幅拉开。ISR 的思路很讨巧页面构建时先静态生成一版之后当用户访问到过期页面时触发后台重新生成并缓存新版本。用户在页面上的体验始终是静态文件而数据更新这件事被放到了后台异步完成。就好比你开了一家快餐店菜单是提前印好的但每隔一段时间你会在后厨把菜单内容偷偷更新一版客人拿到手的永远是快速打印出来的最新版而不会在柜台前等十分钟。ISR 让静态生成这件事从一次性变成了滚动更新。内容型网站、电商商品页、半实时榜单全都可以用 SSG ISR 撑起来。1.3 判断清单这个页面该不该用 SSG业务里我一般拿下面这个清单快速做判断页面内容是否对单个用户高度个性化如果每个用户看到的都不一样SSG 不适合优先考虑 SSR 或客户端渲染。数据更新频率是否可容忍秒级到分钟级的延迟能容忍ISR 就能扛。页面是否依赖登录态或 Cookie依赖的话不能直接静态生成。内容是否以读为主并发再高也不需要频繁回源这种页面是 SSG 的最佳目标。对照这个清单绝大多数博客、文档、官网、帮助中心、活动页都能放心上 SSG。真正做不了的是那些千人千面仪表盘和实时榜单这时候强行静态化属于自我感动。2. getStaticProps构建期的数据注入与执行边界2.1 它在哪运行什么时候运行在 Next.js 的 Pages Router 里getStaticProps 是整个 SSG 流程的核心。它的执行时机是构建阶段确切说是在 Next.js 编译每个页面、打算生成静态 HTML 的时候。函数在 Node.js 环境执行不是浏览器环境。这意味着两件事。第一getStaticProps 里可以随便走 Node.js 的 fs 模块读本地文件烤面包机里放面包这个操作是合法且常见的。第二千万不要在里面用 window、document 这类浏览器 API代码在构建机上跑没有这些东西。写了这些代码构建时直接报错给你看。还有一点很容易被忽略getStaticProps 里写的代码不会进入客户端的 JavaScript bundle。你在这个函数里写的数据库密码、私钥、内部接口地址构建之后不会暴露给浏览器。这个特性在做一些数据清洗、内部统计或降级逻辑时非常有用。2.2 getStaticProps 的返回值每一个都要理解getStaticProps 返回的对象里最常用的是 props它会被传给页面组件。但另外几个字段才是真正影响 SSG 行为的关键revalidate单位是秒标记这个页面多长时间视为陈旧。到了时间用户访问时会触发后台再生。这是 ISR 的生命线。notFound返回 true 时Next.js 会把页面当成 404 处理。静态生成阶段已知某些路径无数据可以直接让页面变成 404。redirect返回重定向配置构建期就能对旧链接做 301/302 跳转减少运行时逻辑。notFound和redirect我用的频率不高因为大部分情况我会放在业务代码里处理。但在数据源删除导致页面必须下线的场景里它们挺好用。2.3 一个真实的数据读取示例假设我们要做一个开源的文档站本地有一堆 Markdown 文件构建时需要把它们读出来生成目录。getStaticProps 的写法如下// pages/docs/[slug].js import fs from fs import path from path import matter from gray-matter export async function getStaticProps({ params }) { const filePath path.join(process.cwd(), docs, ${params.slug}.md) const fileContent fs.readFileSync(filePath, utf-8) const { data, content } matter(fileContent) return { props: { title: data.title, content, }, // 文档内容更新后最多每 60 秒重新生成一次 revalidate: 60, } }这段代码对应的是内容存在本地、构建时可即时读取的场景。如果数据源在远端比如从 CMS 接口拿数据我强烈建议在 getStaticProps 里做超时控制和降级处理。构建机如果访问不到接口整个构建直接失败是生产环境最常踩的坑之一。后面我会专门讲。2.4 常见误区把 getStaticProps 当成请求处理器最典型的误区是在 getStaticProps 里读取 req.headers试图拿到当前用户的信息。构建时根本没有当前用户这个概念同一份静态 HTML 会服务所有访问者。这类需求应该走 SSR 或客户端中间件处理。另一个常见错误是在 getStaticProps 里塞一个依赖用户时区的日期函数——构建时生成的时间戳是构建机时区的时间放到页面上会被全世界用户看到。需要当前时间的组件建议放到客户端动态渲染。3. getStaticPaths 与动态路由把未知 URL变成已知 URL3.1 动态路由与 SSG 的组合逻辑动态路由是另一个让 SSG 使用者容易晕的点。比如博客文章页路径是/posts/[slug]构建时 Next.js 不知道你打算生成哪些 slug 对应的页面。它只知道有这个路由规则但具体生成哪一百个页面这件事需要显式告诉它。这个任务就落在 getStaticPaths 上。函数返回的内容本质是我允许哪些路径参数参与静态生成。// pages/posts/[slug].js export async function getStaticPaths() { const posts await getAllPosts() // 假设从 CMS 或本地文件读取 const paths posts.map((post) ({ params: { slug: post.slug } })) return { paths, fallback: false, } }看到这里你应该能理解 SSG 的整体链路了先通过 getStaticPaths 枚举出所有合法的动态参数再对每个参数执行 getStaticProps 读取数据、渲染静态 HTML。一个动态路由模板在构建时被展开成几十上百个静态页面。3.2 fallback 的三种模式选错是要出事的getStaticPaths 返回值里最关键的字段是 fallback它有三种取值很多项目从文档上抄代码根本不理解差异跑到生产环境就出事。fallback 值行为用户体验适用场景false未列出的路径直接返回 404访问非预生成路径立即 404博客、文档站路径全集已知true未列出的路径先返回降级页再在后台生成并缓存首次访问有轻微 fallback 状态之后变正常内容量大、无法一次性全量预生成blocking未列出的路径由服务器等待生成后再返回首次访问等待时间取决于生成速度后面对所有用户都是静态内容更新频繁但希望首次访问不看到降级页fallback: false是所有路径预先可知的方案构建时全量生成未在 paths 列表里的内容直接 404。简单直接但要保证数据源在构建时是完整且确定的。fallback: true有点像懒生成——用户第一次访问一个不在列表里的 URL 时Next.js 会返回一个带router.isFallback true的降级页面让你显示 loading 占位同时在后台生成真正的页面并缓存。同一个 URL 第二次访问时就直接命中缓存好的静态页了。fallback: blocking是服务器等待生成完再返回的模式首访请求会等页面真正生成出来之后同样命中静态缓存。体验上比 true 更平滑也不容易产生页面闪了一下的问题。生产环境我建议你先想清楚业务场景路径全集有限、确定不变用 false内容量大可能有新条目但不想全量构建用 blocking对首访体验容忍度高愿意做 fallback 占位展示用 true。选错模式的结果要么是大量 404要么是首访性能崩要么是用户看到不该出现的 loading。3.3 增量生成的组合revalidate fallback 的边界体验当 fallback 不为 false且 getStaticProps 里配置了 revalidate两者组合起来就是一套完整的 ISR 行为已知路径构建时生成未知路径延迟生成所有页面定期后台刷新。实际访问链路是这样的用户第一次访问/posts/new-slug路径不在预生成的 paths 里。if fallback 为 blocking服务器开始执行该页面的 getStaticProps生成 HTML返回给用户。HTML 同时被写入构建缓存之后所有/posts/new-slug的请求都直接由 CDN 缓存兜底。到了 revalidate 约定的时间后下一个访问者触发后台重新生成用户拿到的仍是旧版本页面但后台已经悄悄做了一次刷新。这套机制跑通后站点新增一篇文章不需要重新构建整站数据源有了新内容用户第一次访问时才会顺带把它生成出来。相比 full rebuild省的不是一星半点。4. 从零搭一个 SSG 博客完整流程与生产配置4.1 项目结构与静态页面生成流程我在做一个典型的 SSG 博客时一般把项目结构分成两块数据获取层放在lib/里页面模板放在pages/下。my-blog/ ├── lib/ │ ├── posts.js # 读取所有文章数据 │ └── markdown.js # Markdown 转 HTML ├── pages/ │ ├── index.js # 首页列出所有文章 │ └── posts/ │ └── [slug].js # 文章详情页 └── content/ └── hello-world.mdlib/posts.js里我会封装一个getAllPosts()它返回文章元信息slug、标题、日期以及一个getPostBySlug(slug)返回正文内容。这两个函数分别对应 getStaticPaths 和 getStaticProps 的需求。首页的 getStaticProps 负责把所有文章的标题、日期、摘要取出来生成一个文章列表页。文章详情页则依赖 getStaticPaths 枚举全部 slug再对每个 slug 调用 getStaticProps 获取正文。构建流程跑起来后你去看.next/server/app/pages目录就能看到生成的静态 HTML 文件这说明 SSG 真正生效了。4.2 App Router 时代的新写法别学岔了Next.js 13 之后的 App Router 改变了静态生成的写法很多新项目直接上了 Pages Router 的旧文档结果发现 API 对不上。这里必须说清楚两者的对照关系。在 App Router 模式下app/目录取代了pages/getStaticPaths的位置由文件内导出的generateStaticParams取代它返回一个参数数组。getStaticProps不再存在取而代之的是页面组件直接使用顶层fetch配合next: { revalidate }选项或在使用第三方数据源时通过export const dynamic force-static强制静态化。fallback的对应项变成了export const dynamicParams true/false。页面组件的默认导出对应原来的页面模板。一个 App Router 博客文章页的写法大概长这样// app/posts/[slug]/page.js import { getPostBySlug, getAllPosts } from /lib/posts export async function generateStaticParams() { const posts await getAllPosts() return posts.map((post) ({ slug: post.slug })) } export const dynamicParams true export const revalidate 60 export default async function PostPage({ params }) { const post await getPostBySlug(params.slug) return article{post.title}/article }如果你正在维护旧项目Pages Router 的写法完全够用且稳定。如果是新项目直接上 App Router 的新写法别在旧 API 上投入太多学习成本。4.3 生产部署时最容易忽略的配置SSG 页面跑到生产环境有四个配置细节我是每次都检查的第一next.config.js里如果用了output: export那 ISR 就完全失效了。这个配置相当于把 Next.js 降级成纯静态站点生成器所有动态能力都被剪掉。用了它getStaticProps 里的 revalidate 实际上不会生效next export 模式下也不会执行增量再生。第二图片优化。Next.js 默认图片组件是运行时按需优化的一旦部署到纯静态托管或用 output: export图片优化服务没法跑需要设置images: { unoptimized: true }或者预先生成好图片格式。第三CDN 缓存。SSG 页面本身适合在 CDN 层做长缓存但如果你的页面有 revalidate 需求CDN 缓存时间不能设置得太离谱。否则可能出现更新了数据但 CDN 还缓存着三天前的页面这种事故。建议 CDN 缓存时间设置为 revalidate 的一半左右或者直接遵循源站返回的 Cache-Control。第四构建时的环境变量。Next.js 在构建时会内联NEXT_PUBLIC_开头的环境变量到浏览器 bundle 中。如果某个页面内容依赖环境变量切换请务必确认构建时环境变量是对的否则你会发现本地好好的线上页面内容不对。5. 我在生产环境踩过的 SSG 坑5.1 构建期请求第三方 API超时与降级策略这是我被坑得最惨的一次。项目里有个页面需要从第三方 CMS 拉取文章列表我直接在 getStaticProps 里await fetch(cmsApi)本地构建一切正常一上 CI 构建就时不时挂掉。排查了半天发现是第三方接口偶尔超时一旦超时整个构建流程崩掉所有页面全军覆没。解决办法是给所有构建期的外部请求加超时控制和降级方案。请求超过几秒就放弃降级成用本地缓存数据构建或生成一个带默认内容的页面保证构建流程不会因为一个外部服务不可用而整体挂掉。export async function getStaticProps() { const controller new AbortController() const timeout setTimeout(() controller.abort(), 5000) try { const res await fetch(https://api.example.com/posts, { signal: controller.signal, }) // ... } catch (error) { // 降级读取上一次构建时留下的本地缓存 const fallbackPosts JSON.parse( fs.readFileSync(path.join(process.cwd(), .cache/posts.json), utf-8) ) return { props: { posts: fallbackPosts } } } finally { clearTimeout(timeout) } }这个思路特别适合内容站内容短暂不可用可以忍构建失败是一分钟都忍不了的。5.2 revalidate 与 CDN 缓存叠加的延迟生效还有一次客户说博客更新了一篇文章线上访问还是旧内容。我看代码一眼就知道问题页面配了revalidate: 60但 CDN 厂商把静态资源的缓存默认设成了 24 小时。用户访问时CDN 节点上的旧 HTML 直接命中压根没回源自然触发不了 Next.js 的后台再生逻辑。SSG ISR 的生效链路是依赖回源频率的。CDN 缓存越久ISR 的刷新频率实际体验就越慢。想控制更新时效得把 CDN 缓存策略一起设计进去而不是只调 Next.js 那行配置。比较好的做法对包含 ISR 的页面让 CDN 缓存时间等于 revalidate 值或直接配置按 query 参数回源。5.3 环境变量的构建期与运行期陷阱SSG 页面的环境变量问题是最隐蔽的。有一次我在NEXT_PUBLIC_API_URL里配了域名本地运行没问题构建产物测试也没问题结果上线后页面里所有接口都请求到内网域名去了。原因很简单构建机上的环境变量是内网地址构建时被内联到了 JS bundle 和静态 HTML 里。NEXT_PUBLIC_开头的变量在构建期就被写死到产物里它不是运行时读取的。SSG 页面尤其要注意因为你连运行时读取环境变量的机会都没有。处理方式是把这类变量统一放到构建机的环境配置里并仔细检查生产构建命令对应的环境变量来源。5.4 对 SSG 的错误预期build 时间不受控怎么办还有一个容易让团队崩溃的问题内容越来越多全量静态生成越来越慢。原来 build 3 分钟内容翻倍后变成 6 分钟再往后可能要 20 分钟。这不是 bug是 SSG 的固有特性——所有路径都在构建期展开内容量直接等于构建时间。遇到这种情况我的经验是不要让所有页面都走全量预生成改用少量核心页预生成 fallback 延迟生成 revalidate 增量刷新的三级组合。先把核心路径预生成出来保证首访体验其余路径靠 ISR 兜底。构建时间从全量内容数降成核心内容数能撑很久。我在实际项目里保留的一个习惯是每次调整 SSG 相关配置后一定会在生产环境做一个假更新确认 revalidate 后 CDN 缓存行为、页面内容更新都符合预期后才放手。SSG 看着简单但构建期数据 运行时缓存 CDN 兜底这条链路上任何一环配置错了表现都非常隐蔽。希望这套从原理到实战的梳理能帮你少走我这几年走过的弯路。
返回列表