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

Поскольку функции можно передавать куда угодно, мы можем поместить их в аргументы функций.

Мой первый практический опыт работы с программированием в целом был связан с началом написания кода на JavaScript, и одна из концепций на практике, которая меня смущала, была передача функций в другие функции. Я пытался сделать некоторые из этих "продвинутых" вещей, которые применяют все профессионалы, но в итоге у меня получалось что-то вроде этого:

function getDate(callback) {
  return callback(new Date())
}

function start(callback) {
  return getDate(callback)
}

start(function (date) {
  console.log(`Todays date: ${date}`)
})

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

const date = new Date()
console.log(`Todays date: ${date}`)

Но почему этого недостаточно для более сложных ситуаций? Какой смысл в создании пользовательской функции getDate(callback) и необходимости делать дополнительную работу, кроме как для ощущения собственного превосходства?  

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

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

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

Функция с намерением

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

Как насчет функции, которая примет объект и вернет новый, который обновит стили так, как мы этого хотели?

Давайте поработаем с этим объектом (мы будем называть его компонент):

const component = {
  type: 'label',
  style: {
    height: 250,
    fontSize: 14,
    fontWeight: 'bold',
    textAlign: 'center',
  },
}

Мы хотим, чтобы наша функция сохраняла height не менее 300 и применяла border к компонентам кнопок (компоненты с type: 'button') и возвращала их нам.

Это может выглядеть примерно так:

function start(component) {
  // Restrict it from displaying in a smaller size
  if (component.style.height < 300) {
    component.style['height'] = 300
  }
  if (component.type === 'button') {
    // Give all button components a dashed teal border
    component.style['border'] = '1px dashed teal'
  }
  if (component.type === 'input') {
    if (component.inputType === 'email') {
      // Uppercase every letter for email inputs
      component.style.textTransform = 'uppercase'
    }
  }
  return component
}

const component = {
  type: 'div',
  style: {
    height: 250,
    fontSize: 14,
    fontWeight: 'bold',
    textAlign: 'center',
  },
}

const result = start(component)
console.log(result)

Результат:

{
  "type": "div",
  "style": {
    "height": 300,
    "fontSize": 14,
    "fontWeight": "bold",
    "textAlign": "center"
  }
}

Предположим, что мы пришли к идее, будто каждый компонент может иметь больше компонентов внутри себя, помещая их в свойство children. Таким образом, необходимо сделать так, чтобы это свойство обрабатывало и внутренние компоненты. 

Итак, давайте зададим такой компонент:

{
  "type": "div",
  "style": {
    "height": 300,
    "fontSize": 14,
    "fontWeight": "bold",
    "textAlign": "center"
  },
  "children": [
    {
      "type": "input",
      "inputType": "email",
      "placeholder": "Enter your email",
      "style": {
        "border": "1px solid magenta",
        "textTransform": "uppercase"
      }
    }
  ]
}

Очевидно, что наша функция пока не справляется с поставленной задачей:

function start(component) {
  // Restrict it from displaying in a smaller size
  if (component.style.height < 300) {
    component.style['height'] = 300
  }
  if (component.type === 'button') {
    // Give all button components a dashed teal border
    component.style['border'] = '1px dashed teal'
  }
  if (component.type === 'input') {
    if (component.inputType === 'email') {
      // Uppercase every letter for email inputs
      component.style.textTransform = 'uppercase'
    }
  }
  return component
}

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

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

Абстракция и композиция

Чтобы понять, от чего абстрагироваться, подумайте, какова наша конечная цель:

"Функция, которая будет принимать объект и возвращать новый, в котором стили обновлены таким образом, как мы этого хотим".

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

function resolveStyles(component) {
  // Restrict it from displaying in a smaller size
  if (component.style.height < 300) {
    component.style['height'] = 300
  }
  if (component.type === 'button') {
    // Give all button components a dashed teal border
    component.style['border'] = '1px dashed teal'
  }
  if (component.type === 'input') {
    if (component.inputType === 'email') {
      // Uppercase every letter for email inputs
      component.style.textTransform = 'uppercase'
    }
  }
  return component
}

function resolveChildren(component) {
  if (Array.isArray(component.children)) {
    component.children = component.children.map((child) => {
      // resolveStyles's return value is a component, so we can use the return value from resolveStyles to be the the result for child components
      return resolveStyles(child)
    })
  }
  return component
}

function start(component, resolvers = []) {
  return resolvers.reduce((acc, resolve) => {
    return resolve(acc)
  }, component)
}

const component = {
  type: 'div',
  style: {
    height: 250,
    fontSize: 14,
    fontWeight: 'bold',
    textAlign: 'center',
  },
  children: [
    {
      type: 'input',
      inputType: 'email',
      placeholder: 'Enter your email',
      style: {
        border: '1px solid magenta',
      },
    },
  ],
}

const result = start(component, [resolveStyles, resolveChildren])
console.log(result)

Результат:

{
  "type": "div",
  "style": {
    "height": 300,
    "fontSize": 14,
    "fontWeight": "bold",
    "textAlign": "center"
  },
  "children": [
    {
      "type": "input",
      "inputType": "email",
      "placeholder": "Enter your email",
      "style": {
        "border": "1px solid magenta",
        "textTransform": "uppercase"
      }
    }
  ]
}

Критические изменения

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

Если мы внимательно изучим резольверы и посмотрим, как они используются для вычисления конечного результата, то увидим, что они могут легко сломаться и привести к краху нашего приложения по двум причинам:

  1. Резольверы мутируют - Что, если произойдет неизвестная ошибка и значение мутирует неправильно, ошибочно присвоив неопределенные величины? Значение также изменяется вне функции, потому что оно было мутировано (поймите, как работают ссылки). 

Если мы уберем return component из resolveStyles, то сразу же столкнемся с TypeError, потому что это значение станет входящим для следующей функции резольвера:

TypeError: Cannot read property 'children' of undefined
  1. Резольверы переопределяют предыдущие результаты - Это не очень хорошая практика, которая нарушает цель абстракции. Наш resolveStyles может вычислять свои значения, но это не будет иметь смысла, если функция resolveChildren вернет совершенно новое значение.

Сохранение неизменяемости 

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

Объединение новых изменений

Внутри нашей функции resolveStyles мы можем вернуть новое значение (объект), содержащее измененные параметры, которые мы объединим с исходными.Таким образом мы можем гарантировать, что резольверы не переопределяют друг друга, а возврат undefined не повлияет на другой код впоследствии:

function resolveStyles(component) {
  let result = {}

  // Restrict it from displaying in a smaller size
  if (component.style.height < 300) {
    result['height'] = 300
  }
  if (component.type === 'button') {
    // Give all button components a dashed teal border
    result['border'] = '1px dashed teal'
  }
  if (component.type === 'input') {
    if (component.inputType === 'email') {
      // Uppercase every letter for email inputs
      result['textTransform'] = 'uppercase'
    }
  }
  return result
}

function resolveChildren(component) {
  if (Array.isArray(component.children)) {
    return {
      children: component.children.map((child) => {
        return resolveStyles(child)
      }),
    }
  }
}

function start(component, resolvers = []) {
  return resolvers.reduce((acc, resolve) => {
    return resolve(acc)
  }, component)
}

Когда проект становится больше

Если у нас есть 10 резольверов стилей и только 1, работающий с дочерними элементами, это затрудняет обслуживание, поэтому мы можем разделить их в той части, где они будут совмещены:

function callResolvers(component, resolvers) {
  let result

  for (let index = 0; index < resolvers.length; index++) {
    const resolver = resolvers[index]
    const resolved = resolver(component)
    if (resolved) {
      result = { ...result, ...resolved }
    }
  }

  return result
}


function start(component, resolvers = []) {
  let baseResolvers
  let styleResolvers

  // Ensure base resolvers is the correct data type
  if (Array.isArray(resolvers.base)) baseResolvers = resolvers.base
  else baseResolvers = [resolvers.base]
  // Ensure style resolvers is the correct data type
  if (Array.isArray(resolvers.styles)) styleResolvers = resolvers.styles
  else styleResolvers = [resolvers.styles]

  return {
    ...component,
    ...callResolvers(component, baseResolvers),
    style: {
      ...component.style,
      ...callResolvers(component, styleResolvers)),
    },
  }
}

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

А что если у нас есть резольверы, которым требуется дополнительный контекст для вычисления результата?

Например, если у нас есть функция резольвер resolveTimestampInjection, которая вводит свойство time, когда используется некоторый параметр опций, переданный где-то в обертке?

Функции, нуждающиеся в дополнительном контексте

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

Что если бы у резольверов была возможность возвращать функцию и вместо этого получать нужный им контекст из аргументов возвращаемой функции?

Это может выглядеть примерно так:

function resolveTimestampInjection(component) {
  return function ({ displayTimestamp }) {
    if (displayTimestamp === true) {
      return {
        time: new Date(currentDate).toLocaleTimeString(),
      }
    }
  }
}

Хотелось бы включить эту функциональность без изменения поведения исходного кода:

function callResolvers(component, resolvers) {
  let result

  for (let index = 0; index < resolvers.length; index++) {
    const resolver = resolvers[index]
    const resolved = resolver(component)
    if (resolved) {
      result = { ...result, ...resolved }
    }
  }

  return result
}


function start(component, resolvers = []) {
  let baseResolvers
  let styleResolvers

  // Ensure base resolvers is the correct data type
  if (Array.isArray(resolvers.base)) baseResolvers = resolvers.base
  else baseResolvers = [resolvers.base]
  // Ensure style resolvers is the correct data type
  if (Array.isArray(resolvers.styles)) styleResolvers = resolvers.styles
  else styleResolvers = [resolvers.styles]

  return {
    ...component,
    ...callResolvers(component, baseResolvers),
    style: {
      ...component.style,
      ...callResolvers(component, styleResolvers)),
    },
  }
}

const component = {
  type: 'div',
  style: {
    height: 250,
    fontSize: 14,
    fontWeight: 'bold',
    textAlign: 'center',
  },
  children: [
    {
      type: 'input',
      inputType: 'email',
      placeholder: 'Enter your email',
      style: {
        border: '1px solid magenta',
      },
    },
  ],
}

const result = start(component, {
  resolvers: {
    base: [resolveTimestampInjection, resolveChildren],
    styles: [resolveStyles],
  },
})

Именно здесь начинает проявляться потенциал композиции функций высшего порядка, и хорошая новость заключается в том, что их легко реализовать!

Абстрагируясь от абстракций

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

function makeInjectContext(context) {
  return function (callback) {
    return function (...args) {
      let result = callback(...args)
      if (typeof result === 'function') {
        // Call it again and inject additional options
        result = result(context)
      }
      return result
    }
  }
}

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

const getBaseStyles = () => ({ baseStyles: { color: '#333' } })
const baseStyles = getBaseStyles()

const injectContext = makeInjectContext({
  baseStyles,
})

function resolveTimestampInjection(component) {
  return function ({ displayTimestamp }) {
    if (displayTimestamp === true) {
      return {
        time: new Date(currentDate).toLocaleTimeString(),
      }
    }
  }
}

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

Сначала она принимает объект, который вы хотите передать всем резольвер-функциям, и возвращает функцию, принимающую в качестве аргумента функцию обратного вызова (callback). Этот параметр коллбэка позже станет одной из исходных функций резольвера. Причина, по которой это делается, заключается в том, что мы выполняем так называемую "обертку". Мы обернули коллбэк внешней функцией, чтобы внедрить дополнительную функциональность, сохранив при этом поведение нашей исходной функции, обеспечив вызов коллбэка внутри нее. Если возвращаемый тип результата коллбэка - функция, то можно предположить, что коллбэку нужен контекст, поэтому мы вызываем результат коллбэка еще раз - и именно здесь мы передаем контекст.

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

И теперь наша функция start нуждается в корректировке для функции более высокого порядка makeInjectContext:

const getBaseStyles = () => ({ baseStyles: { color: '#333' } })

function start(component, { resolvers = {}, displayTimestamp }) {
  const baseStyles = getBaseStyles()
  // This is what will be injected in the returned function from the higher order function
  const context = { baseStyles, displayTimestamp }
  // This will replace each original resolver and maintain the behavior of the program to behave the same by calling the original resolver inside it
  const enhancedResolve = makeInjectContext(context)

  let baseResolvers
  let styleResolvers

  // Ensure base resolvers is the correct data type
  if (Array.isArray(resolvers.base)) baseResolvers = resolvers.base
  else baseResolvers = [resolvers.base]
  // Ensure style resolvers is the correct data type
  if (Array.isArray(resolvers.styles)) styleResolvers = resolvers.styles
  else styleResolvers = [resolvers.styles]

  return {
    ...component,
    ...callResolvers(component, baseResolvers.map(enhancedResolve)),
    style: {
      ...component.style,
      ...callResolvers(component, styleResolvers.map(enhancedResolve)),
    },
  }
}

const component = {
  type: 'div',
  style: {
    height: 250,
    fontSize: 14,
    fontWeight: 'bold',
    textAlign: 'center',
  },
  children: [
    {
      type: 'input',
      inputType: 'email',
      placeholder: 'Enter your email',
      style: {
        border: '1px solid magenta',
      },
    },
  ],
}

const result = start(component, {
  resolvers: {
    base: [resolveTimestampInjection, resolveChildren],
    styles: [resolveStyles],
  },
})

И мы по-прежнему получаем обратно объект с ожидаемыми результатами!

{
  "type": "div",
  "style": {
    "height": 300,
    "fontSize": 14,
    "fontWeight": "bold",
    "textAlign": "center"
  },
  "children": [
    {
      "type": "input",
      "inputType": "email",
      "placeholder": "Enter your email",
      "style": {
        "border": "1px solid magenta"
      },
      "textTransform": "uppercase"
    }
  ],
  "time": "2:06:16 PM"
}

Заключение

На этом я завершаю этот пост! Надеюсь, что вы нашли это полезным и ждите новых материалов в будущем!


Материал подготовлен в рамках курса JavaScript Developer. Basic. Всех желающих приглашаем на открытый урок «Как сверстать любой вебсайт: Float, Flexbox, Grid». На открытом уроке мы рассмотрим, как проработать вебдизайн и перенести его на любой интерфейс. Разберем самые популярные способы сверстать макет, поработаем с такими инструментами, как Float, Flexbox, Grid >> РЕГИСТРАЦИЯ

Комментарии (3)


  1. mSnus
    16.09.2021 22:39
    +6

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


    Собственно, в приведенных исходниках — ошибки, и отлавливать их через весь этот resolver hell уже очень неприятно.


    А я всего-то хотел посмотреть, что будет с вот этим


    style: {
    ...component.style,
    ...callResolvers(component, styleResolvers.map(enhancedResolve)),
    }, 

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


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


  1. bubuq
    16.09.2021 22:52
    +4

    function makeInjectContext(context) {
      return function (callback) {
        return function (...args) {
          let result = callback(...args)
          if (typeof result === 'function') {
            // Call it again and inject additional options
            result = result(context)
          }
          return result
        }
      }
    }

    Если рябит в глазах от скобок:

    const makeInjectContext = context => callback => (...args) => {
      let result = callback(...args)
      if (typeof result === 'function')
      // Call it again and inject additional options
        result = result(context)
      return result
    }
    if (displayTimestamp === true) {

    Фу-фу.

    if (Array.isArray(resolvers.base)) baseResolvers = resolvers.base
      else baseResolvers = [resolvers.base]

    Первая мысль,

    const baseResolvers = Array.isArray(resolvers.base)
    	? resolvers.base
      : [resolvers.base]

    А вторая мысль вот какая:

    if (!Array.isArray(resolvers.base))
    	throw new Error('Wrong type for resolvers.base.')

    Не должно быть никаких плавающих типов.


    1. Rsa97
      17.09.2021 08:10
      +1

      Не должно быть никаких плавающих типов.
      Тогда уж и
      if (typeof result !== 'function')
        throw new Error('Wrong type for result. Function expected.')
      return result(context)
      И ожидать вместо примитива/объекта функцию вида
      (x) => x