Главная / Статьи / Скрытый набор инструментов JavaScript: символы, WeakMaps, прокси и генераторы

Скрытый набор инструментов JavaScript: символы, WeakMaps, прокси и генераторы

Узнайте, как внутри работают символы, WeakMap/WeakSet, Proxy/Reflect, FinalizationRegistry и генераторы для предотвращения утечек памяти и обеспечения метапрограммирования на уровне фреймворка.

2046 слов

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

Чаще всего разработка на JavaScript опирается на знакомый набор инструментов: const, let, функции-стрелки, методы map/filter, деструктуризацию массивов и конструкции async/await. Этот повседневный набор инструментов позволяет решать подавляющее большинство задач при разработке.

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

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

1. Символы: ключи, которые никогда не сталкиваются

Symbol, добавленный в ES6, является примитивным типом, наряду с string, number, boolean, null, undefined и bigint.

Каждый вызов Symbol() генерирует совершенно новое, уникальное значение. Даже два символа, созданные с точно таким же описанием, никогда не будут равны друг другу:

const id1 = Symbol('id');
const id2 = Symbol('id');

console.log(id1 === id2); // false

Текст, передаваемый в Symbol('id'), представляет собой лишь необязательную метку для отладочного вывода — он не влияет на идентичность символа.

Зачем вообще существуют символы?

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

// Risky: Another script might already use `isProcessed`
user.isProcessed = true;

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

const TRACKING_ID = Symbol('trackingId');

const user = {
  name: 'Sarah',
  role: 'Admin'
};

// Safe: Nothing can clash with this exact key
user[TRACKING_ID] = 'txn_89412';

«Плащ невидимости»

Свойства, ключами к которым являются символы, игнорируются стандартными инструментами отражения и сериализации:

  • Циклы for...in игнорируют их.
  • Метод Object.keys(user) не учитывает их.
  • Метод JSON.stringify(user) полностью удаляет их.
console.log(Object.keys(user)); // ['name', 'role']
console.log(JSON.stringify(user)); // '{"name":"Sarah","role":"Admin"}'

Тем не менее, ключи символов на самом деле не являются секретными — вызов Object.getOwnPropertySymbols(user) всё равно позволит их обнаружить. Однако для обычной итерации и сериализации они остаются скрытыми, что делает их подходящими для внутренних флагов, запоминаемых значений и метаданных на уровне плагинов, которые не должны просачиваться в публичный API.

Хорошо известные символы: доступ к внутренним механизмам языка

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

Пользовательская итерация с помощью Symbol.iterator

Вы можете заставить любой обычный объект работать с конструкцией for...of, реализовав свой собственный символ:

const inventory = {
  items: ['Keyboard', 'Monitor', 'Desk Pad'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        }
        return { done: true };
      }
    };
  }
};

for (const item of inventory) {
  console.log(item); // Prints: Keyboard, Monitor, Desk Pad
}

Собственное поведение принудительной конвертации с использованием Symbol.toPrimitive

Это позволяет точно определять, что произойдёт при преобразовании вашего объекта в строку или число:

const money = {
  amount: 250,
  currency: 'USD',
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `${this.amount} ${this.currency}`;
    return this.amount; // default
  }
};

console.log(+money + 50); // 300
console.log(`${money}`);  // "250 USD"

2. WeakMap и WeakSet: очистка памяти без ручного учёта

Чтобы понять, почему важен WeakMap, полезно сначала рассмотреть, как обычный Map управляет памятью.

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

let cache = new Map();
let element = document.querySelector('#heavy-widget');

cache.set(element, { clicks: 0 });

// Later, the DOM node is removed:
element.remove();
element = null;

// Problem: The DOM node is still stuck in memory because `cache` holds it.

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

Здесь и приходит на помощь WeakMap. Он хранит только слабые ссылки на свои ключи, что имеет два важных последствия:

  1. Ключами могут быть только объекты (или, в современных движках, незарегистрированные символы) — примитивы вроде строк или чисел не могут использоваться в качестве ключей.
  2. Как только ничто другое в программе больше не ссылается на объект-ключ, коллектор мусора может освободить его, и соответствующая запись в WeakMap исчезает вместе с ним.
const metadataStore = new WeakMap();

function setupWidget(domNode) {
  metadataStore.set(domNode, { initializedAt: Date.now() });
}

// When domNode is removed from the DOM and its variable goes out of scope,
// the WeakMap entry is garbage-collected automatically.

Компромиссы, встроенные в дизайн

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

  • Он не является итерируемым: нет методов .forEach(), for...of и .keys().
  • Отсутствует свойство .size, поэтому невозможно узнать, сколько записей существует в настоящее время.
  • Доступны только четыре метода: .get(), .set(), .has() и .delete().

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

Практический паттерн: по-настоящему приватное состояние экземпляра

До того, как поля приватных классов на нативном уровне (#field) получили широкую поддержку, WeakMap считался стандартным способом обеспечения по-настоящему приватного внутреннего состояния экземпляров классов:

const privateData = new WeakMap();

class BankAccount {
  constructor(initialBalance) {
    privateData.set(this, { balance: initialBalance });
  }

  deposit(amount) {
    const data = privateData.get(this);
    data.balance += amount;
  }

  getBalance() {
    return privateData.get(this).balance;
  }
}

const account = new BankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance);       // undefined (completely inaccessible)

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

3. Proxy и Reflect: метапрограммирование, доступное сегодня

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

Если вы когда-либо обновляли реактивное состояние в Vue 3 или наблюдали, как современная библиотека реактивности автоматически отслеживает изменения, вы в действительности взаимодействовали с Proxy на фоне.

const targetUser = { name: 'Alex', age: 28 };

const handler = {
  get(target, prop, receiver) {
    console.log(`Reading property "${prop}"`);
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    if (prop === 'age' && typeof value !== 'number') {
      throw new TypeError('Age must be a valid number.');
    }
    console.log(`Setting "${prop}" to ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const monitoredUser = new Proxy(targetUser, handler);

monitoredUser.age = 29; // Logs: Setting "age" to 29
console.log(monitoredUser.name); // Logs: Reading property "name" -> "Alex"
// monitoredUser.age = 'thirty'; // Throws TypeError

Внутри обработчика Proxy вы, возможно, заметили использование методов Reflect.get() и Reflect.set(). Reflect — это встроенный глобальный объект, содержащий методы, имитирующие операции, которые может перехватить Proxy. Каждый трэп, который можно определить в обработчике Proxy, имеет соответствующий метод в Reflect, принимающий те же аргументы. Вместо того чтобы вручную писать что-то вроде target[prop] = value внутри трэпа set — что может привести к непредвиденным ошибкам при работе с прототипами или свойствами-доступами — достаточно вызвать соответствующий метод Reflect, который безопасно выполняет стандартное поведение движка и возвращает логическое значение, указывающее на то, успешно ли была выполнена операция.

4. FinalizationRegistry и WeakRef: наблюдение за процессом сбора мусора

Долгое время в JavaScript не существовало способа узнать, когда объект действительно был забран движком. ES2021 восполнил этот пробел двумя связанными низкоуровневыми API, предназначенными для программирования в стиле сложных систем: WeakRef и FinalizationRegistry.

WeakRef хранит ссылку на объект, не мешая сборщику мусора забрать его, но при этом позволяет восстановить объект, пока тот ещё жив:

let heavyAsset = { buffer: new ArrayBuffer(1024 * 1024 * 16) }; // 16MB buffer
const assetRef = new WeakRef(heavyAsset);

// Dereference to access
const asset = assetRef.deref();
if (asset) {
  // Asset still exists in memory
  console.log("Using cached asset");
} else {
  // Asset was collected by GC; reload it
  console.log("Asset was garbage collected");
}

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

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Cleanup hook: Resource "${heldValue}" was garbage collected.`);
});

(() => {
  const temporaryWorker = { id: 'worker_404' };
  registry.register(temporaryWorker, temporaryWorker.id);
  // temporaryWorker leaves scope here
})();

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

5. Функции-генераторы: пауза и возобновление по требованию

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

Функции-генераторы, определённые с использованием синтаксиса function*, нарушают это правило. Они могут приостановить своё выполнение и возобновить его позже, используя ключевое слово yield для обозначения точек паузы.

function* idGenerator() {
  let id = 1;
  while (true) {
    yield `UID_${id++}`;
  }
}

const gen = idGenerator();
console.log(gen.next().value); // UID_1
console.log(gen.next().value); // UID_2
console.log(gen.next().value); // UID_3

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

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

function* processLargeLogFile(lines) {
  for (const line of lines) {
    if (line.includes('ERROR')) {
      yield line.trim();
    }
  }
}

// Memory remains flat because lines are yielded one by one as consumed
for (const errorLine of processLargeLogFile(rawLines)) {
  sendToAlertService(errorLine);
}

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

В следующий раз, когда вы будете добавлять метаданные к узлам DOM, которые динамически появляются и исчезают, воспользуйтесь структурой WeakMap. При проектировании архитектуры плагинов, где внешний код должен определять собственные ключи, не пересекающиеся с другими, используйте Symbol. А когда вам нужен реактивный слой данных, автоматически отслеживающий изменения, построите его на основе Proxy.

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

Связанные материалы

  • Обнаружение скрытых цен с помощью обратной разработки запросов к фоновым API — Научитесь использовать инструменты DevTools браузера для поиска и воспроизведения запросов XHR, REST и GraphQL, которые загружают цены, без необходимости парсинга HTML.