Как мы победили утечки памяти в реактивных веб-компонентах (RWC)
- вторник, 24 февраля 2026 г. в 00:00:06
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; };
Когда 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 в обоих направлениях (компонент → эффект и эффект → компонент) даёт дополнительную защиту: если по какой-то причине одна из сторон уже была собрана GC, обращение через deref() вернёт undefined, и код не упадёт с ошибкой. Это особенно важно в edge-кейсах: быстрое монтирование/размонтирование при навигации, hot module replacement, гонки при асинхронной загрузке.
Отдельно стоит упомянуть, что эффекты в 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 при батчинге предотвращает срабатывание мёртвых эффектов.