Проблема: «призрачные» обновления в SPA
В современных одностраничных приложениях (SPA) компоненты часто создаются и уничтожаются динамически. При этом реактивные системы, построенные вокруг подписок на сигналы, могут оставлять «живые» ссылки даже после того, как элемент удалён из DOM. Сценарий типичен: пользователь переключает вкладки, каждый переход создаёт новый набор эффектов, а старые остаются в памяти. В результате объём потребляемой памяти может расти до нескольких гигабайт, что приводит к падениям и заметному ухудшению отклика браузера.
Корневой причиной является отсутствие автоматической очистки подписок. Эффекты, объявленные внутри компонента, продолжают реагировать на изменения сигналов, хотя сам компонент уже не существует. Замыкания, захватывающие ссылки на DOM‑узлы, удерживают их в памяти, а система планирования обновлений инициирует «призрачные» рендеры, которые никогда не отобразятся.
Как работает реактивный эффект
В реактивных веб‑компонентах (RWC) каждый эффект представляет собой функцию, автоматически вызываемую при изменении зависимых сигналов. При создании эффекта система регистрирует его в глобальном списке активных задач. При изменении сигнала вызываются все связанные эффекты, независимо от того, где они находятся в иерархии компонентов.
По умолчанию такие эффекты не знают о жизненном цикле компонента. Если компонент удаляется, его эффекты остаются в списке, продолжают получать сигналы и удерживают ссылки на закрывающие переменные. При большом количестве переходов SPA список растёт линейно, а память не освобождается.
Иерархия parent‑child эффектов
Решение начинается с построения явной иерархии эффектов: каждый компонент становится «родителем» для своих дочерних эффектов. При создании эффекта он регистрируется в наборе (effectSet) текущего компонента. Набор хранит ссылки на все активные эффекты компонента и, что важнее, знает о своём родителе.
class RwcComponent extends HTMLElement {
constructor() {
super();
this._effects = new Set();
}
// регистрируем эффект и связываем его с компонентом
addEffect(effect) {
this._effects.add(effect);
return effect;
}
}
Когда дочерний компонент создаётся, его набор эффектов привязывается к родительскому через parentEffectSet. При удалении родителя можно пройтись по всему набору и корректно завершить каждый эффект, гарантируя, что никакие «зомби‑эффекты» не останутся.
Автоматическое завершение через disconnectedCallback
Web‑components предоставляют метод disconnectedCallback, вызываемый браузером в момент удаления элемента из DOM. Вместо ручного вызова dispose для каждого эффекта, мы привязываем очистку к этому колбэку:
disconnectedCallback() {
// Завершаем все эффекты, принадлежащие компоненту
for (const eff of this._effects) {
eff.stop(); // или любой API завершения эффекта
}
this._effects.clear();
// При наличии родителя освобождаем ссылки в его наборе
if (this._parentEffectSet) {
this._parentEffectSet.delete(this);
}
}
Таким образом, когда компонент исчезает, система автоматически освобождает все связанные ресурсы. Нет необходимости в отдельном управлении подписками в пользовательском коде.
WeakRef: безопасные ссылки на DOM‑узлы
Для полного устранения удерживаемых ссылок используется WeakRef. При регистрации эффекта мы сохраняем слабую ссылку на элемент, а не прямую:
function createEffect(component, fn) {
const ref = new WeakRef(component);
const effect = () => {
const target = ref.deref();
if (!target) return; // элемент уже собран GC
fn.call(target);
};
component.addEffect(effect);
return effect;
}
WeakRef позволяет сборщику мусора удалить объект, даже если эффект всё ещё находится в глобальном списке. При следующем запуске эффекта проверка ref.deref() вернёт null, и эффект просто завершится без попытки доступа к уже собранному узлу.
Практический пример интеграции
Рассмотрим типичный сценарий: компонент «таблица» подписывается на сигнал dataStore. При изменении данных таблица должна перерисоваться.
class DataTable extends RwcComponent {
connectedCallback() {
this._unsubscribe = createEffect(this, () => {
const data = dataStore.value; // реактивный сигнал
this.render(data);
});
}
render(data) {
this.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
}
createEffectсохраняет слабую ссылку наthis.- При удалении
DataTableвызываетсяdisconnectedCallback, который останавливает_unsubscribeи очищает набор эффектов. - Если по какой‑то причине эффект всё ещё запланирован, проверка
ref.deref()вcreateEffectпредотвратит обращение к уничтоженному элементу.
Результаты и дальнейшее развитие
Внедрение иерархии эффектов, автоматической очистки через disconnectedCallback и использования WeakRef привело к резкому падению потребления памяти в реальном проекте: после многократного переключения вкладок объём RAM стабилизировался в пределах 150 МБ вместо нескольких гигабайт. Кроме того, исчезла необходимость писать вручную dispose‑методы для каждой подписки, что упростило кодовую базу и снизило количество ошибок, связанных с утечками.
Дальнейшие планы включают расширение API effectSet для поддержки групповых транзакций, а также интеграцию с другими реактивными библиотеками (например, Solid.js) через адаптеры, сохраняющие те же принципы управления жизненным циклом. Такой подход обещает сделать реактивные веб‑компоненты надёжными и масштабируемыми даже в самых тяжёлых SPA‑приложениях.