Создание веб-приложения с React и Kotlin/JS — руководство
В этом руководстве вы узнаете, как создать браузерное приложение с помощью Kotlin/JS и фреймворка React. Вы:
Выполните типичные задачи, связанные с созданием React-приложения.
Узнаете, как DSL Kotlin помогают лаконично и единообразно выражать идеи, не жертвуя удобочитаемостью, и позволяют полностью написать полноценное приложение на Kotlin.
Узнаете, как использовать готовые компоненты npm, внешние библиотеки и опубликовать готовое приложение.
В результате вы получите веб-приложение KotlinConf Explorer, посвящённое мероприятию KotlinConf и содержащее ссылки на доклады конференции. Пользователи смогут смотреть все доклады на одной странице и отмечать их как просмотренные или непросмотренные.
Предполагается, что вы уже знакомы с Kotlin и обладаете базовыми знаниями HTML и CSS. Понимание основных концепций React поможет разобраться в некоторых примерах кода, но не является обязательным.
Перед началом работы
Скачайте и установите последнюю версию IntelliJ IDEA.
-
Клонируйте шаблон проекта и откройте его в 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 (включая элемент divroot) загружается первым, чтобы браузер загрузил все элементы страницы до запуска скриптов.
-
Фрагмент кода в
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:

Чтобы запустить программу из терминала, вместо этого используйте ./gradlew run.
После компиляции и сборки проекта в окне браузера появится пустая красная страница:
Включение горячей перезагрузки / непрерывного режима
Настройте режим непрерывной компиляции, чтобы не компилировать и запускать проект вручную после каждого изменения. Перед продолжением остановите все работающие экземпляры сервера разработки.
-
Отредактируйте конфигурацию запуска, которую IntelliJ IDEA автоматически создаёт после первого выполнения задачи Gradle
run:
-
В диалоговом окне Конфигурации запуска и отладки добавьте параметр
--continuousк аргументам конфигурации запуска:
После применения изменений нажмите кнопку Запустить в IntelliJ IDEA, чтобы снова запустить сервер разработки. Чтобы запускать непрерывные сборки Gradle из терминала, вместо этого используйте
./gradlew run --continuous. -
Чтобы проверить эту возможность, измените цвет страницы на синий в файле
Main.kt, пока выполняется задача Gradle:document.bgColor = "blue"
Затем проект перекомпилируется, и после перезагрузки страница в браузере изменит цвет.
Во время разработки можно оставить сервер разработки работающим в непрерывном режиме. Он будет автоматически пересобирать проект и перезагружать страницу после внесения изменений.
Создание черновика веб-приложения
Добавление первой статической страницы с 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-страницу:
Преобразование 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.
Дождитесь перезагрузки браузера. Теперь страница должна выглядеть так:
Добавление видео с помощью конструкций Kotlin в разметке
У написания HTML на Kotlin с помощью этого DSL есть несколько преимуществ. Вы можете управлять приложением с помощью стандартных конструкций Kotlin: циклов, условий, коллекций и интерполяции строк.
Теперь можно заменить жёстко заданный список видео списком объектов Kotlin:
-
В
Main.ktсоздайтеVideoкласс данных, чтобы хранить все атрибуты видео в одном месте:data class Video( val id: Int, val title: String, val speaker: String, val videoUrl: String ) -
Заполните два списка: для непросмотренных и просмотренных видео соответственно. Добавьте эти объявления на уровне файла в
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") ) -
Чтобы использовать эти видео на странице, напишите цикл
forна Kotlin для перебора коллекции объектовVideoс непросмотренными видео. Замените три тегаpпод заголовком «Видео для просмотра» следующим фрагментом:for (video in unwatchedVideos) { p { +"${video.speaker}: ${video.title}" } } -
Аналогичным образом измените код для единственного тега после заголовка «Просмотренные видео»:
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() обычно описывает базовый компонент. Текущий макет приложения выглядит так:
Если разбить приложение на отдельные компоненты, структура станет более упорядоченной, а каждый компонент будет отвечать за свои задачи:
Компоненты инкапсулируют определённую функциональность. Использование компонентов сокращает исходный код и упрощает его чтение и понимание.
Добавление главного компонента
Чтобы начать создавать структуру приложения, сначала явно задайте App — главный компонент для отображения в элементе root:
Создайте новый файл
App.ktв папкеsrc/jsMain/kotlin.-
Добавьте в этот файл следующий фрагмент и перенесите в него типобезопасную 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создаёт функциональный компонент. -
В файле
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.
-
Создайте новый файл
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}" } } } -
В
App.ktиспользуйте компонентVideoList, вызвав его без параметров:// . . . div { h3 { +"Videos to watch" } VideoList() h3 { +"Videos watched" } VideoList() } // . . .Пока компонент
Appне может управлять содержимым, отображаемым компонентомVideoList. Содержимое задано жёстко, поэтому оба списка будут одинаковыми.
Добавление пропсов для передачи данных между компонентами
Поскольку вы собираетесь повторно использовать компонент VideoList, нужно предусмотреть возможность заполнять его разным содержимым. Для этого можно передавать список элементов компоненту в качестве атрибута. В React такие атрибуты называются пропсами. Когда пропсы компонента в React меняются, фреймворк автоматически перерисовывает компонент.
Для VideoList понадобится пропс со списком отображаемых видео. Определите интерфейс, содержащий все пропсы, которые можно передать компоненту VideoList:
-
Добавьте следующее определение в файл
VideoList.kt:external interface VideoListProps : Props { var videos: List<Video> }Модификатор external сообщает компилятору, что реализация интерфейса предоставляется извне, поэтому он не пытается генерировать JavaScript-код на основе объявления.
-
Измените объявление класса
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. -
В компоненте
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}"
}
// . . .
Если нажать на один из элементов списка в окне браузера, в окне с сообщением появится информация о видео, как показано ниже:
Добавление состояния для хранения значений
Вместо простого уведомления пользователя можно добавить подсветку выбранного видео треугольником ▶. Для этого введите отдельное для данного компонента состояние.
Состояние — одна из основных концепций React. В современном React, использующем так называемый API Hooks, состояние выражается с помощью хука useState.
-
Добавьте следующий код в начало объявления
VideoList:val VideoList = FC<VideoListProps> { props -> var selectedVideo: Video? by useState(null) // . . .Функциональный компонент
VideoListхранит состояние (значение, не зависящее от текущего вызова функции). Состояние допускает значение null и имеет типVideo?. Значение по умолчанию —null.Функция
useState()из React указывает фреймворку отслеживать состояние между несколькими вызовами функции. Например, несмотря на то что вы указываете значение по умолчанию, React гарантирует, что оно будет присвоено только в начале. При изменении состояния компонент перерисовывается с учётом нового состояния.Ключевое слово
byуказывает, чтоuseState()является делегированным свойством. Как и любую другую переменную, её можно читать и изменять. РеализацияuseState()берёт на себя всю необходимую для работы состояния механику.
Подробнее о хуке состояния см. в документации React.
-
Измените обработчик
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.
Проверьте результат в браузере и нажмите на элемент списка, чтобы убедиться, что всё работает правильно.
Композиция компонентов
Сейчас оба списка видео работают независимо друг от друга: каждый отслеживает выбранное видео. Пользователи могут выбрать два видео — одно в списке непросмотренных, другое в списке просмотренных, — хотя проигрыватель только один:
Список не может одновременно отслеживать, какое видео выбрано в нём самом и в соседнем списке. Причина в том, что выбранное видео относится не к состоянию списка, а к состоянию приложения. Это значит, что состояние нужно поднять из отдельных компонентов.
Подъём состояния
React гарантирует, что пропсы можно передавать только от родительского компонента дочерним. Это не позволяет компонентам напрямую зависеть друг от друга.
Если компоненту нужно изменить состояние соседнего компонента, он должен сделать это через родительский компонент. В этом случае состояние уже не принадлежит ни одному из дочерних компонентов, а относится к родительскому компоненту верхнего уровня.
Перенос состояния из компонентов в их родительские компоненты называется подъёмом состояния. В вашем приложении добавьте currentVideo в качестве состояния компонента App:
-
В
App.ktдобавьте следующий код в начало определения компонентаApp:val App = FC<Props> { var currentVideo: Video? by useState(null) // . . . }Компонент
VideoListбольше не должен отслеживать состояние. Вместо этого он будет получать текущее видео через пропс. Удалите вызов
useState()вVideoList.kt.-
Подготовьте компонент
VideoListк получению выбранного видео через пропс. Для этого расширьте интерфейсVideoListProps, включив в негоselectedVideo:external interface VideoListProps : Props { var videos: List<Video> var selectedVideo: Video? } -
Измените условие отображения треугольника так, чтобы оно использовало
propsвместоstate:if (video == props.selectedVideo) { +"▶ " }
Передача обработчиков
Сейчас присвоить значение пропсу нельзя, поэтому функция onClick не будет работать так, как задумано. Чтобы изменить состояние родительского компонента, нужно снова поднять состояние.
В React состояние всегда передаётся от родителя к потомку. Поэтому, чтобы изменить состояние приложения из одного из дочерних компонентов, нужно перенести логику обработки действий пользователя в родительский компонент и передать эту логику через пропс. Помните, что в Kotlin переменные могут иметь тип функции.
-
Снова расширьте интерфейс
VideoListProps, добавив в него переменнуюonSelectVideo— функцию, которая принимаетVideoи возвращаетUnit:external interface VideoListProps : Props { // ... var onSelectVideo: (Video) -> Unit } -
В компоненте
VideoListиспользуйте новый пропс в обработчикеonClick:onClick = { props.onSelectVideo(video) }Теперь можно удалить переменную
selectedVideoиз компонентаVideoList. -
Вернитесь к компоненту
Appи передайтеselectedVideoи обработчик дляonSelectVideoкаждому из двух списков видео:VideoList { videos = unwatchedVideos // and watchedVideos respectively selectedVideo = currentVideo onSelectVideo = { video -> currentVideo = video } } Повторите предыдущий шаг для списка просмотренных видео.
Вернитесь в браузер и убедитесь, что при выборе видео выделение перемещается между двумя списками, не дублируясь.
Добавление других компонентов
Выделение компонента видеоплеера
Теперь можно создать ещё один автономный компонент — видеоплеер, который пока представлен изображением-заглушкой. Видеоплееру нужно знать название доклада, его автора и ссылку на видео. Эта информация уже содержится в каждом объекте Video, поэтому её можно передать в качестве пропа и получить доступ к его атрибутам.
-
Создайте новый файл
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" } } } -
Поскольку интерфейс
VideoPlayerPropsуказывает, что компонентVideoPlayerпринимает значениеVideo, которое не может быть null, обязательно обработайте этот случай в компонентеAppсоответствующим образом.В
App.ktзамените предыдущий фрагментdivдля видеоплеера на следующий:currentVideo?.let { curr -> VideoPlayer { video = curr } }Функция области видимости
letгарантирует, что компонентVideoPlayerбудет добавлен, только еслиstate.currentVideoне равен null.
Теперь при нажатии на элемент списка откроется видеоплеер, в который будут переданы данные выбранного элемента.
Добавление и настройка кнопки
Чтобы пользователи могли отмечать видео как просмотренные или непросмотренные и перемещать их между двумя списками, добавьте кнопку в компонент VideoPlayer.
Поскольку эта кнопка будет перемещать видео между двумя разными списками, логику изменения состояния нужно поднять из компонента VideoPlayer и передать ему от родительского компонента в качестве пропа. Внешний вид кнопки должен меняться в зависимости от того, просмотрено видео или нет. Эту информацию также нужно передать в качестве пропа.
-
Расширьте интерфейс
VideoPlayerPropsвVideoPlayer.kt, добавив свойства для этих двух случаев:external interface VideoPlayerProps : Props { var video: Video var onWatchedButtonPressed: (Video) -> Unit var unwatchedVideo: Boolean } -
Теперь можно добавить кнопку непосредственно в компонент. Скопируйте следующий фрагмент в тело компонента
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. При нажатии кнопки видео должно перемещаться из списка непросмотренных в список просмотренных или наоборот. Поскольку теперь эти списки могут изменяться, перенесите их в состояние приложения:
-
В
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") )) // . . . } Поскольку все демонстрационные данные уже заданы непосредственно в значениях по умолчанию для
watchedVideosиunwatchedVideos, объявления на уровне файла больше не нужны. ВMain.ktудалите объявления дляwatchedVideosиunwatchedVideos.-
Измените вызов
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.
-
Проверьте файл
build.gradle.kts. Пакетreact-playerуже должен быть включён:dependencies { // ... // Video Player implementation(npm("react-player", "2.12.0")) // ... }Как видите, зависимости npm можно добавлять в проект Kotlin/JS с помощью функции
npm()в блокеdependenciesфайла сборки. Затем Gradle-плагин загружает и устанавливает эти зависимости за вас. Для этого он использует собственную встроенную установку пакетного менеджера Yarn. -
Чтобы использовать пакет 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:
-
Измените содержимое
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 } -
Теперь новый
ReactPlayerможно использовать вместо серого прямоугольника-заглушки в компонентеVideoPlayer. ВVideoPlayer.ktзамените тегimgследующим фрагментом:ReactPlayer { url = props.video.videoUrl controls = true }
Добавление кнопок для публикации в соцсетях
Простой способ поделиться содержимым приложения — добавить кнопки для публикации в мессенджерах и по электронной почте. Для этого также можно использовать готовый React-компонент, например react-share:
-
Проверьте файл
build.gradle.kts. Эта библиотека npm уже должна быть включена:dependencies { // ... // Share Buttons implementation(npm("react-share", "4.4.1")) // ... } -
Чтобы использовать
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 } -
Добавьте новые компоненты в пользовательский интерфейс приложения. В
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 видео. Если кнопки не отображаются или не работают, возможно, нужно отключить блокировщик рекламы и социальных сетей.

Попробуйте повторить этот шаг для кнопок публикации в других социальных сетях, доступных в 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.
-
Проверьте файл
build.gradle.kts. Соответствующий фрагмент уже должен быть в нём:plugins { // . . . kotlin("plugin.serialization") version "2.4.20" } dependencies { // . . . // Serialization implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") } -
Перед загрузкой первого видео нужно сообщить библиотеке сериализации о классе
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:
-
Добавьте следующую реализацию в
App.kt:suspend fun fetchVideos(): List<Video> = coroutineScope { (1..25).map { id -> async { fetchVideo(id) } }.awaitAll() }В соответствии с принципом структурированной конкурентности реализация обёрнута в
coroutineScope. После этого можно запустить 25 асинхронных задач (по одной на запрос) и дождаться их завершения. -
Теперь можно добавить данные в приложение. Добавьте объявление
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). Он указывает, что компонент выполняет побочный эффект. Компонент не просто отображает себя, но и взаимодействует с сетью.
Проверьте результат в браузере. Приложение должно отображать настоящие данные:

При загрузке страницы:
Будет вызван код компонента
App. Это запускает код в блокеuseEffectOnce.Компонент
Appотображается с пустыми списками просмотренных и непросмотренных видео.После завершения запросов к API блок
useEffectOnceприсваивает результат состоянию компонентаApp. Это запускает повторный рендеринг.Код компонента
Appбудет вызван снова, но блокuseEffectOnceне выполнится повторно.
Чтобы подробно разобраться в работе корутин, ознакомьтесь с этим руководством по корутинам.
Развёртывание в рабочей среде и облаке
Пора опубликовать приложение в облаке и сделать его доступным для других пользователей.
Подготовка рабочей сборки
Чтобы упаковать все ресурсы в рабочем режиме, запустите задачу build в Gradle через окно инструментов IntelliJ IDEA или выполнив команду ./gradlew build. Будет создана оптимизированная сборка проекта с различными улучшениями, например DCE (удалением неиспользуемого кода).
После завершения сборки все файлы, необходимые для развёртывания, можно найти в /build/dist. Среди них — файлы JavaScript, HTML и другие ресурсы, необходимые для работы приложения. Их можно разместить на статическом HTTP-сервере, опубликовать с помощью GitHub Pages или разместить у выбранного облачного провайдера.
Развёртывание в Heroku
Heroku позволяет легко запустить приложение, доступное по собственному домену. Бесплатного тарифа должно быть достаточно для разработки.
-
Создайте Git-репозиторий и подключите приложение Heroku, выполнив следующие команды в терминале из корневой папки проекта:
git init heroku create git add . git commit -m "initial commit"
-
В отличие от обычного приложения JVM, работающего в Heroku (например, написанного с использованием Ktor или Spring Boot), ваше приложение генерирует статические HTML-страницы и файлы JavaScript, которые нужно соответствующим образом обслуживать. Чтобы программа работала правильно, настройте нужные buildpack:
heroku buildpacks:set heroku/gradle heroku buildpacks:add https://github.com/heroku/heroku-buildpack-static.git
-
Чтобы buildpack
heroku/gradleработал правильно, в файлеbuild.gradle.ktsдолжна быть задачаstage. Эта задача эквивалентна задачеbuild, а соответствующий псевдоним уже добавлен в конец файла:// Heroku Deployment tasks.register("stage") { dependsOn("build") } Добавьте новый файл
static.jsonв корневую папку проекта, чтобы настроитьbuildpack-static.-
Добавьте в файл свойство
root:{ "root": "build/distributions" } -
Теперь можно запустить развёртывание, например, выполнив следующую команду:
git add -A git commit -m "add stage task and static content root configuration" git push heroku master
Если развёртывание прошло успешно, вы увидите URL-адрес, по которому пользователи смогут открыть приложение в интернете.

Что дальше
Добавление других возможностей
Полученное приложение можно использовать как отправную точку для изучения более сложных тем, связанных с React, Kotlin/JS и не только.
Поиск. Можно добавить поле поиска, чтобы фильтровать список докладов, например, по названию или автору. Узнайте, как работают элементы HTML-форм в React.
Сохранение данных. Сейчас приложение забывает список просмотренных видео пользователя при каждой перезагрузке страницы. Подумайте о создании собственного бэкенда с помощью одного из доступных для Kotlin веб-фреймворков, например Ktor. Другой вариант — изучить способы хранения данных на клиенте.
Сложные API. Доступно множество наборов данных и API. В приложение можно добавлять самые разные данные. Например, можно создать средство для просмотра фотографий кошек или использовать API бесплатных стоковых фотографий.
Улучшение стиля: адаптивность и сетки
Дизайн приложения пока очень простой и будет плохо выглядеть на мобильных устройствах и в узких окнах. Изучите дополнительные возможности CSS DSL, чтобы сделать приложение удобнее.
Присоединение к сообществу и получение помощи
Лучший способ сообщить о проблемах и получить помощь — воспользоваться трекером проблем kotlin-wrappers. Если вы не нашли подходящего обращения, создайте новое. Также можно присоединиться к официальному Kotlin Slack. Там есть каналы для #javascript и #react.
Подробнее о корутинах
Если вы хотите узнать больше о написании конкурентного кода, ознакомьтесь с руководством по корутинам.
Подробнее о React
Теперь, когда вы знаете основные концепции React и то, как они применяются в Kotlin, можно перенести на Kotlin некоторые другие концепции, описанные в документации React.
© 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