Используем Ramda вместе с Redux

в 13:47, , рубрики: javascript, JS, ramda, React, redux, ФП, функциональное программирование

Это перевод статьи Using Ramda With Redux, в которой рассказывается о том, как упростить ваш код на основе библиотеки Redux с помощью библиотеки Ramda, позволяющей писать код в функциональном стиле.

P.S. Если вы не знаете, что такое Ramda — приглашаю вас к переводу цикла статей о ней.

На моей текущей работе мы работаем над фронтенд-проектами, использующими React и Redux. Мы также используем библиотеку Ramda для того чтобы эффективно работать с Redux. Данный пост описывает несколько способов, в которых мы использовали Ramda в наших React/Redux приложениях.

Предпоссылки

Если вы не знакомы с этими библиотеками, давайте сделаем их краткий обзор.

React

React — это "JavaScript библиотека для создания пользовательских интерфейсов". Данный пост не является руководством к React, и многое из того, о чём я буду здесь говорить, не зависит от React. Если у вас есть желание разобраться с реактом — можно начать со статьи Пете Хантса "Мышление в стиле React".

Для данного поста, главная вещь, которую нужно знать — это то, что React подчёркивает методику декомпозиции интерфейса на дерево компонентов. Каждый компонент получает "свойства" (которые часто называют "пропсами"), которые конфигурируют этот компонент для текущего контекста. Компонент может также иметь некоторое внутреннее "состояние", которое может меняться.

Redux

Redux — это "предсказуемый контейнер состояния для JavaScript приложений". Redux имплементирует что-то похожее на архитектуру Flux, используя идеи из Elm. Будучи не привязанным к React, Redux часто используется вместе с ним.

Данный пост также не является руководством к Redux; чтобы разобраться в нём, можно посмотреть серию видео-уроков от Дэна Абрамова на egghead.io. Даже если вы не думаете, что когда-нибудь будете использовать Redux, стоит посмотреть эти видеоролики просто для того чтобы узнать, как эффективно преподавать материал. Учебный поток этих видео потрясающий.

Базовая архитектура Redux приложений состоит из одного объекта, содержащего всё состояние (state) приложения. Текущее состояние содержится в "хранилище" (store). Пользовательский интерфейс рендерится как чистая функция, использующая это состояние, что прекрасно подходит для React. Состояние никогда не изменяется напрямую; вместо этого, пользовательские экшены и/или асинхронные действия создают экшены, которые, в свою очередь, вызываются через хранилище. Хранилище передаёт текущее состояние и экшен в "редюсер", который возвращает новое состояние хранилища. Хранилище заменяет это состояние новым и обновляет пользовательский интерфейс, завершая на этом весь цикл.

Это некоторые реально классные паттерны для декомпозиции интерфейса на компоненты и декомпозиции редюсеров на кусочки, которые работают на основе изолированных частей состояния. Это дизайн, который позволяет вам начать с малого и далее вырасти до красивой модульной архитектуры. Я был скептически настроен по отношению ко всему этому вначале, потому что хранилище выглядит подобно гигантскому глобальному объекту, который хранит сразу всё. Но если вы будете следовать этим стандартным паттернам, состояние будет доступно только через явно заданные способы, которые будут препятствовать распространению беспорядка.

При использовании вместе с React, почти всё состояние, которое может храниться в индивидуальных React компонентах, — перемещается в хранилище. Большинство React компонентов в Redux приложении используют только пропсы для выполнения своей работы.

Важной частью архитектуры для данного поста является редюсер, который определяется как чистая функция из (currentState, action) -> newState, и отображает состояние компонентов интерфейса. В Redux с React, эта последняя работа выполняется чистой функцией mapStateToProps(state, ownProps) -> extraProps. ownProps это свойства, которые используются для создания компонента, и extraProps — это дополнительные свойства, которые будут переданы компоненту вместе с ownProps.

Ramda

Ramda называет себя "практичной функциональной библиотекой для JavaScript программистов". Она предоставляет множество возможностей, которые используют функциональные программисты. В отличие от чего-то вроде Immutable, она работает с чистыми JavaScript объектами.

Я ещё не написал слишком много на функциональном программировании, но я нахожу Ramda достаточно подходящим способов чтобы попасть в него, и это действительно выглядит подходящим для того стиля, который поощряет Redux.

Давайте взглянем на некоторые способы, с которыми мы можем использовать Ramda, когда мы пишем свой Redux код.

Написание редюсеров

Есть определённое количество способов, которые можно использовать с Ramda для написания своих редюсеров.

Обновление свойств объектов

В документации к Redux есть пример todo-приложения. Один из редюсеров содержит данный сниппет кода:

Object.assign({}, state, {
  completed: !state.completed
})

Если вы используете Babel и его синтаксис расширения объектов, вы можете писать это немного более лаконично, вот так например:

{
  ...state,
  completed: !state.completed
}

Есть несколько способов написать подобное на Ramda. Вот довольно прямой порт:

assoc('completed', !state.completed, state)

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

Другой способ написания этого — использование функции evolve:

evolve({
  completed: not
}, state)

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

Используя evolve, вы можете компактно указать множество трансформаций своего состояния.

Когда я использую evolve, я склонен идти дальше и использовать __ для того чтобы сделать его чуть более ясным для чтения:

evolve(__, state)({
  completed: not
})

Для преобразования таких простых вещей, я более склонен использовать стандартный синтаксис расширения объектов из ES7. Но если ваши редюсеры становятся немного более сложными, использование Ramda сильно упрощает код, как мы сможем увидеть далее.

Обновление массива элементов

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

{
  board: ['X', 'O', 'X', ' ', 'O', ' ', ' ', 'X', ' '],
  nextToken: 'O'
}

Мы имеем экшен PLACE_TOKEN, который включает в себя индекс, указывающий, куда следует установить следующий символ.

Используя синтаксис расширения массивов из ES6 и синтаксис расширения объектов из ES7, мы можем написать наш редюсер для этого в следующем виде, после извлечения маленькой функции-хелпера nextToken:

{
  ...state,
  board: [
    ...state.board.slice(0, index),
    state.nextToken,
    ...state.board.slice(index + 1)
  ],
  nextToken: nextToken(state.nextToken)
}

function nextToken(token) {
  return token === 'X' ? 'O' : 'X'
}

Это использование синтаксиса расширения массива становится довольно стандартной идиомой для иммутабельных обновлений JavaScript массивов.

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

{
  ...state,
  board: update(index, state.nextToken, state.board),
  nextToken: nextToken(state.nextToken)
}

function nextToken(token) {
  return token === 'X' ? 'O' : 'X'
}

update предоставляет нам новый массив, который обновляет элемент по индексу index указанным значением. Если вам нужно трансформировать существующий элемент массива вместо замены его, вы можете использовать функцию adjust для этого.

Далее мы можем использовать evolve, как было описано ранее, для того чтобы пойти на шаг дальше:

evolve(__, state)({
  board: update(index, state.nextToken),
  nextToken
}

function nextToken(token) {
  return token === 'X' ? 'O' : 'X'
}

Если вы не знакомы с ES6, { nextToken } — это короткая запись { nextToken: nextToken }.

Обратите внимание, что update принимает три аргумента, но мы передаём ей только два. Если мы предоставляем ramda-функции только несколько аргументов из необходимых, она вернёт нам другую функцию, которая будет ожидать получения оставшихся аргументов для выполнения действия. Это так называемое каррирование. Каждая функция в Ramda может быть каррирована подобным образом, и это становится очень мощным инструментом, когда вы разберётесь с ним.

В данном случае, evolve будет вызывать нашу каррированную функцию update с оригинальным значением state.board, также как мы вызываем нашу функцию nextToken с оригинальным значением state.nextToken.

Добавляем State в Props

Ramda также может быть удобна при добавлении состояния в пропсы компонента. В приложении крестиков-ноликов, компонент Board нуждался в элементах board и nextToken из состояния. Традиционный путь написания этого следующий:

function mapStateToProps(state) {
  return {
    board: state.board,
    nextToken: state.nextToken
  }
}

Это можно упростить с помощью Ramda-функции pick:

function mapStateToProps(state) {
  return pick(['board', 'nextToken'], state)
}

Обратите внимание, что мы прокидываем state в нашу функцию, и далее прокидываем её в pick. Когда мы видим подобный паттерн, это ключ к тому, что мы можем использовать преимущество каррирования. Давайте применим его здесь:

const mapStateToProps = pick(['board', 'nextToken'])

Здесь вновь, мы только предоставляем один из двух аргументов, в которых нуждается pick, так что мы получим взамен каррированную функцию, которая будет ожидать переменную state. И это произойдёт тогда, когда Redux вызовет эту функцию для нас.

Создание редюсеров

В секции “сокращаем заготовку” документации к Redux предлагается, как возможно написать функцию createReducer, которая бы принимала initialState и объект, содержащий обработчики состояния:

function createReducer(initialState, handlers) {
  return function reducer(state = initialState, action) {
    if (handlers.hasOwnProperty(action.type)) {
      return handlers[action.type](state, action)
    } else {
      return state
    }
  }
}

Используя функции Ramda propOr и identity, мы можем немного упростить тело функции редюсера:

function createReducer(initialState, handlers) {
  return function reducer(state = initialState, action) {
    propOr(identity, action.type, handlers)(state, action)
  }
}

propOr принимает значение по умолчанию, название свойства и объект. Если объект имеет совпадающее по имени свойство, значение этого свойства будет возвращено. Иначе будет возвращено значение по умолчанию. Начиная с этого, мы можем получить обратно функцию для вызова состояния и экшена. Если эта функцию идентична, она просто вернёт первый параметр, который является состоянием. Так что получается, что данный код будет делать точно то же самое, что и вышенаписанный изначальный код без Ramda.

Мы можем также использовать стрелочную функцию из ES6 для ещё большего упрощения этого:

function createReducer(initialState, handlers) {
  return (state = initialState, action) =>
    propOr(identity, action.type, handlers)(state, action)
}

Заключение

Я не гуру функционального программирования. Я всё ещё не могу рассказать вам о том, что такое Монады. Есть части Ramda, которые более глубоки, чем моё желание идти так далеко. Но даже несколько простых функций могут позволить намного проще писать Redux код.

Я уверен, что мы придём к новым и интересным способам использования Ramda в Redux по мере продолжения работы с ними, но это хорошее начало.

Если вы найдёте другие способы использования Ramda в Redux, пожалуйста, поделитесь ими. Я не написал слишком много об этой комбинации, поэтому было бы полезно получить больше идей от других людей.

Благодарности

Спасибо моему коллеге Люку Барбуто за представление мне Ramda. Множество данных примеров — это штуки, которые мы обнаружили во время парного программирования в нескольких проектах.

Автор: Роман Ахмадуллин

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js