☰
用 Next.js 静态导出与 Cloudflare Pages 构建高性能导航站
2026/10/12 10:36:24 网站建设 项目流程

大概两年前,朋友把一个手写的导航站扔给我,说“帮我改一版,链接越来越多,我改 HTML 改得快疯了”。我打开那个文件,几千行的单一页面,每个分类靠注释分隔,每条链接都是一段复制粘贴的列表项。那一刻我就清楚,真正的成本不在改一个链接,而在每次修改都要小心翼翼地不破坏别的东西。后来我用 Next.js 重写了整个导航站,部署到 Cloudflare Pages,把维护工作彻底压缩成了“改数据、提交代码”两步。

这篇文章把我从选型、数据设计、核心代码到性能优化、部署踩坑的完整过程记录下来。方案适用对象很明确:你手头有一个链接量在几十到几百级别的导航站,希望用最低的维护成本换尽可能快的首屏速度,并且不想为它单独养一台服务器。如果你正好在做工具站、网址聚合页、个人收藏夹导航这类项目,这篇文章应该能直接照抄。

1. 导航站的技术选型:不选纯静态,也不选数据库的理由

先说结论:导航站在技术形态上非常特殊——它的内容不是文章,而是一批结构化的外链。用户访问它只有一个诉求:最快找到目标链接,点完就走。所以决定方案的不是功能多少,而是下面这三个约束。

1.1 导航站的真实约束:快、省、稳

很多人做导航站时第一反应是“用什么框架”,我却习惯先问“这个站点的核心指标到底是什么”。导航站的核心指标只有三个:

  • 首屏要足够快,快到用户几乎没有等待感。导航站的价值就是“立刻找到链接”,加载慢一秒都会流失用户。
  • 维护成本要足够低,一个人也能长期更新。链接会不断增加、失效、改名,每周都可能要动数据。
  • 部署成本要趋近于零,最好不依赖一台长期运行的服务器。为一个静态内容站点养服务器,纯属浪费。

这三个约束直接排除了很多常见选择。纯手工维护的静态 HTML 虽然在速度上没有对手,但内容一旦过百,分类增删、链接排序、图标维护就会变成体力活;WordPress、Typecho 这类带后台的 CMS 功能强大,可为了一个导航站养数据库加后台,有点杀鸡用牛刀;SPA 方案(React/Vue 单页应用)开发效率高,但所有内容要靠 JS 拼接,首屏有白屏风险,SEO 也不好——导航站虽然很少有用户通过搜索进来,但链接被搜索引擎收录后,对增长还是有帮助的。

1.2 主流方案的取舍对比

我把几个常见方案放在一起对比过:

方案维护成本首屏速度服务器依赖适合场景
纯手工 HTML高(链接一多就乱)极快无链接数量不超过 30
传统 CMS中(有后台但笨重)一般有还需要发文章、做完整内容管理
前端 SPA高(要写数据请求和状态管理)差(首屏白屏风险)无强交互型 Web 应用
Next.js 静态导出 + Pages低(改数据即可)极快无导航站、工具站、文档站

当时我列出这些选项,发现唯一能在“维护成本、首屏速度、无服务器”三个维度同时达标的,就是最后一行。这里多补充一句:如果你的导航站将来要做登录、收藏同步这种动态功能,Cloudflare Pages 上也有 Pages Functions,可以写一个轻量的 Worker 接口,不影响静态输出。但我的建议是前期不要急着上动态功能,先把静态版跑通,再做增量扩展。

1.3 为什么 Next.js + Cloudflare Pages 恰好合适

Next.js 在这里的价值,是把组件化开发、数据组织和静态生成揉在了一起。我不需要手写重复的 HTML 片段,改一个分类就是改一个数据对象,重新跑一次构建,所有页面自动更新。而且 Next.js 默认生成的是服务端组件,首屏几乎没有多余 JS,这对导航站来说是决定性的优势。

Cloudflare Pages 补上的是分发环节。静态托管服务很多,Vercel、Netlify、GitHub Pages 我都用过,但 Pages 对导航站这种“几十个文件、没有后端接口”的项目来说几乎是白嫖:免费额度足够一个小站点敞开了跑、全球边缘节点分发、原生支持_headers和_redirects两个文件控制缓存与重定向。最关键的是它和 Git 工作流配合得顺手,push 一次自动构建一次,全程不需要登录服务器执行任何命令。

2. 用结构化的方式管理几百个导航链接:数据模型与内容组织

导航站的核心资产不是代码,是数据。代码写得再漂亮,数据组织得一团糟,后期维护依然痛苦。所以我在动手写页面之前,先把内容抽成结构化数据。这一步看似不起眼,却是整个项目里回报率最高的事。

2.1 先把字段设计清楚:NavSite 与 NavCategory

我定义了两个 TypeScript 类型,一个描述站点条目,一个描述分类:

// src/types.ts export interface NavSite { id: string name: string url: string description: string icon?: string tags: string[] featured?: boolean } export interface NavCategory { slug: string name: string description: string icon?: string sites: NavSite[] }

字段设计逻辑是这样的:id用于 React 列表的 key,必须唯一;name和description是展示核心,description 直接决定用户点不点击;url是跳转目标;icon存的是 favicon 地址;tags预留给将来做标签筛选;featured控制是否在首页置顶展示。slug是分类的 URL 路径,比如“开发工具”对应/dev-tools,这个字段决定了路由怎么生成。

这里有个实操细节:description不要写官方介绍,最好写“你自己为什么用这个工具”。导航站用户往往只看一行字判断“要不要点”,官方介绍太抽象,个人化的使用场景描述反而转化率更高。比如一条链接写“在线压缩 PNG 图片,批量处理,压缩率约 70%”,比“提供 PNG 图像压缩服务的平台”有用得多。

数据文件按分类拆分,方便定位内容:

// src/data/categories.ts import { NavCategory } from "../types" export const categories: NavCategory[] = [ { slug: "dev-tools", name: "开发工具", description: "平时高频使用的开发效率工具集合", sites: [ { id: "github", name: "GitHub", url: "https://github.com", description: "代码托管与开源协作平台", tags: ["git", "开源"], featured: true, }, { id: "regex101", name: "正则表达式在线测试", url: "https://regex101.com", description: "可视化正则调试,带语法提示和测试用例", tags: ["正则"], }, { id: "caniuse", name: "Can I Use", url: "https://caniuse.com", description: "查询浏览器对 Web 特性的兼容性支持情况", tags: ["兼容性", "浏览器"], }, ], }, ]

真实项目里一个分类下放 30~50 条链接很常见,我把数据按语义分组,一个分类一个区块,查找和修改都只需要定位到对应文件。

2.2 为什么我这里不用数据库

导航站的数据量摆在那里,两三百个站点,全量内容可能只有几十 KB,放数据库里还要写 API、处理请求、考虑并发,纯属给自己找事。而把数据放在 TypeScript 文件里,能同时享受两个好处:类型检查和 git 版本管理。改错了字段类型,构建直接报错;改错了内容,一行git diff就能看到前后差异,随时回滚。

如果你不想把数据写死在 TS 文件里,也可以放到public/data/navs.json再读取。但在 Next.js 静态导出模式下,我更推荐 TS 文件:类型安全、导入路径清晰、构建时直接参与打包,没有任何额外的运行时读取开销。等以后数据量涨到几千条,或者你需要外部编辑者时,再平滑迁移到远程 JSON 加增量构建也不迟。

2.3 内容与展示分离的后续扩展

把数据文件放好后,页面层就变成了一个纯粹的渲染逻辑:遍历categories渲染首页导航;根据slug找到分类渲染站点列表。内容归内容,展示归展示,后续换主题、改样式都不需要动数据。

如果你有多个入口需要复用同一份数据,比如后面想加“最近更新”“随机推荐”这些模块,也只需要在数据层加一个工具函数。数据与展示分离的最大意义不是现在省事,而是给未来的改动留出了余地。

3. 页面实现的硬核细节:App Router 静态生成、动态路由与元信息

项目初始化直接用官方脚手架:

npx create-next-app@latest nav-site --ts --app --eslint

这里我没加 Tailwind,因为导航站样式本来就不重,手写几十行 CSS 就够了。如果你习惯 Tailwind,加上也不影响后面的步骤。

3.1 初始化项目与关键配置

这个方案里最关键的一个配置是next.config.ts:

import type { NextConfig } from "next" const nextConfig: NextConfig = { output: "export", images: { unoptimized: true, }, } export default nextConfig

output: "export"是整套方案的灵魂。它意味着所有页面在构建时都会被渲染成 HTML 文件,产出到out目录,之后 Cloudflare Pages 把out当作纯静态站点直接发布。images.unoptimized: true也必须同步设置,因为静态导出模式下 Next.js 的图片优化服务不可用,不关掉会在构建时直接报错。

3.2 动态路由、静态参数与元信息生成

分类页通过动态路由实现,目录结构如下:

src/app/ layout.tsx page.tsx [category]/ page.tsx not-found.tsx

首页是全部站点的聚合浏览,分类页是某一个分类的列表页。动态路由[category]需要先用generateStaticParams把所有路径枚举出来,导出时才不会漏页面:

// src/app/[category]/page.tsx import { categories } from "../../data/categories" import { SiteCard } from "../../components/SiteCard" export function generateStaticParams() { return categories.map((category) => ({ category: category.slug })) } export function generateMetadata({ params }: { params: { category: string } }) { const category = categories.find((c) => c.slug === params.category) return { title: `${category?.name ?? "导航"} | 我的导航`, description: category?.description, } } export default function CategoryPage({ params }: { params: { category: string } }) { const category = categories.find((c) => c.slug === params.category) if (!category) { return <div>该分类不存在</div> } return ( <main> <h1>{category.name}</h1> <p>{category.description}</p> <div className="site-grid"> {category.sites.map((site) => ( <SiteCard key={site.id} site={site} /> ))} </div> </main> ) }

generateStaticParams的作用是告诉 Next.js:所有/xxx路径对应的 HTML 在构建时一次性全部生成,不能依赖运行时渲染。没有它,要么构建报错,要么导出目录里少了对应的页面文件。

generateMetadata负责每个页面的title和description,这是导航站最容易忽略的 SEO 细节。每个分类页都有独立标题和描述,被搜索引擎收录后,用户搜索“开发工具 导航”时,出来的是清晰的分类标题,而不是千篇一律的“首页”。

3.3 首页与卡片组件怎么写

首页的写法类似,只不过遍历所有categories,一次渲染出来:

// src/app/page.tsx import { categories } from "../data/categories" import { Section } from "../components/Section" export default function HomePage() { return ( <main> {categories.map((category) => ( <Section key={category.slug} category={category} /> ))} </main> ) }

Section组件内部渲染分类标题和站点网格,SiteCard是链接卡片。这两个组件都可以写成服务端组件,不携带任何客户端 JS,渲染出来的 HTML 直接就是最终产物,浏览器几乎不需要执行脚本。这也是后续性能优化的基础。

3.4 整个站点唯一需要 JS 的地方:搜索框

导航站通常需要一个小搜索框。这是少数必须引入交互的地方,做法也很克制:用一个小组件接收输入,数据通过 props 从服务端传进来,而不是自己在客户端请求接口:

"use client" import { useMemo, useState } from "react" import type { NavSite } from "../types" export function SearchBox({ sites }: { sites: NavSite[] }) { const [keyword, setKeyword] = useState("") const filtered = useMemo(() => { const q = keyword.trim().toLowerCase() if (!q) return [] return sites.filter( (s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q) || s.tags.some((t) => t.includes(q)) ) }, [keyword, sites]) return ( <div> <input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索工具" /> {keyword && ( <ul> {filtered.map((s) => ( <li key={s.id}>{s.name}</li> ))} </ul> )} </div> ) }

这个组件是“整个导航站唯一的客户端 JS”,体积很小,数据通过 props 从服务端组件传入,不产生额外网络请求,交互也足够流畅。到这里页面骨架已经完整,下一步才是“高性能”三个字真正落地的地方。

4. 性能优化实测:把 Lighthouse 跑分从 85 拉到接近满分

第一版做完后,我用 Lighthouse 跑了一下,Performance 在 85 左右。这个分数对一个导航站来说不算差,但离预期还有距离。逐项排查后主要做了五件事,分数最终稳定在 97 到 100 之间。

4.1 放弃 next/image,回归原生 img

第一次构建时我用next/image加载站点图标。开启静态导出后虽然加了unoptimized: true不报错,但next/image在这种非优化模式下依然会输出一些用不到的属性,HTML 体积白白增加。导航站图标数量多、体积小,直接换成普通img标签反而更干净:

// components/SiteCard.tsx(节选) <img src={site.icon || "/favicon.ico"} alt={site.name} loading="lazy" width={24} height={24} />

首屏以下的站点图标全部加上loading="lazy",浏览器只在接近视口时才下载图标。几百个站点的图标体积,瞬间从“全部加载”变成“按需加载”,这是对 Lighthouse 分数贡献最大的一步。

4.2 字体内联与预连接

第二件事是字体优化。默认情况下浏览器会等字体文件加载完才显示文字,或者出现字体跳变。我用next/font引入并内联字体,构建时字体被压缩成 woff2 并同步预加载,文字渲染稳定且零额外请求。这项改动主要影响 CLS(内容位移)指标,也是导航站页面最容易忽略的体验细节。

同时,很多站点的图标来自同一个域名(比如 icon 服务商或者各站点自身的域名),浏览器解析到第一张图时才建立连接会浪费一次往返时间。在layout.tsx里加预连接:

<link rel="preconnect" href="https://www.google.com" /> <link rel="preconnect" href="https://favicons.githubusercontent.com" />

具体域名取决于你的图标来源,把访问量前几的图标域名列进去就行。首屏图标数量不多时,这个优化能让图标区域肉眼可见地变快。

4.3 用 _headers 把缓存策略分成两层

导航站内容更新不频繁,但更新后必须能快速生效。我在public目录放一个_headers文件,Next.js 构建时会自动把它复制到out目录,Cloudflare Pages 会读取它:

/_next/static/* Cache-Control: public, max-age=31536000, immutable /* Cache-Control: public, max-age=0, s-maxage=60

规则分两层:_next/static下的资源文件名都带 hash,内容变了文件名就变,所以放心让浏览器和 CDN 缓存一年;页面 HTML 本身在浏览器端不缓存,但 CDN 层保留 60 秒。这样既能在重新部署后最多一分钟内让全球用户看到新版本,又能让边缘节点抵挡住高并发流量。

4.4 严格控制客户端脚本

我特意检查过生产环境的 HTML,确认除了搜索框组件,没有其他客户端 JS。导航站踩得最多的隐性坑,是引入了一个看似轻量的组件库,结果它内部带进去一堆运行时逻辑。能不用库就不用库,一段原生div加一个for循环,就是最好的渲染。

优化后的数据:首页 HTML 大约 150KB(这个数字和站点数量正相关),无阻塞脚本,Lighthouse Performance 稳定在 97 左右。在 Cloudflare 边缘节点访问时,TTFB 会因为用户所在区域和网络链路有差异,但整体明显好于常见的动态服务器渲染。对导航站这种场景,这个性能已经处于“过剩”水准。

5. 部署到 Cloudflare Pages:构建配置、六个坑与链接迁移

部署用 Git 集成就行,流程不长,但每一步都有容易踩碎的地方。

5.1 三行配置搞定部署

在 Cloudflare Pages 后台新建项目、连接 GitHub 仓库,然后在构建设置里填三样东西:

  • 构建命令:npm run build
  • 输出目录:out
  • 环境变量:NODE_VERSION=20

构建命令就是 Next.js 的默认构建;输出目录必须和next.config.ts里output: "export"的导出目录一致,Pages 会把这个目录当作网站的根目录发布。

5.2 六个亲手踩过的坑

下面是我实际踩过的六个坑,按杀伤力排序,每个都值得你先避开:

坑一:Node 版本不匹配。Pages 默认的 Node 版本比较旧,我第一版没设NODE_VERSION,构建直接报错,提示 Next.js 需要 Node.js 18.17 或更高。在后台 Environments 页面加上NODE_VERSION=20,重新部署即可。

坑二:next/image 在导出模式下报错。如果你忘了在next.config.ts里加images.unoptimized: true,构建到最后一步会抛出 “Image Optimization is not supported in export mode” 的红色错误日志。这个报错藏在日志末尾,不仔细看会误以为是 Cloudflare 的问题,实际上是 Next.js 图片优化依赖服务端能力,而静态导出没有服务端。加上配置就能过。

坑三:动态路由页面缺失。如果[category]/page.tsx里没有generateStaticParams,构建时 Next.js 会提示页面缺少generateStaticParams(),生成出来的out目录里根本没有分类页文件。我一开始漏写这个函数,上线后所有分类页都返回 404。好在本地构建日志提示足够清楚,补上函数就恢复。

坑四:_headers 文件放错了位置。Cloudflare Pages 只认输出目录(out)下的_headers。如果你和我第一次一样把它放在项目根目录,构建之后不会生效。正确做法是放在public/_headers,Next.js 构建时会自动复制到out。_redirects同理,也放public下。

坑五:HTML 缓存设太长导致更新不可见。第一版我把Cache-Control直接写成max-age=3600,结果改了一个站点描述后 push 上线,页面在浏览器里一个小时内都没更新。排查半天才发现是浏览器缓存了 HTML。改成上面那套“浏览器不缓存、CDN 层 s-maxage=60”的规则后问题消失。静态资源的长期缓存不用担心,文件名带 hash,内容变了 URL 就变了。

坑六:自定义域名的 SSL 模式。如果域名也在 Cloudflare 上托管,Pages 绑定自定义域名后默认的 SSL/TLS 加密模式是 Full,通常没问题。但如果你刚从其他平台迁移域名,记得确认当前域名的 DNS 记录已经切到 Cloudflare,且 SSL 模式不要设成 Flexible,否则边缘节点到源站是明文传输,浏览器偶尔会提示不安全。

5.3 链接迁移:用 _redirects 无缝过渡

还有一个不属于坑、但值得专门说的操作:_redirects文件。导航站做久了,经常需要换链接、迁移分类。比如旧站点的链接结构是/dev.html?id=123这种,迁移到新结构/dev-tools/之后,用_redirects做 301 即可:

/dev.html* /dev-tools 301 /old-tools/* /new-tools 301

Cloudflare Pages 的_redirects支持通配符和状态码,语法非常简单。存量链接迁移时它能帮你保住大部分外部收录和用户书签,不要等上线后才发现旧链接全部失效。

部署完成后,把仓库里的数据文件作为唯一的内容入口,日常更新就变成了:改一个description,提交,两分钟后全世界看到新版本。这种维护节奏,才是导航站真正需要的“高性能”。

最后再分享一个个人体会:第一次部署成功后,我故意改了某个分类的排序,提交代码再推送,等了两分钟刷新页面,新顺序已经生效。那一刻我才意识到,这个方案最大的收益其实不是跑分多高,而是把一个琐碎、重复、容易出错的维护工作,压缩成了一行git push。后续如果想加新分类、增加暗色模式,或者用 Pages Functions 做一个简单的收藏接口,都能在这个骨架上直接长出来,不需要推翻重来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询