在上一篇文章中,我们已经讲清楚,KMP Web 主要有两条路线:
路线一:
KMP + Compose Multiplatform
直接开发完整 Web 应用
路线二:
KMP 编译成 JavaScript / TypeScript 库
交给 Vue、React 等前端项目使用
第二条路线比较容易理解:
KMP 负责共享业务逻辑
Vue 负责页面和交互
但是第一条路线,很多 Android 开发者第一次看到时都会有疑问:
不使用 Vue,也不使用 React,只使用 Kotlin 和 Compose,真的可以开发一个完整网页吗?
答案是:可以。
Compose Multiplatform 可以将 Compose UI 运行在浏览器中。Kotlin 代码通过 wasmJs 编译为 WebAssembly,再由浏览器加载和执行。
这一篇我们就完整走一遍:
创建 Web 模块
↓
配置 wasmJs
↓
在 commonMain 编写 Compose UI
↓
创建 Web main() 入口
↓
运行浏览器开发服务器
↓
打包生产环境文件
↓
部署完整 Web 应用
官方当前也提供了使用 Kotlin/Wasm 和 Compose Multiplatform 创建、运行并生成可部署 Web 产物的完整流程。
一、先给出结论:纯 KMP Web 和 Vue 没有关系
纯 KMP + Compose Multiplatform Web 的完整链路是:
Kotlin 代码
+
Compose Multiplatform UI
↓
Kotlin/Wasm 编译
↓
生成 .wasm、JavaScript 加载文件和资源文件
↓
浏览器加载 index.html
↓
运行完整 Web 应用
这里没有 Vue,也没有 React。
页面按钮、列表、输入框、状态管理和业务逻辑,都可以直接使用 Kotlin 和 Compose 编写。
例如:
@Composable
fun App() {
var count by remember {
mutableStateOf(0)
}
Column {
Text("当前计数:$count")
Button(
onClick = {
count++
}
) {
Text("增加")
}
}
}
这段代码和 Android 中的 Jetpack Compose 几乎没有区别。
它既不是 Vue 组件,也不是先转换成 Vue 页面,而是由 Compose Multiplatform 直接绘制到浏览器页面中。
Compose Multiplatform 的 Web UI 当前主要建立在 Kotlin/Wasm 之上,官方将 Compose Multiplatform Web 支持标记为 Beta。
二、浏览器最终加载的到底是什么?
虽然我们使用 Kotlin 和 Compose 写页面,但浏览器并不能直接识别 Kotlin 源代码。
中间需要经历一次编译:
App.kt
↓
Kotlin/Wasm 编译器
↓
WebAssembly 模块
↓
JavaScript 加载代码
↓
浏览器执行
最终生成的文件通常包括:
index.html
xxx.wasm
xxx.js
xxx.mjs
skiko.js
图片、字体等资源
浏览器首先加载 index.html,然后由 JavaScript 加载和初始化 WebAssembly 模块。
所以,纯 KMP Web 并不是说:
浏览器直接运行 Kotlin
而是:
Kotlin
↓
编译为 WebAssembly
↓
浏览器运行 WebAssembly
Kotlin/Wasm 是 Kotlin 编译器提供的一个目标平台,它可以把 Kotlin 代码编译成 WebAssembly,并运行在支持相应 Wasm 特性的浏览器环境中。
三、推荐的项目结构
如果只是学习,可以把所有代码都放在一个模块里。
但如果我们的目标是同时支持 Android、iOS 和 Web,更推荐采用下面这种结构:
KmpProject
├── sharedLogic
│ └── 共享业务逻辑
│
├── sharedUI
│ └── Compose Multiplatform 共享页面
│
├── androidApp
│ └── Android 应用入口
│
├── iosApp
│ └── iOS 应用入口
│
└── webApp
└── Web 应用入口
职责分别是:
sharedLogic
网络、数据模型、业务规则、Repository、UseCase
sharedUI
登录页、首页、列表页、表单页等 Compose UI
androidApp
Activity、Manifest、Android 平台入口
iosApp
SwiftUI/UIKit 入口、Xcode 配置
webApp
浏览器入口、index.html、CSS、Wasm 打包
需要特别注意:
sharedUI 负责提供可共享的页面,webApp 负责把这些页面启动为一个完整的浏览器应用。
可以把它类比成 Android:
sharedUI
类似可复用的 Compose UI Library
webApp
类似最终的 application 模块
四、sharedUI 为什么也需要 wasmJs 目标?
假设 sharedUI 中已经有一个页面:
@Composable
fun App() {
Text("Hello KMP Web")
}
现在我们希望 Android、iOS、Web 都能使用这个页面。
那么 sharedUI 就不能只配置 Android 和 iOS,还需要声明 WebAssembly 目标。
sharedUI/build.gradle.kts 可以写成:
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
androidTarget()
listOf(
iosArm64(),
iosSimulatorArm64()
)
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.components.resources)
}
}
}
这里要注意:
wasmJs {
browser()
}
没有配置:
binaries.executable()
原因是 sharedUI 是一个共享类库,不是最终应用。
它只需要声明:
我的代码可以被编译到浏览器的 Wasm 环境。
最终生成完整可执行 Web 应用的任务,交给 webApp 模块完成。
因此可以先记住:
sharedUI
wasmJs + browser
作为共享类库
webApp
wasmJs + browser + binaries.executable
作为最终应用
五、配置最终的 webApp 模块
接下来创建最终的 Web 应用模块。
webApp/build.gradle.kts 的核心配置如下:
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
sourceSets {
commonMain.dependencies {
implementation(project(":sharedUI"))
implementation(compose.runtime)
implementation(compose.ui)
}
}
}
这段配置中最核心的就是:
wasmJs {
browser()
binaries.executable()
}
分别翻译一下。
1. wasmJs
wasmJs
表示:
将这个模块中的 Kotlin 代码编译成 WebAssembly,并运行在 JavaScript 宿主环境中。
在这里,宿主环境就是浏览器。
2. browser()
browser()
表示:
当前 Wasm 程序运行在浏览器环境中。
配置以后,Gradle 会提供浏览器开发服务器、浏览器测试和生产打包等相关任务。
3. binaries.executable()
binaries.executable()
表示:
当前模块不是普通共享类库,而是一个可以独立启动的最终应用。
没有它,模块更接近一个被其他模块依赖的 Library。
有了它,Gradle 才会生成完整的浏览器应用产物。
所以,这三段配置连起来可以理解为:
wasmJs
把 Kotlin 编译成 WebAssembly
browser()
运行环境是浏览器
binaries.executable()
生成可以独立运行的最终应用
六、在 commonMain 中编写共享页面
接下来在 sharedUI 中创建 Compose 页面。
目录可以是:
sharedUI
└── src
└── commonMain
└── kotlin
└── App.kt
先写一个简单的计数页面:
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun App() {
MaterialTheme {
var count by remember {
mutableStateOf(0)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "纯 KMP + Compose Web"
)
Text(
text = "当前计数:$count",
modifier = Modifier.padding(top = 16.dp)
)
Button(
modifier = Modifier.padding(top = 16.dp),
onClick = {
count++
}
) {
Text("点击增加")
}
}
}
}
这就是一个普通的 Compose 页面。
同一个 App(),理论上可以被多个平台入口调用:
Android Activity
↓
App()
iOS ComposeUIViewController
↓
App()
Web ComposeViewport
↓
App()
这就是 Compose Multiplatform 共享 UI 的核心价值:
不是只共享数据模型
而是连 UI、状态和交互逻辑
也可以一起共享
七、创建 Web 的 main() 入口
Android 应用的入口通常是:
Activity
iOS 应用的入口通常是:
SwiftUI App
或者 UIViewController
Web 应用同样需要一个入口。
在当前较新的 KMP 项目结构中,可以放在:
webApp
└── src
└── webMain
└── kotlin
└── main.kt
部分旧项目也可能使用:
webApp/src/wasmJsMain/kotlin
在 main.kt 中编写:
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(
viewportContainerId = "webApp"
) {
App()
}
}
这里的核心是:
ComposeViewport(
viewportContainerId = "webApp"
)
它的作用是:
找到 HTML 中 ID 为 webApp 的容器,然后在这个容器中创建 Compose 绘制区域,并显示 App() 页面。
完整关系是:
index.html 中的 webApp 容器
↓
ComposeViewport 找到这个容器
↓
在容器中创建 Canvas
↓
Compose 绘制 App()
当前官方推荐使用 ComposeViewport 将 Compose UI 渲染到 HTML 页面中的 Canvas。过去常见的 CanvasBasedWindow 已经被弃用,新的方式允许开发者通过 HTML 和 CSS 更灵活地控制承载区域。
八、index.html 到底负责什么?
接下来需要准备一个普通的 HTML 页面。
目录可以是:
webApp
└── src
└── webMain
└── resources
├── index.html
└── styles.css
旧项目中也可能位于:
webApp/src/wasmJsMain/resources
一个简单的 index.html 可以写成:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>KMP Web Demo</title>
<link
rel="stylesheet"
href="styles.css"
>
<script
type="application/javascript"
src="skiko.js"
></script>
<script
type="application/javascript"
src="webApp.js"
></script>
</head>
<body>
<div id="webApp"></div>
</body>
</html>
这里最重要的是:
<div id="webApp"></div>
因为 Kotlin 入口中写的是:
ComposeViewport(
viewportContainerId = "webApp"
)
两边的 ID 必须一致。
HTML:
<div id="webApp"></div>
Kotlin:
viewportContainerId = "webApp"
如果一个叫 webApp,另一个叫 composeApp,页面就无法正确挂载。
官方 Compose Multiplatform 示例同样会在 HTML 中声明页面容器,并加载 Skiko 和应用的 JavaScript 启动文件。
需要注意,最终 JavaScript 文件名可能由模块名或者 Webpack 配置决定。
例如你的模块叫:
webApp
文件可能是:
webApp.js
如果模块名或者 outputFileName 被修改了,HTML 中的文件名也要对应调整。
九、为什么还要写 CSS?
Compose 页面虽然由 Compose 控制,但它仍然运行在 HTML 页面提供的容器中。
如果 HTML 的 body 或容器本身没有高度,Compose Canvas 就可能无法正确铺满浏览器窗口。
因此可以创建 styles.css:
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#webApp {
width: 100%;
height: 100%;
}
这段 CSS 的意思是:
html 铺满整个浏览器
body 铺满整个 html
webApp 容器铺满整个 body
Compose Canvas 再铺满 webApp
最终形成:
浏览器窗口
↓
html
↓
body
↓
#webApp
↓
Compose Canvas
↓
App()
官方文档也特别指出,ComposeViewport 不会自动向页面注入全局 CSS。如果没有为页面和宿主容器设置尺寸,Canvas 可能不能正确调整大小或铺满窗口。
十、如何在浏览器中运行?
配置完成后,可以直接使用 IDE 中的运行配置。
通常可以看到类似:
webApp [wasmJs]
点击运行后,Gradle 会启动本地开发服务器,并自动打开浏览器。
也可以在终端中执行:
./gradlew :webApp:wasmJsBrowserDevelopmentRun
某些项目中也可能使用:
./gradlew :webApp:wasmJsBrowserDevelopmentRun –continuous
或者:
./gradlew :webApp:wasmJsBrowserDevelopmentRun -t
-t 表示持续构建。
修改 Kotlin 代码以后,Gradle 会重新编译项目。
浏览器地址通常类似:
http://localhost:8080
如果 8080 端口被占用,开发服务器会自动使用其他端口,具体端口可以在 Gradle 控制台中查看。官方入门教程同样通过 webApp [wasmJs] 运行配置启动应用,默认示例地址为本地 8080 端口。
十一、如何打包生产环境文件?
开发完成以后,需要生成可以部署到服务器的生产环境文件。
执行:
./gradlew :webApp:wasmJsBrowserDistribution
构建完成后,文件通常位于:
webApp
└── build
└── dist
└── wasmJs
└── productionExecutable
里面会包含类似:
productionExecutable
├── index.html
├── styles.css
├── webApp.js
├── webApp.mjs
├── webApp.wasm
├── skiko.js
└── composeResources
不同版本和项目配置生成的文件名可能稍有区别,但核心都是:
HTML
JavaScript 启动文件
WebAssembly 文件
图片、字体等静态资源
官方当前推荐使用 wasmJsBrowserDistribution 生成可发布产物,并将结果输出到模块的 build/dist/wasmJs/productionExecutable 目录。
部署时,不是只上传 .wasm 文件,而是要上传整个目录。
也就是:
错误:
只上传 webApp.wasm
正确:
上传 productionExecutable 中的全部文件
因为浏览器还需要:
index.html
JavaScript 加载代码
Skiko
Compose 资源
WebAssembly 模块
这些文件共同组成完整应用。
十二、它和 Vue 打包出来的 dist 有什么区别?
Vue 项目执行:
npm run build
通常会生成:
dist
├── index.html
└── assets
├── app.js
├── app.css
└── images
KMP Web 执行:
./gradlew :webApp:wasmJsBrowserDistribution
通常会生成:
productionExecutable
├── index.html
├── webApp.js
├── webApp.wasm
├── skiko.js
└── resources
从部署角度看,两者非常相似:
Vue dist
↓
上传到静态服务器
↓
浏览器访问 index.html
KMP Web productionExecutable
↓
上传到静态服务器
↓
浏览器访问 index.html
真正的区别在于页面的实现方式。
Vue
JavaScript / TypeScript
↓
Vue 组件
↓
DOM
↓
浏览器页面
KMP + Compose Multiplatform
Kotlin
↓
Compose UI
↓
WebAssembly
↓
Canvas 绘制
↓
浏览器页面
所以可以这样理解:
KMP Web 的 productionExecutable,在部署角色上类似 Vue 的 dist。
但是它们内部的渲染机制不同。
Vue 主要构建和更新 HTML DOM。
Compose Multiplatform Web 则主要通过 Compose 和 Skia 在浏览器 Canvas 中绘制界面。
十三、哪些代码能够真正共享?
假设我们有一个登录功能。
1. 业务模型
data class LoginState(
val username: String = "",
val password: String = "",
val loading: Boolean = false,
val errorMessage: String? = null
)
可以放在:
commonMain
2. ViewModel 或状态管理
class LoginViewModel {
// 登录状态和业务处理
}
只要使用的是跨平台依赖,也可以放在:
commonMain
3. Compose 页面
@Composable
fun LoginScreen() {
// 登录 UI
}
同样可以放在:
sharedUI/commonMain
4. 网络请求
如果使用支持 Wasm 的 Ktor Client 依赖,也可以尽量放在:
sharedLogic/commonMain
5. 平台能力
以下能力往往需要平台适配:
Android 权限
iOS 权限
浏览器剪贴板
文件选择器
CameraX
AVFoundation
浏览器 DOM API
本地存储
这时可以继续使用:
expect / actual
例如:
expect fun openExternalUrl(url: String)
然后分别实现:
androidMain
iosMain
webMain
所以,纯 KMP Web 并不意味着所有代码都必须放进 commonMain。
更准确的规则是:
跨平台一致的逻辑
放 commonMain
平台实现不同的能力
放对应平台 Source Set
十四、最容易混淆的几个问题
1. wasmJs 是完整 Web 应用吗?
不是。
wasmJs
只是声明编译目标。
还需要:
browser()
说明运行环境是浏览器。
如果是最终应用,通常还需要:
binaries.executable()
完整配置才是:
wasmJs {
browser()
binaries.executable()
}
2. 写了 App() 就能在浏览器显示吗?
不能。
App() 只是一个 Compose 页面,还需要 Web 入口:
fun main() {
ComposeViewport(
viewportContainerId = "webApp"
) {
App()
}
}
也就是:
App()
负责页面
main()
负责启动页面
3. 有 ComposeViewport 就不需要 index.html 了吗?
仍然需要。
浏览器首先加载的是 HTML 页面。
ComposeViewport 只是找到 HTML 中的容器,并在容器里创建 Compose 绘制区域。
index.html
提供页面和容器
ComposeViewport
找到容器
App()
绘制真正的业务界面
4. 生成了 wasm 文件就可以直接打开吗?
不能只双击 .wasm 文件。
需要通过 Web 服务器加载完整产物。
开发阶段使用:
Gradle Webpack Dev Server
生产阶段则可以部署到:
Nginx
Apache
GitHub Pages
Cloudflare Pages
其他静态文件服务器
官方文档列出的发布方式也包括 GitHub Pages、Cloudflare 和 Apache HTTP Server。
5. 它是不是完全不需要 JavaScript?
业务页面可以基本使用 Kotlin 编写,但最终产物中仍然会有 JavaScript 文件。
这些 JavaScript 代码主要负责:
加载 WebAssembly
连接浏览器 API
初始化运行环境
启动 Compose
处理 Wasm 与 JavaScript 互操作
所以更准确的表达是:
开发者不需要使用 Vue 或 React 来编写页面,但浏览器运行链路仍然需要 JavaScript 作为宿主和加载层。
十五、浏览器兼容性需要注意什么?
Kotlin/Wasm 会使用 WasmGC 等较新的 WebAssembly 能力,因此需要较新的浏览器版本。
根据 Kotlin 官方当前文档:
Chrome 119 及以上
默认支持
Firefox 120 及以上
默认支持
Safari 18.2 及以上
默认支持
旧浏览器的兼容性可能不足,因此面向实际用户发布前,需要根据目标用户设备进行浏览器测试。
这也是纯 KMP Web 和成熟 Vue 项目相比,需要重点评估的一点。
十六、纯 KMP + CMP Web 适合什么项目?
比较适合:
Android、iOS、Web 希望共享大量 UI
团队成员主要是 Kotlin 开发者
内部管理系统
设备控制后台
IoT 控制面板
数据看板
跨平台工具类应用
已有 Compose Multiplatform 项目,需要补充 Web 端
例如一个设备管理项目:
登录页
设备列表
设备详情
告警列表
维保记录
表单页面
这些页面在 Android、iOS、Web 上的业务结构相似,就可以考虑使用 Compose Multiplatform 共享。
但是对于下面这些项目,需要谨慎评估:
强 SEO 内容网站
高度依赖 HTML DOM 的页面
传统门户网站
复杂富文本编辑器
强依赖成熟前端组件生态的系统
需要兼容大量旧浏览器的项目
因为 Compose Multiplatform Web 的定位,更接近:
运行在浏览器中的跨平台应用
而不是传统的内容型网页。
十七、完整链路重新整理
现在把整篇文章重新串起来。
第一步:在 sharedUI 中声明 Wasm 能力
wasmJs {
browser()
}
表示共享 UI 可以被 Web 平台使用。
第二步:在 webApp 中声明最终应用
wasmJs {
browser()
binaries.executable()
}
表示生成完整浏览器应用。
第三步:在 commonMain 中编写页面
@Composable
fun App() {
Text("Hello KMP Web")
}
第四步:创建 Web 入口
fun main() {
ComposeViewport(
viewportContainerId = "webApp"
) {
App()
}
}
第五步:在 HTML 中创建容器
<div id="webApp"></div>
第六步:运行开发环境
./gradlew :webApp:wasmJsBrowserDevelopmentRun
第七步:生成生产文件
./gradlew :webApp:wasmJsBrowserDistribution
第八步:部署完整目录
webApp/build/dist/wasmJs/productionExecutable
最终链路就是:
sharedUI/commonMain 中的 Compose 页面
↓
webApp 中的 main() 启动页面
↓
ComposeViewport 挂载到 HTML 容器
↓
Kotlin 编译为 WebAssembly
↓
浏览器加载 Wasm 和相关资源
↓
形成完整 Web 应用
十八、总结
纯 KMP + Compose Multiplatform Web,并不是:
把 Kotlin 编译成 JS
然后交给 Vue 使用
而是:
使用 Kotlin 编写业务逻辑
使用 Compose Multiplatform 编写 UI
通过 Kotlin/Wasm 编译
直接生成完整浏览器应用
它与 Vue 没有依赖关系。
最终我们仍然会得到一个可以部署的静态目录:
index.html
JavaScript 加载文件
WebAssembly 文件
资源文件
这个目录在部署角色上,类似 Vue 打包出来的 dist。
最核心的区别是:
Vue
使用 JavaScript / TypeScript 编写 UI
纯 KMP Web
使用 Kotlin + Compose 编写 UI
对于已经在使用 KMP + Compose Multiplatform 的项目来说,这条路线意味着:
Android、iOS 和 Web 不仅可以共享业务逻辑,还有机会进一步共享页面、状态和交互代码。
这才是纯 KMP + Compose Multiplatform Web 真正完整的开发链路。
下一篇预告
这一篇,我们已经真正跑通了纯 KMP Web 的完整链路:
Kotlin
+
Compose Multiplatform UI
↓
Kotlin/Wasm 编译
↓
浏览器加载并运行
↓
形成完整 Web 应用
在这条路线中,KMP 不仅负责业务逻辑,还直接负责 Web 页面、状态和交互。
整个 Web 应用不依赖 Vue,也不依赖 React:
KMP 负责业务逻辑
KMP 负责 Compose UI
KMP 负责生成最终 Web 应用
但是,实际项目中并不一定都适合使用纯 KMP + Compose Multiplatform 开发 Web 页面。
例如,公司已经有成熟的 Vue 项目和前端团队,Web 页面仍然希望继续使用 Vue 开发。
这时,KMP 就可以换一种角色:
KMP 不负责 Web UI
↓
只负责共享业务逻辑
↓
编译成 JavaScript / TypeScript 类库
↓
交给 Vue 项目导入使用
也就是说,同样是 KMP Web,实际上存在两种完全不同的开发模式:
模式一:纯 KMP + Compose Multiplatform
KMP 负责业务逻辑
KMP 负责页面 UI
最终生成完整 Web 应用
模式二:KMP + Vue
KMP 负责共享业务逻辑
Vue 负责页面和交互
KMP 编译成 JS / TS 类库供 Vue 调用
下一篇,我们就切换到第二种模式:
《KMP 如何编译成 JavaScript / TypeScript 库,供 Vue 项目调用?》
下一篇将重点讲清楚:
-
为什么这条路线通常使用 js,而不是 wasmJs
-
为什么共享逻辑模块不需要 binaries.executable()
-
binaries.library() 的作用是什么
-
如何将 Kotlin 类和函数暴露给 JavaScript
-
@JsExport 到底解决什么问题
-
如何生成 TypeScript 类型声明
-
Vue 项目如何导入 KMP 生成的 JS 包
-
KMP 和 Vue 应该如何划分职责
-
如何把 KMP 生成的类库发布成 npm 包
我们最终会跑通下面这条完整链路:
sharedLogic/commonMain
↓
Kotlin/JS 编译
↓
生成 JavaScript 和 TypeScript 类型声明
↓
Vue 项目导入
↓
在 Vue 页面中调用共享业务逻辑
看完下一篇,就能真正区分:
wasmJs + Compose Multiplatform
用于直接开发完整 Web 应用
js + library
用于把 KMP 业务能力提供给 Vue 等前端项目
这两条路线没有谁一定更好,关键在于项目中的 Web UI 到底由谁负责。
