https://habr.com/post/418553/- ReactJS
- Kotlin
- JavaScript
- Java
Мысль перевести фронт на какой-либо js фреймворк появилась одновременно с возможностью писать React на Kotlin. И я решил попробовать. Основная проблема: мало материалов и примеров (постараюсь эту ситуацию поправить). Зато у меня полноценная типизация, безбоязненный рефакторинг, все возможности Kotlin, а главное, общий код для бека на JVM и фронта на Javascript.
В этой статье будем писать страницу на Javasript + React параллельно с её аналогом на Kotlin + React. Чтобы сравнение было честным, я добавил в Javasript типизацию.
Добавить типизацию в Javascript оказалось не так просто. Если для Kotlin мне понадобились gradle, npm и webpack, то для Javascript мне понадобились npm, webpack, flow и babel с пресетами react, flow, es2015 и stage-2. При этом flow тут как-то сбоку, и запускать его надо отдельно и отдельно дружить его с IDE. Если вынести за скобки сборку и подобное, то для непосредственного написания кода с одной стороны остается Kotlin+React, а с другой Javascript+React+babel+Flow+ES5|ES6|ES7.
Для нашего примера сделаем страничку со списком машин и возможностью фильтрации по марке и цвету. Возможные для фильтрации марку и цвет подтаскиваем с бека один раз при первой загрузке. Выбранные фильтры сохраняем в query. Машины отображаем в табличке. Мой проект не про машины, но общая структура в целом похожа на то, с чем я регулярно работаю.
Результат выглядит вот так (дизайнером мне не быть):
Конфигурацию всей этой шайтан-машины я здесь описывать не буду, это тема для отдельной статьи (пока можно курить исходники от этой).
Подгрузка данных с бека
Для начала надо подгрузить бренды и доступные цвета с бека.
javascript
|
kotlin
|
class Home
extends React.Component
<ContextRouter, State>{
state = {
loaded: false, //(1)
color: queryAsMap(
this.props.location.search
)["color"],
brand: queryAsMap(
this.props.location.search
)["brand"],
brands: [], //(2)
colors: [] //(2)
};
async componentDidMount()
{
this.setState({ //(3)
brands: await ( //(4)
await fetch('/api/brands')
).json(),
colors: await ( //(4)
await fetch('/api/colors')
).json()
});
}
}
type State = {
color?: string, //(5)
brand?: string, //(5)
loaded: boolean, //(1)
brands: Array<string>, //(2)
colors: Array<string> //(2)
};
export default Home;
|
class Home(
props: RouteResultProps<*>
) : RComponent
<RouteResultProps<*>, State>
(props) {
init {
state = State(
color = queryAsMap(
props.location.search
)["color"],
brand = queryAsMap(
props.location.search
)["brand"]
)
}
override fun componentDidMount()
{
launch {
updateState { //(3)
brands = fetchJson( //(4)
"/api/brands",
StringSerializer.list
)
colors = fetchJson( //(4)
"/api/colors",
StringSerializer.list
)
}
}
}
}
class State(
var color: String?, //(5)
var brand: String? //(5)
) : RState {
var loaded: Boolean = false //(1)
lateinit var brands: List<String> //(2)
lateinit var colors: List<String> //(2)
}
private val serializer: JSON = JSON()
suspend fun <T> fetchJson( //(4)
url: String,
kSerializer: KSerializer<T>
): T {
val json = window.fetch(url)
.await().text().await()
return serializer.parse(
kSerializer, json
)
}
|
Выглядит очень похоже. Но есть и различия:
- Дефолтные значения можно прописать там же, где объявляется тип. Так легче поддерживать целостность кода.
- lateinit позволяет не задавать дефолтное значение вообще для того, что будет подгружено позже. При компиляции такая переменная считается как NotNull, но при каждом обращении проверяется то, что она была заполнена и выдается человекочитабельная ошибка. Особенно это будет актуально при более сложном объекте, чем массив. Знаю, того же можно было бы достигнуть при помощи flow, но это настолько громоздко, что я не стал пробовать.
- kotlin-react из коробки дает функцию setState, но она не сочетается с корутинами, потому что не inline. Пришлось скопировать и поставить inline.
- Собственно, корутины. Это замена async/await и много чего ещё. Например, через них сделан yield. Интересно, что в синтаксис добавлено только слово suspend, всё остальное — просто код. Поэтому больше свободы использования. А ещё немного более жесткий контроль на уровне компиляции. Так, нельзя оверрайдить componentDidMount с
suspend
модификатом, что логично: componentDidMount синхронный метод. Зато можно в любом месте кода вставить асинхронный блок launch { }
. Можно в явном виде принимать асинхронную функцию в параметре или поле класса (чуть ниже пример из моего проекта).
- В Javascript меньший контроль nullable. Так в получившемся state можно менять nullability полей brand, color и loaded и всё будет собираться. В Kotlin варианте будут оправданные ошибки компиляции.
Параллельный поход в бек при помощи корутинsuspend fun parallel(vararg tasks: suspend () -> Unit) {
tasks.map {
async { it.invoke() } //запускаем каждый task, но не ждем ответа. async {} возвращает что-то вроде promise
}.forEach { it.await() } //все запустили, теперь ждем
}
override fun componentDidMount() {
launch {
updateState {
parallel({
halls = hallExchanger.all()
}, {
instructors = instructorExchanger.active()
}, {
groups = fetchGroups()
})
}
}
}
Теперь подгрузим машины с бека используя фильтры из query
JS:
async loadCars() {
let url = "/api/cars?brand=" + (this.state.brand?this.state.brand:"") + "&color=" + (this.state.color?this.state.color:"");
this.setState({
cars: await (await fetch(url)).json(),
loaded: true
});
}
Kotlin:
private suspend fun loadCars() {
val url = "/api/cars?brand=${state.brand.orEmpty()}&color=${state.color.orEmpty()}"
updateState {
cars = fetchJson(url, Car::class.serializer().list) //(*)
loaded = true
}
}
Разработчики Kotlin позаботились об ежедневных нуждах разработчиков. Extension orEmpty() и template строки в данном случае упростили формирование урла.
Отдельно хочу обратить внимание на
Car::class.serializer().list
. Дело в том, что jetBrains написала библиотеку для сериализации/десериализации, которая одинаково работает на JVM и JS. Во-первых, меньше проблем и кода в случае если бек на JVM. Во-вторых валидность пришедшего json проверяется во время десериализации, а не когда-нибудь при обращении, так что при смене версии бека, и при интеграциях впринципе, проблемы будут находиться быстрее.
Рисуем шапку с фильтрами
Напишем stateless component для отображения двух выпадающих списков. В случае Kotlin это будет просто функция, в случае js — отдельный компонент, который будет генерироваться react loader при сборке.
javascript
|
kotlin
|
type HomeHeaderProps = {
brands: Array<string>,
brand?: string,
onBrandChange: (string) => void,
colors: Array<string>,
color?: string,
onColorChange: (string) => void
}
const HomeHeader = ({
brands,
brand,
onBrandChange,
colors,
color,
onColorChange
}: HomeHeaderProps) => (
<div>
Brand:
<Dropdown
value={brand}
onChange={e =>
onBrandChange(e.value)
}
options={withDefault("all",
brands.map(value => ({
label: value, value: value
})))}
/>
Color:
<Dropdown
value={color}
onChange={e =>
onColorChange(e.value)
}
options={withDefault("all",
colors.map(value => ({
label: value, value: value
})))}
/>
</div>
);
function withDefault(
label, options
) {
options.unshift({
label: label, value: null
});
return options;
}
|
private fun RBuilder.homeHeader(
brands: List<String>,
brand: String?,
onBrandChange: (String?) -> Unit,
colors: List<String>,
color: String?,
onColorChange: (String?) -> Unit
) {
+"Brand:"
dropdown(
value = brand,
onChange = onBrandChange,
options = brands.map {
SelectItem(
label = it, value = it
)
} withDefault "all"
) {}
+"Color:"
dropdown(
value = color,
onChange = onColorChange,
options = colors.map {
SelectItem(
label = it, value = it
)
} withDefault "all"
) {}
}
infix fun <T : Any>
List<SelectItem<T>>.withDefault(
label: String
) = listOf(
SelectItem(
label = label, value = null
)
) + this
|
Первое, что бросается в глаза — HomeHeaderProps в JS части, мы вынуждены объявить входящие параметры отдельно. Неудобно.
Ещё немного изменился синтаксис Dropdown. Я тут использую
primereact, естественно, пришлось писать kotlin обертку. С одной стороны это лишняя работа (слава богу, есть
ts2kt), но с другой — это возможность местами сделать api удобнее.
Ну и немного синтаксического сахара при формировании итемов для dropdown.
})))}
в js варианте выглядит интересно, но это не беда. Зато выпрямление последовательности слов намного приятнее: «преобразуем цвета в items и добавляем `all` по-умолчанию», вместо «добавляем `all` к цеветам преобразованным в items». Это кажется небольшим бонусом, но когда у тебя несколько таких переворотов подряд…
Сохраняем фильтры в query
Теперь нужно при выборе фильтров по марке и цвету изменять state, подгружать машины с бека и менять урл.
javascript
|
kotlin
|
render() {
if (!this.state.loaded)
return null;
return (
<HomeHeader
brands={this.state.brands}
brand={this.state.brand}
onBrandChange={brand =>
this.navigateToChanged({brand})}
colors={this.state.colors}
color={this.state.color}
onColorChange={color =>
this.navigateToChanged({color})}
/>
);
}
navigateToChanged({
brand = this.state.brand,
color = this.state.color
}: Object) { //(*)
this.props.history.push(
"?brand=" + (brand?brand:"")
+ "&color=" + (color?color:""));
this.setState({
brand,
color
});
this.loadCars()
}
|
override fun
RBuilder.render() {
if (!state.loaded) return
homeHeader(
brands = state.brands,
brand = state.brand,
onBrandChange = {
navigateToChanged(brand = it) },
colors = state.colors,
color = state.color,
onColorChange = {
navigateToChanged(color = it) }
)
}
private fun navigateToChanged(
brand: String? = state.brand,
color: String? = state.color
) {
props.history.push(
"?brand=${brand.orEmpty()}"
+ "&color=${color.orEmpty()}")
updateState {
this.brand = brand
this.color = color
}
launch {
loadCars()
}
}
|
И здесь опять проблема с дефолтными значениями параметров. Почему-то flow не разрешил мне одновременно иметь типизацию, деструктор и дефолтное значение взятое из state. Возможно, просто бага. Но, если бы все-таки вышло, то пришлось бы объявить тип за пределами класса, т.е. вообще на экран выше или ниже.
Рисуем таблицу
Последнее что нам осталось сделать — написать stateless component для отрисовки таблицы с машинами.
javascript
|
kotlin
|
const HomeContent = (props: {
cars: Array<Car>
}) => (
<DataTable value={props.cars}>
<Column header="Brand"
body={rowData =>
rowData["brand"]
}/>
<Column header="Color"
body={rowData =>
<span
style={{
color: rowData['color']
}}>
{rowData['color']}
</span>
}/>
<Column header="Year"
body={rowData =>
rowData["year"]}
/>
</DataTable>
);
|
private fun RBuilder.homeContent(
cars: List<Car>
) {
datatable(cars) {
column(header = "Brand") {
+it.brand
}
column(header = "Color") {
span {
attrs.style = js {
color = it.color
}
+it.color
}
}
column(header = "Year") {
+"${it.year}"
}
}
}
|
Здесь видно, как я выпрямил api primefaces, и как в kotlin-react задавать стиль. Это обычный json, как и в js варианте. В своем проекте я делал обертку, которая выглядит также, но со строгой типизацией, насколько это возможно в случае html стилей.
Заключение
Ввязываться в новую технологию рискованно. Мало гайдов, на stack overflow ничего нет, не хватает некоторых базовых вещей. Но в случае с Kotlin мои затраты окупились.
Пока я готовил эту статью, я узнал кучу новых вещей о современном Javascript: flow, babel, async/await, шаблоны jsx. Интересно, насколько быстро эти знания устареют? И всё это не нужно, если использовать Kotlin. При этом знать о React нужно совсем немного, потому что большая часть проблем легко решается при помощи языка.
А что Вы думаете о замене всего этого зоопарка одним языком с большим набором плюшек впридачу?
Для заинтересовавшихся
исходники.
PS
В планах написать статьи об конфигах, интеграции с JVM и о dsl формирующем одновременно react-dom и обычный html.
Уже написаные статьи о Kotlin.
Послевкусие от Kotlin, часть 1
Послевкусие от Kotlin, часть 2
Послевкусие от Kotlin, часть 3. Корутины — делим процессорное время