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