几周前,ZEIT 发布了 Next.js 9.3。这个版本引入了 getStaticProps 和 getStaticPaths,它们是整个框架里最值得深入理解的一组 API 之一。
为什么这两个 API 很重要?这篇文章用一个完整的小例子说明:如何把本来发生在运行时的工作,尽量前移到构建时完成。
原文相关资源:
- 原文地址:Next.js Static Props
- Next.js 9.3 发布说明:Next.js 9.3
场景:做一个疫情数据网站
假设你要做一个展示新冠疫情统计数据的网站:
- 首页展示每个国家的统计摘要。
- 点击国家后进入详情页。
- 所有数据都来自一个公开可下载的 JSON 文件。
- 这个 JSON 每天更新一次。
先停一下,自己想想如果你来做,会怎么设计数据获取和页面渲染。接下来我们按原文的路线,用 Next.js 9.3 一步步搭起来。
先搭一个最基本的 Next.js 项目
在空目录中先创建 package.json,安装这些依赖:
{
"dependencies": {
"@nivo/stream": "0.59.0",
"@nivo/treemap": "0.59.0",
"next": "^9.3.0",
"node-fetch": "^2.6.0",
"react": "^16.13.0",
"react-dom": "^16.13.0"
},
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
}
然后创建 pages/index.js,只导出一个 React 组件,例如:
export default function HomePage() {
return <h1>Hello</h1>
}
Next.js 会把 pages/index.js 预渲染成 index.html。
接着运行:
yarn install
yarn dev
默认访问地址是 http://localhost:3000。确认这个最小项目能跑通后,再继续往里加功能。
方案一:客户端拉数据
第一个版本很直觉:浏览器加载页面后,再从远端拉取疫情数据。
数据源是:
https://pomber.github.io/covid19/timeseries.json
它的结构大致是:
{
"China": [
{
"date": "2020-1-22",
"confirmed": 548,
"deaths": 17,
"recovered": 28
}
],
"Spain": []
}
你可以把请求逻辑包进一个 useData hook,在数据下载完成前返回 undefined,页面先显示 Loading...。
例如首页组件会像这样依赖客户端状态:
function HomePage() {
const data = useData()
if (!data) {
return <p>Loading...</p>
}
const countries = Object.keys(data)
const aCountry = data[countries[0]]
const { date } = aCountry[aCountry.length - 1]
return <h2>Coronavirus {date}</h2>
}
这一版当然能工作,但问题也很明显:
- 每个用户一打开页面,就得先看加载态。
- 浏览器要下载一个很大的 JSON。
- 首页一开始其实只用了里面极少的一部分信息。
方案二:把数据获取前移到构建时
这时 getStaticProps 就登场了。
在 Next.js 页面文件中,你可以导出一个异步 getStaticProps。它在构建阶段运行,也就是你执行 yarn build 的时候。你可以在里面做任何构建期可完成的工作,然后把结果作为 props 传给页面组件。
先把客户端请求逻辑挪进去:
const api = "https://pomber.github.io/covid19/"
const DATA = api + "timeseries.json"
import fetch from "node-fetch"
export async function getStaticProps() {
const response = await fetch(DATA)
const data = await response.json()
const countries = Object.keys(data)
const aCountry = data[countries[0]]
const { date } = aCountry[aCountry.length - 1]
const rows = countries
.map(country => {
const { deaths } = data[country].find(
r => r.date === date
)
return { country, deaths }
})
.filter(r => r.deaths > 8)
return {
props: { date, rows },
}
}
export default function HomePage({ date }) {
return <h2>Coronavirus {date}</h2>
}
这一步的核心收益有两个:
- 首屏没有 loading。
- 浏览器不再自己去下载那份大 JSON。
还有一个很重要的细节:getStaticProps 运行在 Node 环境,不是浏览器环境。所以原文示例用了 node-fetch 而不是浏览器原生 fetch。这意味着你还可以在里面做很多浏览器做不了的事,例如读文件系统、查数据库、用 Puppeteer 起一个无头浏览器。
不要把整个数据对象都发给浏览器
即便把获取时机挪到了构建期,也不代表应该把所有数据都原样传给页面。
首页如果只需要日期,就应该在 getStaticProps 里就把日期算出来,再把一个很小的 date prop 传下去,而不是把整份 data 送到客户端后再做计算。
这个思路很关键:既然构建时已经拿到了原始数据,就尽量在构建时把派生数据也算完。
把首页做成 TreeMap
接下来让首页展示更有意义的内容:每个国家在某个日期的死亡人数,并用 @nivo/treemap 做成 TreeMap。
我们先从 timeseries.json 里提取每个国家最后一天的统计数据,再整理成:
[
{ "country": "Albania", "deaths": 20 },
{ "country": "Algeria", "deaths": 96 }
]
然后引入另一个数据源,为每个国家补上 emoji 国旗:
https://pomber.github.io/covid19/countries.json
完整的构建期数据准备代码如下:
const api = "https://pomber.github.io/covid19/"
const DATA = api + "timeseries.json"
const FLAGS = api + "countries.json"
import fetch from "node-fetch"
export async function getStaticProps() {
const [data, flags] = await Promise.all([
fetch(DATA).then(r => r.json()),
fetch(FLAGS).then(r => r.json()),
])
const countries = Object.keys(data)
const aCountry = data[countries[0]]
const { date } = aCountry[aCountry.length - 1]
const rows = countries
.map(country => {
const { deaths } = data[country].find(
r => r.date === date
)
const flag = flags[country]?.flag || "❓"
return { country, deaths, flag }
})
.filter(r => r.deaths > 8)
return {
props: { date, rows },
}
}
页面部分则使用 TreeMap:
import { TreeMap } from "@nivo/treemap"
export default function HomePage({ date, rows }) {
return (
<>
<h2>Coronavirus {date}</h2>
<TreeMap
tile="binary"
colorBy="flag"
colors={{ scheme: "pastel2" }}
labelSkipSize={9}
label={({ value, flag }) => (
<tspan
style={{ fontSize: 10 + value / 200 }}
dominantBaseline="central"
children={flag}
/>
)}
tooltip={r =>
`${r.value} deaths in ${r.id}`
}
root={{ children: rows }}
identity="country"
value="deaths"
width={402}
height={190}
innerPadding={1}
/>
</>
)
}
原文这里还提了几个可视化调整点:
- 布局算法改成
binary - 颜色使用
pastel2 - 标签显示国旗而不是国家名
- 自定义 tooltip
对应示意图:

加上国家详情页链接
首页每个矩形块都可以点进对应国家详情页,例如:
- 点击 Italy 进入
/country/Italy - 点击 Spain 进入
/country/Spain
但我们不可能手写 200 多个页面文件。这时就要用 Next.js 的动态路由。
动态路由
如果希望多个路径共用同一个页面组件,可以在 pages 目录下使用带方括号的文件名:
pages/country/[name].js
这样 /country/Spain、/country/Iran 等路径都会匹配到这一个页面。
在较早期的 Next.js 用法中,你可以在组件里通过 useRouter 拿到参数:
const router = useRouter()
const { name } = router.query
但如果是静态预渲染页面,真正关键的不是运行时读参数,而是构建时提前把所有路径列出来。
getStaticPaths
如果要让国家详情页也在构建时生成,就必须告诉 Next.js:到底有哪些国家页面需要预渲染。
这就是 getStaticPaths 的职责。
import Link from "next/link"
import fetch from "node-fetch"
const api = "https://pomber.github.io/covid19/"
const DATA = api + "timeseries.json"
export async function getStaticPaths() {
const response = await fetch(DATA)
const data = await response.json()
const countries = Object.keys(data)
return {
paths: countries.map(name => ({
params: { name },
})),
fallback: false,
}
}
这里返回的每一项都定义了一个实际要生成的页面参数。例如:
{ params: { name: "Spain" } }
详情页里的 getStaticProps
一旦页面使用了 getStaticPaths,通常也会配合 getStaticProps。此时 getStaticProps 可以通过 context.params 读取当前页面参数,并据此构建该页所需的数据。
国家详情页的完整示例如下:
import Link from "next/link"
import fetch from "node-fetch"
const api = "https://pomber.github.io/covid19/"
const DATA = api + "timeseries.json"
export async function getStaticPaths() {
const response = await fetch(DATA)
const data = await response.json()
const countries = Object.keys(data)
return {
paths: countries.map(name => ({
params: { name },
})),
fallback: false,
}
}
export async function getStaticProps(context) {
const { name } = context.params
const response = await fetch(DATA)
const data = await response.json()
const rows = data[name]
return { props: { name, rows } }
}
import { Stream } from "@nivo/stream"
export default function Country({ name, rows }) {
return (
<>
<h1 style={{ textAlign: "center" }}>
{name}
</h1>
<Stream
data={rows}
width={390}
height={160}
keys={["deaths", "confirmed"]}
offsetType="diverging"
colors={{ scheme: "pastel1" }}
enableGridX={false}
/>
<Link href="/">
<a>Go Back</a>
</Link>
</>
)
}
这个版本里有一个现实层面的提醒:getStaticProps 会为每个国家页面都执行一次,所以如果你有 200 个国家,它就会请求 200 次同一个 JSON。原文建议真实项目里一定要做缓存。
为什么还要用 Link
原文在这里专门强调了 Link 组件。原因很直接:
- 不用
Link,浏览器每次切页都要整页重载。 - 用
Link,页面间跳转由 Next.js 接管,只下载下一页真正需要的代码和数据。 - 用户鼠标悬停时,Next.js 还会预取下一页资源。
也就是说,静态预渲染解决的是“页面生成时机”,Link 解决的是“页面切换体验”。
生产环境下会发生什么
运行下面两个命令:
yarn build
yarn start
然后打开浏览器的 Network 面板观察。
原文也给了一个线上演示站点:https://nextjs-static-props.now.sh。如果该旧链接已经不可访问,仍然可以通过下面的视频和截图理解它想展示的网络行为。
例如:
- 当你把鼠标悬停到 Spain 上,Next.js 会开始下载
/country/Spain.json和/country/[name].js - 接着再悬停 Italy,就只需要再下载
/country/Italy.json - 这时点击 Spain 或 Italy,切换几乎是瞬时的
这正是构建时生成页面、运行时只加载最小必要资源带来的好处。
甚至可能在无 JavaScript 环境下也能工作
如果图表库支持服务端渲染,那么整个站点甚至可以在禁用 JavaScript 的情况下继续可用。
原文给出的示意图如下:

你当然会失去一些交互能力,例如:
- tooltip
- 客户端路由切换
- 复杂交互动画
但页面内容和基础导航仍然可以正常工作。这正是静态预渲染带来的另一个价值:即便没有客户端 JavaScript,HTML 本身也已经是可用页面。
演示视频
原文中还展示了一个完整演示,观察悬停预取与页面切换效果会更直观:
这篇文章真正想说明什么
getStaticProps 和 getStaticPaths 的价值,不只是“API 新增了两个函数”,而是它们让你重新思考:
- 哪些工作必须在用户访问时做?
- 哪些工作其实可以在构建时提前做完?
- 哪些原始数据根本没必要下发到浏览器?
- 哪些页面可以在部署前就提前生成?
当数据更新频率较低、页面内容高度可预知时,把运行时工作前移到构建时,往往能同时带来:
- 更快的首屏
- 更少的客户端 JavaScript
- 更轻的网络负担
- 更稳定的无脚本降级体验
原作者:Rodrigo Pombo
原文地址:Next.js Static Props
原文讨论入口:Twitter