javascript

Как мы победили утечки памяти в реактивных веб-компонентах (RWC)

  • вторник, 24 февраля 2026 г. в 00:00:06
https://habr.com/ru/articles/1002708/

https://github.com/tamazyanarsen/reactive-web-components

Проблема: эффекты живут дольше компонентов

Реактивная модель на основе сигналов и эффектов — мощная штука. Сигнал хранит значение, эффект подписывается на сигнал и срабатывает при каждом изменении. Но есть фундаментальная проблема: когда компонент удаляется из DOM, его эффекты продолжают жить — они всё ещё подписаны на сигналы, всё ещё ссылаются на DOM-узлы, которых больше нет.

В классических фреймворках эта проблема прячется за абстракциями: Angular убирает эффекты через DestroyRef, Solid.js — через onCleanup внутри createRoot. Но если строить реактивность с нуля поверх нативных Web Components, ответственность за очистку ложится на разработчика библиотеки.

В ранних версиях RWC эффекты создавались свободно — через effect(() => { ... }) — и ни к чему не привязывались. Это приводило к:

  • Утечкам памяти — мёртвые эффекты удерживали ссылки на DOM-узлы через замыкания

  • Phantom-обновлениям — эффект продолжал записывать данные в уже удалённые элементы

  • Экспоненциальному росту подписчиков — при навигации по SPA каждый mount создавал новые эффекты, а старые не удалялись

Архитектура решения

Решение состоит из трёх частей: структура эффектов, привязка к компонентам и автоматическая очистка в disconnectedCallback.

Иерархия эффектов

Каждый эффект в RWC — это объект EffectCb с метаданными:

export type EffectCb = (() => void) & {
  status: "active" | "inactive";
  children?: Set<EffectCb>;
  parent?: WeakRef<EffectCb>;
  cleanupSet?: Set<() => void>;
  component?: WeakRef<HTMLElement>;
  destroy?: () => void;
};

Эффекты формируют дерево: когда effect() вызывается внутри другого effect(), дочерний добавляется в children родителя, а ссылка на родителя хранится через WeakRef. Когда родитель уничтожается, рекурсивно уничтожаются все дочерние:

export const removeEffect = (effectCb: EffectCb) => {
  effectCb.children?.forEach((child) => child.destroy?.());
  effectCb.children?.clear();
  effectCb.cleanupSet?.forEach((clean) => clean());
  effectCb.cleanupSet?.clear();
};

Привязка к компоненту

Каждый BaseElement содержит effectSet — набор WeakRef<EffectCb>, куда попадают все эффекты, созданные для данного компонента:

effectSet = new Set<WeakRef<EffectCb>>();

Метод addEffect в HtmlComponentConfig при создании эффекта делает две вещи: сохраняет WeakRef на компонент в самом эффекте и добавляет WeakRef на эффект в effectSet компонента:

addEffect = (cb, key?) => {
  const effectCb = () => cb(this, wrapperValue);
  wrapperValue.effectSet?.add(
    new WeakRef(effectCb as unknown as EffectCb)
  );
  effectCb.component = this.wrapper;
  effect(effectCb, { name: key?.toString() || wrapperValue.tagName });
  return this;
};

Очистка в disconnectedCallback

Когда Web Component удаляется из DOM, браузер вызывает disconnectedCallback. RWC перехватывает это в декораторе @component и вычищает всё:

disconnectedCallback() {
  this.allSlotContent = [];
  this.slotContent = {};
  this.htmlSlotContent = {};

  this.effectSet.forEach(eff => eff.deref()?.destroy?.());
  this.effectSet.clear();

  this.shadow.replaceChildren();
  this.replaceChildren();
}

Каждый эффект из effectSet получает вызов destroy(), который в свою очередь вызывает removeEffect — рекурсивно уничтожая все дочерние эффекты и вызывая все cleanup-функции из cleanupSet. Затем effectSet очищается, а Shadow DOM и light DOM компонента полностью очищаются через replaceChildren().

Почему WeakRef

Использование WeakRef в обоих направлениях (компонент → эффект и эффект → компонент) даёт дополнительную защиту: если по какой-то причине одна из сторон уже была собрана GC, обращение через deref() вернёт undefined, и код не упадёт с ошибкой. Это особенно важно в edge-кейсах: быстрое монтирование/размонтирование при навигации, hot module replacement, гонки при асинхронной загрузке.

Батчинг через queueMicrotask

Отдельно стоит упомянуть, что эффекты в RWC не выполняются синхронно при каждом set() — они батчатся через queueMicrotask:

export const sheduleEffect = (effectCb: EffectCb) => {
  if (effectCb.status === "active") {
    pendingEffects.add(effectCb);
  }
  if (!isPending) {
    isPending = true;
    queueMicrotask(() => {
      isPending = false;
      const effectList = Array.from(pendingEffects);
      pendingEffects.clear();
      effectList.forEach((cb) => callCb(cb));
    });
  }
};

Проверка status === "active" предотвращает выполнение эффектов, которые уже были помечены как неактивные через destroy(). Даже если сигнал обновляется в промежутке между вызовом destroy() и фактическим выполнением микротаска, мёртвый эффект не попадёт в очередь.

Итог

Привязка эффектов к жизненному циклу Web Component через effectSet + disconnectedCallback полностью решила проблему утечек памяти. Иерархия parent-child гарантирует каскадную очистку, WeakRef страхует от edge-кейсов, а проверка status при батчинге предотвращает срабатывание мёртвых эффектов.