基于 Rust 实现了一个 virtual DOM 库 Respo.rs

#1

英文介绍写过了, 这边写的随意很多,

当前我在使用的版本用的是 Calcit-js 代替 ClojureScript 在跑, 原理其实是一样的, 只是自己定制了 API 和工具链. 注意的是, ClojureScript 跟 JSX 相似, 都是动态类型语言, 编译到 JavaScript 运行, 通过 Webpack/Vite 工具链提供热替换功能.

Rust 语言的优势

Rust 生态跟 ClojureScript 就有相当大的不同,

  • 首先, Rust 是静态类型语言, 而且通过代数类型对各种行为进行约束, 特别是对数据的抽象能力, 还有对数据可变性以及线程安全的约束,
  • 其次, Rust 编译到 WASM 运行, 不是脚本语言的方式解释执行, 因而不能套用 JavaScript 这边的热替换机制. 于是现在直接用 WASM 的话, 是没有热替换的.
  • 此外所有权和内存结构相关的因素, 也在实际当中带来一些麻烦,

我最开始想用 Rust, 是因为发现 Rust 有的 trait, 以及配合 trait 有 pattern matching 一系列语法, 相对于 TypeScript 提供了一些额外的多态的抽象能力, 以及更清晰的分支写法. 对于 React 来说, 需要 class 或者 stateful hooks 来做抽象, 一直是让人感觉别扭的, 我比较想知道代数类型的语言当中, 是否有可以尝试的其他方案.

Rust 语言继承了部分 FP 语言的功能, 比如 if else 设计成了表达式, 还有对 Macro 的支持, 这就方便在框架层面提供一些比较简略的语法糖. 这是我在 ClojureScript 当中熟悉的功能, 我觉得对于一个框架来说, 也比较必要.

Rust 本身出名的特征有它是静态类型语言, 类型安全, 而且性能很高. 但老实说限制在 WebAssembly 这个场景里, 这两点未必有足够好处. 比如说你调用 DOM API 的时候通过 web_sys 去间接调用的, 这个时候就有额外的开销, 而且有大量的 Result<_, _> Option<_> 的情况要处理, 并不精简.

Respo ClojureScript 版本的设计

首先 Respo 是一个微型框架, 实现的功能比较少, 老实说不敢直接跟 React 做比较. 但一些思路上的区别还是挺明显的,

  • 继承 FP 语言, 一切皆是表达式, 拥抱不可变数据, 有额外的性能开销, 但是编写体验非常灵活.
  • Respo 在设计的时候着重思考了怎样对热替换(HMR)的体验进行优化, 甚至框架层面改变了状态的写法. Respo 里状态是用树状结构定义的, 然后由于没有支持 Context, 状态需要层层传递, 确实比较繁琐. 但是带来的好处是框架热替换自动能保留组件状态, 开发体验较好. 不过代价也很大, 只能说好坏参半.
  • Respo 生命周期也做了限制, 只能更新 DOM, 不能触发 dispatch. 这样设计的目的是保证单向数据流, 应用的逻辑很清晰. 但从业务场景来说, 大家都习惯组件局部使用可变状态了, 其实是缺功能了, 包括 Context 也类似, 很多人是想要这功能的.
  • Respo 其实算是做了减法, 减去之后, 所有的组件都是纯函数描述的(没有到 Haskell 意义的纯函数, 但除了 caching 部分以外, 没有隐层状态, 也没有内部的可变状态). 既然一个组件就是一个普通的函数, 组件结构式非常清晰的, 性能优化也通过普通函数来做.

此外, 调试能力, CSS 支持, Effects, 我都逐步做了一些支持. 普通的场景还是可以 hold 住的, 至于效果怎样可以看我用 Respo.calcit 实现的一些工具和页面:

从我的角度, 我觉得 Respo 整体的设计是对朴素的 FP 理念执行得比较好的,

  • 组件就是函数, 函数还能用 memoization 的方式存下来重复使用呢,
  • 状态从组件分离, 局部状态是语法糖的事情, 数据状态的管理是被隔离出组件层面的,
  • 组件描述过程, 大量使用 persistent data structure, 也遵循"一切皆是表达式"的理念,

最大的缺憾, 就是动态类型没有 type class 可以用, 少了很多抽象手段. 由此 Monad 和 Applicative 之类的模式也很含糊.

Respo Rust 实现带来的好处和妥协

目前实现的一个例子是仿写的 TodoMVC, 具体细节看代码,

组件的写法可以直接看图:

image

原理不重新介绍了, 英文的帖子里有. 主要说自己的观感吧,

我觉得 Store 部分 Rust traits 给出的抽象能力就让我非常满意了. 虽然没有用不可变数据, 但是类型系统对可变数据和不可变数据的约束追踪得非常细, 所以用的时候其实也还好, 并且基于 Rust 也有成熟性能优化手段可以学习使用.

描述 virtual DOM 这部分代码, 我本来看的是 Yew, 用的是 JSX, 具体用 Macro 实现的. 但我尝试 Yew 的体验式 Macro 对于报错和代码格式化不大友好, 我不期望在静态类型语言当中存在一大块代码难以被 Rust 自动管控, 所以还是妥协用 traits 和 methods 来写. 只能说整体体验比起 TypeScript 确实差了不少, 但好在有 rustfmt, 还算能接受. 我有在犹豫, 现在很多属性还是以字符串形式传递的, 是否需要类型化, 一方面工作量不小, 另一方面类型的话书写的时候 struct 也要引用很多, 不好抉择.

dispatch 写法, 还算可以, 感觉跟 ClojureScript 那边相比有利有弊. 类型覆盖到了各个函数, 算一个优势, 但是中间涉及类型转化, 所有权处理, 也烦. 状态的树的实现相对恶心一点, 后边章节说.

关于 CSS 和性能优化, 这两块因为函数不能直接实现, 纠结了一下, 后来尝试 Macro, 也算有了还能接受的结果, 只能说暂时先这样吧. 性能优化后边还得想想.

Respo Rust 遇到的问题

具体问题就需要对 Rust 语言有比较深的了解了, 我这边只是大致提一下, 真讲清楚太累了,

  • 状态树是全局存的, 组件状态是局部定义的, 动态语言好处理, Rust 不好处理, Rust 要求所有结构定义的时候就知道大小, 至少能被分析, 不然就是 dynamic trait object, 是一个信息很少操作都麻烦的引用… 我实际在做这个事情的时候是通过 serde_json 的时候绕过了, 组件存状态的时候用 Value 这种 enum 的形式存, 组件获取状态的时候再 cast 回到自己定义的 struct, 其实比较啰嗦, 极端情况可能还会 cast 出错. 但已经是我想到最好的办法. Yew 就不用这种任性的玩法…
  • Respo 更新和渲染整个流程是一个循环, 按照 JavaScript mutable data 的方式处理, Rust 认为其中存在循环引用, 或者说我实现的时候, 某些数据用 RefCell 抽象, 会因为 borrow mut 触发 borrowed data 然后报错, 现在不知道怎么能解决. 现在只能真的用 requestAnimationFrame 搞了个 loop 去轮询, 有点性能问题. 真是很考验 Rust 技巧的题…
  • Rust 使用闭包的话, 数据就要引用计数. JavaScript 环境因为有 GC, 大家默认觉得没问题, 引用就引用了, 但 Rust 这边需要专门声明, 而且每次引用计数增加都要单独声明, 写起代码来就很累了. Yew 的闭包我看看也有这种问题, 估计很棘手.

只能说加强学习吧, 不然只能指望大佬们出手看看能不能解决了.

其他

整体观感我自己还是能接受的. 但是作为静态类型的一个类型, 我也感受到这是需要长期努力才能做好的. Yew 已经有大量代码提交了, 但目前也不敢说 production ready. 我这只能算 toy.

因为现在没有 WASM 可用的热替换方案, 这个项目我现在也只认为是试验. 以及练习 Rust 的一个场景, 缓慢做一些改进, 没有意图也没有水平快速推进. 我现在主要是验证 Respo.rs 能在简单场景用起来, 这样逐步有一些小的改进, 在一些工具项目当中真的用起来, 作为特定场景的类库.

#2

0.0.13 内容更新: Respo Rust 开发记录: dialogs 实现

增加了若干插件, 大致用法, 创建组件:

use respo::dialog::AlertPlugin;

// dd 

let alert_plugin = AlertPlugin::new(
  states.pick("info"),
  AlertOptions {
    // card_style: RespoStyle::default().background_color(CssColor::Blue).to_owned(),
    ..AlertOptions::default()
  },
  |_dispatch: DispatchFn<ActionOp>| {
    respo::util::log!("user has read the message");
    Ok(())
  },
)?
.share_with_ref();

在事件监听器中调用 p.show(..) 方法显示对话框:

let on_alert = {
  let alert_plugin = alert_plugin.clone();
  move |e, dispatch: DispatchFn<_>| -> Result<(), String> {
    util::log!("click {:?}", e);

    // alert_plugin.show(dispatch, Some("a mesasge for you".to_owned()))?;
    alert_plugin.show(dispatch, None)?;

    Ok(())
  }
};

在 Virtual DOM 描述当中加上对话框对应的 Virtual DOM:

alert_plugin.render()?

大致就这样. 代码比预期的短. 但是没有想到做性能优化的好办法, memoization 方案没有想明白. 此外有些很明显的多余的内存拷贝, 希望后续有机会干掉.

#3

我觉得在 Virtual DOM 使用这个事情上, Rust 的优势还是语言本身抽象能力强大, 不管是 Trait, Pattern Matching, 还是 macro, 相比 JavaScript 多出来不少维度. 但是 Rust 生命周期的各种麻烦, 实在还是给编写应用形成了不小的门槛,

Rust 生命周期我怀疑让很多人学不会, 至少对于自带 GC 的 JavaScript 程序员来说, Rust 很累. 当然也不排除年轻人们整体智力水平比我经验里的高哈. 但是大量得处理引用还有命中周期, 很累的.

相较而言, 一个跟 Rust 相似, 但是自带 GC 的语言, 可能更合适 Virtual DOM 场景使用. 当然, 之前的 ReasonML 也是往哪个方向设计的, 只是没走到终点. 相似的语言, 比如 https://grain-lang.org/ 我看着挺有意思的, 不过这个语法开发团队似乎很小, 也只能观望了. 总之基于 Rust 简化, 用 GC 简化开发者心智负担, 对工程和业务来说会更好. 而 Rust 更应该集中在性能敏感的场景.

现在只是自己试验的话, 从视频里也可以看到, 至少用 builder syntax 一点点描述 Virtual DOM, 简单的场景, 其实也不难接受, 顶多是括号多了一些.