Full AOT pipeline — как компилируется ШАБЛОН приложения

Это про твой код (ng build приложения, режим full). Для библиотек путь иной — см. 02-partial-ivy-and-linker. Контекст компиляций: 00-overview.

Разбор по реальному pipeline @angular/compiler: каждый шаг подтверждён прогоном на нашем шаблоне (apps/demo21/dump-compile.js) и итоговым dist/.../main.js.

TL;DR — 7 стадий одной таблицей

# Стадия Вход → Выход Файл в src-v21
1 HtmlParser строка шаблона → HTML AST compiler/src/ml_parser/
2 parseTemplate HTML AST → render3 AST (t.Node) compiler/src/render3/r3_template_transform.ts
2b Lexer+Parser строки выражений → expression AST (e.AST) compiler/src/expression_parser/
3 ingest render3 AST → IR (2 списка ops) compiler/src/template/pipeline/src/ingest.ts
4 ~70 фаз IR → IR (слоты, advance, имена) .../pipeline/src/phases/
5 reify IR-ops → вызовы ɵɵ-инструкций .../phases/reify.ts
6 chain вызовы → цепочки a().b() .../phases/chain.ts
7 emit всё → ɵɵdefineComponent({...}) .../pipeline/src/emit.ts

Главная идея Ivy-компилятора: не превращать AST сразу в JS, а сначала перевести в промежуточное представление (IR — набор операций), прогнать через много мелких проходов, и только в конце «материализовать» в инструкции. Это как оптимизирующий компилятор языка программирования. Стадия 3 (ingest) и стадия 4 (фазы) — самое важное и самое непонятное по другим статьям, поэтому разберём подробно.


Кто это запускает: ngtsc и его 3 шага

Шаблон НЕ компилируется в браузере (это AOT). При сборке TypeScript запускается с плагином ngtsc (из @angular/compiler-cli) — обычный TS-трансформер. Он находит классы с @Component и обрабатывает их через ComponentDecoratorHandler (compiler-cli/src/ngtsc/annotations/component/src/handler.ts) в 3 шага:

  1. analyze — прочитать декоратор, распарсить шаблон (стадии 1–2 ниже).
  2. resolve — определить scope: какие директивы/пайпы видны в шаблоне.
  3. compile — запустить template pipeline (стадии 3–7) и заменить декоратор статическими полями ɵfac + ɵcmp.

Итог для нашего App (упрощённо из main.js):

var App = class _App {
  title = "demo21";
  count = signal(0, ...);          // signal/computed/методы компилятор НЕ трогает
  doubled = computed(() => this.count() * 2, ...);
  inc() { this.count.update((c) => c + 1); }
  static ɵfac = function App_Factory(t) { return new (t || _App)(); };
  static ɵcmp = ɵɵdefineComponent({ /* стадия 7 */ });
};

Стадия 1 — HtmlParser: текст → HTML-дерево

Строка лексится и парсится в HTML AST (ml_parser/). С tokenizeBlocks:true парсер понимает @if/@for как особые узлы Block.

Реальный вывод (сокращённо):

Element name=button
  attrs: Attribute { name="(click)", value="inc()" }   ← биндинг ЕЩЁ строка-атрибут
  Text "+1"
Block name=if  parameters: BlockParameter{ expression="count() > 2" }
Block name=for parameters: BlockParameter{ expression="n of items()" },
                           BlockParameter{ expression="track n" }

На этой стадии выражения ещё не разобраны — это просто строки. Парсер знает только структуру тегов и блоков.


Стадия 2 — parseTemplate: HTML AST → render3 AST (t.Node)

Семантический разбор (r3_template_transform.ts). Здесь:

  • (click)/[x] распознаются как BoundEvent/BoundAttribute;
  • {{ }}BoundText с объектом Interpolation;
  • Block{if}IfBlock, Block{for}ForLoopBlock;
  • каждое выражение парсится Lexer+Parser (стадия 2b).

Реальный вывод (сокращённо):

Element name=button
  BoundEvent { name=click, handler: Call → PropertyRead{name=inc} }
IfBlock → IfBlockBranch
  expression: Binary{ op=">", left: Call→PropertyRead{count}, right: LiteralPrimitive{2} }
ForLoopBlock
  expression: Call → PropertyRead{name=items}
  item: Variable{ name=n, value=$implicit }
  trackBy: PropertyRead{name=n}

Различай два «AST»:

  • t.Node (Element/BoundText/IfBlock…) — структура шаблона.
  • e.AST (PropertyRead/Call/Binary/LiteralPrimitive…) — выражения внутри биндингов. PropertyRead всегда висит на ImplicitReceiver (= ctx), поэтому count() позже станет ctx.count().

2b — Lexer+Parser выражения count() > 2

  • Lexer → токены: ['count', '(', ')', '>', '2'].
  • Parser → Binary{ ">", Call(PropertyRead "count"), LiteralPrimitive 2 }.

Стадия 3 — ingest: render3 AST → IR (тут начинается «магия»)

Точка входа ingestComponent (ingest.ts:57) создаёт ComponentCompilationJob и вызывает ingestNodes (ingest.ts:240), который идёт по t.Node и наполняет IR.

Самое важное про IR: у каждого view — два независимых списка операций:

  • unit.create — будущий create-проход (создание DOM);
  • unit.update — будущий update-проход (обновление биндингов).

Узлы адресуются НЕ числами, а символическими XrefId (allocateXrefId()). Числовые слоты появятся только на стадии 4.

Правила на нашем примере:

t.Node что кладётся в IR
Element create: ElementStartOpElementEndOp
BoundText create: TextOp(xref,'') (пустой) + update: InterpolateTextOp
BoundEvent create: ListenerOp
IfBlock для ветки allocateView() + condition в update
ForLoopBlock дочерний view + RepeaterCreateOp(create) + RepeaterOp(update)

Например ingestBoundText (ingest.ts:465) сразу раздваивает текст:

textXref = allocateXrefId()
create.push(createTextOp(textXref, ''))                          // пустой узел
update.push(createInterpolateTextOp(textXref, Interpolation(...)))// контент

Вот откуда в финале берётся раздвоение create/update. @if/@for через allocateView() создают вложенные compilation units — отдельные шаблонные функции (App_Conditional_8_Template, App_For_11_Template).


Стадия 4 — ~70 фаз: постепенное «опускание» (lowering)

transform(job) (emit.ts) гоняет IR через упорядоченный массив phases (emit.ts:103–170). Каждая фаза — маленький проход. Порядок критичен. Нам важны эти (остальное — i18n/defer/animations/pipes, пропускаем):

  1. specializeBindings — общий BindingOp → конкретика (property/listener/text).
  2. extractAttributes — статические атрибуты (class="big") и слушатели уходят в таблицу consts → станет consts:[[3,"click"],[1,"big"]].
  3. generateConditionalExpressions@if → тернарник count() > 2 ? слот : -1.
  4. resolveNames / resolveContextscountctx.count, nctx.$implicit; ImplicitReceiver материализуется в ссылку на ctx.
  5. slot allocation / countVariables — каждому create-op даётся числовой слот (0,1,2…); считаются decls (слоты) и vars (ячейки биндингов) → decls:12, vars:4.
  6. generateAdvance (phases/generate_advance.ts) — строит карту «op→слот» и вставляет ɵɵadvance(n) перед update-ops, чтобы сдвинуть указатель рантайма на нужный слот. Отсюда ɵɵadvance(2) между интерполяциями.
  7. nameFunctionsAndVariables — имена дочерним функциям.

После стадии 4 каждая операция знает свой числовой слот и точный вид инструкции — но это всё ещё IR, не JS.


Стадии 5–7 — reify → chain → emit

reify переводит каждый op в конкретный вызов инструкции:

IR op инструкция
ElementStartOp ɵɵdomElementStart(0,"h1")
TextOp ɵɵtext(1)
ListenerOp ɵɵdomListener("click", () => ctx.inc())
InterpolateTextOp ɵɵtextInterpolate1("Счётчик: ", ctx.count())
ConditionalOp ɵɵconditional(ctx.count() > 2 ? 8 : -1)
RepeaterOp ɵɵrepeater(ctx.items())
AdvanceOp ɵɵadvance(n)

Арность интерполяции выбирается тут (0 вставок → ɵɵtextInterpolate, 1 → …1).

chain склеивает подряд идущие вызовы одной инструкции в a().b().c() ради размера бандла. emit (emitTemplateFn) обходит view-дерево вглубь, оборачивает create- список в if(rf&1), update-список в if(rf&2), дочерние views эмитит отдельными функциями, статику кладёт в ConstantPool, и собирает итоговый ɵɵdefineComponent.


Итог: что реально в main.js

static ɵcmp = ɵɵdefineComponent({
  type: _App, selectors: [["app-root"]],
  decls: 12,                          // слотов в LView (стадия 4)
  vars: 4,                            // ячеек под биндинги (стадия 4)
  consts: [[3,"click"],[1,"big"]],    // таблица констант (extractAttributes)
  template: function App_Template(rf, ctx) { /* create + update */ },
  encapsulation: 2
});

Create-проход (rf&1) — строит DOM один раз, узлы по числовым слотам:

ɵɵdomElementStart(0,"h1"); ɵɵtext(1); ɵɵdomElementEnd();
ɵɵdomElementStart(6,"button",0); ɵɵdomListener("click",()=>ctx.inc()); ɵɵtext(7,"+1"); ɵɵdomElementEnd();
ɵɵconditionalCreate(8, App_Conditional_8_Template, 2, 0, "p", 1);          // @if
ɵɵrepeaterCreate(10, App_For_11_Template, 2, 1, "li", null, ɵɵrepeaterTrackByIdentity); // @for

Update-проход (rf&2) — биндинги каждый CD, навигация через advance:

ɵɵadvance(); ɵɵtextInterpolate(ctx.title);
ɵɵadvance(2); ɵɵtextInterpolate1("Счётчик: ", ctx.count());
ɵɵadvance(3); ɵɵconditional(ctx.count() > 2 ? 8 : -1);
ɵɵadvance(2); ɵɵrepeater(ctx.items());
  • ɵɵadvance(n) двигает указатель на n слотов (фаза generateAdvance) — узлы находятся по смещению, без хранения ссылок.
  • ɵɵtextInterpolate1 сравнивает значение со старым (ячейки vars) и трогает DOM только при изменении.
  • ctx.count() — это вызов signal; связь «сигнал→view» рантайм ставит во время чтения (см. фаза Runtime).

Дочерние шаблоны (@if/@for) — отдельные функции с тем же делением rf&1/rf&2; переменная цикла n приходит как ctx.$implicit, track nɵɵrepeaterTrackByIdentity.

Эволюция (для дельт к v18/v14): ɵɵdomElementStart/domListener в v14–v18 назывались ɵɵelementStart/listener; @if/@for в v14 не было — были *ngIf/*ngFor.


Карта pipeline

строка шаблона
  │ HtmlParser (1)
  ▼ HTML AST
  │ parseTemplate (2) + Lexer/Parser (2b)
  ▼ render3 AST (t.Node + e.AST)
  │ ingest (3)
  ▼ IR: create[] / update[], адресация XrefId
  │ ~70 фаз (4): specialize → extractAttrs → conditionalExprs →
  │              resolveNames → slot alloc → generateAdvance
  ▼ IR со слотами
  │ reify (5) → chain (6) → emit (7)
  ▼
ɵɵdefineComponent({ decls, vars, consts, template })