kjs-box: добавляем ленивые модули, ресурсы с типизацией и модели представления в React-проекты на Kotlin

от автора

Года три назад я захотел сделать для себя небольшой сайт. Ключевое слово здесь — “небольшой”: мне бы хватило одностраничного приложения с минимальной серверной частью. Лезть в TypeScript или тем более в JavaScript, когда любишь Kotlin, желания не было, равно как и не хотелось создавать какие-то типовые функции с нуля. По этим причинам выбор пал на React в сочетании с Kotlin Wrappers.

Ознакомившись с тем, что предлагается по умолчанию в упомянутой комбинации языка и инструментов для разработки, я понял, что не все стандартные решения из неё мне полностью нравятся — в первую очередь:

  1. Невозможность легко создавать подгружаемые по необходимости модули.

  2. Загрузка ресурсов по строковым путям без типизации.

  3. Громоздкий React Redux.

  4. Способы добавления логики для стилей.

По мере решения обозначенных выше моментов у меня стали возникать новые хотелки, а некоторые из моих изначальных реализаций переписывались почти полностью. От первичного плана работ на несколько месяцев процесс затянулся на более чем два года. Сайта в итоге так и нет, но вместо него появился небольшой фреймворк — kjs-box: в этой статье я расскажу о его основных концепциях и функциях.

Немного о подводных камнях, пока мы ещё на берегу. Я не являюсь веб- или в частности React-разработчиком, а решения, предложенные мной в kjs-box, вполне могут оказаться тысячу раз изобретёнными. Прежде чем начать реализовывать ту или иную функцию самому, я достаточно долго искал для неё уже готовые варианты, а при необходимости создавать что-то в незнакомом мне профиле руководствовался общим опытом и здравым смыслом. Поддерживать и развивать проект, к сожалению, не планирую: будет лучше его воспринимать как набор примеров и возможных практик. Теперь, когда все извинения извинены — к сути.

Настраиваем необходимое окружение — подключаемся к GitHub Packages

Для конфигурации и сборки проектов с kjs-box используется Gradle. Все необходимые для этого библиотеки и Gradle-плагины опубликованы в GitHub Packages. Согласен, не самое удобное с точки зрения конечного пользователя решение: для скачивания нужных зависимостей придётся обзавестись соответствующим токеном с разрешением read:packages, инструкции здесь. GitHub Packages был удобен на этапе разработки, чтобы не публиковать десятки различных версий пакетов лишь с небольшими изменениями для проверки. Сейчас же, очевидно, было бы лучше переместить kjs-box в более удобоваримый репозиторий.

GitHub токен с read:packages создан? Тогда можно начать новый Gradle-проект (назовём его demo) и добавить для него нужные репозитории в файле settings.gradle.kts:

pluginManagement {     repositories {         mavenCentral()         maven {             name = "GitHubPackages"             url  = uri("https://maven.pkg.github.com/andrew-k-21-12/kjs-box")             credentials {                 username = "github-username"                 password = "github-access-token"             }         }     } }  dependencyResolutionManagement {     @Suppress("UnstableApiUsage")     repositories {         mavenCentral()         maven {             name = "GitHubPackages"             url  = uri("https://maven.pkg.github.com/andrew-k-21-12/kjs-box")             credentials {                 username = "github-username"                 password = "github-access-token"             }         }     } }

github-username и github-access-token нужно заменить на ваши имя пользователя и токен с read:packages в GitHub соответственно. Теперь всё готово для начала использования kjs-box.

Создаём нужные модули — в том числе ленивые

Под модулями мы будем понимать Gradle-проекты — в том числе вложенные. Иными словами, одна папка с файлом build.gradle.kts внутри — один модуль.

В принципе нас ничего не ограничивает в использовании модулей и при стандартной структуре браузерных JavaScript-проектов в Kotlin Multiplatform. Однако вне зависимости от количества созданных модулей мы получим на выходе по умолчанию только один JavaScript-файл для всего нашего кода. Этот JavaScript-файл может оказаться достаточно большим и будет загружаться целиком при открытии полученного веб-приложения в браузере. Если в веб-приложении предполагаются разделы, в которые пользователь будет попадать только время от времени, неплохо было бы подгружать их только по необходимости.

Запрос такого функционала для ленивой загрузки зависимостей уже имеется. Также есть рабочий прототип на GitHub, однако по крайней мере мне им пользоваться неудобно, потому что создание каждого подгружаемого модуля требует не самых очевидных дополнительных конфигураций. Было бы здорово иметь возможность просто пометить тот или иной модуль как ленивый — и чтобы все нужные для этого манипуляции выполнялись сами автоматически.

В kjs-box мы можем создавать подгружаемые по требованию модули — и, на мой взгляд, достаточно легко: Gradle-плагины берут на себя всю работу, начиная от генерации вспомогательного кода и заканчивая подготовкой финального бандла. Но перед тем, как перейти к конфигурации ленивых зависимостей, нам придётся предварительно рассмотреть два других вида модулей: корневой и точки входа.

Настраиваем корневой модуль

Корневой модуль конфигурируется в файле build.gradle.kts, находящемся на одном уровне с settings.gradle.kts. Все настройки ниже в этом разделе будут задаваться только внутри упомянутого build.gradle.kts в корне проекта.

Единственный плагин, который нам достаточно применить — io.github.andrew-k-21-12.kjs-box.frontend-main:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-main") version "1.0.0" }

Без применения этого плагина ленивые модули, генерация обёрток для ресурсов и некоторые виды деплоя работать не будут. Однако после его задействия мы также получаем:

  1. Набор webpack-конфигураций и JavaScript-зависимостей для создания быстрых development- и минифицированных production-сборок.

  2. Небольшой Service Worker, кэширующий все статические скрипты и ресурсы. В частности index.html кэшируется вне зависимости от того, какой путь используется в браузере для открытия одностраничного приложения. В результате бонусом к экономии трафика добавляется возможность открывать ранее посещённые страницы в офлайне.

  3. Шаблонный index.html с корневым div-элементом для размещения React-приложения.

Если есть желание использовать свой index.html, сделать это можно, указав имя соответствующей замены — файл должен быть размещён в папке src/jsMain/resources корневого модуля, название index.html недопустимо:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-main") version "1.0.0" }  main.customIndexHtmlTemplateFile = "my-index.html"

Обязательно следует установить либо версию, либо переопределённое имя директории для размещения статических скриптов и ресурсов в production-сборке — main.customBundleStaticsDirectory. Можно присвоить и то, и другое — в этом случае заданное имя директории будет иметь приоритет над версией:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-main") version "1.0.0" }  version = "1.0.0"  main.customBundleStaticsDirectory = "release-name"

Наконец, рекомендуется также задать группу проекта — она необходима для генерации имён пакетов у обёрток ресурсов. В общем итоге полученный build.gradle.kts может выглядеть так:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-main") version "1.0.0" }  group   = "org.example" version = "1.0.0"

Создаём точку входа

В приложении может быть множество React-компонентов, однако нам нужно пометить только один из них в качестве запускаемого в первую очередь. Для этой цели в kjs-box есть соответствующий плагин — io.github.andrew-k-21-12.kjs-box.frontend-entry-point.

Создадим новый модуль, чтобы применить этот плагин: я буду использовать название entry, но вместо него можно выбрать и другое произвольное имя. По итогу у нас должна быть добавлена строка include("entry") в файле settings.gradle.kts, а также появи́ться папка entry на одном уровне с ним. Внутри папки — пока что только один новый build.gradle.kts.

Переходим к содержимому entry/build.gradle.kts — оно сводится лишь к применению упомянутого плагина и указанию полного имени (включая пакет) React-компонента, назначенного точкой входа:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-entry-point") version "1.0.0" }  entryPoint.rootComponent = "org.example.demo.entry.App"

Всё, что осталось сделать — это создать org.example.demo.entry.App в entry/src/jsMain/kotlin/org/example/demo/entry/App.kt со следующим примерным кодом:

package org.example.demo.entry  import react.FC import react.dom.html.ReactHTML.p  val App = FC {     p {         +"This is an index page."     } }

Точка входа готова: задать в качестве неё React-компонент, как описано выше — самый простой способ. В этом случае kjs-box берёт на себя добавление нужных зависимостей, инициализацию React-приложения в корневом div-элементе, применение небольшого набора базовых CSS и регистрацию ранее упомянутого кэширующего Service Worker’а.

Тем не менее, если необходима более точная ручная настройка, существует альтернативный подход к конфигурации точки входа. Вместо React-компонента мы можем указать функцию без аргументов, которая запустится в первую очередь — пусть это будет org.example.demo.entry.bootstrap (пакет и имя могут быть произвольными). Код entry/build.gradle.kts в этом случае обновляем так:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-entry-point") version "1.0.0" }  kotlin.sourceSets.jsMain {     dependencies {         implementation(dependencies.platform("org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom:1.0.0-pre.757"))         implementation("org.jetbrains.kotlin-wrappers:kotlin-react")         implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom")     } }  entryPoint.customInitializationFunction = "org.example.demo.entry.bootstrap"

Обращаем внимание, что требуемые зависимости теперь добавлены вручную. Минимальный вариант функции org.example.demo.entry.bootstrap может выглядеть следующим образом:

package org.example.demo.entry  import react.create import react.dom.client.createRoot import web.dom.document   fun bootstrap() {     createRoot(document.getElementById("root")!!)         .render(App.create()) }

Как видите, в коде выше происходит только загрузка React-компонента org.example.demo.entry.App в качестве корневого: Service Worker или какие бы то ни было CSS не подключаются.

Самое время запустить полученный проект и посмотреть на результат. В kjs-box для этой цели используется стандартный набор задач Gradle из Kotlin Multiplatform. Проще всего будет выполнить jsBrowserDevelopmentRun из основного модуля — переходим в корневую папку проекта и командуем:

./gradlew jsBrowserDevelopmentRun     # Linux или macOS .\gradlew.bat jsBrowserDevelopmentRun # Windows PowerShell

В некоторых случаях может предварительно потребоваться запуск kotlinUpgradeYarnLock, если в консоли появляются соответствующие ошибки при сборке.

Добавляем ленивый модуль

Чтобы продемонстрировать создание и работу ленивых модулей, задействуем роуты. Вдобавок к ранее подготовленной индексной странице у нас появится ещё одна, подгружаемая только по открытию её пути в браузере.

Собственно объявление подобного рода модуля предельно тривиально. Я назову его lazy (не забываем дописать include("lazy") в settings.gradle.kts). Содержимое же соответствующего файла lazy/build.gradle.kts будет таким:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-lazy-module") version "1.0.0" }  lazyModule.exportedComponent = "org.example.demo.lazy.LazyPage"

Тут всё просто: применили плагин io.github.andrew-k-21-12.kjs-box.frontend-lazy-module, указали полное имя React-компонента, который станет точкой входа для ленивого модуля. Код непосредственно org.example.demo.lazy.LazyPage можем сделать максимально примитивным, чтобы не усложнять пример:

package org.example.demo.lazy  import react.FC  val LazyPage = FC {     +"This is a lazy page." }

Теперь остаётся лишь интегрировать новоиспеченный модуль в существующую кодовую базу. Стандартные средства добавления зависимостей в Gradle тут не подойдут: они сделают код ленивого модуля частью основного проекта, так что из всех исходников в результате сборки сгенерируется только один монолитный JavaScript-файл. Для верной же интеграции стоит использовать плагин io.github.andrew-k-21-12.kjs-box.frontend-lazy-module-accessors: он подготовит код React-компонентов, подгружающих нужное содержимое динамически.

Инициировать загрузку модуля lazy будем из ранее созданного entry, поэтому обновляем entry/build.gradle.kts:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-entry-point")           version "1.0.0"     id("io.github.andrew-k-21-12.kjs-box.frontend-lazy-module-accessors") version "1.0.0" }  kotlin.sourceSets.jsMain {     kotlin.srcDirs(         lazyModuleAccessors.generateOrGetFor(project(":lazy"))     )     dependencies {         implementation("org.jetbrains.kotlin-wrappers:kotlin-react-router-dom")     } }  entryPoint.rootComponent = "org.example.demo.entry.App"

Два замечания по обновлённому коду:

  1. Для основных исходников (sourceSets.jsMain) подпроекта entry мы добавили генерацию точки входа в lazy. Если попадать в один и тот же ленивый модуль предполагается из нескольких мест, каждое из них будет иметь свою копию соответствующего входного React-компонента. Чтобы не плодить бесполезный дублирующийся код, можно создать отдельный модуль, содержащий только нужные сгенерированные точки входа (разве что придётся сделать для них обёртки с public-видимостью).

  2. Также была подключена зависимость для объявления роутов в React.

Финальный шаг — описываем все страницы приложения, как предполагали изначально. Изменения делаем в entry/src/jsMain/kotlin/org/example/demo/entry/App.kt:

package org.example.demo.entry  import DemoLazyEntryPoint import react.FC import react.Suspense import react.create import react.dom.html.ReactHTML.p import react.router.RouteObject import react.router.RouterProvider import react.router.dom.Link import react.router.dom.createBrowserRouter  val App = FC {     Suspense {         fallback = SuspenseLoadingIndicator.create()         RouterProvider {             router = routes         }     } }  private val SuspenseLoadingIndicator = FC {     +"Loading..." }  private val IndexPage = FC {     p {         +"This is an index page."     }     Link {         to = "/lazy-page"         +"Open lazy page"     } }  private val routes = createBrowserRouter(     arrayOf(         RouteObject(             path    = "/",             element = IndexPage.create()         ),         RouteObject(             path    = "lazy-page",             element = DemoLazyEntryPoint.create()         ),     ) )

Важный нюанс: имя компонента — DemoLazyEntryPoint — генерируется на основе названий корневого проекта — в моём случае это demo — и ленивого модуля. Если у вас использовалось отличное именование, стоит чуть обновить код выше соответствующим образом.

Наконец, можно всё снова запустить (таск jsBrowserDevelopmentRun) и посмотреть, что получилось в итоге. На индексной странице добавилась лишь одна ссылка, однако по нажатию на неё теперь происходит желаемая магия — подгружается дополнительный JavaScript-файлик с содержимым модуля lazy.

Пожалуй, это всё, что касается модулей в kjs-box. Отмечу, что обязательными являются только корневой модуль и модуль точки входа. В приложении можно вполне обойтись и без ленивых модулей: если необходимо обособить какую-то отдельную часть кода, её можно поместить и в обычный Kotlin Multiplatform JavaScript-модуль. Разумеется, в этом случае динамической подгрузки JavaScript’а не будет. В остальном же kjs-box представляет собой надстройку поверх Kotlin Multiplatform и некоторых библиотек из Kotlin Wrappers: каких-то существенных ограничений при работе с ними в kjs-box быть не должно.

Генерируем обёртки для ресурсов

Писать подобный код каждый раз, когда нужна, например, картинка из локальных ресурсов — занятие не самое интересное:

@JsModule("./images/crane.png") @JsNonModule external val сraneImage: String

Конечно, можно выделить все похожие объявления в отдельные файлы или модули и предоставить для доступа к ним удобный интерфейс. Однако делать это придётся всё равно вручную для каждого ресурса. При набирании такого кода автодополнение будет под вопросом: сделаем ошибку в одной буковке имени файла — узнаем об этом уже в рантайме. А ещё неоднозначна ситуация с типизацией: мы не знаем наверняка, что скрывается за тем или иным строковым путём.

На мой взгляд, автоматическая генерация типизированных обёрток хотя бы для основных видов ресурсов ускоряет и упрощает процесс разработки. Сгенерированный а также сопутствующий код может предоставлять базовый функционал под каждый тип. В kjs-box для этого есть отдельный Gradle-плагин — io.github.andrew-k-21-12.kjs-box.frontend-resource-wrappers. Его можно применить ко всем браузерным JavaScript-модулям на основе Kotlin Multiplatform, включая описанные ранее точки входа и ленивые модули из kjs-box:

plugins {     // ...     id("io.github.andrew-k-21-12.kjs-box.frontend-resource-wrappers") version "1.0.0" }

Действие плагина распространяется исключительно на тот модуль, к которому он был применён, однако для его корректной работы есть несколько требований к корневому модулю:

  1. Обязательно использование плагина io.github.andrew-k-21-12.kjs-box.frontend-main.

  2. Необходимо задание группы (group). Она используется для генерации имён пакетов у обёрток ресурсов.

  3. На выбор установлена версия (version) и/или свойство main.customBundleStaticsDirectory. Таким образом указывается расположение статических компонентов — в том числе ресурсов — в собранном бандле.

Чтобы сгенерировать или обновить обёртки ресурсов, нужно пересобрать соответствующий модуль.

Генерация кода доступна для SVG-иконок, основных форматов растровых изображений и шрифтов а также некоторых видов JSON-файлов локализаций. Рассмотрим каждый из перечисленных случаев.

SVG-иконки

Для добавления SVG-иконок в директории с ресурсами модуля создаётся папка icons, внутри которой могут быть вложенные директории: <модуль>/src/jsMain/resources/icons/*.svg. Иконки должны именоваться в kebab-case. Для каждой из них будет сгенерирован соответствующий React-компонент. 

Например, если в проекте с именем demo и группой org.example есть модуль entry с иконкой arrow-right-thin.svg (полный путь — demo/entry/src/jsMain/resources/icons/arrow-right-thin.svg), отобразить её можно так:

package org.example.demo.entry  import org.example.demo.resourcewrappers.icons.entry.ArrowRightThinIcon import react.FC import web.cssom.ClassName  val App = FC {     ArrowRightThinIcon()     // Также есть возможность применить какой-нибудь класс для стилизации иконки:     ArrowRightThinIcon {         className = ClassName("some-class")     } }

В production-сборке все SVG-иконки будут заинлайнены, отдельные файлы для них не сохранятся.

Растровые изображения

Среди поддерживаемых форматов изображений — WebP, PNG, GIF и JPEG. Все картинки размещаем в папке images внутри ресурсов модуля. По аналогии с SVG-иконками можно использовать вложенные директории, а для имён самих изображений обязателен kebab-case.

У кода примера оставим всё ту же комбинацию: проект demo с группой org.example и модулем entry, картинка же будет называться shape-power.png. В отличие от генерации обёрток для SVG-иконок, в случае изображений создаются объекты данных. Отрисовать их можно с помощью React-компонента io.github.andrewk2112.kjsbox.frontend.image.components.Image — он позволяет браузеру выбрать наиболее подходящий растровый формат из всех доступных:

package org.example.demo.entry  import io.github.andrewk2112.kjsbox.frontend.image.components.Image import org.example.demo.resourcewrappers.images.entry.ShapePowerImage import react.FC  val App = FC {     Image(ShapePowerImage, "Альтернативный текст", "some-class") }

Что касается доступных форматов для выбора браузером — в production-сборке из исходного изображения генерируются только два варианта: WebP и PNG. Все картинки упаковываются в собранном бандле в соответствии с модулями, в которых они были изначально расположены.

Шрифты

Работа проверялась только с WOFF2-шрифтами, но вполне возможно, что подойдут и некоторые другие форматы. Файлы шрифтов добавляем в созданную в ресурсах модуля папку fonts, вложенные директории также допустимы. А вот с именованием в этот раз чуть сложнее: название каждого шрифта должно состоять из двух частей, написанных через дефис, и расширения. Первая часть — основное имя в UpperCamelCase, вторая — вариант шрифта с большой буквы, например: Roboto-Regular.woff2. В качестве вариантов, к сожалению, можно использовать только Regular и Light, парсинг других значений не реализован.

Теперь небольшой пример — конфигурацию демонстрационного проекта не меняем: demo / org.example / entry. В качестве же шрифта я положу Roboto-Regular.woff2 в ранее упомянутую папку fonts. После выполнения сборки должен быть сгенерирован объект, расширяющий io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.DynamicStyleSheet. Этот объект содержит набор стилей в соответствии с предоставленными вариантами шрифта. Использовать их можно несколькими способами:

package org.example.demo.entry  import io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.DynamicStyleSheet import io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.NamedRuleSet import kotlinx.css.FontWeight import kotlinx.css.fontWeight import org.example.demo.resourcewrappers.fonts.entry.RobotoFontStyles import react.FC import react.dom.html.ReactHTML.p import web.cssom.ClassName  val App = FC {     p {         // Напрямую в качестве имени класса:         className = ClassName(RobotoFontStyles.regular.name)         +"Параграф с обычным начертанием."     }     p {         className = ClassName(MyFontStyles.bold.name)         +"Параграф с более жирным начертанием."     } }  // При составлении стилей из нескольких наборов правил: private object MyFontStyles : DynamicStyleSheet() {     val bold: NamedRuleSet by css {         +RobotoFontStyles.regular.rules         fontWeight = FontWeight.w600     } }

Все шрифты, как и картинки, упаковываются в собранном бандле в соответствии с модулями, в которых они были изначально расположены.

JSON-локализации

В ресурсах модулей могут содержаться наборы локализационных JSON-файлов. Одна из причин выбора именно JSON-формата — в предпочтении библиотеки i18next для работы с локализациями. Она позволяет подгружать по необходимости только нужные переводы как в зависимости от выбранного языка, так и по принципу модульности.

Использование i18next задаёт несколько правил для организации соответствующих ресурсов, а полный список требований такой:

  1. Все локализации располагаем в папке locales внутри ресурсной директории модуля.

  2. Непосредственно в locales — только папки под нужные переводы, например: ru, en и т. д. Насколько помню, выделение регионов (en-US, en-GB и аналогичные варианты) также возможно.

  3. Внутри директорий ru, en и подобных — собственно локализационные JSON-файлы: обычно по одному файлу на каждый поддерживаемый язык. Имя у этих JSON’ов может быть произвольным: важно лишь, чтобы оно было одинаковым в каждой из папок ru, en и прочих.

  4. Ключи в упомянутых JSON-файлах должны быть идентичными (отличаются только соответствующие им строки с переводами) и написанными в формате lowerCamelCase, вложенность ключей поддерживается.

По сравнению с ранее рассмотренными видами ресурсов, в случае JSON-локализаций kjs-box готовит код с минимальной типизацией. Для всех встреченных в оригинальных JSON’ах ключей фреймворк сгенерирует только свойства с их именами а также создаст строковые константы для подгрузки отдельных локализационных групп.

Пожалуй, увидеть всё на примере будет гораздо понятнее. В проекте с именем demo, группой org.example и точкой входа entry добавляем два локализационных файла: demo/entry/src/jsMain/resources/locales/ru/translation.json и demo/entry/src/jsMain/resources/locales/en/translation.json. Их содержимое соответственно:

{   "helloWorld": "Привет, мир!" }
{   "helloWorld": "Hello, world!" }

Для подгрузки локализаций выше будем использовать небольшую обёртку поверх i18next. Регистрируем соответствующие зависимости у модуля точки входа (demo/entry/build.gradle.kts):

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-entry-point")       version "1.0.0"     id("io.github.andrew-k-21-12.kjs-box.frontend-resource-wrappers") version "1.0.0" }  kotlin.sourceSets.jsMain.get().dependencies {     implementation("io.github.andrew-k-21-12.kjs-box:frontend-localization:1.0.0")     implementation("io.github.andrew-k-21-12.kjs-box:frontend-localization-i18next:1.0.0") }  entryPoint.rootComponent = "org.example.demo.entry.App"

Наконец, добавим минимальный код, демонстрирующий работу со сгенерированными локализационными ключами:

package org.example.demo.entry  import io.github.andrewk2112.kjsbox.frontend.localization.i18next.I18NextLocalizationEngine import org.example.demo.resourcewrappers.locales.entry.TranslationLocalizationKeys import react.FC import react.dom.html.ReactHTML.p  val App = FC {     val localizationEngine = I18NextLocalizationEngine         .getInstance("ru", false)         // Подгружаем набор локализаций для модуля.         .apply { loadLocalizations(TranslationLocalizationKeys.NAMESPACE) }     p {         // Получаем локализованную строку по сгенерированному ключу.         +localizationEngine.getLocalization(TranslationLocalizationKeys.HELLO_WORLD_KEY)     } }

Чтобы проверить работу полученного примера, дописываем в адресной строке у запущенного в браузере приложения либо ?lng=en, либо ?lng=ru.

Отмечу, что в production-сборке локализации упаковываются в отдельные JavaScript’ы, исходные JSON-файлы не копируются. Все неиспользуемые в коде локализационные ключи будут удалены — это в принципе актуально и для всего остального: нигде не задействованные иконки, изображения и шрифты в собранный бандл не включаются.

На этом обзор возможностей по работе с ресурсами в kjs-box подходит к концу. Несмотря на очевидные различия в генерируемом коде, все действия по подготовке ресурсов сводятся лишь к их размещению в соответствующих папках.

Заменяем Redux моделями представления

Мой опыт работы с Redux сводится практически к нулю. Возможно, именно этим объясняется отсутствие у меня симпатии к нему. Также вероятно, что инструментарий, изначально проектируемый для мира JavaScript, вписывается в контекст Kotlin не лучшим образом. Так или иначе, в первую очередь в Redux мне не нравятся громоздкость дополнительных прослоек для работы с ним а также монолитность самого состояния. Последнее, как понял, частично исправляется вложенностью состояний и редюсеров, однако моё отношение к библиотеке это радикально не меняет.

В kjs-box для работы с состояниями я решил использовать модели представления и парадигму Model-View-ViewModel в целом. Model-View-Presenter мне кажется менее адаптируемым к разным подходам обработки UI (имею в виду Unidirectional и Bidirectional Data Flow) за счёт большей привязанности к программным интерфейсам. Применение же исключительно React-хуков, считаю, может приводить к разрастанию компонентов и их тесному связыванию с логикой состояний.

Всё, что нужно для введения моделей представления в kjs-box — это корутины и небольшие расширения, конвертирующие Flow и StateFlow в состояния React. Обновляем конфигурацию ранее созданной точки входа:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-entry-point") version "1.0.0" }  kotlin.sourceSets.jsMain.get().dependencies {     implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.9.0")     implementation("io.github.andrew-k-21-12.utility:coroutines-react:1.0.0") }  entryPoint.rootComponent = "org.example.demo.entry.App"

Копируем код минимальной демонстрации (со множеством упрощений в угоду краткости):

package org.example.demo.entry  import io.github.andrewk2112.utility.coroutines.react.extensions.asReactState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import react.FC import react.dom.html.ReactHTML.button import react.dom.html.ReactHTML.input import react.dom.html.ReactHTML.li import react.dom.html.ReactHTML.ul import react.useRef import react.useState import web.html.HTMLInputElement import web.html.InputType  val App = FC {     val viewModel by useState { ViewModel() }     val uiState   by viewModel.uiState.asReactState()     val inputRef   = useRef<HTMLInputElement>(null)     input {         ref  = inputRef         type = InputType.text     }     button {         onClick = {             viewModel.addTask(inputRef.current?.value!!)         }         +"+"     }     ul {         for (taskDescription in uiState.tasks) {             li {                 +taskDescription             }         }     } }  private class ViewModel {      data class UiState(val tasks: List<String>)      fun addTask(taskDescription: String) {         _uiState.update {             it.copy(tasks = it.tasks + taskDescription)         }     }      val uiState: StateFlow<UiState> by ::_uiState     private val _uiState = MutableStateFlow(UiState(emptyList()))  }

В примере выше я использовал ранее рассматриваемый проект, чтобы не расписывать нужные приготовления с нуля. Однако все утилиты из группы io.github.andrew-k-21-12.utility никоим образом не привязаны к модульной структуре из kjs-box: эти зависимости можно применять к любым подходящим модулям на основе Kotlin Multiplatform.

Что же получилось в итоге в обновлённом проекте? В React-компоненте у нас теперь есть поле ввода текста и маленькая кнопочка с плюсиком. По нажатию на неё из модели представления дёргается метод addTask, который соответствующим образом обновляет состояние UI. Это состояние, в свою очередь, наблюдается в компоненте: каждое обновление uiState приводит к перерисовке всего списка. Повторюсь, что некоторые оптимизации и структурирование кода были опущены, чтобы не усложнять пример.

Вместо заключения отмечу, что при верной организации кодовой базы подобные модели представления помогают отделить независимый код (Interface Adapters и иные абстракции) от платформозависимого (имею в виду Frameworks & Drivers из Чистой архитектуры) и использовать его в общих исходниках для разных целевых платформ из Kotlin Multiplatform.

Задаём статические и динамические стили

При разработке инструментария для работы с таблицами стилей я, скорее, руководствовался собственными пожеланиями, нежели неудобством каких-то из существующих библиотек. Мне, прежде всего, хотелось, чтобы стили:

  1. Не становились неотъемлемой частью React-компонентов.

  2. Могли бы конфигурироваться на основе получаемых из кода аргументов (контекста).

  3. Имели структурированные имена.

Чтобы реализовать всё перечисленное выше, оказалось достаточно лишь немного дополнить класс styled.StyleSheet, уже существующий в Kotlin Wrappers. Полученные изменения рассмотрим снова на примере, но сначала добавим нужную зависимость:

plugins {     id("io.github.andrew-k-21-12.kjs-box.frontend-entry-point") version "1.0.0" }  kotlin.sourceSets.jsMain.get().dependencies {     implementation("io.github.andrew-k-21-12.kjs-box:frontend-dynamic-style-sheet:1.0.0") }  entryPoint.rootComponent = "org.example.demo.entry.App"

Мы опять используем ранее созданный проект. Плагин io.github.andrew-k-21-12.kjs-box.frontend-entry-point добавляет несколько необходимых для взаимодействия со стилями и DOM’ом библиотек. Если же брать чистый Kotlin Multiplatform-модуль, потребуется объявить чуть больше зависимостей из Kotlin Wrappers, включая kotlin-css и kotlin-react-dom.

Копируем код для разбора:

package org.example.demo.entry  import io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.DynamicCssProvider import io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.DynamicStyleSheet import io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.HasCssSuffix import io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.NamedRuleSet import io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.extensions.invoke import kotlinx.css.Color import kotlinx.css.color import react.FC import react.dom.html.ReactHTML.p import react.useEffectOnce import react.useState import web.timers.clearInterval import web.timers.setInterval  val App = FC {     val (secondsSinceStart, setSecondsSinceStart) = useState(0)     useEffectOnce {         val interval = setInterval(             { setSecondsSinceStart { it + 1 } },             1_000         )         cleanup {             clearInterval(interval)         }     }     val context = Context(secondsSinceStart)     +p(Styles.static.name) {         +"Параграф постоянного цвета."     }     +p(Styles.dynamic(context).name) {         +"Параграф меняет цвет в зависимости от чётности числа — ${secondsSinceStart}."     } }  private class Context(val isEven: Boolean) : HasCssSuffix {      constructor(count: Int) : this(count % 2 == 0)      override val cssSuffix: String = if (isEven) "even" else "odd"  }  private object Styles : DynamicStyleSheet() {      val static: NamedRuleSet by css {         color = Color.green     }      val dynamic: DynamicCssProvider<Context> by dynamicCss {         color = if (it.isEven) Color.red else Color.blue     }  }

Двигаемся по всем объявлениям снизу вверх. Чтобы создать группу стилей, нужен класс, расширяющий io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.DynamicStyleSheet — в нашем случае для упрощения это статический объект Styles. Каждый стиль внутри этого объекта будет иметь префикс, соответствующий простому имени объявленного класса, то есть “Styles”. Можно задать данный префикс явно, передав в конструктор DynamicStyleSheet желаемое базовое имя. Также есть возможность добавить дополнительный суффикс к базовому имени: в конструкторе DynamicStyleSheet перечисляются классы, из которых он будет составлен. Суффиксы могут быть полезны в случаях, когда экземпляры таблиц стилей создаются динамически и есть необходимость принципиально (не только в рамках передаваемых контекстов) видоизменять их содержимое — иными словами, чтобы получить нечто вроде тем. Но мне бы хотелось оставить подобные усложнения за пределами этой статьи.

Внутри Styles всего два стиля. Первый — static — всегда будет иметь полное имя Styles-static и устанавливать зелёный цвет. Второй — dynamic — в зависимости от передаваемого аргумента типа Context получит имя либо Styles-dynamic-even, либо Styles-dynamic-odd, задаваемый им цвет также меняется в соответствии с контекстом.

Немного о контекстах. Динамические стили (то есть объявленные посредством функции dynamicCss) требуют один аргумент, который может быть следующих типов: Boolean, Number, String, HasCssSuffix, Enum<*>, KProperty<*>. Во всех случаях, кроме HasCssSuffix, kjs-box сам подготовит полное имя стиля, получаемого в зависимости от аргумента. Однако если есть желание задать суффикс имени самостоятельно, можно создать класс, реализующий HasCssSuffix — именно с этой целью чуть выше отдельно объявлен Context.

Наконец, мы добрались до самого верха и готовы разобрать обновлённый код React-компонента. Здесь просто запускается таймер, увеличивающий значение secondsSinceStart на единицу каждую секунду. Затем создаётся экземпляр уже упомянутого контекста, добавляются два параграфа со статическим и динамическим стилями соответственно. Обратите внимание, что для более краткого указания имён классов — в скобках сразу после элемента — используется расширение io.github.andrewk2112.kjsbox.frontend.dynamicstylesheet.extensions.invoke. Также напомню, что помимо применения стилей указанием их названий (свойство name), стилевые таблицы можно ещё и компоновать (свойство rules) — пример был выше в разделе генерации обёрток для шрифтов.

Попробуйте запустить полученный проект и посмотреть на результат. С визуальной точки зрения всё очень просто, но больший интерес тут представляет структура HTML в инспекторе браузера: у первого параграфа всегда должен быть класс Styles-static, у второго — раз в секунду сменяться между Styles-dynamic-even и Styles-dynamic-odd.

Пожалуй, это основные моменты по стилям в kjs-box. Разного рода архитектурные ухищрения отдаются, в свою очередь, на откуп пользователю библиотеки. Например, можно задать наборы дизайн-токенов: с конечными значениями, с вычисляемыми свойствами в зависимости от контекста, с композициями стилей для отдельных UI-компонентов. Но это, как говорится, уже совсем другая история.

Деплой: обновляем бандл целиком или применяем только изменения

Как следует из заголовка, сделать деплой можно двумя способами. Однако в любом случае нужно выдержать пару требований:

  1. Проект создан на основе kjs-box модулей: корневого и точки входа.

  2. Сервер отдаёт всё содержимое собранного бандла с кэшированием по дате последней модификации.

Способ 1 — заменяем весь бандл

В случае обновления всех файлов production-сборки каждый из них — даже при отсутствии изменений — будет снова скачан в браузере. Предполагается, что предыдущая версия или изначальный релиз уже опубликованы. Последнее делается достаточно тривиально:

  1. Запускаем Gradle-таск jsBrowserProductionWebpack из корневого модуля.

  2. Копируем полученное содержимое папки build/kotlin-webpack/js/productionExecutable в директорию, откуда сервер собирается отдавать одностраничное веб-приложение.

Собственно алгоритм полного деплоя бандла новой версии состоит из чуть большего количества шагов:

  1. Обновляем version или main.customBundleStaticsDirectory в корневом build.gradle.kts-файле проекта.

  2. Выполняем jsBrowserProductionWebpack.

  3. Копируем созданную папку новой версии из build/kotlin-webpack/js/productionExecutable/static в аналогичную директорию с фронтендом на сервере.

  4. Атомарно заменяем серверный index.html на вновь сгенерированный из build/kotlin-webpack/js/productionExecutable.

Последний шаг является своего рода рубильником, включающим выдачу обновлённой версии приложения пользователям. Поскольку все новые скрипты и ресурсы уже предварительно размещены на сервере, а заменённый index.html лишь применяет пути к ним, исключаются ситуации, когда исходники разных версий могут смешаться в процессе релиза.

Способ 2 — применяем только последние изменения

Описанный далее алгоритм обновляет лишь изменённые файлы в уже опубликованной версии. Это значительно экономит пользовательский трафик: все исходники без модификаций не будут скачиваться повторно. Однако стоит иметь в виду, что такой метод деплоя может не сработать в очень редких случаях, когда хэш у какого-то из обновлённых файлов совпадает с хэшем его аналогичного варианта на сервере. При таком сценарии, чтобы сохранить атомарность релиза, замена подобного файла осуществлена не будет: алгоритм завершит своё выполнение, и придётся обновлять весь бандл первым способом выше.

Чтобы не делать всё частичное копирование вручную с учётом упомянутой возможности коллизии хэшей, в kjs-box подготовлена отдельная утилита под названием frontend-patching-deployer. Её дистрибутив можно скачать здесь или скомпилировать самостоятельно, выполнив Gradle-таск fatJar из модуля kjs-box/frontend/modules/utility/patching-deployer.

Ключевое требование для верного выполнения частичного деплоя — наличие файлов предыдущей версии бандла в папке сборки (то есть в build/kotlin-webpack/js/productionExecutable). Дело в том, что webpack не заменяет то её содержимое, которое остаётся без изменений по сравнению с прошлым релизом. Иными словами, как хэши, так и даты последней модификации (что критично для frontend-patching-deployer) останутся нетронутыми для всех файлов бандла, которых мы не касались в соответствующих им исходниках. Если же вдруг по какой-то причине предыдущая сборка пропала, можно попробовать её вернуть, скопировав в build/kotlin-webpack/js/productionExecutable релиз с сервера: главное — не менять у файлов даты последней модификации.

Наконец, шаги самого алгоритма:

  1. После применения в проекте всех изменений новой версии надо убедиться, что структура директорий у будущей сборки останется прежней. По этой причине либо оставляем значение version в корневом модуле таким же, либо присваиваем там же в качестве main.customBundleStaticsDirectory имя предыдущей версии.

  2. Выполняем Gradle-таск jsBrowserProductionWebpack.

  3. Запускаем скомпилированный frontend-patching-deployer, указав ему для —source-bundle путь к папке static полученной новой сборки, для —deployment-destination — путь к аналогичной директории static, где размещается фронтенд, например:
    java -jar frontend-patching-deployer-jvm-fat-1.0.0.jar --source-bundle=build/kotlin-webpack/js/productionExecutable/static --deployment-destination=../server/public/static

  4. Если предыдущий шаг прошёл успешно, атомарно обновляем серверный index.html на вновь сгенерированный. В противном же случае, скорей всего, придётся заменять весь бандл, как описано в первом способе выше.

В рассмотренном алгоритме очень бросается в глаза по меньшей мере один нюанс: и сборка, и размещение полученного билда происходят в пределах одной машины. Маловероятно, что кто-то станет компилировать новую версию фронтенда прямо на сервере. Другой возможный здесь подход заключается в том, чтобы уже обновлённый бандл сначала отправить на компьютер, который хостит веб-приложение, потом там же запустить frontend-patching-deployer. Однако даты последних изменений у файлов после подобной отправки могут не сохраниться — обращайте внимание на их сохранность после копирования: в случае модификации этих метаданных frontend-patching-deployer не отработает корректно. Я не стал упоминать пересылку билда на хостинг в шагах алгоритма, поскольку разные средства для взаимодействия с сервером конфигурируются различным образом. Тем не менее данный нюанс стоит иметь в виду.

Финал и пара слов о невошедшем

На мой взгляд, при знакомстве с новым инструментарием очень помогают примеры, особенно если в них есть немного интерактива. Обратная сторона такого подхода — в необходимости для автора всё предельно упрощать и что-то оставлять за кадром. Мне же не хотелось бы, чтобы часть из того, что я делал в рамках kjs-box, осталась без внимания, поэтому напомню о существовании репозитория проекта.

Скачав репозиторий, можно запустить сопутствующие демки и поковырять их код. Для этого не требуется никаких дополнительных действий (например, по созданию GitHub-токенов), главное — открыть в IDE именно проект frontend-example. Демонстрационные модули дают гораздо больше деталей по использованию библиотек из kjs-box. Например, работа со стилями показана в контексте дизайн-токенов, а для проверки деплоя есть минимальный сервер на Ktor с нужными конфигурациями. Отдельно упомяну, что в статье я полностью обошёл стороной вопрос инъекции зависимостей. Отчасти потому, что этот функционал идёт уже поверх ядра фреймворка, а отчасти — из-за возможной громоздкости получаемого примера.

Что ж, на этом наше знакомство с kjs-box подходит к концу. Если идеи, реализованные во фреймворке, показались вам интересными и хоть чуточку новыми, то я определённо счастлив. Если же нет — конструктивная критика также неоценима. Как бы то ни было, спасибо за ваше внимание и уделённое время.


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