Rust in Everything
基于 dioxus 0.7.10

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),外加总的 RMatchErrorRouteEnum::error_type、create_error_type
display_implimpl Display for R,每个变体一个分支,按段写 URLimpl_display、Route::display_match
routable_implimpl Routable for R:SITE_MAP 常量和 renderroutable_impl、Route::routable_match
parse_implimpl 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: (冒号后面是空串),整体解析失败。

📝NOTE
冷知识:/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 都可以自己实现。

❗IMPORTANT
动脑筋: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 里的 "/" 路由写回去带尾斜杠。

💬 关联讨论

发起讨论
加载中...