组件、并发渲染与流式
主线位置:请求主线的末段。上一页讲了一个
view!怎么变成指令;本页讲多个视图怎么一起跑:谁先就绪、首屏什么时候能发、后续更新怎么一块块追加。响应这一侧(ViewBody、<template>和 swap 脚本)在 一个请求从监听到 HTML 里已经看过。
stateDiagram-v2 [*] --> Start Start --> Done: 子内容立刻就绪,且不再更新 Start --> Child: 子内容立刻就绪,还会更新 Start --> Fallback: 子内容未就绪(Stream 模式)发 fallback,包 region Start --> Start: 子内容未就绪(Wait 模式)返回 Pending Fallback --> Child: 子内容就绪,swap 整个 region,还会更新 Fallback --> Done: 子内容就绪,swap 整个 region Child --> Done: 子内容的 poll_swap 返回 None Done --> [*]
View:两个方法
View 不是 Future,而是一个自己定义的 poll 接口:
pub struct ViewFirst {
pub content: ViewHandle,
pub live: bool,
}
pub struct ViewSwap {
pub region: RegionId,
pub replacement: ViewHandle,
}
pub trait View: Send {
fn poll_first(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<ViewFirst>>;
fn poll_swap(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<Option<ViewSwap>>>;
}
poll_first一直 poll 到首屏内容可以交出,live表示之后还有没有更新。poll_swap在首屏之后反复调用,每次交出「某个 region 换成这段内容」,None表示再也没有更新。
两个方法对应响应的两个阶段:router 的 stream 先 poll_first,拿到内容就返回 Response;之后 hyper poll 响应体时,ViewBody 调 poll_swap,每个 ViewSwap 变成一段 <template> 加 topcoat.swap()(或一帧 NDJSON)。一个视图如果只想要完整的内容,ViewExt::single() 会断言 live 为假。
组件的 async fn 怎么变成这个接口?展开结果里的 MoveView 把一个 async 块包成 View,块里 MoveView::drive(view).await 把内层视图的内容「交出去」。交接靠 yielder.rs 里的三个 thread-local:外面 poll 之前写下「这次要 first 还是 swap」,里面的 drive 拿到内容后放进槽里并返回 Pending,外面 poll 返回后把槽取空。等于用 async/await 模拟了一个生成器:async 块可以 await 数据库、可以借用自己拥有的值,同时一次次产出视图内容。
同一个任务里并发
JoinView 的 poll_first:
fn poll_first(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
let mut this = self.project();
ready!(this.units.as_mut().poll_contents(cx))?;
let contents = this.units.as_mut().take_contents();
let burst = this.burst.take().expect("the burst runs once");
let content = Builder::block(this.cx, |builder| burst(builder, contents));
Poll::Ready(Ok(ViewFirst {
content,
live: this.units.is_live(),
}))
}
units 是一个编译期展开的链表 JoinUnit<V0, JoinUnit<V1, ...()>>,poll_contents 依次 poll 每个还没有内容的 unit,全部就绪才返回 Ready。这和 futures::join! 是同一个思路:没有 spawn,所有子组件在调用者的任务里轮流被 poll;也没有每个 unit 单独的 waker,任何一个被唤醒,整串都再 poll 一遍(已就绪的跳过)。有一个 unit 返回错误,整个 JoinView 立刻返回错误。
实验:三个组件分别睡 300、100、200 毫秒,打印各自所在的 tokio 任务 id 和线程。
#[component]
async fn slow(name: &str, ms: u64) -> Result<impl View> {
println!("[{:>4}ms] {name} start, task {:?}, thread {:?}", t(), tokio::task::try_id(), std::thread::current().id());
tokio::time::sleep(Duration::from_millis(ms)).await;
println!("[{:>4}ms] {name} done", t());
Ok(view! { <li>(name)</li> })
}
// 页面:
Ok(view! {
<ul>
slow(name: "a", ms: 300)
slow(name: "b", ms: 100)
slow(name: "c", ms: 200)
</ul>
})
[ 0ms] handler, task Some(Id(13))
[ 0ms] a start, task Some(Id(13)), thread ThreadId(13)
[ 0ms] b start, task Some(Id(13)), thread ThreadId(13)
[ 0ms] c start, task Some(Id(13)), thread ThreadId(13)
[ 103ms] b done
[ 204ms] c done
[ 303ms] a done
<ul><li>a</li><li>b</li><li>c</li></ul>
[200 0.309197s]
三个组件和 handler 是同一个任务(Id(13)),同时开始,总耗时 309 毫秒而不是 600;输出按源码顺序,不按完成顺序。由此可以推出几件事:
- 组件里的阻塞代码(同步的文件读写、CPU 重活)会卡住整个页面的所有兄弟组件,不只是它自己。需要时用
spawn_blocking。 - 文档说组件体和模板表达式的执行顺序「unspecified」,不要靠共享可变状态在组件之间传信息;要有顺序的工作放在构造
view!之前。 - 首屏要等所有兄弟组件都就绪。一个慢组件拖住整页,除非把它放进
suspense。
首屏之后,poll_swap 按环形顺序扫描:从上次交出更新的 unit 的下一个开始,扫一整圈;交出一个更新就记下位置,下次从它后面开始。注释的说法是「so a busy node cannot starve its siblings」:一个不停 emit! 的 live! 不会让旁边的 region 永远等着。for 循环的 LoopView 也是同样的并发 poll 和环形扫描,只是 unit 数量运行时才知道。
suspense
suspense 是门面 crate 里的一个普通组件:
#[component]
pub async fn suspense(
cx: &Cx,
#[into]
fallback: Child<'_>,
#[default]
child: Child<'_>,
#[into]
#[default]
mode: Option<SuspenseMode>,
) -> Result<impl View> {
const SITE: SiteKey = SiteKey::new(file!(), line!(), column!(), 0);
let region = RegionId::new(identity(cx), SITE);
let mode = mode.unwrap_or_else(|| suspense_mode(cx));
let wait = mode == SuspenseMode::Wait;
Ok(SuspenseView::new(region, fallback, child, wait))
}
region id 由调用处的 identity 和 suspense 函数自己的源码位置算出,所以同一个页面每次渲染都一样。SuspenseView::poll_first 先 poll 子内容:就绪就直接用,连 region 注释都不加(fallback 从未出现,也就不需要被替换);没就绪而且是 Stream 模式,才 poll fallback,把它包在 region::start / region::end 注释里交出去,并报告 live: true。之后 poll_swap 继续 poll 子内容,就绪时交出一个替换整个 region 的 ViewSwap;等待期间 fallback 自己的更新(fallback 本身也可以是 live 的)照常透传。本页开头的状态图就是 State 枚举的四个值。
子内容用 ScopeView::self_contained 包着,有自己的 ViewBuffer:它可能在首屏发出去之后才完成,那时首屏的缓冲区已经封存了。
SuspenseMode 有两个值。Stream 是默认;Wait 让 suspense 在子内容就绪前一直返回 Pending,等于没有 suspense。模式可以按调用传(mode: SuspenseMode::Wait),也可以对整个应用设(RouterBuilder::suspense(SuspenseMode::Wait),内部就是 app_context(mode)),或者在某个 layer 里用 cx.with(SuspenseMode::Wait) 按请求设。suspense_mode 依次查请求上下文、应用上下文、默认值。爬虫、生成静态快照、或者不想要流式响应的场合用得上。实测同一个 200 毫秒的组件:
[ 204ms] HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: Accept
connection: close
content-length: 23
<h1>Wait</h1><li>a</li>
头和体在 204 毫秒一起到,有 content-length,没有分块,没有 region 注释。
error_boundary
ErrorBoundaryView 有两个状态:Child(正常渲染,fallback 闭包还在)和 Fallback(子内容出错,fallback 闭包已经用掉,它返回的视图接管)。出错的时机决定了效果:
| 出错时机 | 结果 |
|---|---|
| 首屏之前 | fallback 原地渲染,不加 region |
| 首屏之后(子内容是 live 的,已经包在 region 里) | 整个 region 被 swap 成 fallback |
没有 error_boundary | 首屏之前:整个响应变成 500;首屏之后:响应被中止(见 请求、响应与错误) |
三个实验,组件 failing 睡 50 毫秒后返回 Err(Error::msg("database is down")):
// /boundary
error_boundary(
fallback: |error| Ok(view! { <p>"unavailable: " (error.to_string())</p> }),
failing()
)
// /unbounded
<h1>"Stats"</h1> failing()
// /late
error_boundary(
fallback: |error| Ok(view! { <p>"unavailable: " (error.to_string())</p> }),
suspense(fallback: view! { <p>"loading"</p> }, failing())
)
== /boundary
<h1>Stats</h1><p>unavailable: database is down</p>
[200 0.053927s]
== /unbounded
internal server error
[500 0.053159s]
/unbounded 的 <h1> 早就就绪了,但首屏要等所有兄弟组件,failing 的错误在首屏之前冒出来,整页变成 500。/late:
[ 6ms] ...
<h1>Stats</h1><!--::topcoat::region::start(5849b1d6...)--><!--::topcoat::region::start(5c904853...)--><p>loading</p><!--::topcoat::region::end(5c904853...)--><!--::topcoat::region::end(5849b1d6...)-->
[ 57ms] ...
<script>…swap 脚本…</script><template data-topcoat-swap="5849b1d6..."><p>unavailable: database is down</p></template><script>topcoat.swap("5849b1d6...")</script>
两层 region:外层是 error_boundary 的,内层是 suspense 的。错误发生在首屏之后,替换的是外层,suspense 的 region 连同 loading 一起被换掉。所以要让流式阶段的错误变成页面上的内容,error_boundary 要放在 suspense 外面。
live! 与 emit!
live! 是一个 async 块,里面每次 emit! 产出一版内容,后一版替换前一版。实现是 LiveView,同样建在 yielder 上:第一次 emit! 的内容是首屏;交出首屏之前它会再 poll 一次 body,看它是不是马上就结束了,结束了就不加 region 注释。body 返回 Result<EmitToken>,类型上保证「至少结束于一次 emit」(或显式返回 Ok(EmitToken));真的一次都没 emit 就结束,poll_first 里是一个 panic(live view future completed without yielding anything)。
(live! {
for percent in [0, 50] {
emit! { <p>"Working " (percent) "%"</p> }?;
tokio::time::sleep(Duration::from_millis(150)).await;
}
emit! { <p>"Done"</p> }?;
Ok::<_, topcoat::Error>(EmitToken)
})
用逐次打印 recv 的客户端看:
[ 1ms] HTTP/1.1 200 OK
...
transfer-encoding: chunked
A2
<h1>Progress</h1><!--::topcoat::region::start(9f5c2b90...)--><p>Working 0%</p><!--::topcoat::region::end(9f5c2b90...)-->
[ 153ms] 404
<script>…swap 脚本…</script><template data-topcoat-swap="9f5c2b90..."><p>Working 50%</p></template><script>topcoat.swap("9f5c2b90...")</script>
[ 306ms] 96
<template data-topcoat-swap="9f5c2b90..."><p>Done</p></template><script>topcoat.swap("9f5c2b90...")</script>
0
无头 Chrome 打开同一个页面再 --dump-dom,region 里只剩 <p>Done</p>。HTTP 响应结束后 live! 也就结束了;要在响应结束后继续推送(聊天、行情),body 里调 topcoat::runtime::connected(cx),浏览器运行时会建一条 WebSocket 把页面重跑一遍,那一次 connected(cx) 返回 true,body 可以一直 await 下去。这一部分在 signal、shard 与 procedure。
递归组件与 BoxView
组件的返回类型是 impl View,组件里调用自己,类型就无限嵌套。不加处理:
error[E0733]: recursion in an async block requires boxing
--> src/bin/recur.rs:5:6
|
5 | Ok(view! {
| ______^
6 | | <li>(n)</li>
7 | | if n > 0 {
8 | | countdown(n: n - 1)
9 | | }
10 | | })
| | ^ recursive call here
在 view! 后面加 .boxed()(ViewExt 的方法),得到 BoxView<'a> = Pin<Box<dyn View + 'a>>,类型就有了确定的大小。countdown(n: 3) 输出 <ul><li>3</li><li>2</li><li>1</li><li>0</li></ul>。互相递归的一组组件,环上有一个 boxed() 就够了。
flowchart TB page["page 的 HoistView"] --> join["JoinView:一个任务里轮流 poll"] join --> a["组件 a:MoveView"] join --> s["suspense:SuspenseView"] join --> l["live!:LiveView"] s --> sc["子内容:自己的 ViewBuffer"] join -->|"全部有首屏内容"| burst["burst:写一个指令块"] burst --> first["ViewFirst:首屏 + live"] join -->|"之后环形扫描"| swap["ViewSwap:region 与替换内容"]
小结
View只有poll_first和poll_swap;async fn组件靠MoveView和 thread-local 的 yielder 接到这个接口上。- 同一个
view!里的组件由JoinView在同一个任务里并发 poll,不 spawn;首屏等所有兄弟就绪,后续更新按环形扫描防饿死。组件里的阻塞代码会卡住整页。 suspense子内容立刻就绪就不加 region;Wait模式(按调用、按应用、按请求)关掉流式。error_boundary在首屏前原地渲染 fallback,首屏后替换自己的 region;要接住流式阶段的错误,它得包在suspense外面。live!用emit!逐次产出内容,每次替换同一个 region;响应结束后的持续推送要靠connected(cx)和 WebSocket。- 递归组件用
.boxed()变成BoxView。