MoonBit 与 React 的结合:基于 FFI 的实践与思考

拿 AI 刷了一个文档排版, 看是哪个 AI 的味儿…

前些天,MoonBit 公众号发布了文章:《MoonBit Pearls Vol.12 : 初探 MoonBit 中的 JavaScript 交互》。

其中提到一个关键的类型转换功能,允许对泛型进行类型转换。这正是社区(包括我本人)之前反馈过的类似问题,现在看来,它为封装 React 等带有泛型接口的库提供了解决方案。

关键的类型转换函数如下:

fn[T] Value::cast_from(value : T) -> Value = "%identity"

fn[T] Value::cast(self : Self) -> T = "%identity"

什么是 %identity

%identity 是 MoonBit 提供的一个特殊的内置函数(intrinsic),它是一个“零成本”的类型转换操作。

  • 编译时:会进行类型检查。
  • 运行时:不会产生任何效果。
  • 作用:它仅是告诉编译器,“开发者比你更清楚这个值的真实类型,请直接将它当作另一种类型来处理”。

重要提示:

%identity 是一把双刃剑。它在 FFI(外部函数接口)边界层代码中提供了强大的表达能力,但如果滥用,则可能破坏类型安全。因此,它的使用场景应严格限制在 FFI 相关代码范围内。

可以理解为,它类似于 TypeScript 中的 anyunknown,允许进行任意类型强制转换。但请注意,不当使用可能导致数据在 MoonBit 代码中无法识别,甚至无法匹配。然而,在绑定像 React 这样具有大量动态特性、TypeScript 需要反复重载才能标注的 API 时,%identity 成为了一种在内部绕过类型检查的实用方案,避免了穷举所有类型变体的复杂性。

React 绑定示例

我目前的代码仓库位于:https://github.com/Respo/react.mbt/tree/0.0.2

一个基本的 TodoMVC 示例已经完成,核心 API 可正常使用:https://repo.respo-mvc.org/react.mbt/

项目状态:

目前项目仍缺少大量后续 API 绑定。由于个人时间有限,进展可能较慢。项目大量依赖 Trae 生成代码,欢迎社区贡献。

组件编写方式

一个组件的基本写法大致如下:

///|
struct ContainerProps {} derive(Default)

///| 不稳定的用法, 先省略, 后面细说问题
impl @react.JsValueTrait for ContainerProps ...

///| 组件定义
fn comp_container(_v : ContainerProps) -> VirtualNode {
  let (counter, set_counter) = @react.use_state(0.0.to_float())
  @react.use_effect_once(fn() { println("comp_container mounted") })
  @react.div(
    id="container",
    style=respo_style(color=Blue, font_family="Arial", padding=10.0 |> Px),
    [
      @react.div(
        on_click=fn(_) {
          println("clicked \{counter}")
          set_counter(counter + 1.0)
        },
        class_list=[style_counter],
        [
          @react.Fragment([@react.Text("Demo: ")]),
          @react.Text("Counter \{counter}"),
        ],
      ),
      @react.component(comp_todolist, TodoListProps::default(), []),
      @react.component(comp_hooks_demo, HooksDemoProps::default(), []),
    ],
  )
}

页面入口组件的挂载方式:

///|
fn main {
  let window = @dom.window()
  let doc = window.document()
  let body = doc.body()
  let props = ContainerProps::default()
  @react.render(@react.component(comp_container, props, []), body)
  println("loaded")
}

///|
let style_counter : String = @react.static_style([
  ("&", respo_style(margin_bottom=20.0 |> Px, padding=10.0 |> Px)),
])

你会在 @react.component(...) 中看到特定的用法。在 React 中,JSX 编译后,组件调用实际上是通过一个函数间接进行的,例如 React.createElement(Hello, {toWhat: 'World'}, null)。在我的封装中,@react.component(...) 函数在内部会将组件转化为 JsValueJsObscure 这样的动态类型表示,从而实现对 React.createElement(...) 的实际调用。这部分细节依赖于前面提到的绕过类型检查的方式。

Hooks API 封装

目前已封装了几个基本的 Hooks API:

  // 基础状态管理
  let (count, set_count) = @react.use_state(0)
  let (message, set_message) = @react.use_state("初始消息")
  let (timer_count, set_timer_count) = @react.use_state(0)

Hooks API 的 deps 数组目前通过动态方式实现,@react.obscure(count) 同样是利用绕过类型检查的方式:

  // 演示 use_effect_deps - 依赖于 count 变化
  @react.use_effect_deps(
    fn() {
      println("📊 计数器变化: \{count}")
      if count > 0 {
        set_message("计数器已更新到: \{count}")
      }
    },
    [count |> @react.obscure],
  )

useReducer 的封装写法:

///|
extern "js" fn react_use_reducer(
  reducer : JsValue,
  initial : JsValue,
) -> JsValue =
  #| (reducer, initial) => window.React.useReducer(reducer, initial)

///|
// useReducer
pub fn[S : Default, A] use_reducer(
  initial? : S,
  reducer : (S, A) -> S,
) -> (S, (A) -> Unit) {
  let pair = react_use_reducer(
    any_to_js_value(reducer),
    any_to_js_value(initial.unwrap_or_default()),
  ).to_array()
  let s0 = any_from_js_value(pair[0])
  let dispatch_raw = fn1_from_value(pair[1])
  (s0, fn(a : A) { dispatch_raw(any_to_js_value(a)) })
}

总体而言,项目大量依赖这种绕过类型系统(即 %identity)的技巧,同时尽量在暴露的 API 上保留类型约束。

Ant Design (AntD) UI 组件绑定

项目地址:https://github.com/Respo/antd.mbt/tree/0.0.1

已实现的封装示例:https://repo.respo-mvc.org/antd.mbt/。注意:该项目 AI 生成代码占比较高,不建议直接使用,仍需大量验证。

绑定过程分为两步:

  1. 定义动态类型接口以绑定 JavaScript API 调用:

    ///|
    extern "js" fn antd_button(
      props : @dom.JsObjectObscure,
      children : Array[@react.JsValue],
    ) -> @react.JsValue =
      #| (props, children) => {
      #|   return window.React.createElement(window.AntD.Button, props, ...children)
      #| }
    
  2. 在 MoonBit 中定义类型,处理 props 并封装语法:

    pub fn button(
      type_? : ButtonType,
      auto_insert_space? : Bool,
      block? : Bool,
      class_names? : @hashmap.HashMap[String, String],
      color? : ButtonColor,
      ...
    ) -> @react.VirtualNode {
      let js_props = @dom.new_js_object()
      if auto_insert_space is Some(auto_insert_space) {
        js_props.set("autoInsertSpace", @dom.v_to_js_obscure(auto_insert_space))
      }
      ...
      let react_children = children.map(fn(child) { child.to_js_value() })
      JsNode(antd_button(js_props, react_children))
    }
    

最终,我们可以得到如下简洁的调用方式:

@antd.button([Text("Default")])

@antd.button(type_=Primary, [Text("Primary")])

其中 TextJsNode 都是 @react.VirtualNode 的枚举构造器,这里为了简洁进行了省略,它们主要完成了特定类型标记或转换。

项目整体状态与展望

这个项目目前仍处于起步验证阶段。由于我个人无法持续投入大量时间,只能间断更新,因此不建议在生产项目中使用。如果您的项目对稳定性要求不高,例如工具或文档页面,可以尝试使用,并欢迎 fork 代码并提交修复。

当前项目存在以下局限和粗糙之处:

  • 模块体系分离:MoonBit 与 ES Module 体系相互独立,JavaScript 对象需挂载到 window 上才能使用。
  • JavaScript 引入:目前相关的 JavaScript 代码都以内联方式处理,方便模块使用,尚未处理成 wasm imports 的写法。
  • API 覆盖不全:超过一半的 React Hooks API 尚未绑定。
  • AntD 组件:一半的 AntD 组件尚未生成,且整体未经过充分验证。
  • 测试缺失:React 部分缺乏测试。由于很多功能依赖 window 和 DOM,如何更好地处理测试尚不明确。
  • props 定义不稳定 组件 props 目前没想明白怎样定义更合理,可能后续会简化处理。

我原本的目的是尽快打通整个技术链路,而非提供一个完善的网站模块和框架。如果急需在项目中使用 React,请理解目前阶段仍需自行完善不足之处。

关于 Preact 的考虑:

最初计划是基于 Preact 进行绑定,因为 React 未来将与 React Compiler 更好地配合进行优化,而 MoonBit 生成的代码可能难以利用 React Compiler 的优势。但后来意识到 Preact 生态不够活跃,缺乏优质的组件库。与其使用兼容模式,不如直接围绕 React 进行绑定。

我非常希望 MoonBit 在前端场景的生态能够更加活跃。