这次给 chumanic-blog 做了一次深度性能优化,发现首页 JS 载荷从 5MB+ 降到 11KB。过程比预期曲折,结果比预期显著。完整记录如下。
优化前的状态
检查 dist/ 目录时,最显眼的数字是:
5,415,227 dist/D6O_Etdi.js # Three.js
724,236 dist/C5rh5wLt2.js # Three.js sub-modules
191,844 dist/CdadFylL.js # 其他
首页虽然用动态 import('three'),但动态导入只是延迟加载,不是按需加载。Three.js 5.4MB 的 chunk 在用户首次与月相场景交互时就会被下载——而大多数访客根本不会点“展开项目星图”。
这意味着 99% 的访客为他们不需要的功能付出了带宽成本。
第一次尝试:命名导入
最直接的想法:Three.js 不是 monolithic 的,可以用 import { Scene, Mesh, ... } from 'three' 启用 tree-shaking。
实际上 Three.js 的导出结构支持命名导入。改造 lunar-renderer.ts:
// 改造前
const three = await import('three');
const renderer = new three.WebGLRenderer({ ... });
const scene = new three.Scene();
// 改造后
const { WebGLRenderer, Scene, Mesh, ... } = await import('three');
const renderer = new WebGLRenderer({ ... });
const scene = new Scene();
预期效果:Tree-shaking 移除未使用的导出,实际大小可能降到 1-2MB。
实际效果:零变化。5.4MB 一字节没少。
原因是 Vite/Rolldown 的 tree-shaking 对 Three.js 不起作用——Three.js 的每个类都有副作用(注册到全局类型表、WebGL 常量等),编译器无法证明它们可以被移除。
第二次尝试:真正的懒加载
既然 tree-shaking 没用,那就让 Three.js 永远不被大多数用户下载。
改造前的逻辑:
- 页面加载
startLunarStage()立即执行- 立即创建 orchestrator
- 异步等待 renderer
- renderer 内部
await import('three')→ 下载 5.4MB chunk - 渲染场景
改造后的逻辑:
- 页面加载
startLunarStage()注册事件监听器- 不创建 orchestrator,不初始化 renderer,不下载任何 Three.js 代码
- 等用户点击“展开项目星图”按钮,或者滚动到场景附近
- 触发后才开始加载和渲染
关键代码:
let activated = false;
const activate = (): void => {
if (activated || disposed) return;
activated = true;
// 创建 orchestrator + renderer
// 在这里才 await import('three')
};
// 触发 1:用户点击主按钮
const primaryButton = stage.querySelector('[data-lunar-primary-action]');
primaryButton?.addEventListener('click', activate, { once: true });
// 触发 2:场景进入视口(滚到这里也算"感兴趣")
const observer = new IntersectionObserver(...);
observer.observe(stage);
实际效果:首页初始 JS 载荷从 5.4MB 降到 11KB。
5.4MB → 11KB。492x 减少。这就是把“按需加载”真正做到“按用户需求加载”的效果。
验证效果
构建后查看首页 HTML:
<!-- 之前:3 个脚本 + 一个 5MB 的隐式预加载 -->
<script type="module" src="/D4VkRPe8.js"></script>
<script type="module" src="/D2CEUkIe.js"></script>
<script type="module" src="/C_VVgPUG.js"></script>
<!-- 之后:3 个小脚本,没有隐式预加载 -->
<!-- Three.js chunk 只在用户点击后才下载 -->
实际加载的脚本大小:
| 脚本 | 大小 |
|---|---|
| 主题切换器 | 752 字节 |
| 月相控制器 | 9,780 字节 |
| 页脚脚本 | 755 字节 |
| 初始总计 | 11,287 字节 |
如果用户真的想看 3D 月相,他们点击按钮后才会下载那 5.4MB。这是合理的——他们已经在主动请求这个功能。
顺手的可访问性改进
在优化过程中,顺便补了几项可访问性工作:
1. prefers-contrast: more 支持
增强对比度偏好用户的焦点指示器:
@media (prefers-contrast: more) {
:where(a, button, input, ...):focus-visible {
outline-width: 0.25rem;
outline-offset: 0.25rem;
}
}
2. 主题色双轨配置
iOS Safari 在浅色/深色模式间切换时,使用哪个 theme-color:
<meta
name="theme-color"
content="#090b0a"
media="(prefers-color-scheme: dark)"
/>
<meta
name="theme-color"
content="#f0eee5"
media="(prefers-color-scheme: light)"
/>
之前只有深色,浅色模式下浏览器 UI 与站点对比度差。
3. PWA manifest
添加 manifest.webmanifest:
{
"name": "Chumanic",
"display": "standalone",
"theme_color": "#090b0a",
"icons": [{ "src": "/favicon.svg", "sizes": "any" }]
}
虽然不做完整 PWA,但 manifest 让“添加到主屏幕”时的图标、主题色正常。
4. humans.txt
/* TEAM */
Author: Chumanic
Site: https://chumanic.com/
GitHub: https://github.com/Chumaniac
/* SITE */
Standards: HTML5, CSS3, ES2022, WCAG 2.1 AA
Components: Astro 7, Three.js (lazy), Pagefind 1
humans.txt 是 robots.txt 的对偶——前者告诉搜索引擎谁是机器人,后者告诉访客谁是构建者。
5. /.well-known/security.txt
安全披露的标准位置。比让用户翻 README 找邮箱好得多:
Contact: security [at] chumanic [dot] com
Response: within 7 days
Scope: ...
更深的优化方向
这次只做了“懒加载”和“元数据补全”。下一轮可以做的:
CSS 关键路径
- 把首页的关键 CSS 内联进
<head>(Astro 已经做了大部分) - 用
font-display: swap避免字体阻塞 - 预连接 Google Fonts(如果使用)
图片优化
- 用
astro:assets自动生成 responsive srcset - AVIF 优先,WebP 备选,原图兜底
- 关键图片
<link rel="preload">
字体优化
- 切换到
font-display: optional(避免 FOUT) - 子集化 Noto Serif SC(包含 5,000+ 字形,实际只用 200+)
Pagefind 索引
目前索引文件总大小约 442KB。可以:
- 排除代码块(只索引散文)
- 用
data-pagefind-filter排除草稿 - 设置
excerpt_length减少索引大小
Lighthouse 自动检查
可以在 CI 中加入 Lighthouse:
- name: Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
urls: |
https://chumanic.com/
https://chumanic.com/posts/
budgetPath: ./lighthouse-budget.json
把性能预算写入 CI,不达标不允许合并。
经验教训
这次优化最值得记的一点是:
“动态导入”≠“按需加载”。
动态导入只是“不在初始化时同步加载”。它仍然是“首次访问该代码路径时加载”。真正的按需加载需要:
- 默认状态完全不加载
- 监听明确的用户意图(点击、滚动、悬停)
- 在用户意图触发后才发起请求
- 提供 fallback 在加载失败时降级
这次月相场景正好满足所有条件——它有 SVG fallback、用户必须主动展开、按需加载失败会自动降级。
下次做类似优化时,先问自己:
- 这个功能是否所有用户都需要?
- 是否有合适的 fallback?
- 是否能等待明确的用户意图?
- 失败的降级路径是什么?
如果四个问题都有肯定答案,那大概率可以放心做懒加载。
接下来
这次主要优化了首页和文章页。项目页和笔记页也有类似的懒加载机会(比如 Reader 面板的 FreshRSS 数据获取),但目前已经在构建时获取,不影响运行时。
下一阶段会:
- 加入 Lighthouse CI 自动化
- 优化字体加载(子集化 + font-display: swap)
- 评估是否真的需要 3D 月相(SVG fallback 其实已经够好)
- 把懒加载模式抽成公共组件(其他重型功能可以复用)
也许下一个版本,3D 场景会变成完全可选的增强模块,默认关闭,按需启用。
评论