yggdrasil/docs/superpowers/plans/2026-06-25-dioxus-antipatterns-cleanup.md
xfy 9e9a3faeed docs(plan): 新增 Dioxus 反模式清零实施计划
覆盖 antipatterns 文档点名的三类反模式的剩余项:
- Task 1: trash.rs 的 dirty 派生值改 use_memo(小)
- Task 2: 为已修复的 post_detail slug 反模式补回归测试(小)
- Task 3: 从 write_editor 抽取 CoverUploader 子组件(大)

god component 全量拆分(TrashPage 等)明确排除出本计划,列为后续独立计划。
Task 3 经核对真实源码(行号、API 调用、import 路径)写成「移动现有代码」式步骤。
2026-06-25 18:33:56 +08:00

393 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Dioxus 反模式清零 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 清零项目中残留的 Dioxus 0.7 antipatterns 官方指南点名的三类反模式——render body 副作用、用 `use_signal`/内联计算存派生值、god component。使代码库与已新建的 `.agents/skills/dioxus-render-purity/SKILL.md` 完全一致。
**Architecture:** 三个独立任务,互不依赖,可单独提交、单独回滚:
- **Task 1**(小):`trash.rs``dirty` 派生布尔值改为 `use_memo`消除「render 期内联重算派生值」。
- **Task 2**(中):`post_detail.rs` 的 render 期 `set signal` 反模式——**已在 commit `225bb24` 修复**,本任务为补一个回归测试锁定行为。
- **Task 3**(大):`write_editor``src/pages/admin/write.rs`746 行 god component抽取「封面上传」子组件 `CoverUploader`,把 5 个 cover 相关 signal + 上传闭包 + 封面 rsx 整体迁出,让 `write_editor` 减重约 150 行。
god component 的**全量拆分**`TrashPage` 等)超出本计划范围——它风险高、收益是可维护性而非正确性,建议作为独立后续计划;本计划只做其中收益最明确、边界最清晰的一块(封面上传),验证「抽取子组件」的模式可行后再决定是否扩展。
**Tech Stack:** Rust, Dioxus 0.7`use_memo` / `use_signal` / `#[component]` / `rsx!`wasm-bindgen`web_sys::File`cargo + dx CLI。
---
## 前置知识:本项目 Dioxus 约定(执行者必读)
1. **双 target 编译**`dx check` 同时校验 server`aarch64-apple-darwin`)与 wasm client`wasm32-unknown-unknown`)。任何改动都要过 `dx check`,不能只过 `cargo build`
2. **wasm 专属代码**用 `#[cfg(target_arch = "wasm32")]` 包裹(如 `web_sys::File``EditorHandle``Closure`)。`#[cfg(not(target_arch = "wasm32"))]` 分支必须给出降级返回,否则 server 端编译报「未初始化」。
3. **`write_editor` 不是 `#[component]`**:它是普通函数,由 `Write` / `WriteEdit` 两个 `#[component]` 薄包装委托。抽取子组件后,新组件必须是 `#[component]` 才能在 `rsx!` 里用 `<CoverUploader ... />`
4. **`#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]`**:这是项目惯例,避免 server 构建因 wasm-only 的 `mut` signal 报 unused 警告。新组件若含 wasm-only signal 要沿用此属性。
5. **测试**`cargo test`405 个 server 端单测)。前端组件无单测框架,靠 `dx check` 类型校验 + 手动 `make dev` 验证。
6. **提交风格**`type(scope): 中文描述`,如 `refactor(write): 抽取封面上传子组件`
---
## File Structure
| File | Change | Responsibility |
|------|--------|----------------|
| `src/pages/admin/trash.rs` | Modify~112 行) | `dirty``use_memo` |
| `src/models/settings.rs` | Read only | 确认 `TrashSettings` 字段名(`retention_days`/`auto_purge_enabled` |
| `src/pages/post_detail.rs` | Read only | 已修复Task 2 仅加测试 |
| `tests/post_detail_slug_rerun.rs` | Create | 锁定 slug 变化重跑的回归测试 |
| `src/pages/admin/write.rs` | Modify大改 | 抽取 `CoverUploader` 子组件,`write_editor` 减重 |
| `.agents/skills/dioxus-render-purity/SKILL.md` | Read only | 参照规则(已存在) |
---
## Task 1: `trash.rs` 的 `dirty` 改用 `use_memo`
**问题**`src/pages/admin/trash.rs:112-118` 在 render body 内联计算 `dirty` 布尔值,每次渲染都重新 `trim().parse::<i32>()`。语义上它是 `settings_draft_*` / `settings` 的派生值,文档要求派生值用 `use_memo`(依赖不变则不重算)。
**Files:**
- Modify: `src/pages/admin/trash.rs:111-118`
**当前代码(`src/pages/admin/trash.rs:111-118`**
```rust
// 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存”提示。
let dirty = settings_draft_enabled() != settings().auto_purge_enabled
|| settings_draft_days()
.trim()
.parse::<i32>()
.ok()
.map(|d| d != settings().retention_days)
.unwrap_or(true);
```
- [ ] **Step 1: 把 `dirty` 改为 `use_memo`**
用以下内容替换 `src/pages/admin/trash.rs:111-118`(保持注释语义):
```rust
// 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存」提示。
// 派生值用 use_memo依赖信号不变时不重算避免每次渲染重复 parse 字符串)。
let dirty = use_memo(move || {
settings_draft_enabled() != settings().auto_purge_enabled
|| settings_draft_days()
.trim()
.parse::<i32>()
.ok()
.map(|d| d != settings().retention_days)
.unwrap_or(true)
});
```
**要点**
- `use_memo` 已在 prelude`use dioxus::prelude::*``trash.rs` 顶部已导入,无需额外 use
- `dirty` 的读取方式不变:原本 `dirty``bool`,现在 `dirty``Memo<bool>`,但在 `rsx!``{dirty()}` / `disabled: !dirty()` 这种用法——**注意**`Memo<T>` 实现了 `Deref<Target = T>`,但在条件判断里仍需 `*dirty()``dirty()()`。实测:`if dirty() {` 在 Dioxus 里 `dirty()` 返回 `bool``Memo::read` 返回 `&T`,但 `if` 会自动解引用),保持原写法 `dirty()` 即可。若 `dx check` 报类型错,改为 `*dirty.read()`
- 闭包用 `move ||` 捕获三个 signal 的读取。
- [ ] **Step 2: 用 `dx check` 验证类型**
运行:`dx check`
预期:`No issues found.`
若报 `expected bool, found Memo<bool>` 类错误,定位 `dirty` 的所有使用点(`grep -n "dirty" src/pages/admin/trash.rs`),把 `dirty()` 改为 `dirty()`(多数情况下无需改,`Signal`/`Memo``if`/`disabled:` 上下文自动解引用)。仍报错则改 `*dirty()`
- [ ] **Step 3: 跑测试**
运行:`cargo test`
预期:`test result: ok. 405 passed; 0 failed`
- [ ] **Step 4: 提交**
```bash
git add src/pages/admin/trash.rs
git commit -m "refactor(trash): dirty 派生值改用 use_memo
依据 dioxus-render-purity skill 规则二:派生值不应在 render 期内联重算。
dirty 是 settings_draft_* 与 settings 的纯派生布尔值,改用 use_memo
依赖信号不变时跳过 trim/parse 重算。"
```
---
## Task 2: 为已修复的 `post_detail` slug 反模式补回归测试
**背景**`src/pages/post_detail.rs` 原本用 `use_signal` 镜像 `slug` prop 并在 render 期 `set`(反模式),已在 commit `225bb24` 改为直接读 prop。本任务补一个回归测试锁定「`PostDetail` 组件不依赖镜像 signal」的契约防止后续回退。
**注意**Dioxus 组件在 `cargo test`(非 wasm、纯逻辑下无法直接渲染。可行的回归方式是写一个**静态契约测试**:读取 `post_detail.rs` 源码字符串,断言它不再包含反模式签名(`slug_signal` / `use_signal(|| slug`)。这是项目已有模式(见 `src/theme.rs``theme_preload_script_*` 系列字符串断言测试)。
**Files:**
- Create: `tests/post_detail_slug_rerun.rs`
- [ ] **Step 1: 写失败的契约测试**
创建 `tests/post_detail_slug_rerun.rs`
```rust
//! 回归测试:锁定 PostDetail 组件不再用镜像 signal 触发 server future。
//!
//! 背景src/pages/post_detail.rs 曾用 `use_signal(|| slug.clone())` 镜像 slug prop
//! 并在 render 期 `if slug_signal() != slug { slug_signal.set(...) }` 触发重取——
//! 这是 Dioxus antipatterns 明确点名的「render body 副作用」。
//! 已在 commit 225bb24 改为直接读 prop。本测试通过源码字符串断言防止回退。
/// PostDetail 组件源码不得包含镜像 slug 的 signal 反模式签名。
#[test]
fn post_detail_does_not_mirror_slug_into_signal() {
let src = include_str!("../src/pages/post_detail.rs");
// 反模式签名:用 use_signal 镜像 slug prop。
assert!(
!src.contains("slug_signal"),
"post_detail.rs 重新引入了 slug_signal 镜像——这是 render 期 set signal 反模式。\
应直接在 use_server_future 闭包里读 slug prop让 Dioxus 在 prop 变化时自动重跑。\
详见 .agents/skills/dioxus-render-purity/SKILL.md 规则一。"
);
// 反模式签名render 期对 slug 相关 signal 调用 set。
assert!(
!src.contains("slug_signal.set"),
"post_detail.rs 在 render 期 set signal——违反渲染纯净性。"
);
}
```
- [ ] **Step 2: 运行测试,验证它通过(源码已修复,断言应成立)**
运行:`cargo test --test post_detail_slug_rerun`
预期:`test result: ok. 1 passed; 0 failed`
**反向验证**(可选,确认测试有效):临时把 `src/pages/post_detail.rs` 改回含 `slug_signal` 的写法,重跑应 FAIL验证后 `git checkout src/pages/post_detail.rs` 还原。
- [ ] **Step 3: 提交**
```bash
git add tests/post_detail_slug_rerun.rs
git commit -m "test(post_detail): 锁定 slug 重取不再依赖镜像 signal
防止 post_detail.rs 回退到 render 期 set signal 反模式commit 225bb24 已修)。
用源码字符串契约断言,与 theme.rs 的 preload-script 测试同模式。"
```
---
## Task 3: 从 `write_editor` 抽取 `CoverUploader` 子组件
**问题**`src/pages/admin/write.rs``write_editor`63809 行,约 746 行)是 god component。封面上传是一个**高内聚、低耦合**的子领域,分布在三处:
- **信号声明**`src/pages/admin/write.rs:75-80``cover_uploading`/`cover_error`/`cover_url_mode`/`cover_drag_active`/`cover_url_input` 五个,注意 `cover_image` 在 71 行,**必须保留**)。
- **上传闭包**`src/pages/admin/write.rs:230-249``spawn_cover_upload``#[cfg(target_arch = "wasm32")]` 包裹)。
- **封面 rsx**`src/pages/admin/write.rs:474-708`(封面 `div` + URL 输入模式 + 错误提示,一个连续块)。
把它整体抽成 `CoverUploader` 子组件,`write_editor` 只保留 `cover_image`(保存时需要读)。
**抽取边界**
- `CoverUploader` 拥有 5 个私有 cover signal + `spawn_cover_upload` 闭包 + 封面 rsx。
- 对外**只**暴露一个 `cover_image: Signal<String>` prop双向父组件声明该 signal 并传引用,子组件内 `set` 写最终 URL父组件读它用于保存
- `write_editor` 删除上述信号/闭包/rsx净减约 150 行。
**Files:**
- Modify: `src/pages/admin/write.rs`(删除 cover signal/闭包/rsx改为 `<CoverUploader cover_image=cover_image />`,新增 `CoverUploader` 组件)
### Step 1: 通读 cover 相关代码,确认边界与引用点
- [ ] **Step 1: 确认 `cover_image` 的全部引用点(必须保留在 `write_editor`**
运行:`grep -n "cover_image" src/pages/admin/write.rs`
预期引用点(**这些保留在 `write_editor`**,是 `cover_image` 不能迁走的理由):
- `:71` 声明 `let mut cover_image = use_signal(|| "".to_string());`
- `:114` backfill effect`cover_image.set(post.cover_image.clone().unwrap_or_default());`
- `:325` / `:328` 保存逻辑:`let cover_image_opt = if cover_image().trim().is_empty() {...}`
- `rsx` 内多处读 `cover_image()`
确认:`cover_image` 留在 `write_editor`,作为 prop 传给 `CoverUploader`
- [ ] **Step 2: 确认其余 5 个 cover signal 的引用点(全部迁入 `CoverUploader`**
运行:`grep -n "cover_uploading\|cover_error\|cover_url_mode\|cover_url_input\|cover_drag_active" src/pages/admin/write.rs`
预期:所有引用都集中在 `spawn_cover_upload` 闭包230249与封面 rsx474708内。**注意**:经核实 `on_submit` 保存逻辑里**没有**读 `cover_uploading()` 的拦截,故无需额外处理;若执行时发现存在该拦截(仓库后续可能改动),一并随闭包删除即可。
### Step 2: 在 `write.rs` 文件末尾新增 `CoverUploader` 组件骨架
- [ ] **Step 3: 在文件末尾(`write_editor` 函数 `}` 之后)追加 `CoverUploader` 骨架**
`src/pages/admin/write.rs` **末尾**追加以下骨架(仅信号声明 + 上传闭包 + 空 `rsx!`,封面 UI 在 Step 4 用「剪切粘贴」填入):
```rust
/// 封面上传子组件。
///
/// 封装封面图的全部状态与交互:拖拽/粘贴/选择文件上传、URL 输入、预览、移除。
/// 通过 `cover_image` signal 与父组件双向绑定——子组件写入最终 URL
/// 父组件读取它用于保存。其余上传中间态uploading/error/drag/url对本组件私有。
///
/// 从 `write_editor` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill
#[component]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
fn CoverUploader(cover_image: Signal<String>) -> Element {
let mut cover_uploading = use_signal(|| false);
let mut cover_error = use_signal(|| None::<String>);
let mut cover_url_mode = use_signal(|| false);
let mut cover_drag_active = use_signal(|| false);
let mut cover_url_input = use_signal(|| "".to_string());
// 封面图上传spawn 一个 async 调用 upload_image_file。
// 三条入口file input / drop / paste收敛成拿到 web_sys::File 后统一调用此闭包。
#[cfg(target_arch = "wasm32")]
let mut spawn_cover_upload = move |file: web_sys::File| {
cover_uploading.set(true);
cover_error.set(None);
spawn(async move {
match upload_image_file(file).await {
Ok(url) => cover_image.set(url),
Err(msg) => cover_error.set(Some(msg)),
}
cover_uploading.set(false);
});
};
rsx! {
// Step 4把 write_editor 的封面 rsx 块 474708 剪切粘贴到这里,
// 去掉一层缩进使其成为 CoverUploader 的直接子元素)
}
}
```
**要点**
- `upload_image_file` 已在 `write_editor` 所在文件顶部 import`use ...upload_image_file``CoverUploader` 同文件可直接用,无需重复 import。执行前用 `grep -n "use.*upload_image_file\|upload_image_file" src/pages/admin/write.rs | head -1` 确认 import 路径。
- `#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]``write_editor` 同款,避免 server 构建因 wasm-only signal 报 unused。
- **不要**手写封面 rsx——Step 4 从 `write_editor` 剪切现有代码,确保 `evt.files().into_iter().next()` / `get_web_file()` / `try_as_web_event()` / `dyn_ref::<ClipboardEvent>()` 等真实 API 调用一字不差。
### Step 3: 把封面 rsx 从 `write_editor` 剪切到 `CoverUploader`
- [ ] **Step 4: 剪切封面 rsx 块474708 行)**
`src/pages/admin/write.rs` 中,**剪切**(不是复制)从这一行:
```rust
// 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。
```
(约 474 行)一直到这一行(含):
```rust
}
```
(约 708 行——即「封面上传失败提示」`div` 的闭合 `}`)。这一整段是封面 `div` + URL 输入模式 `if` + 错误提示 `if` 三个连续块。
**粘贴位置**`CoverUploader``rsx! { ... }` 内部,替换 Step 3 骨架里的占位注释。粘贴后**去掉一层缩进**(原在 `write_editor` 内是 5 级缩进 20 空格,在 `CoverUploader` 内应是 4 级 16 空格——以 `rsx! {` 为基准对齐)。
**校对**:粘贴后,`CoverUploader` 内部的所有 `cover_image` / `cover_uploading` / `cover_error` / `cover_url_mode` / `cover_url_input` / `cover_drag_active` / `spawn_cover_upload` 引用都解析到本组件的本地 signal/闭包。
### Step 4: 从 `write_editor` 删除已迁出的信号与闭包
- [ ] **Step 5: 删除 5 个 cover signal 声明7580 行,保留 71 行 `cover_image`**
删除 `src/pages/admin/write.rs` 中这 6 行(注释 + 5 个 signal**不含 `cover_image`**
```rust
// 封面图上传状态uploading 进度态、错误消息、URL 输入框展开、拖拽高亮。
let mut cover_uploading = use_signal(|| false);
let mut cover_error = use_signal(|| None::<String>);
let mut cover_url_mode = use_signal(|| false);
let mut cover_drag_active = use_signal(|| false);
// 封面 URL 输入框的临时值(确认前不直接写入 cover_image避免半截 URL 触发预览加载)。
let mut cover_url_input = use_signal(|| "".to_string());
```
保留后该区域应只剩:
```rust
let mut cover_image = use_signal(|| "".to_string());
let mut status = use_signal(|| "draft".to_string());
```
- [ ] **Step 6: 删除 `spawn_cover_upload` 闭包230249 行)**
删除 `src/pages/admin/write.rs``spawn_cover_upload` 的整段定义,含上方注释:
```rust
// 封面图上传spawn 一个 async 调用 upload_image_file。
// 三条入口file input / drop / paste收敛成拿到 web_sys::File 后统一调用此闭包。
// 仅在 WASM 端有意义upload_image_file 与 spawn 都依赖 WASM 运行时),
// server SSR 不渲染上传逻辑,故整体 cfg-gate 避免引用 wasm-only 符号。
#[cfg(target_arch = "wasm32")]
let mut spawn_cover_upload = move |file: web_sys::File| {
...
};
```
### Step 5: 在 `write_editor` 原封面位置插入 `CoverUploader` 调用
- [ ] **Step 7: 在 `write_editor` 的 `rsx!` 中,原封面块位置插入子组件调用**
Step 4 剪切后,`write_editor``rsx!` 里原封面块的位置现在是空的。在该位置(原 474 行处)插入:
```rust
// 封面图上传区(抽取为子组件 CoverUploader
CoverUploader { cover_image }
```
**缩进**:与原封面 `div` 同级5 级缩进20 空格)。`cover_image` 不加括号——传 signal 本身prop 类型是 `Signal<String>`),不是 `cover_image()`(那是 `String`)。
### Step 6: 编译与验证
- [ ] **Step 8: `dx check` 验证类型**
运行:`dx check`
预期:`No issues found.`
**常见报错与修复**
- `cannot find value cover_uploading in this scope`(在 `write_editor` 内):有遗漏——`grep -n "cover_uploading\|cover_error\|cover_url_mode\|cover_url_input\|cover_drag_active\|spawn_cover_upload" src/pages/admin/write.rs`,确认这些名字只出现在 `CoverUploader` 组件函数体内(从 `fn CoverUploader` 到它的闭合 `}`)。
- `cannot find function spawn_cover_upload`(在 `write_editor`):封面 rsx 没剪切干净,有残留调用。回到 Step 4 确认整块已迁出。
- `expected Signal<String>, found String` on `cover_image` prop调用处误写 `CoverUploader { cover_image: cover_image() }`——改为 `CoverUploader { cover_image }`
- `unused import: upload_image_file`:若 `CoverUploader` 是唯一用 `upload_image_file` 的地方且 import 在文件顶部import 仍有效(同文件),不会报;若报则确认 import 行还在。
- [ ] **Step 9: 跑测试**
运行:`cargo test`
预期:`test result: ok. 405 passed; 0 failed`cover 抽取是纯前端重组,不影响 server 单测)。
- [ ] **Step 10: 手动验证封面功能(`make dev`**
运行:`make dev`,登录后台,进入 `/admin/write`,逐项验证:
- [ ] 空态:显示「拖拽 · 点击 · 粘贴封面图」+「或使用图片 URL」
- [ ] 点空态区:弹出文件选择,选图后显示预览
- [ ] 拖拽图片到上传区:边框高亮 → 上传成功显示预览
- [ ] 粘贴图片Ctrl/Cmd+V上传成功显示预览
- [ ] 点「或使用图片 URL」展开 URL 输入框,输入有效图片 URL → 确认 → 显示预览
- [ ] 预览态点右上角「×」:清空封面回到空态
- [ ] 上传中:显示「上传中...」骨架
- [ ] 进入编辑模式 `/admin/edit/<id>`(有封面的文章):封面正确回填显示
- [ ] 填写标题+正文后点保存:成功(`cover_image` 正确传给 server function
验证无误后 `Ctrl+C` 停止 dev server。
- [ ] **Step 11: 提交**
```bash
git add src/pages/admin/write.rs
git commit -m "refactor(write): 抽取 CoverUploader 子组件,降低 god component 复杂度
write_editor 从 746 行降至约 600 行。封面上传5 个私有 signal + 上传闭包 +
封面 rsx整体迁入 CoverUploader 子组件,仅通过 cover_image signal 双向绑定
与父组件通信。依据 dioxus-render-purity skill 的 god component 治理建议。"
```
---
## 验收清单(全部任务完成后过一遍)
- [ ] `dx check` 全绿
- [ ] `cargo test` 405+ passedTask 2 新增 1 个,共 406
- [ ] `grep -rn "slug_signal" src/` 无结果Task 2 测试已锁定)
- [ ] `src/pages/admin/trash.rs``dirty``use_memo`
- [ ] `src/pages/admin/write.rs``write_editor` 不再含 `cover_uploading`/`spawn_cover_upload`(已迁入 `CoverUploader`
- [ ] `make dev` 手动验证封面功能正常
## 不在本计划范围(明确排除)
- **`TrashPage`481 行god component 全量拆分**:风险高、收益是可维护性而非正确性。建议作为独立后续计划,且应在 Task 3 验证「抽取子组件」模式可行后再评估。
- **`AdminCommentsPage`315 行)/ `PostsPage`181 行)拆分**:体量中等,同上,后续单独评估。
- **`post_id` prop drilling评论树 3 跳)**:轻微,`CommentContext` 已存在,改造成本高于收益。
- **streaming / SSR / server functions 三轮文档对照**:已确认合规,无改动。