Static Generation实战:提升网站性能与SEO的静态生成方案

___春景 框架 阅读 1,456
赞 17 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月接手一个老项目,用的是 Next.js + Static Generation(SSG),理论上应该飞快才对。结果一打开首页,Lighthouse 直接给我打了个 42 分,FCP(First Contentful Paint)5.2 秒,TTI(Time to Interactive)更是飙到 6.8 秒。用户反馈“点进去以为页面挂了”,我自己本地 dev 模式跑都卡得想砸键盘。

Static Generation实战:提升网站性能与SEO的静态生成方案

最离谱的是,明明是静态生成的页面,为什么还这么慢?数据量也不大,就几百个产品页,每个页面也就拉个 JSON 文件渲染。我一开始以为是图片没优化,结果把所有图片换成占位图,加载时间只降了 300ms —— 根本问题不在那。

找到瓶颈了!

折腾了半天,打开 Chrome DevTools 的 Performance 面板录了个加载过程,发现主线程在 getStaticProps 里疯狂跑 JS,而且每个页面都重复拉同一个 API 接口。原来问题出在这:我们用 getStaticPaths 生成了 500 多个动态路由,每个路由的 getStaticProps 都独立调用一次远程 API 获取数据。

比如:

// pages/products/[id].js
export async function getStaticProps({ params }) {
  const res = await fetch(https://jztheme.com/api/products/${params.id});
  const product = await res.json();
  return { props: { product } };
}

这代码看着没问题,但构建时 500 个页面就发 500 次请求,不仅慢,还把测试 API 打崩了两次(别问,问就是被运维骂了)。更糟的是,这些产品数据其实变动极低,完全没必要每个页面单独拉。

再看 Lighthouse 报告,最大的问题是“减少主线程工作”和“避免重复网络请求”。好,方向明确了:合并数据拉取,减少 I/O 次数。

方案一:全量预取 + 内存缓存(失败)

我第一反应是:干脆在构建开始时一次性拉完整个产品列表,然后在 getStaticProps 里直接从内存读。试了下:

// lib/data.js
let allProducts = null;

export async function fetchAllProducts() {
  if (allProducts) return allProducts; // 缓存
  const res = await fetch('https://jztheme.com/api/products');
  allProducts = await res.json();
  return allProducts;
}

然后在每个页面里:

import { fetchAllProducts } from '@/lib/data';

export async function getStaticProps({ params }) {
  const products = await fetchAllProducts();
  const product = products.find(p => p.id === params.id);
  return { props: { product } };
}

想法很美,但实际构建时 Next.js 的 SSG 是并行执行 getStaticProps 的,每个 worker 进程都有自己独立的内存空间,allProducts 缓存根本没生效。结果还是发了 500 次请求,只是这次是并发打过去,API 更扛不住了。这方案直接废掉。

方案二:构建时写本地 JSON(亲测有效)

既然内存缓存靠不住,那就落地到磁盘。我在根目录加了个 scripts/build-data.js,在构建前先跑这个脚本:

// scripts/build-data.js
import fs from 'fs';
import path from 'path';

async function main() {
  const res = await fetch('https://jztheme.com/api/products');
  const products = await res.json();
  
  // 按 ID 分文件存,方便后续按需读
  const dataDir = path.join(process.cwd(), 'public/data');
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
  
  products.forEach(product => {
    fs.writeFileSync(
      path.join(dataDir, ${product.id}.json),
      JSON.stringify(product)
    );
  });
  
  console.log(✅ Built ${products.length} product data files);
}

main();

然后在 package.json 里改构建命令:

{
  "scripts": {
    "build": "node scripts/build-data.js && next build"
  }
}

页面里就简单多了,直接读本地文件:

// pages/products/[id].js
import fs from 'fs';
import path from 'path';

export async function getStaticProps({ params }) {
  const filePath = path.join(process.cwd(), 'public/data', ${params.id}.json);
  const product = JSON.parse(fs.readFileSync(filePath, 'utf8'));
  return { props: { product } };
}

这里注意我踩过好几次坑:一定要用 process.cwd(),别用相对路径,否则在 CI/CD 环境容易找不到文件。另外,public/data 目录会被 Next.js 当作静态资源,但因为我们只在构建时读,运行时不会暴露,所以安全。

改完后重新构建,时间从原来的 3 分 12 秒降到 48 秒。更重要的是,API 只被调了一次,再也不怕被打崩了。

性能数据对比

部署上线后,用 WebPageTest 测了三次取平均值:

  • 优化前:FCP 5.2s,TTI 6.8s,Lighthouse 性能分 42
  • 优化后:FCP 780ms,TTI 920ms,Lighthouse 性能分 96

首屏加载快了将近 7 倍,用户反馈“秒开”。而且因为数据是构建时生成的,CDN 缓存效率极高,连带降低了服务器压力。

不过有个小问题:如果产品数据更新,必须重新触发构建。但我们业务场景是每天凌晨同步一次数据,所以完全可接受。如果你的数据是实时变动的,可能得考虑 ISR(Incremental Static Regeneration),但那是另一个故事了。

其他小优化(顺手做了)

除了主干优化,我还顺手干了几件事:

  • getStaticPathsfallback: true 改成 false,因为所有路径都是已知的,没必要让未知路径走 fallback
  • 给 JSON 文件加了 gzip 压缩(Next.js 默认支持,不用动)
  • 移除了页面里没用的第三方库,减小了 bundle 体积

这些改动加起来又省了 100-200ms,但比起主干优化,算是锦上添花了。

结尾

以上是我这次 Static Generation 性能优化的实战经验。核心思路就一点:别在 SSG 里做重复、低效的 I/O。能合并就合并,能预生成就预生成。虽然写本地 JSON 看着有点“土”,但胜在简单、稳定、效果拔群。

这个方案不是最优解(比如用数据库缓存可能更优雅),但对我们这种小团队来说,维护成本最低,出问题也最容易排查。改完后我终于能睡个好觉了,不用半夜被报警叫醒说 API 被打挂了。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你们怎么处理大规模 SSG 数据拉取的?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论