Rust in Everything
基于 topcoat-runtime 0.10.0

$(...) 表达式:一份 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,
        )
    }
}

三件事:

  1. 捕获:表达式里没有定义的标识符 open 被当成外部值,Clone 一份再转成 Expr<_>。signal 转成的 Expr 带着一段 JS(cx.hydrate({"t":"Signal","id":...}));普通值转成的 Expr 带着它序列化后的快照。所以「捕获值」和「signal」在宏里没有区别,区别在各自类型的 Into<Expr> 实现里。grammar 的 NameResolver 只分两类:表达式内部 let 和闭包参数绑定的局部名(JS 里改名成 __local0……),和其余一律捕获的外部名。
  2. JS 半边:宏在编译期只知道 JS 的「骨架」:""、外部值、".get().not()"。外部值的那段 JS 运行时才拼进去。! 被译成 .not()。
  3. 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
Stringlen 数 UTF-8 字节同样数 UTF-8 字节,trim 用 Unicode White_Space(保留 U+FEFF、去掉 U+0085)
f64Display 规则文本输出照 Rust 的 inf、-inf、-0 拼写

信号值在 HTML 里也是带类型标签的,一个请求从监听到 HTML 里看到的 usize 是 {"t":"usize","bits":64,"v":"1"}:数值写成字符串,避免 JS 的 Number 丢精度。

HTML 里留下了什么

下一页的实验页面里,几个 $(...) 渲染出来是这样(&quot; 已还原成 "):

<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 转义(" 变 &quot;),保证注释里的引号只当分隔符(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表达式RustJS原因(照录)
captured_nan捕获 f64::NANnanunit非有限浮点序列化成 null,水合成 unit
captured_infinity捕获 f64::INFINITYinfunit同上
some_unittrue.then_some({}).is_some()truefalseJS 的 option 用 undefined 同时表示 None 和 unit 载荷
return_inside_if{ if true { return 1.0; } 2.0 }1.02.0return 只退出了 if 生成的函数
break_inside_ifloop { if true { break; } break; }正常SyntaxError: Illegal break statementif 生成的函数把 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 让整个事件处理器编译失败。

💬 关联讨论

发起讨论
加载中...