Rust in Everything
基于 topcoat-view 0.10.0

组件、并发渲染与流式

主线位置:请求主线的末段。上一页讲了一个 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。

💬 关联讨论

发起讨论
加载中...