Rust in Everything
基于 topcoat-view 0.10.0

view! 展开成什么

主线位置:请求主线的末段。page、layout、组件的函数体返回的 impl View 都由 view! 构造;一个请求从监听到 HTML 里 stream poll 的就是它。本页讲宏展开和渲染,下一页讲组件之间怎么并发、怎么流式。

flowchart TB
  src["view! 标记"] --> parse["topcoat-view-grammar:Parse 成语法树"]
  parse --> hir["HIR:Emitter 分两个阶段"]
  hir --> h["第一阶段:按源码顺序求值,绑定成 __expr0、__expr1 ..."]
  hir --> b["第二阶段 burst:一个闭包,往 Builder 里写 markup、node、view"]
  h --> join["JoinView:动态位置作为 unit 并发 poll"]
  b --> join
  join --> buf["全部就绪:burst 一次性写入 ViewBuffer,得到 ViewHandle"]
  buf --> render["Renderer::execute:执行指令,按 HtmlContext 转义"]

语法

view! 的语法贴近 HTML,几条规则:

写法含义
<p class="x">"文本"</p>元素用真名;文本必须带引号;非 void 元素必须闭合,<br>、<img> 不写闭合
(expr)任意 Rust 表达式,作为节点或属性值
name=(expr)属性值。bool 为真写空属性、为假省略;Option 为 None 省略;字面量属性永远存在
if / for / match / let直接写,不用花括号包表达式
hello(name: "World")调用组件,参数按名字传
#[key(post.id)] for ...给循环的每次迭代一个稳定身份
(StatusCode::NOT_FOUND)节点位置的状态码、响应头不渲染内容,只在变成响应时生效
<!DOCTYPE html>文档类型声明
:hidden=$(...)、@click=$(...)运行时属性,05 章讲

组件外面(普通函数里)用 view! { cx => ... } 显式传入上下文;组件里的 cx 是隐式的。

展开:两个阶段

cargo expand 一个最小的组件:

#[component]
async fn hello(name: &str) -> Result<impl View> {
  Ok(view! { <p class="greet">"Hello, " (name) "!"</p> })
}

view! 部分展开成(::topcoat::view::internal:: 前缀省略):

ScopeView::new(MoveView::new(async move {
    {
        let (__expr0_parts, __expr0) = NodeClassify::classify(name);
        MoveView::drive(JoinView::new(
            __cx,
            JoinUnit::new(__expr0, ()),
            move |__b, (__view0, ())| {
                __b.markup(&"<p class=\"greet\">Hello, ");
                __b.node(__expr0_parts);
                __b.view(__view0);
                __b.markup(&"!</p>");
            },
        ))
        .await
    }
}))

grammar 里的 Emitter 注释把这个结构说得很清楚:

The hoist phase evaluates expressions in source order and binds their results to fresh identifiers. The burst phase writes an instruction block synchronously using those bindings.
  1. 第一阶段:每个动态位置的表达式按源码顺序求值,绑到 __expr0、__expr1……NodeClassify::classify 把值分成两半:普通值(字符串、数字、实现了 NodeViewParts 的类型)是「parts」,在第二阶段直接写入;视图(组件、Child、live!、嵌套的 view!)是「unit」,要先 poll。name 是 &str,所以 __expr0_parts 是它本身,__expr0 是一个什么也不做的 () 视图。
  2. 第二阶段(burst):一个闭包,按源码顺序往 Builder 写:静态片段 markup,普通值 node,就绪的子视图 view。相邻的静态文本在编译期就合并了:<p class="greet">Hello, 是一个字面量。

两个阶段之间是 JoinView:所有 unit 都有了首屏内容,才调用 burst。没有任何动态节点的作用域走同步路径,两个阶段合在一起。

注意 grammar 里的「hoist 阶段」只是「先求值」的意思,和下文运行时的 hoist() 函数不是一回事。

外面两层:MoveView 把一个 async move 块包成 View,块里拥有被捕获的值(所以 view! 像 async move 一样移走捕获的变量);ScopeView 负责安装一个渲染缓冲区,下面讲。

控制流和组件调用

再展开一个带 for 和 if 的组件:

#[component]
async fn list(items: Vec<String>, admin: bool) -> Result<impl View> {
  Ok(view! {
    <ul>
      for item in items {
        <li>(item)</li>
      }
    </ul>
    if admin {
      hello(name: "admin")
    }
  })
}

要点(摘自展开结果):

  • for 在第一阶段立刻跑完:每次迭代把一个 MoveView 装箱推进 Vec,最后是 LoopView::new(__iterations)。每次迭代一次堆分配,迭代体并发 poll。
  • 每次迭代的 __cx 换成了一个新的 identity:identity_raw(__cx).ambiguous_child(SiteKey::new("src/bin/expandv.rs", 10, 6, 0), "for loop at src/bin/expandv.rs:10:6")。没写 #[key],这个 identity 被标成有歧义,下面「key」一节讲后果。
  • if 变成 EitherView::left(...) / EitherView::right(...),两个分支类型不同也能放进同一个位置。
  • 组件调用 hello(name: "admin") 展开成:用 props_builder() 建参数(typestate builder,漏了必填参数编译不过),派生一个 child(SiteKey::new(..., 1)) 的 identity,再把 Component::render(...) 包进 HoistView::new(MoveView::new(async { ... }))。
  • SiteKey 用的是 view! 调用处的文件、行、列,加一个递增的序号(循环是 0,组件是 1),在 const 块里编译期算好。

#[component] 本身把函数变成一个零大小的 struct 加一个 Component 实现,原函数挪到 render 里面;参数变成 HelloProps<'a> 结构体,所以组件参数可以是借用(name: &str)。

ViewBuffer:一串指令

burst 写的不是字符串,而是指令。ViewBuffer 的文档:

/// The instruction buffer of a build.
///
/// The outermost view creates a buffer and installs it through
/// [`ViewBufferScope`] during each poll. Nested views append instructions
/// to it. When the outer view resolves, its handle takes ownership of the
/// buffer. Rendering a [`ViewHandle`] executes its instructions.
///
/// # Contiguity
///
/// A view's instructions must form a contiguous block ending in a return
/// instruction. Push the whole block synchronously. Awaiting between
/// instructions would let concurrent views append into the middle of it.
pub(crate) struct ViewBuffer {
    id: ViewBufferId,
    instructions: InstructionBuffer,
    consts: ConstBuffer,
}

一次构建(一个页面的首屏)只有一个缓冲区,最外层视图在每次 poll 时把它装进 thread-local(ViewBufferScope),里面所有嵌套视图都往同一个缓冲区里追加。「连续性」约束解释了为什么要分两个阶段:子组件并发运行,如果 burst 中途 await,别的组件的指令就会插进来;所以必须等所有 unit 就绪,再同步地一口气写完一个块,以 Ret 结尾。

Instruction 是一个枚举:

指令作用
Call { entry } / Ret跳进一个嵌套块,Ret 时回来
ViewHandle { ptr }执行另一个缓冲区里的块(suspense 的子内容有自己的缓冲区)
Bool、I8 … F64、Char数字和字符,渲染时才格式化
PromotedStr、StaticStr、Str、String、Dyn各种来源的字符串,都带一个 HtmlContext
RegionStart / RegionEnd写出 <!--::topcoat::region::start(id)--> 这类注释
StatusCode、Headers只记录,不输出

文件末尾有两条编译期断言:

const _: () = {
    assert!(
        !std::mem::needs_drop::<Instruction>(),
        "instruction should not require Drop to improve performance"
    );
    assert!(
        std::mem::size_of::<Instruction>() <= 16,
        "instruction should not exceed 16 bytes"
    );
};

需要析构的东西(String、Box<dyn ...>)都放在旁边的 ConstBuffer 里,指令只存偏移。字面量用 PromotedStr { value: &'static &'static str }:指向「指向字符串的指针」,一个字宽,这就是展开结果里 __b.markup(&"...") 多一个 & 的原因。

渲染时 Renderer::execute 是一个小解释器:一个指令指针、一个返回地址栈,Call 压栈、Ret 弹栈,栈空时的 Ret 结束。每条字符串指令都经过 context.writer(f),转义就发生在这里。

flowchart TB
  start["entry"] --> m1["PromotedStr:p 开标签,Unescaped"]
  m1 --> n1["Str:name,Text"]
  n1 --> c1["Call:子视图的块"]
  c1 --> sub["子块 ... Ret"]
  sub --> m2["PromotedStr:感叹号与闭标签"]
  m2 --> ret["Ret:栈空,结束"]

转义:按位置

HtmlContext 的文档注释里就是一张表:

位置&<>"
Unescaped----
Text&amp;&lt;&gt;-
AttributeValue&amp;--&quot;
Comment&amp;-&gt;&quot;
AttributeKey / ElementName-panicpanicpanic

属性名和标签名不转义而是校验:空白、控制字符、"、'、<、>、/、= 一律 panic,因为 HTML 在这两个位置不解码字符引用,没有安全的转义方式。转义本身用 memchr 找特殊字节,一段普通文本一次 write_str,连续的特殊字节查表输出。Comment 是给 signal 注释用的:JSON 里的 " 变成 &quot;,所以 一个请求从监听到 HTML 里看到的 signal 注释满是 &quot;,浏览器端读注释时自己解码。

实验(依赖同上一页:topcoat =0.10.0 的 full feature、tokio =1.54.1):

let text = "<b>Tom & \"Jerry\"</b>";
let url = "javascript:alert(1)";
let in_script = "</script><script>alert(1)</script>";
let is_disabled = false;
let maybe_title: Option<&str> = None;
let pending = true;
Ok(view! {
  <p title=(text)>(text)</p>
  <a href=(url)>"link"</a>
  <script>"var s = '" (in_script) "';"</script>
  <button disabled=(is_disabled) title=(maybe_title) aria-current=(pending.then_some("page"))
    class=(class!("btn", "opacity-50" if pending, None::<&str>))>"Save"</button>
  (StatusCode::IM_A_TEAPOT)
})
HTTP/1.1 418 I'm a teapot
content-type: text/html; charset=utf-8

<p title="<b>Tom &amp; &quot;Jerry&quot;</b>">&lt;b&gt;Tom &amp; "Jerry"&lt;/b&gt;</p><a href="javascript:alert(1)">link</a><script>var s = '&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;';</script><button aria-current="page" class="btn opacity-50">Save</button>

逐项看:

  • 文本位置转了 <、>、&,没转 ";属性值位置转了 &、",没转 <。两者都合规。
  • <script> 里的插值也按 Text 转义。</script> 注入被挡住了,但 JS 拿到的字符串变成了字面的 &lt;/script&gt;...:<script> 是原始文本元素,浏览器不解码实体。往脚本里传数据要走 05 章的 signal 或 data-* 属性,不要直接插值。字面量也一样:<script>"setTimeout(() => { document.getElementById('b').click(); }, 300)"</script> 输出成 setTimeout(() =&gt; { document.getElementById('b').click(); }, 300),浏览器报 Uncaught SyntaxError: Unexpected token ')'(05 章的实验里踩到过)。带 <、>、& 的脚本要放进单独的文件,用 asset! 引用(06 章)。
  • href 不检查协议:javascript:alert(1) 原样输出。转义只保证值不跑出属性,用户提供的 URL 要自己校验。
  • disabled=(false)、title=(None) 整个属性被省略;class! 跳过了 None。
  • 节点位置的 StatusCode 把响应变成了 418,没有输出任何文本。

hoist:放到内容前面

hoist 让渲染中的代码把一段内容「提」到最近的 HoistView 的内容之前;hoist_once 多一个 HoistKey(任意 Hash 值的 128 位 SipHash),同一份内容里同一个键只放一次。收集器是一个 thread-local,只在 HoistView 被 poll 的那一刻装上:

let poll = {
    let _guard = HoistGuard::install(this.hoisted);
    this.view.poll_first(cx)
};

所以在 tokio::spawn 出去的任务里调 hoist 会 panic(「no view is collecting hoisted parts」),交错 poll 的兄弟视图也不会收到彼此的内容。

谁是 HoistView?展开结果里每个组件调用都包了一层,router 的 #[page]、#[layout] 和 runtime 的 #[shard] 也各包一层。谁在用 hoist?整个工作区里只有 topcoat-runtime 的 signal:signal(cx, init) 用 hoist 把 <!--::topcoat::signal(...)--> 声明注释放到所在组件或页面内容的最前面;服务端对 signal 的 tracked read(.get()、.read())用 hoist_once 放一个 <!--::topcoat::dep(id)-->,同一个 signal 只放一次(05 章讲它的用途)。

这正好解释了 signal 注释的位置。在组件里:

#[component]
async fn badge(cx: &Cx) -> Result<impl View> {
  let n = signal(cx, || 1u32);
  Ok(view! { <span :data-n=$(n.get())>"badge"</span> })
}
// 页面里:<div>"before" badge() "after"</div>
<div>before<!--::topcoat::signal({&quot;t&quot;:&quot;signal&quot;,...})--><span data-n="1" ...>badge</span>after</div>

注释紧贴在组件内容之前。在一个自己写了 <!DOCTYPE html> 的页面里(没有 layout):

<!--::topcoat::signal({&quot;t&quot;:&quot;signal&quot;,&quot;id&quot;:&quot;4df2bc612caf2ada90c066923bd043ee&quot;,&quot;v&quot;:false})--><!DOCTYPE html><html><body><p hidden="" ...>hi</p><script>document.body.dataset.mode = document.compatMode</script></body></html>

注释跑到了 <!DOCTYPE html> 前面。DOCTYPE 之前有内容,会不会让浏览器进怪异模式?HTML 解析器在「initial」插入模式下遇到注释,只是把它挂到 Document 上,模式不变。用无头 Chrome 打开这个页面再 --dump-dom:

<!DOCTYPE html>
<html><head></head><body data-mode="CSS1Compat"><p hidden="" ...>hi</p>...</body></html>

document.compatMode 是 CSS1Compat,仍是标准模式。一个请求从监听到 HTML 的页面有 layout,DOCTYPE 在 layout 里、signal 在 page 里,page 自己的 HoistView 在 <body> 内,所以那里注释出现在 <body> 之后。

key:循环里的身份

上面展开里,没写 #[key] 的 for 给每次迭代派生的是 ambiguous_child:所有迭代算出同一个哈希,只是带着「有歧义」的标记。只要循环体里没人读 identity(普通元素、文本),什么事也没有;一旦有组件读它(suspense、live! 要 region id,signal 要 signal id),identity(cx) 就 panic。把上面的 badge() 放进一个不带 key 的循环:

Ok(view! { <ul> for i in [1u32, 2] { <li>(i) badge()</li> } </ul> })

响应是 500,服务器打印(panic 位置是 badge 里调 signal 的那一行):

ambiguous identity: `for` loop at src/bin/viewrun.rs:147:8 repeats without a key attribute; add `#[key(...)]` to give each iteration its own identity

加上 #[key(i)] 之后两次迭代的 signal id 分别是 53e3b473... 和 4c563641...,页面正常。写上 #[key(post.id)],展开就换成 keyed_child(site, key),每次迭代的身份由数据决定,列表重新排序时身份跟着条目走。05 章的 shard 重跑要靠这个身份在下一次请求里找回同一个组件。

小结

  • view! 展开成两个阶段:先按源码顺序求值所有动态位置,再由一个同步的 burst 闭包写指令;中间是 JoinView,等所有子视图都有首屏内容。
  • 指令写进每次构建一个的 ViewBuffer,必须同步、连续地写完一块;指令不超过 16 字节、不需要 Drop,渲染时由一个带返回栈的小解释器执行。
  • 转义按位置:文本、属性值、注释各有一张表,属性名和标签名只校验、非法就 panic;href 的协议不检查,<script> 里的插值会被实体化。
  • hoist / hoist_once 把内容放到最近的 HoistView(组件、page、layout、shard)前面;signal 注释因此可能出现在 DOCTYPE 之前,实测不影响标准模式。
  • for 不写 #[key] 时迭代的身份有歧义,循环体里用到 region 或 signal 的组件会报错。

💬 关联讨论

发起讨论
加载中...