Как Angular находит селекторы и привязывает директивы/компоненты

Версия 21.0.3, Ivy. Главный вопрос: «как Angular в рантайме ищет селекторы и подвязывается к ним». Сразу снимаем путаницу: бОльшая часть матчинга — на этапе компиляции, в рантайме «поиск селектора» в реальном DOM есть лишь в одной точке (bootstrap), а внутри шаблонов идёт сопоставление кандидатов из заранее зашитого списка против TNode.attrs — без обращения к DOM. Структуры (TView/LView/TNode) — 02-view-data-structures.

TL;DR — три уровня «нахождения»

  1. Compile-time: компилятор знает imports/dependencies компонента и зашивает в его defineComponent список директив-кандидатов (directiveDefs). Сам по элементу он директивы НЕ резолвит в рантайм-DOM — он лишь готовит реестр и template-функцию с инструкциями ɵɵdomElement(...).
  2. Runtime, bootstrap корня: ЕДИНСТВЕННЫЙ настоящий querySelector по живому DOM — найти host-элемент корневого компонента (<app-root>).
  3. Runtime, create-проход шаблона: для каждого элемента в первый проход (firstCreatePass) Angular перебирает реестр директив-кандидатов и тестирует селектор каждого против TNode.attrs (НЕ против DOM). Совпавшие — инстанцирует, вешает host-биндинги. Результат кешируется в TView, при повторных инстансах матчинг не повторяется.

Уровень 1 — что зашивает компилятор

ɵɵdefineComponent (render3/definition.ts:120) сохраняет:

  • selectors: CssSelectorList — селекторы самого компонента (по ним его найдут как кандидата В РОДИТЕЛЬСКОМ шаблоне);
  • dependencies → из них при создании TView строится directiveRegistry (render3/view/construction.ts:112) — плоский список всех директив/компонентов, видимых в этом шаблоне.

CssSelectorList — не строка, а уже распарсенный компилятором массив с флагами (SelectorFlags.ELEMENT/ATTRIBUTE/CLASS/NOT). Т.е. парсинг CSS-селектора сделан в compile-time; рантайму остаётся дешёвое сравнение по флагам.

Уровень 2 — bootstrap корня (единственный DOM-query)

Цепочка: bootstrapApplicationApplicationRef.bootstrapComponentFactory.create (render3/component_ref.ts:254). Внутри:

const hostElement = rootSelectorOrNode
  ? locateHostElement(hostRenderer, rootSelectorOrNode, cmpDef.encapsulation, injector)
  : createHostElement(cmpDef, hostRenderer);   // синтетический, если узел не задан

locateHostElement (render3/instructions/shared.ts:184) дёргает renderer.selectRootElement(selector) — вот он, реальный document.querySelector по селектору корневого компонента. Дальше на найденном узле сразу запускается тот же create-проход, что и для любого элемента (см. Уровень 3): directiveHostFirstCreatePasscreateDirectivesInstancesrenderView.

Вывод: «поиск селектора в живом DOM» в Angular = только эта точка. Всё остальное — сопоставление со статическими TNode.attrs.

Уровень 3 — create-проход: матчинг внутри шаблона

Инструкция элемента (в v21 — ɵɵdomElementStart/ɵɵelementStart, render3/instructions/element.ts:78):

const tNode = tView.firstCreatePass
  ? directiveHostFirstCreatePass(adjustedIndex, lView, TNodeType.Element, name,
      findDirectiveDefMatches, ...)        // <-- матчинг ТОЛЬКО в первый проход
  : (tView.data[adjustedIndex] as TElementNode);  // <-- потом берём готовый TNode
...
if (isDirectiveHost(tNode)) {
  createDirectivesInstances(tView, lView, tNode);  // <-- инстанцируем найденное
  executeContentQueries(tView, tNode, lView);
}

Два ключевых момента: (1) матчинг идёт один раз на класс шаблона; результат (directiveStart/directiveEnd, флаги) пишется в TNode и переиспользуется. (2) Матчинг смотрит в TNode.attrs, а не в DOM-атрибуты.

3.1 Перебор кандидатов: findDirectiveDefMatches

render3/instructions/shared.ts:459:

const registry = tView.directiveRegistry;          // кандидаты из dependencies
for (let i = 0; i < registry.length; i++) {
  const def = registry[i];
  if (isNodeMatchingSelectorList(tNode, def.selectors!, /*isProjectionMode*/ false)) {
    matches ??= [];
    if (isComponentDef(def)) matches.unshift(def);  // компонент — в начало
    else matches.push(def);
  }
}

То есть для каждого элемента линейно проходим список директив шаблона и проверяем селектор каждого. Компонент (если совпал) ставится первым. На элементе допустим максимум один компонент + сколько угодно директив.

3.2 Сам матчинг: isNodeMatchingSelector

render3/node_selector_matcher.ts:113 — сердце темы. Это маленький конечный автомат по распарсенному селектору с режимами ELEMENT → ATTRIBUTE/CLASS и поддержкой :not:

  • режим ELEMENT: сравнивает имя тега (hasTagAndTypeMatch); для <ng-template> тег подменяется на NG_TEMPLATE_SELECTOR;
  • режим ATTRIBUTE: ищет имя атрибута в TNode.attrs через findAttrIndexInNode; если у селектора задано значение — сравнивает (lowercase, без регистра);
  • режим CLASS: матчит класс через isCssClassMatching;
  • :not(...): инвертирует ожидание (SelectorFlags.NOT), при провале — skipToNextSelector.

findAttrIndexInNode (node_selector_matcher.ts:236) знает про плоский формат attrs с маркерами: пропускает Classes/Styles-секции, переключается в «binding mode» после AttributeMarker.Bindings/I18n (там только имена, без значений), игнорирует namespaced и Template-атрибуты при директивном матчинге. Поэтому селектор [foo] совпадает и с [foo]="x" (binding), и с обычным foo="...".

isNodeMatchingSelectorList (:286) — просто «ИЛИ» по списку: совпал хоть один селектор из CssSelectorList → матч.

3.3 Инстанцирование: createDirectivesInstances

render3/instructions/shared.ts:123instantiateAllDirectives (:386):

if (isComponentHost(tNode))
  createComponentLView(lView, tNode, /*componentDef*/ ...);  // у компонента — свой LView
...
for (let i = start; i < end; i++) {              // start/end = directiveStart/End
  const def = tView.data[i] as DirectiveDef;
  const directive = getNodeInjectable(lView, tView, i, tNode);  // вызов DI-фабрики
  attachPatchData(directive, lView);             // метка __ngContext__
  if (initialInputs !== null)
    setInputsFromAttrs(lView, i - start, directive, def, tNode, initialInputs);  // статические @Input из attrs
}

Каждая директива конструируется через node-injector (DI), кладётся в свой expando-слот LView, получает начальные @Input из статических атрибутов. У компонента дополнительно создаётся отдельный дочерний LView (его собственный шаблон).

3.4 Host-биндинги и host-листенеры

После инстанцирования — invokeDirectivesHostBindings (shared.ts:422), если у узла стоит TNodeFlags.hasHostBindings. Для каждой директивы с hostBindings:

def.hostBindings!(RenderFlags.Create, directive);  // создание: регистрация листенеров и т.п.

В create-режиме host: {'(click)': ...} регистрирует листенер (через ɵɵlistener/ ɵɵdomListener), статические hostAttrs применяются к элементу. Параллельно в первый проход registerHostBindingOpCodes (render3/view/directives.ts:485) пакует обновляемые host-биндинги в TView.hostBindingOpCodes — компактную «программу» из чисел: отрицательное число = «выбери этот элемент» (~tNode.index), дальше тройка (directiveIdx, varsIdx, hostBindingsFn).

В update-проходе processHostBindingOpCodes (render3/instructions/change_detection.ts:539) гоняет эту программу: hostBindingFn(RenderFlags.Update, context) — так host-[class]/ [attr]/[style] директив пересчитываются на каждом CD-цикле (увязка с 03-runtime).

Полный поток (create-проход) одной строкой

ɵɵdomElementStart
  └─(firstCreatePass)─ directiveHostFirstCreatePass
        └─ findDirectiveDefMatches            // перебор tView.directiveRegistry
              └─ isNodeMatchingSelectorList    // тест селектора против TNode.attrs
        └─ запись directiveStart/End + flags в TNode
  └─ createDirectivesInstances
        ├─ instantiateAllDirectives           // DI-фабрики, @Input из attrs, LView компонента
        └─ invokeDirectivesHostBindings        // hostBindings(Create): листенеры, hostAttrs
                                                + registerHostBindingOpCodes (для update)

Частые недопонимания

  • «Angular в рантайме сканирует DOM и ищет селекторы» — нет. DOM-query один раз на корне. Внутри — матч против статических TNode.attrs, и только в первый проход.
  • [attr] vs attr — селектор по атрибуту матчит и обычный атрибут, и property/event binding (через binding-mode в findAttrIndexInNode).
  • Почему директива не подхватилась — её нет в directiveRegistry, т.е. не попала в imports/dependencies шаблона (compile-time), а не «селектор не нашёлся в рантайме».
  • Один компонент на элементfindDirectiveDefMatches ставит компонент первым; второй компонент на том же узле — ошибка компиляции.

Привязка к исходникам

  • render3/definition.ts:120ɵɵdefineComponent (хранит selectors).
  • render3/view/construction.ts:112directiveRegistry из dependencies.
  • render3/instructions/shared.ts:184locateHostElementselectRootElement (DOM-query); :459findDirectiveDefMatches; :386instantiateAllDirectives; :422invokeDirectivesHostBindings.
  • render3/component_ref.ts:254ComponentFactory.create (bootstrap корня).
  • render3/instructions/element.ts:78ɵɵelementStart/create-проход элемента.
  • render3/node_selector_matcher.ts:113isNodeMatchingSelector; :236findAttrIndexInNode; :286isNodeMatchingSelectorList.
  • render3/view/directives.ts:485registerHostBindingOpCodes; render3/instructions/change_detection.ts:539processHostBindingOpCodes.