Skip to content

理解 Next.js 的 Static Props

Updated: at 12:00 AM

几周前,ZEIT 发布了 Next.js 9.3。这个版本引入了 getStaticPropsgetStaticPaths,它们是整个框架里最值得深入理解的一组 API 之一。

为什么这两个 API 很重要?这篇文章用一个完整的小例子说明:如何把本来发生在运行时的工作,尽量前移到构建时完成。

原文相关资源:

场景:做一个疫情数据网站

假设你要做一个展示新冠疫情统计数据的网站:

先停一下,自己想想如果你来做,会怎么设计数据获取和页面渲染。接下来我们按原文的路线,用 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。确认这个最小项目能跑通后,再继续往里加功能。

方案一:客户端拉数据

第一个版本很直觉:浏览器加载页面后,再从远端拉取疫情数据。

数据源是:

它的结构大致是:

{
  "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>
}

这一版当然能工作,但问题也很明显:

方案二:把数据获取前移到构建时

这时 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>
}

这一步的核心收益有两个:

还有一个很重要的细节: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 国旗:

完整的构建期数据准备代码如下:

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}
      />
    </>
  )
}

原文这里还提了几个可视化调整点:

对应示意图:

TreeMap 图例与展示效果

加上国家详情页链接

首页每个矩形块都可以点进对应国家详情页,例如:

但我们不可能手写 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 解决的是“页面切换体验”。

生产环境下会发生什么

运行下面两个命令:

yarn build
yarn start

然后打开浏览器的 Network 面板观察。

原文也给了一个线上演示站点:https://nextjs-static-props.now.sh。如果该旧链接已经不可访问,仍然可以通过下面的视频和截图理解它想展示的网络行为。

例如:

这正是构建时生成页面、运行时只加载最小必要资源带来的好处。

甚至可能在无 JavaScript 环境下也能工作

如果图表库支持服务端渲染,那么整个站点甚至可以在禁用 JavaScript 的情况下继续可用。

原文给出的示意图如下:

关闭 JavaScript 后页面仍可浏览

你当然会失去一些交互能力,例如:

但页面内容和基础导航仍然可以正常工作。这正是静态预渲染带来的另一个价值:即便没有客户端 JavaScript,HTML 本身也已经是可用页面。

演示视频

原文中还展示了一个完整演示,观察悬停预取与页面切换效果会更直观:

这篇文章真正想说明什么

getStaticPropsgetStaticPaths 的价值,不只是“API 新增了两个函数”,而是它们让你重新思考:

当数据更新频率较低、页面内容高度可预知时,把运行时工作前移到构建时,往往能同时带来:

原作者:Rodrigo Pombo
原文地址:Next.js Static Props
原文讨论入口:Twitter