Map принимает ключи любого типа, хранит порядок вставки, знает свой size и итерируется; обычный объект превращает каждый ключ в строку или символ и подходит для записей с известными полями. Set хранит уникальные значения с быстрым has; массив допускает дубликаты, а includes перебирает все элементы.
Ключи объекта строковые: obj[user] сохраняет ключ "[object Object]", поэтому два разных объекта затирают друг друга; кроме того, целочисленные ключи идут первыми, не в порядке вставки.
Нет унаследованных ключей: Map изначально пуст, а 'constructor' in {} даёт true; для ключей, которые вводит пользователь, Map (или Object.create(null)) защищает от конфликтов с __proto__.
JSON: JSON.stringify(map) даёт {}; преобразуйте через Object.fromEntries(map), а обратно через new Map(Object.entries(obj)).
**Set**: дубликаты убираются через [...new Set(list)]; NaN считается равным самому себе; в ES2025 появились union, intersection, difference и isSubsetOf.
В стейте React коллекцию заменяют, а не мутируют: setIds((prev) => new Set(prev).add(id)).
ReactMiddle
Продвинутый JavaScript
Приведение типов, call, apply и bind, оператор new, Map, Set и WeakMap, итераторы и генераторы, Proxy, сборка мусора, задачи «что выведет», свои Promise.all, curry и EventEmitter
Закрыто вопросов
0 / 18
- 0Повторить
- 4Часто спрашивают
Вопросы
WeakMap и WeakSet держат ключи слабо: запись не удерживает объект-ключ в памяти и исчезает, когда этот объект собран сборщиком мусора. WeakRef слабо держит один объект и возвращает его из deref(), пока тот не собран. Они нужны, чтобы привязать данные к объектам, временем жизни которых вы не управляете.
Ключами могут быть только объекты (и незарегистрированные символы, начиная с ES2023); new WeakMap().set('a', 1) бросает «Invalid value used as weak map key».
**Нет итерации, size и clear: содержимое зависит от того, когда сработает сборщик мусора, поэтому API не даёт его наблюдать.
Значения держатся сильно, пока жив ключ, но значение, которое ссылается на собственный ключ, не удерживает этот ключ.
Кэш на объект**: производные данные для неизменяемого объекта стейта, как делает weakMapMemoize, мемоизатор по умолчанию в Reselect 5; обычный Map здесь навсегда удержал бы каждый старый стейт.
**WeakSet помечает объекты как уже увиденные или обработанные, например для поиска циклов при глубоком копировании.
WeakRef и FinalizationRegistry** подходят для кэша больших объектов, которые можно создать заново. Момент сборки не гарантирован, а финализаторы могут не вызваться вовсе, поэтому логика программы не должна от них зависеть.
Выведется [object Object], 3, 52, 2, 1, NaN, true и baNaNa. Бинарный + склеивает строки, если после приведения обоих операндов к примитивам хотя бы один оказался строкой; все остальные арифметические операторы приводят обе стороны к числам.
**[] + {}**: оба объекта сначала становятся примитивами: [] превращается в '', а {} в '[object Object]', поэтому результат будет строкой.
**'5' - 2 и '5' + 2**: - работает только с числами, поэтому '5' становится 5; + видит строку и склеивает.
**true + 1, null + 1, undefined + 1**: приведение к числу даёт 1, 0 и NaN.
**[] == ![]**: сначала выполняется ! и даёт false, потому что любой объект truthy. Затем == превращает boolean в 0, а массив в '' и потом в 0, и сравнивается 0 == 0.
**'b' + 'a' + +'a' + 'a'**: унарный +'a' даёт NaN, который затем склеивается как текст.
Как объект становится примитивом: через Symbol.toPrimitive, если он есть, иначе valueOf, а потом toString (сначала toString, когда ожидается строка).
В начале инструкции {} + [] даёт 0 (проверьте eval('{} + []')): {} разбирается как пустой блок, и остаётся +[]. REPL Node оборачивает такой ввод в скобки и показывает '[object Object]'.
Все три задают this для обычной функции. call(thisArg, a, b) сразу вызывает её с аргументами через запятую, apply(thisArg, [a, b]) вызывает с массивом аргументов, а bind(thisArg, a) возвращает новую функцию, у которой this и первые аргументы зафиксированы для всех будущих вызовов.
Полифил: сохраните исходную функцию в замыкании и верните обёртку, которая объединяет заранее заданные и новые аргументы и вызывает fn.apply(thisArg, args).
**Привязанный this окончателен**: call, apply или повторный bind на привязанной функции его не меняют; добавляются только новые аргументы.
**new сильнее bind**: вызов привязанной функции через new игнорирует привязанный this и создаёт экземпляр исходной функции; обёртка проверяет для этого new.target.
**Стрелочные функции игнорируют thisArg** во всех трёх случаях, потому что берут this из внешней области видимости.
**call вручную**: положите функцию в объект под новым ключом-Symbol, вызовите её как метод и удалите ключ.
Сегодня spread заменил большинство применений apply (Math.max(...nums)), а каждый bind создаёт новую функцию, поэтому bind внутри рендера каждый раз передаёт дочернему компоненту новый проп.
Код выведет 1, 11, 2 и 12, а затем бросит TypeError: Cannot read properties of undefined (reading 'count'). Обычный метод берёт this из способа вызова, привязанная функция из bind, а стрелка в поле класса из экземпляра, который её создал.
**c.inc() даёт 1**: вызов метода, поэтому this равен c.
**other.inc() даёт 11**: та же функция, вызванная на other; метод не привязан к объекту или классу, из которого его взяли.
**other.incArrow() даёт 2**: стрелка создана в конструкторе c и запомнила this оттуда, поэтому увеличивает c.count, кто бы её ни вызвал.
**c.inc.bind(other).call(c) даёт 12**: bind навсегда фиксирует this как other, и call не может его переопределить.
**inc() бросает ошибку**: при простом вызове this равен undefined, потому что тело класса всегда работает в strict mode.
Приоритет: new, затем bind, затем call или apply, затем объект перед точкой, затем простой вызов; стрелки игнорируют всё это.
new Fn(args) создаёт пустой объект с прототипом Fn.prototype, вызывает Fn с this, равным этому объекту, и возвращает объект, если только Fn сама не вернула объект: тогда результатом будет он. Возвращённый примитив игнорируется.
Реализация: Object.create(Fn.prototype), затем Fn.apply(obj, args), затем выбор результата; это и есть весь myNew.
Связь с прототипом объясняет, почему работает instanceof и почему методы на Fn.prototype общие для всех экземпляров.
Вернуть объект из конструктора разрешено, и этим иногда пользуются для кэширования или фабрик; возврат 42 ничего не меняет.
Не всё является конструктором: стрелочные функции и методы из литерала объекта бросают «is not a constructor».
Классы бросают «Class constructor X cannot be invoked without 'new'», поэтому myNew не может вызвать их через apply; общий вариант даёт Reflect.construct(Fn, args), который к тому же выставляет new.target.
**new.target** показывает функции, вызвана ли она через new, и позволяет конструктору запретить простой вызов.
У каждого свойства есть дескриптор: value и флаг writable (или вместо них геттер и сеттер), а также enumerable и configurable. Object.seal запрещает добавлять и удалять свойства, Object.freeze ещё и делает значения только для чтения, и оба действуют поверхностно, поэтому вложенные объекты остаются изменяемыми.
Разные значения по умолчанию: у свойств, созданных присваиванием или в литерале, все флаги равны true; Object.defineProperty ставит недостающие флаги в false, и так добавляют скрытое поле только для чтения.
**enumerable: false** скрывает свойство от Object.keys, for...in, spread и JSON.stringify, но не от прямого обращения.
Громко или молча: запись в замороженное свойство бросает TypeError в strict-коде (модули, классы) и молча игнорируется в нестрогих скриптах.
Поверхностно: после Object.freeze(config) поле config.retry.count по-прежнему можно менять; deepFreeze рекурсивно замораживает каждый вложенный объект.
**const устроен иначе: он запрещает переприсваивать переменную, а не менять объект.
На практике** Immer, а значит и Redux Toolkit, замораживает создаваемый стейт, поэтому случайная мутация бросает ошибку, а не молча пропускает ре-рендер.
Объект итерируемый, если у него есть метод [Symbol.iterator](), который возвращает итератор: объект с методом next(), возвращающим { value, done }. for...of обходит значения любого итерируемого объекта; for...in обходит перечисляемые строковые ключи объекта, включая унаследованные.
Кто использует протокол: for...of, spread, деструктуризация массива, Array.from, new Map(iterable), Promise.all и yield*.
Встроенные итерируемые объекты: массивы, строки (по кодовым точкам), Map, Set, типизированные массивы, arguments и NodeList. Обычные объекты не итерируемые; обходите Object.entries(obj).
Ленивость: значения выдаются по одному на каждый next(), поэтому итератор может быть бесконечным или остановиться раньше.
Досрочный выход: break или брошенная ошибка вызывают метод итератора return(), и туда кладут очистку ресурсов.
**for...in по массиву** выдаёт строковые индексы плюс любые лишние или унаследованные перечисляемые свойства, поэтому для массивов нужны for...of или методы массива.
Iterator helpers из ES2025: map.values().filter(fn).toArray() лениво связывает map, filter, take и drop без промежуточного массива.
Функция-генератор (function*) не выполняет тело сразу, а возвращает итератор; каждый next() выполняет тело до следующего yield и останавливается там, сохраняя всё локальное состояние. Поэтому на генераторах легко писать ленивые, в том числе бесконечные, последовательности и пошаговые процессы.
В обе стороны: next(value) возобновляет генератор, и value становится результатом приостановленного yield; return() и throw() возобновляют его с возвратом или исключением в точке этого yield, поэтому блоки finally всё равно выполняются.
**yield* делегирует другому итерируемому объекту, поэтому рекурсивный обход дерева получается коротким.
Асинхронные генераторы** (async function*) читают через for await...of: постраничный API превращается в поток элементов, а break останавливает обход до запроса следующей страницы.
redux-saga построена на генераторах: сага выдаёт через yield простые объекты-эффекты вроде call(api.fetchUser, id), поэтому тест проходит её по шагам без мока сети.
**async/await ведёт себя как генератор, которым управляют промисы, и Babel для старых браузеров компилировал его именно в это.
Когда не нужны**: для обычного списка массив понятнее и быстрее.
Цепочка выведет b BOOM 1, затем d, затем e again. Брошенная ошибка пропускает все обработчики успеха, пока её не возьмёт обработчик отказа; первый catch возвращает значение и этим возвращает цепочку на путь успеха, а вторая ошибка проходит мимо c к последнему catch.
**a пропускается**: then без обработчика отказа передаёт отказ дальше без изменений.
**Первый catch восстанавливает цепочку**: то, что он вернул, становится значением следующего шага, здесь 'BOOM 1'.
**c не вызывается никогда**: обработчик отказа в then(onFulfilled, onRejected) ловит только отказ предыдущего промиса, а не ошибку из своего же onFulfilled. В этом и состоит практическая разница с .then(f).catch(g).
**finally пропускает результат дальше: он не получает аргументов, выполняется в любом случае и передаёт отказ дальше, если только его колбэк сам не бросит ошибку или не вернёт отклонённый промис.
Цепочка без завершающего catch** заканчивается необработанным отказом (unhandled rejection), о котором сообщают консоль и событие unhandledrejection.
Каррирование превращает функцию от нескольких аргументов в цепочку вызовов, которая собирает аргументы, пока их не станет достаточно. curry сравнивает число собранных аргументов с fn.length: если их хватает, вызывает fn, иначе возвращает функцию, которая ждёт остальные.
Сбор между вызовами: каждый частичный вызов хранит уже собранные аргументы в замыкании и добавляет к ним новые, поэтому работают и sum(1, 2)(3), и sum(1)(2)(3).
**fn.length** считает только параметры до первого значения по умолчанию или rest-параметра: у (a, b = 2) => {} длина 1, у (...nums) => {} длина 0. Такие функции каррируют с явно заданной арностью.
Любое число аргументов: популярный на собеседованиях вариант add(1)(2)(3)() собирает аргументы, пока пустой вызов не завершит цепочку.
Каррирование и частичное применение: частичное применение один раз фиксирует часть аргументов (fn.bind(null, a)), а каррирование позволяет передавать их любыми группами.
Где пригодится: для специализированных функций вроде setField('email') или filterBy('status') и для композиции конвейеров; в обычном React-коде простое замыкание обычно понятнее.
Оберните функцию, постройте ключ из аргументов, верните результат из кэша, если ключ уже есть, а иначе вычислите и сохраните его. Простой кэш растёт без ограничений, поэтому его ограничивают: Map хранит порядок вставки, и на нём легко сделать небольшой LRU-кэш, а для ключей-объектов подходит WeakMap.
Только для чистых функций: одни и те же аргументы должны давать один и тот же результат без побочных эффектов, иначе кэш вернёт устаревший ответ.
Ключ: JSON.stringify(args) подходит для примитивов, но превращает функции в null, падает на циклах и считает два разных объекта с одинаковым содержимым одним; дайте вызывающему коду передать свою функцию key.
**LRU на Map**: при попадании удалите ключ и вставьте заново, чтобы пометить его свежим; когда size превысит лимит, удалите cache.keys().next().value, самую старую запись.
Один аргумент-объект: WeakMap с ключом по этому объекту освобождает запись вместе с объектом.
Асинхронные функции: кэшируйте промис, чтобы одновременные вызовы делили один запрос, и удаляйте запись при отказе, иначе одна ошибка останется в кэше навсегда.
В React useMemo работает как кэш размером один на компонент; memoize нужен во вспомогательных функциях уровня модуля.
Храните в Map список слушателей для каждого имени события: on добавляет, off удаляет, emit вызывает каждого слушателя с аргументами, а once регистрирует обёртку, которая удаляет себя перед вызовом настоящего слушателя. Пусть on возвращает функцию отписки.
**once и off**: в списке лежит обёртка, поэтому off(event, original) её не найдёт; сохраните ссылку (wrapper.original) и сравнивайте с обеими.
**Изменения во время emit**: слушатель из once удаляет себя посреди цикла; обходите снимок списка или заменяйте массив в off, а не вырезайте из него элемент, чтобы ни один слушатель не пропустился.
Ошибки: слушатель, который бросил ошибку, прерывает остальной emit; оберните каждый вызов в try/catch, если слушатели не должны влиять друг на друга.
Утечки: каждому on нужен парный off, например в функции очистки useEffect; именно поэтому эмиттер Node выводит MaxListenersExceededWarning, когда на одно событие подписано больше 10 слушателей.
Дубликаты: массив допускает одного слушателя дважды, как в Node; Set молча оставит один.
В браузере можно также унаследоваться от EventTarget и отправлять CustomEvent.
Верните новый промис, оберните каждый входной элемент в Promise.resolve, сохраняйте каждое значение по его индексу и считайте выполненные: резолвьте, когда счётчик дойдёт до общего числа, и отклоняйте, как только отклонится любой элемент. Пустой вход сразу резолвится в [].
Порядок по индексу: значения приходят в порядке завершения промисов, поэтому пишите results[index] = value, а не push.
**Считайте, а не проверяйте length**: если первым записать results[2], results.length станет равен 3, хотя два слота ещё пусты.
**Promise.resolve(item)** принимает обычные значения и thenable-объекты, как и настоящий Promise.all.
Промис завершается один раз: после первого reject последующие вызовы resolve и reject игнорируются, так что отдельный флаг не нужен.
Ничего не отменяется: остальные элементы продолжают выполняться после отказа; чтобы остановить их, передайте AbortSignal.
Варианты на том же каркасе: allSettled сохраняет { status, value } или { status, reason } и никогда не отклоняется; race завершается так же, как первый завершившийся элемент.
Вызывайте fn в цикле внутри try/catch; при ошибке, которую стоит повторить, ждите baseDelay * 2 ** attempt миллисекунд со случайным разбросом (jitter) и пробуйте снова, а после последней попытки пробросьте ошибку дальше. Повторяйте только временные сбои: ошибки сети, 429 и 5xx.
**return await** внутри try: простой return fn() отдаёт промис до его завершения, и его отказ минует ваш catch.
Jitter: когда перезапуск сервера роняет тысячу клиентов разом, фиксированные задержки возвращают их всех в один момент; случайная задержка в пределах backoff разносит их по времени.
Ограничьте задержку (maxDelay) и число попыток, чтобы пользователь не ждал минутами.
Не повторяйте 4xx: 400 или 403 упадут точно так же; для 429 учитывайте заголовок Retry-After, если сервер его присылает.
Идемпотентность: повтор POST может создать второй заказ; повторяйте только безопасные запросы или отправляйте ключ идемпотентности, по которому сервер убирает дубликаты.
Отмена: принимайте AbortSignal, чтобы уход со страницы останавливал и ожидание, и сам запрос.
Запустите N воркеров с общим курсором по списку задач: каждый воркер берёт следующий индекс, ждёт эту задачу, кладёт результат по тому же индексу и берёт следующую, пока список не кончится. Promise.all по воркерам резолвится, когда выполнены все задачи.
Почему именно так
Задачи передаются функциями, а не промисами: промис уже выполняется в момент, когда вы его получили, поэтому пул промисов ничего не ограничивает.
Пачки по N медленнее: Promise.all на каждую пачку ждёт самую медленную задачу в ней, а воркеры берут новую задачу сразу, как только освободились.
**Общий next++ безопасен**: JavaScript выполняет один фрагмент кода за раз, и между чтением курсора и его увеличением нет await.
Ошибки и отмена
В этой версии первый отказ отклоняет весь пул, но остальные воркеры продолжают запускать задачи; проверяйте в цикле флаг failed или передавайте задачам AbortSignal.
Чтобы вернуть исход каждой задачи, сохраняйте по индексу { status, value } или { status, reason }, как делает allSettled.
На практике
Типичные случаи: загрузка множества файлов, предзагрузка сотен ресурсов, вызовы API с rate limit. По HTTP/1.1 браузер и так ставит в очередь запросы сверх примерно 6 соединений на хост, но HTTP/2 пропускает гораздо больше запросов через одно соединение, а лимит к тому же бережёт сервер и память устройства.
Когда задачи приходят со временем, а не готовым списком, лучше подходит обёртка limit(fn) с очередью; обычные библиотеки для этого p-limit и p-map.
Proxy оборачивает объект и пропускает операции над ним (чтение, запись, удаление, in, получение списка ключей) через ваши функции-ловушки (traps), а Reflect выполняет поведение по умолчанию. Библиотеки используют его, чтобы видеть каждое чтение и запись без особых действий со стороны вызывающего кода.
Как его используют библиотеки
Immer: produce(state, (draft) => ...) отдаёт вам прокси; записи фиксируются, и копируется только изменённый путь, поэтому нетронутые ветки сохраняют идентичность. Результат замораживается, а черновики потом отзываются (revoke), поэтому обращение к ним позже бросает ошибку.
Vue 3: reactive() в ловушке get запоминает, какой эффект читает какой ключ, и перезапускает эти эффекты из set. Vue 2 использовал Object.defineProperty, который не видел добавленные ключи, и для них нужен был Vue.set.
MobX: observable() возвращает прокси для обычных объектов и массивов, чтобы отслеживать, что читает каждая реакция (Map вместо этого становится экземпляром класса ObservableMap); класс, сделанный наблюдаемым через makeObservable, не оборачивается в прокси: его экземпляр получает геттеры и сеттеры.
Чего он стоит
Скорость: каждое обращение превращается в вызов функции; для стейта это нормально, для горячих циклов по тысячам объектов слишком медленно.
Идентичность: proxy !== target, поэтому ===, проверка в Set и ключи WeakMap видят два разных объекта.
Внутренние слоты: метод Map, Set или Date, вызванный через простой прокси, бросает TypeError, потому что у прокси нет внутренних слотов цели, и метод класса, читающий #private-поля, тоже падает; библиотеки оборачивают такие случаи вручную.
**structuredClone** и postMessage не принимают прокси; сначала разверните его (current() в Immer, toRaw() во Vue, toJS() в MobX).
Инварианты: ловушка не может врать о неконфигурируемом свойстве только для чтения; движок бросит TypeError.
Движок освобождает объекты, до которых больше нельзя добраться от корней (глобальные объекты, стек вызовов, живые хэндлы), а не объекты, которые просто не используются. V8 делит кучу на молодое поколение, которое собирается часто и дёшево, и старое, которое собирается реже сборщиком mark-compact.
Как собирает V8
Молодое поколение: большинство объектов умирает молодыми, поэтому новые объекты попадают в небольшую область, которую scavenger очищает, копируя немногих выживших; его стоимость зависит от числа выживших, а не от объёма мусора.
Старое поколение: объекты, пережившие пару малых сборок, переводятся сюда; сборщик mark-compact размечает объекты от корней инкрементально и во вспомогательных потоках, очищает память в фоне, а во время короткой паузы переносит объекты с фрагментированных страниц.
Паузы всё ещё бывают, но обычно длятся несколько миллисекунд; код, который много аллоцирует в цикле, платит за это более частыми сборками.
Циклы не мешают: два объекта, ссылающиеся только друг на друга, недостижимы и будут собраны.
Ловушка замыкания
Все замыкания, созданные в одной области видимости, делят один объект контекста, и V8 кладёт в него каждую переменную, которую использует хотя бы одно из этих замыканий.
В примере total читает report, поэтому report попадает в общий контекст; обработчик клика о нём не упоминает, но удерживает контекст, а с ним и 8 МБ, пока жив сам обработчик. Двадцать вызовов удерживают около 160 МБ.
Исправления: вычисляйте в отдельной функции, чтобы большое значение не попадало в долгоживущую область видимости, или присвойте переменной null, когда она больше не нужна.
Как найти: в heap snapshot в DevTools панель Retainers показывает цепочку от обработчика через его context до report.