- PVSM.RU - https://www.pvsm.ru -
Время от времени в Kotlin-мире появляется новый виток надежды: вдруг web-frontend можно писать на привычном языке. Обычно такие попытки заканчиваются где-то между “интересно” и “давайте все-таки сделаем на React/Vue”. Но иногда маленький энтузиаст в голове все-таки хочет потыкать палочкой новую штуку. Так я и добрался до Kilua [1] — нового web-фреймворка для Kotlin, который вырос рядом с KVision [2], но пошел в сторону Compose-подхода. С недавних пор он включен в список рекомендаций Kotlin/Js фреймворков от JetBrains [3], поэтому его рассмотрение особенно актуально.
В качестве полигона сделаем небольшое CRUD-приложение для управления домашней аптечкой: лекарства, места хранения, теги, сроки годности и сканирование штрихкода камерой. Ничего космического, но достаточно живо, чтобы посмотреть основные возможности. Полный код лежит в репозитории на GitLab [4].
Kilua [1] — это 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 [5]. Последний особенно интересен для 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, чтобы не усложнять.
Самый честный ответ: не всегда нужно.
Если команда уверенно пишет на 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 [6]: там UI живет в основном на сервере, вы собираете приложение из Java-компонентов, а Vaadin синхронизирует это с браузером. Цена другая: больше завязки на сервер, состояние сессии, сетевое взаимодействие. Kilua — это все-таки клиентское приложение. Просто написанное на Kotlin и собранное в браузерный bundle.
Официальная документация предлагает два пути: поставить 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.
Внутри приложения есть несколько сущностей: лекарство, место хранения и тег. Модели лежат в 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 [7] и Interoperability with JavaScript [8] в документации 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 будет логичным шагом.
Автор: good_vladik
Источник [10]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/kotlin-multiplatform/453145
Ссылки в тексте:
[1] Kilua: https://kilua.dev/
[2] KVision: https://kvision.io/
[3] список рекомендаций Kotlin/Js фреймворков от JetBrains: https://kotlinlang.org/docs/js-frameworks.html#kilua
[4] репозитории на GitLab: https://gitlab.com/VladKarandashov/SalusTrove
[5] Kilua RPC: https://kilua.gitbook.io/kilua-rpc-guide
[6] Vaadin Flow: https://vaadin.com/docs/latest/flow/what-is-flow
[7] Browser APIs: https://kilua.dev/development-guide/browser-apis
[8] Interoperability with JavaScript: https://kilua.dev/development-guide/interoperability-with-javascript
[9] мышление: http://www.braintools.ru
[10] Источник: https://habr.com/ru/articles/1046505/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1046505
Нажмите здесь для печати.