Skip to content

Vite 应用预渲染实战:Lighthouse 性能从 30 提升到 80 ​

译注:本文编译自 dev.to 社区文章,作者 Shreyas H Tripathi 是一名前端与 UI/UX 工程师。原文记录了他如何通过构建时预渲染(prerender)改造一个 Vite + React 单页应用,解决性能与爬虫可见性两大问题。原文链接见文末。

一个带有赛博朋克终端主题的 Vite + React 单页应用——矩阵雨、实时终端、小游戏、彩蛋一应俱全。作者在笔记本上看着效果不错,但在移动端跑了一次 Lighthouse 后发现了严重问题:

指标(移动端)改造前
性能得分30
最大内容绘制(LCP)8.1 s
总阻塞时间(TBT)3,390 ms
不执行 JavaScript 时爬虫可见词数134

问题有两个:站点慢,且不运行 JavaScript 的搜索引擎与 AI 爬虫只能看到一个空的 <div id="root"></div>。作者按影响程度依次做了以下改造。

1. 在构建时预渲染页面 ​

Vite 单页应用先发送空壳,再在浏览器里构建页面。作者改为在构建期间把 React 树渲染成 HTML 并注入 index.html,让首个字节就包含真实内容。

首先编写一个 SSR 入口,用 StaticRouter 替代 BrowserRouter,渲染与应用相同的组件树:

tsx
// src/entry-server.tsx
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { AppShell, AppRoutes } from "./App";

export function render(url = "/"): string {
  return renderToString(
    <AppShell>
      <StaticRouter location=&#123;url&#125;>
        <AppRoutes />
      </StaticRouter>
    </AppShell>
  );
}

然后用一个小型 Node 脚本构建,并为每个路由写出一个 HTML 文件:

javascript
// scripts/prerender.mjs (简化版)
const { render } = await import("../dist-ssr/entry-server.js");
const template = await readFile("dist/index.html", "utf8");

for (const page of pages) {
  const html = template.replace(
    '<div id="root"></div>',
    `<div id="root">${render(page.url)}</div>`
  );
  await writeFile(`dist/${page.file}`, html);
}
json
"build": "vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs"

客户端则改为 hydrate,而非从零渲染,让 React 复用已经绘制的 HTML:

tsx
// src/main.tsx
const container = document.getElementById("root")!;
if (container.hasChildNodes()) hydrateRoot(container, <App />);
else createRoot(container).render(<App />);

结果: 爬虫现在能看到 1,007 个词(此前为 134 个),且文本在任何 JavaScript 执行前就已绘制。

遇到的 hydration 陷阱 ​

  • 动画文本初始为空。 作者的“解密”名字效果把状态初始化为 "",导致预渲染的 <h1> 是空白的。修复方法:从真实文本开始,在 useEffect 中做打乱效果。
  • React 错误 #419。 renderToString 无法等待 React.lazy,因此 <Suspense> 内的懒加载图表会让 React 退回客户端渲染。修复方法:仅在挂载后渲染懒加载组件(见第 3 步),让服务端和客户端都先渲染 fallback。
  • 服务端与客户端组件树必须完全一致。 App.tsx 中的 Toaster 和 Provider 也必须出现在 SSR 树中,否则 hydration 会失败。作者把 App 拆成共享的 AppShell 和 AppRoutes,供两端复用。

2. 页面可见后再加载“好玩的部分” ​

终端、游戏、彩带和光标效果原本都在主包里并立即挂载。没人需要在第一秒就玩到贪吃蛇,于是它们改为浏览器空闲时加载:

tsx
const InteractiveLayer = lazy(() => import("@/components/InteractiveLayer"));

const useIdleMount = () => {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    const ric = window.requestIdleCallback;
    if (ric) {
      const id = ric(() => setReady(true), { timeout: 2500 });
      return () => window.cancelIdleCallback(id);
    }
    const t = setTimeout(() => setReady(true), 1200);
    return () => clearTimeout(t);
  }, []);
  return ready;
};

// 在页面中
{interactive && (
  <Suspense fallback=&#123;null&#125;>
    <InteractiveLayer />
  </Suspense>
)}

作者还移除了一个每次首次访问都会遮挡页面约 3 秒的自动播放启动屏——它很有趣,但它也正是 LCP 的元凶。

3. 仅在接近视口时加载重型库 ​

技能雷达图引入了 recharts(约 354 KB)。现在只有当其所在区块进入屏幕 400px 范围内时才加载:

tsx
const { ref, isVisible: near } = useReveal<HTMLDivElement>({
  threshold: 0,
  rootMargin: "400px 0px",
});

<div ref=&#123;ref&#125;>
  {near ? (
    <Suspense fallback={<RadarSkeleton />}>
      <SkillsRadar />
    </Suspense>
  ) : (
    <RadarSkeleton />
  )}
</div>

4. 修复没人注意到的图片 ​

作者的头像原本是一张 1.5 MB 的 PNG,显示尺寸仅 288px。转换为 600×600 的 WebP 后只有 12 KB,并显式设置 width/height 以消除布局偏移。Open Graph 图片也从 275 KB 降到 89 KB。

5. 让动画跑在 GPU 上 ​

Lighthouse 的“非合成动画”审计发现两个问题:

  • 一个用 background-position 动画的背景网格,每帧都会重绘整个屏幕。作者将其改为用 transform 动画的超大图层:
javascript
'grid-pan': {
  '0%':   { transform: 'translate3d(0,0,0)' },
  '100%': { transform: 'translate3d(40px,40px,0)' },
},
  • 一个用 box-shadow 动画的脉冲圆环,现改为对伪元素的 transform 和 opacity 做动画。

作者还把三个巨大的 blur(130px) 光晕替换为 radial-gradient 背景,视觉效果几乎一致,但在手机上绘制成本极低。

6. 阻止字体阻塞首次绘制 ​

三个 Google Fonts 字体族原本是阻塞渲染的样式表。现在它们被预加载,通过一个小型异步脚本应用,并用 display=swap 立即显示回退文本。

改造结果 ​

指标(移动端)改造前改造后
性能得分3080
最大内容绘制8.1 s3.4 s
总阻塞时间3,390 ms240 ms
累积布局偏移0.0140
SEO / 无障碍 / 最佳实践100 / 100 / 100100 / 100 / 100
不执行 JavaScript 时可见词数1341,007

(数据来自 Lighthouse 12,移动端模拟,实验室数据。)

交互式终端、游戏和彩蛋依然可用——它们只是学会了排队等待。

附加收益:页面成为真实 HTML 后,SEO 变得简单 ​

由于每个路由都预渲染,作者可以为每个项目生成独立的案例研究页,拥有各自的标题、描述、canonical URL 和 JSON-LD 结构化数据,全部在构建时从 UI 使用的同一份数据文件生成。再加上 sitemap 和 llms.txt,搜索引擎和 AI 助手就能真正读取站点内容了。

原文链接 ​

https://dev.to/shreyashtripathi/how-i-made-my-react-portfolio-2x-faster-lighthouse-30-to-80-by-prerendering-a-vite-app-3j03