一直消失的 404 页面

一条 catch-all 路由、一个框架 bug 和 Caddy

09.04.2026 | 21 Shawwal 1447
12 min read

بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ

本来应该简单的事

每个网站都会有失效的链接。页面会被改名,URL 被分享出去之后又被修改,搜索引擎索引了一些早已不存在的东西。当有人走进这些死胡同,接下来看到的东西决定了他是留下还是离开。

给 Astro 站点加一个自定义 404 页面,听起来是件常规的事。文档说:创建 src/pages/404.astro,它会被构建为 dist/404.html,托管服务提供商把它发出去。完成。

只是事情没完成。页面在构建时没有报错,没有警告,没有冲突提示。但产出的结果是错的。dist/404.html 里不是那个自定义的 404 页面,而是一条回首页的重定向。404 页面被悄悄覆盖掉了。

术语说明
什么是静态站点构建?

在任何访客到来之前,Astro 会处理每一个源文件(模板、内容、组件),把结果以纯 HTML、CSS、JavaScript 文件的形式写到一个叫 dist/ 的文件夹里。服务器直接把这些文件发出去:没有数据库,每次请求也不会再做什么处理。本文谈到的冲突就发生在这个构建步骤里,在站点真正上线之前。

什么是 catch-all 路由?

catch-all 路由能匹配任何没有更具体处理逻辑的 URL 路径。在这套配置里,[...index].astro 会接住所有根级路径:某个路径下没有内容时,就把请求重定向到首页。在构建过程中,/404 在 catch-all 路由眼里只是又一条要处理的路径而已,问题的根源就在这里。

什么是 Caddy?

Caddy 是一个 Web 服务器:负责接收浏览器的请求,并决定要把什么发回去。Astro 构建站点,Caddy 把它发出去。文件找不到时,Caddy 来处理这个错误,所以这次修复同时涉及框架和服务器两边。

前提

这个站点运行在 Astro 6.x 上,以静态构建的形式部署到一台运行 Caddy 的服务器。它是多语言的:英文、德文、西班牙文、法文、中文。落地页在根路径使用一条带 rest 参数的 catch-all 路由:

src/pages/[...index].astro

getStaticPaths 函数把语言代码作为参数返回。英文对应 undefined(生成 /),德文对应 de(生成 /de),依此类推。如果访客访问的路径没有匹配任何 content entry,catch-all 路由就把他重定向到首页。

Rest 参数在结构上是必需的。在 Astro 里,undefined 是只有 rest 参数才支持的特性,也是让英文在 / 上不带语言前缀被提供的唯一方式。命名的 [index] 参数做不到这一点。所以这条 catch-all 路由并不是一个可以随便换掉的设计选择。它是承重的。

对这个站点来说,这套方案一直跑得好好的。真正把它打破的,是一个自定义 404 页面。

问题

先放进去一个最简的 src/pages/404.astro:站点 logo、一段简短的文字、两张指向主内容区的卡片。构建站点。查看 dist/404.html

文件在那儿,但访客看到的不是一张带导航的有用页面,而是一次悄无声息的回首页跳转。没有任何解释,也没有任何迹象表明出了问题。文件内容是这样的:

<!doctype html>
<title>Redirecting to: /</title>
<meta http-equiv=refresh content="2;url=/">
<a href=/>Redirecting from /404/ to /</a>

这不是我的 404 页面。这是 catch-all 路由的重定向。Astro 的 [...index].astro 在构建过程中拦下了 /404,没找到匹配的 content entry,走到了 Astro.redirect("/") 这个兜底分支,把那条重定向写进了 dist/404.html,覆盖掉了 404.astro 本来会生成的内容。

没有报错,没有警告,没有任何迹象表明出了问题。构建输出的是 404.html (+24ms),仿佛一切正常。

custom page writtenredirect fallback overwritesAstro static build404.astrocatch-all route/404: no content entrydist/404.htmlbuild exits: no errors

文档是怎么说的

这里相关的路由优先级规则:

  1. 保留路由(_astro/_server_islands/_actions/
  2. 路径分段越具体,优先级越高
  3. 静态路由优先于动态路由
  4. 命名参数优先于 rest 参数
外部链接 docs.astro.build/en/guides/routing

按这些规则,404.astro(静态路由)应当赢过 [...index].astro(rest 参数路由)。实际并没有。文件被一个压一个地生成。catch-all 路由最后写,把它覆盖掉了。

没起作用的办法

那些显而易见的修复,和一些没那么明显的。

从 catch-all 路由里返回 404 Response:

if (index === "404") {
return new Response(null, { status: 404 });
}

结果:Astro 完全跳过了 dist/404.html 的生成。文件根本不存在。

使用 Astro.rewrite("/404")

if (!entry?.data?.file_name) {
return Astro.rewrite("/404");
}

结果:检测到循环。rewrite 又被路由到 [...index].astro

在 Astro 配置里设置 prerenderConflictBehavior: 'error'

结果:没有任何报错。Astro 不把这视为冲突。

404.astro 移到子目录(src/pages/_error/404.astro):

结果:Astro 会忽略以下划线开头的目录。

[...index].astro 重命名为 [index].astro

这确实会把 catch-all 行为去掉。但 [...index].astro 对英文返回的是 { params: { index: undefined } },从而生成根路径 /。Astro 文档写明 undefined 参数是只有 rest 参数才支持的特性。换成命名的 [index] 参数会把首页弄坏。

外部链接 docs.astro.build/en/guides/routing

GitHub issues

这不是一个新问题。Astro 的维护者们已经多次报告并确认过它:

Issue #9103:「static route in subfolder gets overridden by Rest-parameter.」 一位维护者确认道:「the priority is correct, it even works correctly on the dev server, but the file gets generated one on top of the other.」 这个 issue 被关闭了,但底层问题仍在。

外部链接 github.com/withastro/astro/issues/9103

Issue #9832:「Prerender page conflicts are silently ignored.」 由同一位诊断了 #9103 的维护者提交。这个 issue 被分配出去然后关闭了;在这套配置里,底层行为依然可以复现。

外部链接 github.com/withastro/astro/issues/9832

Issue #12175:「Custom 404 pages in localized sites.」 仍未关闭。一位 Astro 维护者曾经以 「this is fixed」 为由把它关掉了,然后当它被重新打开时,又想不起来当初为什么关。

外部链接 github.com/withastro/astro/issues/12175

根本原因在 Astro 静态构建生成文件的方式:路由是按顺序构建的,而当 catch-all 生成的路径与一个已经写入的文件相同时,它就会覆盖掉。路由优先级在解析层面上是正确的,但在文件写入层面并没有被强制执行。

两种方案

一旦你理解了这个 bug,就有两种干净的绕行方案:

**方案 A:构建后拷贝。**保留 404.astro,但换一个名字(比如 not-found.astro)。它会被无冲突地构建为 dist/not-found/index.html。然后一个构建后脚本把它拷贝到 dist/404.html。整条 Astro 流水线都保留下来:字体、主题系统、共享组件。

**方案 B:独立 HTML。**把一份自包含的 404.html 放进 Public/ 目录。Astro 会把它作为静态资源拷贝到 dist/404.html,完全绕开路由系统。不可能再出冲突。

为什么选了独立 HTML

方案 B 没有活动部件。没有要维护的构建后脚本,没有要记录的改名把戏,没有耦合到一个也许哪天会被修复的 bug 上(真修了还得回头清理这个绕行方案)。

代价:没有 Astro 字体流水线,没有浅色/深色主题切换,没有共享组件。这个页面用系统字体作为回退,硬写死了深色主题的 CSS 变量。对于一张只有两行文字和两张导航卡片的页面来说,这个代价是值得的。

如果 Astro 把底层的 bug 修了,这条路由能因沙安拉搬回 Astro 的流水线里。这次迁移并不是一步完成的:字体设置和主题切换器得在这条路由上重建一遍。值得做一次,但不是现在。在那之前,更简单的方案才是更好的。

Caddy 这边

这个 404 页面需要 Web 服务器真正把它发出去。在 Caddy 里,这只要一条指令:

handle_errors {
rewrite * /404.html
file_server
}

file_server 返回 404(文件未找到)时,handle_errors 会把这个错误捕获住,把请求重写到 /404.html,然后把自定义页面发出去。不需要后端,不需要额外配置。

Caddy 的默认配置对 favicon 没有任何 Cache-Control 头。这就是为什么 Chromium 类浏览器在一次服务器迁移之后,会出现 favicon 过期或显示不出来的情况。一行 header 指令就能解决:

header /favicon.svg Cache-Control "max-age=3600, must-revalidate"

都是小事,但和 404 bug 是同一个套路:看起来没问题,直到你真的去检查。

归根到底

框架会静悄悄地出错。路由优先级在解析层面可以是正确的,到了文件写入层面仍然可以是错的。一个报不出任何错误的构建,仍然可以发布出错误的产物。最简单的干净绕行方案,往往不是最聪明的那一个。

404 页面起作用了。访客落在失效链接上,会看到一张干净的页面,和一条可以继续往下走的路。代码就是一份没有依赖的 HTML 文件。

这会因沙安拉撑到那个 bug 被修复为止。

常见问题
这个问题影响所有 Astro 站点,还是只影响特定的配置?

只有在根级使用 rest 参数路由的站点才会撞上这个问题。覆盖之所以会发生,是因为 `[...index].astro` 在构建过程中会为 `/404` 生成一个文件。使用固定路由的标准 Astro 站点不会出现这种冲突。

怎么判断自己的 404 页面是不是被悄悄覆盖了?

构建之后,打开 `dist/404.html` 看看里面的内容。如果里面装的是 `<meta http-equiv=refresh>` 重定向,而不是页面本身的内容,那就是 catch-all 把它覆盖掉了。无论是哪种情况,构建过程都不会报错。

这个修复必须用 Caddy 吗?还是别的 Web 服务器也行?

放进 `Public/` 这种做法在任何 Web 服务器上都能用;`handle_errors` 指令则是 Caddy 专属的。把 `404.html` 放进 `Public/`,可以完全绕过 Astro 的路由系统,在 `dist/` 里得到一个任何服务器都能直接发出去的文件。不同服务器的区别在于怎么配置它在出错时去发这个文件:Caddy 用 `handle_errors`,Nginx 用 `error_page`,Apache 用 `ErrorDocument`。Astro 这边的修复是通用的。

Astro 会修这个问题吗?

这个问题已经被报告、确认,并且也得到了部分处理,但写下这段时,它在这套配置里仍然能复现。覆盖它的三个 GitHub issue 已经在上面给出。维护者已经关掉了其中两个,而底层行为没有消失。Issue #12175 还在开着。如果这个 bug 被彻底修好,404 页面能因沙安拉重新搬回 Astro 的流水线里,但少不了重新搭建字体和主题的集成。

航空航天工程师

公开透明的道德创业者

你专注你的事业

我负责数字化的部分

与我合作
  • 诚实的 AI
  • 私有基础设施
  • 高性能网站

告诉我您的情况:

javed@javedab.com 了解更多