Практика функционального программирования на JavaScript — методы составления данных

Spread the love

Оригинальная статья: Richard TongPractical Functional Programming in JavaScript — Techniques for Composing Data

Всем привет. Добро пожаловать на мою серию статей о практике функционального программирования на JavaScript. Сегодня мы рассмотрим методы составления данных (Composing Data), то есть лучшие практики, которые облегчают жизнь при работе со структурированными данными. Составление данных связано с формой и структурой данных и является таким же фундаментальным, как и преобразование, когда речь идет о функциональном программировании в JavaScript. Если все преобразования A => B, то при составлении данных рассматривается, как именно A становится B, когда и A, и B являются структурированными данными. From Geeks

Структурированные данные — это данные, которые соответствуют модели данных, имеют четко определенную структуру, следуют последовательному порядку и могут быть легко доступны и использоваться человеком или компьютерной программой.

Структурированные данные могут представлять что угодно, от профиля пользователя до списка книг и транзакций на банковском счете. Если вы когда-либо работали с записями в базе данных, вы работали со структурированными данными.

Существует множество способов составления данных, поскольку эта территория все еще относительно не развита. Хорошая компоновка данных означает разницу между простотой чтения / сложностью работой с кодом / кодом который вызывает только раздражение. Давайте рассмотрим это на практике, выполнив структурированное преобразование данных. Возьмем к примеру следующие структурированные данные о пользователях

const users = [
  {
    _id: '1',
    name: 'George Curious',
    birthday: '1988-03-08',
    location: {
      lat: 34.0522,
      lon: -118.2437,
    },
  },
  {
    _id: '2',
    name: 'Jane Doe',
    birthday: '1985-05-25',
    location: {
      lat: 25.2048,
      lon: 55.2708,
    },
  },
  {
    _id: '3',
    name: 'John Smith',
    birthday: '1979-01-10',
    location: {
      lat: 37.7749,
      lon: -122.4194,
    },
  },
]

Допустим, нам нужно будет превратить эти пользовательские данные в данные, которые будут отображаться, например, на панели администратора. Согласно следующим требованиям:

  • Отображать только имя (firstName)
  • Отображать возраст (age) вместо дня рождения (birthday)
  • Отображать название города (city) вместо координат местоположения

Окончательный результат должен выглядеть примерно так.

const displayUsers = [
  {
    _id: '1',
    firstName: 'George',
    age: 32,
    city: 'Los Angeles',
  },
  {
    _id: '2',
    firstName: 'Jane',
    age: 35,
    city: 'Trade Center Second',
  },
  {
    _id: '3',
    firstName: 'John',
    age: 41,
    city: 'San Francisco',
  },
]

На верхнем уровне представления users структурированы как массив объектов. Поскольку displayUsers также является массивом объектов, это хороший случай для использования функции map. Из документов MDN docs,

Метод map() создает новый массив, заполненный результатами вызова предоставленной функции для каждого элемента в вызывающем массиве.

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

Promise.all(users.map(async user => ({
  _id: user._id,
  firstName: user.name.split(' ')[0],
  age: (Date.now() - new Date(user.birthday).getTime()) / 365 / 24 / 60 / 60 / 1000,
  city: await fetch(
    `https://geocode.xyz/${user.location.lat},${user.location.lon}?json=1`,
  ).then(res => res.json()).then(({ city }) => city),
}))).then(console.log)

/* [
  { _id: '1', firstName: 'George', age: 32, city: 'Los Angeles' },
  { _id: '2', firstName: 'Jane', age: 35, city: 'Trade Center Second' },
  { _id: '3', firstName: 'John', age: 41, city: 'San Francisco' },
] */

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

// user {
//   name: string,
// } => firstName string

const getFirstName = ({ name }) => name.split(' ')[0]

// ms number => years number
const msToYears = ms => Math.floor(ms / 365 / 24 / 60 / 60 / 1000)

// user {
//   birthday: string,
// } => age number

const getAge = ({ birthday }) => msToYears(
  Date.now() - new Date(birthday).getTime(),
)

// user {
//   location: { lat: number, lon: number },
// } => Promise { city string }

const getCityName = ({ location: { lat, lon } }) => fetch(
  `https://geocode.xyz/${lat},${lon}?json=1`,
).then(res => res.json()).then(({ city }) => city)

Для получения переменных из свойств объекта эти функции используют присвоение деструктуризацию (destructuring assignment). Здесь мы видим композицию данных в силу разбивки нашей проблемы на более мелкие проблемы. Когда вы разбиваете одни объекты на более мелкие объекты (меньшие по размеру функции), вам нужно указать больше входных и выходных параметров. Таким образом, вы создаете больше данных в результате написания более понятного кода. Из документации ясно, что getFirstName, getAge и getCityName ожидают пользовательский объект user в качестве входного параметра. getAge дополнительно преобразует миллисекунды в года, msToYears.

  • getFirstName — берет user с атрибутом name и возвращает только первое слово имени для firstName
  • getAge — берет user с атрибутом birthday, например 1992-02-22 и возвращает соответствующий age в годах
  • getCityName — берет user с объектом location {lat, lon} и возвращает название ближайшего города в качестве Promise.

Забыли, что такое Promise? Посмотрите в MDN документацию MDN docs

Объект Promise представляет возможное успешное завершение (или ошибочное завершение) асинхронной операции и ее итоговое значение.

Здесь я не буду углубляться в Promises. По сути, если возвращаемого значения еще нет, вы получаете Promises (Обещание) вместо него. В getCityName мы делаем запрос к внешнему API через fetch и получаем Promise, потому что отправка запроса и ожидание его ответа является асинхронной операцией. Получение значения названия города займет некоторое время.

Собрав все воедино, вот один из способов выполнить полное преобразование. Благодаря нашей новой структуре данных мы теперь можем ясно видеть новые поля firstName, age и city, вычисляемые из объекта user.

Promise.all(users.map(async user => ({
  _id: user._id,
  firstName: getFirstName(user),
  age: getAge(user),
  city: await getCityName(user),
}))).then(console.log) 

/* [
  { _id: '1', firstName: 'George', age: 32, city: 'Los Angeles' },
  { _id: '2', firstName: 'Jane', age: 35, city: 'Trade Center Second' },
  { _id: '3', firstName: 'John', age: 41, city: 'San Francisco' },
] */

Этот код довольно хорош, но можно сделать еще лучше. У нас есть некоторый шаблонный код Promise, и я не самый большой поклонник того, как мы выражаем асинхронное преобразование user => ({…}). Что касается ванильного JavaScript, этот код великолепен, однако, улучшения могут быть сделаны с помощью библиотечных функций. В частности, мы можем улучшить этот пример, используя fork и map из моей библиотеки асинхронного функционального программирования rubico. И нет, я не верю, что мы могли бы улучшить этот пример, используя другую библиотеку.

  • map функция, довольно часто реализуемая асинхронными библиотеками; например, вы можете найти варианты map в библиотеках Bluebird и async. map берет функцию и применяет ее к каждому элементу входных данных, возвращая результаты. Если какие-либо значение являются Promises, map возвращает Promise в окончательный результат.
  • Вы не найдете fork нигде, кроме rubico, хотя его создание было частично вдохновлено функциями параллельного выполнения, такие как async.parallel  и Promise.all. fork немного похож на Promise.all, но вместо Promises он принимает массив или объект функций, которые потенциально могут возвращать Promises, и разрешает каждую функцию на основе переданных данных. Если какие-либо значения являются Promises, fork ждет выполнения этих Promises и возвращает результат в окончательном значение.

Мы можем выразить предыдущее преобразование с помощью функций fork и map следующим образом

// users [{
//   _id: string,
//   name: string,
//   birthday: string,
//   location: { lat: number, lon: number },
// }] => displayUsers [{
//   _id: string,
//   firstName: string,
//   age: number,
//   city: string,
// }]

map(fork({
  _id: user => user._id,
  firstName: getFirstName,
  age: getAge,
  city: getCityName, // fork and map will handle the Promise resolution
}))(users).then(console.log) 

/* [
  { _id: '1', firstName: 'George', age: 32, city: 'Los Angeles' },
  { _id: '2', firstName: 'Jane', age: 35, city: 'Trade Center Second' },
  { _id: '3', firstName: 'John', age: 41, city: 'San Francisco' },
] */

Больше нет Promise, и мы таким образом сократили трансформацию. Здесь мы одновременно указываем выходной массив объектов [{_id, firstname, age, city}] и способы, которыми мы вычисляем эти значения из объекта user: getFirstName, getAge и getCityName. Мы также прошли полный цикл; Теперь мы декларативно составляем массив пользовательских объектов в массив отображаемых пользовательских объектов.

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

Спасибо за прочтение!

Остальные статьи этой серии вы можете найти на ресурсе Rubico github. Увидимся в следующий раз Practical Functional Programming in JavaScript — Control Flow

Была ли вам полезна эта статья?
[3 / 5]

Spread the love
Подписаться
Уведомление о
guest
0 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments