低 JS、静态渲染与懒加载:一次 Astro 博客运行时减重记录

这次修改不是重写主题,也不是追求“零 JS”。目标更直接:保留现有搜索、Mermaid、Blog Galaxy、标签统计和词云功能,把不属于首屏的脚本从初始加载路径挪到真正需要时再加载。

这个博客仍然是 Astro 静态站点。所谓“静态渲染”,在这次修改里指的是继续在构建阶段生成 HTML,不引入 SSR;所谓“低 JS”,指的是减少首屏不必要的客户端运行时代码,而不是禁止所有交互。

搜索:Fuse 只在需要搜索时加载

搜索功能以前直接在 src/components/SearchModal.astro 的客户端脚本里静态引入 Fuse。这样做简单,但问题是:只要页面带搜索入口,搜索库就更容易进入初始客户端路径,即使用户完全没有打开搜索框。

这次改成了类型导入加动态导入:

import type Fuse from 'fuse.js';

const [{default: FuseConstructor}, response] = await Promise.all([
  import('fuse.js'),
  fetch('/search.json'),
]);

搜索数据也仍然来自 /search.json,只是加载时机推迟到 loadSearchData() 被触发之后。原来的搜索权重没有改:title 权重 4,descriptiontags 权重 2,body 权重 1,threshold 仍然是 0.35。

这里的取舍很清楚:搜索功能完整保留,首次进入普通页面时不急着为“可能发生的搜索”加载搜索库和索引数据。

文章页:没有 Mermaid 就不挂载 Mermaid

文章页的 Mermaid 处理也做了同样的事。src/layouts/BlogPost.astro 现在先检查正文里是否有 Mermaid 代码块:

const hasMermaid = /(^|\n)```mermaid\b/i.test(body);

只有 hasMermaid 为真时,页面才会渲染 MermaidRendererOptimizedMermaidViewer。这和“先把组件都输出到页面,再让浏览器决定是否需要”不同:判断发生在 Astro 渲染阶段,非 Mermaid 文章不会输出这两个组件。

这对博客这种内容站更合适。Mermaid 是少数文章才需要的增强功能,不应该成为所有文章页的默认运行时成本。

图表页:重库等到接近视口

src/components/BlogGalaxy.astro 之前直接输出:

<script is:inline src="/libs/echarts.min.js"></script>

这次移除了这个首屏脚本,改成在客户端通过 loadScriptOnce('/libs/echarts.min.js') 按需加载。触发条件是组件进入或接近视口:全屏 Galaxy 用 0pxrootMargin,嵌入式预览用 360px 0pxrootMargin

这不是把 ECharts 改回 CDN。脚本仍然来自本地的 public/libs/echarts.min.js,只是加载时机从“页面 HTML 解析时”推迟到了“用户接近这块交互区域时”。

IntersectionObserver 不可用时,代码会退回到 requestIdleCallback,再退一步使用 setTimeout。这让懒加载逻辑保持简单,也避免为了兼容少数环境引入额外依赖。

标签页:Chart.js 和词云也延后

标签页的两个组件也做了同类修改:

  • src/components/tags/TagsStatistics.astro 不再首屏输出 /libs/chart.umd.min.js,而是在柱状图和累计图接近视口时加载 Chart.js。
  • 同一个文件里的 treemap canvas 和 tag network canvas 也改成接近视口后再初始化绘制。
  • src/components/tags/TagsWordCloud.astro 不再首屏输出 /libs/wordcloud2.js,而是在词云容器接近视口后加载并渲染。

这些组件本身仍然存在,视觉和交互目标没有变;变化只发生在初始化时机上。对静态博客来说,这种改法比拆掉功能更合适,因为它减少的是无用首屏工作,而不是减少页面能力。

验证结果

这次验证分三层做。

第一层是类型检查:

npm run type-check

最终结果为 0 errors,仅保留 Astro 输出的 hints 和 warnings。中间曾遇到两个真实问题:BlogGalaxy.astrowindow.setTimeout 的类型不匹配,以及 SearchModal.astro 中 Fuse 动态导入后的构造器类型不够精确。前者改成 globalThis.setTimeout,后者用 import type Fuse 和构造器类型约束解决。

第二层是构建检查:

npm run build

构建通过,输出模式仍然是 static,也就是继续生成静态页面,没有引入 SSR。

第三层是产物检查。对 dist/index.htmldist/tags/index.htmldist/blog/galaxy/index.html 做关键字扫描后,首页、标签页和 Galaxy 页面没有出现 Fuse、ECharts、Chart.js、WordCloud 这些库的直接首屏脚本引用。dist/tags/index.html 中仍能看到 /libs/chart.umd.min.js/libs/wordcloud2.js 字符串,这是预期结果:它们现在是懒加载路径常量,不是初始 <script src>

没有写进结论的数字

这次没有给出“首屏减少多少 KB”或“加载速度提升多少毫秒”的结论,因为这需要稳定的前后构建产物、网络条件和浏览器性能记录。现在能确定的是代码路径已经改变:搜索库、Mermaid 组件、ECharts、Chart.js 和词云库都从默认首屏路径移动到了条件路径。

对这类静态博客优化,我更看重先把加载路径理顺,再谈数字。结构上的判断标准很简单:普通页面只带普通页面需要的运行时代码;少数交互功能存在,但不抢所有页面的首屏预算。