view! 展开成什么
主线位置:请求主线的末段。page、layout、组件的函数体返回的
impl View都由view!构造;一个请求从监听到 HTML 里streampoll 的就是它。本页讲宏展开和渲染,下一页讲组件之间怎么并发、怎么流式。
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.
- 第一阶段:每个动态位置的表达式按源码顺序求值,绑到
__expr0、__expr1……NodeClassify::classify把值分成两半:普通值(字符串、数字、实现了NodeViewParts的类型)是「parts」,在第二阶段直接写入;视图(组件、Child、live!、嵌套的view!)是「unit」,要先 poll。name是&str,所以__expr0_parts是它本身,__expr0是一个什么也不做的()视图。 - 第二阶段(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), "forloop 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 | & | < | > | - |
AttributeValue | & | - | - | " |
Comment | & | - | > | " |
AttributeKey / ElementName | - | panic | panic | panic |
属性名和标签名不转义而是校验:空白、控制字符、"、'、<、>、/、= 一律 panic,因为 HTML 在这两个位置不解码字符引用,没有安全的转义方式。转义本身用 memchr 找特殊字节,一段普通文本一次 write_str,连续的特殊字节查表输出。Comment 是给 signal 注释用的:JSON 里的 " 变成 ",所以 一个请求从监听到 HTML 里看到的 signal 注释满是 ",浏览器端读注释时自己解码。
实验(依赖同上一页: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 & "Jerry"</b>"><b>Tom & "Jerry"</b></p><a href="javascript:alert(1)">link</a><script>var s = '</script><script>alert(1)</script>';</script><button aria-current="page" class="btn opacity-50">Save</button>
逐项看:
- 文本位置转了
<、>、&,没转";属性值位置转了&、",没转<。两者都合规。 <script>里的插值也按Text转义。</script>注入被挡住了,但 JS 拿到的字符串变成了字面的</script>...:<script>是原始文本元素,浏览器不解码实体。往脚本里传数据要走 05 章的 signal 或data-*属性,不要直接插值。字面量也一样:<script>"setTimeout(() => { document.getElementById('b').click(); }, 300)"</script>输出成setTimeout(() => { 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({"t":"signal",...})--><span data-n="1" ...>badge</span>after</div>
注释紧贴在组件内容之前。在一个自己写了 <!DOCTYPE html> 的页面里(没有 layout):
<!--::topcoat::signal({"t":"signal","id":"4df2bc612caf2ada90c066923bd043ee","v":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 的组件会报错。