Время от времени в 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 будет логичным шагом.