低 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,description 和 tags 权重 2,body 权重 1,threshold 仍然是 0.35。
这里的取舍很清楚:搜索功能完整保留,首次进入普通页面时不急着为“可能发生的搜索”加载搜索库和索引数据。
文章页:没有 Mermaid 就不挂载 Mermaid
文章页的 Mermaid 处理也做了同样的事。src/layouts/BlogPost.astro 现在先检查正文里是否有 Mermaid 代码块:
const hasMermaid = /(^|\n)```mermaid\b/i.test(body);
只有 hasMermaid 为真时,页面才会渲染 MermaidRendererOptimized 和 MermaidViewer。这和“先把组件都输出到页面,再让浏览器决定是否需要”不同:判断发生在 Astro 渲染阶段,非 Mermaid 文章不会输出这两个组件。
这对博客这种内容站更合适。Mermaid 是少数文章才需要的增强功能,不应该成为所有文章页的默认运行时成本。
图表页:重库等到接近视口
src/components/BlogGalaxy.astro 之前直接输出:
<script is:inline src="/libs/echarts.min.js"></script>
这次移除了这个首屏脚本,改成在客户端通过 loadScriptOnce('/libs/echarts.min.js') 按需加载。触发条件是组件进入或接近视口:全屏 Galaxy 用 0px 的 rootMargin,嵌入式预览用 360px 0px 的 rootMargin。
这不是把 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.astro 里 window.setTimeout 的类型不匹配,以及 SearchModal.astro 中 Fuse 动态导入后的构造器类型不够精确。前者改成 globalThis.setTimeout,后者用 import type Fuse 和构造器类型约束解决。
第二层是构建检查:
npm run build
构建通过,输出模式仍然是 static,也就是继续生成静态页面,没有引入 SSR。
第三层是产物检查。对 dist/index.html、dist/tags/index.html 和 dist/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 和词云库都从默认首屏路径移动到了条件路径。
对这类静态博客优化,我更看重先把加载路径理顺,再谈数字。结构上的判断标准很简单:普通页面只带普通页面需要的运行时代码;少数交互功能存在,但不抢所有页面的首屏预算。