Spec-Zone.ru › Kotlin 2

Создание веб-приложения с React и Kotlin/JS — руководство

В этом руководстве вы узнаете, как создать браузерное приложение с помощью Kotlin/JS и фреймворка React. Вы:

  • Выполните типичные задачи, связанные с созданием React-приложения.

  • Узнаете, как DSL Kotlin помогают лаконично и единообразно выражать идеи, не жертвуя удобочитаемостью, и позволяют полностью написать полноценное приложение на Kotlin.

  • Узнаете, как использовать готовые компоненты npm, внешние библиотеки и опубликовать готовое приложение.

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

Предполагается, что вы уже знакомы с Kotlin и обладаете базовыми знаниями HTML и CSS. Понимание основных концепций React поможет разобраться в некоторых примерах кода, но не является обязательным.

Готовое приложение можно найти здесь.

Перед началом работы

  1. Скачайте и установите последнюю версию IntelliJ IDEA.

  2. Клонируйте шаблон проекта и откройте его в IntelliJ IDEA. Шаблон включает базовый проект Kotlin Multiplatform Gradle со всеми необходимыми конфигурациями и зависимостями.

    • Зависимости и задачи в файле build.gradle.kts:

    dependencies {
        // React, React DOM + Wrappers
        implementation(enforcedPlatform("org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom:1.0.0-pre.430"))
        implementation("org.jetbrains.kotlin-wrappers:kotlin-react")
        implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom")
    
        // Kotlin React Emotion (CSS)
        implementation("org.jetbrains.kotlin-wrappers:kotlin-emotion")
    
        // Video Player
        implementation(npm("react-player", "2.12.0"))
    
        // Share Buttons
        implementation(npm("react-share", "4.4.1"))
    
        // Coroutines & serialization
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
        implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
    }
    
    • Шаблон HTML-страницы в src/jsMain/resources/index.html для вставки кода JavaScript, который вы будете использовать в этом руководстве:

    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Hello, Kotlin/JS!</title>
    </head>
    <body>
        <div id="root"></div>
        <script src="confexplorer.js"></script>
    </body>
    </html>
    

    При сборке проекты Kotlin/JS автоматически объединяют весь код и его зависимости в один файл JavaScript с тем же именем, что и у проекта, — confexplorer.js. Согласно стандартному соглашению JavaScript, содержимое body (включая элемент div root) загружается первым, чтобы браузер загрузил все элементы страницы до запуска скриптов.

  • Фрагмент кода в src/jsMain/kotlin/Main.kt:

    import kotlinx.browser.document
    
    fun main() {
        document.bgColor = "red"
    }
    

Запуск сервера разработки

По умолчанию плагин Kotlin Multiplatform Gradle поддерживает встроенный webpack-dev-server, позволяя запускать приложение из IDE без ручной настройки серверов.

Чтобы проверить, что программа успешно запускается в браузере, запустите сервер разработки, выполнив задачу run или browserDevelopmentRun (доступную в каталоге other или kotlin browser) в окне инструментов Gradle в IntelliJ IDEA:

Gradle tasks list

Чтобы запустить программу из терминала, вместо этого используйте ./gradlew run.

После компиляции и сборки проекта в окне браузера появится пустая красная страница:

Blank red page

Включение горячей перезагрузки / непрерывного режима

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

  1. Отредактируйте конфигурацию запуска, которую IntelliJ IDEA автоматически создаёт после первого выполнения задачи Gradle run:

    Edit a run configuration
  2. В диалоговом окне Конфигурации запуска и отладки добавьте параметр --continuous к аргументам конфигурации запуска:

    Enable continuous mode

    После применения изменений нажмите кнопку Запустить в IntelliJ IDEA, чтобы снова запустить сервер разработки. Чтобы запускать непрерывные сборки Gradle из терминала, вместо этого используйте ./gradlew run --continuous.

  3. Чтобы проверить эту возможность, измените цвет страницы на синий в файле Main.kt, пока выполняется задача Gradle:

    document.bgColor = "blue"
    

    Затем проект перекомпилируется, и после перезагрузки страница в браузере изменит цвет.

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

Это состояние проекта можно найти здесь, в ветке master.

Создание черновика веб-приложения

Добавление первой статической страницы с React

Чтобы приложение выводило простое сообщение, замените код в файле Main.kt следующим:

import kotlinx.browser.document
import react.*
import emotion.react.css
import csstype.Position
import csstype.px
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.img
import react.dom.client.createRoot
import kotlinx.serialization.Serializable

fun main() {
    val container = document.getElementById("root") ?: error("Couldn't find root container!")
    createRoot(container).render(Fragment.create {
        h1 {
            +"Hello, React+Kotlin/JS!"
        }
    })
}
  • Функция render() указывает kotlin-react-dom отобразить первый HTML-элемент внутри фрагмента в элемент root. Этот элемент — контейнер, определённый в src/jsMain/resources/index.html и включённый в шаблон.

  • Содержимое представляет собой заголовок <h1> и использует типобезопасный DSL для создания HTML.

  • h1 — функция, принимающая параметр-лямбду. Когда перед строковым литералом добавляется знак +, фактически вызывается функция unaryPlus() с помощью перегрузки оператора. Она добавляет строку во вложенный HTML-элемент.

После перекомпиляции проекта браузер отобразит эту HTML-страницу:

An HTML page example

Преобразование HTML в типобезопасный HTML DSL Kotlin

Обёртки Kotlin для React wrappers включают предметно-ориентированный язык (DSL), позволяющий писать HTML на чистом Kotlin. В этом отношении он похож на JSX в JavaScript. Однако, поскольку эта разметка написана на Kotlin, вы получаете все преимущества статически типизированного языка, например автодополнение и проверку типов.

Сравните классический код HTML для будущего веб-приложения с его типобезопасным вариантом на Kotlin:

<h1>KotlinConf Explorer</h1>
<div>
    <h3>Videos to watch</h3>
    <p>John Doe: Building and breaking things</p>
    <p>Jane Smith: The development process</p>
    <p>Matt Miller: The Web 7.0</p>
    <h3>Videos watched</h3>
    <p>Tom Jerry: Mouseless development</p>
</div>
<div>
    <h3>John Doe: Building and breaking things</h3>
    <img src="https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder">
</div>
h1 {
    +"KotlinConf Explorer"
}
div {
    h3 {
        +"Videos to watch"
    }
    p {
        + "John Doe: Building and breaking things"
    }
    p {
        +"Jane Smith: The development process"
    }
    p {
        +"Matt Miller: The Web 7.0"
    }
    h3 {
        +"Videos watched"
    }
    p {
        +"Tom Jerry: Mouseless development"
    }
}
div {
    h3 {
        +"John Doe: Building and breaking things"
    }
    img {
       src = "https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"
    }
}

Скопируйте код Kotlin и обновите вызов функции Fragment.create() внутри функции main(), заменив предыдущий тег h1.

Дождитесь перезагрузки браузера. Теперь страница должна выглядеть так:

The web app draft

Добавление видео с помощью конструкций Kotlin в разметке

У написания HTML на Kotlin с помощью этого DSL есть несколько преимуществ. Вы можете управлять приложением с помощью стандартных конструкций Kotlin: циклов, условий, коллекций и интерполяции строк.

Теперь можно заменить жёстко заданный список видео списком объектов Kotlin:

  1. В Main.kt создайте Video класс данных, чтобы хранить все атрибуты видео в одном месте:

    data class Video(
        val id: Int,
        val title: String,
        val speaker: String,
        val videoUrl: String
    )
    
  2. Заполните два списка: для непросмотренных и просмотренных видео соответственно. Добавьте эти объявления на уровне файла в Main.kt:

    val unwatchedVideos = listOf(
        Video(1, "Opening Keynote", "Andrey Breslav", "https://youtu.be/PsaFVLr8t4E"),
        Video(2, "Dissecting the stdlib", "Huyen Tue Dao", "https://youtu.be/Fzt_9I733Yg"),
        Video(3, "Kotlin and Spring Boot", "Nicolas Frankel", "https://youtu.be/pSiZVAeReeg")
    )
    
    val watchedVideos = listOf(
        Video(4, "Creating Internal DSLs in Kotlin", "Venkat Subramaniam", "https://youtu.be/JzTeAM8N1-o")
    )
    
  3. Чтобы использовать эти видео на странице, напишите цикл for на Kotlin для перебора коллекции объектов Video с непросмотренными видео. Замените три тега p под заголовком «Видео для просмотра» следующим фрагментом:

    for (video in unwatchedVideos) {
        p {
            +"${video.speaker}: ${video.title}"
        }
    }
    
  4. Аналогичным образом измените код для единственного тега после заголовка «Просмотренные видео»:

    for (video in watchedVideos) {
        p {
            +"${video.speaker}: ${video.title}"
        }
    }
    

Дождитесь перезагрузки браузера. Макет должен остаться таким же, как и раньше. Добавьте в список ещё несколько видео, чтобы убедиться, что цикл работает.

Добавление стилей с помощью типобезопасного CSS

Обёртка kotlin-emotion для библиотеки Emotion позволяет задавать атрибуты CSS, в том числе динамические, непосредственно рядом с HTML с помощью JavaScript. В целом это похоже на CSS-in-JS, но для Kotlin. Преимущество использования DSL заключается в том, что для задания правил форматирования можно использовать конструкции языка Kotlin.

В шаблонном проекте для этого руководства уже есть зависимость, необходимая для использования kotlin-emotion:

dependencies {
    // ...
    // Kotlin React Emotion (CSS) (chapter 3)
    implementation("org.jetbrains.kotlin-wrappers:kotlin-emotion")
    // ...
}

С помощью kotlin-emotion можно указать блок css внутри HTML-элементов div и h3, где можно определить стили.

Чтобы переместить видеоплеер в правый верхний угол страницы, используйте CSS и измените код видеоплеера (последний div во фрагменте):

div {
    css {
        position = Position.absolute
        top = 10.px
        right = 10.px
    }
    h3 {
        +"John Doe: Building and breaking things"
    }
    img {
        src = "https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"
    }
}

Попробуйте и другие стили. Например, можно изменить fontFamily или добавить в интерфейс color.

Проектирование компонентов приложения

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

Содержимое функции render() обычно описывает базовый компонент. Текущий макет приложения выглядит так:

Current layout

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

Structured layout with components

Компоненты инкапсулируют определённую функциональность. Использование компонентов сокращает исходный код и упрощает его чтение и понимание.

Добавление главного компонента

Чтобы начать создавать структуру приложения, сначала явно задайте App — главный компонент для отображения в элементе root:

  1. Создайте новый файл App.kt в папке src/jsMain/kotlin.

  2. Добавьте в этот файл следующий фрагмент и перенесите в него типобезопасную HTML-разметку из Main.kt:

    import kotlinx.coroutines.async
    import react.*
    import react.dom.*
    import kotlinx.browser.window
    import kotlinx.coroutines.*
    import kotlinx.serialization.decodeFromString
    import kotlinx.serialization.json.Json
    import emotion.react.css
    import csstype.Position
    import csstype.px
    import react.dom.html.ReactHTML.h1
    import react.dom.html.ReactHTML.h3
    import react.dom.html.ReactHTML.div
    import react.dom.html.ReactHTML.p
    import react.dom.html.ReactHTML.img
    
    val App = FC<Props> {
        // typesafe HTML goes here, starting with the first h1 tag!
    }
    

    Функция FC создаёт функциональный компонент.

  3. В файле Main.kt обновите функцию main() следующим образом:

    fun main() {
        val container = document.getElementById("root") ?: error("Couldn't find root container!")
        createRoot(container).render(App.create())
    }
    

    Теперь программа создаёт экземпляр компонента App и отображает его в указанном контейнере.

Дополнительные сведения о концепциях React см. в документации и руководствах.

Выделение компонента списка

Поскольку списки watchedVideos и unwatchedVideos содержат видео, имеет смысл создать один повторно используемый компонент и менять только содержимое списков.

Компонент VideoList следует тому же шаблону, что и компонент App. Он использует функцию-конструктор FC и содержит код списка unwatchedVideos.

  1. Создайте новый файл VideoList.kt в папке src/jsMain/kotlin и добавьте следующий код:

    import kotlinx.browser.window
    import react.*
    import react.dom.*
    import react.dom.html.ReactHTML.p
    
    val VideoList = FC<Props> {
        for (video in unwatchedVideos) {
            p {
                +"${video.speaker}: ${video.title}"
            }
        }
    }
    
  2. В App.kt используйте компонент VideoList, вызвав его без параметров:

    // . . .
    
    div {
        h3 {
            +"Videos to watch"
        }
        VideoList()
    
        h3 {
            +"Videos watched"
        }
        VideoList()
    }
    
    // . . .
    

    Пока компонент App не может управлять содержимым, отображаемым компонентом VideoList. Содержимое задано жёстко, поэтому оба списка будут одинаковыми.

Добавление пропсов для передачи данных между компонентами

Поскольку вы собираетесь повторно использовать компонент VideoList, нужно предусмотреть возможность заполнять его разным содержимым. Для этого можно передавать список элементов компоненту в качестве атрибута. В React такие атрибуты называются пропсами. Когда пропсы компонента в React меняются, фреймворк автоматически перерисовывает компонент.

Для VideoList понадобится пропс со списком отображаемых видео. Определите интерфейс, содержащий все пропсы, которые можно передать компоненту VideoList:

  1. Добавьте следующее определение в файл VideoList.kt:

    external interface VideoListProps : Props {
        var videos: List<Video>
    }
    

    Модификатор external сообщает компилятору, что реализация интерфейса предоставляется извне, поэтому он не пытается генерировать JavaScript-код на основе объявления.

  2. Измените объявление класса VideoList, чтобы использовать пропсы, передаваемые в блок FC в качестве параметра:

    val VideoList = FC<VideoListProps> { props ->
        for (video in props.videos) {
            p {
                key = video.id.toString()
                +"${video.speaker}: ${video.title}"
            }
        }
    }
    

    Атрибут key помогает средству отрисовки React определить, что делать при изменении значения props.videos. По ключу оно определяет, какие части списка нужно обновить, а какие оставить без изменений. Подробнее о списках и ключах см. в руководстве по React.

  3. В компоненте App убедитесь, что дочерние компоненты создаются с нужными атрибутами. В App.kt замените два цикла под элементами h3 вызовом VideoList вместе с атрибутами для unwatchedVideos и watchedVideos. В DSL Kotlin присвойте им значения внутри блока компонента VideoList:

    h3 {
        +"Videos to watch"
    }
    VideoList {
        videos = unwatchedVideos
    }
    h3 {
        +"Videos watched"
    }
    VideoList {
        videos = watchedVideos
    }
    

После перезагрузки браузер покажет, что списки теперь отображаются правильно.

Добавление интерактивности списку

Для начала добавьте всплывающее сообщение, которое будет появляться при нажатии на элемент списка. В VideoList.kt добавьте функцию-обработчик onClick, которая выводит сообщение с информацией о текущем видео:

// . . .

p {
    key = video.id.toString()
    onClick = {
        window.alert("Clicked $video!")
    }
    +"${video.speaker}: ${video.title}"
}

// . . .

Если нажать на один из элементов списка в окне браузера, в окне с сообщением появится информация о видео, как показано ниже:

Browser alert window

Определить функцию onClick непосредственно как лямбду — это лаконичное и очень удобное решение для прототипирования. Однако из-за того, как в настоящее время работает сравнение в Kotlin/JS, с точки зрения производительности передавать обработчики нажатий таким способом не оптимально. Если вы хотите оптимизировать производительность отрисовки, сохраните функции в переменной и передавайте их.

Добавление состояния для хранения значений

Вместо простого уведомления пользователя можно добавить подсветку выбранного видео треугольником ▶. Для этого введите отдельное для данного компонента состояние.

Состояние — одна из основных концепций React. В современном React, использующем так называемый API Hooks, состояние выражается с помощью хука useState.

  1. Добавьте следующий код в начало объявления VideoList:

    val VideoList = FC<VideoListProps> { props ->
        var selectedVideo: Video? by useState(null)
    
    // . . .
    
    • Функциональный компонент VideoList хранит состояние (значение, не зависящее от текущего вызова функции). Состояние допускает значение null и имеет тип Video?. Значение по умолчанию — null.

    • Функция useState() из React указывает фреймворку отслеживать состояние между несколькими вызовами функции. Например, несмотря на то что вы указываете значение по умолчанию, React гарантирует, что оно будет присвоено только в начале. При изменении состояния компонент перерисовывается с учётом нового состояния.

    • Ключевое слово by указывает, что useState() является делегированным свойством. Как и любую другую переменную, её можно читать и изменять. Реализация useState() берёт на себя всю необходимую для работы состояния механику.

    Подробнее о хуке состояния см. в документации React.

  2. Измените обработчик onClick и текст в компоненте VideoList, как показано ниже:

    val VideoList = FC<VideoListProps> { props ->
        var selectedVideo: Video? by useState(null)
        for (video in props.videos) {
            p {
                key = video.id.toString()
                onClick = {
                    selectedVideo = video
                }
                if (video == selectedVideo) {
                    +"▶ "
                }
                +"${video.speaker}: ${video.title}"
            }
        }
    }
    
    • При нажатии пользователя на видео его значение присваивается переменной selectedVideo.

    • При отображении выбранного элемента списка перед ним добавляется треугольник.

Дополнительные сведения об управлении состоянием см. в часто задаваемых вопросах по React.

Проверьте результат в браузере и нажмите на элемент списка, чтобы убедиться, что всё работает правильно.

Композиция компонентов

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

Two videos are selected in both lists simultaneously

Список не может одновременно отслеживать, какое видео выбрано в нём самом и в соседнем списке. Причина в том, что выбранное видео относится не к состоянию списка, а к состоянию приложения. Это значит, что состояние нужно поднять из отдельных компонентов.

Подъём состояния

React гарантирует, что пропсы можно передавать только от родительского компонента дочерним. Это не позволяет компонентам напрямую зависеть друг от друга.

Если компоненту нужно изменить состояние соседнего компонента, он должен сделать это через родительский компонент. В этом случае состояние уже не принадлежит ни одному из дочерних компонентов, а относится к родительскому компоненту верхнего уровня.

Перенос состояния из компонентов в их родительские компоненты называется подъёмом состояния. В вашем приложении добавьте currentVideo в качестве состояния компонента App:

  1. В App.kt добавьте следующий код в начало определения компонента App:

    val App = FC<Props> {
        var currentVideo: Video? by useState(null)
    
        // . . .
    }
    

    Компонент VideoList больше не должен отслеживать состояние. Вместо этого он будет получать текущее видео через пропс.

  2. Удалите вызов useState() в VideoList.kt.

  3. Подготовьте компонент VideoList к получению выбранного видео через пропс. Для этого расширьте интерфейс VideoListProps, включив в него selectedVideo:

    external interface VideoListProps : Props {
        var videos: List<Video>
        var selectedVideo: Video?
    }
    
  4. Измените условие отображения треугольника так, чтобы оно использовало props вместо state:

    if (video == props.selectedVideo) {
        +"▶ "
    }
    

Передача обработчиков

Сейчас присвоить значение пропсу нельзя, поэтому функция onClick не будет работать так, как задумано. Чтобы изменить состояние родительского компонента, нужно снова поднять состояние.

В React состояние всегда передаётся от родителя к потомку. Поэтому, чтобы изменить состояние приложения из одного из дочерних компонентов, нужно перенести логику обработки действий пользователя в родительский компонент и передать эту логику через пропс. Помните, что в Kotlin переменные могут иметь тип функции.

  1. Снова расширьте интерфейс VideoListProps, добавив в него переменную onSelectVideo — функцию, которая принимает Video и возвращает Unit:

    external interface VideoListProps : Props {
        // ...
        var onSelectVideo: (Video) -> Unit
    }
    
  2. В компоненте VideoList используйте новый пропс в обработчике onClick:

    onClick = {
        props.onSelectVideo(video)
    }
    

    Теперь можно удалить переменную selectedVideo из компонента VideoList.

  3. Вернитесь к компоненту App и передайте selectedVideo и обработчик для onSelectVideo каждому из двух списков видео:

    VideoList {
        videos = unwatchedVideos // and watchedVideos respectively
        selectedVideo = currentVideo
        onSelectVideo = { video ->
            currentVideo = video
        }
    }
    
  4. Повторите предыдущий шаг для списка просмотренных видео.

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

Добавление других компонентов

Выделение компонента видеоплеера

Теперь можно создать ещё один автономный компонент — видеоплеер, который пока представлен изображением-заглушкой. Видеоплееру нужно знать название доклада, его автора и ссылку на видео. Эта информация уже содержится в каждом объекте Video, поэтому её можно передать в качестве пропа и получить доступ к его атрибутам.

  1. Создайте новый файл VideoPlayer.kt и добавьте в него следующую реализацию компонента VideoPlayer:

    import csstype.*
    import react.*
    import emotion.react.css
    import react.dom.html.ReactHTML.button
    import react.dom.html.ReactHTML.div
    import react.dom.html.ReactHTML.h3
    import react.dom.html.ReactHTML.img
    
    external interface VideoPlayerProps : Props {
        var video: Video
    }
    
    val VideoPlayer = FC<VideoPlayerProps> { props ->
        div {
            css {
                position = Position.absolute
                top = 10.px
                right = 10.px
            }
            h3 {
                +"${props.video.speaker}: ${props.video.title}"
            }
            img {
                src = "https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"              
            }
        }
    }
    
  2. Поскольку интерфейс VideoPlayerProps указывает, что компонент VideoPlayer принимает значение Video, которое не может быть null, обязательно обработайте этот случай в компоненте App соответствующим образом.

    В App.kt замените предыдущий фрагмент div для видеоплеера на следующий:

    currentVideo?.let { curr ->
        VideoPlayer {
            video = curr
        }
    }
    

    Функция области видимости let гарантирует, что компонент VideoPlayer будет добавлен, только если state.currentVideo не равен null.

Теперь при нажатии на элемент списка откроется видеоплеер, в который будут переданы данные выбранного элемента.

Добавление и настройка кнопки

Чтобы пользователи могли отмечать видео как просмотренные или непросмотренные и перемещать их между двумя списками, добавьте кнопку в компонент VideoPlayer.

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

  1. Расширьте интерфейс VideoPlayerProps в VideoPlayer.kt, добавив свойства для этих двух случаев:

    external interface VideoPlayerProps : Props {
        var video: Video
        var onWatchedButtonPressed: (Video) -> Unit
        var unwatchedVideo: Boolean
    }
    
  2. Теперь можно добавить кнопку непосредственно в компонент. Скопируйте следующий фрагмент в тело компонента VideoPlayer, между тегами h3 и img:

    button {
        css {
            display = Display.block
            backgroundColor = if (props.unwatchedVideo) NamedColor.lightgreen else NamedColor.red
        }
        onClick = {
            props.onWatchedButtonPressed(props.video)
        }
        if (props.unwatchedVideo) {
            +"Mark as watched"
        } else {
            +"Mark as unwatched"
        }
    }
    

    С помощью Kotlin CSS DSL, позволяющего динамически изменять стили, цвет кнопки можно изменить, используя обычное выражение Kotlin if.

Перенос списков видео в состояние приложения

Теперь нужно изменить место использования VideoPlayer в компоненте App. При нажатии кнопки видео должно перемещаться из списка непросмотренных в список просмотренных или наоборот. Поскольку теперь эти списки могут изменяться, перенесите их в состояние приложения:

  1. В App.kt добавьте следующие свойства с вызовами useState() в начало компонента App:

    val App = FC<Props> {
        var currentVideo: Video? by useState(null)
        var unwatchedVideos: List<Video> by useState(listOf(
            Video(1, "Opening Keynote", "Andrey Breslav", "https://youtu.be/PsaFVLr8t4E"),
            Video(2, "Dissecting the stdlib", "Huyen Tue Dao", "https://youtu.be/Fzt_9I733Yg"),
            Video(3, "Kotlin and Spring Boot", "Nicolas Frankel", "https://youtu.be/pSiZVAeReeg")
        ))
        var watchedVideos: List<Video> by useState(listOf(
            Video(4, "Creating Internal DSLs in Kotlin", "Venkat Subramaniam", "https://youtu.be/JzTeAM8N1-o")
        ))
    
        // . . .
    }
    
  2. Поскольку все демонстрационные данные уже заданы непосредственно в значениях по умолчанию для watchedVideos и unwatchedVideos, объявления на уровне файла больше не нужны. В Main.kt удалите объявления для watchedVideos и unwatchedVideos.

  3. Измените вызов VideoPlayer в компоненте App, относящемся к видеоплееру, следующим образом:

    VideoPlayer {
        video = curr
        unwatchedVideo = curr in unwatchedVideos
        onWatchedButtonPressed = {
            if (video in unwatchedVideos) {
                unwatchedVideos = unwatchedVideos - video
                watchedVideos = watchedVideos + video
            } else {
                watchedVideos = watchedVideos - video
                unwatchedVideos = unwatchedVideos + video
            }
        }
    }
    

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

Использование пакетов из npm

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

У React богатая экосистема с множеством готовых компонентов, которые можно использовать вместо того, чтобы самостоятельно создавать эту функциональность.

Добавление компонента видеоплеера

Чтобы заменить компонент-заглушку видео на настоящий YouTube-плеер, используйте пакет react-player из npm. Он позволяет воспроизводить видео и управлять внешним видом плеера.

Документацию по компоненту и описание API см. в его файле README на GitHub.

  1. Проверьте файл build.gradle.kts. Пакет react-player уже должен быть включён:

    dependencies {
        // ...
        // Video Player
        implementation(npm("react-player", "2.12.0"))
        // ...
    }
    

    Как видите, зависимости npm можно добавлять в проект Kotlin/JS с помощью функции npm() в блоке dependencies файла сборки. Затем Gradle-плагин загружает и устанавливает эти зависимости за вас. Для этого он использует собственную встроенную установку пакетного менеджера Yarn.

  2. Чтобы использовать пакет JavaScript в React-приложении, нужно сообщить компилятору Kotlin, чего ожидать, предоставив ему внешние объявления.

    Создайте новый файл ReactYouTube.kt и добавьте в него следующее содержимое:

    @file:JsModule("react-player")
    @file:JsNonModule
    
    import react.*
    
    @JsName("default")
    external val ReactPlayer: ComponentClass<dynamic>
    

    Увидев внешнее объявление, например ReactPlayer, компилятор предполагает, что реализация соответствующего класса предоставляется зависимостью, и не генерирует для него код.

    Последние две строки эквивалентны импорту JavaScript, например require("react-player").default;. Они сообщают компилятору, что во время выполнения компонент гарантированно будет соответствовать ComponentClass<dynamic>.

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

Лучше создать external interface, в котором указать, какие свойства входят в пропы этого внешнего компонента. Описание интерфейса пропов можно найти в файле README компонента. В данном случае используйте пропы url и controls:

  1. Измените содержимое ReactYouTube.kt, заменив dynamic внешним интерфейсом:

    @file:JsModule("react-player")
    @file:JsNonModule
    
    import react.*
    
    @JsName("default")
    external val ReactPlayer: ComponentClass<ReactPlayerProps>
    
    external interface ReactPlayerProps : Props {
        var url: String
        var controls: Boolean
    }
    
  2. Теперь новый ReactPlayer можно использовать вместо серого прямоугольника-заглушки в компоненте VideoPlayer. В VideoPlayer.kt замените тег img следующим фрагментом:

    ReactPlayer {
        url = props.video.videoUrl
        controls = true
    }
    

Добавление кнопок для публикации в соцсетях

Простой способ поделиться содержимым приложения — добавить кнопки для публикации в мессенджерах и по электронной почте. Для этого также можно использовать готовый React-компонент, например react-share:

  1. Проверьте файл build.gradle.kts. Эта библиотека npm уже должна быть включена:

    dependencies {
        // ...
        // Share Buttons
        implementation(npm("react-share", "4.4.1"))
        // ...
    }
    
  2. Чтобы использовать react-share из Kotlin, потребуется написать дополнительные базовые внешние объявления. В примерах на GitHub показано, что кнопка для публикации состоит из двух React-компонентов: например, EmailShareButton и EmailIcon. У разных типов кнопок и значков для публикации одинаковый интерфейс. Внешние объявления для каждого компонента создаются так же, как и для видеоплеера.

    Добавьте следующий код в новый файл ReactShare.kt:

    @file:JsModule("react-share")
    @file:JsNonModule
    
    import react.ComponentClass
    import react.Props
    
    @JsName("EmailIcon")
    external val EmailIcon: ComponentClass<IconProps>
    
    @JsName("EmailShareButton")
    external val EmailShareButton: ComponentClass<ShareButtonProps>
    
    @JsName("TelegramIcon")
    external val TelegramIcon: ComponentClass<IconProps>
    
    @JsName("TelegramShareButton")
    external val TelegramShareButton: ComponentClass<ShareButtonProps>
    
    external interface ShareButtonProps : Props {
        var url: String
    }
    
    external interface IconProps : Props {
        var size: Int
        var round: Boolean
    }
    
  3. Добавьте новые компоненты в пользовательский интерфейс приложения. В VideoPlayer.kt добавьте две кнопки для публикации в div непосредственно перед использованием ReactPlayer:

    // . . .
    
    div {
        css {
             position = Position.absolute
             top = 10.px
             right = 10.px
         }
        EmailShareButton {
            url = props.video.videoUrl
            EmailIcon {
                size = 32
                round = true
            }
        }
        TelegramShareButton {
            url = props.video.videoUrl
            TelegramIcon {
                size = 32
                round = true
            }
        }
    }
    
    // . . .
    

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

Share window

Попробуйте повторить этот шаг для кнопок публикации в других социальных сетях, доступных в react-share.

Использование внешнего REST API

Теперь можно заменить жёстко заданные демонстрационные данные настоящими данными из REST API.

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

Использование функциональности JS из Kotlin

В браузерах уже есть множество Web API. Их также можно использовать из Kotlin/JS, поскольку он содержит готовые обёртки для этих API. Например, Fetch API используется для выполнения HTTP-запросов.

Первая возможная проблема заключается в том, что браузерные API, например fetch(), используют колбэки для выполнения неблокирующих операций. Если несколько колбэков должны выполняться один за другим, их приходится вкладывать друг в друга. В результате код получает множество уровней отступов, а блоки функциональности оказываются вложены друг в друга, что затрудняет чтение.

Решить эту проблему можно с помощью корутин Kotlin — более удобного подхода для такой функциональности.

Вторая проблема связана с динамической типизацией JavaScript. Тип данных, возвращаемых внешним API, не гарантирован. Решить эту проблему можно с помощью библиотеки kotlinx.serialization.

Проверьте файл build.gradle.kts. Нужный фрагмент уже должен быть в нём:

dependencies {
    // . . .

    // Coroutines & serialization
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}

Добавление сериализации

При вызове внешнего API возвращается текст в формате JSON, который ещё нужно преобразовать в объект Kotlin, с которым можно работать.

kotlinx.serialization — это библиотека, позволяющая преобразовывать строки JSON в объекты Kotlin.

  1. Проверьте файл build.gradle.kts. Соответствующий фрагмент уже должен быть в нём:

    plugins {
        // . . .
        kotlin("plugin.serialization") version "2.4.20"
    }
    
    dependencies {
        // . . .
    
        // Serialization
        implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
    }
    
  2. Перед загрузкой первого видео нужно сообщить библиотеке сериализации о классе Video. В Main.kt добавьте аннотацию @Serializable к его объявлению:

    @Serializable
    data class Video(
        val id: Int,
        val title: String,
        val speaker: String,
        val videoUrl: String
    )
    

Загрузка видео

Чтобы загрузить видео из API, добавьте следующую функцию в App.kt (или в новый файл):

suspend fun fetchVideo(id: Int): Video {
    val response = window
        .fetch("https://my-json-server.typicode.com/kotlin-hands-on/kotlinconf-json/videos/$id")
        .await()
        .text()
        .await()
    return Json.decodeFromString(response)
}
  • Приостанавливаемая функция fetch() загружает из API видео с заданным id. Ответ может поступать некоторое время, поэтому результат нужно await(). Затем text() считывает тело ответа, используя колбэк. После этого нужно дождаться завершения await().

  • Перед возвращением значения функции передайте его в Json.decodeFromString — функцию из kotlinx.coroutines. Она преобразует текст JSON, полученный в ответ на запрос, в объект Kotlin с соответствующими полями.

  • Вызов функции window.fetch возвращает объект Promise. Обычно нужно определить обработчик колбэка, который будет вызван, когда Promise разрешится и станет доступен результат. Однако корутины позволяют await() эти промисы. При вызове такой функции, как await(), метод останавливает (приостанавливает) своё выполнение. Оно продолжится, когда Promise будет разрешён.

Чтобы пользователи могли выбирать видео, определите функцию fetchVideos(), которая загрузит 25 видео из того же API. Чтобы выполнить все запросы параллельно, используйте функциональность async, предоставляемую корутинами Kotlin:

  1. Добавьте следующую реализацию в App.kt:

    suspend fun fetchVideos(): List<Video> = coroutineScope {
        (1..25).map { id ->
            async {
                fetchVideo(id)
            }
        }.awaitAll()
    }
    

    В соответствии с принципом структурированной конкурентности реализация обёрнута в coroutineScope. После этого можно запустить 25 асинхронных задач (по одной на запрос) и дождаться их завершения.

  2. Теперь можно добавить данные в приложение. Добавьте объявление mainScope и измените компонент App так, чтобы он начинался со следующего фрагмента. Не забудьте также заменить демонстрационные значения экземплярами emptyLists:

    val mainScope = MainScope()
    
    val App = FC<Props> {
        var currentVideo: Video? by useState(null)
        var unwatchedVideos: List<Video> by useState(emptyList())
        var watchedVideos: List<Video> by useState(emptyList())
    
        useEffectOnce {
            mainScope.launch {
                unwatchedVideos = fetchVideos()
            }
        }
    
    // . . .
    
    • MainScope() — часть модели структурированной конкурентности Kotlin, которая создаёт область выполнения асинхронных задач.

    • useEffectOnce — ещё один React-хук (в частности, упрощённая версия хука useEffect). Он указывает, что компонент выполняет побочный эффект. Компонент не просто отображает себя, но и взаимодействует с сетью.

Проверьте результат в браузере. Приложение должно отображать настоящие данные:

Fetched data from API

При загрузке страницы:

  • Будет вызван код компонента App. Это запускает код в блоке useEffectOnce.

  • Компонент App отображается с пустыми списками просмотренных и непросмотренных видео.

  • После завершения запросов к API блок useEffectOnce присваивает результат состоянию компонента App. Это запускает повторный рендеринг.

  • Код компонента App будет вызван снова, но блок useEffectOnce не выполнится повторно.

Чтобы подробно разобраться в работе корутин, ознакомьтесь с этим руководством по корутинам.

Развёртывание в рабочей среде и облаке

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

Подготовка рабочей сборки

Чтобы упаковать все ресурсы в рабочем режиме, запустите задачу build в Gradle через окно инструментов IntelliJ IDEA или выполнив команду ./gradlew build. Будет создана оптимизированная сборка проекта с различными улучшениями, например DCE (удалением неиспользуемого кода).

После завершения сборки все файлы, необходимые для развёртывания, можно найти в /build/dist. Среди них — файлы JavaScript, HTML и другие ресурсы, необходимые для работы приложения. Их можно разместить на статическом HTTP-сервере, опубликовать с помощью GitHub Pages или разместить у выбранного облачного провайдера.

Развёртывание в Heroku

Heroku позволяет легко запустить приложение, доступное по собственному домену. Бесплатного тарифа должно быть достаточно для разработки.

  1. Создайте аккаунт.

  2. Установите и выполните аутентификацию в CLI-клиенте.

  3. Создайте Git-репозиторий и подключите приложение Heroku, выполнив следующие команды в терминале из корневой папки проекта:

    git init
    heroku create
    git add .
    git commit -m "initial commit"
    
  4. В отличие от обычного приложения JVM, работающего в Heroku (например, написанного с использованием Ktor или Spring Boot), ваше приложение генерирует статические HTML-страницы и файлы JavaScript, которые нужно соответствующим образом обслуживать. Чтобы программа работала правильно, настройте нужные buildpack:

    heroku buildpacks:set heroku/gradle
    heroku buildpacks:add https://github.com/heroku/heroku-buildpack-static.git
    
  5. Чтобы buildpack heroku/gradle работал правильно, в файле build.gradle.kts должна быть задача stage. Эта задача эквивалентна задаче build, а соответствующий псевдоним уже добавлен в конец файла:

    // Heroku Deployment
    tasks.register("stage") {
        dependsOn("build")
    }
    
  6. Добавьте новый файл static.json в корневую папку проекта, чтобы настроить buildpack-static.

  7. Добавьте в файл свойство root:

    {
        "root": "build/distributions"
    }
    
  8. Теперь можно запустить развёртывание, например, выполнив следующую команду:

    git add -A
    git commit -m "add stage task and static content root configuration"
    git push heroku master
    

Если вы отправляете изменения не из основной ветки, измените команду так, чтобы отправить их в удалённый репозиторий main, например, git push heroku feature-branch:main.

Если развёртывание прошло успешно, вы увидите URL-адрес, по которому пользователи смогут открыть приложение в интернете.

Web app deployment to production

Это состояние проекта можно найти в ветке finished здесь.

Что дальше

Добавление других возможностей

Полученное приложение можно использовать как отправную точку для изучения более сложных тем, связанных с React, Kotlin/JS и не только.

  • Поиск. Можно добавить поле поиска, чтобы фильтровать список докладов, например, по названию или автору. Узнайте, как работают элементы HTML-форм в React.

  • Сохранение данных. Сейчас приложение забывает список просмотренных видео пользователя при каждой перезагрузке страницы. Подумайте о создании собственного бэкенда с помощью одного из доступных для Kotlin веб-фреймворков, например Ktor. Другой вариант — изучить способы хранения данных на клиенте.

  • Сложные API. Доступно множество наборов данных и API. В приложение можно добавлять самые разные данные. Например, можно создать средство для просмотра фотографий кошек или использовать API бесплатных стоковых фотографий.

Улучшение стиля: адаптивность и сетки

Дизайн приложения пока очень простой и будет плохо выглядеть на мобильных устройствах и в узких окнах. Изучите дополнительные возможности CSS DSL, чтобы сделать приложение удобнее.

Присоединение к сообществу и получение помощи

Лучший способ сообщить о проблемах и получить помощь — воспользоваться трекером проблем kotlin-wrappers. Если вы не нашли подходящего обращения, создайте новое. Также можно присоединиться к официальному Kotlin Slack. Там есть каналы для #javascript и #react.

Подробнее о корутинах

Если вы хотите узнать больше о написании конкурентного кода, ознакомьтесь с руководством по корутинам.

Подробнее о React

Теперь, когда вы знаете основные концепции React и то, как они применяются в Kotlin, можно перенести на Kotlin некоторые другие концепции, описанные в документации React.

12 августа 2026 г.
Плагин компилятора для обычных объектов JSKotlin/Wasm

© 2010–2026 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/js-react.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API