一个下划线目录,炸了我的博客构建:Astro content collections 的约定陷阱
在 src/content/posts 下建了个 _templates 目录放文章模板,以为 Astro 会像 pages 一样忽略下划线开头的路径,结果 npm run build 直接崩。排查下来是三层问题叠出来的:glob loader 根本不认下划线约定、模板文件被生成了带斜杠的 id、单段动态路由吞不下两级路径。修好之后还留了一个 dev 模式独有的尾巴。
这个博客是用 Astro 搭的,文章就是一个目录里的一堆 markdown。写了几篇之后我想把文章的骨架固化下来——技术文怎么起小标题、游记该有哪几段——于是在 src/content/posts/ 下面建了个 _templates/ 子目录,放了四个模板文件,每个都老老实实标了 draft: true。
目录名为什么带下划线?因为 Astro 有个广为人知的约定:src/pages/ 里下划线开头的文件和目录不会生成路由。我想当然地觉得,这个约定在 content 目录下也管用——模板嘛,藏在 _templates/ 里,构建时会被自动跳过,优雅。
然后某天 npm run build,崩了。
现象
报错来自动态路由生成阶段,指向 src/pages/posts/[id].astro 这个文件——它是文章详情页的动态路由,getStaticPaths 枚举所有文章、为每篇生成一个静态页面。错误信息的核心意思是:有一个 entry 的 id 叫 _templates/template-tech,这个 id 和 [id] 这段路由参数对不上。
第一眼看到 _templates/template-tech 出现在报错里,我就知道坏了:模板文件根本没有被忽略,它们被当成正经文章收进了内容集合。
排查:三层问题叠出来的崩溃
顺着报错往回捋,这个崩溃其实是三件事叠加的结果,单独任何一件都不会炸。
第一层:glob loader 不认下划线约定。
Astro 5 的 content collections 用 content layer API 定义,我的配置大致是:
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
// ...schema
});
我去翻了 astro/loaders 里 glob() 的源码,里面只有对 config 文件的排除和 content 目录的判断,没有任何一行代码处理下划线前缀。也就是说,“下划线开头就忽略”从来不是 Astro 的全局规范——它只是 pages 路由器这一个模块的行为。content loader 是另一套东西,两者除了都姓 Astro,互相不认识。
**/*.md 这个 pattern 老老实实匹配了 _templates/ 下的所有文件,四个模板全部入库。
第二层:嵌套路径生成了带斜杠的 id。
glob loader 默认用文件相对路径生成 entry id:2026-08-31-xxx.md 变成 2026-08-31-xxx,而 _templates/template-tech.md 变成 _templates/template-tech——注意,id 里带了一个斜杠。这在 loader 看来完全合法,嵌套目录组织内容本来就是支持的用法。
第三层:单段动态路由吞不下两级路径。
我的详情页路由是 src/pages/posts/[id].astro,[id] 是一个单段参数,只能匹配一段路径。2026-08-31-xxx 没问题;_templates/template-tech 带着斜杠,是两段——一个 [id] 吞不下,构建期直接报错。

三层连起来就是完整的因果链:loader 不忽略下划线 → 模板被收录且 id 带斜杠 → 单段路由匹配不了 → build 崩。假如我的路由写的是 [...id].astro(rest 参数,能吃多段路径),构建甚至不会报错——模板会被安安静静地发布成 /posts/_templates/template-tech/ 这样的正式页面,那才是更隐蔽的事故。从这个角度说,崩了反而是运气好。
draft: true 为什么挡不住
四个模板都标了 draft: true,为什么一点用没有?
因为 draft 过滤是查询层的事,不是收录层的。我在文章列表页写的是类似这样的查询:
const posts = await getCollection('posts', ({ data }) => !data.draft);
这个过滤只发生在我调用它的地方。而 getStaticPaths 里如果枚举的是全量 collection(或者过滤条件写得不一致),draft 条目照样被枚举出来参与路由生成。更根本地说:entry 进没进 collection,和它会不会被页面用到,是两个独立的问题。draft 只是 frontmatter 里的一个普通布尔字段,Astro 并不赋予它任何魔法——所有”draft 文章不显示”的行为,都是开发者自己在查询时写出来的。
所以想让模板文件彻底不参与构建,唯一正确的位置是在 loader 这一层就把它们挡在门外。
解法:glob pattern 的否定项
glob() 的 pattern 参数接受数组,数组里可以放 ! 开头的否定 pattern:
const posts = defineCollection({
loader: glob({
pattern: ['**/*.md', '!_templates/**'],
base: './src/content/posts',
}),
// ...schema
});
含义很直白:收录所有 markdown,但排除 _templates/ 下的一切。这不是什么黑魔法——Astro 5 的 glob loader 底层是 tinyglobby,否定 pattern 是它的一等公民特性。改完重新 build,四个模板从 collection 里消失,构建恢复正常。
顺手说一句,这个写法比另外两个候选方案都好:把模板挪出 content 目录(可以,但模板和文章放一起找起来顺手);或者给模板改个不匹配 *.md 的扩展名(太 hack,编辑器的 markdown 支持也没了)。排除规则写在 collection 定义里,和 schema 待在同一个文件,下一个维护的人一眼就能看到。
遗留坑:dev 模式的热更新不认否定 pattern
以为到此为止了?还有个尾巴。
修完之后某次 astro dev 跑着,我顺手改了一下 _templates/ 里的模板内容,dev server 立刻崩了——同样的报错又出现了。重启 dev server,一切正常;再改模板,再崩。
原因是 dev 模式的文件监听走的是另一条路:watch 到文件变化后,用 picomatch.isMatch(entry, pattern) 判断这个文件是否属于 collection,命中就触发内容重新同步。而 picomatch 的 isMatch 在接到 pattern 数组时的语义是”匹配数组中任意一个”——它不把 ! 开头的项当否定规则解析。我实际验证过:
picomatch.isMatch('_templates/template-tech.md', ['**/*.md', '!_templates/**'])
// => true
_templates/template-tech.md 命中了数组里的 **/*.md,于是被判定为集合成员,触发同步,走回那条会崩的路。也就是说,同一个 pattern 数组,全量构建和增量监听用了两个语义不一致的匹配器:tinyglobby 认否定项,picomatch 的 isMatch 不认。
好在影响范围很小:只有 dev server 运行期间去改排除目录下的文件才会触发,重启即恢复,产线 build 完全不受影响。知道成因之后,我的对策就是”dev 跑着的时候不改模板”——要改模板就顺手重启一下,成本忽略不计。真要根治得等上游把 watch 侧的匹配逻辑和 loader 侧对齐。
复盘
这次踩坑最值得留下来的一句话是:“约定”是某一层的行为,不是整个框架的规范。
下划线忽略是 pages 路由器的约定,我把它脑补成了 Astro 的全局规则——这种”把局部经验泛化到整个系统”的错误,写代码的人隔三差五就会犯一次。类似的还有:.gitignore 挡不住已经 track 的文件,.dockerignore 和 .gitignore 语法相似但解析器不同,nginx 的 location 匹配规则和路由框架的差异……每一个都是”我以为它们一致,实际各归各管”。
预防手段其实很朴素:对”我以为会被忽略”的东西,用构建产物验证一次,而不是用直觉。build 完翻一眼 dist 目录,或者在 collection 定义处打印一下 entry 数量,三十秒的事。这三十秒,能省掉一次生产构建挂掉之后的手忙脚乱。
评论