一点针对 UI 的语法糖的建议

首先,这个建议仅代表个人想法,没有参考价值,个人的目的在于抛砖引玉。

UI 语法糖的方向与意义

  1. Moonbit 作为一个针对 AI 优化语言,在适配 GUI 开发领域有非常重大的意义。目前的 GUI 相关的编程语言都是针对人类优化,对 AI 并不友好。Moonbit 一旦成功适配 GUI 开发,那么在 AI 时代,有潜力超车现有的 GUI 编程语言。
  2. 语法糖本身不捆绑任何 UI 开发框架,但目的是为生态提供一种“简化”写法,正如 JSX 这种语法的意义一样。
  3. 提升开发效率,这里的开发效率不单单是针对人类,对于 AI 同样有意义,使用更少的 token 输出更多的内容,目的能有效降低成本。

语法方案 1.0:

类似 kotlin 语法:最后一个必要参数如果是 函数,那么可以缺省(...,fn(){})

  1. 使用foo{}替代foo(fn(){})
  2. 或者使用foo(...){}替代foo(...,fn(){})

let view =
>| div {
>|   h1(
>|      style = Style()
>|              ..font_size(20)
>|              ..color("red")
>|   ) {
>|     text("Title:\{model.to_string()}")
>|   }
>|   button(click=Msg::Increment) { text("+") }
>|   button(click=Msg::Decrement) { text("-") }
>| }

// 等价于
let view =
    div(fn(){
        h1(style = Style()
              ..font_size(20)
              ..color("red"), fn(){
            text("Title:\{model.to_string()}")
        });
        button(click=Msg::Increment,fn(){ text("+") })
        button(click=Msg::Decrement,fn(){ text("-") })
    });


// 底层定义:
// 这里我返回Unit,只是想解释说这里的返回值是自由的,因为这里只是一个语法糖,并不是一定要构建个什么出来。
fn div(style ?: Map[String,String], children : ()->Unit)->Unit{
}

优点:

  1. 简单直观,易于实现
  2. 参考 Kotlin 的尾函数调用外置的风格,学习成本较低
  3. 参考 Moonbit 的多行字符串风格,可以有效隔离一些语法冲突,易于扩展

缺点:

  1. 不能完全做到 kotlin 那种隐式上下文的类型安全

    比如 compose 的 LazyColumn 内,可以调用 item(index) { },而这个 item 函数,其实是 LazyColumn 的 content 回调函数提供的上下文。
    我仔细考虑了一下,moonbit 可能不适合引入这种隐式上下文的能力

  2. 具有一定污染性,需要成片的使用,这会导致使用>|的时候,语法被一定程度被限制,导致使用的时候有些内容需要抽离>|区域,会有一些麻烦

    最好的方案是不使用 >| 头,所以有以下 1.1 方案

语法方案 1.1:

类似 html 语法,但底层原理和 1.0 类似,最后一个必要参数如果是 函数,那么可以缺省foo(...,fn(){})

  1. 使用<foo>{}替代foo(fn(){})
  2. 或者使用<foo ...>{}替代foo(...,fn(){})

let view =
<div> {
  <h1 style = Style()
             ..font_size(20)
             ..color("red")> {
    text("Title:\{model.to_string()}")
  }
  <button click=Msg::Increment> { text("+") }
  <button click=Msg::Decrement> { text("-") }
}

优点:

  1. 参考了 html 语法,易于学习
  2. 没有1.0大范围污染的问题

缺点:

  1. 使用</>这些符号,可能会和 大于号、小于号 混淆

语法方案 2.0:

尝试构建一个 Tree


let tree =
>| Div {
>|   H1 {
>|     style: Style()
>|              ..font_size(20)
>|              ..color("red"),
>|     text("Title:\{model.to_string()}")
>|   }
>|   Button {click:Msg::Increment, text("+") }
>|   Button {click:Msg::Decrement, text("-") }
>| }

// 等价于

let view = Div::{
        ..Div::default(),
        children: [
            H1::{
                ..H1::default(),
                style: Style()
                    ..font_size(20)
                    ..color("red")
                children: [
                    text("Title:\{model.to_string()}") // 等价于 Text::{content="Title:\{model.to_string()}"}
                ]
            },
            Button::{
                ..Button::default(),
                click: Msg::Increment, children: [ text("+") ]},
            Button::{
                ..Button::default(),
                click: Msg::Decrement, children: [ text("-") ]},
        ]
    }

// 底层定义:
// 1. 需要明确要求 `struct` 有 `children:Array[?]` 这样的结构;
// 1. 如果 `struct` 提供了 Default 的实现,那么会进行调用,否则需要提供全量的属性
struct Div {
  style : Map[String, String]
  children : Array[HTMLElement]
} derive(Default)

优点:

  1. 本质是在构建一个 Tree,原理简单易懂,和原本的语法差别不大,只是将 children 属性作为最后一个属性,省去了一些书写
  2. 理论上同样可以复用1.01.1的语法

缺点:

  1. 需要隐式地提供一个children属性,或者可以改成使用trait TreeNode,但是目前TreeNode好像不能提供泛型?
  2. 目前 moonbit 没有联合类型,所以 children 也无法做到混合多种 enum/struct/trait

语法方案 3.0

提供宏插件,难度极高,需要极强的可扩展性,允许编译插件将“字符串”编译成 moobit 的 AST,同时提供 sourcemap 对象、高亮结构,使得 AST 可以反向映射回到源码片段。


let view = #HTML(
#| <div> {
#|   <h1 style="font-size:20px;color:red;">
$|     Title:\{model.to_string()}
#|   </h1>
$|   <button click=\{Msg::Increment}>+</button>
$|   <button click=\{Msg::Decrement}>-</button>
#| </div>
,
options: Options() // 这里理论上可以提供 Options ,但是要求都是常量
)

个人用 Virtual DOM 方案比较多, 然后在 Swift 当中也接触到这个 trailing closure syntax 的语法. 我觉得如果用 trailing closure 描述 UI 还涉及到一个比语法更深点的问题.

React 是在定义 Virtual DOM, 虽然里边包含各种集合类型, 包含一些判断逻辑, 甚至包含闭包, 但很大程度是在用 字面量 或者说 value 的思路来描述 UI. 代码展示的什么样子, 就希望界面看上去结构跟代码结构对应. DSL 只是描述 UI, 而不是直接开始初始化, 这里能操作的东西相对受限, 如果要对 UI 元素需要更复杂的控制, 那么就需要在 reconciliation 阶段再去操作, 这是在两个阶段中分离开的. 而且按照 React 的搞法, Virtual DOM 会反复多次被重复生成, 但 UI 组件实际的初始化预期只在需要实际界面渲染的时候才发生.

let wrapChildren = (children: React.element) => {
  <div>
    <h1> {React.string("Overview")} </h1>
    children
  </div>
}

wrapChildren(<div> {React.string("Let's use React with ReScript")} </div>)

而 trailing closure 写出来的代码, Button 在闭包当中被执行, 然后创建好实例(我对 SwiftUI 跟 Compose 内部实现不了解, 不知道是不是内部依然进行 diff, 只能认为这里的实例跟 UI 当中的实例有更强的关联甚至引用). 这里就留出了更多进行副作用的空间,

struct ContentView: View {
    @State private var showDetails = false

    var body: some View {
        VStack(alignment: .leading) {
            Button("Show details") {
                showDetails.toggle()
            }

            if showDetails {
                Text("You should follow me on Twitter: @twostraws")
                    .font(.largeTitle)
            }
        }
    }
}

Kotlin 的 trailing lambda syntax 看着高度相似(我还没用过), Date pickers  |  Jetpack Compose  |  Android Developers 猜测行为相似,

// ...
    var selectedDate by remember { mutableStateOf<Long?>(null) }
// ...
        if (selectedDate != null) {
            val date = Date(selectedDate!!)
            val formattedDate = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date)
            Text("Selected date: $formattedDate")
        } else {
            Text("No date selected")
        }
// ...
        DatePickerModal(
            onDateSelected = {
                selectedDate = it
                showModal = false
            },
            onDismiss = { showModal = false }
        )
    }
// ...

可能这里重要的区别是, children 是作为 value 通过参数传递的, 还是在闭包当中通过资源初始化创建的?

用值去表示, 相对会减少副作用的依赖, 方便代码复用, 有更多可能去序列化. 能够序列化的话, 对于 DSL 跨线程传递, 跨网络传递更友好, 甚至 AI 生成代码时更少一些对于状态操作的顾虑.

在闭包里初始化, 有更多机会对组件做更精细的控制, 特别是对资源做控制, 甚至还有特殊的场景中涉及到 3D 还有模型加载或者 buffer 初始化之类的需要很明确很琐碎的逻辑. 估计手机上对资源控制和性能优化更关注的话, 更想要这种程度的控制手段.

当然这首先是语法上的区别, 调换语法跟内部实现, 也都可以额外加上约束去模拟. 比如 React 的写法依然可以传个 onInit=fn(c) {} 去控制实际的 UI 元素初始化行为.

我个人因为 FP 语言的习惯更倾向前者, 更多用值去描述 UI, 甚至未来是否有机会看看能不能把闭包都用可序列的方式描述出来. 虽然也不排斥 trailing closure 因为很多场景写的代码真的挺灵活的.

不知道官方在这方面是否有倾向性. 至少看到 MoonBit 在已有的风格上感觉对于 FP 很多风格, 对于精细的性能控制, 两个都比较重视.

1 个赞

kotlin compose 是在闭包中执行 compose-function 来构建。执行 compose-function 本质也是在构建一个 Tree 结构。


一、

如果使用“尾跟随函数” 这种语法来做 GUI,最好还得搭配 Signals 提案。

这样一来,这些 div/h1/text UI 函数,其实都是 Signal.compute 函数。我们只需要执行一次,就可以构建好 状态树依赖树,某一个 Signal.State 变更了,只需要重新执行被依赖的 Signal.compute,就可以更新 状态树依赖树 中的某一个节点。

因此我个人觉得: “尾跟随函数” 是可以达成更好的理论性能。


二、

基于值去表示优点,你说的这几点都没有问题:“ 相对会减少副作用的依赖, 方便代码复用, 有更多可能去序列化. 能够序列化的话, 对于 DSL 跨线程传递, 跨网络传递更友好”。

但是这些优点大部分是“Tree”的优点。意味着大部分的优势基于 “尾跟随函数” 来构建“Tree”同样可以有。


三、

但我们仍然需要讨论“尾跟随函数” 与 moonbit 的适配性,最关键点在于“类型安全”做不到 kotlin 那样,正如我前文举例的“隐式上下文”。

比如 html 的 select 与 option 这对标签,我们假设约束 option 只能出现 select 标签下:

3.1

如果基于函数:


// 正确用法

select(fn(){

option(...)

})

// 错误用法

option(fn(){

select(...)

})

错误用法无法报错。需要引入更多的上下文来进行约束,但这必然会书写体验带来影响,同时需要消耗更多的AI-token。

3.2

而基于值表达式,因为类型约束可以直接在struct类型中定义好:


struct Option {}

struct Select{

children: Array[Option]

}

因此错误的使用能够正确被检查出来。

2 个赞