Kotlin + React vs Javasript + React

Мысль перевести фронт на какой-либо js фреймворк появилась одновременно с возможностью писать React на Kotlin. И я решил попробовать. Основная проблема: мало материалов и примеров (постараюсь эту ситуацию поправить). Зато у меня полноценная типизация, безбоязненный рефакторинг, все возможности Kotlin, а главное, общий код для бека на JVM и фронта на Javascript.

В этой статье будем писать страницу на Javasript + React параллельно с её аналогом на Kotlin + React. Чтобы сравнение было честным, я добавил в Javasript типизацию.

ohidlj29--2tvypgpyosvlthsra.png
Добавить типизацию в 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. Машины отображаем в табличке. Мой проект не про машины, но общая структура в целом похожа на то, с чем я регулярно работаю.

Результат выглядит вот так (дизайнером мне не быть):

gq_gzlqmvngkcana1qcw0ydq9hg.png

Конфигурацию всей этой шайтан-машины я здесь описывать не буду, это тема для отдельной статьи (пока можно курить исходники от этой).

Подгрузка данных с бека


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

javascript
kotlin
class Home
  extends React.Component
    {



  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, //(2)
  colors: Array //(2)
};

export default Home;


class Home(
  props: RouteResultProps<*>
) : RComponent
, 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 //(2)
lateinit var colors: List //(2)
}

private val serializer: JSON = JSON()

suspend fun  fetchJson( //(4)
  url: String,
  kSerializer: KSerializer
): T {
  val json = window.fetch(url)
    .await().text().await()
  return serializer.parse(
    kSerializer, json
  )
}



Выглядит очень похоже. Но есть и различия:

  1. Дефолтные значения можно прописать там же, где объявляется тип. Так легче поддерживать целостность кода.
  2. lateinit позволяет не задавать дефолтное значение вообще для того, что будет подгружено позже. При компиляции такая переменная считается как NotNull, но при каждом обращении проверяется то, что она была заполнена и выдается человекочитабельная ошибка. Особенно это будет актуально при более сложном объекте, чем массив. Знаю, того же можно было бы достигнуть при помощи flow, но это настолько громоздко, что я не стал пробовать.
  3. kotlin-react из коробки дает функцию setState, но она не сочетается с корутинами, потому что не inline. Пришлось скопировать и поставить inline.
  4. Собственно, корутины. Это замена async/await и много чего ещё. Например, через них сделан yield. Интересно, что в синтаксис добавлено только слово suspend, всё остальное — просто код. Поэтому больше свободы использования. А ещё немного более жесткий контроль на уровне компиляции. Так, нельзя оверрайдить componentDidMount с suspend модификатом, что логично: componentDidMount синхронный метод. Зато можно в любом месте кода вставить асинхронный блок launch { }. Можно в явном виде принимать асинхронную функцию в параметре или поле класса (чуть ниже пример из моего проекта).
  5. В 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 || ""}&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
    }
  }


Хочу обратить внимание на Car::class.serializer().list. Дело в том, что jetBrains написала библиотеку для сериализации/десериализации, которая одинаково работает на JVM и JS. Во-первых, меньше проблем и кода в случае если бек на JVM. Во-вторых валидность пришедшего json проверяется во время десериализации, а не когда-нибудь при обращении, так что при смене версии бека, и при интеграциях впринципе, проблемы будут находиться быстрее.

Рисуем шапку с фильтрами


Напишем stateless component для отображения двух выпадающих списков. В случае Kotlin это будет просто функция, в случае js — отдельный компонент, который будет генерироваться react loader при сборке.

javascript
kotlin
type HomeHeaderProps = {
brands: Array,
brand?: string,
onBrandChange: (string) => void,
colors: Array,
color?: string,
onColorChange: (string) => void
}

const HomeHeader = ({
brands,
brand,
onBrandChange,
colors,
color,
onColorChange
}: HomeHeaderProps) => (
  
Brand: onBrandChange(e.value) } options={withDefault("all", brands.map(value => ({ label: value, value: value })))} /> Color: onColorChange(e.value) } options={withDefault("all", colors.map(value => ({ label: value, value: value })))} />
); function withDefault( label, options ) { options.unshift({ label: label, value: null }); return options; }


private fun RBuilder.homeHeader(
brands: List,
brand: String?,
onBrandChange: (String?) -> Unit,
colors: List,
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 
  List>.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 (
      
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 || ""}`
+ `&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
}) => (
  
    
      rowData["brand"]
            }/>
    
      
        {rowData['color']}
      
  }/>
    
      rowData["year"]}
    />
  
);


private fun RBuilder.homeContent(
  cars: List
) {
  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 нужно совсем немного, потому что большая часть проблем легко решается при помощи языка.

А что Вы думаете о замене всего этого зоопарка одним языком с большим набором плюшек впридачу?

Для заинтересовавшихся исходники.

P.S.: В планах написать статьи об конфигах, интеграции с JVM и о dsl формирующем одновременно react-dom и обычный html.

Уже написаные статьи о Kotlin:

Послевкусие от Kotlin, часть 1
Послевкусие от Kotlin, часть 2
Послевкусие от Kotlin, часть 3. Корутины — делим процессорное время

© Habrahabr.ru