Алгоритм резолва зависимостей в Angular Ivy: Математика Блум-фильтров и битовые маски
- понедельник, 26 января 2026 г. в 00:00:05
В своей прошлой статье я рассмотрел, как работает Dependency Injection в Ангуляре, но остался один интересный вопрос, как Ангуляр находит и резолвит сервисы в иерархиях почти моментально? Чтобы понять, как Angular находит сервисы в иерархии инжекторов так быстро, нужно заглянуть под капот Ivy. Главная оптимизация здесь переход от объектной модели (View Engine) к структуре плоских массивов и битовых масок.
Первая и главная оптимизация Ivy использование паттерна Flyweight (Легковес). Вместо того чтобы создавать тяжелый объект Injector для каждого компонента, Angular разделяет данные на два вида:
LView (Logical View): Создается для каждого экземпляра компонента. Это массив, который представляет конкретную ноду в DOM-иерархии и хранит живые инстансы сервисов.
TView (Template View): Создается один раз для всех компонентов одного типа. Это чертеж, который хранит статические характеристики.
Нас интересует поле data в TView. В нем хранится массив, в котором при создании TView резервируется место под битовую маску по адресу injectorIndex. На момент инициализации injectorIndex это просто текущая длина массива TView.data.
При создании Инжектора узла (getOrCreateNodeInjectorForNode) происходит наследование маски от родителя. Это критически важный этап для быстрого поиска:
Angular находит родительский инжектор (parentLoc).
Запускается цикл из 8 итераций (для обработки 256 бит).
Для каждого сегмента выполняется побитовое OR:
Child.Bloom = Parent.CumulativeBloom | Parent.OwnProvidersBloom
Это гарантирует, что каждый ребенок заранее знает о наличии любого сервиса выше по дереву. В 9-й слот памяти записывается ссылка parentLoc, которая служит указателем для физического перехода вверх при обходе. В итоге мы получаем два вида масок: кумулятивную (в LView) и локальную для конкретной ноды (в TView).
Как сервис получает свой бит? Angular Ivy унифицирует подход: неважно, класс это или InjectionToken система рассматривает их как ссылки на объекты.
Ленивая идентификация: В рантайме Angular расширяет объекты свойством __NG_ELEMENT_ID__. ID создается только тогда, когда токен реально запрашивается.
Генерация бита: Метод bloomHashBitOrFactory превращает любой ID в номер бита.
Если в качестве токена используется строка, метод возвращает charCodeAt(0) (код первой буквы). Это резко увеличивает шансы на False Positive.
Например: Если у вас есть AuthService в root и AdminService в компоненте (оба начинаются на 'A'), Angular может выдать ложноположительный результат и начать дорогостоящий поиск по иерархии, вместо того чтобы сразу пропустить дерево.
Ключевые моменты по токенам:
Защита наследования: hasOwnProperty гарантирует уникальность ID, даже если классы наследуются.
Схлопывание: Операция & BLOOM_MASK превращает любой ID в число от 0 до 255.
Системные исключения: Отрицательные ID зарезервированы для специальных объектов (например, самого Injector).
Блум-фильтр это вероятностный алгоритм: он точно говорит об отсутствии бита и возможно есть при позитивном результате. В Angular это массив из 8 чисел по 32 бита каждое (256 бит).
Пример: ID сервиса = 54.
Bucket Index: 54 >> 5 (быстрое деление на 32). Получаем индекс 1 (второе число в массиве).
Bit Index: 54 & 31 (остаток от деления на 32). Получаем позицию бита 22.
Fast Path (Мгновенная проверка): Angular проверяет совокупность битов: кумулятивную маску из LView (информация обо всех предках) и локальную маску из TView.data (информация о текущем узле). Если 22-й бит равен 0 в обоих источниках, поиск в DOM-дереве завершается мгновенно сервиса нет ни здесь, ни выше. Мы сразу переходим к EnvironmentInjector.
Traversal (Эффективный подъем): Если маска выдала 1, начинаем подъем:
Если Node Bloom родителя = 0, пропускаем его и идем выше по parentLoc.
Если Node Bloom родителя = 1, выполняем линейный поиск в массиве провайдеров этого узла.
Это превращает обход O(N) в серию битовых проверок. Мы тратим ресурсы на поиск только тогда, когда вероятность успеха максимальна.
Блум-фильтр является вероятностной структурой данных. Его основное свойство: он гарантирует отсутствие элемента в наборе (отрицательный результат), но лишь с определенной вероятностью подтверждает его наличие (положительный результат).
В контексте Angular DI это означает, что если соответствующий бит в маске равен 1, сервис может как присутствовать в данном инжекторе, так и отсутствовать из-за коллизии.
Коллизии в Angular DI возникают по двум причинам:
1. Математическая коллизия (ID & 255): Поскольку пространство маски ограничено 256 битами, разные токены неизбежно будут использовать один и тот же бит.
Токен А: получил ID = 60. Вычисление бита: 60 & 255 = 60. Позиция: 28-й бит во второй корзине.
Токен Б: получил ID = 316. Вычисление бита: 316 & 255 = 60. Позиция: также 28-й бит во второй корзине.
Если в компоненте зарегистрирован только Токен А, 28-й бит будет установлен в 1. При запросе Токена Б алгоритм получит положительный результат, несмотря на физическое отсутствие сервиса на узле.
2. Использование строковых токенов
Как было показано выше в разборе функции bloomHashBitOrFactory, для строк в качестве ID используется charCodeAt(0). Это сужает пространство уникальных ID до набора кодов первых символов, что многократно увеличивает вероятность совпадения битов для разных сервисов (например, для всех сервисов, начинающихся на букву "A").
Ложноположительный результат не приводит к ошибке инъекции неверного типа, так как алгоритм поиска включает этап обязательной верификации:
Проверка маски: Если бит в TView.data или кумулятивной маске LView равен 0, узел пропускается.
Вход в инжектор: Если бит равен 1, Angular обращается к массиву провайдеров данного узла.
Линейный поиск: Происходит последовательный перебор провайдеров с проверкой на строгое равенство токенов.
Обработка промаха: Если после перебора массива нужный токен не найден (случай False Positive), алгоритм возобновляет поиск, переходя к родительскому инжектору через ссылку parentLoc.
False Positive влияет исключительно на время резолва зависимости. Вместо мгновенного перехода к следующему узлу за O(1), Angular выполняет операцию O(n), где n - количество провайдеров на данном узле.
Учитывая, что на одном узле обычно регистрируется небольшое количество сервисов, накладные расходы на единичные коллизии остаются минимальными. Однако плотное заполнение маски (большое количество уникальных токенов на одном пути иерархии) может снизить эффективность быстрого пути Блум-фильтра.
Если Блум-фильтр вернул 0 или линейный поиск в ElementInjector не дал результатов, Angular переходит к EnvironmentInjector. Поиск продолжается в классе R3Injector.
Тут нет Блум-фильтров, иерархия плоская:
Records Map: Проверка в Map<Token, Record>.
Hydration: Создание инстанса, если его еще нет.
Parent Lookup: Рекурсивный вызов parent.get().
Полная цепочка резолва:
NodeInjector (через Bloom) -> RouteInjector -> EnvironmentInjector (Root) -> PlatformInjector -> NullInjector (ошибка или @Optional).
Angular DI это гибридная система:
Element Injector: Оптимизирован для глубоких деревьев DOM. Использует Блум-фильтры для минимизации обходов за счет битовых операций.
Environment Injector: Оптимизирован для плоских структур модулей и роутов с помощью Map.
Понимание этой анатомии позволяет не перегружать дерево лишними токенами и четко представлять, какую цену платит приложение за каждую инъекцию.