一棵只有一根树干的树
Yggdrasil 的前端曾经完全由 Dioxus 0.7 写成:前台阅读页、笔记、评论、代码运行器,后台的写作、素材、日志、SQL 控制台,以及组件图鉴,全部编译进同一个 WASM。这在开发上很省心,一套路由枚举、一套组件、一条 dx build 就能出货;代价则由读者承担。一位只想读一篇文章的访客,要先下载完后台编辑器、MCP 令牌管理和数据库备份界面的代码,浏览器才能完成水合。
当时测到的整包体积是 Brotli 压缩后约 989 KiB,裁剪浏览器日志链后约 969 KiB。博客的读者绝大多数是匿名访客,他们永远不会打开 /admin,却为它付了一半以上的流量。
这篇文章记录 Yggdrasil 从 Dioxus 0.7.10 迁移到 Leptos 0.8.22 的经过:为什么非迁不可、迁移如何组织、换来了什么,以及新框架带来的新坑。


在 Dioxus 里拆包的两次尝试
问题很清楚:需要按路由拆分 WASM,让每个页面只下载自己用得到的代码。迁移之前,我们在 Dioxus 内部认真尝试过两条路。
第一次:dx --wasm-split
dx 0.7.10 提供实验性的 --wasm-split。在 Yggdrasil 上,它先撞上了拆分器自身的缺陷:经 wasm-bindgen 导出、只被 JS 调用的 wasm_streams 方法没有计入可达性,主模块做垃圾回收时访问已删除函数,walrus 直接断言失败。给 wasm-split-cli 打补丁绕过之后,数字很诱人:主包从 1.10 MB 降到 372 KB,首访文章约 499 KB。
然而所有路由直接访问都水合失败。客户端路由被包进一个挂起的 LoaderInner,服务端却渲染了完整页面,rehydrate().unwrap() 落到 unreachable。结论只能是:0.7.10 的路由拆分与 fullstack SSR 水合并不兼容。对一个以 SSR 为基础的博客,这条路走不通。
第二次:reader / full 双产物
退而求其次,我们把同一份代码编译两次:reader 产物在编译期裁掉后台、登录和图鉴,full 产物保持完整,服务端按请求路径挑选 HTML 模板。效果是真实的:reader 产物降到 495.6 KiB,比基线少了 49.9%。
可它的复杂度几乎全部来自“对抗框架”:
- dx 会把“default features 减去 server”注入客户端构建,
client-reader只能做成反向的累加 feature,再由build.rs发射一个自定义cfg。 - RSX 子元素上不支持
#[cfg],跨产物的入口只能写成同名组件的两份实现。 dioxus-server没有公开IndexHtml,reader 模板要在启动期临时切换DIOXUS_PUBLIC_PATH注入,读完再还原。- 服务端为 reader 路径渲染 full 组件树时,
Link会在 HTML 里留下点击监听器标记,reader 客户端拿到的模板与之不符,水合直接崩溃。 make build要顺序跑两次dx build并分别快照,发布脚本再按清单合并两套哈希资源。
更根本的是,它只有两档。读者从文章进入组件图鉴,要整页切换到 full 产物,首读加上进入图鉴累计下载 2286.5 KiB,两份 WASM 里大部分代码是重复的。
那些不属于业务的补丁
拆包之外,仓库里还积累了一批只为绕开框架行为而存在的代码和注释:
- 挂起边界卸载的 VDOM 腐蚀。 挂起中的
SuspenseBoundary连同子树一起卸载时,dioxus-core 会对同一元素双重回收(cannot reclaim ElementId(N)),解释器的节点表随之失步,此后页面上所有点击都失效,表现为“列表点不动”。后台预览页因此被迫放弃挂起式取数,改用信号加spawn,SSR 也就无法内嵌数据。 - 会漂移的接口地址。
#[server]宏在 URL 末尾追加一个由CARGO_MANIFEST_DIR参与计算的哈希后缀,不同构建环境会算出不同的地址,只能在.cargo/config.toml里固定SERVER_FN_OVERRIDE_KEY。 - 工具链摩擦。 dx 把 wasm 的
getrandom后端 cfg 泄漏进服务端构建;它在 npmjs.org 上硬编码下载 esbuild,国内构建要预先缓存;它的 rustc 包装链还与 sccache 冲突,Makefile 和 Dockerfile 里为此堆了大段缓存与绕行说明。
单看每一条都能绕过去。合在一起,它们说明框架与这个项目的形状已经不再契合。
为什么是 Leptos
决定迁移之前,先在独立分支做了一个原型:用 Leptos 0.8.22 + cargo-leptos --split 移植首页、文章详情(含评论与 Tiptap 桥接)和后台评论审核三条路由,验证三件事:懒加载路由能否与 SSR 水合共存、SSR HTML 缓存能否捕获流式响应、发布切换后旧标签页能否继续加载上一版分包。三项都通过,才动主仓库。
Leptos 吸引我们的地方可以归为四点:
- 路由级分包是一等公民。
Lazy::<T>路由由 cargo-leptos 的--split切成独立 WASM 分包,并且与 SSR 水合配合工作。后台、登录、图鉴和每一个公开页面都可以按需加载,双产物机制可以整体删除。 - 细粒度响应式,没有虚拟 DOM。 组件函数只运行一次,之后由信号直接更新对应的 DOM 节点。不存在 VDOM diff,也就不存在“元素 ID 被回收两次”这一类问题。
- 贴合现有的 Axum 服务端。
leptos_axum把 SSR 和 server function 挂进现成的 Axum 路由,中间件、SSE、上传、MCP 端点原样保留。server function 可以显式指定endpoint,地址固定为/api/<函数名>。 - 按路由选择 SSR 模式。
<Title>、<Meta>或 HTTP 状态码依赖数据的页面用SsrMode::Async,其余可以流式输出,取舍落在每一条路由上。
迁移给自己定了一条硬门槛:任何公开路由首屏的 Brotli WASM 总量,不得超过当时 reader 产物的 594,603 B。 如果新框架连这一档都达不到,就没有理由承受迁移成本。
迁移怎样组织
这是一次原地、一次性的替换,不留 Dioxus 与 Leptos 并存的过渡期。执行方案被写成仓库里的 docs/plan/Leptos迁移_PLAN.md,按阶段推进,每个阶段有明确的验收:
flowchart TD
P0["Phase 0<br/>原型验证与方案"] --> P1["Phase 1<br/>crate 骨架与服务端层"]
P1 --> P2A["Phase 2A<br/>路由表、导航桥接、UI 原子"]
P2A --> P2B["Phase 2B<br/>后台外壳、素材、文章组件"]
P2A --> P5["Phase 5<br/>构建、发布、Docker、CI"]
P2B --> P3["Phase 3<br/>六个页面切片并行"]
P3 --> P4["Phase 4<br/>组件图鉴"]
P4 --> P6["Phase 6<br/>体积门槛与发布验收"]
P5 --> P6
几个关键决定:
- 先服务端,后界面。 Phase 1 只让 API、数据库、MCP、后台任务在 Leptos 下编译并启动。121 个
#[server]宏用结构化 codemod 统一改为endpoint = "<函数名>"加 JSON 编码,不手改。 - 写法对照表作为共享契约。
rsx!对view!,use_signal对RwSignal::new,EventHandler对Callback,use_server_future对路由的data()……所有执行者按同一张表迁移,DOM 结构、class、data-*与文案保持原样,CSS 和浏览器回归脚本不用跟着改。 - 并行切片,独立 worktree。 页面阶段拆成公开页、笔记与搜索、后台内容、后台笔记、后台系统、杂项六个切片,各自在 git worktree 里工作、只改自己名下的文件,再按顺序合并验收。
- 导航主权留在 JS。
libs/yggdrasil-core继续持有 history 与视图过渡,Leptos 的BrowserUrl只负责“读 URL、渲染页面”,站内链接是普通<a href>。原有的页面过渡动画因此得以完整保留。
最终的合并请求涉及 327 个文件,新增 39,095 行、删除 27,836 行。从 10 月 23 日的原型到 10 月 28 日随 v1.1.0 上线,用了五天。
数据与代码同时上路
Leptos 的懒加载路由有一个精巧的结构:LazyRoute::data() 在主包里同步执行,view() 则在分包里。文章页的实现大致如此:
async
点击链接时,取数请求与视图分包的下载同时发出,而不是先等代码到齐再请求数据。route_data 把数据类型擦除为 JSON 文本,取数函数本身也用 #[leptos::lazy] 放进分包,避免数据类型和 server function 客户端回流进主包。在此之上,链接被悬停、聚焦或在触屏上按下时,还会提前预取目标页的分包和数据。
sequenceDiagram
participant U as 读者
participant R as 路由器(主包)
participant C as CDN
participant S as 服务端
U->>R: 悬停文章链接
par 预取视图分包
R->>C: post_route_view.wasm
and 预取页面数据
R->>S: /api/get_public_post_by_slug
end
U->>R: 点击
R-->>U: 分包与数据已就绪,直接渲染
新框架的新坑
迁移不是只有收益。下面几处问题都是实测踩到、逐一修掉的:
erase_components必须两端一致。 服务端和 WASM 都要带--cfg erase_components,只有一端擦除时页面会静默地停在未水合状态,不报任何错误。- 不能在
view()里创建资源。 SSR 会不止一次执行懒加载路由的view(),资源被重复创建、编号与客户端错位,首页水合就此挂起。资源一律放在data()。 - 水合完成前的点击。 懒加载分包还在水合时点击导航,路由器中途换页,水合不匹配让整个 WASM 失效。现在整棵树水合完毕才接管站内链接,之前的点击交给浏览器整页跳转。
- 预载泛滥。 Leptos 会为 SSR 期间调用过的每个懒加载函数展开整份分包清单作为
<link rel="preload">,而且不去重:首页<head>里有 363 条,弱网下与 CSS 抢带宽,移动端首次绘制慢 0.5–2.5 秒。改为只预载当前路由的视图分包,并标记fetchpriority="low"。 - 分包过碎。 wasm-split 按“需要它的分包集合”切出共享分包,59 个懒加载点切出 1121 个,半数不到 200 字节,打开一篇文章要请求 379 个
.wasm。我们在 fork 里给拆分器加了贪心合并,再结合 CDN 的排队特性调高合并代价,效果见下图。 - 路由器释放了仍在显示的页面数据。 慢分包挂起时再次导航,
leptos_router会在中止预载时连带释放当前页data()所在的 owner,页面随后读到已释放的信号而 panic。这个问题已向上游报告(leptos-rs/leptos#4971)并提供修复分支;同期报告的<Title>重建问题(#4968)已由维护者修复。


这些坑的共同点是:问题都能定位到具体机制,有明确的修复路径,并且能写成回归测试锁住。这与“框架内部状态腐蚀、只能绕开”是不同性质的麻烦。
结果
先看迁移门槛。下图中 Leptos 一栏是 release 产物在真实浏览器中直开各路由时,请求到的全部 .wasm 按 Brotli 字节求和:


最重的文章页(530.2 KiB,含评论与代码运行器入口)也低于门槛,更关键的是不再有分档:进入后台只下载后台分包,不必重新下载一整份 full 产物。
迁移上线后的首屏调优(v1.2.x)在此基础上继续推进。下表是线上 https://rua.plus 的实测,基线是迁移后首个线上版本 v1.1.1,档位为移动端(390 px,1.6 Mbps / 150 ms RTT,CPU 降速 4 倍),单位毫秒,3 次取中位数:
| 页面 | 基线 FCP | 优化后 FCP | 基线请求数 | 优化后请求数 |
|---|---|---|---|---|
/ | 3464 | 1088 | 591 | 130 |
/post/… | 4892 | 1164 | 574 | 140 |
/archives | 2372 | 1052 | 323 | 130 |
/notes | 3912 | 1072 | 485 | 114 |
入口文件按内容寻址后可以长期缓存,移动端回访的水合约 1.0–1.5 秒,传输只剩约 60 KB 的 HTML 与数据。v1.2.1 再把首页水合前请求的分包从 61 个减到 20 个,无头 Chromium 冷加载首页约 270–400 ms 完成水合,此前的 v1.2.0 要等到 888 ms 之后。
构建链也明显变轻:Dioxus.toml、dx 缓存脚本和双产物流水线全部删除,Makefile 少了 113 行,Dockerfile 少了 70 行,Cargo.lock 少了约 800 行依赖。
得与失
| 维度 | Dioxus 0.7 | Leptos 0.8 |
|---|---|---|
| 代码拆分 | --wasm-split 与 SSR 水合不兼容,只能做 reader / full 两档 | Lazy::<T> 路由级分包,每个页面独立 |
| 更新模型 | 虚拟 DOM diff | 细粒度信号,组件只运行一次 |
| 数据加载 | 挂起边界卸载存在状态腐蚀,部分页面被迫放弃挂起取数 | data() 在主包中与分包并行,Suspense 统一消费 |
| 接口地址 | 带环境相关的哈希后缀,需固定覆盖键 | 显式 /api/<函数名>,JSON 编码,错误类型化 |
| SSR 控制 | 全站统一 | 每条路由选择 Async 或流式 |
| 构建 | dx、双产物快照与合并 | cargo-leptos 一次构建 |
| 代价 | — | Send + Sync 信号约束、两端 cfg 必须一致、分包工具需要补丁、部分问题仍待上游发布 |
Leptos 并不是没有代价。信号值要求 Send + Sync,JS 句柄只能放进 _local 变体;release 前端依赖打了合并补丁的 cargo-leptos;几个路由器问题的修复尚未进入正式版本。但这些代价都是可见的:它们有名字、有复现、有测试,可以写进 AGENTS.md 成为约定。
尾声
Yggdrasil 是北欧神话里连接九个世界的世界树。迁移之前,这棵树只有一根粗壮的树干,任何人想摘一片叶子,都得先把整棵树搬回家。现在它有了一条主干和许多枝条:读者沿着要去的那一枝走,后台的枝叶只在博主推门时才长出来。
换框架从来不是目的。真正的收获,是让代码结构重新贴合读者的路径:谁访问什么,就只为什么付费。

评论
按时间排序