Dioxus SSR 文章排序 Bug 排查与修复记录

技术随笔

Dioxus SSR 文章排序 Bug 排查与修复记录

1. 问题现象

在博客首页和系列文章列表中,本应按照 order 字段升序排列的系列教程文章,显示顺序却是乱的。例如 Dioxus 系列 30 篇文章的 slug 顺序为:

dioxus-22, dioxus-13, dioxus-03, dioxus-12, ...  // ❌ 乱序

而期望的顺序是:

dioxus-01, dioxus-02, dioxus-03, ..., dioxus-30  // ✅ 按 order 升序

2. 排查过程

2.1 确认数据层排序正确

首先检查文章加载的核心逻辑 content.rs,排序代码看起来是合理的:

self.by_slug.sort_by(|a, b| match (a.series.as_ref(), b.series.as_ref()) {
    (Some(a_series), Some(b_series)) if a_series == b_series => {
        match (a.order, b.order) {
            (Some(a_order), Some(b_order)) => a_order.cmp(&b_order),
            // ...
        }
    }
    _ => b.date.cmp(&a.date),
});

为了验证,我们在 rebuild() 方法中添加了调试输出,打印排序后的文章列表。

2.2 发现问题 1:调试代码导致崩溃

启动服务器后,页面反而打不开了!查看日志发现:

thread panicked at src/content.rs:165:31:
end byte index 28 is not a char boundary; it is inside '南' (bytes 27..30)

原因:调试代码中使用 &article.title[..28] 来截取标题前 28 个字符显示,但 Rust 的字符串切片是按 字节 操作的。中文标题"项目部署与..."的第 28 个字节正好在一个 3 字节中文字符的中间,导致运行时 panic。

这个 panic 发生在 rebuild()println! 中——它获取了 RwLock 的写锁,然后在持有锁的情况下 panic 了。Rust 的 std::sync::RwLock 在 panic 后会进入毒化(poisoned) 状态,所有后续尝试获取该锁的操作都会返回 PoisonError

2.3 发现问题 2:RwLock 毒化导致连锁崩溃

由于 REGISTRY 这个 OnceLock<RwLock<ArticleRegistry>> 被毒化,所有后续页面请求在调用 load_all_articles() 时都会 panic:

let mut guard = reg.write().unwrap();  // panic! PoisonError

这造成了连锁反应——首页、文章列表页、系列页全部报错,看起来像是"排序不生效",实际是根本没有正确返回文章数据

2.4 发现问题 3:前端渲染层未按 order 排序

修复 panic 后重新观察排序输出,确认 by_slug 中排序正确。但页面上显示仍然不对——问题的根因不在数据层,而在前端渲染层

查看 blog_list.rs,代码按年份分组后没有对组内文章重新排序:

// 年份分组
let mut year_map: std::collections::BTreeMap<String, Vec<&ArticleSummary>> = ...;
for a in &articles {
    let year = a.date.get(..4).unwrap_or("0000").to_string();
    year_map.entry(year).or_default().push(a);  // ❌ 未排序
}

虽然 content.rs 中的全局排序保证了某些顺序,但同一系列的文章可能分散在多年份中,且同一年的文章顺序在放入 Vec 时只取决于 articles 的原始遍历顺序。

home.rs 首页在提取系列文章时,也只做了筛选 .filter(),没有对筛选结果排序。

3. 解决方案

3.1 修复 UTF-8 字符安全截断

// ❌ 字节截断,遇到中文会 panic
&article.title[..std::cmp::min(article.title.len(), 28)]

// ✅ 字符截断,安全处理中文
let preview: String = article.title.chars().take(28).collect();

3.2 修复 RwLock 毒化

// ❌ unwrap 会在锁毒化时 panic
let mut guard = reg.write().unwrap();

// ✅ 从毒化状态恢复
let mut guard = reg.write().unwrap_or_else(|e| e.into_inner());

into_inner() 会获取被毒化锁的内部数据,使得后续操作可以继续进行。这对于非关键数据的缓存场景是合理的——一次渲染 panic 不应该导致整个应用不可用。

3.3 前端渲染层增加排序

blog_list.rs 的年份分组中,对每个年份组内的文章增加排序逻辑:

for items in year_map.values_mut() {
    items.sort_by(|a, b| match (a.series.as_ref(), b.series.as_ref()) {
        (Some(a_series), Some(b_series)) if a_series == b_series => {
            match (a.order, b.order) {
                (Some(a_order), Some(b_order)) => a_order.cmp(&b_order),
                (Some(_), None) => std::cmp::Ordering::Less,
                (None, Some(_)) => std::cmp::Ordering::Greater,
                (None, None) => b.date.cmp(&a.date),
            }
        }
        _ => b.date.cmp(&a.date),
    });
}

home.rs 的系列文章筛选中同样增加按 order 排序:

items.sort_by(|a, b| match (a.order, b.order) {
    (Some(a_order), Some(b_order)) => a_order.cmp(&b_order),
    (Some(_), None) => std::cmp::Ordering::Less,
    (None, Some(_)) => std::cmp::Ordering::Greater,
    (None, None) => b.date.cmp(&a.date),
});

4. 经验教训

4.1 不要在持有锁的上下文中做可能 panic 的操作

println! 中的字符串切片看似无害,但遇到非 ASCII 字符就会出问题。持有锁的代码路径必须格外小心,任何 panic 都会污染共享状态。

4.2 数据层排序 ≠ 渲染层排序

content.rs 中的全局排序不能保证前端渲染时保持。特别是当数据经过分组、筛选等变换操作后,原始顺序可能丢失。前端渲染层也需要独立的排序逻辑

4.3 Rust 字符串切片与 UTF-8

Rust 的 str 是 UTF-8 编码的,string[index] 形式的索引是不允许的(编译时拒绝),但 &string[..n] 的切片操作是允许的——它会在运行时检查边界是否在字符边界上。对于包含中文的文本,应当使用 .chars() 迭代器来安全处理。

RustDioxusDebugSSRRwLockUTF-8