自定义 404 页面返回 200
主线位置:首屏主线「等 suspense」之后、定状态码那一步。
render_to只在根错误边界里找到ParseRouteError时才回 404;错误被别处接住,或者根本没有错误,状态码就是FullstackContext里的那个,默认 200。这张错误到状态码的表见一次请求的渲染,路由那一侧见Router 运行时的「服务端:MemoryHistory、404 和状态码」。
flowchart TB
req["GET /user/x"] --> parse{"路由能解析吗"}
parse -->|不能| err["Router 抛 ParseRouteError"]
err --> eb{"谁接住"}
eb -->|根错误边界| s404["404,纯文本路由表"]
eb -->|"用户 ErrorBoundary"| s200a["200,错误页"]
parse -->|"catch-all 匹配"| nf["NotFound 组件"]
nf --> s200b["200,自定义页面"]
nf --> fix["commit_http_status 404"]
fix --> s404b["404,自定义页面"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),dx 0.7.9 --release 构建。同一个 crate 用 cargo feature 构建出五个变体,分别启动后用 curl 请求三个路径。
现象
路由枚举(实验应用里还有二十来个别的路由,这里省略)和 app:
#[derive(Routable, Clone, Debug, PartialEq)]
enum Route {
// ... other routes
#[route("/")]
Home {},
#[route("/user/:id")]
User { id: u32 },
#[cfg(feature = "catchall")]
#[route("/:..rest")]
NotFound { rest: Vec<String> },
}
#[cfg(feature = "catchall")]
#[component]
fn NotFound(rest: Vec<String>) -> Element {
rsx! { p { id: "page", "not found: {rest:?}" } }
}
fn app() -> Element {
#[cfg(feature = "eb")]
return rsx! {
ErrorBoundary { handle_error: |e: ErrorContext| rsx! { p { id: "page", "error: {e.error().unwrap()}" } },
Router::<Route> {}
}
};
#[cfg(not(feature = "eb"))]
rsx! { Router::<Route> {} }
}
三个变体:什么都不加(base)、加 catch-all 路由(catchall)、把 Router 包进 ErrorBoundary(eb)。每个变体请求 /user/7、/user/x(id 不是数字)、/nope/x,打印状态行、content-type 和正文里的页面部分:
##### build: base
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 404 Not Found
Failed to parse route Route did not match:
Attempted Matches:
1) Route 'Res' ('/res') did not match:
Static segment 'res' did not match instead found 'user'
2) Route 'ResSuspend' ('/res-suspend') did not match:
Static segment 'res-suspend' did not match instead found 'user'
...
##### build: catchall
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: ["user", "x"]<!--#--></p>
== GET /nope/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: ["nope", "x"]<!--#--></p>
##### build: eb
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
== GET /nope/x
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
base 的 /nope/x 和 /user/x 一样是 404。它的完整响应头和长度:
HTTP/1.1 404 Not Found
content-length: 1996
Failed to parse route Route did not match:
Attempted Matches:
lines in body: 40
eb 的 /user/x 正文里是错误边界渲染的页面(错误消息有多行,上面按行匹配没抓到):
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
age: 0
transfer-encoding: chunked
<p id="page" data-node-hydration="0"><!--node-id1-->error: Failed to parse route Route did not match:
base:状态码对了,但响应是 40 行纯文本,没有content-type,没有页面框架,把路由枚举里每个变体的名字和路径都列给了访问者。catchall:页面是自己的,但每个 URL 都能解析,/user/x、/nope/x全是 200。页面说「找不到」,HTTP 说「成功」;搜索引擎会把这些地址当作正常页面收录。eb:ErrorBoundary接住了ParseRouteError,页面是错误边界画的,状态码 200,路由表进了 HTML。
出处
render_to 等完 suspense 后只看根错误边界(ssr.rs):
// If there was an error while routing, return the error with a 404 status
// Return a routing error if any of the errors were a routing error
if let Some(routing_error) = error.downcast_ref::<ParseRouteError>().cloned() {
status_code = Some(StatusCode::NOT_FOUND);
out_message = Some(routing_error.to_string());
}
有状态码时整页不渲染,正文就是 out_message,也就是 ParseRouteError 的 Display:每个候选路由为什么没匹配上。这条路只在错误冒到根错误边界时才走:
- catch-all 路由让每个路径都能解析,
RouterContext::current不再抛错,根错误边界是空的,状态码取FullstackContext的当前值,默认 200。 - 用户的
ErrorBoundary包在Router外面,错误在它那里就被接住了,同样到不了根。
修法
catch-all 路由的组件里自己设 404。 FullstackContext::commit_http_status 写的就是 render_to 在没有错误时读的那个状态。status404 变体:
#[cfg(feature = "catchall")]
#[component]
fn NotFound(rest: Vec<String>) -> Element {
#[cfg(all(feature = "status404", feature = "server"))]
dioxus::fullstack::FullstackContext::commit_http_status(
dioxus::fullstack::http::StatusCode::NOT_FOUND,
None,
);
rsx! { p { id: "page", "not found: {rest:?}" } }
}
ErrorBoundary 里认出路由错误,也设 404。 ebfix 变体:
#[cfg(feature = "ebfix")]
return rsx! {
ErrorBoundary {
handle_error: |e: ErrorContext| {
let err = e.error().unwrap();
// a routing error caught here still deserves a 404
#[cfg(feature = "server")]
if err.downcast_ref::<dioxus::router::ParseRouteError>().is_some() {
dioxus::fullstack::FullstackContext::commit_http_status(
dioxus::fullstack::http::StatusCode::NOT_FOUND,
None,
);
}
rsx! { p { id: "page", "error: {err}" } }
},
Router::<Route> {}
}
};
##### build: status404
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: ["user", "x"]<!--#--></p>
== GET /nope/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->not found: ["nope", "x"]<!--#--></p>
##### build: ebfix
== GET /user/7
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<p id="page" data-node-hydration="0"><!--node-id1-->user 7<!--#--></p>
== GET /user/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
== GET /nope/x
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
两种都是带完整页面的 404,/user/7 不受影响。几点注意:
- 客户端没有
FullstackContext,commit_http_status在那里什么也不做,不加cfg也能编译运行(Router 运行时的实验就没加);这里的cfg只是省掉客户端那份代码。 ebfix的页面仍把{err}原样显示出来,也就是那张路由表。正式的 404 页面不要显示路由错误的内容,只显示一句「找不到」。- 开了乱序流式时,状态码要在首块之前设好,
NotFound里不能先等异步数据再设,见乱序流式下晚设的状态码和响应头丢了。
怎么确认没中招
- 对一个肯定不存在的路径和一个参数类型不对的路径各 curl 一次,只看状态码:
curl --noproxy '*' -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18830/no/such/page
curl --noproxy '*' -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18830/user/x
两个都应该是 404,且 content-type 是 text/html(自定义页面)。是 200,就是 catch-all 或外层 ErrorBoundary 吞掉了;是没有 content-type 的 404,就是访问者看到的是路由表。
- 搜
#[route("/:..和包在Router外面的ErrorBoundary,确认对应的组件里有commit_http_status。
小结
- 路由解析失败只在错误冒到根错误边界时变成 404,正文是列出所有路由的纯文本。
- catch-all 路由让所有路径都能解析,外层
ErrorBoundary把错误截在半路:两种都让 404 页面变成 200。 - 修法:在 catch-all 组件或错误边界的
handle_error里FullstackContext::commit_http_status(StatusCode::NOT_FOUND, None),只在服务端调用。