Динамические view и порталы: TemplateRef / ViewContainerRef + CDK Portal

Версия 21.0.3, Ivy. Тема: как Angular создаёт и переносит контент динамически — низкоуровневые примитивы ядра (ng-template, TemplateRef, ViewContainerRef, createComponent) и тонкая обёртка над ними — CDK Portal. Структуры (LContainer/LView/TNode) — 02-view-data-structures.

Важно: пакет @angular/cdk НЕ входит в src-v21 (там только core/common). Часть про CDK Portal ниже описана по стабильному публичному API CDK (не менялось годами), но без построчной привязки к локальным исходникам — в отличие от части про ядро, которая выверена по src-v21.

Главная идея: всё сводится к LContainer + вставке LView

Любая «динамика» — это:

  1. где-то есть LContainer (якорь, обычно comment-нода) — точка вставки;
  2. создаётся LView (из шаблона embedded-view или из компонента);
  3. этот LView сплайсится в массив LContainer и его DOM-узлы физически вставляются в дерево.

TemplateRef/ViewContainerRef/ComponentRef/CDK Portal — разные «фасады» над этими тремя шагами. Понятна механика — понятно всё остальное.

<ng-template> → TemplateRef

Компилятор для <ng-template> эмитит инструкцию ɵɵtemplate (render3/instructions/template.ts:266). Она:

  • создаёт TNode типа Container + отдельный TView (TViewType.Embedded) под содержимое шаблона;
  • заводит LContainer в declaration-LView (шаблон САМ по себе ничего не рендерит — только «заготовка»).

TemplateRef (packages/core/src/linker/template_ref.ts:38) — это handle на такой узел. Главный метод:

createEmbeddedViewImpl(context, injector?, dehydratedView?): EmbeddedViewRef<C> {
  const embeddedLView = createAndRenderEmbeddedLView(
    this._declarationLView, this._declarationTContainer, context, {...});
  return new ViewRef(embeddedLView);
}

То есть createEmbeddedView клонирует TView шаблона в новый LView, прогоняет create-проход и отдаёт EmbeddedViewRef. Но сам по себе он ещё не вставлен в DOM — вставкой управляет ViewContainerRef (либо это делает структурная директива).

context становится LView[CONTEXT] — отсюда $implicit, let-x в шаблоне.

ViewContainerRef — императивная вставка

packages/core/src/linker/view_container_ref.ts:129. Обёртка над конкретным LContainer, заякоренным на comment-узле. API: createEmbeddedView, createComponent, insert, move, remove, detach, indexOf, get, length.

createEmbeddedView

view_container_ref.ts:386 — делегирует в TemplateRef, затем вставляет:

const viewRef = templateRef.createEmbeddedViewImpl(context || {}, injector, dehydratedView);
this.insertImpl(viewRef, index, shouldAddViewToDom(...));
return viewRef;

createComponent (v15+ API)

view_container_ref.ts:429 — принимает тип компонента (не фабрику). Внутри строит ComponentRef через ComponentFactory.create и вставляет его host-view:

const componentDef = getComponentDef(componentType)!;
const componentRef = componentFactory.create(contextInjector, projectableNodes, rNode,
                                             environmentInjector, directives, bindings);
this.insertImpl(componentRef.hostView, index, shouldAddViewToDom(...));
return componentRef;

Единая точка вставки: insertImpl

view_container_ref.ts:590 — куда сходятся ОБА пути (embedded и component):

private insertImpl(viewRef, index?, addToDOM?): ViewRef {
  const lView = viewRef._lView!;
  if (viewAttachedToContainer(lView)) { /* уже где-то вставлен → сначала detach */ }
  const adjustedIdx = this._adjustIndex(index);
  addLViewToLContainer(this._lContainer, lView, adjustedIdx, addToDOM);  // <-- сплайс + DOM
  viewRef.attachToViewContainerRef();
  addToArray(getOrCreateViewRefs(lContainer), adjustedIdx, viewRef);
  return viewRef;
}

addLViewToLContainer (render3/view/container.ts:98):

insertView(tView, lView, lContainer, index);     // логически: в массив LContainer + view tree
if (addToDOM) {
  const beforeNode = getBeforeNodeForView(index, lContainer);
  const parentRNode = renderer.parentNode(lContainer[NATIVE]);
  if (parentRNode !== null)
    addViewToDOM(tView, lContainer[T_HOST], renderer, lView, parentRNode, beforeNode);
}

addViewToDOMapplyView(... WalkTNodeTreeAction.Insert ...) обходит TNode-дерево view и зовёт nativeInsertBefore/nativeAppendChild (render3/dom_node_manipulation.ts:47) — это и есть физическая вставка в DOM перед якорной нодой. move = insert уже присоединённого view (view_container_ref.ts:641); remove/detach делают обратное (detachView + destroyLView).

Структурные директивы (@if/@for, *ngIf) под капотом используют ровно это: инжектят ViewContainerRef + TemplateRef и зовут createEmbeddedView/remove. В v21 @if/@for компилируются в ɵɵconditional/ɵɵrepeater, но механика контейнера та же (см. 03-runtime).

ngTemplateOutlet

packages/common/src/directives/ng_template_outlet.ts:44 — тончайшая обёртка: на изменение входа удаляет старый view и зовёт viewContainerRef.createEmbeddedView(templateRef, ctx, {injector}). Никакой магии — просто публичный API контейнера.

createComponent — компонент без контейнера

packages/core/src/render3/component.ts:84 — standalone-функция createComponent из @angular/core для императивного создания компонента в произвольный host-элемент:

createComponent(component, { environmentInjector, hostElement?, projectableNodes?, ... })
  → new ComponentFactory(getComponentDef(component)).create(injector, projectableNodes,
        hostElement, environmentInjector, directives, bindings);

ComponentFactory.create (render3/component_ref.ts:254) создаёт root TView/LView, находит/создаёт host-элемент (locateHostElement, если задан селектор/узел — см. 03-selector-matching), гоняет directiveHostFirstCreatePasscreateDirectivesInstancesrenderView, возвращает ComponentRef. Это же — путь bootstrap корня. Чтобы такой компонент попал в CD-дерево, его host-view либо вставляют в ViewContainerRef, либо регистрируют через ApplicationRef.attachView.

EmbeddedViewRef / ViewRef

render3/view_ref.ts:51 — публичная обёртка над LView:

  • rootNodescollectNativeNodes(...) собирает верхние DOM-узлы view;
  • contextlView[CONTEXT];
  • destroy() — отцепляет из LContainer[VIEW_REFS], detachView, destroyLView;
  • реализует ChangeDetectorRef (detectChanges, markForCheck) для этого view.

CDK Portal — полиморфная обёртка (не в src-v21)

@angular/cdk/portal. Идея: «контент (шаблон ИЛИ компонент ИЛИ кусок DOM), который можно динамически воткнуть в любой PortalOutlet». Это фасад над теми же createEmbeddedView / createComponent / перенос DOM — ничего нового на уровне рантайма не добавляет.

Портал (что переносим):

  • TemplatePortal(templateRef, viewContainerRef, context?) — обёртка над TemplateRef.
  • ComponentPortal(component, viewContainerRef?, injector?) — обёртка над типом компонента.
  • DomPortal(element) — уже существующий кусок DOM.

Outlet (куда вставляем): PortalOutlet/BasePortalOutlet; две реализации:

  • DomPortalOutlet — императивный, привязан к произвольному DOM-элементу (нужны ComponentFactoryResolver/ApplicationRef/Injector);
  • CdkPortalOutlet — директива [cdkPortalOutlet], держит ViewContainerRef.

Диспетчеризация attach(portal) по типу:

attach(TemplatePortal)  → viewContainerRef.createEmbeddedView(templateRef, context)
attach(ComponentPortal) → viewContainerRef.createComponent(component, ...)   // или factory.create в DomPortalOutlet
attach(DomPortal)       → физически переносит portal.element в host outlet'а (appendChild)

То есть TemplatePortal = createEmbeddedView (наш путь выше), ComponentPortal = createComponent, DomPortal = голый перенос DOM-узлов. CDK добавляет лишь единый полиморфный интерфейс + хуки attached/detach. На этом построены CDK Overlay, Dialog, меню и т.п.

Сквозная линия

ng-template ─ɵɵtemplate→ LContainer + TemplateRef
                                   │
TemplateRef.createEmbeddedView ─┐  │ ComponentFactory.create ─┐
                                ▼  ▼                          ▼
                       ViewContainerRef.insertImpl      (root/host LView)
                                   │
                       addLViewToLContainer  → insertView (массив LContainer + view tree)
                                              → addViewToDOM → applyView → nativeInsertBefore
                                   ▲
        CDK Portal.attach() ───────┘  (TemplatePortal→createEmbeddedView,
                                       ComponentPortal→createComponent, DomPortal→DOM move)

ViewContainerRef / TemplateRef — это движок; ngTemplateOutlet, @if/@for, CDK Portal — фасады разной толщины над одной и той же вставкой LView в LContainer.

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

  • render3/instructions/template.ts:266ɵɵtemplate (ng-template → LContainer+TView).
  • linker/template_ref.ts:38TemplateRef.createEmbeddedViewImpl.
  • linker/view_container_ref.ts:129ViewContainerRef; :386createEmbeddedView; :429createComponent; :590insertImpl (единая точка вставки); :641move.
  • render3/view/container.ts:98addLViewToLContainer; :194insertView.
  • render3/node_manipulation.ts:213addViewToDOM; render3/dom_node_manipulation.ts:47nativeInsertBefore/nativeAppendChild.
  • render3/view_ref.ts:51ViewRef/EmbeddedViewRef.
  • render3/component.ts:84 — standalone createComponent; render3/component_ref.ts:254ComponentFactory.create.
  • common/src/directives/ng_template_outlet.ts:44ngTemplateOutlet.
  • CDK Portal: @angular/cdk/portal (вне src-v21).