Routable
主线位置:首屏主线上「每个请求一个
VirtualDom」之后,Router第一次渲染时用FromStr把请求的 path 解析成枚举值,解析失败就是 SSR 的 404;更新主线上点一个Link,写进历史记录的是枚举值的Display,各层Outlet再用render(level)取出自己那一层的组件。Router、Outlet、Link本身在 Router 运行时。
flowchart TB enumdef["enum Route 加属性"] --> parsenode["RouteEnum::parse"] parsenode --> eps["endpoints nests layouts"] eps --> errty["错误类型 RouteMatchError"] eps --> disp["impl Display"] eps --> rend["impl Routable render"] eps --> tree["ParseRouteTree 排序"] tree --> fromstr["impl FromStr"] fromstr -->|"成功"| variant["枚举值"] fromstr -->|"失败"| perr["RouteParseError"] variant --> disp variant --> rend
路由枚举的每个变体是一个页面,字段是这个页面从 URL 里拿到的参数。dioxus-router 自己几乎不懂 URL:它只要求枚举实现 Routable,
pub trait Routable: FromStr<Err: Display> + Display + Clone + 'static {
/// The error that can occur when parsing a route.
const SITE_MAP: &'static [SiteMapSegment];
/// Render the route at the given level
fn render(&self, level: usize) -> Element;
// ...
}
也就是「字符串 → 枚举」(FromStr)、「枚举 → 字符串」(Display)和「枚举 → 第几层的组件」(render)三件事,加一张站点地图。这三件事全部由 dioxus-router-macro 的 #[derive(Routable)] 在编译期生成,运行时没有任何路由表、正则或 trie。
实验 crate 依赖 dioxus =0.7.10(router、fullstack),另加 dioxus-router、dioxus-history、dioxus-html(serialize)都是 =0.7.10,原生 VirtualDom、debug 构建。平台 M2 Max、macOS 27、rustc 1.99.0。
一个小枚举展开成什么
用一个把五种属性都用上的枚举做 cargo expand:
#[rustfmt::skip]
#[derive(Routable, Clone, Debug, PartialEq)]
enum R {
#[layout(Frame)]
#[nest("/post")]
#[route("/:id?:q")]
Post { id: u32, q: String },
#[end_nest]
#[end_layout]
#[redirect("/p/:id", |id: u32| R::Post { id, q: String::new() })]
#[route("/:..rest")]
NotFound { rest: Vec<String> },
}
:id 是动态段,?:q 是一个查询参数,:..rest 是 catch-all,#[layout(Frame)] 把 Post 包进 Frame 组件,#[nest("/post")] 给里面的路由加前缀,#[redirect] 把 /p/:id 解析成 Post。宏的入口只有四行:
(quote! {
const _: () = {
#error_type
#display_impl
#routable_impl
#parse_impl
};
})
展开结果 1754 行(debug,含 #[component] 生成的 props builder),属于路由的是这四段:
| 段 | 生成的东西 | 生成它的函数 |
|---|---|---|
error_type | 每个路由、重定向、nest 一个错误枚举(PostParseError、Redirect1ParseError、NotFoundParseError、Nest0ParseError),外加总的 RMatchError | RouteEnum::error_type、create_error_type |
display_impl | impl Display for R,每个变体一个分支,按段写 URL | impl_display、Route::display_match |
routable_impl | impl Routable for R:SITE_MAP 常量和 render | routable_impl、Route::routable_match |
parse_impl | impl FromStr for R 和转发给它的 TryFrom<&str> | parse_impl、route_tree.rs |
重定向不是变体:它只出现在 FromStr 和错误类型里,Display 和 render 里没有它。错误枚举的名字带序号,Redirect1 的 1 是它在 endpoints 里的下标(前面有一个 Post),Nest0 是第 0 个 nest。
属性是按变体的顺序扫一遍处理的(RouteEnum::parse):#[nest] / #[layout] 各压一个栈,#[end_nest] / #[end_layout] 出栈,每个 #[route] 变体记下当时栈里的 nest 和 layout;#[layout(!Frame)] 把某个 layout 从这一个变体里排除。这些属性只能挂在变体上,所以枚举最后一个变体后面不能再写 #[end_layout],rustc 会报 expected identifier, found }。
render:按层号挑组件
render 展开后是一个 (level, self) 的 match(只留分支头,每个分支体是一段 rsx!):
match (level, myself) {
(0usize, Self::Post { id, q, .. }) => { /* rsx! { Frame {} } */ }
(1usize, Self::Post { id, q }) => { /* rsx! { Post { id, q } } */ }
(0usize, Self::NotFound { rest }) => { /* rsx! { NotFound { rest } } */ }
_ => VNode::empty(),
}
Post 外面有一层 layout,所以第 0 层是 Frame,第 1 层才是 Post 自己;NotFound 没有 layout,第 0 层就是它。layout 组件拿到的 props 只有它所在 nest 的动态段(Layout::routable_match),这里 /post 没有动态段,所以是 Frame {}。层号从哪来、谁来调 render,是 Outlet 的事,见 Router 运行时。
叶子分支其实包在 dioxus::config_macros::maybe_wasm_split! 里:没开 wasm-split 时取 else 分支,就是上面的 rsx!;开了以后每个路由组件被放进一个懒加载模块,用 use_resource(..).suspend()? 等它加载。route.rs 里的注释说这段代码「pretty gnarly」,并提到路由宏的生成代码占了 dioxus 文档站二进制的 30%–40%。
Display:按段写回 URL
Post 的 Display 分支:
Self::Post { id, q } => {
{
f.write_fmt(format_args!("/{0}", "post"))?;
}
{
let as_string = id.to_string();
f.write_fmt(
format_args!(
"/{0}",
dioxus_router::exports::percent_encoding::utf8_percent_encode(
&as_string,
dioxus_router::exports::PATH_ASCII_SET,
),
),
)?;
}
f.write_fmt(format_args!("?"))?;
{
let as_string = dioxus_router::routable::DisplayQueryArgument::new(
"q",
q,
)
.to_string();
// ... utf8_percent_encode(&as_string, QUERY_ASCII_SET)
}
}
Self::NotFound { rest } => {
dioxus_router::ToRouteSegments::display_route_segments(rest, f)?;
}
先写各层 nest 的段,再写自己的段:静态段原样,动态段 to_string() 后按 PATH_ASCII_SET 转义,catch-all 交给 ToRouteSegments(对任何能迭代出 Display 的类型自动实现,每项前面加 / 再转义),查询参数写成 q=值 按 QUERY_ASCII_SET 转义。转义集定义在 query_sets:
pub const QUERY_ASCII_SET: &AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>');
/// The ASCII set that must be escaped in path segments.
pub const PATH_ASCII_SET: &AsciiSet = &QUERY_ASCII_SET
.add(b'?')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'}');
这是 WHATWG URL 规范里 query 和 path 的转义集(源码注释给了出处)。这两个集合是给「浏览器地址栏里合法」用的,不是给「能原样解析回来」用的:路径集里没有 / 和 %,查询集里没有 &。后面的往返实验会看到这一点的后果。
FromStr:一棵按静态段合并的树
FromStr 的开头对所有路由都一样(parse_impl 里的模板):
fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
let route = s;
let (route, hash) = route.split_once('#').unwrap_or((route, ""));
let (route, query) = route.split_once('?').unwrap_or((route, ""));
// Remove any trailing slashes. We parse /route/ and /route in the same way
// Note: we don't use trim because it includes more code
let route = route.strip_suffix('/').unwrap_or(route);
let query = dioxus_router::exports::percent_encoding::percent_decode_str(query)
.decode_utf8()
.unwrap_or(query.into());
// ... hash 同样解码
let mut segments = route.split('/').map(|s| {
dioxus_router::exports::percent_encoding::percent_decode_str(s)
.decode_utf8()
.unwrap_or(s.into())
});
// skip the first empty segment
if s.starts_with('/') {
let _ = segments.next();
} else {
// if this route does not start with a slash, it is not a valid route
return Err(dioxus_router::routable::RouteParseError {
attempted_routes: Vec::new(),
});
}
let mut errors = Vec::new();
#(#tokens)*
Err(dioxus_router::routable::RouteParseError {
attempted_routes: errors,
})
}
切掉 #… 和 ?…,去掉一个尾斜杠,按 / 切段并逐段百分号解码,然后依次跑 #(#tokens)*:每个路由一段代码块,块里匹配成功就 return Ok(..),失败就往 errors 里推一条原因,落到底就是 Err。segments 是迭代器,每段代码开头 segments.clone() 一份,所以一次失败的尝试不会吃掉后面尝试要用的段。
tokens 不是按枚举顺序一条条写出来的。ParseRouteTree::construct 先把所有路由(和重定向)按开头的静态段合并成一棵树:/user/:id 和 /user/me 共用一个 user 节点,nest 的静态前缀也一样;遇到第一个动态段就停,剩下的段挂在叶子上由叶子自己匹配。然后 sort_ids 给每一层的兄弟排序:
ids.sort_by_key(|&seg| {
let seg = self.get(seg).unwrap();
match seg {
RouteTreeSegmentData::Static { .. } => 0,
RouteTreeSegmentData::Nest { .. } => 1,
RouteTreeSegmentData::Route(route) => {
// Routes that end in a catch all segment should be checked last
match route.segments.last() {
Some(RouteSegment::CatchAll(..)) => 2,
_ => 1,
}
}
// ... Redirect 同 Route
}
});
静态段节点 0,nest 和普通路由 1,以 catch-all 结尾的 2。sort_by_key 是稳定排序,同一档里保持枚举里的先后。展开后的代码就是这棵树的前序遍历:静态节点是 if "post" == segment { 子节点 } else { errors.push(..) },动态段是 FromRouteSegment::from_route_segment(segment) 成功才往下,叶子最后检查段有没有用完(return_constructed),没用完推一条 ExtraSegments。
上面 R 展开出的 Post 那一支(缩短了 map_err 和错误分支):
{
let mut segments = segments.clone();
let segment = segments.next();
if let Some(segment) = segment.as_deref() {
if "post" == segment {
{
let mut segments = segments.clone();
let segment = segments.next();
let parsed = if let Some(segment) = segment.as_deref() {
<u32 as dioxus_router::routable::FromRouteSegment>::from_route_segment(
segment,
)
// ...
} else {
Err(RMatchError::Post(PostParseError::idMissingError))
};
match parsed {
Ok(id) => {
// ...
if next_segment.is_none() {
let split_query: std::collections::HashMap<&str, &str> = query
.split('&')
.filter_map(|s| s.split_once('='))
.collect();
let q = match split_query.get("q") {
Some(query_argument) => {
use dioxus_router::routable::FromQueryArgument;
<String>::from_query_argument(query_argument)
.unwrap_or_default()
}
None => <String as Default>::default(),
};
return Ok(R::Post { id, q });
} else {
// ... errors.push(ExtraSegments(..))
}
}
// ...
几个由这段代码直接决定的行为:
- 查询参数不参与匹配。 它们在路径匹配成功之后才解析,解析失败走
unwrap_or_default(),变成默认值,路由照样匹配成功。FromQueryArgument的默认实现失败时还会打一条tracing::error!。 - 查询串先整体解码,再按
&、=切。 所以值里转义过的%26解码成&以后又被当成分隔符。 - catch-all 收下剩下的所有段,包括零个。 它在
try_parse里把剩余段collect成Vec<&str>交给FromRouteSegments,以它结尾的路由不检查剩余段。宏在parse_route_segments里拒绝 catch-all 后面再有段,Nest::parse拒绝在 nest 里用 catch-all。 - 哈希片段只有声明了
#:field的路由才用,否则切下来就丢掉。
实验:解析一组 URL
用这个枚举(组件只打印自己的参数):
#[rustfmt::skip]
#[derive(Routable, Clone, Debug, PartialEq)]
pub enum Route {
#[layout(Shell)]
#[route("/")]
Home {},
#[route("/user/:id")]
User { id: u32 },
#[route("/user/me")]
Me {},
#[nest("/blog")]
#[route("/")]
BlogIndex {},
#[route("/:slug")]
Post { slug: String },
#[end_nest]
#[route("/search?:q&:page")]
Search { q: String, page: u32 },
#[end_layout]
#[redirect("/u/:id", |id: u32| Route::User { id })]
#[route("/files/:..path")]
Files { path: Vec<String> },
}
注意 Me 写在 User 后面。对每个字符串调 parse::<Route>(),成功就再打印一次 Display:
/ -> Ok(Home) display=/
/user/42 -> Ok(User { id: 42 }) display=/user/42
/user/me -> Ok(Me) display=/user/me
/user/42/ -> Ok(User { id: 42 }) display=/user/42
/blog -> Ok(BlogIndex) display=/blog/
/blog/ -> Ok(BlogIndex) display=/blog/
/blog/hello%20world -> Ok(Post { slug: "hello world" }) display=/blog/hello%20world
/search?q=rust&page=2 -> Ok(Search { q: "rust", page: 2 }) display=/search?q=rust&page=2
/search?q=a%26b&page=2 -> Ok(Search { q: "a", page: 2 }) display=/search?q=a&page=2
ERROR Failed to parse query argument: invalid digit found in string
/search?page=abc -> Ok(Search { q: "", page: 0 }) display=/search?q=&page=0
/u/7 -> Ok(User { id: 7 }) display=/user/7
/files/a/b%2Fc/d -> Ok(Files { path: ["a", "b/c", "d"] }) display=/files/a/b/c/d
/files -> Ok(Files { path: [] }) display=/files
user/42 -> Err
Route did not match:
Attempted Matches:
逐行对一下:
/user/me是Me,虽然User先声明:两者在user节点下,me是静态节点(0 档),排在User这个路由(1 档)前面。/user/42/和/user/42一样;/blog和/blog/都是BlogIndex,但BlogIndex的Display是/blog/:nest 写/blog,路由自己的"/"是一个空的静态段,写出来就是一个/。Router启动时发现两者不同会改写地址,见 Router 运行时。a%26b解码成a&b后被切开,q只剩a;page=abc解析失败,打一条 ERROR,page变成 0,q缺失变成空串,路由仍然是Search。/u/7走的是重定向闭包,得到的是User,Display给出/user/7:重定向只存在于解析这一侧。b%2Fc在切段之后才解码,所以它是一段b/c;写回去时/不转义,变成了两段。- 不以
/开头的字符串直接失败,attempted_routes为空。
ParseRouteError:每条候选为什么没匹配上
失败时的错误类型是 RouteParseError<RouteMatchError>,Display 先写两行标题,再给 attempted_routes 里每一条编号:
impl<E: Display> Display for RouteParseError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Route did not match:\nAttempted Matches:\n")?;
for (i, route) in self.attempted_routes.iter().enumerate() {
writeln!(f, "{}) {route}", i + 1)?;
}
Ok(())
}
}
每一条的文字来自宏生成的 Display:路由是 Route '名字' ('路径') did not match:,nest 和重定向写的是错误类型名(Nest 'Nest0ParseError'、Redirect 'Redirect6ParseError'),下一行是具体原因,四种原文是 Static segment '..' did not match instead found '..'、Dynamic segment '(id:u32)' did not match: <FromStr 的错误>、Dynamic segment '(..)' was missing、Found additional trailing segments: ..。同一个枚举解析两个失败的 URL:
/user/abc -> Err
Route did not match:
Attempted Matches:
1) Route 'Home' ('/') did not match:
Found additional trailing segments: user/abc
2) Route 'Me' ('/user/me') did not match:
Static segment 'me' did not match instead found 'abc'
3) Route 'User' ('/user/:id') did not match:
Dynamic segment '(id:u32)' did not match: invalid digit found in string
4) Nest 'Nest0ParseError' ('/blog') did not match:
Static segment 'blog' did not match instead found 'user'
5) Route 'Search' ('/search?:q&:page') did not match:
Static segment 'search' did not match instead found 'user'
6) Redirect 'Redirect6ParseError' ('/u/:id') did not match:
Static segment 'u' did not match instead found 'user'
7) Route 'Files' ('/files/:..path') did not match:
Static segment 'files' did not match instead found 'user'
/nope/x -> Err
Route did not match:
Attempted Matches:
1) Route 'Home' ('/') did not match:
Found additional trailing segments: nope/x
2) Route 'User' ('/user/:id') did not match:
Static segment 'user' did not match instead found 'nope'
3) Nest 'Nest0ParseError' ('/blog') did not match:
Static segment 'blog' did not match instead found 'nope'
4) Route 'Search' ('/search?:q&:page') did not match:
Static segment 'search' did not match instead found 'nope'
5) Redirect 'Redirect6ParseError' ('/u/:id') did not match:
Static segment 'u' did not match instead found 'nope'
6) Route 'Files' ('/files/:..path') did not match:
Static segment 'files' did not match instead found 'nope'
列表的顺序就是树的遍历顺序,不是枚举顺序(Me 在 User 前面)。合并后的静态节点只报一次错,用的是第一个建出这个节点的路由的名字:/nope/x 里 user 节点挂在 User 名下,Me 不再出现。Home 的 "/" 是一个空静态段,展开时不消耗段,所以它对任何多于零段的 URL 都报 Found additional trailing segments。
这个错误本身只是一个值。Router 拿它做什么(包一层 ParseRouteError、抛给错误边界、SSR 返回 404 并把这段文字当响应体)在 Router 运行时。
尾斜杠
/user/42/ 能匹配 /user/:id,靠的是开头那一句 strip_suffix('/')。反过来,路由写成 "/path/" 时,宏把末尾的空静态段当作「尾随段」,建树和生成匹配代码时都跳过它(is_trailing_static_segment),于是 /path 和 /path/ 都能匹配。实测一个只有 #[route("/path/")] 的枚举:两个字符串都解析成功,Display 写出来是 /path/。只去掉一个斜杠:/user/42// 去掉一个后还剩一个空段,User 那一条报的是 Found additional trailing segments: (冒号后面是空串),整体解析失败。
/path/ 和 /path 一视同仁是 0.6 才有的#2896「Parse trailing route slash」之前,FromStr 不去尾斜杠。PR 的说明是:「The router currently accepts /path/ if you try define the route route("/path"), but it doesn't accept /path if you define the route route("/path/"). This PR accepts both variants and adds more tests for parsing」。它在 from_str 开头加了上面那句 strip_suffix('/') 和两行注释,并在 route_tree.rs 里加了跳过尾随空段的逻辑。首次进入的版本是 0.6.0(最早的 tag 是 v0.6.0-alpha.3)。
它只管解析,不管 Display:BlogIndex 仍然写成 /blog/,两个字符串不相等这件事留给了运行时。
百分号编码:往返不一定回得来
上面的转义集决定了 Display 之后再 parse 能不能得到原值。对 Post { slug } 和 Search { q, page: 1 } 各试几个值,to_string() 再 parse(),比较是否相等:
fn check(r: Route) {
let s = r.to_string();
let back = s.parse::<Route>();
let same = back.as_ref().map(|b| *b == r).unwrap_or(false);
println!("{:<44} {:<34} {}", format!("{r:?}"), s, if same { "ok".to_string() } else { format!("BACK = {:?}", back.ok()) });
}
Post { slug: "hello world" } /blog/hello%20world ok
Post { slug: "中文" } /blog/%E4%B8%AD%E6%96%87 ok
Post { slug: "a?b" } /blog/a%3Fb ok
Post { slug: "a#b" } /blog/a%23b ok
Post { slug: "100%" } /blog/100% ok
Post { slug: "a%20b" } /blog/a%20b BACK = Some(Post { slug: "a b" })
Post { slug: "a/b" } /blog/a/b BACK = None
Post { slug: "a+b" } /blog/a+b ok
Search { q: "a b", page: 1 } /search?q=a%20b&page=1 ok
Search { q: "a+b", page: 1 } /search?q=a+b&page=1 ok
Search { q: "a&b", page: 1 } /search?q=a&b&page=1 BACK = Some(Search { q: "a", page: 1 })
Search { q: "a=b", page: 1 } /search?q=a=b&page=1 ok
Search { q: "x#y", page: 1 } /search?q=x%23y&page=1 ok
Search { q: "50%", page: 1 } /search?q=50%&page=1 ok
Files { path: ["a", "b/c"] } /files/a/b/c BACK = Some(Files { path: ["a", "b", "c"] })
Files { path: [] } /files ok
| 值里有 | 写出去 | 读回来 | 原因 |
|---|---|---|---|
空格、?、#、非 ASCII | 转义 | 原值 | 在转义集里 |
% 后跟两位十六进制(a%20b) | 原样 | 解码成别的字符 | % 不在转义集里,解析时又整体解码 |
孤立的 %(100%) | 原样 | 原值 | 不是合法转义序列,percent_decode 原样保留 |
路径段里的 / | 原样 | 多出一段或匹配失败 | / 不在路径集里 |
查询值里的 & | 原样 | 截断 | & 不在查询集里 |
+ | 原样 | 原值 | 解码时不把 + 当空格 |
查询值里的 = | 原样 | 原值 | split_once('=') 只切第一个 |
所以 slug、搜索词这类来自用户的字符串要么限制字符,要么自己先编码一层(字段类型用一个 newtype,在它的 Display / FromStr 里处理)。这张表只对派生出来的实现成立;FromRouteSegment、FromQuery 这些 trait 都可以自己实现。
Post { slug: "a/b" } 写成 /blog/a/b,parse 为什么是 None,而不是匹配到别的路由?用上面的 Route 枚举,对 "/blog/a/b" 调 parse::<Route>(),打印错误。错误列表里有几条?Post 那一条说了什么?
谁先匹配:重叠的路由
排序规则只有一句:同一层里,静态段节点先于 nest 和普通路由,以 catch-all 结尾的路由最后,同档按声明顺序。用一个故意写反的枚举验证:
#[derive(Routable, Clone, Debug, PartialEq)]
enum Amb {
#[route("/:..rest")]
Rest { rest: Vec<String> },
#[route("/:s")]
Str { s: String },
#[route("/:n")]
Num { n: u32 },
#[route("/about")]
About {},
}
/about -> Some(About)
/42 -> Some(Str { s: "42" })
/abc -> Some(Str { s: "abc" })
/a/b -> Some(Rest { rest: ["a", "b"] })
/ -> Some(Rest { rest: [] })
About最后声明,但about是静态节点,最先试。/42落到Str:Str和Num同档,Str先声明,String的FromStr永远成功,Num永远轮不到。宏不会对这种「到不了的路由」报警。- catch-all 写在最前也是最后试;它也接受零段,所以连
/都归它。枚举里没有"/"路由时这通常就是想要的「404 页面」;有"/"时"/"的空静态段是 0 档,先于 catch-all。
把 Num 挪到 Str 前面(同一个程序用 cfg 换一下顺序)再跑,/42 变成 Some(Num { n: 42 }),/abc 仍是 Some(Str { s: "abc" }),其余三行不变:同档时声明顺序就是优先级。
站点地图
SITE_MAP 是编译期常量,记录每条路由的段类型(Static、Dynamic、CatchAll、Child),不含查询参数和重定向。Routable 的默认方法 static_routes 用它列出所有只含静态段的路由,再逐个 parse 成枚举值,静态生成(SSG)靠它知道要预渲染哪些页面。is_child_of / parent 两个默认方法都是把 Display 的结果当字符串比较、截断后再 parse,不看枚举结构。
#[child("/prefix")] 是第五种属性:变体里放一个字段,类型是另一个 Routable 枚举,前缀之后的部分交给子枚举解析,渲染时包一个 ChildRouter。它很少用,本页不展开。
本站的路由枚举在 crates/app/src/routes/mod.rs:一个 #[layout(Navbar)] 包住全部 37 条路由,没有 nest 和重定向,唯一的 catch-all 是文档页的 /docs/:..path,根上没有 catch-all,所以不认识的路径在 SSR 时就是 404。它的取舍放在 本站的页面。
动脑筋答案
把 "/blog/a/b" 交给同一个 Route 枚举,打印 Err 的 Display:
Route did not match:
Attempted Matches:
1) Route 'Home' ('/') did not match:
Found additional trailing segments: blog/a/b
2) Route 'User' ('/user/:id') did not match:
Static segment 'user' did not match instead found 'blog'
3) Route 'BlogIndex' ('/') did not match:
Found additional trailing segments: a/b
4) Route 'Post' ('/:slug') did not match:
Found additional trailing segments: b
5) Route 'Search' ('/search?:q&:page') did not match:
Static segment 'search' did not match instead found 'blog'
6) Redirect 'Redirect6ParseError' ('/u/:id') did not match:
Static segment 'u' did not match instead found 'blog'
7) Route 'Files' ('/files/:..path') did not match:
Static segment 'files' did not match instead found 'blog'
七条。blog 这个 nest 节点匹配上了,里面的两个路由都试了:BlogIndex 剩 a/b 两段,Post 把 a 当作 slug 后还剩一段 b,都报 Found additional trailing segments。/blog/ 之下没有 catch-all,没有别的路由能收下三段,于是整体失败。slug 原来的值 a/b 在写出去的那一刻就已经变成了两段。
小结
#[derive(Routable)]生成四段代码:错误类型、Display、Routable(SITE_MAP和render)、FromStr;运行时没有路由表。render(level)是(层号, 变体)的match:每层 layout 一个层号,叶子组件在最后一层,层号对不上返回空节点。FromStr去掉一个尾斜杠、逐段解码,按「静态段 → nest / 普通路由 → catch-all」的顺序、同档按声明顺序尝试,失败时把每条候选的原因收进RouteParseError。- 查询参数不参与匹配,解析失败变默认值;查询串先整体解码再切分。
- 转义集不含
/、%、&,带这些字符的动态段、catch-all 项和查询值Display之后再解析会变样。 - 重定向只存在于解析,nest 里的
"/"路由写回去带尾斜杠。