我们的技术栈选型在初期看起来颇为理想:使用 Kotlin Multiplatform (KMP) 编写核心业务逻辑与数据模型,编译为 JavaScript 模块,供前端的 React + MobX 应用消费。这个决策的目标很明确——复用代码,保证跨平台逻辑的一致性。然而,当第一个原型跑起来时,一个巨大的开发效率鸿沟暴露无遗。
任何对 .kt
文件的修改,都必须经过一个迟钝且完全手动的流程:
- 切换到终端,运行
./gradlew :shared:jsBrowserDevelopmentWebpack
。 - 等待 Gradle 守护进程唤醒、配置、编译,最后生成 JavaScript 输出。
- 切换回另一个终端,重启
webpack-dev-server
以加载新的 KMP 模块。 - 等待 Webpack 重新打包,浏览器刷新。
整个反馈周期轻易超过30秒。这在现代前端开发中是完全无法接受的。前端工程师习惯了亚秒级的热模块替换 (HMR),而 KMP 的引入,让我们的开发体验倒退了十年。问题核心在于,两个独立的构建系统——Gradle 和 Webpack——彼此毫无感知。
graph TD A[开发者修改 .kt 文件] --> B{手动运行 Gradle 编译}; B --> C{等待 KMP 模块产物生成}; C --> D{手动重启 Webpack Dev Server}; D --> E{等待 Webpack 重新打包}; E --> F[浏览器整页刷新]; style B fill:#f9f,stroke:#333,stroke-width:2px style D fill:#f9f,stroke:#333,stroke-width:2px
初步构想是利用 Gradle 的持续构建模式 (--continuous
)。但这治标不治本。它确实能自动重新编译 Kotlin 代码,但 Webpack 的 watch
机制并不知道 KMP 模块的产物目录发生了变化,更不用说触发 HMR 了。我们需要一个真正的桥梁,让 Webpack 能够“理解”并“驱动”KMP 的构建流程,并将其无缝整合进自己的 HMR 工作流中。
方案定格在开发一个自定义 Webpack 插件。这个插件的职责是:
- 将 Kotlin 源文件目录 (
src/commonMain/kotlin
,src/jsMain/kotlin
) 加入 Webpack 的监听列表。 - 当监听到
.kt
文件变更时,异步触发一次精准的 Gradle 构建任务。 - 确保 Gradle 构建完成后,通知 Webpack 的编译进程,让其能够捡起最新的产物并触发 HMR。
基础项目结构
在深入插件实现之前,先看一下项目的核心配置。
KMP 模块 (shared/build.gradle.kts
):
// shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary) // For Android target, not relevant here but common
}
kotlin {
// ... other targets like jvm, android, ios
js(IR) {
browser {
// This is critical. It configures Gradle to generate JS files
// suitable for consumption by Webpack.
webpackTask {
output.libraryTarget = "commonjs2"
}
binaries.executable()
}
}
sourceSets {
val commonMain by getting {
dependencies {
// Common dependencies
}
}
val jsMain by getting {
dependencies {
// JS-specific dependencies
}
}
}
}
关键在于 webpackTask
的配置,它指示 Gradle 生成一个 commonjs2
格式的模块,这是 Webpack 消费的标准格式之一。编译后的产物会出现在 shared/build/js/packages/shared/kotlin/
目录下。
前端项目 (webpack.config.js
):
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// The path to the KMP module's output.
const kmpModulePath = path.resolve(__dirname, 'shared/build/js/packages/shared/kotlin/shared.js');
module.exports = {
mode: 'development',
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
alias: {
// Create an alias for easier import.
'kmp-shared-module': kmpModulePath,
},
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
],
devServer: {
static: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
hot: true,
},
};
这里我们用 resolve.alias
将 KMP 模块的复杂路径映射成一个易于引用的别名 kmp-shared-module
。
第一版插件:暴力触发构建
我们的第一个尝试是创建一个简单的插件,在 Webpack 启动时执行一次 Gradle 构建。
// KMPWatchPlugin.v1.js
const { exec } = require('child_process');
class KMPWatchPlugin {
constructor(options) {
this.options = options || {};
this.gradleTask = this.options.gradleTask || ':shared:jsBrowserDevelopmentWebpack';
}
apply(compiler) {
compiler.hooks.beforeRun.tapAsync('KMPWatchPlugin', (compiler, callback) => {
console.log('KMPWatchPlugin: Running initial Gradle build...');
// A common mistake is using execSync, which blocks the entire Webpack process.
// We must use the async version.
exec(`./gradlew ${this.gradleTask}`, (error, stdout, stderr) => {
if (error) {
console.error(`Gradle build failed: ${error}`);
// Pass the error to Webpack to halt the compilation.
callback(error);
return;
}
console.log(stdout);
console.error(stderr);
console.log('KMPWatchPlugin: Initial Gradle build finished.');
callback();
});
});
}
}
module.exports = KMPWatchPlugin;
在 webpack.config.js
中引入并使用它:
// ...
const KMPWatchPlugin = require('./KMPWatchPlugin.v1.js');
module.exports = {
// ...
plugins: [
// ...
new KMPWatchPlugin({ gradleTask: ':shared:jsBrowserDevelopmentWebpack' }),
],
// ...
};
这个版本解决了启动时手动编译的问题,但它完全没有监听文件变化的能力。任何对 Kotlin 代码的修改依然需要重启 Webpack。
第二版插件:集成 Webpack 的 Watch 机制
为了让 Webpack 能够监听到 Kotlin 文件的变化,我们需要将 KMP 源码目录添加到 Webpack 的监听依赖中。Webpack 插件系统中的 compiler.hooks.afterCompile
是一个合适的时机。
// KMPWatchPlugin.v2.js
const { exec } = require('child_process');
const path = require('path');
class KMPWatchPlugin {
constructor(options) {
this.options = options || {};
this.gradleTask = this.options.gradleTask;
this.kmpSrcDirs = this.options.srcDirs.map(dir => path.resolve(compiler.context, dir));
this.isBuilding = false; // A simple lock to prevent concurrent builds.
}
apply(compiler) {
// Add KMP source directories to Webpack's file dependencies.
compiler.hooks.afterCompile.tap('KMPWatchPlugin', (compilation) => {
this.kmpSrcDirs.forEach(dir => {
compilation.contextDependencies.add(dir);
});
});
// The core logic: hook into the watcher.
compiler.hooks.watchRun.tapAsync('KMPWatchPlugin', (compiler, callback) => {
if (this.isBuilding) {
callback();
return;
}
// `compiler.modifiedFiles` is a Set of changed file paths since the last compilation.
const changedFiles = Array.from(compiler.modifiedFiles || []);
const isKotlinFileChanged = changedFiles.some(file =>
this.kmpSrcDirs.some(dir => file.startsWith(dir))
);
if (!isKotlinFileChanged) {
callback();
return;
}
this.isBuilding = true;
console.log('KMPWatchPlugin: Kotlin source changed. Triggering Gradle build...');
exec(`./gradlew ${this.gradleTask}`, (error, stdout, stderr) => {
this.isBuilding = false;
if (error) {
console.error(`\x1b[31m%s\x1b[0m`, `Gradle build failed: ${error.message}`); // Use colors for errors
console.error(stderr);
// Don't halt Webpack, just log the error. The old bundle is still valid.
// Let the developer see the error and fix it.
callback();
return;
}
console.log(`\x1b[32m%s\x1b[0m`, `KMPWatchPlugin: Gradle build successful.`);
// Don't need to do anything else. Webpack's own watcher will detect
// the changed output file in the `shared/build` directory and trigger
// a new compilation cycle automatically.
callback();
});
});
}
}
module.exports = KMPWatchPlugin;
更新 webpack.config.js
以使用新插件并传入源目录:
// webpack.config.js
// ...
const KMPWatchPlugin = require('./KMPWatchPlugin.v2.js');
module.exports = {
// ...
plugins: [
// ...
new KMPWatchPlugin({
gradleTask: ':shared:jsBrowserDevelopmentWebpack',
srcDirs: ['./shared/src/commonMain/kotlin', './shared/src/jsMain/kotlin'],
}),
],
// ...
};
现在,工作流已经大为改善。修改 .kt
文件并保存,终端会显示 KMP 插件触发了 Gradle 构建,构建成功后,Webpack 会自动检测到 KMP 产物的变化,并进行一次热重载。
graph TD subgraph Webpack Dev Server Process A[开发者修改 .kt 文件] --> B(Webpack Watcher 侦测到变更); B --> C{KMPWatchPlugin}; C --> D[异步执行 ./gradlew ...]; end subgraph Gradle Process D --> E[Gradle 编译 Kotlin 代码]; E --> F[生成新的 JS 产物]; end subgraph Webpack Dev Server Process F -- 文件变更 --> G(Webpack Watcher 侦测到 KMP 产物变更); G --> H[Webpack 重新打包]; H --> I[HMR 推送至浏览器]; end style C fill:#ccf,stroke:#333,stroke-width:2px
数据桥接:将 KMP 模型与 MobX 状态连接
自动化构建只是第一步。真正的挑战在于如何在前端优雅地消费 KMP 模块,并将其融入 MobX 的响应式系统。
假设我们在 KMP 中定义了一个用户模型:
// shared/src/commonMain/kotlin/com/example/model/User.kt
package com.example.model
import kotlin.js.JsExport
@JsExport // This annotation makes the class visible to JavaScript.
data class User(
val id: Long,
var name: String,
var email: String
) {
fun getFormattedInfo(): String {
return "User(id=$id, name='$name')"
}
}
@JsExport
object UserFactory {
fun createDefaultUser(): User {
return User(1L, "John Doe", "[email protected]")
}
}
@JsExport
是关键,它告诉 Kotlin/JS 编译器将这个类和对象暴露出去。
在前端,我们需要一个 MobX Store 来管理这个 User
对象的状态。一个常见的错误是直接将 KMP 对象实例赋值给 store 的属性。
// src/stores/UserStore.ts (Incorrect approach)
import { makeAutoObservable } from 'mobx';
import { User, UserFactory } from 'kmp-shared-module';
class UserStore {
// This will NOT be reactive. MobX cannot observe properties on plain JS objects
// that are compiled from Kotlin.
user: User | null = null;
constructor() {
makeAutoObservable(this);
}
loadUser() {
this.user = UserFactory.createDefaultUser();
}
// This mutation will not trigger any reaction in React components.
updateUserName(name: string) {
if (this.user) {
this.user.name = name;
}
}
}
export const userStore = new UserStore();
这里的 user.name = name
只是修改了一个普通 JavaScript 对象的属性。MobX 的响应式系统对此一无所知。
正确的做法是创建一个“适配器”或在 Store 内部将 KMP 数据模型的可观察属性进行“代理”。我们将 KMP 对象视为纯粹的数据载体,而响应式状态由 MobX 在 TypeScript/JavaScript 层完全接管。
// src/stores/UserStore.ts (Correct, production-grade approach)
import { makeAutoObservable, runInAction } from 'mobx';
// Import the TYPE, not the implementation, for better separation.
import type { User } from 'kmp-shared-module';
import { UserFactory } from 'kmp-shared-module';
// A dedicated logger for store actions for better traceability
const log = (message: string, ...args: any[]) => console.log(`[UserStore] ${message}`, ...args);
class UserStore {
userId: number | null = null;
userName: string = '';
userEmail: string = '';
isLoading: boolean = false;
private kmpUserInstance: User | null = null;
constructor() {
// We explicitly define which properties are observable, computed, or actions.
// This is more robust than makeAutoObservable for complex stores.
makeAutoObservable(this, {}, { autoBind: true });
}
// Action to load user data, wrapping the KMP factory call.
async loadUser() {
log('Loading user...');
this.isLoading = true;
try {
// Simulate an async operation
await new Promise(resolve => setTimeout(resolve, 500));
const userFromKmp = UserFactory.createDefaultUser();
// All state mutations should be wrapped in `runInAction`
// especially after an `await` call.
runInAction(() => {
this.syncWithKmpInstance(userFromKmp);
this.isLoading = false;
log('User loaded successfully.', this.kmpUserInstance);
});
} catch (error) {
runInAction(() => {
this.isLoading = false;
// Proper error handling is crucial.
log('Failed to load user.', error);
});
}
}
// Action to update user's name. It updates MobX state first,
// then synchronizes back to the KMP instance.
updateUserName(newName: string) {
if (!this.kmpUserInstance) {
log('Cannot update name, user not loaded.');
return;
}
log(`Updating user name to "${newName}"`);
this.userName = newName;
this.kmpUserInstance.name = newName; // Update the underlying KMP object
}
// This method allows calling a function directly on the KMP object.
getFormattedInfo(): string {
if (this.kmpUserInstance) {
// We can still leverage the business logic defined in Kotlin.
return this.kmpUserInstance.getFormattedInfo();
}
return "No user loaded";
}
// Private helper to sync state from KMP object to MobX observables.
private syncWithKmpInstance(kmpInstance: User) {
this.kmpUserInstance = kmpInstance;
this.userId = kmpInstance.id;
this.userName = kmpInstance.name;
this.userEmail = kmpInstance.email;
}
}
export const userStore = new UserStore();
这个版本的 UserStore
将 KMP 的 User
对象视为一个内部的、非响应式的数据源。Store 自身维护一组 MobX 的 observable 属性 (userId
, userName
等),并通过 syncWithKmpInstance
方法将 KMP 对象的数据同步到这些 observable 属性上。任何状态变更都通过 MobX 的 action
进行,这保证了 React 组件能够正确地重新渲染。
在 React 组件中消费
现在,React 组件可以像消费任何普通 MobX store 一样消费 userStore
。
// src/components/UserProfile.tsx
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { userStore } from '../stores/UserStore';
export const UserProfile = observer(() => {
useEffect(() => {
userStore.loadUser();
}, []);
if (userStore.isLoading) {
return <div>Loading profile...</div>;
}
if (!userStore.userId) {
return <div>User not found.</div>;
}
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
userStore.updateUserName(e.target.value);
};
return (
<div>
<h1>User Profile</h1>
<p>ID: {userStore.userId}</p>
<div>
<label htmlFor="name">Name: </label>
<input
id="name"
type="text"
value={userStore.userName}
onChange={handleNameChange}
/>
</div>
<p>Email: {userStore.userEmail}</p>
<hr />
<p>Info from KMP method: <code>{userStore.getFormattedInfo()}</code></p>
</div>
);
});
这个组件完全不知道 User
数据的源头是一个 Kotlin Multiplatform 模块。它只与 userStore
交互,状态管理的边界非常清晰。当你在输入框中修改名字时,updateUserName
action 会被调用,MobX 驱动组件重新渲染,整个流程流畅且高效。
方案的局限性与未来展望
我们建立的这套工作流,虽然极大地提升了开发效率,但并非没有权衡。
首先,KMPWatchPlugin
依赖于通过 child_process
调用 Gradle。这会带来一定的进程启动开销。对于非常频繁的修改,这个延迟虽然比手动操作好得多,但仍不如原生 TypeScript 的 HMR 那么快。一个更激进的优化方向是探索使用 Gradle Tooling API,在 Webpack 插件进程中与一个长驻的 Gradle 守护进程直接通信,从而消除重复的进程启动开销。
其次,Kotlin 到 TypeScript 的类型映射并非完美。虽然 KMP 会生成 .d.ts
类型定义文件,但对于一些 Kotlin 特有的语言特性,如 sealed class
、复杂的泛型或者某些标准库类型,转换后的 TypeScript 类型可能并不符合直觉。这要求团队在 KMP 与前端的接口层设计上投入更多精力,保持接口的简洁与类型友好。
最后,此方案的错误处理机制相对基础。Gradle 构建失败时,插件只是将错误打印到控制台。在大型团队中,可能需要更完善的错误上报机制,例如通过 Webpack 的 stats
对象将错误信息更明显地展示在开发服务器的覆盖层上。这需要对 Webpack 的 compilation
和 stats
对象有更深入的理解和操作。