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,渲染与应用相同的组件树:
// 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={url}>
<AppRoutes />
</StaticRouter>
</AppShell>
);
}然后用一个小型 Node 脚本构建,并为每个路由写出一个 HTML 文件:
// 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);
}"build": "vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs"客户端则改为 hydrate,而非从零渲染,让 React 复用已经绘制的 HTML:
// 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. 页面可见后再加载“好玩的部分”
终端、游戏、彩带和光标效果原本都在主包里并立即挂载。没人需要在第一秒就玩到贪吃蛇,于是它们改为浏览器空闲时加载:
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={null}>
<InteractiveLayer />
</Suspense>
)}作者还移除了一个每次首次访问都会遮挡页面约 3 秒的自动播放启动屏——它很有趣,但它也正是 LCP 的元凶。
3. 仅在接近视口时加载重型库
技能雷达图引入了 recharts(约 354 KB)。现在只有当其所在区块进入屏幕 400px 范围内时才加载:
const { ref, isVisible: near } = useReveal<HTMLDivElement>({
threshold: 0,
rootMargin: "400px 0px",
});
<div ref={ref}>
{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动画的超大图层:
'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 立即显示回退文本。
改造结果
| 指标(移动端) | 改造前 | 改造后 |
|---|---|---|
| 性能得分 | 30 | 80 |
| 最大内容绘制 | 8.1 s | 3.4 s |
| 总阻塞时间 | 3,390 ms | 240 ms |
| 累积布局偏移 | 0.014 | 0 |
| SEO / 无障碍 / 最佳实践 | 100 / 100 / 100 | 100 / 100 / 100 |
| 不执行 JavaScript 时可见词数 | 134 | 1,007 |
(数据来自 Lighthouse 12,移动端模拟,实验室数据。)
交互式终端、游戏和彩蛋依然可用——它们只是学会了排队等待。
附加收益:页面成为真实 HTML 后,SEO 变得简单
由于每个路由都预渲染,作者可以为每个项目生成独立的案例研究页,拥有各自的标题、描述、canonical URL 和 JSON-LD 结构化数据,全部在构建时从 UI 使用的同一份数据文件生成。再加上 sitemap 和 llms.txt,搜索引擎和 AI 助手就能真正读取站点内容了。