文章

个人站点的性能优化复盘:把首页 JS 载荷从 5MB 降到 11KB

记录这次针对 chumanic-blog 的性能优化:从 Three.js 懒加载、命名导入、到 prefers-contrast 支持的完整过程。

这次给 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,不达标不允许合并。

经验教训

这次优化最值得记的一点是:

“动态导入”≠“按需加载”。

动态导入只是“不在初始化时同步加载”。它仍然是“首次访问该代码路径时加载”。真正的按需加载需要:

  1. 默认状态完全不加载
  2. 监听明确的用户意图(点击、滚动、悬停)
  3. 在用户意图触发后才发起请求
  4. 提供 fallback 在加载失败时降级

这次月相场景正好满足所有条件——它有 SVG fallback、用户必须主动展开、按需加载失败会自动降级。

下次做类似优化时,先问自己:

  • 这个功能是否所有用户都需要?
  • 是否有合适的 fallback?
  • 是否能等待明确的用户意图?
  • 失败的降级路径是什么?

如果四个问题都有肯定答案,那大概率可以放心做懒加载。

接下来

这次主要优化了首页和文章页。项目页和笔记页也有类似的懒加载机会(比如 Reader 面板的 FreshRSS 数据获取),但目前已经在构建时获取,不影响运行时。

下一阶段会:

  1. 加入 Lighthouse CI 自动化
  2. 优化字体加载(子集化 + font-display: swap)
  3. 评估是否真的需要 3D 月相(SVG fallback 其实已经够好)
  4. 把懒加载模式抽成公共组件(其他重型功能可以复用)

也许下一个版本,3D 场景会变成完全可选的增强模块,默认关闭,按需启用。

评论