前言

Angular 是 MVVM 框架。

MVVM 的宗旨是 "不要直接操作 DOM"。

为了这个 "不要直接操作 DOM",我们已经讲了 2 篇文章了。

第一篇是 Template Binding Syntax

第二篇是 Query Elements

但是!依然有一些 DOM Manipulation 是它们没有覆盖到的。

比如说

  1. Create & Append Element

    e.g. document.createElement & document.append

  2. Clone Template & Append

    e.g. template.content.clone & document.append

这篇,我们就来朴上这些 DOM Manipulation 替代方案,看看在 Angular 要如何动态创建与输出内容。

App 组件

所谓的 Dynamic Component 就是 document.createElement,只是它创建的是组件。

Dynamic create App 组件

我们对 Dynamic Component 其实一点都不陌生,在 NodeInjector 文章中,我们逛 bootstrapApplication 源码时就见证了一个被动态创建的组件 -- AppComponent。

没错,App 组件就是通过 Dynamic Component 手法创建的,我们回顾一下源码片段

这里有几个点:

  1. 把 class AppComponent 变成 ComponentFactory

  2. 需要 ngModule

  3. 需要一个 selector,而且这个 selector 必须可以 select 出一个 element。

上半段是 create mode,下半段是 update mode。

这里也有几个点:

  1. 把 hostView 放入 ApplicationRef

  2. appRef.tick 会执行 hostView.detectChanges 方法

Dynamic create App2 组件

上面创建 App 组件所使用到的函数,对象,方法等等,Angular 都有直接或间接 (经过包装) 公开给我们用,所以我们可以用相同的方式去创建组件。

这里,我们就来试试创建一个 App2 组件,它是一个模拟 App 的组件。

首先,创建 App2

ng g c app2

把 selector 换成 app-root2

index.html 添加 <app-root2>

接着在 app.component.httml 添加一个 button

我们的实现代码就在 app.component.ts 中

最终效果是这样

实现代码是这样

export class AppComponent {
injector = inject(Injector); async createApp2() {
const { App2Component } = await import('../app2/app2.component'); const ngModuleRef = this.injector.get(NgModuleRef);
const renderer = this.injector.get(Renderer2);
const appRef = this.injector.get(ApplicationRef); const app2ComponentRef = createComponent(App2Component, {
environmentInjector: ngModuleRef.injector,
hostElement: renderer.selectRootElement('app-root2'),
elementInjector: Injector.NULL,
}); appRef.attachView(app2ComponentRef.hostView);
}
}

我们一句一句来解释

首先,创建过程中需要依赖一些 Service,那由于实现代码不是在 constructor (injection context) 内,所以我们不能直接使用 inject 函数,得提前把 Injector 存起来。

接着是 lazy load 组件

我们不需要一开始就 import App2 组件,在点击 button 之后才去 import 是没有问题的,这样 Angular 在打包的时候也会把 App2 组件的资料分开到另一个文件里,这样 main.js 也会比较小哦。

接着是一些依赖的 Service

接着是 createComponent 函数

createComponent 函数是 Angular 包装过的 ComponentFactory,上面这段代码和下面是等价的。

createComponent 函数的源码在 component.ts

虽然它没有用 resolver 来创建 Factory,但最终依然是使用 ComponentFactory.create 方法来创建组件。

有几个点需要知道:

  1. factory.create 参数一 elementInjector 被用于 ChainedInjector,也就是当 NodeInjector 找不到之后会往上到 ChainedInjector 找,

    上面虽然放的是 NullInjector 但不要紧,因为即便 ChainedInjector 找不到它还会继续往上到参数四 enviromentInjector 找。

  2. factory.create 参数三可以放 selector or node,如果放 selector 那内部会使用 renderer.selectRootElement 获取到 node,

    Angular 包装的 createComponent 不支持参数用 selector,我们只能自己在外部用 renderer.selectRootElement 获取到 node 再传进来。

    另外,如果完全不提供 selector 和 node,内部会用组件 Definition 的 selector 创建一个 node。这个 node 会存放在最终返回的组件 Ref,

    外部需要自己做 DOM Manipulation 把它 append 到 DOM 里。

  3. factory.create 参数四,上面放的是 ngModuleRef.injector,其实它就是 Root Injector 来的。

    还记得这张图吗?

createComponent 之后,App2 Root TView、App2 Root LView、App2 TNode、App2 TView、App2 LView、App2 实例都创建了,App2 template 方法 create mode 也执行了。

接着是要执行 App2 template 方法 by update mode。

把 App2 hostView attach 进 ApplicationRef 就可以了。

attchView 方法里面最终会执行 appRef.tick,所以它会跑 update mode,具体细节我们下面再细说。

再看一篇效果

三棵树

仅仅通过 App 组件去理解 Dynamic Component 是不全面的,毕竟 App 组件太独特了,它是第一个生成的组件,同时它也是最顶层的组件。

要想深入理解 Dynamic Component 最简单的方法自然是逛源码咯

幸好,经过 Change DetectionNodeInjectorLifecycle HooksQuery Elements 的源码洗礼,

我们对 Angular 渲染引擎源码已经相当熟悉了,那就让我们从渲染机制上去理解 Dynamic Component 吧。

渲染机制的三棵树

我们知道 Angular 渲染机制中有三颗大树,Injector Tree、Logical View Tree 还有 DOM Tree。

Injector Tree 负责依赖注入

Logical View Tree 负责 Change Detection

DOM Tree 负责最终呈现

这三棵树大部分情况下结构是差不多的,只有两种情况下可能差别会大一些。

第一种是 Content Projection,第二种则是本篇的主角 Dynamic Component。

App 组件 の 三棵树

下图是 App 组件的三颗树

在动态创建组件时,我们需要为组件选择它在三棵树上各自的位置。

三棵树都要选,位置不需要一致,依据各自需求做选择即可。

App 组件对三棵树节点的选择

App 组件没遇到的难题

App 组件把 LView 放到了 ApplicationRef 里头,也就是 Logical View Tree 的最顶部。

另外呢,它的 DOM Tree 选择了 body > app-root2,这也是一个最顶层的节点。

所以我说 App 组件太独特了,其它 Dynamic Component 怎么可能放到顶部呢?它们是要插入到中底部节点的丫。

于是,问题来了

  1. 怎么样把一个 hostView 插入到某一层的 Logical View Tree 里。

  2. 怎么样 select 一个 node,或者 append 一个 node 到某一层的 DOM Tree 里。

日常与需求

我们透过一个新例子,来看看正真日常的 Dynamic Component 长啥样,以及如何解决上述问题。

Static SayHi 组件

创建一个 SayHi 组件

ng g c say-hi

添加 @Input 和依赖注入

SayHi 组件必须可以 inject 到 App 组件和 providedIn: 'root' 的 ServiceA。

这用来验证 SayHi 组件在 Injector Tree 里的位置是否正确。

SayHi Template

在 App Template 使用 SayHi 组件,并且可以通过 button 换名字。

AppComponent

我关闭了 Zone.js,所以这里需要手动执行 detectChanges 方法,由此也可以验证 SayHi 组件在 Logical View Tree 里的位置是否正确。

效果

Dynamic SayHi 组件需求

现在我们要把 SayHi 换成 Dynamic Component。

点击 button 后 SayHi 组件必须 append 到注释的位置。并且 changeName 正常工作。

效果

Injector

要动态创建 SayHi 组件,第一步是选择正确的 Injector。

在动态创建组件时,可以提供 2 个 Injector,一个是 enviromentInjector,一个是 elementInjector。

environmentInjector 是 required,而且类型必须是 EnvironmentInjector (R3Injector 是 EnvironmentInjector 的派生,NodeInjector 则不是哦)

这个 environmentInjector 通常我们放 Root Injector 或者 App Standalone Injector 就可以了。

提醒:EnvironmentInjector 我们在 NodeInjector 文章中有教过的,如果大家忘记了可以回去复习一下。

createComponent 内部依赖一些 providers,所以它会用 environmentInjector 去 inject

比如

  1. StandaloneService

  2. RendererFactory2

  3. Sanitizer

  4. AfterRenderEventManager

  5. EnvironmentInjector

所有,严格来讲,只要 Injector 属于 EnvironmentInjector 类型,并且里面有提供 createComponent 所需要的依赖,那就 ok 了。

elementInjector 则不是 required 的,如果没有放,它默认是 NullInjector,类型只要是 Injector 就可以了 (R3Injector 或 NodeInjector 都可以)。

为了让 SayHi 组件能 inject 到 App 组件,我们可以把 elementInjector 设置成 App NodeInjector。

这样从 SayHi NodeInjector 开始找,找不到就去到 Chained Injector 链接上 appNodeInjector 就找到了。

代码长这样

如果我们想要扩展 environmentInjector,可以通过 createEnvironmentInjector 函数创建一个扩展 Injector。

elementInjector 也可以扩展,而且它不需要是 EnvironmentInjector 类型,所以用 Inject.create 创建一个 R3Injector 就可以了。

Injector Tree 的查找路线

下图是当前的 Injector Tree

蓝线是第一条查找路线,红线是第二条查找路线

第一条找不到,就去找第二条。

比较特别的地方是 Stop Here,ChainedInjector 在查找的时候会 skip 掉嵌套的 parent Injector。

class ChainedInjector 源码在 component_ref.ts

ComponentRef

createComponent 返回的是 ComponentRef (Ref 是 reference 的意思,简单说就是一个对象里面包裹了相关的信息)

Angular 有很多很多的 Ref,比较有名的:ApplicationRef、NgModuleRef、ChangeDetectorRef、ViewRef、ComponentRef、ElementRef、VIewContainerRef、TemplateRef 等等等。

到这个阶段,SayHi 组件已经完成了 create mode。

TNode、RNode、TView、LView、NodeInjector 资料都有了,template 函数也执行了 create mode,后裔组件也都通通完成了 create mode。

SayHi LView 的 parent 是 SayHi Host LView,Host LView 还没有被插入 Logical View Tree。

SayHi RNode 目前还没有被插入到 DOM Tree。

组件还没有经过 update mode,binding 资料还没有 update,Lifecycle Hooks 全部还没有跑。

要搞定接下来的事儿,我们需要通过 SayHi ComponentRef,我们先来了解这个对象有什么属性和方法。

  1. SayHi 组件实例

  2. set SayHi 组件 @Input

    通过 setInput 方法和直接给实例属性赋值是有区别的哦。

    setInput 会走 transform,实例属性赋值不会。

    但是 setInput 没有类型提示,代码维护扣分。

    相关 Github Issue – Type safe Input setting on components created with createComponent

  3. SayHi NodeInjector

    通过 NodeInjector,我们就可以拿到许多东西了,比如 ElementRef、ChangeDetectorRef 等等

  4. SayHi Host ElementRef

    等同于

  5. SayHi Host LView

    hostView 我们在 NodeInjector 文章中顺便教过。

  6. SayHi LView

    SayHi Host LView 是 SayHi LView 的 parent。只有动态创建的组件有 Host LView 哦。

  7. onDestroy 和 destroy

    onDestroy 是监听组件被摧毁,destroy 是摧毁组件。

    关于摧毁组件,下面会详细教,这里带过先。

通过 ComponentRef,我们就可以操控组件完成接下来的步骤了,比如说

当然,这些只是示范而已,正确的做法是要把 SayHi 组件插入 Logical View Tree,而且插入 DOM Tree 的方式也不应该是直接 DOM Manipulation。

<ng-container /> & ViewContainerRef

Injector Tree 搞定后,接下来是 Logical View Tree 和 DOM Tree 的选择。

依据需求,我们必须把 SayHi Host LView 放到 App LView 里,

然后把 DOM node append 到上图注释的位置。

<ng-container />

首先我们需要做一个卡位,把上面的注释换成 <ng-container />

<ng-container /> 是 Angular 的特别 element

compile 之后长这样

ɵɵelementContainer 和之前我们研究过的 ɵɵelement 大同小异,这里源码我们就不看了 (以为我又要逛源码吗?你不累啊)。

结论是

  1. 它最终是一个 Comment 节点

  2. App LView

  3. App TView.data

    TNode.type 是 8 号

    1 是 Text,2 是 Element,8 是 ElementContainer

Template Variables on <ng-container />

<ng-container /> compile 后就真的只是一个单纯的 Comment 节点。

我们通过 Template Variables 展现一下它。

效果

App LView

即便我们通过 @ViewChild 去拿,它依然是一个 Comment 节点。

效果

ViewContainerRef

为什么我一直强调它只是普通的 Comment 节点呢?

因为在 @ViewChild read: ViewContainerRef 的情况下就不是这样了!

不要小看这个 read: ViewContainerRef,它背地里干了不少事。

Query Elements 文章中,我们翻过 ɵɵqueryRefresh 函数源码。

ɵɵqueryRefresh 函数内执行了 materializeViewResults 函数,

materializeViewResults 函数内又执行了 createResultForNode 函数,相关源码在 query.ts

createSpecialToken 函数

createContainerRef 函数

LContainer 和 LView 其实挺像的,我们来认识它一下

class LContainer 源码在 container.ts

我讲一些比较有名的:

例子说明

App Template

App DOM

App LView

添加 read: ViewContainerRef

之后的 App DOM

之后的 App LView

原本的 HTMLDivElement、C1 LView、Comment 被存放到各自的 LContainer[HOST 0]。

Append to Logical View & DOM Tree

我想或许你已经猜到了,ViewContainerRef 就是让我们把 Dynamic Component 插入 Logical View Tree 的工具。

App Template

App Query ViewContainerRef

App LView

插入 SayHi Host LView 到 LContainer

通过 ViewContainerRef.insert 方法就可以把 SayHi Host LView 插入到 LContainer 里。

insert 方法的源码在 view_container_ref.ts

addLViewToLContainer 函数的源码在 view_manipulation.ts

插入 LContainer 的位置

我刻意插入了 3 个 LView,这样比较明显。

LContainer[VIEW_REFS 8] 是一个 Array,里面装了每一个插入的 ViewRef (也就是 sayHiComponentRef.hostView)。

从 index 10 开始就是每一个插入的 LView,也就是 SayHi Host LView。

插入 DOM 的位置

ViewContainerRef.insert 时会把 node 插入到这个 LContainer[NATIVE 7] 也就是 Comment 节点的上方。

注意:如果我们用 element 作为 container 会是这样的

不是在 <div> 里面哦!别搞错。

Query ViewContainerRef 会在 #container element 的 nextSibling 插入一个 <!--container--> 并把此 Comment 节点存放到 LContainer[NATIVE 7]。

ViewContainerRef.insert 时会把 SayHi RNode 插入到这个 LContainer[NATIVE 7] (也就是 Comment 节点) 的上方。

detectChanges

虽然已经插入 Logical View Tree 和 DOM Tree,但是 update mode 并不会立刻被执行,假如我们什么也不做,在 v18 版本之前,它甚至都不会自动执行 update mode (关闭 Zone.js 的情况下)。

我们在 Change Detection 文章介绍过 Angular v18 推出的 Zoneless ChangeDetection 概念。

它会在许多地方加上 setTimeout + tick 来替代原本 Zone.js 的工作。

其中一个地方便是 ViewContainerRef.insert。

ViewContainerRef.insert 会自动 setTimeout + tick,所以在 v18 以后,我们插入后什么也不需要做,它最终会自动执行 update mode。

ViewContainerRef.insert 的源码在 view_container_ref.ts

addLViewToLContainer 函数的源码在 view_manipulation.ts

insertView 的源码在 node_manipulation.ts

updateAncestorTraversalFlagsOnAttach 的源码在 view_utils.ts

markAncestorsForTraversal 函数

markAncestorsForTraversal 等价于 markForCheck + tick 的效果 (注:细节上有区别,但目前我们不用关心,把它理解为 markForCheck + tick 就可以了,以后有机会再细讲)

最终代码

AppComponent

export class AppComponent {
injector = inject(Injector); @ViewChild('container', { read: ViewContainerRef })
viewContainerRef!: ViewContainerRef; private sayHiComponentRef!: ComponentRef<SayHiComponent>; async appendSayHiComponent() {
const { SayHiComponent } = await import('./say-hi/say-hi.component'); const appNodeInjector = this.injector;
const appStandaloneInjector = this.injector.get(EnvironmentInjector); this.sayHiComponentRef = createComponent(SayHiComponent, {
environmentInjector: appStandaloneInjector,
elementInjector: appNodeInjector,
});
this.sayHiComponentRef.setInput('name', 'Derrick');this.viewContainerRef.insert(this.sayHiComponentRef.hostView);
} changeName() {
this.sayHiComponentRef.setInput('name', 'New Name');
}
}

App Template

<p>app works!</p>
<ng-container #container />
<button (click)="changeName()">change name</button>
<button (click)="appendSayHiComponent()">Append SayHi Component</button>

小心坑 の Zoneless ChangeDetection vs 手动 detectChange / tick

正所谓 The devil is in the detail (魔鬼存在于细节之中),Zoneless ChangeDetection 和手动 detectChanges / tick 还是有微微区别的。

我们来看一个例子感受一下这个细节,这对深入理解 Angular 的坑非常有帮助。

App Template

<ng-template #template>
@let names = ['Derrick', 'Alex', 'David']; @for (name of names; track name) {
<p style="height: 128px;">{{ name }}</p>
}
</ng-template> <ng-container #container /> <button (click)="append()">append</button>

操作是这样的,点击 append button 以后,拿 ng-template 创建 embeded view,然后插入到 <ng-container /> (ViewContainerRef)。

它会 append 3 个 <p>,每一个高度是 128px,我是特意放这么高的,为了呈现 scrollable 的状况。

App 组件

export class AppComponent {

  private readonly templateRef = viewChild.required('template', { read: TemplateRef });
private readonly viewContainerRef = viewChild.required('container', { read: ViewContainerRef }); append() {
this.viewContainerRef().length !== 0 && this.viewContainerRef().clear();
this.viewContainerRef().createEmbeddedView(this.templateRef());
}
}

append 会被点击多次,每一次执行会先把之前的 embeded view 删除掉。

好,看效果

这里最诡异的地方是,为什么第二次 append 的时候,scroll top 会跳回最上方,而不是保持在原位?

推测:

第二次点击 append,首先会把之前的 embeded view 删除,删除后 body 就空了,高度也就没了,scrollbar 也跟着没了,scroll top 就变成 0 (在最上方),

接着 insert 新的 embeded view,body 内容回来了,高度回来了,但是 scroll top 不会自动回复,所以依据停留在最上方。

质疑:

嗯,解释的通,但是!有个疑点...

remove embeded view 和 insert embeded view 不是发生在 "同时" 吗?游览器理应会 "同时" 渲染,而不是删除了,渲染一次高度,再插入,再渲染一次。

解答:

没错,所以事实其实是这样的,

remove embeded view 和 insert emebeded view 确实发生在同一个瞬间,但是 insert 的 emebeded view 内有 @for,这个需要 detechChanges 后才会渲染。

我们没有手动 detechChanges,而是让 Zoneless ChangeDetection 负责,而它的具体操作是 setTimeout + tick。

也就是因为这个 setTimeout,所有游览器就先渲染了,它渲染的时候 @for 还没有执行,所以内容是空的,这就导致了没有高度,scroll top 变成 0。

我们改成手动 detectChanges 或 tick

append() {
this.viewContainerRef().length !== 0 && this.viewContainerRef().clear();
const componentRef = this.viewContainerRef().createEmbeddedView(this.templateRef()); // 1. 手动 detectChanges
componentRef.detectChanges(); // 2. 或者 appRef.tick
const appRef = this.injector.get(ApplicationRef);
appRef.tick();
}

效果

由于我们提早执行了 detectChanges / tick (而不是在 setTimeout 之后),所以游览器没有渲染两次,这样它就不会跳去最上面了。

总结:Angular 里有许多类似的坑。如果不想掉坑里,一定要按照 Best Practice 来使用 Angular,或者深入了解它。

Destroy Component

当我们确定一个组件不会再被使用后,我们就可以把它摧毁掉。

两个点:

  1. 组件内的后裔组件全部也会跟着被摧毁掉。

  2. destroy 会让 LView 脱离 Logical View Tree 和 RNode 脱离 DOM Tree。

组件可以通过 DestroyHooks 监听 destroy 事件,然后做出相应的资源释放,比如说 clearInterval。

要摧毁组件可以使用 ComponentRef.destroy 方法,或者 ViewRef.destroy 方法。

它两没有区别,ComponentRef.destroy 内部就只是调用了 hostView.destroy 而已。

DestroyHooks

有三种方法可以 regsiter DestroyHooks。

  1. ComponentRef.onDestroy 方法

    它两没有区别,ComponentRef.onDestroy 内部就只是调用了 hostView.onDestroy 而已。

  2. ngOnDestroy

  3. DestroyRef

    DestroyRef 比较新,它是 Angular v16 才推出的,它通常是配合 RxJS 使用的

    constructor() {
    const destroyRef = inject(DestroyRef);
    interval(1000)
    .pipe(takeUntilDestroyed(destroyRef))
    .subscribe(v => console.log(v));
    }

    takeUntilDestroyed 是 Angular built-in 的 RxJS Operators 它的源码在 take_until_destroyed.ts

三种方法大同小异,用哪个都可以。

DestroyHooks 源码逛一逛

我们先看 register DestroyHooks 的源码

  1. ComponentRef.onDestroy 方法

    源码在 component_ref.ts

    hostView.onDestroy 方法的源码在 view_ref.ts

    storeLViewOnDestroy 函数的源码在 view_utils.ts

  2. ngOnDestroy

    ngOnDestroy 属于 PostOrderHooks。

    它、ContentHooks 和 ViewHooks 都是在 registerPostOrderHooks 函数内一起 register 的。

    源码在 Lifecycle Hooks 文章中研究过了,这里不再复述。

    callback 被存入 TView.destroyHooks Array 里。

  3. DestroyRef

    源码在 destroy_ref.ts

    它是一个特别的 Provider。

    injectDestroyRef 函数

    class NodeInjectorDestroyRef

  4. DestroyRef in Root Injector

    还有一个冷知识,NodeInjector 注入的 DestroyRef 是上面说的那样,但是 Root Injector 注入的 DestroyRef 则是另外一个。

    export class AppComponent {
    constructor() {
    const nodeDestroyRef = inject(DestroyRef); // NodeInjector 注入的 DestroyRef const rootInjector = inject(EnvironmentInjector); // 注入 Root Injector const rootDestroyRef = rootInjector.get(DestroyRef); // Root Injector 的 DestroyRef console.log(nodeDestroyRef === rootDestroyRef); // false,不一样
    console.log(rootDestroyRef === (rootInjector as any)); // true,竟然是同一个
    console.log('nodeDestroyRef', nodeDestroyRef);
    console.log('rootDestroyRef', rootDestroyRef);
    }
    }

    效果

    Root Injector 就是 DestroyRef 本尊。

    在 R3Injector.get 注入时,第一步不是查找 records,而是先检查 token 有没有 NG_ENV_ID 方法,如果有就调用它,并且把 Injector 实例放进去。

    而 DestroyRef 这个 token 就有这个 NG_ENV_ID 方法

    这个方法接收 Injector 实例,并且返回同一个 Injector 实现。

    所以 Root Injector inject DestroyRef 最终得到的是 Root Injector。

    至于 Root Injector 什么时候有可能被 destroy 掉呢?这我就不太清楚了。

再看 hostView.destroy 方法的源码 view_ref.ts

这里有一个重点 -- DestroyHooks 触发之前,LView 就已经脱离 Logical View Tree 了,同时 RNode 也脱离 DOM Tree 了。

destroyLView 函数源码在 node_manipulation.ts

destroyViewTree 函数

cleanUpView 函数

这里就把所有 DestroyHooks 执行完了。

记得释放资源

我们要牢牢记得 -- 组件是动态的。

它随时可能会被销毁,所以在开发组件时,如果组件内有引用全局资源,那在组件销毁前,我们一定要记得释放它们。

比如常见的 setTimeout,setInterval,DOM event listener,Ajax request 等等。

如果我们使用 Template Binding Syntax 去 add event listener 的话,那我们不需要自己 remove event listener,Angular 会替我们 remove。

相关源码在 listener.ts

另外,通常 Ajax request 会在组件销毁前就回来了,所以我们一般没有在意它,但要记得,如果真的发生这种情况,而且会影响业务逻辑,那我们要在组件销毁的时候去 abort Ajax request。

提醒:Angular 的 HttpClient (以后会教) 并不会在组件销毁时替我们 abort Ajax request。

ViewContainerRef 其它功能

ViewContainerRef 除了可以 insert,还可以做许多操作。

  1. 获取当前 LContainer 的 LView 数量

  2. 选择插入时的 index 位置

    参数二是 index

    默认是 append 效果,把 index 设置成 0 表示每一次插入第 0 位,这就是 prepend 的效果。

  3. Injector

    LContainer 可以是 element、组件、ng-container

    当它是组件的时候,这个 ViewContainerRef.injector 指的就是这个组件的 NodeInjector。

    假如是 element 和 ng-container 那它虽然也是有一个 NodeInjector 对象,但是里面的 TNode.injectorIndex 是 -1。

    一般组件的话,TNode.injectorIndex 会指向它在 TView 的位置,-1 的话会特殊处理。

    lookupTokenUsingNodeInjector 函数的源码在 di.ts

    大致意思就是跳去用祖先的 NodeInjector 啦。

  4. get LView

    它返回的是 ViewRef 哦,Angular 没有接口让我们访问到 LView 的,只能通过 ViewRef 操作 LView。

  5. indexOf LView

    查看 viewRef 的位置。

  6. move LView

    比如从 index 0 移位到 index 3

    效果

    也会更新 DOM 哦。

  7. detech

    把 LView 从 LContainer 里抽出,同时它也把 RNode 从 DOM Tree 抽出。

  8. remove

    remove 和 detech 最大的不同是,

    detech 只是把 LView 抽出 LContainer,

    remove 则是 LView.destroy 摧毁它。

  9. 清空 LViews

    就是 remove all

  10. createComponent

    它是对 createComponent + insert 的封装,目的是为了方便我们。

    下图是 createComponent + ViewContainerRef.insert

    |
    对比下图是 ViewContainerRef.createComponent

    代码少了几行

    那它是如何搞定 environmentInjector 和 elementInjector 的呢?其实很好理解嘛,上面不是提到了吗,ViewContainerRef 本身就有 injector 丫。

    不过它并不是直接把 ViewContainerRef.injector 用作 elementInjector 哦,我们看看源码中的细节。

    contextInjector 最终会被用作 elementInjector。

    这个 contextInjector 来自 createComponent 方法的参数 options.injector,如果我们没有传入,那它会用 ViewInjectorRef.parentInjector

    这个 parentInjector 和上面提到的 ViewInjectorRef.injector 在一些情况下是会有区别的。

    如果 ViewContainerRef 是组件 (比如 C1) 那 ViewContainerRef.injector 是 C1 NodeInjector,如果是 element 或者 <ng-container> 那 ViewContainerRef.injector 是 parent NodeInjector。

    而 ViewContainerRef.parentInjector 一定就是 parent NodeInjector。哪怕 ViewContainerRef 是组件。

    结论:如果 ViewContainerRef 是 div 或 ng-container 那 injector 和 parentInjector 是一样的,如果 ViewContainerRef 是 C1 组件,那 injector 和 parentInjector 是不一样的。

    好,回到来,记得哦,createComponent 用的是 ViewContainerRef.parentInjector 而不是 ViewContainerRef.injector,不要搞错了。

    至于 environmentInjector 则是透过 contextInjector 去 inject

    以上就是 ViewContainerRef.createComponent 时如何搞定 environmentInjector 和 elementInjector 的细节。

@Output

上面为了验证 Logical View Tree 是否正确,@Input 已经提前教了。

这里我们继续 @Output。

在 SayHi 组件添加一个 @Output 事件

在 App 组件

直接访问 SayHi 组件实例即可。

@Input 之所以需要用 ComponentRef.setInput 是因为要兼顾 @Input 的 transform。

Content Projection

组件除了 @Input、@Output,还有一个重要的功能是 Content Projection。

App Template

SayHi Template

效果

Dynamic Component 对 Content Projection 的支持很薄弱,接口体验不好,甚至有些功能是缺失的。

在 createComponet 时可以传入一个 projectableNodes 参数

这个 projectableNodes 参数有两个大问题:

  1. 它的类型是 DOM Node 节点,这意味着要由外部负责渲染,里面只是单纯引用 DOM 而已。

    同时这样直接导致了 SayHi 的 Content Query (@ContentChildren) 失效。

  2. 它的类型是 Array Array,每一个 Array 对应里面的一个 <ng-content>,

    它没有了 CSS Selector 匹配的概念,它是按 projectableNodes Array 的顺序放入 <ng-content> 的。

DOM Manipulation 和 reflectComponentType

通过 DOM Manipulation 和 reflectComponentType 函数,我们可以勉强使用 projectableNodes。

首先,定义 <template>

接着 query template

然后 clone template and reflectComponentType

我们通过 DOM Manipulation 搞定了第一个问题 -- 外部渲染。

再通过 reflectComponentType 函数拿到 SayHi 的 ng-content 顺序和 selector,解决了第二个问题 -- no more CSS Selector 匹配。

注:reflectComponentType 只能用于组件,不能用于指令或 Pipe。

总结

Dynamic Component 对 Content Projection 的支持相当弱,它完全没有维护 Content 与 Host 组件的关系,Host 组件 Content Query 失效,Content 组件 inject Host 也失效。

它只是单纯的把 Node 放进去而已,而且只有在创建的时候可以传入,过了就没机会了,也不能改。

Dynamic Directive

下图是通过 App Template 在 SayHi 组件上 apply Dir1 指令。

那有办法可以动态添加指令到 SayHi 吗?

点击按钮后,添加 Dir1 指令到 SayHi 组件上。

很遗憾,完全没有办法做到。我们只能动态创建组件,不能动态创建指令。

那假如 SayHi 是 Dynamic Component,可否在动态创建组件的同时一起动态创建指令呢?

很遗憾,Angular 是不支持的,但是!有一些 hacking 的手法。

Dynamic Directive composition API

Dir1 指令

export class Dir1Directive implements OnInit {
ngOnInit(): void {
console.log('Dir1 init', [this.name, this.age]);
} @Input({ alias: 'appDir1Name' })
name!: string; @Input({ alias: 'appDir1Age', transform: numberAttribute })
age!: number;
}

SayHi 组件

@Component({
selector: 'app-say-hi',
standalone: true,
imports: [],
templateUrl: './say-hi.component.html',
styleUrl: './say-hi.component.scss',
hostDirectives: [
{
directive: Dir1Directive,
inputs: ['appDir1Name:name', 'appDir1Age:age'],
},
],
})
export class SayHiComponent {
@Input()
name!: string; @Input({ transform: numberAttribute })
age!: number;
}

我们使用了 Directive composition API

接着,动态创建 SayHi 组件

export class AppComponent {
private injector = inject(Injector); @ViewChild('container', { read: ViewContainerRef })
viewContainerRef!: ViewContainerRef; async appendSayHiComponent() {
const { SayHiComponent } = await import('./say-hi/say-hi.component'); const appNodeInjector = this.injector;
const appStandaloneInjector = this.injector.get(EnvironmentInjector); const sayHiComponentRef = createComponent(SayHiComponent, {
environmentInjector: appStandaloneInjector,
elementInjector: appNodeInjector,
}); this.viewContainerRef.insert(sayHiComponentRef.hostView); sayHiComponentRef.setInput('name', 'Derrick');
sayHiComponentRef.setInput('age', 5);
sayHiComponentRef.changeDetectorRef.detectChanges();
}
}

效果

效果没有问题,所以 Dynamic Component + Directive composition API 是 ok 的。

如果我们有办法把 Directive composition API 弄成 Dynamic 就可以了。

Angular 没有提供一个 right way,但是我们翻源码可以看出 Directive composition API 的实现思路。

SayHi Definition

ɵɵHostDirectivesFeature 函数的源码在 host_directives_feature.ts

于是我们可以这样写

效果是一样的。

不过,这种黑魔法不顺风水,不建议使用。

Query Elements

问:@ViewChildren 能 query 到 Dynamic Component 吗?

答:不行

我们在 Query Elements 文章中,研究过它的原理。

Query 是在 renderView 的时候在 create TNode 时做 matching 的。

在 Dynamic Component 的过程中,上面这个环节根本不存在。

App 在 renderView 的时候,没有任何 SayHi TNode 被创建,所以 matching 不到。

SayHi TNode 被创建的时候,它的 TView 是 SayHi Host TView,也跟 App 无关。

结论就是,八竿子打不着,所以 query 不到。

总结

组件是从 HTML、CSS、TypeScript 经过 compile 变成 JavaScript,然后通过 main.js 启动,最终变成 DOM 的。

所谓的 Dynamic Component 并不是指我们可以用 HTML、CSS、TypeScript 去动态制造一个组件。

它更多的是让我们有能力动态输出一个组件而已,所以对于它做不到的事情 (比如:Content Projection、Dynamic Directive 等等),我们要释怀。

早期 Angular 是支持 runtime compile 的,那时我们真的可以用 string 写 HTML、CSS、TypeScript 然后在游览器执行 compile,最后输出 DOM。

后来 Angular 把 render engine 换成了 Ivy 就不行了。

相关 Issue:

RFC: Exploration of use-cases for Angular JIT compilation mode

Imperative View & Template Composition APIs

目录

上一篇 Angular 18+ 高级教程 – Component 组件 の Query Elements

下一篇 Angular 18+ 高级教程 – Signals

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐,若发现教程内容以新版脱节请评论通知我。happy coding

Angular 18+ 高级教程 – Component 组件 の Dynamic Component 动态组件的更多相关文章

  1. 046——VUE中组件之使用动态组件灵活设置页面布局

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...

  2. vue19 组建 Vue.extend component、组件模版、动态组件

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...

  3. Vue.js 2使用中的难点举例--子组件,slot, 动态组件,事件监听

    一例打尽..:) <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> < ...

  4. 46.VUE学习之--组件之使用动态组件灵活设置页面布局

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...

  5. angular2 学习笔记 ( Dynamic Component 动态组件)

    更新 2018-02-07 详细讲一下 TemplateRef 和 ViewContainerRef 的插入 refer : https://segmentfault.com/a/1190000008 ...

  6. Angular 动态组件

    Angular 动态组件 实现步骤 Directive HostComponent 动态组件 AdService 配置AppModule 需要了解的概念 Directive 我们需要一个Directi ...

  7. Angular 学习笔记 (动态组件 & Material Overlay & Dialog 分析)

    更新: 2019-11-24  dialog vs router link refer : https://stackoverflow.com/questions/51821766/angular-m ...

  8. VUE3 之 动态组件 - 这个系列的教程通俗易懂,适合新手

    1. 概述 暗示效应告诉我们: 巧妙的暗示会在不知不觉中剥夺我们的判断力,对我们的思维形成一定的影响,造成我们行为的些许改变或者偏差. 例如你的朋友说你脸色不太好,是不是病了,此时,你可能就会感觉浑身 ...

  9. Vue框架:6、Vue组件间通信,动态组件,插槽,计算属性,监听属性

    目录 前端开发之Vue框架 一.Vue组件间通信 1.组件间通讯父传子 2.组件间通讯子传父 3.ref属性 二.动态组件 1.不使用动态组件 2.使用动态组件 3.keep-alive保持组件不销毁 ...

  10. 学习笔记:Vue——动态组件&异步组件

    动态组件 01.在动态组件上使用keep-alive,保持组件的状态,以避免反复重渲染导致的性能问题. <!-- 失活的组件将会被缓存!--> <keep-alive> < ...

随机推荐

  1. 苹果手机使用charles抓包无法下载charles证书

    苹果手机使用charles抓包无法下载charles证书的问题记录: 使用:chls.pro/ssl       --------无效 使用:http://chls.pro/ssl      ---- ...

  2. [oeasy]python017_万行代码之梦_vim环境_复制粘贴

    继续运行 回忆上次内容 上次 保存运行一条龙 :w|!python3 %   我想 再多输出 几行 增加一下 代码量 可以吗?       添加图片注释,不超过 140 字(可选) 代码量 在正常模式 ...

  3. TIER 1: Appointment

    TIER 1: Appointment SQL Structured Query Language 是一种用于管理关系型数据库的编程语言.它是一种标准化的语言,用于定义.操作和管理数据库中的数据. 经 ...

  4. ADB:移动端专项测试必备神器!!

    01 Android调试桥 (adb) Android调试桥 (adb) 是一种功能多样的命令行工具,可让您与设备进行通信. adb命令可用于执行各种设备操作(例如安装和调试应用),并提供对Unix ...

  5. Fiddler使用界面介绍-右侧面板

    右侧面板是对左侧请求进行解析的面板,点击左侧的请求右侧面板就会出现分析数据 1.Statistics关于HTTP请求的性能 2.Inspectors请求内容,包含请求数据和响应数据 3. AutoRe ...

  6. Jmeter函数助手6-time

    time函数用于获取不同格式的当前时间(年月日时分秒). Format string for SimpleDateFormat (optional):时间格式,填入如yyyyMMdd-HHmmss.d ...

  7. 【SpringSecurity】02 权限控制、自定义登陆、记住我

    [资源过滤 权限控制] 就之前的权限问题,例如一个user1登录成功去访问level1的资源当然没有问题 但是页面还呈现了其他权限的资源,比如level2 & level3 既然呈现给了use ...

  8. 聊一聊 Netty 数据搬运工 ByteBuf 体系的设计与实现

    本文基于 Netty 4.1.56.Final 版本进行讨论 时光芿苒,岁月如梭,好久没有给大家更新 Netty 相关的文章了,在断更 Netty 的这段日子里,笔者一直在持续更新 Linux 内存管 ...

  9. IE、Chrome、Firefox修改http header信息

    在测试系统交互时,可能会碰到需要修改header信息的要求,下面介绍下如何在IE.Chrome.Firefox修改http header信息. 1.IE(fiddler) >在IE下修改head ...

  10. sshpass 带密码登陆

    brew install hudochenkov/sshpass/sshpass sshpass -p password ssh -p port user@xxxx.xxxx.xxxx.xxxx