$(...) 表达式:一份 Rust,两种执行
主线位置:交互主线的前半段。从
view!里的一个$(...)开始,到它在 HTML 里留下的属性和注释,再到浏览器把那段字符串编译成函数为止。服务端什么时候被叫回来(signal、shard、procedure)在下一页。
上游文档对这一块的定性是「highly experimental」:表达式只支持一个 Rust 子集,API 还会变。
flowchart TB src["$(!open.get())"] --> gram["topcoat-runtime-grammar:dispatch 每种 syn::Expr"] gram --> rust["Rust 半边:在 surrogate 类型上求值"] gram --> js["JS 半边:源码片段 + 捕获值自己的 JS"] rust --> eval["Expr::evaluate:得到值,记录有没有读 signal"] js --> eval eval --> html["HTML:hidden 属性 + data-topcoat-bind:hidden"] html --> browser["浏览器:new Function 编译,surrogate 类实现同名方法"]
展开一个表达式
view! 里的 $(...) 就是 expr!(...)。单独展开一个:
fn hidden(cx: &Cx) -> Expr<bool> {
let open = signal(cx, || false);
expr!(!open.get())
}
fn hidden(cx: &Cx) -> Expr<bool> {
let open = signal(cx, || false);
{
let __topcoat_external0 = ::core::convert::Into::<
::topcoat::runtime::Expr<_>,
>::into(::core::clone::Clone::clone(&open));
let __js = ::topcoat::runtime::Js::builder()
.source("")
.expression(&__topcoat_external0)
.source(".get().not()")
.build();
::topcoat::runtime::Expr::evaluate(
|| {
let __topcoat_external0 = __topcoat_external0.into_captured_value();
::topcoat::runtime::Surrogate::into_real(!(__topcoat_external0).get())
},
__js,
)
}
}
三件事:
- 捕获:表达式里没有定义的标识符
open被当成外部值,Clone一份再转成Expr<_>。signal 转成的Expr带着一段 JS(cx.hydrate({"t":"Signal","id":...}));普通值转成的Expr带着它序列化后的快照。所以「捕获值」和「signal」在宏里没有区别,区别在各自类型的Into<Expr>实现里。grammar 的NameResolver只分两类:表达式内部let和闭包参数绑定的局部名(JS 里改名成__local0……),和其余一律捕获的外部名。 - JS 半边:宏在编译期只知道 JS 的「骨架」:
""、外部值、".get().not()"。外部值的那段 JS 运行时才拼进去。!被译成.not()。 - Rust 半边:闭包里先
into_captured_value()把值换成它的 surrogate 类型,在 surrogate 上算!x.get(),最后into_real()换回bool。
Expr::evaluate 立刻同步调用这个闭包,同时用一个 thread-local 记下「求值期间有没有读过 signal」:
pub fn evaluate(evaluate: impl FnOnce() -> T, js: Js) -> Self {
let _scope = ReadScope::enter();
let evaluated = evaluate();
Self {
evaluated,
js,
is_static: SIGNAL_READ.get() == Some(false),
}
}
没读 signal 的表达式是静态的,渲染成普通值,不留 JS;读了的才在 HTML 里带上 JS。这也是为什么表达式里的 .get() 不会让服务端重跑:表达式里的读取被 mark_signal_read 截走,算作「浏览器端响应式」,不留下服务端依赖。
支持的语法
dispatch 是一个对 syn::Expr 的 match,每个分支对应 grammar/src/expr/ 下的一个文件:Await、Lit、Paren、Binary、Unary、MethodCall、Call、Field、Struct、Tuple、Index、Block、Closure、If、Loop、While、Continue、Break、Return、Path、Macro(raw!)。其余一律:
expr!(n.get() as u64)
error: unsupported expression
--> src/bin/unsup.rs:5:9
|
5 | expr!(n.get() as u64)
| ^^^^^^^^^^^^^^
没有 match、没有 as、没有区间、没有 for。闭包可以写(事件处理器就是闭包),但在表达式里定义一个闭包再调用它也不行:实验里写 { let f = || ...; f() },编译器报 use of unstable library feature fn_traits。
类型也是一个子集:f64、各宽度整数、bool、String / &str、Option、Result、Vec / 数组 / 切片、元组、#[record] 结构体、Signal、Event。每种只有一部分方法,String 只有 len、is_empty、trim*、starts_with、ends_with、contains、to_owned 和比较。不认识的类型在编译期就被拦下,报错信息是 Surrogated trait 上的 on_unimplemented:「cannot be used in runtime expressions」,并提示用 #[record] 声明结构体。
surrogate:两份替身
Surrogated / Surrogate 是一对互逆的 trait:真实类型 T 对应一个替身类型,替身上只实现「两边都能做到一样」的方法。服务端求值走替身,浏览器端 browser/src/surrogate/ 下有一组同名的 TS 类。几个看得出用心的地方:
| 类型 | 服务端替身 | 浏览器替身 |
|---|---|---|
| 整数 | 带位宽的包装,溢出 panic(debug 和 release 都是) | Integer 类用 BigInt 存值,构造时越界就 throw new Panic("integer overflow");usize 的位宽从脚本标签的 data-topcoat-usize-bits="64" 读 |
bool | ! 走 not() | Bool 类;then 改名 then_,因为有 then 方法的对象会被 Promise 当成 thenable |
String | len 数 UTF-8 字节 | 同样数 UTF-8 字节,trim 用 Unicode White_Space(保留 U+FEFF、去掉 U+0085) |
f64 | Display 规则 | 文本输出照 Rust 的 inf、-inf、-0 拼写 |
信号值在 HTML 里也是带类型标签的,一个请求从监听到 HTML 里看到的 usize 是 {"t":"usize","bits":64,"v":"1"}:数值写成字符串,避免 JS 的 Number 丢精度。
HTML 里留下了什么
下一页的实验页面里,几个 $(...) 渲染出来是这样(" 已还原成 "):
<button id="toggle" data-topcoat-on:click="(__local0) => (cx.hydrate({"t":"Signal","id":"7985db25..."})).set((cx.hydrate({"t":"Signal","id":"7985db25..."})).get().not())">toggle</button>
<p id="msg" hidden="" data-topcoat-bind:hidden="(cx.hydrate({"t":"Signal","id":"7985db25..."})).get().not()">hello</p>
<span id="count"><!--::topcoat::expr::start("(cx.hydrate({"t":"Signal","id":"fd753936..."})).get()")-->1<!--::topcoat::expr::end--></span>
| 位置 | 写法 | 服务端输出 | 浏览器做什么 |
|---|---|---|---|
| 事件 | @click=$(...),值是一个闭包 | data-topcoat-on:click="<JS 闭包>",闭包体不在服务端执行 | 编译成函数,挂到 click 上 |
| 绑定属性 | :hidden=$(...) | 属性的初值 + data-topcoat-bind:hidden="<JS>" | signal 变化时重算、改属性 |
| 文本节点 | $(...) | <!--::topcoat::expr::start("<JS>")--> 初值 <!--::topcoat::expr::end--> | signal 变化时重算、换掉两个注释之间的文本 |
JS 写在注释里时用 Comment 转义(" 变 "),保证注释里的引号只当分隔符(NodeViewParts for Expr)。
浏览器怎么执行
浏览器端在 crates/topcoat-runtime/browser/,TypeScript 4.3k 行(不含测试),构建产物 dist/index.js 提交在仓库里,作为 asset! 打进应用的资产包(06 章),由 topcoat::runtime::script() 输出一个 <script type="module">。入口 Runtime.start 遍历 DOM,找到这些属性和注释,建立 signal 注册表和响应式依赖。字符串变成函数只有一处,compile.ts:
try {
return new Function("cx", `return ${source};`) as Expression<T>;
} catch (cause) {
throw new Error(`Failed to compile ${label}`, { cause });
}
cx 是浏览器端的上下文,cx.hydrate(...) 按标签把 JSON 变回 surrogate 对象。用的是 new Function,这意味着页面的 Content-Security-Policy 必须允许 'unsafe-eval',否则所有 $(...) 都编译不了。本卷没有在严格 CSP 下实测,按源码如此;打算给站点加 CSP 的话,这是选 topcoat 运行时之前要先确认的一条。
raw! 与 procedure 调用
表达式词汇表之外的事用 raw!:第一个参数是 JS 源码(${n} 引用表达式里的绑定),第二个可选参数是等价的 Rust。省略 Rust 半边,表达式就只能在浏览器里跑。两边是否等价由写的人负责,coherence 测不到。
#[procedure] 在表达式里以 double(count.get()).await 的形式调用。服务端这一侧它是一个 ProcedureSurrogate,call 方法只有类型,函数体是 panic!("procedures cannot be executed on the server")。所以 procedure 调用只能出现在事件处理器这类服务端不执行的闭包里;放在会被服务端求值的位置,渲染时直接 panic。
coherence:拿 V8 对拍
两份实现靠什么保持一致?crates/topcoat-runtime/coherence(不发布,2.2k 行)。每个用例用 coherent! 写一个表达式,harness 把它编译成 Rust 闭包和 JS,Rust 在测试进程里跑,JS 放进 V8 加上浏览器运行时跑,比较「返回值或 panic」:
- 比较保留类型结构和精确值,包括浮点的位模式和负零;所有 NaN 视为相等。
- Rust 的 panic 和 JS 的
Panic异常算一致,不比文字;其他 JS 异常、编译失败、超时都算失败。 - 每次 V8 求值有新的全局环境,5 秒期限;没有定时器、没有网络。
它只比最终结果,不比渲染输出、事件交互和副作用。文档最后一句很坦白:两边都跑在 surrogate 上,「Agreement checks consistency between the server and browser, but does not independently verify every operation against ordinary Rust」。
已知不一致登记在 known-mismatches.toml,用例照常跑,结果变了或者两边一致了都会让测试失败:
| id | 表达式 | Rust | JS | 原因(照录) |
|---|---|---|---|---|
captured_nan | 捕获 f64::NAN | nan | unit | 非有限浮点序列化成 null,水合成 unit |
captured_infinity | 捕获 f64::INFINITY | inf | unit | 同上 |
some_unit | true.then_some({}).is_some() | true | false | JS 的 option 用 undefined 同时表示 None 和 unit 载荷 |
return_inside_if | { if true { return 1.0; } 2.0 } | 1.0 | 2.0 | return 只退出了 if 生成的函数 |
break_inside_if | loop { if true { break; } break; } | 正常 | SyntaxError: Illegal break statement | if 生成的函数把 break 和循环隔开了 |
continue_inside_if | 同上,continue | 正常 | SyntaxError: Illegal continue statement: ... | 同上 |
后三条同一个根源:expr_if 为了让 if 在表达式和语句位置都能用,把 JS 一侧包成立即调用的箭头函数 (() => { if (...) ... })()。函数边界挡住了 return、break、continue。
在浏览器里复现两条
下一页的实验页面里加了一个按钮,flag 初值为 true,ret 初值 0.0:
<button id="retbtn" @click=$(|_e| { if flag.get() { return ret.set(1.0); } ret.set(2.0) })>"ret"</button>
<span id="ret">$(ret.get())</span>
按 Rust 语义,点一下 ret 是 1。无头 Chrome 里点一下,页面上的驱动脚本记下的是:
ret after click (Rust would give 1)=2
return 只退出了包着 if 的那个函数,后面的 ret.set(2.0) 照样执行。再写一个 break 在 if 里的事件处理器:
<button id="b" @click=$(|_e| { loop { if true { break; } break; } n.set(3.0) })>"b"</button>
服务端照常编译、照常渲染(闭包体不在服务端执行)。浏览器加载时打出(--enable-logging=stderr 收集的控制台输出):
"Uncaught Error: Failed to compile event @click", source: http://127.0.0.1:38203/_topcoat/assets/topcoat-a85ca5b0b8314af5.js (3)
这个按钮的处理器整个没有挂上,点击没有任何反应。控制台只看得到 Failed to compile event @click,SyntaxError 在 cause 里,要展开才看得到。
小结
$(...)展开成一个Expr:Rust 半边在 surrogate 类型上同步求值,JS 半边是编译期的骨架加上捕获值运行时给出的 JS;没读 signal 的表达式是静态的,不带 JS。- 捕获不区分 signal 和普通值,统一
Clone后转成Expr;表达式内部对 signal 的读取只建立浏览器端依赖,不触发服务端重跑。 - 语法和类型都是子集,不支持的写法编译期报
unsupported expression或cannot be used in runtime expressions。 - 浏览器用
new Function编译表达式,CSP 需要'unsafe-eval'(按源码,未实测)。 - coherence 用 V8 对拍 Rust 和 JS 的最终结果,登记了 6 条已知不一致;
if里的return在无头 Chrome 里实测得到 2 而不是 1,if里的break让整个事件处理器编译失败。