Написать пост

Упрощенный Redux

Аватарка пользователя Sultan

Рассмотрели абстракции, линзы и каррированные функции в Redux, слегка коснувшись комбинаторного программирования.

Обложка поста Упрощенный Redux

В первой части статьи Декларативный JavaScript мы рассмотрели конструкцию switch/case и представили её в виде функций высшего порядка. В этой части мы продолжим развивать эту идею и применим её в создании редьюсеров Redux.

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

Абстракции

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

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

			const initialState = {
  attempts: 0,
  isSigned: false,
}

// action payload  ⮧        ⮦ current state
const signIn = pincode => state => ({
  attempts: state.attempts + 1,
  isSigned: pincode === '2023',
})

const signOut = () => () => initialState

const authReducer = createReducer(
  initialState,
  on('SIGN_IN', signIn),
  on('SIGN_OUT', signOut),
  on('SIGN_OUT', clearCookies),
)
		

Создание редьюсера подобно оглавлению книги: оно позволяет быстро найти необходимую функцию по типу экшена. Функции, такие как signIn и signOut, иллюстрируют преобразования состояния объекта: они принимают на вход полезную нагрузку и текущее состояние объекта. Второстепенный код, включающий проверку типов экшена, вызов нужной функции и прочее, скрыты за функциями createReducer и on.

			const createReducer = (initialState, ...fns) => (state, action) => (
  fns.reduce(
    (nextState, fn) => fn(nextState, action),
    state || initialState,
  )
)

const on = (actionType, reducer) => (state, action) => (
  action.type === actionType
    ? reducer(action.payload)(state)
    : state
)
		

Для удобства мы добавим вспомогательные функции useAction и useStore:

			import {useState} from 'react'
import {useDispatch, useSelector} from 'react-redux'

const useAction = type => {
  const dispatch = useDispatch()
  return payload => dispatch({type, payload})
}

const useStore = path => (
  useSelector(state => state[path])
)

const SignIn = () => {
  const [pincode, setPincode] = useState('')
  const signIn = useAction('SIGN_IN')
  const attempts = useStore('attempts')
  
  return (
    <form>
      <h1>You have {3 - attempts} attempts!</h1>
      <input value={pincode} onChange={event => setPin(event.target.value)}/>
      <button disabled={attempts >= 3} onClick={() => signIn(pincode)}>
        Sign In
      </button>
    </form>
  )
}
		

Линзы

В функциональном программировании, линзы – это абстракции, которые позволяют работать с вложенными структурами данных, такими как объекты или массивы. Проще говоря это иммутабельные сеттеры и геттеры. Эти функции называются линзами, потому что они позволяют сфокусироваться на значении определенной ветки объекта:

Упрощенный Redux 1

Для начало давайте взглянем как обновляется значение объекта без линз:

			const initialState = {
  lastUpdate: new Date(),
  user: {
    firstname: 'Peter',
    lastname: 'Griffin',
    phoneNumbers: ['+19738720421'],
    address: {
      street: '31 Spooner',
      zip: '00093',
      city: 'Quahog',
      state: 'Rhode Island',
    }
  }
}

const updateCity = name => state => ({
  ...state,
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: name,
    }
  }
})

const userReducer = createReducer(
  initialState,
  on('UPDATE_CITY', updateCity),
  ...
)
		

:

			const updateCity = name => state => (
  set('user.address.city', name, state)
)

const updateHomePhone = value => state => (
  set('user.phoneNumbers.0', value, state)
)

const useStore = path => useSelector(get(path))
// or by composing
const useStore = compose(useSelector, get)

const homePhone = useStore('user.phoneNumbers.0')
		

Ниже представлена реализация линз. Обратите внимание, что все эти функции уже существуют в библиотеке Ramda:

			const update = (keys, fn, obj) => {
  const [key, ...rest] = keys
  if (keys.length === 1) {
    return Array.isArray(obj)
      ? obj.map((v, i) => i.toString() === key ? fn(v) : v)
      : ({...obj, [key]: fn(obj[key])})
  }

  return Array.isArray(obj)
    ? obj.map((v, i) => i.toString() === key ? update(rest, fn, v) : v)
    : {...obj, [key]: update(rest, fn, obj[key])}
}

const get = value => obj => (
  value
    .split(`.`)
    .reduce((acc, key) => acc?.[key], obj)
)

const set = (path, fn, object) => (
  update(
    path.split('.'),
    typeof fn === 'function' ? fn : () => fn,
    object,
  )
)

const compose = (...fns) => (...args) => (
  fns.reduceRight(
    (x, fn, index) => index === fns.length - 1 ? fn(...x) : fn(x),
    args
  )
)
		

Каррированные функции

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

			set('user.address.city')(name)(state)
		

Можно написать функцию curry, которая позволит преобразовать любую функцию в каррированную и позволит вызывать её с разным количеством параметров:

			set('user.address.city', 'Harrisburg', state)
set('user.address.city', 'Harrisburg')(state)
set('user.address.city')('Harrisburg', state)
set('user.address.city')('Harrisburg')(state)
		

Ниже приведена реализация и пример ее использования.

			const curry = fn => (...args) => (
  args.length >= fn.length
    ? fn(...args)
    : curry(fn.bind(undefined, ...args))
)

const set = curry((path, fn, object) =>
  update(
    path.split('.'),
    typeof fn === 'function' ? fn : () => fn,
    object,
  )
)

const get = curry((value, obj) =>
  value
    .split(`.`)
    .reduce((acc, key) => acc?.[key], obj)
)
		

Я часто замечаю, что многие разработчики по какой-то причине оборачивают callback функции в анонимные, только чтобы передать параметр.

			fetch('api/users')
  .then(res => res.json())
  .then(data => setUsers(data)) // ⇐ worng
  .catch(error => console.log(error))   // ⇐ wrong
		

Вместо этого просто нужно передать функцию в качестве параметра. Однако стоит помнить, что функция then ожидает колбэк-функцию с двумя параметрами. Поэтому прямая передача будет безопасной только если setUsers ожидает только один параметр.

			fetch('api/users')
  .then(res => res.json())
  .then(setUsers)
  .catch(console.log)
		

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

Упрощенный Redux 2
			then(data => setUsers(data))
// ⮁ it equals
then(setUsers)
		

Предлагаю сократить функцию updateCity:

			const updateCity = name => state => (
  set('user.address.city', name, state)
)
// ⮁ it equals
const updateCity = set('user.address.city')
		

Можно сразу вставить функцию в редюсер без объявления переменной:

			const userReducer = createReducer(
  initialState,
  on('UPDATE_CITY', set('user.address.city')),
  on('UPDATE_HOME_PHONE', set('user.phones.0')),
  ...
)
		

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

			const signIn = pincode => state => ({
  attempts: state.attempts + 1
  isSigned: pincode === '2023',
})

// 'state => ({ ...' is removed
const signIn = pincode => compose(
  set('attempts', attempts => attempts + 1),
  set('isSigned', pincode === '2023'),
)

const updateCity = name => state => ({
  lastUpdate: new Date(),
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: name,
    }
  }
})

// ⮁ it equals
const updateCity = name => compose(
  set('lastUpdate', new Date()),
  set('user.address.city', name),
)
		

Такой стиль программирования называется комбинаторным (point free) и широко используется в функциональном программировании.

Эта статья получилась немного длиннее, чем планировалось, но я надеюсь, что вы узнали что-то новое для себя. В следующей статье мы представим конструкцию try/catch в виде функции. И, как обычно, небольшой тизер к следующему посту:

			const result = trap(unstableCode)
  .pass(x => x * 2)
  .catch(() => 3)
  .release(x => x * 10)
		
Следите за новыми постами
Следите за новыми постами по любимым темам
757 открытий1К показов