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 при батчинге предотвращает срабатывание мёртвых эффектов.

Комментарии (5)


  1. nihil-pro
    24.02.2026 14:35

    победили утечки памяти

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

    Слишком дорогой ценой.

    1) WeakRef дороги сами по себе
    2) WeakRef немного тормозят GC
    3) WeakRef немного тормозят event loop

    Кроме того:

    дочерний добавляется в children родителя, а ссылка на родителя хранится через WeakRef

    Тут children просто Set, а не WeakSet, наличие в нем ссылки на EffectCb препятствует очистки WeakRef содержащее этот же EffectCb, а значит сама упаковка в WeakRef не имеет смысла.

    Куда проще и дешевле была бы реализация через back-pointer.


    1. tamazyanarsen Автор
      24.02.2026 14:35

      Тут children просто Set, а не WeakSet, наличие в нем ссылки на EffectCb препятствует очистки WeakRef содержащее этот же EffectCb, а значит сама упаковка в WeakRef не имеет смысла.

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

      Основная суть в том, что всё это привязывается к компонентам и удаляется вместе с ними.

      1) WeakRef дороги сами по себе

      2) WeakRef немного тормозят GC

      3) WeakRef немного тормозят event loop

      Да, но пользы значительно больше, чем незначительное влияние на производительность. И там не так много WeakRef получается, если смотреть в рамках одного компонента.


      1. nihil-pro
        24.02.2026 14:35

        WeakRef здесь больше как подстраховка

        Какой-то странный способ программировать. Вы или понимаете что код делает, или нет. Какая может быть подстраховка, если ясно что ссылку на объект держит Set, и пока он держит GC этот объект не очистит?

        чем незначительное влияние на производительность

        Очень даже значительное.


        1. tamazyanarsen Автор
          24.02.2026 14:35

          Какая может быть подстраховка, если ясно что ссылку на объект держит Set, и пока он держит GC этот объект не очистит?

          Чистит, когда разрушается компонент. Вызывается destroy для каждого эффекта. И после разрушения компонента уже никто не держит ссылку на эти эффекты.

          А WeakRef на parent больше нужен, чтобы случайно не обратиться к эффекту, который был удален (есть такие ситуации в коде)

          Очень даже значительное

          Наверное, стоит уточнить, что наши пользователи не заметили изменения в производительности.

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

          ---------------------------------

          Не пытайтесь найти там идеальное решение, его там точно нет)

          Можно было бы сделать лучше?! Конечно.

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

          Возможно, в будущем мы сделаем рефакторинг текущего решения и найдём что-то лучше.


  1. tamazyanarsen Автор
    24.02.2026 14:35

    не туда ответил изначально