- PVSM.RU - https://www.pvsm.ru -

Kotlin + React vs Javasript + React

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

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

Kotlin + React vs Javasript + React - 1 [1]

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

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

Kotlin + React vs Javasript + React - 2

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

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

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

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
  )
}

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

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

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

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

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

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

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

Автор: Нефедьев Георгий

Источник [11]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/java/290364

Ссылки в тексте:

[1] Image: https://habr.com/post/418553/

[2] lateinit: https://kotlinlang.org/docs/reference/properties.html#late-initialized-properties-and-variables

[3] корутины: https://kotlinlang.ru/docs/reference/coroutines.html

[4] yield: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines.experimental/-sequence-builder/yield.html

[5] primereact: https://www.primefaces.org/primereact/

[6] ts2kt: https://github.com/Kotlin/ts2kt

[7] исходники: https://github.com/gnefedev/kotlin-js-react

[8] Послевкусие от Kotlin, часть 1: https://habrahabr.ru/post/331280/

[9] Послевкусие от Kotlin, часть 2: https://habrahabr.ru/post/337002/

[10] Послевкусие от Kotlin, часть 3. Корутины — делим процессорное время: https://habrahabr.ru/post/339618/

[11] Источник: https://habr.com/post/418553/?utm_source=habrahabr&utm_medium=rss&utm_campaign=418553