React 静态站点生成(SSG)集成:在构建期预渲染 React 页面以提升极速加载性能

嘿,大家好!欢迎来到这场关于“让 React 像闪电一样快”的讲座。我是你们的向导,一个在代码堆里摸爬滚打多年的资深老司机。

今天我们不聊那些虚头巴脑的 React Hooks 基础,也不搞那些花里胡哨的动画库。今天,我们要聊聊一个能让你的网页在 0.1 秒内从服务器跳到用户屏幕上的黑科技——静态站点生成

想象一下,你走进一家面包店。你是想看厨师在后面现揉面、现发酵、现烘烤(这叫服务端渲染,SSR,虽然也快,但每单都要烤),还是想直接拿一盒已经烤好、包装精美的面包(这叫静态站点生成,SSG,打开就能吃)?

SSG 就是那个“拿现成面包”的策略。它在构建期就把你的 React 页面预渲染成了 HTML 文件。用户访问时,服务器直接把 HTML 发过去,React 只负责在浏览器里做一点点“交互”,就像给面包加点果酱一样。这速度,简直比光速还快(好吧,比光速慢一点点,但在网络延迟面前,这叫神速)。

好了,废话不多说,让我们直接把键盘敲得噼里啪啦响,看看怎么把 React 变成 SSG。


第一部分:从 CSR 到 SSG 的“降维打击”

在 React 的世界里,我们以前最常用的模式是 CSR(客户端渲染)。这就像是你去餐馆,服务员端上来一张白纸,告诉你:“先生,菜单在您的手机 App 里,请点击这里,再点击那里,再等两秒钟,我们后厨才能开始做菜。”

用户体验好吗?不好。SEO(搜索引擎优化)好吗?不好。因为搜索引擎爬虫看不到你的菜,它只看到一张白纸。

SSG 的核心逻辑:
在构建期,Node.js 环境运行你的代码,把组件渲染成 HTML 字符串,把这些字符串写入硬盘的 build 文件夹里。当用户访问时,你直接送给他 HTML。

代码示例 1:最简单的 SSG 示范

别急,先看一个最基础的组件。注意看 export const getStaticProps 这个函数,这是 SSG 的魔法入口。

// pages/index.js

// 1. 这是一个纯组件,和以前写法一样
const HomePage = ({ message, timestamp }) => {
  // 这里的 window 对象是安全的,因为这是在浏览器里跑
  return (
    <div style={{ padding: '20px', fontFamily: 'Arial' }}>
      <h1>SSG 极速加载演示</h1>
      <p>这是一个静态生成的页面。</p>
      <p>当前时间: {timestamp}</p>
      <p>消息: {message}</p>
      <button onClick={() => alert('我是静态页面,但我也能动!')}>
        点击我
      </button>
    </div>
  );
};

// 2. 这里的 getStaticProps 会在构建时运行
// 就像是一个预演,告诉 React:“嘿,兄弟,把这些数据算好,生成 HTML 吧。”
export const getStaticProps = async () => {
  // 模拟从 API 获取数据
  const data = {
    message: "欢迎来到静态世界的尽头!",
    timestamp: new Date().toLocaleString(),
  };

  // 返回 props,这些 props 会自动传给组件
  return {
    props: data,
    // 可选:重新验证的时间(稍后讲 ISR)
    revalidate: 10, 
  };
};

export default HomePage;

看懂了吗?getStaticProps 在构建时执行。服务器把组件渲染成了 HTML。当你把这个 HTML 部署到 CDN 上时,用户打开网页,根本不需要等待 JavaScript 下载和执行,页面瞬间就出来了!


第二部分:动态路由与 getStaticPaths —— 如何处理“成千上万”个页面

如果只有一两个页面,SSG 简直是神技。但现实是残酷的,你的博客有成千上万篇文章,你的电商网站有成千上万种产品。你不可能一个个手写页面文件。

这时候,React Router 的动态路由 [id].js 就派上用场了。但怎么让 SSG 知道要生成哪些页面呢?这就需要 getStaticPaths

代码示例 2:动态博客列表

假设你有一个博客系统。

// pages/posts/[id].js

// 这是一个通用的组件,用来显示具体的文章
const PostPage = ({ post }) => {
  return (
    <div>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </div>
  );
};

// 关键来了:getStaticPaths
// 它告诉 Next.js:“构建时,请先生成以下这些路径对应的 HTML 文件。”
export const getStaticPaths = async () => {
  // 模拟从 CMS 或数据库获取所有文章的 ID
  const paths = [
    { params: { id: 'react-is-awesome' } },
    { params: { id: 'ssg-is-fast' } },
    { params: { id: 'nextjs-rules' } }
  ];

  // fallback: false 意味着如果访问了路径里没有的 id,会返回 404 页面
  // fallback: 'blocking' 意味着如果路径没有预生成,Next.js 会临时生成一个,然后缓存起来
  return {
    paths,
    fallback: false,
  };
};

// getStaticProps 在这里运行,专门处理对应路径的数据
export const getStaticProps = async ({ params }) => {
  const res = await fetch(`https://api.example.com/posts/${params.id}`);
  const post = await res.json();

  return {
    props: {
      post,
    },
  };
};

export default PostPage;

深度解析 getStaticPaths

这里有个坑,新手最容易踩。getStaticPaths 返回的 paths 数组,决定了预渲染的页面数量。

  • 场景 A: 你有 10,000 篇文章。你把所有 ID 都写进 paths 数组?内存会爆炸,构建会超时,你的电脑会冒烟。
  • 场景 B: 你只写前 100 篇?用户点第 101 篇怎么办?404。

解决方案:ISR(增量静态再生)。这是 SSG 的进化版,我们稍后再详细唠叨。但核心思想是:fallback: 'blocking'。这意味着,当用户访问一个未预生成的页面时,Next.js 会像 SSR 一样现场生成一次,然后缓存起来,下次再访问就快了。


第三部分:ISR —— SSG 的补丁与进化

SSG 有个硬伤:数据是死的。文章更新了,静态页面还是旧的,直到你重新部署。

为了解决这个问题,我们引入了 ISR(Incremental Static Regeneration,增量静态再生)

ISR 允许你设置一个 revalidate 时间。比如你设置 revalidate: 60。这意味着:

  1. 用户访问页面,拿到的是最新的静态 HTML。
  2. 当这个页面在浏览器中处于活跃状态超过 60 秒后(或者是用户访问后 60 秒),Next.js 后台会悄悄发起一个请求去更新这个页面的数据。
  3. 更新成功后,下次用户访问时,就能看到新内容了。

代码示例 3:ISR 实战

// pages/news/[slug].js

export const getStaticProps = async ({ params }) => {
  const news = await fetch(`https://api.example.com/news/${params.slug}`).then(res => res.json());

  return {
    props: { news },
    // 核心魔法:这个页面每 60 秒自动更新一次
    revalidate: 60, 
  };
};

export const getStaticPaths = async () => {
  // 假设我们只预生成热门新闻,其他新闻由 ISR 处理
  const popularNews = await fetch('https://api.example.com/news/popular').then(res => res.json());

  const paths = popularNews.map(news => ({ params: { slug: news.slug } }));

  return {
    paths,
    // 关键配置:允许未预生成的路径通过 ISR 现场生成
    fallback: 'blocking', 
  };
};

const NewsPage = ({ news }) => {
  return <div>{news.title} - 发布时间: {new Date().toLocaleTimeString()}</div>;
};

export default NewsPage;

为什么 fallback: 'blocking' 这么重要?

它解决了 SSG 的“冷启动”问题。如果网站有 100 万个产品页面,你不可能全部预渲染。

  • fallback: false:你只能访问预渲染的页面,其他全是 404。这等于没做 SSG。
  • fallback: true:访问未预渲染页面时,会立即返回一个“加载中”的 HTML(通常是空页面或骨架屏),然后 Next.js 后台生成,生成完后再把 HTML 返回给用户。
  • fallback: 'blocking':访问未预渲染页面时,Next.js 会阻塞用户的请求,直到页面生成完毕。这保证了用户体验,但首次访问未生成页面时会有轻微延迟(通常在 1-2 秒内,取决于数据源速度)。

第四部分:构建期的数据获取策略

SSG 的核心是“构建期”。这意味着你不能在 getStaticProps 里使用 windowdocument,因为构建时根本就没有浏览器环境!

代码示例 4:构建期 vs 运行期

// ❌ 错误示范
export const getStaticProps = async () => {
  const data = await fetch('https://api.com/data');
  // 危险!构建时 window 是 undefined!
  const isBrowser = typeof window !== 'undefined'; 

  return { props: { isBrowser } };
};

// ✅ 正确示范
export const getStaticProps = async () => {
  // 纯粹的 Node.js 逻辑
  const response = await fetch('https://api.com/data', {
    headers: {
      // 如果你的 API 需要 Authorization,这里可以设置
      'Authorization': `Bearer ${process.env.API_SECRET}`,
    },
  });
  const data = await response.json();

  return {
    props: { data },
  };
};

// 组件里判断
const MyComponent = ({ data }) => {
  // 这里 window 是可用的
  const handleClick = () => {
    console.log('Clicked', data.id);
  };

  return <button onClick={handleClick}>{data.title}</button>;
};

高级技巧:使用 fetch 的默认缓存策略

Next.js 13+ 对 fetch 做了巨大的优化。默认情况下,fetch 请求会被 Next.js 缓存。这意味着在构建期,getStaticProps 里的 fetch 请求可能会被缓存,导致你获取不到最新的数据(除非你刷新了构建)。

如果你想确保每次构建都获取最新数据,或者想在构建期使用 POST 请求,你需要设置 { cache: 'no-store' }

export const getStaticProps = async () => {
  const res = await fetch('https://api.example.com/data', { 
    cache: 'no-store' // 强制不缓存,每次构建都去拉取
  });
  // ...
};

第五部分:图片、字体与性能优化 —— 别让你的页面变成“重武器”

SSG 生成的 HTML 虽然快,但如果里面塞满了几兆的图片,用户打开还得下载 JS,那速度依然感人。

1. 图片优化:next/image

别再用 <img> 标签了,那是上个世纪的产物。用 <Image> 组件。它会自动压缩图片、提供响应式尺寸、懒加载。

import Image from 'next/image';

const HeroSection = () => {
  return (
    <div>
      <h1>欢迎来到极速世界</h1>
      {/* next/image 会自动处理这些事情,你只需要指定宽高 */}
      <Image 
        src="/hero-image.jpg" 
        alt="Hero Image" 
        width={800} 
        height={600} 
        priority // 如果这是首屏图片,加上这个,防止闪烁
      />
    </div>
  );
};

注意: 使用 next/image 时,图片必须放在 public 文件夹下,或者必须是外部 URL(需要配置 images.domains)。

2. 字体优化:next/font

Google Fonts 加载慢,还会阻塞渲染。next/font 可以自动优化字体,甚至内联字体,让浏览器直接使用字体文件,无需额外的 HTTP 请求。

import { Inter } from 'next/font/google';

// 配置字体
const inter = Inter({ subsets: ['latin'] });

const App = () => {
  return (
    <main className={inter.className}>
      {/* 现在字体已经内联,加载速度极快 */}
      <h1>Hello, Next.js!</h1>
    </main>
  );
};

第六部分:自定义 Webpack/Vite —— 当 Next.js 太重时

虽然 Next.js 很强大,但有时候我们想保留对构建工具的完全控制权,或者不想引入 Next.js 那么庞大的依赖。这时候,我们可以手动配置 Webpack 或 Vite 来实现 SSG。

方案 A:Vite + React Router

Vite 是目前最流行的构建工具,它天生支持 SSR/SSG。

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { ssg } from 'vite-plugin-ssr'; // 关键插件

export default defineConfig({
  plugins: [
    react(),
    ssg({ // 启用静态站点生成
      include: '/**/*.html', // 指定哪些路由生成 HTML
      exclude: ['/api/**'],  // 排除 API 路由
    }),
  ],
});

然后在你的入口文件(entry.ssr.js)中,手动渲染路由:

// entry.ssr.js
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './App';

export default async function render(url, manifest) {
  // 这里你可以解析 url,决定渲染哪个组件
  // 模拟路由匹配
  let Component = App;

  const html = ReactDOMServer.renderToString(<Component url={url} />);

  return {
    html,
    documentProps: {
      title: 'My Static Site',
    },
  };
}

这种方法更轻量,适合那些只需要简单静态化,不需要 Next.js 那些高级功能(比如自动路由、API Routes)的项目。

方案 B:手动 Webpack 构建

如果你还在用 Webpack 5,你可以使用 html-webpack-plugin 在构建期生成 HTML 文件,然后配合 copy-webpack-plugin 把这些 HTML 和打包后的 JS 文件复制到 dist 目录。

但这需要你自己处理 React 的 hydration 过程,稍微有点繁琐。通常情况下,除非你有极其特殊的定制需求,否则直接用 Next.js 是最明智的选择。


第七部分:SSG 的架构陷阱与最佳实践

作为资深专家,我必须告诉你,SSG 并不是万能药,用不好就是“定时炸弹”。

1. 数据新鲜度与更新策略

SSG 的核心是“缓存”。如果你在 SSG 页面中展示实时数据(比如股票价格、在线人数),你会非常痛苦。

  • 策略: 尽量将实时数据与静态数据分离。静态数据(文章、产品)用 SSG。实时数据(价格)用 API,在前端通过轮询或 WebSocket 获取。
  • ISR 的运用: 对于新闻、博客这种内容,使用 ISR 是最佳实践。设置一个合理的 revalidate 时间(比如 5-10 分钟),在数据更新频率和加载速度之间取得平衡。

2. windowdocument 的幽灵

正如前面提到的,在 getStaticProps 里千万别用 window

但是,在组件内部,window 是存在的。这会导致一个常见的 Bug:你试图在 useEffect 里获取数据,但数据已经通过 getStaticProps 传进来了,你根本不需要再请求一次!

// ❌ 重复请求
const MyComponent = ({ data }) => {
  useEffect(() => {
    fetch('/api/similar-data').then(...) // 这是多余的!
  }, []);

  return <div>{data}</div>;
};

// ✅ 直接使用 props
const MyComponent = ({ data }) => {
  // 直接渲染,不需要 useEffect
  return <div>{data}</div>;
};

3. 预渲染的 HTML 结构

SSG 生成的 HTML 必须是完整的 DOM 结构。如果你在组件里使用了条件渲染(比如 if (loading) return null),在构建期,这个 loading 状态可能不会出现,导致生成的 HTML 是空的。

最佳实践:getStaticProps 完成后,确保组件始终渲染出基本的骨架结构,而不是在构建期返回 null


第八部分:大型项目的 SSG 策略

如果你的项目有几万个页面,全量预渲染(生成几万个 HTML 文件)会导致构建时间过长(可能长达 30 分钟甚至更久)。

这时候,我们需要一种混合策略

  1. 首页、核心落地页、热门内容:使用 SSG,生成完整的 HTML。
  2. 长尾内容、搜索结果页、归档页:使用 SSR 或者 ISR (fallback: true)
    • SSR:每次请求都生成,保证数据最新。
    • ISR:第一次请求生成,后续请求命中缓存。
// pages/search/[query].js

// 搜索页用 SSR,因为搜索结果经常变,而且数据量小
export async function getServerSideProps(context) {
  const { query } = context;
  const results = await fetch(`/api/search?q=${query}`).then(res => res.json());
  return { props: { results } };
}

// 首页用 SSG
// pages/index.js
export async function getStaticProps() {
  // ...
  return { props: { featuredPosts } };
}

这种混合渲染模式是大型企业级应用的标准配置。它既保证了核心页面的极速加载(SEO 友好),又保证了动态内容的实时性。


第九部分:构建流程深度解析

当你运行 npm run build 时,到底发生了什么?让我们把 Next.js 的黑盒打开看看。

  1. 收集页面信息:Next.js 扫描 pages 目录,识别所有带有 getStaticProps 的文件。
  2. 调用 getStaticProps:对于每个页面,Next.js 启动一个 Node.js 进程,运行你的代码,获取数据。
  3. 生成 HTML:拿到数据后,Next.js 调用 React 的 renderToStringrenderToPipeableStream,生成 HTML 字符串。
  4. 写入文件:Next.js 将生成的 HTML 写入 .next/static/pages/ 目录。
  5. 生成路由:Next.js 在 .next/server/pages/ 目录下生成对应的路由处理文件(.js 文件),这些文件负责在运行时处理 getStaticPaths 中定义的动态路由。
  6. 优化资源:压缩 CSS、JS,生成 manifest 文件。

构建时的错误处理:
如果在 getStaticProps 中抛出错误,Next.js 会跳过该页面的构建,并在控制台打印错误。生成的 HTML 文件将包含错误信息(或者你配置的 Fallback 组件)。注意: 这种错误不会导致整个构建失败,除非你配置了 onBuildError


第十部分:实战案例——一个完整的电商 SSG 站点

让我们综合一下前面学到的所有知识,构建一个简单的电商产品页。

需求:

  1. 产品列表页(动态路由,使用 getStaticPaths)。
  2. 产品详情页(SSG,展示产品信息)。
  3. 使用 next/image 优化图片。
  4. 使用 ISR 处理库存更新。

1. 路由配置

// pages/products/[slug].js

import Image from 'next/image';
import Link from 'next/link';

// 1. 定义哪些产品会被预渲染
export const getStaticPaths = async () => {
  // 假设我们从 CMS 获取所有产品 ID
  const products = await fetch('https://api.shop.com/products').then(res => res.json());

  const paths = products.map(p => ({
    params: { slug: p.slug }
  }));

  return {
    paths,
    fallback: 'blocking', // 未预渲染的产品允许 ISR 生成
  };
};

// 2. 获取每个产品的数据
export const getStaticProps = async ({ params }) => {
  const res = await fetch(`https://api.shop.com/products/${params.slug}`);
  const product = await res.json();

  return {
    props: { product },
    revalidate: 30, // 30秒后重新生成
  };
};

// 3. 组件
const ProductPage = ({ product }) => {
  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
      <Link href="/">← 返回列表</Link>
      <h1>{product.name}</h1>
      <div style={{ position: 'relative', width: '100%', height: '400px', marginBottom: '20px' }}>
        <Image 
          src={product.image} 
          alt={product.name} 
          fill 
          sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        />
      </div>
      <p>价格: ${product.price}</p>
      <p>库存: {product.stock}</p>
      <button style={{ padding: '10px 20px', cursor: 'pointer' }}>
        加入购物车
      </button>
    </div>
  );
};

export default ProductPage;

2. 产品列表页

// pages/products/index.js

import Link from 'next/link';

export const getStaticProps = async () => {
  const products = await fetch('https://api.shop.com/products').then(res => res.json());
  return {
    props: { products },
  };
};

const ProductList = ({ products }) => {
  return (
    <div>
      <h1>所有产品</h1>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '20px' }}>
        {products.map(product => (
          <Link key={product.id} href={`/products/${product.slug}`} passHref>
            <div style={{ border: '1px solid #ccc', padding: '10px', cursor: 'pointer' }}>
              <div style={{ height: '150px', background: '#f0f0f0', marginBottom: '10px' }}>
                {/* 这里可以用 next/image 优化,但为了演示简单省略 */}
                 <img src={product.image} alt={product.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
              </div>
              <h3>{product.name}</h3>
              <p>${product.price}</p>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
};

export default ProductList;

分析:

  • 列表页是纯静态的(SSG),加载极快。
  • 点击列表项跳转到详情页。
  • 如果详情页已经预渲染,它瞬间加载。
  • 如果详情页没预渲染(fallback: ‘blocking’),Next.js 现场生成,然后缓存。
  • 库存信息每 30 秒自动更新(ISR)。

第十一部分:SSG 的未来与边缘计算

随着 Vercel、Cloudflare Workers 等边缘计算的发展,SSG 的概念正在发生演变。

传统的 SSG 是在构建时生成的,部署到全球 CDN。但如果数据需要实时更新,还是得请求服务器。

Edge SSG(边缘静态站点生成) 结合了 SSG 和 SSR 的优点。

  1. 边缘节点(离用户更近的地方)进行构建/渲染。
  2. 使用边缘函数处理动态数据请求。
  3. 将结果缓存为静态文件。

这就像是把面包店开到了你家楼下的便利店,你买到的面包是现烤的(动态),但它是包装好的(静态),而且离你只有一步之遥。

虽然 Next.js 13+ 的 App Router 已经开始支持这种模式,但传统的 SSG 依然是目前构建高性能内容网站的基石。


第十二部分:总结与避坑指南(干货中的干货)

好了,老司机要交底了。在实际项目中,我见过太多人把 SSG 用得乱七八糟。这里列出几个最常见的坑:

  1. 不要在 getStaticProps 里用 localStorage

    • 构建时根本没有浏览器环境,你会得到 undefined。这会导致你的应用崩溃。
  2. 不要过度预渲染

    • 如果你有 100 万个页面,别试图全部预渲染。使用 fallback: trueblocking。构建时间过长是项目失败的直接原因。
  3. SEO 的坑:动态路由的 404

    • 如果你用了 getStaticPaths 但没有配置 fallback,那么任何不在列表里的 URL 都会返回 404。搜索引擎可能会认为你的网站有死链,降低权重。务必配置 fallback
  4. 图片路径问题

    • 在 SSG 环境下,next/imagesrc 必须是绝对路径或者是 public 下的相对路径。如果你从 API 拿到的是相对路径(比如 /images/a.jpg),next/image 可能无法正确处理,因为它会尝试去构建时请求这个相对路径,导致 404。解决方案通常是配置 images.loader 或者直接使用 next/imageunoptimized 属性(虽然不推荐)。
  5. TypeScript 的类型推断

    • 现在的 Next.js 对 TypeScript 支持很好。getStaticProps 返回的 props 会自动推断为组件的 props。如果类型不匹配,TypeScript 会直接报错,帮你避开 90% 的运行时错误。

最后,记住这句话:

SSG 不是让你放弃动态性,而是用构建时的计算换取运行时的极速。它是 Web 性能优化的圣杯。

当你看到你的网站在 Lighthouse 测试中拿到 100 分,首屏加载时间在 0.5 秒以内,你会感谢今天坐在这里听讲座的你的。现在,去构建你的静态站点吧!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注