[Перевод] Практика по Котлину: Создание веб приложений на React и Kotlin/JS

От переводчика.

Привет! Про Kotlin есть стереотип, будто бы это язык для разработки только под Android. На самом деле, это совсем не так: язык официально поддерживает несколько платформ (JVM, JS, Native), а также умеет работать с библиотеками для этих платформ, написанных на других языках. Такая поддержка «мультиплатформенности» позволяет не только писать всевозможные проекты на одном языке в единой форме, но и переиспользовать код при написании одного проекта под разные платформы.

В этой статье я перевожу официальный туториал Kotlin Hands-On о создании веб сайтов на Котлине. Мы рассмотрим многие аспекты программирования на Kotlin/JS и поймем, как работать не только с чистым DOM. В основном будем говорить о React JS, но также коснемся системы сборки Gradle, использования зависимостей из NPM, обращения к REST API, деплоя на Heroku, и в итоге сделаем приложение-видеоплеер.

Текст ориентирован на тех, кто немного знает Котлин и не знает или почти не знает Реакт. Если вы более опытны по этим вопросам, то части туториала могут показаться вам чрезмерно разжеванными.

kotlin-react

Надеюсь, статья удовлетворит пусть даже непопулярные запросы на материалы о Котлине на русском.

Предлагать правки в эту статью лучше всего на ГитХабе. Текущий перевод построен на версии оригинального туториала, актуальной на 09.04.2021.


Содержание


  1. Введение
  2. Настройка
  3. Первая страница на Реакте — статичная
  4. React — о реакциях. Наш первый компонент
  5. Работаем совместно. Композиция компонентов
  6. Больше компонентов!
  7. Использование NPM пакетов
  8. Используем внешнее REST API
  9. Деплоим в продакшен и в облако
  10. В дополнение: современный Реакт с хуками
  11. Что дальше?

Шаг 1. Введение

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

Использование Котлина для написания приложений на React позволяет опираться на наши знания о парадигмах, синтаксисе и инструментах этого языка при создании фронт-энд приложений для современных браузеров. А еще использовать котлиновские библиотеки одновременно с возможностями платформы и экосистемы JavaScript.

На этой практике мы научимся создавать приложение на Kotlin/JS и React, используя Gradle плагин org.jetbrains.kotlin.js. Мы решим задачи, обычно возникающие при создании типичного простого React приложения.

Мы узнаем, как предметно-ориентированные языки (DSL) помогают выражать идеи кратким и единообразным способом без жертв читаемости, давая возможность написать полноценное приложение полностью на Котлине. Также мы покажем, как использовать уже сделанные сообществом компоненты и библиотеки, и как опубликовать получившееся приложение.

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


Что именно мы создадим

Ежегодное событие KotlinConf стоит посетить, если вы хотите узнать больше о Котлине и пообщаться с сообществом. KotlinConf 2018 предлагал огромное количество информации в виде мастер-классов и лекций и насчитывал 1300 участников. Доклады публично доступны на YouTube, и поклонникам Котлина было бы полезно увидеть перечень докладов на одной странице и помечать их как просмотренные — идеально для погружения в Котлин «запоем». На этой практике мы как раз создадим такое приложение — KotlinConf Explorer (см. скриншот ниже).

Результат

Исходный код финального приложения, как и промежуточные результаты, можно найти в репозитории на GitHub. Результат каждого шага доступен в соответствующей ветке, ссылки на которые мы дополнительно дублируем в конце каждого параграфа.

Начнем с настройки среды разработки и установки инструментов, которые помогут нам в работе.


Шаг 2. Настройка


Пререквизиты

Чтобы начать, давайте убедимся, что у вас установлена актуальная среда разработки. Вот все, что нам нужно сейчас — это IntelliJ IDEA (версии 2020.3 или новее, достаточно бесплатной Community Edition) с плагином Котлин (1.4.30 или новее) — скачать можно по ссылке. Выберите установочный файл, соответствующий вашей ОС (поддерживаются Windows, MacOS и Linux).


Создаем проект

Для этой практики мы подготовили стартовый шаблон проекта, включающий все настройки и зависимости.

Склонируйте этот GitHub репозиторий и откройте его с помощью IntelliJ IDEA (например, с помощью File | New | Project from Version Control… или Git | Clone…).

Этот шаблон содержит простейший Kotlin/JS Gradle проект, на основе которого можно делать что-то свое. Так как в Gradle конфигурации шаблона уже прописаны все необходимые для практики зависимости, вам не придется изменять ее.

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

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


Зависимости и задачи Gradle

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

Для начала давайте убедимся, что внутри файла build.gradle.kts есть блок repositories. Таким образом объявляются источники зависимостей.

Блок зависимостей dependencies содержит все нужные для практики внешние библиотеки:

dependencies {
    // React, React DOM + Wrappers (шаг 3)
    implementation("org.jetbrains:kotlin-react:17.0.1-pre.148-kotlin-1.4.21")
    implementation("org.jetbrains:kotlin-react-dom:17.0.1-pre.148-kotlin-1.4.21")
    implementation(npm("react", "17.0.1"))
    implementation(npm("react-dom", "17.0.1"))

    // Kotlin Styled (шаг 3)
    implementation("org.jetbrains:kotlin-styled:5.2.1-pre.148-kotlin-1.4.21")
    implementation(npm("styled-components", "~5.2.1"))

    // Video Player (шаг 7)
    implementation(npm("react-youtube-lite", "1.0.1"))

    // Share Buttons (шаг 7)
    implementation(npm("react-share", "~4.2.1"))

    // Coroutines (шаг 8)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3")
}

Если отредактировать файл, IDEA автоматически предложит импортировать изменения Gradle скриптов. Импорт также можно инициировать в любой момент, нажав на кнопку Reimport All Gradle Projects в тул-окне Gradle (сбоку справа).


HTML страница

Так как мы не можем вызывать JavaScript сам по себе, мы должны написать связанную с нашим JS файлом HTML страницу, и именно ее открывать в браузере. В проекте уже есть файл src/main/resources/index.html со следующим содержимым:




    
    Hello, Kotlin/JS!


    

Благодаря Kotlin/JS Gradle плагину, весь наш код и зависимости будут объединены («забандлены») в единый JavaScript артефакт, носящий с проектом одно имя. Соответственно мы добавили в HTML файл вызов скрипта confexplorer.js (заметьте, что если бы проект был назван, например, как followingAlong, имя скрипта было бы followingAlong.js).

Выполняя обыденную конвенцию JavaScript, мы сначала позволяем загрузить контент нашей страницы (включая элемент #root) и только в конце загружаем скрипт. Таким образом, страница будет загружена к моменту выполнения нашего скрипта, и мы сможем сразу же к ней обращаться.

Примечание от переводчика: если у вас уже есть опыт с HTML, возможно, вы привыкли использовать свойство onLoad у элемента body для решения этой же проблемы. Однако при использовании Kotlin/JS намного проще именно просто объявлять скрипт в конце body.

Перед написанием «Hello, World» с настоящей разметкой, начнем с простейшего визуального примера — страницы, залитой сплошным цветом. Этот пример поможет понять, то что наш код действительно доходит до браузера и выполняется без ошибок. Для кода у нас есть файл src/main/kotlin/Main.kt с таким содержимым:

import kotlinx.browser.document

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

Теперь нам нужно скомпилировать и запустить наш код.


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

Kotlin/JS Gradle плагин из коробки поддерживает webpack-dev-server, что позволяет нам хостить приложение прямо с помощью IDE и не настраивать веб сервер отдельно.

Мы можем запустить сервер, вызвав задачу run или browserDevelopmentRun из тул-окна Gradle. Она может быть либо в группе other (как на скриншоте), либо в kotlin browser:
Задача для запуска сервера

Если хочется запускать не из IDE, а из терминала, то можно выполнить ./gradlew run (в Windows Gradle команды выглядят немного по-другому: .\gradlew.bat run).

Наш проект скомпилируется и забандлится, и через несколько секунд должно открыться окно браузера с пустой красной страницей, означающей, что наш код заработал успешно:
Красная страница


Включение горячей перезагрузки (hot reload) a.k.a. непрерывного режима

Вместо того чтобы вручную вызывать компиляцию проекта и обновление страницы в браузере для тестирования изменений в коде, мы можем использовать режим непрерывной компиляции — Kotlin/JS поддерживает ее. Для этого нам потребуется немного модифицировать вызов run задачи Gradle.

Необходимо также убедиться, что запущенный ранее веб сервер остановлен (нажмите в IDE на красный квадрат — Stop; если работаете в терминале — нажмите Ctrl+C).

Если вы запускаете задачу с помощью IDEA, нужно добавить флаг в конфигурацию запуска. Эту конфигурацию IDEA создала, когда мы впервые запустили Gradle задачу, а теперь нам нужно ее отредактировать:
Открытие редактирования

В открывшемся окне Run/Debug Configurations надо добавить флаг --continuous в аргументы конфигурации запуска:
Добавление аргумента

После применения изменений мы можем использовать зеленую кнопку Run (|>) для запуска сервера.

Если вы запускаете сервер из терминала, это можно сделать примерно так: ./gradlew run --continuous.

Для тестирования только что активированной функции предлагаем изменить цвет страницы, когда Gradle задача выполняется. Например, можно изменить на синий:

document.bgColor = "blue"

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

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


Примечание от переводчика насчет непрерывной компиляции

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

В итоге я уже года два, с момента начала моего использования Kotlin/JS, вручную запускаю компиляцию кода и перезагрузку страницы. Более того, я вообще не использую веб сервер при разработке: я просто открываю в браузере локальный HTML файл. Для компиляции я использую задачу browserDevelopmentWebpack, после этого построенные файлы становятся доступны в папке build/distributions или в build/developmentExecutable. Оттуда я переношу в браузер файл index.html, и все работает довольно безотказно и предельно логично.


На старт, внимание…

Мы настроили пустой Kotlin/JS проект, который может развиться во все что угодно. Время начинать верстать!


Состояние проекта после выполнения этого шага доступно в ветке master в репозитории.

Шаг 3. Первая страница на Реакте — статичная

В мире программирования принято начинать обучение с Hello, World. Так давайте изменим нашу одноцветную страницу в соответствии с традициями.

Поменяйте код в файле src/main/kotlin/Main.kt на примерно следующий:

import react.dom.*
import kotlinx.browser.document

fun main() {
    render(document.getElementById("root")) {
        h1 {
            +"Hello, React+Kotlin/JS!"
        }
    }
}

После сборки изменившегося проекта в браузере можно увидеть магию:
hello-world

Ура, вы только что написали свой первый веб сайт на чистом Котлине с Реактом! Давайте попробуем понять, как работает этот код. Функция render говорит библиотеке kotlin-react-dom отрендерить наш компонент (поговорим о компонентах чуть позже) внутрь элемента на странице. Если помните, в src/main/resources/index.html есть элемент с ID root, как раз туда мы и рендерим. Содержимое рендеринга сейчас довольно простое — единственный заголовок первого уровня. Для объявления содержимого, то есть HTML элементов, используется типобезопасный DSL.


Типобезопасный HTML

Библиотека kotlin-react использует котлиновскую возможность написания DSL, таким образом заменяя синтаксис разметки HTML на нечто более легкочитаемое. Возможно, такой DSL вам покажется и легче в написании.

Код на Котлине дает нам все преимущества статически типизированного языка, от проверки типов до автодополнения. Скорее всего, из-за этого вы проведете меньше времени в отладке, охотясь за опечатками в именах атрибутов, и у вас появится больше времени на создание отточенного приложения!

О знаке +:

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

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


Переписываем классический HTML

Когда у нас есть мысли о том, как будет выглядеть наш сайт, мы можем сразу перевести наш (мысленный) набросок в котлиновское объявление HTML. Если вы уже привыкли писать обычный HTML, у вас не должно возникнуть проблем и с котлиновским. Сейчас мы хотим создать разметку, которую можно записать примерно так на чистом HTML:

KotlinConf Explorer

Videos to watch

John Doe: Building and breaking things

Jane Smith: The development process

Matt Miller: The Web 7.0

Videos watched

Tom Jerry: Mouseless development

John Doe: Building and breaking things

Давайте переведем этот код в Kotlin DSL. Конверсия довольно прямолинейна. Если хотите поупражняться, можете попробовать переписать самостоятельно, не подглядывая в листинг ниже:

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 {
       attrs {
           src = "https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"
       }
    }
}

Перепишите или скопируйте этот листинг внутрь вызова render. Если IntelliJ IDEA ругается на отсутствующие импорты, просто вызовите соответствующие быстрые исправления (quick-fixes) с помощью Alt+Enter. Когда обновленный файл будет скомпилирован и страница в браузере перезагружена, вас будет приветствовать следующий экран:
kotlinconf-placeholder


Использование котлиновских языковых конструкций в разметке

Написание HTML на DSL на самом деле имеет намного больше преимуществ по сравнению с чистым HTML. Основное отличие — это то что мы можем жонглировать контентом страницы, используя уже знакомые нам конструкции языка. Условные переходы, циклы, коллекции, подстановка внутри строк — все это будет работать в HTML DSL так же, как и обычно в Котлине.

Давайте теперь вместо захардкоживания списка видео объявим переменную-список и будем ее использовать в разметке. Создадим класс KotlinVideo, чтобы хранить свойства видео (класс можно создать либо в Main.kt, либо в другом файле — как хотите), а также external интерфейс — о нем поговорим позже, когда будем получать данные из внешнего API:

external interface Video {
    val id: Int
    val title: String
    val speaker: String
    val videoUrl: String
}

data class KotlinVideo(
    override val id: Int,
    override val title: String,
    override val speaker: String,
    override val videoUrl: String
) : Video

Потом объявим два списка: для непросмотренных и просмотренных видео. Пока что можно сделать это в файле Main.kt на верхнем уровне:

val unwatchedVideos = listOf(
    KotlinVideo(1, "Building and breaking things", "John Doe", "https://youtu.be/PsaFVLr8t4E"),
    KotlinVideo(2, "The development process", "Jane Smith", "https://youtu.be/PsaFVLr8t4E"),
    KotlinVideo(3, "The Web 7.0", "Matt Miller", "https://youtu.be/PsaFVLr8t4E")
)

val watchedVideos = listOf(
    KotlinVideo(4, "Mouseless development", "Tom Jerry", "https://youtu.be/PsaFVLr8t4E")
)

Чтобы использовать эти значения в HTML, нам не нужно знать ничего, кроме базового синтаксиса Котлина! Мы можем написать код для прохода по коллекции и добавлять HTML элемент для каждого элемента коллекции. То есть вместо трех тегов p для непросмотренных видео, мы можем написать примерно такое:

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

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


Типобезопасный CSS

Можно сказать, мы уже продвинулись в проекте, но не время делать паузу: к сожалению, наше приложение до сих пор выглядит несколько безвкусно и не сильно привлекательно. Для исправления ситуации мы могли бы подключить какой-нибудь .css файл в наш файл index.html, но давайте лучше воспользуемся случаем, чтобы поиграться с Kotlin DSL опять — на этот раз с CSS.

Библиотека kotlin-styled предоставляет чудесные типобезопасные обертки для styled-components и позволяет нам быстро и безопасно объявлять стили как глобально, так индивидуально для конкретных компонентов. Эти обертки очень похожи на концепт CSS-in-JS. Описывая стили на Котлине, мы опять же получаем возможность использовать краткие, понятные и единообразные языковые конструкции.

Нам не нужно делать дополнительных шагов для использования этого CSS DSL, так как мы уже добавили все зависимости в конфигурацию Gradle. Вот соответствующий блок:

dependencies {
    //...
    // Kotlin Styled (шаг 3)
    implementation("org.jetbrains:kotlin-styled:5.2.1-pre.148-kotlin-1.4.21")
    implementation(npm("styled-components", "~5.2.1"))
    //...
}

Теперь вместо блоков вроде div или h3 мы можем использовать их аналоги с префиксом styled, например, styledDiv или styledH3. Внутри их тел стили можно настраивать с помощью блока css. Например, для сдвига видеоплеера в правый верхний угол страницы, мы можем изменить наш код примерно так:

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

Скорее всего, IDEA начнет жаловаться на непонятные ссылки. Мы можем избавиться от этих ошибок, добавив импорты сверху в файле:

import kotlinx.css.*
import styled.*

Или можно воспользоваться быстрыми исправлениями с помощью Alt+Enter для добавления импортов автоматически.

Мы привели довольно минималистичный пример. Не стесняйтесь поэкспериментировать — изменять стиль приложения, как душе угодно. Можете даже поиграться с CSS Grids, чтобы сделать интерфейс отзывчивым (но эта тема уже слишком сложна для этого туториала). Попробуйте сделать шрифт (свойство fontFamily) заголовка без засечек (значение sans-serif), или, например, сделать гармоничные цвета (свойство color).


Состояние проекта после выполнения этого шага доступно в ветке step-02-first-static-page в репозитории.

Шаг 4. React — о реакциях. Наш первый компонент


Основная идея

Базовые строительные блоки в Реакте называются компонентами. Комбинируя компоненты, часть из которых в свою может быть комбинацией других более маленьких, мы создаем приложение. Делая компоненты переиспользуемыми и обобщенными, мы можем помещать их в несколько мест в приложении, не дублируя код и/или логику.

На самом деле, корневой элемент нашего рендеринга тоже можно представить как компонент. Если мы отметим его рамкой, то это будет выглядеть примерно так:
root-component

А если посмотреть на структуру приложения, то можно найти следующие компоненты, каждый из которых имеет свою ответственность:
split-components


Корневой компонент

Давайте разобьем приложение на компоненты в соответствии с его структурой. Начнем с явного объявления главного компонента App, который будет являться корневым. Для этого создадим файл App.kt по пути src/main/kotlin в проекте. Внутри файла опишем класс App, наследующий RComponent (сокращение от React Component). Дженерики пока что можно оставить стандартными (RProps и RState), а потом разберемся и с ними:

import react.*

@JsExport
class App : RComponent() {

    override fun RBuilder.render() {
        // Помещаем сюда типобезопасный HTML!
    }
}

Переместите весь наш типобезопасный HTML внутрь новой функции render. Таким образом мы поместили весь код приложения в соответствующе названный явный компонент. Теперь функция main должна как-то ссылаться на App. Это делается очень просто: достаточно сказать Реакту рендерить компонент App как ребенка корневого элемента, используя функцию child:

fun main() {
    render(document.getElementById("root")) {
        child(App::class) {}
    }
}

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


Компонент для списка

Какие части нашего приложения дублируются? Конечно же, списки видео — и это сразу же заметно. Так как и список непросмотренного, и список просмотренного имеют одинаковую функциональность, есть смысл создать единый компонент и переиспользовать его.

Сделаем это в новом файле VideoList.kt. Подобно классу App, создадим класс VideoList, наследующий RComponent и содержащий HTML DSL со списком unwatchedVideos:

import react.*
import react.dom.*

@JsExport
class VideoList : RComponent() {

    override fun RBuilder.render() {
        for (video in unwatchedVideos) {
            p {
                +"${video.speaker}: ${video.title}"
            }
        }
    }
}

Теперь часть со списками внутри App можно сделать примерно такой:

div {
    h3 {
        +"Videos to watch"
    }
    child(VideoList::class) {}

    h3 {
        +"Videos watched"
    }
    child(VideoList::class) {}
}

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


Добавляем атрибуты

Теперь мы понимаем, что при переиспользовании компонента-списка мы бы хотели заполнять его разным содержимым. Другими словами, вместо хранения списка элементов статически, мы хотели бы задавать его внешне и передавать компоненту как атрибуты. В терминологии Реакта такие атрибуты называются props. Когда атрибуты задаются, Реакт берет на себя задачу по их передаче в компонент и по рендерингу компонента.

В нашем случае мы хотим добавить атрибут, содержащий список докладов. Давайте переработаем наш код. Создайте следующий интерфейс в файле VideoList.kt:

external interface VideoListProps : RProps {
    var videos: List

Теперь изменим объявление класса VideoList, чтобы он использовал этот атрибут:

@JsExport
class VideoList : RComponent() {

    override fun RBuilder.render() {
        for (video in props.videos) {
            p {
                key = video.id.toString()
                +"${video.speaker}: ${video.title}"
            }
        }
    }
}

Так как содержимое компонента теперь потенциально динамично (то есть переданные в рантайме атрибуты могут меняться, мы так и будем делать в следующих шагах), следует проставлять свойство key в каждый элемент списка. Он помогает Реакту понять, какие части списка нужно обновить, а какие можно оставить без изменений — хорошая и почти бесплатная оптимизация! Больше информации насчет списков и ключей можно найти, например, в официальном гайде Реакта.

Наконец, на месте использования VideoList (внутри App) нам остается передать правильные атрибуты. Подставьте unwatchedVideos и watchedVideos примерно так:

child(VideoList::class) {
    attrs.videos = unwatchedVideos
}

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


Уменьшаем громоздкость вызова

Если вам тоже не очень нравится предыдущая конструкция, мы можем улучшить ее, используя крутую котлиновскую фичу под названием функция с получателем. Выделим функцию, которая делает доступ к компонентам легче: она выполняет то же самое, что и предыдущая конструкция, но изменяет синтаксис использования:

fun RBuilder.videoList(handler: VideoListProps.() -> Unit): ReactElement {
    return child(VideoList::class) {
        attrs.handler()
    }
}

Расскажем, что происходит в этом коде: мы определяем функцию videoList как расширение для типа RBuilder. Функция принимает единственный параметр handler — функцию-расширение для VideoListProps, возвращающую Unit. Функция оборачивает вызов child (который мы делали изначально для вставки VideoList), и вызывает handler на объекте attrs.

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

videoList {
    videos = unwatchedVideos
}

В общем, мы убираем из вызова не сильно информативные слова типа child, class и attrs, оставляя только специфичные для конкретного компонента символы. Аналогичные функции можно писать для всех компонентов, которые вы описываете. Запомните этот трюк! При желании потренироваться уже сейчас можете попробовать это проделать для класса App.


Добавляем интерактивность

Основная цель нашего компонента-списка — задавать видео для показа в видеоплеере. Чтобы это сделать, нужно позволить пользователю взаимодействовать с элементами списка. Начнем с простого: будем показывать выбранное пользователем видео в диалоге alert.

Для этого модифицируем код внутри функции VideoList.render. Сделаем так, чтобы при клике на элемент p соответствующее сообщение показывалось бы в диалоге:

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

Если IntelliJ IDEA просит добавить импорты, это можно сделать по нажатию Alt+Enter. Или можно добавить импорты вручную:

import kotlinx.html.js.onClickFunction
import kotlinx.browser.window

Теперь при клике на элементе списка в браузере мы увидим всплывающее сообщение о выбранном элементе:
alert


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

Добавляем состояние

Не устали?

Давайте сделаем настоящий селектор видео вместо вывода всплывающего сообщения. Будем подсвечивать выбранное видео треугольником (|>). Реакт нам поможет — он позволяет ввести некоторое состояние для компонента. Это будет очень похоже на добавление атрибутов — надо объявить интерфейс:

external interface VideoListState : RState {
    var selectedVideo: Video?
}

Дальше надо сделать следующее:


  • Подредактировать объявление класса VideoList, чтобы в качестве типа состояния он использовал VideoListState — нужно унаследовать компонент от RComponent<..., VideoListState>.
  • При рендеринге списка для выбранного видео мы должны выводить треугольник в качестве префикса.
  • Внутри onClickFunction надо записывать в состояние selectedVideo то видео, которое соответствует кликнутому элементу. Чтобы компонент перерисовывался при изменении состояния, код для изменения нужно обернуть лямбду и передать ее в функцию setState.

Когда проделаем это, мы получим такой класс:

@JsExport
class VideoList : RComponent() {

    override fun RBuilder.render() {
        for (video in props.videos) {
            p {
                key = video.id.toString()
                attrs {
                    onClickFunction = {
                        setState {
                            selectedVideo = video
                        }
                    }
                }
                if (video == state.selectedVideo) {
                    +"|> "
                }
                +"${video.speaker}: ${video.title}"
            }
        }
    }
}

Состояние стоит модифицировать только внутри setState. Так Реакт сможет обнаружить изменения и перерисовать нужные части UI быстро и эффективно.

На этом шаге у нас все, но более подробно о состоянии можно почитать в официальном React FAQ.


Состояние проекта после выполнения этого шага доступно в ветке step-03-first-component в репозитории.

Шаг 5. Работаем совместно. Композиция компонентов

Сделанные нами на предыдущем шаге пара списков сами по себе вполне работают. Однако, если мы кликнем по одному видео в каждом из списков, мы можем выбрать два видео одновременно. Это неправильно, ведь у нас только один плеер :)
pair

По-хорошему, у обоих списков должно быть единое состояние — выбранное видео, которое будет одним на все приложение. Но единое состояние не может (и не должно) храниться в разных компонентах. Принято выносить состояние наверх (как еще говорят, «поднимать» состояние).


Вынос состояния наверх

Чтобы не прибивать гвоздями разные компоненты друг к другу и не создавать спагетти-код, можно воспользоваться иерархией компонентов Реакта: передавать атрибуты из родительского компонента. Если компонент хочет изменять состояние соседнего компонента, это следует делать через общего родителя. Значит, состояние должно быть не в соседнем компоненте, а именно в родителе. Миграция состояния из компонента к родителю называется выносом состояния. Давайте выносить его в нашем случае! Для этого нам нужно добавить состояние для нашего родительского компонента, App. Будем действовать примерно так же, как и с состоянием для VideoList.

Объявим интерфейс:

external interface AppState : RState {
    var currentVideo: Video?
}

И сошлемся на него в классе App:

@JsExport
class App : RComponent()

Удалим VideoListState, так как мы теперь будем хранить эту информацию выше. Получается, мы вообще убрали состояние у списка, так что вернем его состояние к стандартному в описании класса:

@JsExport
class VideoList : RComponent()

Теперь передадим вниз состояние выбранного видео из App в VideoList как атрибут. Добавим свойство в интерфейс VideoListProps, которое будет содержать выбранное видео:

external interface VideoListProps : RProps {
    var videos: List

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

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

Но есть еще одна проблема, которую создал наш рефакторинг: у компонента нет доступа к родительскому состоянию, так что вызов setState внутри onClickFunction не сможет сделать ничего полезного. Чтобы побороть это и в итоге опять получить работающее приложение, давайте поднимем кое-что еще.


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

К сожалению, Реакт не позволяет изменять состояние родительского компонента напрямую, как бы мы этого ни хотели. Но мы можем поступить по-другому: перенести логику обработки действия пользователя в атрибут и передавать его из родителя. Помните, что в Котлине у переменных может быть функциональный тип? Добавим еще в одно свойство в интерфейс — функцию, принимающую Video и возвращающую Unit:

external interface VideoListProps : RProps {
    var videos: List

И соответственно поменяем onClickFunction на вызов этой функции из атрибутов:

onClickFunction = {
    props.onSelectVideo(video)
}

Теперь мы сможем передавать выбранное видео как атрибут и вынести логику выбора видео в родительский компонент, где и будем менять состояние. Иными словами, мы хотим поднять логику обработки кликов в родителя. Обновим оба места использования videoList:

videoList {
    videos = unwatchedVideos
    selectedVideo = state.currentVideo
    onSelectVideo = { video ->
        setState {
            currentVideo = video
        }
    }
}

Второе место отличается присваиванием watchedVideos.

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


С

© Habrahabr.ru