首页
/ Angular 内容投送完全指南:ng-content、多占位符、Fallback 与 ngProjectAs 的实操与源码解析

Angular 内容投送完全指南:ng-content、多占位符、Fallback 与 ngProjectAs 的实操与源码解析

2026-09-06 14:39:00作者:农烁颖Land

本文基于 Angular 官方文档《Content projection with ng-content》展开,系统讲解 Angular 内容投送(Content Projection)的核心机制:单占位符、基于 CSS 选择器的多占位符、fallback 内容、ngProjectAs 别名投送,以及投送内容在变更检测与依赖注入中的归属问题。读完后,你将掌握构建"容器型组件"(卡片、对话框、菜单等)的完整技能,并能从 Angular 编译器与 ViewEngine 渲染指令的源码层面理解投送是如何在构建期解析、在运行期完成 DOM 归位的。

一、ng-content:内容投送的基础占位符

在构建 UI 组件库时,经常需要创建"容器型"组件:组件本身负责外壳样式与布局,而具体内容由使用者填入。典型的例子是一个自定义卡片组件。文档给出的起点是:

@Component({
  selector: 'custom-card',
  template: '<div class="card-shadow"> <!-- card content goes here --> </div>',
})
export class CustomCard {
  /* ... */
}

此时组件内部只有一个空位,使用者无法把内容"塞"进去。Angular 的解法是 <ng-content> 元素——一个标记"内容应渲染在哪里"的占位符

@Component({
  selector: 'custom-card',
  template: '<div class="card-shadow"> <ng-content/> </div>',
})
export class CustomCard {
  /* ... */
}

当使用带有 <ng-content> 的组件时,宿主元素的所有子内容都会被**投送(projected)**到该占位符的位置。用文档的三段式示例完整说明:

// 组件源码
@Component({
  selector: 'custom-card',
  template: `
    <div class="card-shadow">
      <ng-content />
    </div>
  `,
})
export class CustomCard {
  /* ... */
}
<!-- 使用组件 -->
<custom-card>
  <p>This is the projected content</p>
</custom-card>
<!-- 渲染后的 DOM -->
<custom-card>
  <div class="card-shadow">
    <p>This is the projected content</p>
  </div>
</custom-card>

这里有两个术语必须区分清楚:

  • 内容(content):以这种方式传入组件的子节点,即写在 <custom-card> 标签内部的节点;
  • 视图(view):组件自身模板中定义的节点,如 <div class="card-shadow">

<ng-content> 既不是组件,也不是真正的 DOM 元素。 它是一个特殊的占位符,Angular 编译器在构建期处理掉所有 <ng-content>:你无法在运行期插入、删除或修改它,也不允许在它上面添加指令、样式或任意属性。文档中特别强调了一条重要限制:

不应使用 @if@for@switch 条件性地包裹 <ng-content>。无论占位符是否可见,Angular 始终会为投送到该 <ng-content> 的内容实例化并创建 DOM 节点。

如果需要条件渲染组件内容,官方建议使用 ng-template 模板片段(template fragment)方案,而不是条件包裹投送占位符。

源码视角:投送定义如何生成

从源码结构看,"构建期处理"这一说法有明确的编译器实现佐证。在 generate_projection_def.ts 中,模板编译流水线会执行 generateProjectionDefs

  1. 遍历整个组件(含嵌套视图)的所有 Projection IR 操作,收集每个 <ng-content> 的选择器文本,并按模板中出现顺序为每个投送位分配从 0 开始的槽位索引projectionSlotIndex);
  2. 若只发现一个通配选择器 *,则直接使用默认行为不生成参数;否则将各选择器解析为 R3 选择器(parseSelectorToR3Selector),生成 projectionDef 指令,并前置插入到根视图的最开始,保证它先于任何 projection 指令执行;
  3. 同时生成 ngContentSelectors 常量数组,记录全部选择器文本形式。

注释里还解释了为什么选择器需要"解析形式 + 文本形式"两份:解析形式用于高效地做节点与 CSS 选择器的匹配;文本形式则是为了支持 ngProjectAs 属性——因为无法从解析形式反向还原出作者书写的原始文本。

二、多内容占位符:用 select 属性按选择器分流

Angular 支持将多个不同元素投送到不同的 <ng-content> 占位符,依据是 select 属性中指定的 CSS 选择器。文档将卡片例子扩展为"标题 + 正文"两个区域:

@Component({
  selector: 'card-title',
  template: `<ng-content>card-title</ng-content>`,
})
export class CardTitle {}

@Component({
  selector: 'card-body',
  template: `<ng-content>card-body</ng-content>`,
})
export class CardBody {}
// 组件模板
@Component({
  selector: 'custom-card',
  template: `
  <div class="card-shadow">
    <ng-content select="card-title" />
    <div class="card-divider"></div>
    <ng-content select="card-body" />
  </div>
  `,
})
export class CustomCard {}
// 使用组件
@Component({
  selector: 'app-root',
  imports: [CustomCard, CardTitle, CardBody],
  template: `
    <custom-card>
      <card-title>Hello</card-title>
      <card-body>Welcome to the example</card-body>
    </custom-card>
`,
})
export class App {}
<!-- 渲染后的 DOM -->
<custom-card>
  <div class="card-shadow">
    <card-title>Hello</card-title>
    <div class="card-divider"></div>
    <card-body>Welcome to the example</card-body>
  </div>
</custom-card>

<ng-content>select 属性支持与组件选择器相同的 CSS 选择器语法(标签名、类、属性、:not() 等)。

通配占位符:捕获"漏网"内容

如果模板中既有带 select 的占位符,又有一个不带 select 的占位符,后者会捕获所有没有被任何 select 匹配到的元素:

<!-- 组件模板 -->
<div class="card-shadow">
  <ng-content select="card-title" />
  <div class="card-divider"></div>
  <!-- 捕获除 "card-title" 之外的一切 -->
  <ng-content />
</div>
<!-- 使用组件 -->
<custom-card>
  <card-title>Hello</card-title>
  <img src="..." />
  <p>Welcome to the example</p>
</custom-card>
<!-- 渲染后的 DOM -->
<custom-card>
  <div class="card-shadow">
    <card-title>Hello</card-title>
    <div class="card-divider"></div>
    <img src="..." />
    <p>Welcome to the example</p>
  </div>
</custom-card>

反过来,若组件中不存在不带 select 的兜底占位符,任何未匹配的元素将直接被丢弃,不会出现在 DOM 中——这是排查"我传的内容怎么没显示"这类问题的第一检查点。

源码视角:匹配算法与"先到先得"规则

投送分流的运行时核心是 matchingProjectionSlotIndex。它对每个待投送节点执行如下逻辑:

let wildcardNgContentIndex = null;
const ngProjectAsAttrVal = getProjectAsAttrValue(tNode);
for (let i = 0; i < projectionSlots.length; i++) {
  const slotValue = projectionSlots[i];
  // 最后一个通配投送槽应匹配所有未匹配任何选择器的节点
  if (slotValue === '*') {
    wildcardNgContentIndex = i;
    continue;
  }
  // 若节点带有 ngProjectAs 属性,则拿其解析后的选择器去比对,
  // 否则退回对节点本身做选择器匹配
  if (
    ngProjectAsAttrVal === null
      ? isNodeMatchingSelectorList(tNode, slotValue, /* isProjectionMode */ true)
      : isSelectorInSelectorList(ngProjectAsAttrVal, slotValue)
  ) {
    return i; // 第一个匹配的选择器"捕获"该节点
  }
}
return wildcardNgContentIndex;

可以从中读出三条文档层面的规则在源码中的对应实现:

  • select 的槽按模板顺序依次尝试,第一个匹配的选择器立即捕获该节点("first matching selector captures");
  • 不带 select 的槽在内部表示为通配符 *,只在不命中任何具名选择器时作为最后归属;
  • 节点上若存在 ngProjectAs,则绕过节点自身标识,改用该属性值去和选择器列表比对(后文详述)。

分好槽之后,ɵɵprojectionDef 指令把组件宿主 TNode 的所有子节点按槽位"分桶"成链表projectionHeads / tails,每个槽一条 projectionNext 单链表),这一步只在组件视图首次初始化时执行一次,为后续插入动作准备好"待投送队列"。

三、Fallback 内容:槽位为空时的默认渲染

当组件的某个 <ng-content> 没有匹配到任何子内容时,Angular 可以展示回退内容(fallback content)——方法就是给 <ng-content> 元素本身写入子内容:

<!-- 组件模板 -->
<div class="card-shadow">
  <ng-content select="card-title">Default Title</ng-content>
  <div class="card-divider"></div>
  <ng-content select="card-body">Default Body</ng-content>
</div>
<!-- 使用组件:只提供了标题 -->
<custom-card>
  <card-title>Hello</card-title>
  <!-- 未提供 card-body -->
</custom-card>
<!-- 渲染后的 DOM -->
<custom-card>
  <div class="card-shadow">
    <card-title>Hello</card-title>
    <div class="card-divider"></div>
    Default Body
  </div>
</custom-card>

源码视角:fallback 是一个"按需渲染的内嵌视图"

fallback 机制在 ɵɵprojection 指令中实现,几个关键细节值得注意:

  1. fallback 模板必须无条件声明。无论当前实例的槽位是否为空,fallback 模板都会被声明,因为同一组件的不同实例可能一个需要、一个不需要。源码注释还特别指出:声明必须发生在投送节点之前,"以便与 hydration(SSR 水合)正确协作";
  2. 判断槽位是否为空:读取宿主 TNode 上 projection 数组中对应槽位的头节点,为 null 即视为空槽(componentHostNode.projection![tProjectionNode.projection] === null);
  3. 空槽 + 有 fallback 时,走 insertFallbackContent:创建一个内嵌视图(createAndRenderEmbeddedLView)并挂入容器——也就是说 fallback 内容是一等公民的视图,拥有自己的声明上下文;
  4. 非空槽位则调用 applyProjection 把分好桶的节点真正插入 DOM。

另外注释里有一句很直白的设计说明:"<ng-content> 本身没有内容,即使存在 fallback 内容,fallback 也是渲染在它旁边(而不是"里面")"。

四、ngProjectAs:为投送内容改别名

有时使用者传入的元素本身不符合占位符选择器,但仍希望它落到某个特定槽位。Angular 提供了特殊属性 ngProjectAs:任何带有该属性的元素,在与 <ng-content> 占位符比对时,Angular 会用 ngProjectAs 的值替代元素自身的标识参与匹配:

<!-- 组件模板 -->
<div class="card-shadow">
  <ng-content select="card-title" />
  <div class="card-divider"></div>
  <ng-content />
</div>
<!-- 使用组件:h3 被"冒充"成 card-title -->
<custom-card>
  <h3 ngProjectAs="card-title">Hello</h3>

  <p>Welcome to the example</p>
</custom-card>
<!-- 渲染后的 DOM:h3 出现在标题槽位,分隔线之前 -->
<custom-card>
  <div class="card-shadow">
    <h3>Hello</h3>
    <div class="card-divider"></div>
    <p>Welcome to the example</p>
  </div>
</custom-card>

两条使用约束:

  • ngProjectAs 只支持静态值,不能绑定动态表达式;
  • 它的值本身是一个 CSS 选择器,会经过选择器解析(parseSelectorToR3Selector)后与 select 的选择器列表做结构相等比对,而非字符串比较——这一行为体现在 getProjectAsAttrValueisSelectorInSelectorList 的配合中:前者从节点属性里按 AttributeMarker.ProjectAs 标记取出解析后的选择器,后者逐段比对两个解析后选择器数组的长度与内容。

值得注意的是属性解析的严谨性:getProjectAsAttrValue 会校验索引的奇偶性(ngProjectAsAttrIdx & 1 === 0),确保只匹配属性名而不会误中恰好同名其值的属性值。

五、投送 DOM 是如何"归位"的:运行时调用链

把前面的源码证据串起来,一次内容投送的完整生命周期是:

  1. 构建期generate_projection_def.ts):收集全部 <ng-content> 选择器、分配槽位索引,生成 projectionDefngContentSelectors
  2. 组件视图初始化ɵɵprojectionDef):遍历宿主 TNode 子树,用 matchingProjectionSlotIndex 把每个可投送节点分桶到对应槽位的单链表;
  3. 到达每个 <ng-content> 节点时ɵɵprojection):空槽且有 fallback 则渲染 fallback 内嵌视图,否则调用 applyProjection
  4. DOM 插入applyProjectionapplyProjectionRecursive):递归遍历分好桶的 TNode 树,按投送节点在模板中的位置计算插入锚点,通过 renderer 逐个把节点挂入宿主元素。源码注释点明了一个容易被忽略的细节——待投送节点本身可能又是从更上层组件"再投送"过来的,所以递归处理时必须沿 DECLARATION_COMPONENT_VIEW 一路追溯到真正声明这些节点的那层视图。

相关行为可通过仓库中的测试文件继续验证:内容投送的验收测试在 content_spec.ts,linker 场景下的投送集成测试在 projection_integration_spec.ts

六、注意事项(Caveats)

投送内容归属于父级视图

即使投送内容渲染在接受方组件内部,它仍归属于声明它的那个父组件。Angular 把它记账在父级视图下,这带来两个值得牢记的副作用:

变更检测:投送内容在父组件执行变更检测时被检查。即便接受方组件使用了 OnPush 策略跳过自身模板的检查,也跳不过投送内容——因为它属于父级:

<!-- 父模板(默认变更检测策略) -->
<onpush-wrapper>
  <!-- 每次父级周期都会被检查,OnPush 在此不起作用 -->
  <expensive-component />
</onpush-wrapper>

依赖注入:投送内容从父级的 injector 获取依赖,而不是来自接受方组件的 viewProviders。这意味着你在容器组件上声明的 viewProviders 对投送进来的子树是"不可见"的。

部分库组件不支持投送子元素

某些组件——菜单、Tabs、列表——依赖 ContentChildren 查找子项并挂接键盘导航、焦点管理、ARIA 属性等行为。这类组件的代码是"假定自己直接拥有子项"而编写的,把外部投送的内容塞进去往往会以微妙的方式破坏功能。文档举的例子是:用额外一层包裹 <mat-menu-item> 再投送进 <mat-menu>,键盘导航与读屏支持可能被静默破坏——查询仍然能找到这些 item,但当 item 来自不同视图上下文时,让它们可交互的内部装配逻辑可能不再正确工作。

经验法则是:如果一个库组件负责管理其子元素的行为,在动手投送前先查它的文档,投送很可能并不被支持。

七、小结

  • <ng-content> 是构建期被编译器消费的投送占位符,不是组件也不是 DOM 元素;不可条件包裹、不可附加指令与样式;
  • 多占位符靠 select 的 CSS 选择器分流,匹配"先到先得";不带 select 的占位符是兜底槽,缺少它时未匹配内容会被丢弃;
  • <ng-content> 写子内容即定义 fallback,槽位为空时渲染为一等内嵌视图;
  • ngProjectAs 允许静态选择器别名,改变元素参与匹配时的身份;
  • 投送内容在变更检测与 DI 上始终归属父级视图,使用 OnPushviewProviders 时不要误以为能覆盖投送子树;
  • 对"管理型"库组件(菜单/Tab/列表)慎用投送,先确认其子项装配机制是否兼容外部视图上下文。

以上机制均可在当前仓库中复核:编译器侧的投送定义生成见 generate_projection_def.ts,运行侧的槽位匹配、分桶与插入见 projection.tsnode_manipulation.ts,选择器匹配与 ngProjectAs 解析见 node_selector_matcher.ts

登录后查看全文
热门项目推荐
相关项目推荐