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.
| Подход | KVision | Kilua |
|---|---|---|
| Модель UI | Объектно-ориентированные компоненты | @Composable функции |
| Runtime | Kotlin/JS UI-фреймворк | Compose Multiplatform Runtime |
| Цели сборки | Kotlin/JS | Kotlin/JS и Kotlin/Wasm |
| Рендеринг | HTML DOM | HTML DOM |
На момент написания примера актуальная версия фреймворка — 0.0.34: проект уже вполне рабочий, но активная разработка еще
идет.
Почему не просто KVision 10? Тут можно только осторожно интерпретировать, но причина выглядит довольно земной. Если у вас был объектный Kotlin/JS-фреймворк, а вы хотите перейти к Compose-модели, Wasm, SSR и новому API — это уже не косметический ремонт. Новое имя в такой ситуации даже полезно: меньше иллюзии, что миграция будет состоять из трех импортов и молитвы.
Из заметных фич Kilua на сайте сейчас выделяются готовые компоненты, поддержка Bootstrap и Tailwind, router, HTTP client, SSR, статический export и Kilua RPC.
Kilua RPC и gRPC - это не одно и то же
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.ktindex.html почти пустой. Он только подключает CSS, кладет корневой элемент и загружает bundle:
<!doctype html>
<html lang="ru">
<body>
<div id="root"></div>
<script src="main.bundle.js"></script>
</body>
</html>Точка входа в 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 и интеграцией с браузером.
- 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 будет логичным шагом.