Astro 静态站的三个隐形约定:CSS 是全局的
用 Astro 写静态站很省心,但它的默认行为里有三条不写在报错里、也不写在文档首页的约定。这三条我都在这两周里踩过,代价分别是:一个整站不跑、多下 200KB 关键 CSS、以及一次错误的性能/SEO 结论。
约定一:<script> 默认不是你想的那种 script
我在接 Cookie 同意横幅时写了这样一段:
---
import { cookieConsentConfig } from "@/config";
---
<script>
// 以为会原样输出到页面里
cc.run({ website_name: cookieConsentConfig.websiteName });
</script>
结果浏览器报 cookieConsentConfig is not defined,横幅整条不出现。
原因:Astro 会把没有 is:inline 的 <script> 当成模块打包(type="module",走 Vite 的模块图)。打包后它运行在独立作用域里,拿不到 frontmatter 的变量。想让它和 frontmatter 共享数据,要么用 is:inline 让它原样输出,要么用 define:vars 显式注入:
<script is:inline define:vars={{ cookieConsentConfig }}>
cc.run({ website_name: cookieConsentConfig.websiteName });
</script>
同一个坑在 JSON-LD 上还有变体:结构化数据必须用 set:html 输出,不能用 {JSON.stringify(x)},否则引号会被转义成 ",搜索引擎读到一段坏 JSON。

自检:grep -c "is:inline" dist/index.html —— 如果一个都没有,说明你站点里所有 inline 脚本都变成了模块,任何”页面里的小脚本”都会失效。
约定二:CSS import 默认是全局的
这条最贵。
我在布局里 import 了公式与代码高亮的样式:
// src/layouts/Layout.astro —— 每个页面都会经过这里
import "katex/dist/katex.min.css";
import "prismjs/themes/prism-tomorrow.css";
看起来很自然:文章页要公式、要代码块。但 Astro 会把布局链上的 CSS 合并进一份全站关键 CSS,于是首页、归档、工具页统统要多下这份样式 —— 而首页连一个公式都没有。
更糟的是,这份关键 CSS 里还有上游模板遗留的编辑器样式(@import "@toast-ui/editor/dist/toastui-editor.css"):项目里根本没人 import 那个编辑器的 JS,也没有任何 .toastui-editor-* 的 DOM,但它的样式一直在被打包。
按选择器前缀统计,311.8 KB 的关键 CSS 是这么构成的:

修法两条:把只在文章页用的样式移到文章页,把模板遗留的死 @import 删掉。结果首页关键 CSS 从 311.8 KB 降到 104 KB,文章页看不出任何差别。
自检:打开构建产物里的 HTML,数一下 <link rel="stylesheet"> 与它们的体积,再问一句「这个页面真的用得到这些样式吗」。工具页、首页、文章页的 CSS 才应该不同。
约定三:dist/ 从不清空
我为 SEO 体检写了个脚本,扫 dist/**/*.html。某次扫出来 28 个页面,其中一个是 /t/:
/t/ - canonical 指向 https://i.juluo.work/
- 缺 description / 缺 h1 / 缺 JSON-LD
但线上访问它明明 404。原因是:dist/ 在两次构建之间不会被清理,/t/ 是几天前删掉的页面残留的产物。我的审计脚本于是给了一份「本地有问题、线上不存在」的结论。

Cloudflare 从干净仓库构建,所以线上是对的;但本地做任何基于产物的分析(审计、体积统计、性能测试)之前,都该先删 dist/。
自检:ls dist/*/index.html 对着 src/pages 数一遍;更省事的做法是每次分析前跑一次 rm -rf dist && pnpm astro build。
附带的第四个坑:0 不等于「没有数据」
这个坑不是 Astro 的,但同样隐形,而且就在这一轮被截图暴露出来:新发布的文章卡片上显示「0 次」,而自建统计里明明已经有浏览记录。
原因在合并逻辑:
// 之前:只有 Umami 返回 undefined 才认为"缺口"
const missing = paths.filter((p) => umami?.paths[p] === undefined);
// ...
const all = { ...selfHosted, ...umami }; // umami 的 0 会盖掉自建的正数
Umami 对新路径返回的是「存在但为 0」,不是「没有这条记录」。于是自建侧的数被当成”已经由 Umami 覆盖”,永远显示 0。修法是把 0 也当作缺口,并在合并时让自建的正数优先:
const missing = paths.filter((p) => {
const v = umami?.paths[p];
return v === undefined || v === 0; // 0 也算缺口
});
const all = { ...(selfHosted ?? {}) };
for (const [p, v] of Object.entries(umami?.paths ?? {})) {
if (v > 0 || all[p] === undefined) all[p] = v; // 0 不覆盖已有的正数
}
教训:「查不到」和「查到了 0」是两件事。API 用 0 表示”没有数据”时,任何”缺省才补”的兜底逻辑都会失效。
三条约定合起来的一句话
Astro 把「布局、样式、脚本、产物」都当成可优化的整体来管,所以它的默认值是为产物服务的,不是为「你以为的作用域」服务的。判断方法很简单:
- 脚本:产物里的
<script>有没有type="module"? - 样式:产物里每个页面引用的 CSS 是不是同一份、多大?
- 产物:你分析的是不是一次干净构建的结果?
这三问都答得出来,Astro 的「隐形约定」就不隐形了。
同一条「作用域」思路也解释了缓存那篇的结论:内容哈希命名的资源应当永久缓存。
发现错误或想要改进这篇文章?
在 GitHub 上编辑此页文章修订历史 (1 次)
查看变更记录
docs(baseline): 第 30 轮终检——数字更新到最终状态,补第 28~30 轮小结
相关文章
- 首屏从 392KB 砍到 59KB,FCP 从 7.4 秒到 2.1 秒用 CDP 模拟中端手机实测博客首页:GA4 与 Clarity 挤在关键路径、头像和 favicon 直接加载 107KB 原图、312KB 关键 CSS 拖慢首屏。四处改动后传输 392KB→59KB,FCP 7.4s→2.1s,长任务 13→2 个。
- 随机图当封面:一张 250KB,我没让它进列表静态博客的卡片没图会很难看,随机图 API 看起来很省事。但实测那张图 250KB、Cache-Control 还是 max-age=0——如果首页 10 条卡片都挂上,一次访问就是 2.5MB。这篇算清这笔账,并给出三种封面方案的取舍。
- 文章配图流水线:截图、压成 WebP、随构建分发写技术文章一半时间花在图上。这套流水线只有四条规则:截图必须真实、按出现顺序编号、统一压成 WebP、和文章目录同名进仓库。附实测压缩数据(2073KB → 357KB)与五个踩过的坑。