Время от времени в Kotlin-мире появляется новый виток надежды: вдруг web-frontend можно писать на привычном языке. Обычно такие попытки заканчиваются где-то между “интересно” и “давайте все-таки сделаем на React/Vue”. Но иногда маленький энтузиаст в голове все-таки хочет потыкать палочкой новую штуку. Так я и добрался до Kilua — нового web-фреймворка для Kotlin, который вырос рядом с KVision, но пошел в сторону Compose-подхода. С недавних пор он включен в список рекомендаций Kotlin/Js фреймворков от JetBrains, поэтому его рассмотрение особенно актуально.
В качестве полигона сделаем небольшое CRUD-приложение для управления домашней аптечкой: лекарства, места хранения, теги, сроки годности и сканирование штрихкода камерой. Ничего космического, но достаточно живо, чтобы посмотреть основные возможности. Полный код лежит в репозитории на GitLab.
Что вообще такое Kilua
Kilua — это open source web-фреймворк для Kotlin. Он использует Compose Multiplatform Runtime (не путайте с Jetpack Compose для Android или Compose Web, который рисует UI через canvas/Skia). Kilua рендерит обычный HTML DOM: на странице в итоге живут нормальные div, button, input, CSS и браузерные события. Если собираем JS target — Kotlin-код буквально превращается в JavaScript bundle.
Предшественником Kilua был KVision, фреймворк развивает тот же разработчик. KVision более старый объектно-ориентированный фреймворк для Kotlin/JS: компоненты, биндинги, UI из Kotlin-кода, интеграции с backend. Kilua выглядит как попытка сделать следующий заход уже с современным Compose runtime: @Composable функции, remember, mutableStateOf, корутины, возможность собираться и в Kotlin/JS, и в Kotlin/Wasm.
На момент написания примера актуальная версия фреймворка — 0.0.34: проект уже вполне рабочий, но активная разработка еще идет.
Почему не просто KVision 10? Тут можно только осторожно интерпретировать, но причина выглядит довольно земной. Если у вас был объектный Kotlin/JS-фреймворк, а вы хотите перейти к Compose-модели, Wasm, SSR и новому API — это уже не косметический ремонт. Новое имя в такой ситуации даже полезно: меньше иллюзии, что миграция будет состоять из трех импортов и молитвы.
Из заметных фич Kilua на сайте сейчас выделяются готовые компоненты, поддержка Bootstrap и Tailwind, router, HTTP client, SSR, статический export и Kilua RPC. Последний особенно интересен для fullstack Kotlin: можно описывать контракты в общем коде и связывать frontend с backend на Ktor, Spring Boot, Micronaut, Javalin, Jooby или Vert.x. Совместимость с gRPC в документации не заявлена: Kilua RPC — отдельная Kotlin-first RPC библиотека, а не gRPC transport поверх .proto, HTTP/2 и protobuf. Если в проекте уже есть gRPC-контракты, их придется интегрировать отдельно. В текущем примере RPC не используется: там обычный REST через fetch, чтобы не усложнять.
Зачем писать frontend на Kotlin?
Самый честный ответ: не всегда нужно.
Если команда уверенно пишет на React/Vue/Svelte, у нее уже есть дизайн-система, Storybook, тесты, CI/CD и привычные инструменты, то приходить туда с Kotlin-фреймворком в руках надо очень аккуратно. Мир frontend — это не только язык, но и экосистема, browser API, CSS, accessibility, сборка, линтеры, пакеты, devtools и соседний чат, где кто-то уже третий час спорит про z-index. Приносить сюда Kotlin означает еще один (возможно лишний) промежуточный шаг, в котором что-то может пойти не так.
Но у Kotlin на фронте все же есть свой смысл. Например:
небольшой внутренний инструмент для Kotlin-команды;
pet project, где хочется один язык и знакомый Gradle;
fullstack-приложение с общими моделями, сериализацией и валидацией;
команда, которой ближе Compose-мышление, чем классический JS-фреймворк;
желание потрогать Kotlin/Wasm без полного ухода в экспериментальную лабораторию.
Kilua не отменяет HTML, CSS и JavaScript. Это важный момент. Код на Kilua часто выглядит как Kotlin-версия HTML+JS: vPanel, div, text, className, onInput. Да, это Kotlin. Но вам все равно надо понимать, как работает input, почему CSS поехал на мобильном экране и почему событие случилось не тогда, когда вы морально были к нему готовы.
Если хочется совсем не думать про браузер, ближе будет Vaadin Flow: там UI живет в основном на сервере, вы собираете приложение из Java-компонентов, а Vaadin синхронизирует это с браузером. Цена другая: больше завязки на сервер, состояние сессии, сетевое взаимодействие. Kilua — это все-таки клиентское приложение. Просто написанное на Kotlin и собранное в браузерный bundle.
Пробуем Kilua в деле
Официальная документация предлагает два пути: поставить Kilua Project Wizard в IntelliJ IDEA или скопировать template project. Если в проекте уже есть backend-модуль, можно просто положить frontend рядом. Это позволит на этапе сборки положить собранный js-bundle прямо в static ресурсы backend, получив единое приложение.
Сам frontend-модуль использует Kotlin Multiplatform, Compose compiler/plugin и Kilua:
plugins { // Kotlin Multiplatform дает JS/Wasm targets. kotlin("multiplatform") version "2.3.21" // Нужен для kotlinx.serialization в DTO и REST-клиенте. kotlin("plugin.serialization") version "2.3.21" // Compose runtime: @Composable, remember, mutableStateOf. id("org.jetbrains.compose") version "1.11.0" id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" // Сам Kilua и его Gradle-задачи. id("dev.kilua") version "0.0.34" } kotlin { js(IR) { useEsModules() browser { commonWebpackConfig { cssSupport { enabled = true } outputFileName = "main.bundle.js" sourceMaps = false } } binaries.executable() compilerOptions { target.set("es2015") } } sourceSets { commonMain.dependencies { implementation("dev.kilua:kilua:0.0.34") implementation("dev.kilua:kilua-bootstrap:0.0.34") implementation("dev.kilua:kilua-bootstrap-icons:0.0.34") } } }
Для разработки можно запускать JS target командой ./gradlew -t :view-frontend:jsBrowserDevelopmentRun. После старта dev-сервер обычно доступен на http://localhost:3000.
Для production-сборки достаточно ./gradlew :view-frontend:jsBrowserDistribution.
Результат появляется в view-frontend/build/dist/js/productionExecutable: index.html, app.css, main.bundle.js, шрифты и прочие ресурсы. В моем случае main.bundle.js получился около 1.6 MB. Для маленького CRUD это не “вау, как компактно”, но и не повод сразу звонить в комитет по чрезвычайным ситуациям.
Отдельная бытовая деталь: Kotlin/JS все равно требует Node/npm — это нужно учитывать в CI/CD. В проекте используется kotlin-js-store/package-lock.json, а после изменения frontend-зависимостей или версии Kotlin/JS-плагина нужно обновлять lock-файл командой ./gradlew kotlinUpgradePackageLock (ручные правки или напрямую через npm просто сломают билд).
Скелет приложения
Для небольшого SPA можно использовать примерно такую структуру:
view-frontend/ src/commonMain/ kotlin/com/example/view/ App.kt model/Models.kt api/MedicineApi.kt store/AppStore.kt ui/Screens.kt platform/Platform.kt resources/ index.html app.css src/jsMain/ kotlin/com/example/view/platform/Platform.js.kt
index.html почти пустой. Он только подключает CSS, кладет корневой элемент и загружает bundle:
... <body> <div id="root"></div> <script src="main.bundle.js"></script> </body> ...
Точка входа в Kilua тоже довольно компактная:
class MedicineFrontend : Application() { override fun start() { root("root") { MedicineApp() } } } fun main() { startApplication( ::MedicineFrontend, BootstrapModule, BootstrapCssModule, BootstrapIconsModule, CoreModule, ) }
Здесь root("root") цепляется к div id="root" из HTML, а дальше начинается обычный Compose-подход: @Composable функции, состояние, перерисовка при изменении state.
Запросы к backend
Внутри приложения есть несколько сущностей: лекарство, место хранения и тег. Модели лежат в commonMain, потому что frontend у нас общий для потенциальных JS/Wasm targets. В реальном fullstack-проекте часть этих DTO можно было бы вынести в общий модуль между backend и frontend. А если подключить Kilua RPC, то можно пойти дальше и не писать ручной REST-клиент. Но для первого знакомства обычный fetch даже полезнее: проще понять, что происходит.
API-слой выглядит примерно так:
class MedicineApi { private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } suspend fun loadMedicines(): List<MedicineDto> { return get("/api/medicines/search") } private suspend inline fun <reified T> get(path: String): T { val response = httpRequest(path) if (!response.successful) error("Ошибка сервера (${response.status})") return json.decodeFromString(response.body) } }
Для JS-only приложения это вполне прямой путь. Если хочется писать один и тот же код под JS и Wasm, придется аккуратнее работать с JsAny, kotlin-wrappers и рекомендациями из разделов Browser APIs и Interoperability with JavaScript в документации Kilua.
Где держать состояние
Для небольшого приложения можно не тащить отдельный state manager. Compose runtime уже дает нормальную модель состояния:
class AppStore( private val api: MedicineApi = MedicineApi(), ) { var medicines by mutableStateOf<List<MedicineDto>>(emptyList()) private set var loading by mutableStateOf(false) private set suspend fun refreshMedicines() { medicines = api.loadMedicines().sortedBy { it.expirationDate } } private suspend fun <T> runLoading(block: suspend () -> T): T { loading = true return try { block() } finally { loading = false } } }
Компонент создает store через remember, дергает initialize() в LaunchedEffect, а дальше UI сам реагирует на изменения:
@Composable fun IComponent.MedicineApp() { val store = remember { AppStore() } var screen by remember { mutableStateOf(AppScreen.Medicines) } LaunchedEffect(Unit) { store.refreshMedicines() } div("app-shell") { AppHeader(screen, store.loading) main("app-main") { when (screen) { AppScreen.Medicines -> MedicinesScreen(store) AppScreen.Locations -> LocationsScreen(store) AppScreen.Tags -> TagsScreen(store) } } BottomNavigation(screen) { screen = it } } }
Верстка
Внутри экрана все похоже на обычное декларативное UI-программирование:
@Composable private fun IComponent.MedicinesScreen(store: AppStore) { var search by remember { mutableStateOf("") } val visibleMedicines = store.medicines.filter { search.isBlank() || it.title.contains(search.trim(), ignoreCase = true) } vPanel(className = "screen-stack") { h2t("Лекарства", "screen-title") text( value = search, type = InputType.Search, placeholder = "Поиск по названию", className = "form-control search-input", ) { onInput { search = value.orEmpty() } } if (visibleMedicines.isEmpty()) { EmptyState("Ничего не найдено", "bi bi-search") } else { vPanel(className = "medicine-list") { visibleMedicines.forEach { medicine -> MedicineCard(medicine) } } } } }
Самое приятное тут — обычный Kotlin DSL: рефакторинг, типы, sealed-классы, extension-функции, корутины, сериализация. Самое отрезвляющее — это все еще верстка: vPanel, hPanel, div, span, className, Bootstrap-классы и CSS никуда не делись.
Форма создания лекарства состоит из обычных Kilua-компонентов: text, date, select, textArea, кнопки, модальное окно. Состояние формы удобно держать отдельным data class, а в нем сделать метод toRequest(), который валидирует обязательные поля и превращает форму в DTO для backend. Сам UI формы получается довольно механическим:
FormField("Название") { text( value = form.title, placeholder = "Например: Парацетамол", required = true, className = "form-control", ) { onInput { form = form.copy(title = value.orEmpty()) } } }
Доступ к браузеру: сканер штрихкодов
Один из полезных тестов для такого фреймворка — попробовать не только кнопки и формы, но и реальный browser API. В аптечном приложении я добавил сканирование штрихкода камерой. Для этого используется BarcodeDetector и navigator.mediaDevices.getUserMedia.
В общем коде оставляем expect-объявления:
data class BarcodeScan( val barcode: String, ) expect fun isBarcodeScannerSupported(): Boolean expect suspend fun scanBarcodeFromCamera(videoElementId: String): BarcodeScan? expect fun stopBarcodeScanner()
А в jsMain лежит JS-реализация:
actual fun isBarcodeScannerSupported(): Boolean { return js( "'BarcodeDetector' in window && " + "!!navigator.mediaDevices && " + "!!navigator.mediaDevices.getUserMedia" ) as Boolean } actual suspend fun scanBarcodeFromCamera(videoElementId: String): BarcodeScan? { val video = js("document.getElementById(videoElementId)") val constraints = js( "({ video: { facingMode: { ideal: 'environment' } }, audio: false })" ) activeStream = js("navigator.mediaDevices.getUserMedia(constraints)") .unsafeCast<Promise<dynamic>>() .await() video.srcObject = activeStream video.play().unsafeCast<Promise<dynamic>>().await() val detector = js( "new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a', 'code_128', 'qr_code'] })" ) while (activeScan) { val codes = detector.detect(video).unsafeCast<Promise<dynamic>>().await() if (codes.length > 0) { stopBarcodeScanner() return BarcodeScan(codes[0].rawValue as String) } delay(350.milliseconds) } return null }
Это место хорошо показывает реальность Kotlin-фронтенда. Пока вы живете внутри компонентов, все выглядит почти уютно. Как только надо поговорить с браузером напрямую — вы снова рядом с JavaScript interop.
Зато в UI эта функция подключается вполне чисто:
div("scanner-preview") { video("scanner-video", "barcode-scanner-video") { attribute("autoplay", "true") attribute("muted", "true") attribute("playsinline", "true") } div("scanner-frame") } bsButton( "Сканировать", "bi bi-upc-scan", disabled = scanning || !isBarcodeScannerSupported(), ) { onClick { scanning = true scanRequest += 1 } }
Дальше в LaunchedEffect можно дождаться результата и положить штрихкод в форму.
Что понравилось
Главное удовольствие — не надо выходить из Kotlin. DTO, enum, extension-функции, корутины, kotlinx.serialization, Gradle-модули — все это остается в привычной зоне. Если вы backend-разработчик на Kotlin, Kilua не выглядит чужим.
Второй плюс — Compose runtime. Не сам Compose UI, а именно модель состояния и @Composable функции. После Android/Compose Multiplatform это ощущается естественно: состояние меняется, UI пересобирается, локальное состояние живет через remember, side effects уходят в LaunchedEffect.
Третий плюс — обычный DOM. Это важно. Canvas-рендеринг хорош для своих задач, но для web-приложений обычные HTML-элементы дают нормальную работу с CSS, accessibility, devtools, SEO и интеграцией с браузером.
И еще понравилось, что Kilua не пытается делать вид, будто мира JavaScript не существует. Есть интероп, есть работа с ресурсами, Bootstrap/Tailwind, RPC и SSR.
Что смущает
Применимость пока не очевидна. Для большинства публичных frontend-проектов React/Vue/Svelte будут прагматичнее: больше экосистема, больше специалистов, больше готовых решений, проще найти ответ на странную ошибку из глубин сборки.
Код на Kilua все равно остается frontend-кодом. Да, синтаксис Kotlin. Но мышление часто такое же: собрать DOM, повесить обработчики, назначить классы, написать CSS, разобраться с browser API. Если человек не знает HTML/CSS/JS, Kilua не телепортирует его сразу в senior frontend. Скорее даст возможность учиться этому из Kotlin-кода, что само по себе неплохо, но чудом не является.
Появляется и отдельный промежуточный слой: Kotlin-код проходит через compiler, Gradle, JS/Wasm-сборку и только потом попадает в браузер. Когда что-то ломается, не всегда сразу понятно, где именно треснуло: в Kotlin DSL, в interop, в generated JavaScript, в source maps, в webpack-обвязке или уже в самом browser API. Это не катастрофа, но дебажить иногда менее прозрачно, чем обычный TypeScript-код, который вы сами же и написали.
CI/CD тоже не становится стерильным JVM-аквариумом. Kotlin/JS тянет npm-зависимости, package lock, webpack/Vite-историю и все сопутствующие радости. Они лучше спрятаны, но в момент поломки все равно выйдут на сцену.
Ну и молодость фреймворка чувствуется. Для pet project, внутренней админки или эксперимента — отлично. Для критичного интерфейса с большой командой я бы пока десять раз подумал. Возможно, даже одиннадцать, если рядом есть frontend-лид с битой.
Вывод
Попробовать Kilua точно стоит. Хотя бы ради того момента, когда ты пишешь @Composable в браузерном приложении, собираешь это Gradle’ом, открываешь DevTools и видишь обычный DOM. В этот момент frontend на Kotlin перестает быть абстрактной идеей из презентации и становится вполне конкретным кодом. Немного странным, но живым.
Если на проекте уже используется KVision, то переход в Kilua будет логичным шагом.
