:sharedLogic 如何编译成 JS 库,供 Vue 项目调用?)
前言前两篇已经讲清楚了 KMP Web 的两条主要路线路线一 Compose Multiplatform wasmJs → KMP 同时负责 Web UI 和业务逻辑 → 最终生成完整 Web 应用 路线二 Vue Kotlin/JS → Vue 继续负责 Web UI → KMP 只提供共享业务逻辑这一篇进入第二条路线的实战把 KMP 中的sharedLogic编译成 JavaScript/TypeScript 库再由 Vue 项目导入使用。这时sharedLogic不是一个网站也不负责生成 Web 页面。它的定位更像一个前端业务 SDKsharedLogic/commonMain ↓ Kotlin/JS 编译 ↓ JavaScript 文件 TypeScript 声明 ↓ Vue 项目 import ↓ Vue 调用共享业务逻辑 ↓ Vue 最终生成 dist一、先明确最终目标假设我们已经有一个 KMP 项目lift-kmp-core ├── sharedLogic ├── sharedUI ├── androidApp └── iosApp公司原有的 Web 项目使用 Vueweb-vue ├── package.json ├── vite.config.ts └── src ├── App.vue ├── views └── components我们希望最终形成lift-kmp-core ├── sharedLogic │ ├── commonMain │ ├── androidMain │ ├── iosMain │ └── jsMain │ ├── sharedUI ├── androidApp ├── iosApp │ └── web-vue其中各模块负责模块主要职责sharedLogic三端共享业务逻辑sharedUIAndroid、iOS 共享 Compose UIandroidAppAndroid 应用入口iosAppiOS 应用入口web-vueVue 页面和 Web 交互最终共享范围是Android iOS → 共享业务逻辑 → 共享 Compose UI Android iOS Web → 共享业务逻辑 Web → 继续使用 Vue UI二、哪些内容适合共享给 Vue适合放进sharedLogic/commonMain并提供给 Vue 使用的内容包括数据模型 表单参数校验 手机号、邮箱校验 二维码内容解析 设备协议解析 业务状态计算 权限判断规则 金额计算 日期规则 分页参数处理 接口参数组装 接口返回数据转换 统一错误码 业务状态机例如class PhoneValidator { fun validate(phone: String): Boolean { return phone.length 11 phone.all { it.isDigit() } } }这段逻辑可以同时被Android iOS Vue调用。不适合直接共享给 Vue 的内容例如Android Context Activity CameraX 高德地图 Android SDK UIKit AVFoundation Compose 页面 Android SharedPreferences iOS NSUserDefaults 浏览器 DOM这些属于平台能力需要分别放在androidMain iosMain jsMain中实现。因此KMP 并不是把所有代码都塞进commonMain而是把真正跨平台、与 UI 和系统 API 无关的业务规则放进commonMain。三、sharedLogic 和完整 Web 应用有什么区别Compose Web 完整应用一般使用wasmJs { browser() binaries.executable() }它会生成一个能够独立运行的网站。而给 Vue 使用的sharedLogic使用js { browser() binaries.library() }它生成的是一个 JavaScript 库。两者的区别是binaries.executable() → 我自己就是最终应用 → 我需要独立运行 binaries.library() → 我是一个库 → 由 Vue、React 等其他项目调用因此sharedLogic不需要fun main()也不需要index.html因为最终网站入口仍然在 Vue 项目中。四、给 sharedLogic 增加 JS Target打开sharedLogic/build.gradle.kts增加 Kotlin/JS Target。一个适合作为起点的配置如下plugins { alias(libs.plugins.kotlinMultiplatform) } kotlin { androidLibrary { namespace com.sqx.shared.logic compileSdk 36 minSdk 24 } iosArm64() iosSimulatorArm64() js { outputModuleName sharedLogic browser() binaries.library() generateTypeScriptDefinitions() compilerOptions { target es2015 } } compilerOptions { optIn.add(kotlin.js.ExperimentalJsExport) } sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) } androidMain.dependencies { // Android 平台依赖 } iosMain.dependencies { // iOS 平台依赖 } jsMain.dependencies { // JavaScript 平台依赖 } } }这套配置与 Kotlin 官方提供的“将 KMP 模块构建成 JavaScript/TypeScript 库”的示例结构基本一致使用browser()、binaries.library()、generateTypeScriptDefinitions()和outputModuleName。下面逐行拆解。五、js {}把 Kotlin 编译成 JavaScriptjs { }表示给当前 KMP 模块增加 Kotlin/JS Target。编译时会把commonMain jsMain组合起来编译成 JavaScript。完整链路sharedLogic/commonMain sharedLogic/jsMain ↓ Kotlin/JS 编译器 ↓ JavaScriptKotlin/JS 的作用就是把 Kotlin 代码、标准库以及兼容的依赖转换成 JavaScript使其能够在 JavaScript 环境中运行。六、browser()这个 JS 库用于浏览器browser()表示当前 JavaScript 目标主要运行在浏览器环境。因为 Vue 项目最终运行在浏览器所以这里使用browser()如果是提供给 Node.js 后端使用则可以配置nodejs()Kotlin/JS 官方支持浏览器和 Node.js 两类主要执行环境。但是要注意browser()不代表生成了 Web 页面。它只说明这个 JS 产物面向浏览器环境页面仍然由 Vue 编写。七、binaries.library()生成 JavaScript 库binaries.library()表示当前模块不是最终 Web 应用而是要生成一个供其他 JavaScript/TypeScript 项目调用的库。这和上一篇讲到的binaries.executable()正好相反。binaries.executable() → 最终应用 → 有 main() 入口 → 能够独立运行 binaries.library() → JavaScript 库 → 不负责启动页面 → 等待 Vue import所以这里不要写fun main() { }sharedLogic只负责提供 API。八、generateTypeScriptDefinitions()生成 TypeScript 类型声明generateTypeScriptDefinitions()表示除了生成 JavaScript 文件还要生成 TypeScript 的.d.ts类型声明文件。假设 Kotlin 中有JsExport class SharedSdk { fun validatePhone(phone: String): Boolean { return phone.length 11 } }生成的 TypeScript 类型大致类似export class SharedSdk { constructor() validatePhone(phone: string): boolean }有了.d.ts文件以后Vue 的 TypeScript 代码可以获得方法自动补全 参数类型提示 返回值类型提示 编译期类型检查 IDE 跳转和提示Kotlin/JS 编译器会收集使用JsExport标记的顶层声明并据此生成 TypeScript 类型定义官方也特别说明这项能力很适合跨端共享业务逻辑的场景。九、outputModuleName控制什么outputModuleName sharedLogic表示设置 Kotlin/JS 生成的 JavaScript 模块名称。它会影响类似目录和文件build/js/packages/sharedLogic/以及生成的sharedLogic.js sharedLogic.d.ts但它不一定等于npm 公共仓库里的包名npm 发布时使用的包名可以另外配置。官方文档说明outputModuleName会影响生成的 JavaScript 模块以及对应的.js和.d.ts文件但不会改变 Webpack 的最终输出名称。十、为什么使用target es2015compilerOptions { target es2015 }表示生成面向 ES2015也就是 ES6 及以上 JavaScript 环境的代码。这使生成结果可以使用更现代的 JavaScript 能力例如JavaScript class ES Module generator 更现代的模块结构对于 Vue 3、Vite 等现代前端项目通常更合适。Kotlin 官方文档说明设置 ES2015 目标后可以启用模块、类和生成器等现代 JavaScript 特性当前文档还说明ES2015 目标默认使用 ES Module 形式。也可以显式写js { browser() useEsModules() binaries.library() generateTypeScriptDefinitions() }useEsModules()表示生成使用import export语法的 ES Module通常更符合 Vue 和 Vite 的使用习惯。Kotlin/JS 也支持 UMD、CommonJS、AMD、Plain 和 ES Module 等模块系统。十一、不是所有 commonMain 依赖都能编译到 JS增加js { browser() }以后commonMain中的代码还需要满足一个条件所有参与编译的依赖都必须支持 Kotlin/JS。例如下面这些跨平台库通常可以放在commonMainkotlinx-coroutines kotlinx-serialization Ktor Client Core 纯 Kotlin 数据模型 纯 Kotlin 业务规则但下面这些 Android 专属库不能参与 JS 编译androidx.lifecycle CameraX Room Android Android Context 高德地图 Android SDK Android SharedPreferences否则在编译 JS Target 时会找不到对应产物。官方文档明确指出并不是所有 Kotlin 库都支持 JavaScript Target只有包含 Kotlin/JS 产物的依赖才能用于 JS 编译。因此KMP 模块中应该这样划分commonMain → 只依赖真正跨平台的库 androidMain → Android 专属依赖 iosMain → iOS 专属依赖 jsMain → 浏览器和 JavaScript 专属依赖十二、使用JsExport暴露 API仅仅写class SharedSdk并不能保证 Vue 能够通过稳定、清晰的名称直接调用。需要使用JsExport例如在commonMain中创建sharedLogic/src/commonMain/kotlin/ └── web └── SharedSdk.kt代码如下package com.sqx.shared.web import kotlin.js.JsExport JsExport class ValidationResult( val valid: Boolean, val message: String ) JsExport class SharedSdk { fun validatePhone(phone: String): ValidationResult { val normalizedPhone phone.trim() val valid normalizedPhone.length 11 normalizedPhone.all { it.isDigit() } return if (valid) { ValidationResult( valid true, message 手机号格式正确 ) } else { ValidationResult( valid false, message 请输入11位手机号 ) } } fun parseQrCode(rawValue: String): String { return rawValue .trim() .removePrefix(QR:) } fun calculateMaintenanceStatus( remainingDays: Int ): String { return when { remainingDays 0 - OVERDUE remainingDays 7 - EXPIRING_SOON else - NORMAL } } }这里的JsExport可以理解成将这个 Kotlin 类作为公共 JavaScript API 导出。JsExport可以写在 KMP 的commonMain中它只会在编译 JavaScript Target 时产生导出效果不会影响 Android 和 iOS 的正常调用。十三、为什么建议增加一个SharedSdk门面假设sharedLogic内部有Repository UseCase Ktor Client ResultT StateFlow sealed class 缓存实现 数据库实现不建议把这些内部结构全部暴露给 Vue。错误做法Vue ↓ 直接调用 Repository ↓ 直接订阅 Flow ↓ 直接处理 Kotlin Result ↓ 直接感知各种内部类型这样会导致KMP 内部实现和 Vue 强耦合 Kotlin 类型很难映射到 TypeScript 后续重构影响前端 生成的 API 不够友好更合理的结构是Vue ↓ SharedSdk ↓ UseCase ↓ Repository ↓ 网络、缓存和业务实现也就是说对外只暴露一层稳定的 JS 门面JsExport class SharedSdk而内部仍然可以自由使用Repository UseCase DTO Mapper Ktor 协程Vue 不需要知道内部实现。十四、哪些类型适合直接暴露对 JavaScript、TypeScript 最友好的类型包括String Boolean Int Double 普通导出类 数组 可空类型例如JsExport class DeviceInfo( val id: String, val name: String, val online: Boolean )Vue 使用时比较直观const device sdk.getDevice() console.log(device.id) console.log(device.name) console.log(device.online)不建议直接暴露的类型例如FlowT StateFlowT ResultT 复杂泛型 平台类型 内部 Repository Kotlin 特有集合嵌套 未经设计的 sealed class 层级Kotlin 集合在 JavaScript 中并不总是直接表现为原生 JavaScriptArray、Map和Set。例如当前 Kotlin/JS 会将List、Map、Set映射成相应的 Kotlin 集合包装类型并提供转换视图。因此对外 API 最好做一次转换// 内部可以使用复杂类型 internal fun loadDevices(): ListDevice // 对外转换成更适合 JS 的结构 JsExport fun getDeviceNames(): ArrayString { return loadDevices() .map { it.name } .toTypedArray() }设计原则是KMP 内部可以保持 Kotlin 风格对 Vue 暴露的边界应该尽量符合 JavaScript 风格。十五、执行构建Gradle 配置和导出类完成后可以先执行./gradlew :sharedLogic:clean :sharedLogic:build这里使用通用的build任务避免不同 Kotlin Gradle 插件版本下具体任务名称略有区别。构建完成后产物通常位于类似目录sharedLogic/build/dist/js/productionLibrary/可能包含productionLibrary/ ├── package.json ├── sharedLogic.js ├── sharedLogic.d.ts └── 相关运行依赖Kotlin/JS 的默认发布目录遵循build/dist/targetName/binaryName而生成的未经过 Webpack 合并的 JavaScript 和.d.ts文件也可能出现在build/js/packages/moduleName/kotlin/中。具体目录会受到 Kotlin 插件版本、Target 名称和构建任务影响。如果没有找到产物可以查看当前模块的 Gradle 任务./gradlew :sharedLogic:tasks --all然后搜索Library Distribution Production Js十六、检查生成的 TypeScript 声明打开生成的sharedLogic.d.ts大概可以看到export class ValidationResult { constructor( valid: boolean, message: string ) readonly valid: boolean readonly message: string } export class SharedSdk { constructor() validatePhone(phone: string): ValidationResult parseQrCode(rawValue: string): string calculateMaintenanceStatus( remainingDays: number ): string }如果.d.ts几乎是空的优先检查1. 是否配置了 generateTypeScriptDefinitions() 2. 是否使用了 binaries.library() 3. 类和函数是否加了 JsExport 4. 构建的是否为生产 Library 5. 导出方法是否使用了不支持的参数类型只有真正导出的声明才会进入对外 TypeScript 类型文件。十七、Vue 项目如何安装本地 JS 库假设项目结构是project-root ├── sharedLogic └── web-vue先构建./gradlew :sharedLogic:build然后进入 Vue 项目cd web-vue安装本地生成的包npm install ../sharedLogic/build/dist/js/productionLibrary也可以在 Vue 的package.json中声明{ dependencies: { shared-logic: file:../sharedLogic/build/dist/js/productionLibrary } }npm 支持将包含package.json的本地目录作为依赖安装本地路径依赖适合联调和测试正式发布则通常使用 npm Registry 或公司私有 npm 仓库。需要注意Vue 中实际使用的 import 名称以生成目录中package.json的name字段为准。例如生成的package.json是{ name: shared-logic, version: 0.1.0 }Vue 中就应该写import { SharedSdk } from shared-logic而不是根据 Gradle 模块名猜测。十八、在 Vue 中调用 SharedSdk例如 Vue 3 页面script setup langts import { ref } from vue import { SharedSdk, ValidationResult } from shared-logic const sdk new SharedSdk() const phone ref() const message ref() const valid ref(false) function validatePhone() { const result: ValidationResult sdk.validatePhone(phone.value) valid.value result.valid message.value result.message } /script template div classphone-form input v-modelphone placeholder请输入手机号 / button clickvalidatePhone 校验手机号 /button p 校验结果{{ message }} /p /div /template此时执行链路是用户点击 Vue 按钮 ↓ Vue 调用 SharedSdk ↓ 执行 commonMain 中的 Kotlin 逻辑 ↓ 返回 ValidationResult ↓ Vue 更新页面这里的业务规则只写了一份Android 调用同一份 Kotlin 逻辑 iOS 调用同一份 Kotlin 逻辑 Vue 调用编译后的 JavaScript十九、Vue 页面最终如何打包KMP 生成的 JS 库并不是最终网站。完整流程是sharedLogic Kotlin 源码 ↓ Gradle 构建 ↓ shared-logic JS/TS 库 ↓ Vue npm install ↓ Vue import SharedSdk ↓ npm run build ↓ Vue dist最终部署的仍然是 Vue 的web-vue/dist/ ├── index.html ├── assets │ ├── index.js │ └── index.css └── 其他资源KMP 生成的逻辑代码会被 Vite 打包进 Vue 的最终 JavaScript 产物中。所以要分清两个构建过程Gradle → 负责把 Kotlin sharedLogic 构建成 JS 库 Vite → 负责把 Vue 页面和 JS 库一起构建成网站它们是前后衔接关系不是互相替代关系。二十、Gradle、npm 和 Vite 分别负责什么Gradle负责编译 Kotlin 处理 KMP Source Set 处理 Kotlin 依赖 生成 JavaScript 生成 TypeScript 声明 生成 npm 包结构Kotlin/JS 项目使用 Gradle 管理构建同时会在后台处理 npm 依赖并生成package.json。npm负责安装 shared-logic 管理 Vue 项目依赖 管理 package.json 管理 node_modulesVite负责编译 Vue 文件 处理 HTML 和 CSS 打包 Vue 组件 合并前端依赖 生成最终 dist所以整个链路是Gradle → 生成 shared-logic npm → 把 shared-logic 安装进 Vue Vite → 把 Vue 和 shared-logic 一起打包二十一、异步接口和suspend怎么处理实际项目中sharedLogic很可能包含suspend fun login() suspend fun loadDevices() suspend fun requestData()Kotlin 2.3.0 开始支持实验性的suspend函数直接导出给 JavaScript。启用后JavaScript 侧可以把它当成异步函数和Promise使用但目前仍需开启对应编译选项。Gradle 中增加kotlin { compilerOptions { freeCompilerArgs.add( -Xenable-suspend-function-exporting ) } }然后可以导出JsExport class LoginSdk { suspend fun login( username: String, password: String ): LoginResult { return loginUseCase.execute( username username, password password ) } }Vue 中的调用形式大致是const result await loginSdk.login( username, password )不过在企业项目中更建议把异步接口也放在专门的 Web 门面中不要让 Vue 直接感知CoroutineScope Flow Job Dispatcher Repository也就是说Vue 只应该看到普通同步方法 Promise 风格异步方法 JavaScript 友好的 DTO 清晰的错误对象二十二、不要直接把Flow暴露给 Vue例如 KMP 内部可能有val uiState: StateFlowDeviceState不要直接设计成Vue 订阅 Kotlin StateFlow虽然理论上可以继续做桥接但会让前端和 Kotlin 协程模型强绑定。更合理的方式有两种。方式一Vue 自己管理状态Vue ↓ 调用 SharedSdk 的方法 ↓ 获得结果 ↓ 存入 Pinia 或 ref方式二jsMain 做事件回调桥接Kotlin 对外暴露fun observeDeviceStatus( callback: (String) - Unit )内部再把Flow转换成回调。这样 Vue 不需要理解StateFlow collect CoroutineScope Job共享的是业务能力而不是强行把 Kotlin 的全部编程模型暴露给前端。二十三、涉及平台能力怎么办假设commonMain中需要保存 Token。可以定义interface TokenStorage { fun saveToken(token: String) fun getToken(): String? }Android 使用SharedPreferences DataStoreiOS 使用NSUserDefaults KeychainWeb 使用localStorage sessionStorage IndexedDB项目结构可以是commonMain └── TokenStorage 接口和业务逻辑 androidMain └── AndroidTokenStorage iosMain └── IosTokenStorage jsMain └── WebTokenStorage所以增加 JS Target 后并不是所有平台能力都自动跨端了。正确思路仍然是commonMain 定义抽象 androidMain 实现 Android iosMain 实现 iOS jsMain 实现浏览器二十四、是否必须发布到 npm不必须。开发阶段可以直接使用本地路径npm install ../sharedLogic/build/dist/js/productionLibrary适合本地联调 同一仓库开发 快速验证 暂时不搭建私有 npm正式项目则可以发布到npm 公共仓库 公司私有 npm Registry GitHub Packages 其他兼容 npm 的制品仓库官方当前提供了完整的 KMP JavaScript 库 npm 发布教程并使用 npm 发布插件配置包名、版本、认证信息和package.json元数据。一个简化的发布配置形式类似plugins { alias(libs.plugins.kotlinMultiplatform) kotlin(npm-publish) version 当前适配版本 } npmPublish { packages { named(js) { packageName shared-logic version 0.1.0 } } }发布以后Vue 项目可以直接npm install shared-logic然后import { SharedSdk } from shared-logic二十五、同一个仓库还是两个仓库两种方式都可以。方式一放在同一个仓库project ├── sharedLogic ├── androidApp ├── iosApp └── web-vue优点修改业务逻辑后方便立即联调 版本统一 提交记录集中 适合小团队但是它仍然有两套构建体系KMP 部分 → Gradle Vue 部分 → npm Vite这很正常。方式二分成两个仓库kmp-shared-sdk └── 发布 shared-logic npm 包 web-project └── npm install shared-logic优点移动端和 Web 团队独立开发 版本边界清晰 适合 SDK 化管理 适合多个 Web 项目复用这时 KMP 项目每次发布shared-logic 1.0.0 shared-logic 1.1.0 shared-logic 2.0.0Vue 项目按版本依赖{ dependencies: { shared-logic: ^1.1.0 } }二十六、常见问题排查问题一生成了 JS但 Vue 找不到类检查 Kotlin 类是否加了JsExport没有导出的声明不会以预期的公共 API 形式出现在 JavaScript 和 TypeScript 中。问题二生成了.d.ts但是内容为空检查是否调用 generateTypeScriptDefinitions() 是否有 JsExport 声明 是否构建了生产 Library 导出类型是否受支持问题三Vue import 的包名不对不要只看outputModuleName应打开产物中的package.json查看{ name: ... }Vue 的 import 名称以这个字段为准。问题四commonMain 原本能编译增加 js 后报错通常是某个依赖只支持 Android、JVM 或 Native没有 Kotlin/JS 产物。检查commonMain.dependencies把平台专属依赖移动到androidMain iosMain jsMain官方文档也强调只有提供 Kotlin/JS 产物的库才能参与 JavaScript Target 编译。问题五Vue 能导入但方法名很奇怪Kotlin 为了处理重载和签名冲突部分声明可能发生名称修饰。可以通过JsName(validatePhone)指定更稳定的 JavaScript 名称。Kotlin 官方文档说明JsName可以用于控制生成的 JavaScript 名称尤其适合处理重载带来的名称变化。例如JsExport class SharedSdk { JsName(validatePhone) fun validatePhoneValue( phone: String ): Boolean { return phone.length 11 } }二十七、完整关系图最终项目链路可以整理成commonMain │ ┌────────────────┼────────────────┐ │ │ │ Android 编译 iOS 编译 Kotlin/JS 编译 │ │ │ Android App iOS App shared-logic │ ↓ Vue import │ ↓ Vite 打包 │ ↓ dist也就是说Android 和 iOS → 可以共享业务逻辑和 Compose UI Android、iOS 和 Web → 共享 commonMain 业务逻辑 Vue → 继续负责 Web 页面二十八、最终总结把sharedLogic提供给 Vue 使用需要完成四件事。第一步增加 Kotlin/JS Targetjs { browser() }表示把 Kotlin 编译成浏览器使用的 JavaScript第二步声明它是一个库binaries.library()表示不是完整网站 而是供 Vue 调用的 JavaScript 库第三步导出 TypeScript APIgenerateTypeScriptDefinitions()配合JsExport生成JavaScript 实现 TypeScript .d.ts 类型声明第四步Vue 安装并调用npm install sharedLogic生成目录Vue 中import { SharedSdk } from shared-logic const sdk new SharedSdk()完整链路commonMain ↓ Kotlin/JS ↓ JS d.ts ↓ Vue import ↓ Vite build ↓ dist因此这条路线最准确的架构描述是Android 和 iOS 可以使用 KMP/CMP 共享业务逻辑和 UIWeb 继续使用 Vue只复用sharedLogic中真正跨平台的业务能力。这不是脱离了 KMP 项目也不是又开发了一套后端。它只是让Gradle 构建 Kotlin 业务库 npm 管理前端依赖 Vite 构建最终 Web 页面三者各自承担自己最擅长的工作。