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 при батчинге предотвращает срабатывание мёртвых эффектов.
nihil-pro
Слишком дорогой ценой.
1) WeakRef дороги сами по себе
2) WeakRef немного тормозят GC
3) WeakRef немного тормозят event loop
Кроме того:
Тут
childrenпростоSet, а неWeakSet, наличие в нем ссылки наEffectCbпрепятствует очисткиWeakRefсодержащее этот жеEffectCb, а значит сама упаковка вWeakRefне имеет смысла.Куда проще и дешевле была бы реализация через back-pointer.
tamazyanarsen Автор
Согласен, но это и не так важно, потому что WeakRef здесь больше как подстраховка, а не основной способ борьбы с утечками.
Основная суть в том, что всё это привязывается к компонентам и удаляется вместе с ними.
Да, но пользы значительно больше, чем незначительное влияние на производительность. И там не так много WeakRef получается, если смотреть в рамках одного компонента.
nihil-pro
Какой-то странный способ программировать. Вы или понимаете что код делает, или нет. Какая может быть подстраховка, если ясно что ссылку на объект держит Set, и пока он держит GC этот объект не очистит?
Очень даже значительное.
tamazyanarsen Автор
Чистит, когда разрушается компонент. Вызывается destroy для каждого эффекта. И после разрушения компонента уже никто не держит ссылку на эти эффекты.
А WeakRef на parent больше нужен, чтобы случайно не обратиться к эффекту, который был удален (есть такие ситуации в коде)
Наверное, стоит уточнить, что наши пользователи не заметили изменения в производительности.
Наверное, для ваших пользователей эти изменения были бы очень даже значительные, тут не буду спорить.
---------------------------------
Не пытайтесь найти там идеальное решение, его там точно нет)
Можно было бы сделать лучше?! Конечно.
Текущее решение мы посчитали наиболее подходящим для нас и наших пользователей.
Возможно, в будущем мы сделаем рефакторинг текущего решения и найдём что-то лучше.