Loading... 备注:本篇文章完全由 GPT-6 Astra 生成 在上一篇文章中,我们利用 Kotlin 的各种语言特性,拼出了一套看起来非常像 SQL 的 DSL。 通过泛型、中缀函数、扩展函数以及带接收者的 Lambda,我们成功让 Kotlin 看起来像是在直接编写 SQL。 那么,既然 Kotlin 的语法如此灵活,我们还能拿它来做什么? 还记得之前介绍 Kotlin DSL 时,我们曾经举过这样的例子。 ```kotlin html { head { title("这是页面标题") } body { div { text("Hello Kotlin") } } } ``` 实际上,这种写法特别适合用来描述树形结构。 而 HTML,恰好就是一个树形结构。 既然如此…… 我们能不能干脆用 Kotlin 来编写前端页面? 等等。 如果只是生成一个 HTML 字符串,似乎没什么意思。 毕竟 Kotlin 本身就已经有 HTML DSL 了。 那么,我们能不能再进一步? 比如,让 Kotlin 直接使用 Vue 的响应式系统。 甚至,让 Kotlin 编写的组件,直接交给 Vue 来渲染? 就像这样: ```kotlin class Counter : Component() { private var count by ref(0) private val doubled by computed { count * 2 } override val body = RenderFun { div { p { text("当前计数:$count") } p { text("双倍计数:$doubled") } button(onClick = { count++ }) { text("点我加一") } } } } ``` 是不是有点意思? 这是 Kotlin。 但这里的 `ref`、`computed`,以及界面的响应式更新,都可以直接交给 Vue 3。 当我们点击按钮时,`count` 发生变化,Vue 便会重新计算需要更新的内容,然后更新页面。 我们甚至没有写一行 Vue 模板。 这就是我正在尝试的一个项目:**[KVUE](https://github.com/IceCream-QAQ/KVUE)**。 一个尝试用 Kotlin 编写 Vue 应用的实验性框架。 不过,今天我们不讨论 KVUE 是如何发展成一个完整项目的。 而是把它还原成最初的那个问题。 **如果只利用 Kotlin 自身提供的语言特性,我们究竟需要做些什么,才能拼出这样一个 Kotlin Vue?** 备注:本文中的底层实现代码是为了说明原理而简化的教学示例,不完全等同于 KVUE 项目的实际源码。我们主要讨论语言能力,而不是构建一套完整可运行的框架。 # 首先,我们需要一个 Vue 等等。 我们不是说要用 Kotlin 写 Vue 吗? 为什么一上来就需要一个 Vue? 因为我们要做的事情,并不是重新实现一套前端框架。 仔细想想,Vue 本身已经提供了很多东西。 响应式系统。 虚拟 DOM。 组件生命周期。 DOM 更新。 这些功能,为什么要再实现一次? 我们真正希望改变的,其实只是开发者编写界面的方式。 原本需要使用 JavaScript 或 TypeScript 编写的部分,现在希望能够使用 Kotlin 来完成。 至于最终的页面渲染,依旧可以交给 Vue。 那么问题就变成了: **我们如何让 Kotlin 调用 Vue?** ## Kotlin/JS 还记得 Kotlin 不仅可以运行在 JVM 上,也可以编译为 JavaScript 吗? 通过 Kotlin/JS,我们能够将 Kotlin 代码编译成 JavaScript,并在浏览器中运行。 更重要的是,Kotlin/JS 还能够与现有的 JavaScript 库进行互操作。 例如,在 JavaScript 中,我们可以调用 Vue 的 `h` 函数。 ```javascript import { h } from "vue" const vnode = h("div", null, "Hello Kotlin") ``` 这里的 `h` 并不是什么神奇的语法。 它就是 Vue 提供的一个普通函数。 用于创建 VNode,也就是虚拟 DOM 节点。 而 Vue 的渲染函数,最终返回的也正是这种 VNode。 那么,我们是不是只需要在 Kotlin 中调用这个函数,就能够让 Kotlin 创建 Vue 的虚拟节点了? 当然可以。 例如,我们可以通过 Kotlin/JS 的 `external` 声明,引入 Vue 提供的函数。 ```kotlin @JsModule("vue") external fun h( type: String, props: dynamic, children: dynamic ): dynamic ``` 这里为了方便理解,简化了 Vue 原本支持的各种参数类型和重载。 重点不在声明本身。 重点在于,**Kotlin 可以直接调用 JavaScript 中已经存在的函数。** 这样一来,我们完全不需要自己实现 Vue 的渲染系统。 只需要想办法,让 Kotlin 代码能够更加自然地创建 VNode 就好了。 # 用 Kotlin 拼出一棵 VNode 树 ## 从普通函数开始 首先,我们希望写出这样的代码: ```kotlin div { p { text("Hello Kotlin") } } ``` 不过,既然 Kotlin 本身没有 `div` 和 `p` 这样的关键字,那我们就自己实现。 还记得带接收者的 Lambda 吗? 例如: ```kotlin fun div(block: RenderScope.() -> Unit) ``` 这里的 `block` 是一个以 `RenderScope` 作为接收者的 Lambda。 当我们调用: ```kotlin div { // this 是 RenderScope } ``` 时,便可以在 Lambda 内部直接调用 `RenderScope` 所提供的函数。 那么,我们可以先实现一个简单的 `RenderScope`。 这里假设 `VNode`、`vueElement` 和 `vueText` 已经通过 Kotlin/JS 接到了 Vue 对应的 VNode 创建接口。 ```kotlin class RenderScope { private val children = mutableListOf<VNode>() fun text(value: String) { children += vueText(value) } fun div(block: RenderScope.() -> Unit) { val childScope = RenderScope().apply(block) children += vueElement( "div", childScope.build() ) } fun build(): List<VNode> = children } ``` 实际上,这段代码非常简单。 `text` 创建一个文本节点。 `div` 创建一个新的 `RenderScope`,执行传入的 Lambda,再把生成的子节点包装成一个 div 节点。 然后,将它添加到当前作用域的 `children` 中。 如果我们再实现一个 `p`: ```kotlin fun p(block: RenderScope.() -> Unit) { val childScope = RenderScope().apply(block) children += vueElement( "p", childScope.build() ) } ``` 那么,我们就可以写: ```kotlin val scope = RenderScope() scope.apply { div { p { text("Hello Kotlin") } } } ``` 最后,`scope.build()` 就会得到一组 VNode。 而这些 VNode,可以直接交给 Vue 渲染。 好了。 我们已经利用普通的 Kotlin 函数,拼出了一套能够描述 Vue VNode 的 DSL。 而且,整个过程中没有任何编译器魔法。 **`div` 是普通函数,`p` 是普通函数,`text` 也是普通函数。** 我们只是通过 Lambda 的嵌套,让这些普通的函数调用看起来像是在描述 HTML。 ## 甚至不需要 v-if 和 v-for 这里还有一个很有意思的地方。 因为我们编写的实际上是 Kotlin 代码,所以我们根本不需要专门设计什么条件渲染语法。 比如 Vue 模板中的: ```vue <p v-if="count > 0"> 已经点击过了 </p> ``` 在 Kotlin 中,我们可以直接写: ```kotlin if (count > 0) { p { text("已经点击过了") } } ``` 如果我们希望循环渲染呢? ```vue <p v-for="item in items"> {{ item }} </p> ``` 在 Kotlin 中,我们可以直接写: ```kotlin for (item in items) { p { text(item) } } ``` 甚至还可以使用 Kotlin 的集合操作。 ```kotlin items .filter { it.isNotBlank() } .forEach { p { text(it) } } ``` 等等。 我们好像没有实现任何 `v-if` 或者 `v-for`。 但这些能力已经有了? 没错。 因为 Vue 模板需要自己的模板语法来描述条件和循环。 而我们本来就在编写一段真正的 Kotlin 程序。 Kotlin 已经提供了完整的控制流。 **为什么还需要重新发明一套呢?** 这其实也是 DSL 非常有意思的地方。 我们不仅能够将函数包装成类似另一种语言的语法。 还能够直接复用宿主语言原本就拥有的能力。 # 但是,页面怎么更新? 到目前为止,我们已经能够用 Kotlin 创建 Vue VNode 了。 但是,这还只是一棵静态的节点树。 假设我们希望实现一个计数器: ```kotlin var count = 0 ``` 然后: ```kotlin button(onClick = { count++ }) { text("当前计数:$count") } ``` 当按钮被点击时,`count` 确实发生了变化。 但普通的 Kotlin 变量并不会自动通知 Vue。 Vue 根本不知道这个变量发生了什么。 更不知道应该什么时候重新执行渲染函数。 怎么办? 当然是使用 Vue 的响应式系统。 ## Vue Ref 熟悉 Vue 3 的读者,应该对下面的代码非常熟悉。 ```javascript const count = ref(0) count.value++ ``` Vue 的 `ref` 可以将一个普通的值包装成响应式状态。 当 Vue 的渲染函数读取这个状态时,Vue 便能够追踪对应的依赖。 当状态发生变化时,Vue 又能够根据这些依赖,触发相应的更新。 那么,我们完全可以在 Kotlin 中调用 Vue 的 `ref`。 比如: ```kotlin val count = vueRef(0) ``` 当我们希望读取计数时: ```kotlin count.value ``` 当我们希望修改计数时: ```kotlin count.value++ ``` 这样,响应式的问题其实就已经解决了。 但是…… 这怎么看着有点像直接在 Kotlin 中写 JavaScript? 我们明明是在写 Kotlin。 为什么还要一直带着 `.value`? 能不能更自然一点? 比如: ```kotlin var count by ref(0) ``` 然后直接: ```kotlin count++ ``` 当然可以。 ## Kotlin 属性委托 还记得之前介绍过的属性委托吗? Kotlin 允许我们通过 `by`,将一个属性的读写操作交给另外一个对象。 例如: ```kotlin class MyDelegate<T>(private var value: T) { operator fun getValue( thisRef: Any?, property: KProperty<*> ): T = value operator fun setValue( thisRef: Any?, property: KProperty<*>, value: T ) { this.value = value } } ``` 然后,我们就可以写: ```kotlin var count by MyDelegate(0) ``` 此时,对 `count` 的读取和修改,实际上都会被转发到委托对象的 `getValue` 和 `setValue`。 这是不是意味着…… 如果我们让 `getValue` 读取 Vue Ref 的 `.value`。 再让 `setValue` 修改 Vue Ref 的 `.value`。 那么,Kotlin 的普通属性,就能够直接拥有 Vue 的响应式能力了? 我们来试试。 ```kotlin class RefDelegate<T>( private val source: VueRef<T> ) { operator fun getValue( thisRef: Any?, property: KProperty<*> ): T = source.value operator fun setValue( thisRef: Any?, property: KProperty<*>, value: T ) { source.value = value } } ``` 再提供一个用于创建委托的函数。 ```kotlin fun <T> ref(value: T): RefDelegate<T> = RefDelegate(vueRef(value)) ``` 好了。 现在,我们就可以写: ```kotlin var count by ref(0) ``` 当我们执行: ```kotlin count++ ``` 时,Kotlin 实际上会先读取 `count`,然后计算加一的结果,再将结果写回。 也就是说,它最终会调用委托对象的 `getValue` 和 `setValue`。 而这两个函数,又会读写 Vue Ref 的 `.value`。 于是,一次普普通通的 Kotlin 属性赋值,就变成了一次 Vue 响应式状态修改。 注意。 **我们没有自己实现响应式系统。** 也没有编写什么特殊的变量追踪逻辑。 只是利用 Kotlin 原本就提供的属性委托机制,将属性的读写操作交给 Vue。 而 Vue 也根本不需要知道,修改这个值的究竟是 JavaScript 代码还是 Kotlin 代码。 它只知道自己的 Ref 被修改了。 这就足够了。 ## computed 也可以这么做 既然 `ref` 可以通过属性委托实现,那么 Vue 的 `computed` 呢? 当然也可以。 例如: ```kotlin var count by ref(0) val doubled by computed { count * 2 } ``` 这里的 `computed` 同样可以返回一个只读属性委托。 区别在于,它内部使用的是 Vue 的 `computed`。 当计算函数读取 `count` 时,实际上读取的是 Vue Ref。 因此,Vue 就能够按照自己的响应式机制追踪计算属性的依赖。 而 `doubled` 则只需要从 Vue Computed Ref 中读取当前值即可。 我们甚至不需要手动管理计算结果的缓存。 也不需要手动维护依赖关系。 这些都是 Vue 本来就已经提供的能力。 但对于使用者来说: ```kotlin val doubled by computed { count * 2 } ``` 看起来就像一个普通的 Kotlin 只读属性。 **仅仅通过 Kotlin 的属性委托,我们就成功把 Vue 的响应式系统包装成了 Kotlin 自己的属性语法。** 是不是非常丝滑? # 把响应式和渲染组合起来 现在,我们已经拥有了两个东西。 一套能够创建 Vue VNode 的 Kotlin DSL。 以及一套能够将 Vue Ref 包装成 Kotlin 属性的委托机制。 那么,接下来要做的事情,就非常简单了。 把它们组合起来。 例如: ```kotlin var count by ref(0) val body = RenderFun { div { p { text("当前计数:$count") } button(onClick = { count++ }) { text("加一") } } } ``` 这里的 `RenderFun`,可以理解为一个用于保存渲染逻辑的对象。 它接收一个带接收者的 Lambda。 ```kotlin RenderScope.() -> Unit ``` 然后,在需要渲染的时候,创建一个 `RenderScope`,执行 Lambda,最终得到 VNode。 注意,这里非常重要的一点是: **我们保存的是渲染函数,而不是一开始就执行它并永久保存第一次生成的 VNode。** 为什么? 因为当 Vue 的响应式状态发生变化时,我们需要能够重新执行这段渲染逻辑。 这样: ```kotlin text("当前计数:$count") ``` 才能读取到最新的 `count`。 而当这个渲染函数由 Vue 的渲染机制调用时,对 Vue Ref 的读取,也就能够被 Vue 的响应式系统追踪。 于是,整个过程便连接起来了。 ```text Kotlin RenderFun ↓ 执行 Kotlin Lambda ↓ 读取 count 属性 ↓ 属性委托读取 Vue Ref ↓ Vue 追踪响应式依赖 ↓ 生成 VNode ↓ Vue 渲染页面 ``` 当用户点击按钮时: ```text button onClick ↓ count++ ↓ 属性委托修改 Vue Ref ↓ Vue 调度组件更新 ↓ 重新执行 RenderFun ↓ 生成新的 VNode ↓ Vue 更新页面 ``` 等等。 我们好像什么响应式更新代码都没有写? 没错。 因为这本来就是 Vue 的工作。 **我们只是通过 Kotlin 的语言特性,让 Kotlin 能够参与 Vue 原本的渲染与响应式流程。** # 那么,组件呢? 到目前为止,我们已经能够编写具有响应式能力的页面了。 但一个真正的前端框架,当然不能只有一大坨渲染函数。 我们还需要组件。 需要将页面拆分成不同的部分。 让每个组件拥有自己的状态,以及自己的渲染逻辑。 那么,在 Kotlin 中,我们应该用什么来描述一个组件? 等等。 Kotlin 不就已经有 Class 了吗? ## 直接使用 Kotlin 类 我们可以先设计一个非常简单的组件抽象。 ```kotlin abstract class Component { abstract val body: RenderFun } ``` 然后: ```kotlin class Counter : Component() { private var count by ref(0) override val body = RenderFun { div { p { text("当前计数:$count") } button(onClick = { count++ }) { text("加一") } } } } ``` 这不就有了吗? `Counter` 是一个普通的 Kotlin 类。 `count` 是它自己的成员属性。 `body` 则描述了这个组件的渲染逻辑。 而且,因为 `body` 是在 `Counter` 实例中创建的 Lambda,所以它可以直接访问当前实例的成员。 例如: ```kotlin text("当前计数:$count") ``` 这里的 `count`,实际上就是当前 `Counter` 实例中的属性。 当按钮被点击时: ```kotlin onClick = { count++ } ``` 它同样能够修改当前实例中的 `count`。 这样一来,我们便能够把组件状态与渲染逻辑,自然地组织在同一个 Kotlin 类中。 并且享受 Kotlin 原本就存在的封装、继承、接口、泛型等面向对象能力。 ## 但是 Vue 不认识 Kotlin 类啊? 确实。 Vue 并不知道什么是 Kotlin 的 `Counter`。 它只知道自己的组件定义,以及组件的 `setup`、`render` 等机制。 不过,这个问题并不复杂。 我们可以在 Vue 的 `setup` 阶段,创建一个对应的 Kotlin `Counter` 实例。 然后,将这个实例的 `body` 包装成 Vue 能够调用的渲染函数。 大致可以理解为: ```text Vue 创建组件实例 ↓ Vue setup ↓ 创建 Kotlin Counter 实例 ↓ 初始化 count 等响应式属性 ↓ 取得 Counter.body ↓ 将 RenderFun 交给 Vue 作为 render ``` 这里有一个需要注意的地方。 每个 Vue 组件实例,都应该拥有自己独立的 Kotlin 组件实例。 否则,两个 Counter 就可能错误地共享同一份 `count`。 不过,只要我们在 Vue 创建组件实例的时候,创建对应的 Kotlin 实例,这个问题便能够得到解决。 而 Vue 原本的组件生命周期,也可以通过这种方式,与 Kotlin 对象联系起来。 例如 Vue 组件挂载时,调用 Kotlin 组件的某个方法。 卸载时,再执行对应的清理逻辑。 这些其实都只是普通的函数调用。 我们并不需要重新发明一套组件生命周期。 只需要把 Vue 原本提供的生命周期,转发给 Kotlin 对象就好了。 这样,**Kotlin 的 Class 就能够成为组织 Vue 组件状态与行为的一种自然方式。** # 事件呢?不就是 Lambda 吗? 到这里,我们似乎还没有认真讨论过按钮事件。 例如: ```kotlin button(onClick = { count++ }) { text("加一") } ``` 这里的 `onClick` 是什么? 其实就是一个普通的 Kotlin 函数参数。 ```kotlin fun button( onClick: () -> Unit, block: RenderScope.() -> Unit ) ``` 我们通过 `onClick` 接收一个 Lambda。 然后在创建 Vue VNode 时,将这个 Lambda 作为事件回调,传递给 Vue。 当浏览器中的按钮被点击时,Vue 调用对应的回调。 而回调执行的,则是我们编写的 Kotlin 代码。 ```kotlin count++ ``` 是不是非常简单? 没有什么特殊的事件语法。 也不需要什么额外的事件表达式解析。 因为 Kotlin 本来就支持将函数作为参数传递。 更进一步,我们甚至可以利用 Kotlin 的闭包能力。 ```kotlin fun CounterButton( label: String, onClick: () -> Unit ) { // 创建一个具有点击事件的按钮 } ``` 调用时: ```kotlin CounterButton("加一") { count++ } ``` 这里的 Lambda 可以直接捕获外部的 `count`。 于是,普通的 Kotlin 高阶函数,就已经足够描述大量前端组件之间的交互行为。 当然,真正使用时,我们还需要处理 Vue 事件参数与 Kotlin 函数类型之间的转换。 但从语言能力的角度来说,并没有什么特别神奇的地方。 **函数本身就是值,而 Lambda 本来就是 Kotlin 的一等公民。** # 那么,我们究竟实现了什么? 回头看看最开始的代码。 ```kotlin class Counter : Component() { private var count by ref(0) private val doubled by computed { count * 2 } override val body = RenderFun { div { p { text("当前计数:$count") } p { text("双倍计数:$doubled") } button(onClick = { count++ }) { text("点我加一") } } } } ``` 仔细想想,我们实际上并没有使用什么特别复杂的 Kotlin 特性。 `div {}`、`p {}` 利用带接收者的 Lambda,让普通函数调用看起来像是在描述 HTML。 `RenderFun` 利用高阶函数,将一段 Kotlin 代码保存为可以反复执行的渲染逻辑。 `var count by ref(0)` 利用属性委托,将 Kotlin 属性的读写操作转发给 Vue Ref。 `val doubled by computed {}` 则利用相同的机制,将 Vue 的计算属性包装成 Kotlin 的只读属性。 `onClick = { count++ }` 利用 Lambda 与闭包,让 Vue 的事件能够直接执行 Kotlin 代码。 而 `Component` 呢? 它甚至只是一个普通的 Kotlin 抽象类。 最终,再借助 Kotlin/JS,让这些 Kotlin 代码能够调用 Vue 原本提供的 JavaScript API。 于是,我们就得到了一个非常有意思的结果。 **Kotlin 提供语法。** **Vue 提供运行时。** 我们不需要重新实现响应式系统,不需要重新实现虚拟 DOM,也不需要重新发明组件更新机制。 只是利用 Kotlin 的语言能力,为 Vue 提供了另外一种开发方式。 而且,因为我们编写的本来就是 Kotlin,所以原本属于 Kotlin 的各种能力,也能够直接使用。 例如普通的 `if`、`for`、`when`。 例如泛型、扩展函数、集合操作。 以及 Kotlin 的各种标准库函数。 我们甚至不需要专门为这些东西再设计一套新的模板语法。 这也是我觉得这件事情非常有意思的地方。 当然,如果真的希望将它做成一个完整的前端框架,还需要考虑很多工程问题。 但那些已经不是本文的重点了。 本文只是想说明,**仅仅利用 Kotlin 原本就提供的语言特性,我们便已经能够搭出这样一个 Kotlin Vue 的基本雏形。** 而这,也正是 [KVUE](https://github.com/IceCream-QAQ/KVUE) 这个项目最初让我感兴趣的地方。 # 总结 在上一篇文章中,我们利用 Kotlin 的类型系统、中缀函数与 Lambda,成功拼出了一套 SQL DSL。 而这一次,我们尝试利用 Kotlin/JS、带接收者的 Lambda、属性委托和高阶函数,把 Vue 的响应式与渲染系统接到了 Kotlin 上。 实际上,这两件事情有一个非常相似的地方。 Kotlin 本身并不知道什么是 SQL。 但通过 Kotlin 的语言特性,我们可以拼出一套用来描述 SQL 的语法。 Kotlin 本身也不知道什么是 Vue。 但通过同样的方式,我们依旧能够让 Kotlin 代码看起来像是在直接编写 Vue 组件。 而且,这次我们甚至不需要自己实现 Vue 的核心功能。 只是将 Kotlin 的属性、函数和类,与 Vue 原本的能力联系起来。 从而得到这样一段代码: ```kotlin class App : Component() { private var name by ref("Kotlin") override val body = RenderFun { div { input( value = name, onInput = { name = it } ) p { text("Hello, $name") } button(onClick = { name = "Kotlin" }) { text("重置") } } } } ``` 这里没有 Vue 模板。 没有特殊的模板语法。 只有普通的 Kotlin 类、属性、函数与 Lambda。 但它已经能够描述一个具有响应式状态和用户交互的 Vue 页面。 当然,能够实现,并不意味着任何场景下都应该这么做。 毕竟 JavaScript、TypeScript 与 Vue 原本的生态就已经相当成熟了。 但这并不妨碍我们利用 Kotlin,探索另一种表达方式。 因为 Kotlin 真正有意思的地方,并不只是它能够减少多少样板代码。 而是它提供了一套足够灵活的语言能力。 让我们能够通过这些能力的组合,创造出原本并不存在的使用方式。 **Kotlin 并没有内置 SQL。** **也没有内置 Vue。** **但它给了我们足够多的积木,让我们能够自己把这些东西拼出来。** 这大概也是 Kotlin 缘何如此丝滑的另一个答案。 最后修改:2026 年 11 月 08 日 © 允许规范转载 赞 1 如果觉得我的文章对你有用,请随意赞赏