Pull to refresh

Как использовать Инверсию Управления в JavaScript и в Reactjs для упрощения работы с кодом

Level of difficultyMedium
Reading time11 min
Views12K
Original author: Kent C. Dodds

Как использовать Инверсию Управления в JavaScript и в Reactjs для упрощения работы с кодом


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


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


  1. Вы создаете многократно используемый фрагмент кода (это может быть функция, React компонент, React хук и тд) и делитесь им (для совместной работы или публикуя в опенсорс).
  2. Кто-то обращается к вам с просьбой добавить новый функционал. Ваш код не поддерживает предложенный функционал, но мог бы, если бы вы сделали небольшое изменение.
  3. Вы добавляете новый аргумент/проп/опцию в свой код и в связанную с ним логику для поддержания работы этого нового функционала.
  4. Повторите стадии 2 и 3 несколько раз (или много-много раз).
  5. Теперь ваш повторно используемый код тяжело использовать и поддерживать.

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


  1. Размер пакета и/или производительность: Просто больше кода для запуска на устройствах может привести к ухудшению производительности. Иногда это может привести к тому что люди просто откажутся использовать ваш код.
  2. Сложно поддерживать: Раньше у вашего повторно используемого кода было всего несколько опций, и он был сфокусирован на том, чтобы хорошо делать одну вещь, но теперь он может делать кучу разных вещей, и вам нужно это все документировать. Кроме того, люди начнут задавать вам вопросы о том, как использовать ваш код для тех или иных вариантов использования, которые могут, а может и нет, быть сопоставимы с вариантами использования, для которых вы уже добавили поддержку. У вас может быть даже два почти одинаковых варианта использования, которые немного отличаются, так что вам придется отвечать на вопросы о том что лучше использовать в той или иной ситуации.
  3. Сложность реализации: Каждый раз это не просто еще один оператор if, каждая ветка логики вашего кода сосуществует с уже имеющимися ветками логики. Фактически возможны ситуации когда вы пытаетесь поддерживать комбинацию аргументов/опций/пропсов, которую никто даже не использует, но вам все равно нужно учитывать любые возможные варианты, так как вы точно не знаете, использует или будет ли использовать кто либо эти комбинации.
  4. Сложный API: Каждый новый аргумент/опция/проп, который вы добавляете в свой повторно используемый код, затрудняет его использование, так как теперь у вас есть огромный README или сайт, где задокументирован весь доступный функционал, и людям приходиться изучать все это для эффективного использования вашего кода. Использовать его то же не удобно, потому что сложность вашего API проникает в код разработчика, который использует его, что усложняет и его код.

В итоге все страдают. Стоит заметить что реализация конечной программы является важнейшей частью разработки. Но было бы отлично если бы мы больше думали о реализации наших абстракций (читайте про "AHA programming"). Существует ли способ, который позволит нам уменьшить проблемы с повторно используемым кодом, и, при этом, все еще пожинать преимущества использования абстракций?


Инверсия управления


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


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

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


Что такое Инверсия Управления в коде?


Для начала, вот очень надуманный пример:


// представим что Array.prototype.filter не существует
function filter(array) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (element !== null && element !== undefined) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

// пример:
filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

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


// представим что Array.prototype.filter не существует
function filter(
  array,
  {
    filterNull = true,
    filterUndefined = true,
    filterZero = false,
    filterEmptyString = false,
  } = {},
) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (
      (filterNull && element === null) ||
      (filterUndefined && element === undefined) ||
      (filterZero && element === 0) ||
      (filterEmptyString && element === '')
    ) {
      continue
    }
    newArray[newArray.length] = element
  }
  return newArray
}

filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterNull: false})
// [0, 1, 2, null, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterUndefined: false})
// [0, 1, 2, undefined, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterZero: true})
// [1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterEmptyString: true})
// [0, 1, 2, 3, 'four']

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


В целом, это довольно простая абстракция. Но ее можно упростить. Часто бывает так, что абстракцию, в которую добавляли новый функционал, можно было бы сильно упростить для тех вариантов использования которые она фактически и поддерживает. К сожалению, как только абстракция начинает что-то поддерживать (например, выполнение { filterZero: true, filterUndefined: false }), мы боимся удалять эту функциональность из-за того что это может сломать код который на нее полагается.


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


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


// представим что Array.prototype.filter не существует
function filter(array, filterFn) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (filterFn(element)) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== null && el !== undefined,
)
// [0, 1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], el => el !== undefined)
// [0, 1, 2, null, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], el => el !== null)
// [0, 1, 2, undefined, 3, 'four', '']

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== undefined && el !== null && el !== 0,
)
// [1, 2, 3, 'four', '']

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== undefined && el !== null && el !== '',
)
// [0, 1, 2, 3, 'four']

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


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


filter(
  [
    {name: 'dog', legs: 4, mammal: true},
    {name: 'dolphin', legs: 0, mammal: true},
    {name: 'eagle', legs: 2, mammal: false},
    {name: 'elephant', legs: 4, mammal: true},
    {name: 'robin', legs: 2, mammal: false},
    {name: 'cat', legs: 4, mammal: true},
    {name: 'salmon', legs: 0, mammal: false},
  ],
  animal => animal.legs === 0,
)
// [
//   {name: 'dolphin', legs: 0, mammal: true},
//   {name: 'salmon', legs: 0, mammal: false},
// ]

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


Плохой API?


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


// до
filter([0, 1, undefined, 2, null, 3, 'four', ''])

// после
filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== null && el !== undefined,
)

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


function filterWithOptions(
  array,
  {
    filterNull = true,
    filterUndefined = true,
    filterZero = false,
    filterEmptyString = false,
  } = {},
) {
  return filter(
    array,
    element =>
      !(
        (filterNull && element === null) ||
        (filterUndefined && element === undefined) ||
        (filterZero && element === 0) ||
        (filterEmptyString && element === '')
      ),
  )
}

Круто, да? Таким образом мы можем создавать абстракции поверх API в котором применяется инверсия управления, и тем самым создавать более простой API. А если в нашем "более простом" API недостаточно вариантов использования, тогда наши пользователи могут применить те же самые строительные блоки, которые мы использовали для создания нашего высокоуровневого API, чтобы разрабатывать решения для более сложных задач. Им не нужно просить нас добавить новую функцию в filterWithOptions и ждать, пока она будет реализована. У них уже есть инструменты при помощи которых они могут самостоятельно разрабатывать нужный им дополнительный функционал.


И, просто для фана:


function filterByLegCount(array, legCount) {
  return filter(array, animal => animal.legs === legCount)
}

filterByLegCount(
  [
    {name: 'dog', legs: 4, mammal: true},
    {name: 'dolphin', legs: 0, mammal: true},
    {name: 'eagle', legs: 2, mammal: false},
    {name: 'elephant', legs: 4, mammal: true},
    {name: 'robin', legs: 2, mammal: false},
    {name: 'cat', legs: 4, mammal: true},
    {name: 'salmon', legs: 0, mammal: false},
  ],
  0,
)
// [
//   {name: 'dolphin', legs: 0, mammal: true},
//   {name: 'salmon', legs: 0, mammal: false},
// ]

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


Примеры из реальной жизни


Итак, это работает в простых случаях, но подойдет ли эта концепция для реальной жизни? Что ж, скорее всего вы постоянно используете инверсию управления. К примеру, функция Array.prototype.filter применяет инверсию управления. Как и функция Array.prototype.map.


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


Вот два моих любимых паттерна, которые демонструют это "Compound Components" и "State Reducers". Ниже идут краткие примеры того, как можно применять эти паттерны.


Составные Компоненты (Compound Components)


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


function App() {
  return (
    <Menu
      buttonContents={
        <>
          Actions <span aria-hidden>▾</span>
        </>
      }
      items={[
        {contents: 'Download', onSelect: () => alert('Download')},
        {contents: 'Create a Copy', onSelect: () => alert('Create a Copy')},
        {contents: 'Delete', onSelect: () => alert('Delete')},
      ]}
    />
  )
}

Это позволяет нам много чего настроить в пунктах меню. Но что если мы хотим вставить линию перед пунктом меню Delete? Должны ли мы добавить специальную опцию к объектам относящимся к пропсу items? Ну, я не знаю, к примеру: precedeWithLine? Так себе идея.


Может, создать особый вид пункта меню, к примеру {contents: <hr />}. Думаю, это сработало бы, но тогда нам пришлось бы обрабатывать случаи, когда нет onSelect. И, если честно, это очень неловкий API.


Когда вы думаете о том, как создать хороший API для людей, которые пытаются сделать что-то немного по-другому, вместо того чтобы тянуться к if оператору, попробуйте инвертировать управление. Что если мы передадим ответственность за визуализацию меню на пользователя? Используем одну из самых сильных сторон Реакта:


function App() {
  return (
    <Menu>
      <MenuButton>
        Actions <span aria-hidden>▾</span>
      </MenuButton>
      <MenuList>
        <MenuItem onSelect={() => alert('Download')}>Download</MenuItem>
        <MenuItem onSelect={() => alert('Copy')}>Create a Copy</MenuItem>
        <MenuItem onSelect={() => alert('Delete')}>Delete</MenuItem>
      </MenuList>
    </Menu>
  )
}

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


Вы можете почитать больше об этом паттерне здесь. Спасибо Райану Флоренсу, который научил меня этому.


Редюсер Состояния (State Reducer)


Я придумал этот паттерн чтобы решить проблему настройки логики компонентов. Вы можете прочитать больше о той ситуации в моем блоге "The State Reducer Pattern", но основная суть в том, что у меня есть библиотека поиска/автозаполнения/ввода типов, которая называется Downshift, и один из пользователей библиотеки разрабатывал версию компонента с множественным выбором, из-за чего он хотел чтобы меню оставалось открытым даже после выбора элемента.


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


Вместо этого я сделал API таким, чтобы пользователи сами могли контролировать как происходят изменения состояния. Думайте о состоянии редюсера (state reducer) как о состояний функции, которая вызывается каждый раз, когда изменяется состояние компонента, и дает разработчику приложения возможность влиять на изменение состояния, которое должно произойти.


Пример использования библиотеки Downshift таким образом чтобы она не закрывала меню после того как пользователь нажал на выбранный элемент:


function stateReducer(state, changes) {
  switch (changes.type) {
    case Downshift.stateChangeTypes.keyDownEnter:
    case Downshift.stateChangeTypes.clickItem:
      return {
        ...changes,
        // нас устраивают изменения которые Downshift хочет сделать
        // за исключением того что мы оставим isOpen и highlightedIndex
        // такими какими они и были
        isOpen: state.isOpen,
        highlightedIndex: state.highlightedIndex,
      }
    default:
      return changes
  }
}
// потом, при рендеринге
// <Downshift stateReducer={stateReducer} {...restOfTheProps} />

После того как мы добавили этот проп, мы стали получать НАМНОГО меньше запросов на добавление новых настроек для этого компонента. Компонент стал более гибким, и разработчикам стало проще настраивать его так как нужно именно им.


Рендер-пропсы (Render Props)


Стоит упомянуть паттерн "render props". Этот паттерн является идеальным примером использования инверсии управления, но он нам особо больше и не нужен. Подробнее об этом здесь: why we don't need Render Props as much anymore.


Предупреждение


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


Вернемся к нашему надуманному примеру:


// представим что Array.prototype.filter не существует
function filter(array) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (element !== null && element !== undefined) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

// пример:

filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

Что если это все что нам требуется от функции filter? И мы никогда не сталкивались с ситуацией, когда нам нужно было бы фильтровать что-либо кроме null и undefined? В этом случае добавление инверсии управления для единственного варианта использования просто усложнило бы код и не принесло бы особой пользы.


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


Выводы


Надеюсь что статья была полезной для вас. Я показал как можно применять концепцию Инверсии Управления в Реакте. Эта концепция, конечно же, применима не только к React (как мы видели на примере функции filter). В следующий раз когда вы заметите что добавляете очередной оператор if в функцию coreBusinessLogic вашего приложения, подумайте, как вы можете инвертировать управление и перенести логику туда, где она используется (или, если она используется в нескольких местах, вы можете создать более специализированную абстракцию для этого конкретного случая).


Если хотите, можете поиграть с примером из статьи на CodeSandbox.


Удачи и спасибо за внимание!


ПС. Если вам понравилась эта статья, вам возможно понравится это выступление: youtube Kent C Dodds — Simply React

Tags:
Hubs:
+11
Comments6

Articles

Change theme settings