Kilua: просим Kotlin сделать вид, что он React

от автора

Время от времени в 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 сам реагирует на изменения:

@Composablefun 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-программирование:

@Composableprivate 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(): Booleanexpect 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 будет логичным шагом.

ссылка на оригинал статьи https://habr.com/ru/articles/1046505/