Это вторая статья в серии, посвященной сравнению языков программирования по необычному критерию — как они справляются с организацией DOM-подобных структур данных.


Вводная часть тут: Настоящий тест для языков программирования. В ней рассказывается:

  • что такое DOM-подобные структуры данных,

  • почему они присутствуют буквально в каждом приложении,

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

Сегодня мы рассмотрим как JavaScript справляется с задачей Card DOM.

Код

Полный пример кода слишком большой (~330 строк), чтобы опубликовать его тут.
Он доступен на JSFiddle:https://jsfiddle.net/bq5gah7r/1/.

Реализация Card DOM показала ряд сильных и слабых сторон JavaScript применительно к DOM-подобным структурам данных.

Где JS хорош из коробки

  • JavaScript обеспечивает безопасную работу с памятью — без use-after-free и висячих указателей.

  • Сборщик мусора предотвращает утечки, пусть и с отложенным освобождением памяти.

  • Полиморфизм позволяeт естественно моделировать предметную область сохраняя модульную структуру программы.

  • Неизменяемость через заморозку: Общие ресурсы, такие как Style и Bitmap, фиксируются с помощью Object.freeze() для предотвращения случайных изменений.

Где JS не справляется

  • Браузер предоставляет встроенные механизмы для строгого контроля уникальности владения и для предотвращения циклических ссылок в дереве HTML DOM. Но JavaScript не предоставляет такие же механизмы для JS-объектов, поэтому разработчикам приходится реализовывать их самостоятельно.

  • JavaScript имеет слабые указатели - WeakRef, но они непригодны для DOM-сценариев, так как они продолжают указывать на объект, пока он не будет удален сборщиком мусора, тогда как нам требуется немедленное удаление ссылок на объекты при их удалении из дерева.

  • Встроенная операция копирования с учетом топологии (structuredClone) не поддерживает пользовательские объекты и не различает композитные, агрегатные и не владеющие ссылки, что делает ее непригодной для использования в нашем Card DOM.

Особенности реализации CardDOM с учетом ограничений

  • Контроль циклов и множественного владения: Каждый узел DOM получает поле parent для обеспечения проверки уникальности владения и отсутствия закольцовок с выбросом исключений при нарушениях. Эта проверка будет вызываться при добавлении элемента в дерево.

  • Не владеющие перекрестные ссылки: Узлы, на которые можно ссылаться, хранят коллекции входящих ссылок (inboundButtons, inboundConnectors). При удалении им приходит detach().

  • Каскадное удаление: Удаление узла рекурсивно очищает всё его поддерево.

  • Двухфазное глубокое копирование:

    • Первая фаза - создается копия дерева и map объектов(оригинал -> копия).

    • Вторая фаза - восстановление топологии перекрестных ссылок в копии. Это требует значительного объема рукописного кода.

Что не получилось

  • Нельзя декларативно указать, что в Text-style и Image-bitmap можно хранить только frozen-объекты.

  • Когнитивная нагрузка: Код включает много ручной логики для методов deepCopy(), detach(), resolve() и т. д. Разработчики должны тщательно отслеживать жизненный цикл объектов и корректно вызывать detach*() во избежание кросс-ссылок на убитые объекты.

  • Ссылки из стека могут указывать на объекты в полу-убитом состоянии (с уже оторванными перекрестными ссылками).

Примеры использования

Создание документа

const doc = new Document();
const normal = makeSharedResource(new Style("Times", 16.5, 600));
const card = new Card();
doc.addCard(card);
const helloText = new TextItem("Hello", normal);
card.addItem(helloText);
const buttonOk = new ButtonItem("Click me", card);
card.addItem(buttonOk);
card.addItem(new ConnectorItem(helloText, buttonOk));
Новый документ
Новый документ

Копирование

const newDoc = deepCopy(doc);
// Неизменяемые ресурсы - шарятся,
// Перекрестные ссылки - сохраняют топологию
// Владеющие - определяют дерево и задают область копирования.
Результат копирования
Результат копирования

Стили: копирование при изменениях

// Стиль нельзя изменять - он замороженный
// doc.cards[0].items[0].style.font = "Helvetica";  // Throws: Immutable!

// Но можно изменять его копию.
doc.cards[0].items[0].mutateStyle(style => {
  style.font = "Helvetica";
});

Вставка с проверками на циклы и multiparenting

// Детектирование циклов
const group = new GroupItem();
const subgroup = new GroupItem();
group.addItem(subgroup);
subgroup.addItem(group);  // Throws: Loop detected!
// Нельзя вставлять один элемент в два места дерева
// doc.addCard(newDoc.cards[0]);  // Throws: Multiparenting attempt!

// Но копию вставлять можно:
doc.addCard(deepCopy(newDoc.cards[0]));

Удаление элемента с обрывом перекрестных связей

newDoc.cards[0].removeItem(newDoc.cards[0].items[0]);
// "Hello" удалился из карточки, и на него больше не ссылается коннектор.
Результат удаления: connector.from==null
Результат удаления: connector.from==null

Оценка: как JavaScript справился с DOM-подобными структурами

Критерий

Да

Но

Безопасность памяти

Безопасность памяти гарантирована: нет указателей на мусор, двойных освобождений и падений.

Однако после отсоединения узлов от DOM и выполнения detach() эти логически «уничтоженные» объекты могут оставаться достижимыми из стека, частично разрушенные с удаленными перекрестными ссылками.

Предотвращение утечек

Освобождением занимается GC.

GC = непредсказуемость моментов освобождения, отсутствие гарантии что любой конкретный объект будет удален раньше чем завершится программа, невозможность привязывать внешние ресурсы к времени жизни объектов (отсюда требование ручной очистки). Освобождение объектов привязано к достижимости по графу ссылок, что требует аккуратно следить, чтобы логически не-владеющие ссылки или аккуратно разрывались вручную или реализовывалось через WeakRef иначе будет утечка.

Ясность владения

Нет различий между агрегатами, композитами и не владеющими ссылками. Всё владение реализуется вручную — через подписки и очистку, проверки при вставке и ручное копирование. Частичные обходные решения есть (например, structuredClone с transfer), но они фрагментарны.

Копирование

Пишется вручную.

Слабые ссылки

Есть WeakRef.

WeakRef разрывается только при отложенном удалении объекта, когда/если до него доберется GC. А если нужно отписываться при логическом удалении объекта (а это почти всегда так) WeakRef бесполезен.

Устойчивость: Переживает ли система произвольные изменения?

Не падает.

Логическую целостность данных приходится обеспечивать вручную.

Выразительность: Можно ли это описать без боли и избыточности?

Использование Card DOM относительно просто, если вручную следить за вставкой, удалением и присваиванием перекрестных ссылок.

Реализация не декларативна, сложна и требует обширных тестов. Любое изменение структуры классов влечет переработку кода.

Момент обнаружения ошибок

Все ошибки целостности структур данных выявляются только во время исполнения, даже при использовании TypeScript. Тесты обязательны и обширны.

Итог: JavaScript справляется, но требует немалых усилий

JavaScript позволяет создать DOM-подобный граф, если:

  • вручную контролировать владение,

  • явно отслеживать слабые ссылки,

  • реализовать вручную логику глубокого копирования,

  • полагаться на реализованные вручную проверки, происходящие в рантайме.

Это решение лучше ручного управления памятью, но далеко от идеала.

Парадоксально, что сборщик мусора, несмотря на свою цену —

  • по памяти,

  • производительности,

  • и при своих непредсказуемых паузах, —

дает взамен сравнительно немного:

  • минимум эргономики,

  • низкую защиту целостности структур,

  • и отсутствие автоматизации при работе с графами объектов.

Эти выводы справедливы и для других языков со сборкой мусора: Java, Kotlin, C#, Python, Dart, Go, TypeScript. Все они сталкиваются с теми же ограничениями при работе со сложными изменяемыми графами объектов.

Следующий — C++.

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


  1. vita55555
    14.10.2025 16:51

    Приветствую,
    ссылка на вводную статью правильная ?
    https://jsfiddle.net/bq5gah7r/1/


    1. kotan-11 Автор
      14.10.2025 16:51

      Огромное спасибо, исправил.