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

Сопутствующий репозиторий GitHub для этой статьи можно найти здесь.

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

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

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

Что мы сделаем, так это значительно уменьшим площадь поверхности жука. Попутно вы познакомитесь с некоторыми концепциями, лежащими в основе React.setState и Redux.

ВОПРОС

Вот код из StackOverflow:

ОБСУЖДЕНИЕ

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

МЕМАРРАЙ

У глобального memArray есть две неотложные проблемы, кроме того, что он глобальный.

  • var

Во-первых, он объявлен как var, что означает, что его можно переназначить во время выполнения.

На самом деле, использование var является заявлением для машины и других программистов, что «я намерен изменить значение этого присваивания в ходе выполнения».

Возможно, начинающий программист неправильно понимает назначение массивов в JS. Создание var не делает содержимое массива изменяемым — вам нужно проделать настоящую преднамеренную работу, чтобы сделать его неизменным. Скорее, объявление этого как var делает само назначение изменяемым. Это означает, что сам memArray можно изменить, указав на что-то другое, чем массив, который вы только что создали и присвоили ему.

Где-то глубоко в коде функция может делать:

memArray = []

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

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

Шансы на это увеличиваются из-за второй проблемы:

  • Именование

См. эту статью о важности нейминга.

В примерах кода на StackOverflow я всегда называю глобальные переменные так: EvilGlobalMembersArray.

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

ПЕРВЫЙ РЕФАКТОР

Сделайте его const, чтобы его нельзя было переназначить, и дайте ему осмысленное и полезное имя. Это «именование по соглашению», которое снимает когнитивную нагрузку при чтении кода.

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

МУТАЦИЯ

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

Вы хотите этого, верно? Предположительно, мы собираемся добавлять, удалять и обновлять элементы в этом массиве.

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

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

ВТОРОЙ РЕФАКТОР — IIFE

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

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

С точки зрения классов ES6 это примерно аналогично созданию экземпляра класса с закрытыми методами.

Вот без аксессоров:

Обратите внимание на закрывающий () и непосредственный вызов: (() => {})().

В этом случае мы получим объект без свойств. Но вам нужно знать, что он также содержит скрытый массив — _members — к которому локальные функции не могут получить доступ.

Но, но… разве ты не из тех, кто Просто скажи нет переменным? Что там делает этот оператор let?!

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

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

РЕАЛИЗАЦИЯ GETMEMBERS

Теперь мы предоставим метод для возврата копии массива _members:

Синтаксис распространения ES6[...members]распространяет содержимое массива локальных элементов в новый массив и возвращает его.

Локальные функции могут добавлять элементы в массив или удалять элементы, но эти операции не влияют на глобальное состояние, поскольку они имеют копию глобального состояния, а не ссылку на глобальное состояние.

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

Мы можем избежать этого сценария следующим образом:

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

Это «создать новый массив, применив это преобразование к каждому элементу в этом другом массиве».

В функции предиката — m => ({...m}) — мы возвращаем копию каждого объекта-члена из массива _members, снова используя синтаксис ES6 Spread, на этот раз для объекта.

Когда вы возвращаете объект в однострочной стрелочной функции, вам нужно поставить вокруг него (), чтобы интерпретатор не интерпретировал содержимое {} как код функции, но знал, что это объект, поэтому: m => ({...m}).

Теперь у нас есть новый массив и новые объекты в массиве.

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

РЕАЛИЗАЦИЯ SETMEMBERS

Первый метод, который мы реализуем, — это метод гидратации, который позволяет локальной функции передавать массив членов.

Я пока уберу getMembers, чтобы было легче читать:

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

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

Если бы мы сделали наивное задание:

setMembers: members => _members = [...members]

Тогда локальная функция, вызывающая этот метод, будет иметь локальную ссылку на объекты-члены, которые сейчас находятся в хранилище состояний. Распространяя их, мы делаем копию — другой объект в памяти, на который локальная функция не ссылается.

РЕАЛИЗАЦИЯ UPDATEMEMBER

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

Итак, мы реализуем функцию updateMember. Мы будем использовать Array.map для возврата нового массива. Наивный подход к этому может быть таким: давайте пройдемся по массиву, используя forEach, и изменим элемент, который мы обновляем. См. пост Просто скажите нет циклам и переменным для подробного объяснения того, почему вы не хотите этого делать.

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

Для каждого члена в государстве,

если идентификатор участника равен идентификатору обновления, вернуть обновление;

в противном случае вернуть элемент.

Итак, наша предикатная функция выглядит так:

member => member.id === update.id ? update : member

Здесь мы используем тернарный оператор, чтобы реализовать if-then-else в одном выражении.

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

Мы заключаем операцию присваивания _members = в круглые скобки (), чтобы показать, что мы не забыли вернуть значение и имели в виду только побочный эффект. Мы могли бы поместить его в {}, но это заставит форматировщики кода превратить нашу единственную строку в три.

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

ПРОЕКТИРОВАНИЕ НА НЕУДАЧУ

20% программирования заключается в том, чтобы заставить его работать. Остальные 80 % — это программирование для когда это не работает.

Что произойдет, если локальная функция запрашивает обновление члена, который не находится в состоянии? На данный момент локальная функция не получает никакой информации от вызова updateMember, и если вы посмотрите на код, то произойдет… ничего.

Функция предиката никогда не будет совпадать, и новое состояние будет новой копией существующего состояния без изменений.

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

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

РЕАЛИЗАЦИЯ GETMEMBER

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

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

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

Функция предиката проста:

Вернуть true, если member.id равно запрошенному id;

в противном случае вернуть false

Уменьшая это, мы получаем:

Возврат member.id равен запрошенному id

or:

m => m.id === id

So,

Массив getMember теперь будет возвращать массив либо с нулем (если в состоянии нет элементов с таким идентификатором), либо с единицей… подождите, что произойдет, если в массиве будет более одного элемента с одинаковым id? В этом случае он вернет более одного члена.

Вероятно, бизнес-требование состоит в том, чтобы элемент id был уникальным. Поэтому мы учтем это, когда будем писать функцию addMember.

Таким образом, он вернет массив с 0 или 1 элементом в нем. Вероятно, локальным функциям нужен член или undefined.

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

{
  found: true
  member: Member
} |
{
  found: false
  member: undefined
}

Затем потребители этого API, использующие TypeScript, могут использовать Type Guard для защиты от доступа к значению undefined, и наш API заставляет их использовать его.

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

So:

Не забудьте Spread члена, чтобы вернуть копию (я взял это, когда тестовый пример не удался здесь).

Хороший API.

ВЫБИРАЕТ НЕВОЗМОЖНОЕ ОБНОВЛЕНИЕ

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

Теперь мы можем использовать getMember из нашего собственного API для защиты от ошибки обновления.

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

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

ВНЕДРЕНИЕ PUTMEMBER

Вероятно, бизнес-требованием приложения будет добавление нового участника в магазин.

Здесь мы должны принять решение о поведении магазина. Что произойдет, если локальная функция попытается поместить элемент с id, который уже находится в хранилище?

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

Итак, мы можем сделать это:

РАБОТА С НЕОПРЕДЕЛЕННЫМ ID

Еще одна потенциальная ошибка, которую мы можем здесь обнаружить, — это локальная функция, передающая либо undefined, либо член с неопределенным id.

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

Вот как мы это используем:

ЗАМОРОЗИТЬ!

В качестве последнего штриха мы собираемся заморозить объект API, используя Object.freeze:

return Object.freeze(Store)

Это предотвращает перезапись или изменение самих методов API.

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

Замораживание объектов влияет на производительность. Заморозка API не будет иметь большого значения, поэтому безопасность того стоит. Объекты, возвращаемые из API, являются копиями, поэтому их замораживание, ИМХО, излишне.

СОЕДИНЯЕМ ВСЕ ВМЕСТЕ

Вот и все:

Это может показаться более сложным, чем:

var memArray = []

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

И будет очень сложно рефакторить в будущем.

При таком подходе вся техническая сложность этой проблемы теперь сосредоточена в одном месте вашего приложения. Это можно проверить с помощью автоматизированных тестов — как показано в сопроводительном репозитории. На 40 строк кода приходится 125 строк тестового кода. Итак, 165 строк кода для замены var memArray = [].

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

#победа

ДОПОЛНИТЕЛЬНЫЕ РЕСУРСЫ

Этот подход к управлению состоянием стал популярным в JS в последние годы и является основой подхода, используемого:

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

Обо мне:я являюсь советником разработчиков в Camunda, работаю в основном над механизмом рабочих процессов Zeebe для оркестровки микросервисов и поддерживаю клиент Zeebe Node.js. В свободное время я создаю Magikcraft, платформу для программирования с помощью JavaScript в Minecraft.