Десять правил использования JavaScript в повседневной работе, которые нарушают вашу когнитивную модель.
Рассматриваются десять тонких особенностей JavaScript — от изменяемости констант до замыканий и обработки ошибок в асинхронном режиме — которые незаметно приводят к багам в коде опытных разработчиков.
JavaScript перестаёт казаться пугающим, как только вы освоите его синтаксис. Вы больше не путаетесь из-за отсутствующих точек с запятой, асинхронные обратные вызовы становятся чем-то привычным, а разница между let и const превращается в нечто само собой разумеющееся. После реализации достаточного количества проектов этот язык начинает казаться старым другом: вы сразу замечаете распространённые ошибки, у вас есть хорошее представление о том, как решаются обещания, и вы понимаете, что null и undefined — это не одно и то же, независимо от того, насколько небрежно некоторые API их смешивают.
Но знакомство не устраняет всех сюрпризов, оно лишь меняет их форму. Начальные трудности сменяются более тонкими, более опасными предположениями. Вы знаете, что объекты передаются по ссылке, но всё равно забываете, что поверхностная копия оставляет вложенные данные общими. Вы знаете, что обещания зависят от микозадач, но всё равно неправильно оцениваете порядок выполнения, когда несколько очередей начинают взаимодействовать. Вы знаете о существовании принудительного преобразования, но всё равно упускаете из виду единственное тихое преобразование, скрытое внутри на первый взгляд невинного сравнения, вызова метода сортировки или поиска свойства.
Самые сложные аспекты JavaScript редко связаны с экзотическими крайними случаями или языковыми мелочами. Это обычные правила, сочетающиеся неожиданным образом. Каждая отдельная строка кажется в порядке, легко читается и пройдёт поверхностную проверку. Сюрприз возникает из-за того, как среда выполнения соединяет эти строки таким образом, который не совпадает с представлением, которое у вас было при чтении кода.
Десять описанных ниже явлений продолжают создавать проблемы у опытных команд именно потому, что они скрыты в коде, который выглядит совершенно нормально.
1. const защищает связь, а не объект
Распространённое краткое описание понятия const — это то, что оно «создаёт значение, которое нельзя изменить». Это хорошее упрощение для примитивов, но оно не справляется с массивами и объектами. На самом деле const гарантирует лишь то, что переменная нельзя переопределить так, чтобы она указывала на что-то другое. Оно ничего не говорит о том, можно ли изменить объект, на который она указывает.
Объект user, объявленный с использованием const, всё ещё может получать новые свойства. Его вложенный объект settings также может обновляться. Массив, объявленный с использованием const, всё ещё можно пополнять элементами, сортировать или очищать. С точки зрения JavaScript всё это не влияет на связь переменной с объектом — она по-прежнему указывает на тот же самый объект, что и раньше.
Эта разница между ожиданиями и реальностью по-прежнему приводит к настоящим ошибкам в кодовых базах, которые в значительной степени основаны на концепции неизменяемости. Объект конфигурации импортируется как константа, затем один модуль тихо изменяет её, и вдруг все остальные модули, использующие этот ссылочный адрес, видят изменения. Массив, хранящийся в состоянии приложения, сортируется на месте, что приводит к изменению как «текущего» значения, так и более старого значения, которое другая часть кода считала замороженной исторической копией. Тест изменяет общий объект-фикстчер, и тест, совершенно не связанный с ним, начинает давать ошибки, причем только тогда, когда запускач тестов случайно выполняет их в другом порядке.
Ключевое слово const создаёт ложное чувство безопасности: кажется, что оно обеспечивает защиту значения, но на самом деле среда выполнения защищает лишь указатель на переменную, а не саму структуру, на которую он ссылается.
Если вам действительно нужна неизменяемость, придётся создавать её самостоятельно. Это может означать возвращение совершенно новых объектов вместо редактирования существующих, применение метода Object.freeze к конкретным значимым полям, использование библиотек, разработанных с учётом неизменяемого состояния, или проектирование функций таким образом, чтобы они с самого начала не передавали ссылки на внутренние изменяемые данные. Даже метод Object.freeze блокирует только верхний уровень объекта; если не заморозить все вложенные объекты, внутренние слои останутся такими же изменяемыми, как и раньше.
Большинство опытных разработчиков могут запомнить это правило наизусть, однако удивление возникает каждый раз, когда визуальный стиль кода создаёт впечатление более строгих гарантий, чем на самом деле предоставляет JavaScript.
2. Распространение объектов копирует меньше, чем кажется
Использование { ...obj } для копирования объекта стало одним из распространённых идиом в JavaScript. Этот синтаксис выглядит чисто и понятно, отлично подходит для объединения значений по умолчанию с пользовательскими настройками, а также позволяет создать изменённую версию значения без изменения оригинала.
Визуально это выглядит как полная, независимая копия. На самом деле копия существует только на одном уровне глубины.
const original = {
profile: {
name: "Umar",
skills: ["JavaScript", "Node.js"],
},
};
const copy = { ...original };
copy.profile.skills.push("TypeScript");
После выполнения этого фрагмента кода ново добавленная навык также появляется в original.profile.skills. Объект верхнего уровня действительно новый, но объект profile, находящийся внутри него, и массив skills, находящийся ещё глубже, остаются теми же объектами, на которые указывал оригинал.
Именно поэтому эта ошибка так долго сохраняется: первый уровень ведет себя ровно так, как и ожидается. Проверка copy !== original возвращает true, что кажется доказательством того, что было достигнуто чистое разделение. Проблема совместных изменений проявляется только тогда, когда исследуем уровень ниже.
Механизм распространения также приводит ко второму сюрпризу при использовании его для объединения объектов конфигурации. Запись { ...defaults, ...options } заменяет целые вложенные объекты, вместо того чтобы объединять их отдельные ключи. Если options переопределяет какое-либо настройки внутри вложенного объекта, все сопутствующие настройки, которые ранее находились вместе с ним в defaults, исчезают вместе с ним, хотя вызывающий код совсем не планировал их изменять.
Решение не заключается в автоматическом выполнении операции «глубокое копирование всего». Полное глубокое копирование может быть дорогостоящим, привести к потере идентичности объектов, от которых вы на самом деле зависите в других частях кода, а также к дублированию данных, которые должны оставаться общими. Важно определить, какие именно вложенные пути требуют независимых копий. Обрабатывайте их явно, используйте соответствующий инструмент для неизменяемых обновлений или перестройте глубоко вложенные данные так, чтобы границы собственности были очевидны с первого взгляда.
Функция распространения объектов работает ровно так, как описано, когда вам действительно нужна поверхностная копия. Однако она становится проблемой в тот момент, когда её краткая синтаксис принимают за полное глубокое копирование.
3. Обычное сравнение равенства может скрывать несколько преобразований
Наиболее опытные разработчики предпочитают использовать ===, чтобы избежать проблем, связанных с принудительным преобразованием, характерными для ==. Такая привычка действительно полезна, но она не защищает от всех косвенных преобразований, которые выполняет JavaScript; многие из них происходят в местах, не связанных с оператором равенства.
Хорошим примером являются ключи свойств объектов. За исключением символов, каждый ключ объекта на самом деле представляет собой строку. Поэтому пристановка значения object[1] и последующее чтение object["1"] влияют на одно и то же свойство. Код, который мысленно рассматривает числовые и строковые идентификаторы как два отдельных типа, может оказаться в затруднительном положении в таких случаях.
При сравнении значений выполняются соответствующие преобразования в зависимости от того, что сравнивается. Два строки сравниваются лексикографически (по одному символу), в то время как сравнение числа с числовой строкой может привести к выполнению числового сравнения. Именно поэтому "20" < "100" возвращает false, тогда как 20 < "100" возвращает true. Если изменить источник значений, логика сортировки или проверки может изменить своё поведение, даже если отображаемые значения кажутся идентичными.
Оператор + особенно сложен в использовании, поскольку он одновременно выполняет функцию числового сложения и объединения строк, причем JavaScript определяет, какую из функций применить, исходя из контекста. Одна-единственная строка, появляющаяся рано в цепочке операций сложения, может изменить интерпретацию всего, что следует за ней. Поскольку значения, получаемые из полей формы, параметров URL и других источников HTML, как правило, поступают в виде строк, выражение, которое работало без проблем с внутренними числовыми значениями, может незаметно перейти на объединение строк в тот момент, когда оно связано с данными от пользователя.
Опытные команды избегают этих ловушек путем нормализации значений прямо на границе, вместо того чтобы полагаться на операторы, которые должны правильно интерпретировать необработанный ввод «на лету». Идентификатор с самого начала определяется как строка или число. Денежная сумма преобразуется в проверенный числовой тип. Дата анализируется и преобразуется в соответствующий временной тип или фиксированную строку ISO до того, как будут выполнены любые сравнения.
Сам процесс принудительного преобразования не является настоящей проблемой — правила JavaScript в этом аспекте хорошо определены и последовательны. Настоящая проблема заключается в том, что эти правила продолжают применяться в местах, где в коде ничего визуально не указывает на то, что происходит какое-либо преобразование.
4. Array.prototype.sort перестраивает массив на месте и по умолчанию сравнивает текст
Сортировка кажется одной из самых простых операций над массивом, но на самом деле она объединяет два аспекта, которые постоянно создают путаницу у пользователей.
Первым сюрпризом является то, что при её выполнении происходит изменение массива. Вызов .sort() перестраивает порядок элементов массива и возвращает ссылку на тот же самый массив. Легко сохранить возвращаемое значение в новую переменную и предположить, что оригинальный массив сохраняет свой прежний порядок, однако в итоге обе переменные будут указывать на один и тот же, перестроенный список.
Вторым сюрпризом является то, как происходит сравнение элементов при отсутствии функции-компаратора. В таком случае JavaScript преобразует каждый элемент в строку и сортирует их лексикографически. Если применить это к массиву чисел, результатом может стать что-то вроде 1, 100, 20, 3 вместо порядка, увеличивающегося по значению.
Такое сочетание становится опасным при управлении состоянием на фронтенде. Представьте компонент, который сортирует массив непосредственно перед отрисовкой, не осознавая, что этот массив — это общий ссылочный элемент на данные из кэша или, предоставленные сервером. В результате происходит изменение этого общего источника данных. Компонент-брат, читающий те же самые данные, также видит новый порядок элементов, даже не вызывая функцию сортировки. Логика обнаружения изменений и мемоизации также может давать ложные сигналы, поскольку идентичность массива (его ссылка) никогда не менялась, хотя его содержимое изменилось — поэтому поверхностное сравнение не покажет никаких различий.
Современный JavaScript предлагает альтернативы без изменения данных: в средах, которые их поддерживают, доступен метод toSorted(), а в тех, где он отсутствует, стандартным решением по-прежнему остается копирование массива перед сортировкой. Для числового порядка сортировки все равно необходимо передать методу .sort() явный компаратор, который определяет именно ту логику сравнения, которая вам нужна.
Главный вывод заключается в том, что название метода не раскрывает всей информации о его функционале. Слово «сортировка» описывает конечный результат, но не говорит ни о том, изменяет ли метод данные на месте, как он преобразует значения для сравнения, какие гарантии стабильности он предоставляет или какой специфический порядок может потребоваться. Даже опытные разработчики попадают в затруднения, когда метод кажется настолько стандартным, что они не задумываются о том, что на самом деле происходит внутри него.
5. Объект Date моделирует отдельный момент времени — большинство входных данных не соответствуют ему однозначно
Проблемы с часовыми поясами в JavaScript связаны не столько с тем, что сама концепция часовых поясов сложна. Проблема заключается в том, что короткая строка содержит гораздо больше скрытых предположений, чем кажется на первый взгляд.
По сути, объект Date представляет собой временную метку — точный момент, измеряемый относительно UTC. Однако большинство дат, с которыми работают люди — дата рождения, крайний срок, цикл выставления счетов, запланированная встреча — являются календарными понятиями, а не фиксированными точками во всемирном времени, причем каждое из них связано с часовыми поясами по-разному.
Если вы парсите строку, содержащую только дату, а затем отображаете её с учётом местного времени, дата в календаре, которую видит пользователь, может меняться в зависимости от его местоположения. Значение, предназначенное для обозначения «3 августа», может быть интерпретировано как полночь по UTC, в результате чего при отображении в другом месте оно будет показано как 2 августа. Аналогичным образом, временная метка, сгенерированная без указания смещения по UTC, может интерпретироваться по-разному в зависимости от точного формата строки и способа её обработки во время выполнения программы.
Летнее время добавляет ещё одну сложность. Добавление фиксированного количества миллисекунд к объекту Date не гарантирует, что это будет эквивалентно добавлению одного календарного дня в местном времени — в некоторые дни в регионах, где действует изменение времени, сутки на самом деле длится двадцать три или двадцать пять часов.
Опытные разработчики по-прежнему сталкиваются с этой проблемой, поскольку их код использует один тип Date для представления нескольких независимых понятий одновременно. В системе типов нет никаких указаний на то, предназначен ли данный значением для обозначения точного момента времени, простой даты календаря или локального времени, предназначенного для интерпретации в определённом регионе.
Надёжные системы решают эту проблему, делая намерение явным, а не косвенным. Моменты времени указываются с учётом смещения по UTC или приводятся непосредственно в формате UTC. Значения, касающиеся только календаря, остаются именно такими, не преобразуясь без необходимости в полные временные метки. Расписания, специфичные для определённого региона, сохраняют контекст часового пояса, в котором были созданы. Обработка и форматирование происходят в чётко определённых точках системы, а не там, где случайно отображается или считывается дата.
Поэтому удивление обычно не вызывает сам факт наличия часовых поясов — а то, что какая-то казалось бы безобидная операция преобразования уже тайно определила, какой именно часовой пояс использовать.
6. Обещание может быть выполнено до того, как его функция обратного вызова фактически запустится
Выполненное обещание кажется завершённой операцией, но функция, привязанная к нему с помощью .then — или код, находящийся после await — не выполняется прямо в процессе выполнения текущего синхронного кода. Вместо этого она попадает в очередь микозадач и выполняется позже.
Такая очередь обеспечивает определенный порядок выполнения, но он может все равно застать опытных разработчиков врасплох, когда начинают смешиваться обещания (promises), таймеры, обработчики событий и обычный синхронный код. Обещание, которое уже выполнено, запланирует свое продолжение на позже, в то время как текущий синхронный код продолжает работать без прерываний. Это запланированное на будущее продолжение обычно будет выполнено раньше, чем сработает любой таймерный обратный вызов, запланированный для следующей задачи — даже setTimeout с отсрочкой в ноль миллисекунд.
Настоящая практическая проблема заключается не в предположениях относительно порядка вывода сообщений в консоль во время тестирования. Речь идет о необходимости понимания того, что фразы «обещание выполнено» и «обрабатывающий код действительно запущен» обозначают два разных момента времени. Обновление состояния, произведенное через обработчик обещания, может еще не быть видно коду, выполняющемуся позже в той же синхронной стек-структуре. Тест может проверять значение до того, как его задержанные микозадачи успеют быть выполнены. Кроме того, достаточно длинная цепочка микозадач может отложить выполнение таймеров и процесса отрисовки дольше, чем ожидается, поскольку движок всегда сначала обрабатывает весь очередь микозадач, прежде чем перейти к чему-либо еще.
Код становится хрупким, когда он зависит от такого случайного порядка выполнения вместо четкой последовательности операций. Именно поэтому опытные разработчики предпочитают использовать функции возврата и ожидания обещаний там, где один шаг действительно должен следовать за другим, опираться на любые механизмы жизненного цикла, предоставляемые их фреймворком, и избегать использования таймеров с нулевой задержкой в качестве надежного способа синхронизации с другими задачами.
Сам цикл событий ведет себя последовательно — именно синтаксис заставляет несколько отдельных временных линий казаться более близкими друг к другу, чем они на самом деле. Обещание уже может содержать свое окончательное значение, в то время как остальная часть приложения еще не узнала об этом.
7. Оборачивание вызова async в try/catch не гарантирует поймать какие-либо ошибки
Блок try/catch, окружающий вызов функции, кажется способным защитить этот вызов от сбоев. Однако при использовании асинхронных функций эффективность такой защиты полностью зависит от того, ждёте ли вы возвращаемого обещания.
try {
saveAuditLog(record);
} catch (error) {
reportError(error);
}
Если функция saveAuditLog выбрасывает исключение синхронно до того, как вернёт обещание, блок catch сможет его обработать без проблем. Но если saveAuditLog — асинхронная функция, которая потом даст сбой, к моменту отклонения уже будет возвращено обещание, и выполнение покинет блок try. Теперь отклонение находится в этом обещании — оно не имеет никакого отношения к уже завершившемуся синхронному вызову.
Добавление await восстанавливает связь между отклонением и соответствующим блоком try/catch, но это сработает только в том случае, если сама функция, которую вы пишете, допускает ожидание чего-либо. Передача обещания в другую цепочку также может сохранить эту связь с ошибкой. Простое вызов асинхронной функции изнутри кода обработки ошибок без использования await или возврата результата ничего не даст.
Эта ошибка постоянно встречается в обработчиках событий, функциях-обратных вызовах для массивов и хуках библиотек, где окружающий API часто не знает, что делать с обещанием, возвращенным асинхронным обратным вызовом, — и часто просто игнорирует его. Обратный вызов все равно корректно вызывает отклонение, но ничто не реагирует на него. В зависимости от среды выполнения это может проявиться как предупреждение об неразобранном отклонении, записанный в лог ошибку, сбой процесса или ситуация, когда действия асинхронной функции никогда не учитываются.
Разработчики, которые пострадали от таких сбоев из-за использования концепции владения обещаниями вместо правильного отступления кода. Они спрашивают, в каком области видимости на самом деле ожидается асинхронная работа, где отклонение преобразуется в что-то действенное, и существует ли у тех вызовов, которые выполняются без последующего контроля, реальный способ сообщения о сбое.
Ошибки асинхронных операций распространяются по цепочкам обещаний, а не в зависимости от того, насколько глубоко заключены скобки.
8. Значения по умолчанию при деструктуризации применяются только к undefined
Значения по умолчанию при деструктуризации кажутся удобной защитой от отсутствия входных данных.
const { timeout = 5000 } = options;
Это значение по умолчанию применяется только тогда, когда timeout равен undefined или вообще отсутствует в объекте. Оно не будет применяться к null, 0, пустой строке или любому другому значению, которое было явно указано.
Часто именно такое поведение и нужно. Значение «ноль» может быть преднамеренным способом отключения задержки, а null может иметь собственное особое значение. Проблемы начинаются тогда, когда кто-то считает, что значение по умолчанию подходит для любого «непригодного» случая. API может вернуть null, и код, находящийся ниже по цепочке обработки, пытается выполнять с ним математические операции. Поле формы может быть отправлено в виде пустой строки, поэтому значение по умолчанию так и не срабатывает. Загрузчик конфигурации может тщательно различать случаи «переменная не установлена» и «переменная явно установлена в пустое значение», тогда как код, использующий эту конфигурацию, относится к обоим случаям одинаково и переходит на значение по умолчанию.
Параметры по умолчанию в функциях работают по той же логике: если вызвать функцию без аргументов, применяется значение по умолчанию, но если явно передать null, оно не используется. Это различие имеет огромное значение при передаче данных через JSON, строки базы данных, формы и API сторонних разработчиков — во всех этих случаях null встречается регулярно.
Разработчики, которые полагаются на эту практику, обычно разделяют задачи установки значений по умолчанию и проверки данных. Значение по умолчанию отвечает на вопрос «что происходит, если ничего не передано?», а проверка — на вопрос «действительно ли переданные данные приемлемы?». Слияние этих двух функций в одну конструкцию может заставить ошибочные данные выглядеть так, будто они были правильно инициализированы.
Правило языка в этом случае точно определено и последовательно применяется. Путаница возникает исключительно из-за того, что слово «default» создает впечатление более широкого охвата, чем на самом деле обеспечивает строгое поведение, основанное исключительно на значении undefined.
9. Отсутствующее свойство, удаленное свойство и undefined — это три разных вещи
JavaScript позволяет свойству объекта иметь значение undefined или вообще не существовать в объекте. При прямом доступе к любому из них возвращается значение undefined, поэтому они кажутся взаимозаменяемыми — но это не так.
Инструменты вроде оператора in, методов Object.hasOwn и Object.keys, синтаксиса распространения, механизмов итерации, валидаторов схемы и сериализации JSON могут помочь определить разницу. Например, метод JSON.stringify полностью удаляет свойства с значением undefined, в то время как обработка элемента массива, содержащего undefined, происходит иначе. Слияние объектов также может заменить существующее корректное значение на undefined, даже если автор операции слияния хотел оставить это поле нетронутым.
Эта проблема часто становится причиной скрытых ошибок при обновлении. Заголовок PATCH на стороне сервера может рассматривать пропущенное поле как «не трогай это», а поле, явно установленное в значение null, — как «удали это». Форма на стороне клиента, в свою очередь, может генерировать значение undefined для любого поля, к которому пользователь так и не приступал; однако при передаче этих данных в объект обновления они всё равно включаются в него, что приводит к замене существующих значений в процессе объединения.
Чтобы избежать этого, необходимо целенаправленно определять семантику обновлений, а не позволять случайностям влиять на их результат. Пропуск полей, значения undefined, null и действительно пустые значения не должны автоматически приобретать определённый смысл из-за используемых механизмов сериализации или объединения объектов.
Это особенно важно в TypeScript, где необязательное свойство и обязательное свойство, тип которого включает undefined, выражают две структурно разные цели — даже если окружающий код приложения впоследствии будет обращаться с ними одинаково. Более строгие настройки компилятора могут обеспечить это различие на уровне типов, но фактические данные, передаваемые в приложении во время выполнения, всё равно требуют собственной проверки.
Два значения, которые кажутся идентичными при чтении, могут всё равно представлять собой совершенно разные форматы данных после того, как они передаются через сетевой раздел или в результате операции обновления.
10. Закрытие хранит переменную, а не её «замороженный» во времени снимок
Закрытия являются одним из самых мощных инструментов, предоставляемых JavaScript. Функция сохраняет доступ к переменным из того области видимости, где она была определена, и именно это позволяет кallback-функциям, фабричным функциям, обработчикам событий и паттернам модулей работать так естественно.
Распространённое краткое описание заключается в том, что закрытие «помнит значение». Более точное описание — это сохранение доступа к привязке переменной. Если значение этой переменной изменится до того, как закрытие будет выполнено, оно увидит новое значение, а не то, которое существовало при его создании.
Это классическое объяснение проблем циклов, связанных с использованием var, но у опытных разработчиков чаще возникают более тонкие варианты той же проблемы в асинхронном коде и логике интерфейса. Функция-возврат может считывать объект конфигурации, который изменился после начала операции. Обработчик событий может использовать устаревшее состояние. Задержанная функция может работать с текущим состоянием изменяемого объекта, тогда как разработчик предполагал, что она будет использовать объект в том виде, в котором он был на момент запланирования выполнения функции.
Фреймворки добавляют свои собственные правила жизненного цикла поверх этого, что обычно делает эффект более заметным. Например, в React кallback, созданный во время определенной обработки отрисовки, сохраняет доступ к значениям, существовавшим во время именно этой обработки. Это может приводить к проявлению поведения, похожего на использование устаревшего состояния, хотя сама структура закрытия в JavaScript работает ровно так, как и задумано. Удивление возникает из-за ожидания, что кallback каким-то образом сможет автоматически получить самое свежее состояние — но закрытия работают именно не так.
Правильное решение зависит от того, что вам действительно нужно. Иногда вам действительно требуется значение в том виде, в котором оно существовало на момент начала операции, и в таком случае правильным шагом будет сначала создание стабильной копии. Иногда вам нужно самое свежее доступное значение, для чего требуется текущая ссылка вроде ref или функциональное обновление. А иногда правильным решением является то, чтобы изменяющиеся зависимости полностью пересоздавали функцию-обратный вызов.
Полезный вопрос заключается в том, должна ли отложенная работа отражать состояние мира на момент её планирования или на момент фактического выполнения. Сам закрытый функционал не имеет мнения по этому вопросу — он верно сохраняет любые отношения, установленные вашим кодом, даже если это не те отношения, которые вы хотели создать.
JavaScript ведет себя последовательно — наши умственные упрощения — нет
Ни одно из описанных здесь поведений не является произвольной особенностью. Ключевое слово const блокирует саму связь, а не значение внутри неё. Функция распространения копирует данные только на один уровень глубины. По умолчанию сортировка массивов сначала преобразует элементы в строки. Обработчики обещаний выполняются как микозадачи. Функция деструктуризации по умолчанию реагирует именно на значение undefined. Закрытия сохраняют лексические связи, а не фиксированные значения. Каждое из этих правил применяется языком с полной последовательностью.
Удивления возникают тогда, когда разработчики опираются на упрощённые представления, отличные от тех, которые на самом деле соблюдает движок выполнения. Мы говорим, что константа «не может измениться», что функция распространения «создаёт копию», что асинхронный вызов «находится внутри try/catch», или что закрытие «помнит значение». Такие упрощения полезны до тех пор, пока какое-то новое требование не зависит именно от деталей, которые они проигнорировали.
То, что защищает опытных разработчиков, — это не запоминание постоянно растущего списка фактов, а привычка делать контракты ясными там, где данные пересекают границы. Это означает нормализацию значений, поступающих из внешнего мира, рассмотрение понятий «отсутствует» и «недействительно» как отдельных категорий, избегание случайной модификации общих ссылок, четкое определение того, кто отвечает за обработку ошибок асинхронной операции, сохранение первоначального значения временных меток и часовых поясов, а также заранее принятие решения о том, должен ли задержанный обратный вызов использовать старое или текущее состояние.
Написание надежного JavaScript не сводится к избеганию каждой гибкой функции, предлагаемой языком. Речь идет о использовании этих функций без предположений о том, что их компактная синтаксис обещает больше, чем на самом деле предоставляет.
JavaScript продолжает удивлять опытных разработчиков именно потому, что опыт порождает доверие к коду, который кажется знакомым. Однако существует риск того, что на первый взгляд знакомая синтаксис может скрывать решения, касающиеся ссылок, преобразования типов, времени выполнения и ответственности за ошибки — решения, которые становятся видимыми лишь тогда, когда что-то ещё в системе меняется рядом с ними.
Язык почти всегда делает именно то, что ему было поручено.
Удивление возникает, когда вы понимаете, что на самом деле ваш код приказал ему делать.
Связанные статьи
- Распространённые ошибки JavaScript и TypeScript, которые тайно ломают код — объясняет тонкие проблемы JavaScript и TypeScript, начиная с сравнений с NaN и заканчивая асинхронным временем выполнения и принудительным преобразованием типов, которые вызывают баги, несмотря на кажущуюся корректность.