原文连接: Here is what you need to know about dynamic components in Angular
本文主要解释如何在 Angular 中动态建立组件(注:在模板中使用的组件可称为静态地建立组件)。node
若是你以前使用 AngularJS(第一代 Angular 框架)来编程,可能会使用 $compile
服务生成 HTML,并链接到数据模型从而得到双向绑定功能:git
const template = '<span>generated on the fly: {{name}}</span>' const linkFn = $compile(template); const dataModel = $scope.$new(); dataModel.name = 'dynamic'; // link data model to a template linkFn(dataModel);
AngularJS 中指令能够修改 DOM,可是无法知道修改了什么。这种方式的问题和动态环境同样,很难优化性能。动态模板固然不是 AngularJS 性能慢的主要元凶,但也是重要缘由之一。github
我在看了 Angular 内部代码一段时间后,发现这个新设计的框架很是重视性能,在 Angular 源码里你会常常发现这几句话(注:为清晰理解,不翻译):编程
Attention: Adding fields to this is performance sensitive! Note: We use one type for all nodes so that loops that loop over all nodes of a ViewDefinition stay monomorphic! For performance reasons, we want to check and update the list every five seconds.
因此,Angular 设计者决定牺牲灵活性来得到巨大的性能提高,如引入了 JIT 和 AOT Compiler,静态模板(static templates),指令/模块工厂(ComponentFactory),工厂解析器(ComponentFactoryResolver)。对 AngularJS 社区来讲,这些概念很陌生,甚至充满敌意,不过不用担忧,若是你以前仅仅是据说过这些概念,但如今想知道这些是什么,继续阅读本文,将让你茅塞顿开。api
注:实际上,JIT/AOT Compiler 说的是同一个 Compiler,只是这个 Compiler 在 building time 阶段仍是在 running time 阶段被使用而已。至于 factory,是 Angular Compiler 把你写的组件如 a.component.ts 编译为 a.component.ngfactory.js,即 Compiler 使用 @Component decorator 做为原材料,把你写的组件/指令类编译为另外一个视图工厂类。浏览器
回到刚刚的 JIT/AOT Compiler,若是 a.component.ngfactory.js 是在 build 阶段生成的那就是 AOT Compiler,这个 Compiler 不会被打包到依赖包里;若是是在 run 阶段生成,那 Compiler 就须要被打包到依赖包里,被用户下载到本地,在运行时 Compiler 会编译组件/指令类生成对应的视图工厂类,仅此而已。下文将会看下这些 *.ngfactory.js 文件代码是什么样的。缓存
至于 factory resolver,那就更简单了,就是一个对象,经过它拿到那些编译后的 factory 对象。app
Angular 中每个组件是由组件工厂建立的,组件工厂又是由编译器根据你写的 @Component
装饰器里的元数据编译生成的。若是你在网上读了大量的 decorator 文章还有点迷惑,能够参考我写的这篇 Medium 文章 Implementing custom component decorator 。框架
Angular 内部使用了 视图 概念,或者说整个框架是一颗视图树。每个视图是由大量不一样类型节点(node)组成的:元素节点,文本节点等等(注:可查看 译 Angular DOM 更新机制)。每个节点都有其专门做用,这样每个节点的处理只须要花不多的时间,而且每个节点都有 ViewContainerRef
和 TemplateRef
等服务供使用,还可使用 ViewChild/ViewChildren
和 ContentChild/ContentChildren
作 DOM 查询这些节点。ide
注:简单点说就是 Angular 程序是一颗视图树,每个视图(view)又是有多种节点(node)组成的,每个节点又提供了模板操做 API 给开发者使用,这些节点能够经过 DOM Query API 拿到。
每个节点包含大量信息,而且为了性能考虑,一旦节点被建立就生效,后面不允许更改(注:被建立的节点会被缓存起来)。节点生成过程是编译器搜集你写的组件信息(注:主要是你写的组件里的模板信息),并以组件工厂形式封装起来。
假设你写了以下的一个组件:
@Component({ selector: 'a-comp', template: '<span>A Component</span>' }) class AComponent {}
编译器根据你写的信息生成相似以下的组件工厂代码,代码只包含重要部分(注:下面整个代码可理解为视图,其中 elementDef2
和 jit_textDef3
可理解为节点):
function View_AComponent_0(l) { return jit_viewDef1(0,[ elementDef2(0,null,null,1,'span',...), jit_textDef3(null,['My name is ',...]) ]
上面代码基本描述了组件视图的结构,并被用来实例化一个组件。其中,第一个节点 elementDef2
就是元素节点定义,第二个节点 jit_textDef3
就是文本节点定义。你能够看到每个节点都有足够的参数信息来实例化,而这些参数信息是编译器解析全部依赖生成的,而且在运行时由框架提供这些依赖的具体值。
从上文知道,若是你可以访问到组件工厂,就可使用它实例化出对应的组件对象,并使用 ViewContainerRef API 把该组件/视图插入 DOM 中。若是你对 ViewContainerRef
感兴趣,能够查看 译 探索 Angular 使用 ViewContainerRef 操做 DOM。应该如何使用这个 API 呢(注:下面代码展现如何使用 ViewContainerRef
API 往视图树上插入一个视图):
export class SampleComponent implements AfterViewInit { @ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef; ngAfterViewInit() { this.vc.createComponent(componentFactory); } }
好的,从上面代码可知道只要拿到组件工厂,一切问题就解决了。如今,问题是如何拿到 ComponentFactory 组件工厂对象,继续看。
尽管 AngularJS 也有模块,但它缺乏指令所须要的真正的命名空间,而且会有潜在的命名冲突,还无法在单独的模块里封装指令。然而,很幸运,Angular 吸收了教训,为各类声明式类型,如指令、组件和管道,提供了合适的命名空间(注:即 Angular 提供的 Module
,使用装饰器函数 @NgModule
装饰一个类就能获得一个 Module
)。
就像 AngularJS 那样,Angular 中的组件是被封装在模块中。组件本身并不能独立存在,若是你想要使用另外一个模块的一个组件,你必须导入这个模块:
@NgModule({ // imports CommonModule with declared directives like // ngIf, ngFor, ngClass etc. imports: [CommonModule], ... }) export class SomeModule {}
一样道理,若是一个模块想要提供一些组件给别的模块使用,就必须导出这些组件,能够查看 exports
属性。好比,能够查看 CommonModule
源码的作法(注:查看 L24-L25):
const COMMON_DIRECTIVES: Provider[] = [ NgClass, NgComponentOutlet, NgForOf, NgIf, ... ]; @NgModule({ declarations: [COMMON_DIRECTIVES, ...], exports: [COMMON_DIRECTIVES, ...], ... }) export class CommonModule { }
因此每个组件都是绑定在一个模块里,而且不能在不一样模块里申明同一个组件,若是你这么作了,Angular 会抛出错误:
Type X is part of the declarations of 2 modules: ...
当 Angular 编译程序时,编译器会把在模块中 entryComponents
属性注册的组件,或模板里使用的组件编译为组件工厂(注:在全部静态模板中使用的组件如 <a-comp></a-comp>
,即静态组件;在 entryComponents
定义的组件,即动态组件,动态组件的一个最佳示例如 Angular Material Dialog 组件,能够在 entryComponents
中注册 DialogContentComp
组件动态加载对话框内容)。你能够在 Sources
标签里看到编译后的组件工厂文件:
从上文中咱们知道,若是咱们能拿到组件工厂,就可使用组件工厂建立对应的组件对象,并插入到视图里。实际上,每个模块都为全部组件提供了一个获取组件工厂的服务 ComponentFactoryResolver。因此,若是你在模块中定义了一个 BComponent
组件并想要拿到它的组件工厂,你能够在这个组件内注入这个服务并使用它:
export class AppComponent { constructor(private resolver: ComponentFactoryResolver) { // now the `factory` contains a reference to the BComponent factory const factory = this.resolver.resolveComponentFactory(BComponent); }
这是在两个组件 AppComponent
和 BComponent
都定义在一个模块里才行,或者导入其余模块时该模块已经有组件 BComponent
对应的组件工厂。
可是若是组件在其余模块定义,而且这个模块是按需加载,这样的话是否是完蛋了呢?实际上咱们照样能够拿到某个组件的组件工厂,方法同路由使用 loadChildren
配置项按需加载模块很相似。
有两种方式能够在运行时加载模块。第一种方式 是使用 SystemJsNgModuleLoader 模块加载器,若是你使用 SystemJS 加载器的话,路由在加载子路由模块时也是用的 SystemJsNgModuleLoader
做为模块加载器。SystemJsNgModuleLoader
模块加载器有一个 load
方法来把模块加载到浏览器里,同时编译该模块和在该模块中申明的全部组件。load
方法须要传入文件路径参数,并加上导出模块的名称,返回值是 NgModuleFactory:
loader.load('path/to/file#exportName')
注: NgModuleFactory 源码是在packages/core/linker
文件夹内,该文件夹里的代码主要是粘合剂
代码,主要都是一些接口类供Core
模块使用,具体实如今其余文件夹内。
若是没有指定具体的导出模块名称,加载器会使用默认关键字 default
导出的模块名。还需注意的是,想要使用 SystemJsNgModuleLoader
还需像这样去注册它:
providers: [ { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader } ]
你固然能够在 provide
里使用任何标识(token),不过路由模块使用 NgModuleFactoryLoader
标识,因此最好也使用相同 token
。(注:NgModuleFactoryLoader
注册可查看源码 L68,使用可查看 L78)
模块加载并获取组件工厂的完整代码以下:
@Component({ providers: [ { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader } ] }) export class ModuleLoaderComponent { constructor(private _injector: Injector, private loader: NgModuleFactoryLoader) { } ngAfterViewInit() { this.loader.load('app/t.module#TModule').then((factory) => { const module = factory.create(this._injector); const r = module.componentFactoryResolver; const cmpFactory = r.resolveComponentFactory(AComponent); // create a component and attach it to the view const componentRef = cmpFactory.create(this._injector); this.container.insert(componentRef.hostView); }) } }
可是在使用 SystemJsNgModuleLoader
时还有个问题,上面代码的 load()
函数内部(注:参见 L70)实际上是使用了编译器的 compileModuleAsync 方法,该方法只会为在 entryComponents
中注册的或者在组件模板中使用的组件,去建立组件工厂。可是若是你就是不想要把组件注册在 entryComponents
属性里,是否是就完蛋了呢?仍然有解决方案 —— 使用 compileModuleAndAllComponentsAsync 方法本身去加载模块。该方法会为模块里全部组件生成组件工厂,并返回 ModuleWithComponentFactories
对象:
class ModuleWithComponentFactories<T> { componentFactories: ComponentFactory<any>[]; ngModuleFactory: NgModuleFactory<T>;
下面代码完整展现如何使用该方法加载模块并获取全部组件的组件工厂(注:这是上面说的 第二种方式):
ngAfterViewInit() { System.import('app/t.module').then((module) => { _compiler.compileModuleAndAllComponentsAsync(module.TModule) .then((compiled) => { const m = compiled.ngModuleFactory.create(this._injector); const factory = compiled.componentFactories[0]; const cmp = factory.create(this._injector, [], null, m); }) }) }
然而,记住,这个方法使用了编译器的私有 API,下面是源码中的 文档说明:
One intentional omission from this list is@angular/compiler
, which is currently considered a low level api and is subject to internal changes. These changes will not affect any applications or libraries using the higher-level apis (the command line interface or JIT compilation via@angular/platform-browser-dynamic
). Only very specific use-cases require direct access to the compiler API (mostly tooling integration for IDEs, linters, etc). If you are working on this kind of integration, please reach out to us first.
从上文中咱们知道如何经过模块中的组件工厂来动态建立组件,其中模块是在运行时以前定义的,而且模块是能够提早或延迟加载的。可是,也能够不须要提早定义模块,能够像 AngularJS 的方式在运行时建立模块和组件。
首先看看上文中的 AngularJS 的代码是如何作的:
const template = '<span>generated on the fly: {{name}}</span>' const linkFn = $compile(template); const dataModel = $scope.$new(); dataModel.name = 'dynamic' // link data model to a template linkFn(dataModel);
从上面代码能够总结动态建立视图的通常流程以下:
模块类也仅仅是带有模块装饰器的普通类,组件类也一样如此,而因为装饰器也仅仅是简单地函数而已,在运行时可用,因此只要咱们须要,就可使用这些装饰器如 @NgModule()/@Component()
去装饰任何类。下面代码完整展现如何动态建立组件:
@ViewChild('vc', {read: ViewContainerRef}) vc: ViewContainerRef; constructor(private _compiler: Compiler, private _injector: Injector, private _m: NgModuleRef<any>) { } ngAfterViewInit() { const template = '<span>generated on the fly: {{name}}</span>'; const tmpCmp = Component({template: template})(class { }); const tmpModule = NgModule({declarations: [tmpCmp]})(class { }); this._compiler.compileModuleAndAllComponentsAsync(tmpModule) .then((factories) => { const f = factories.componentFactories[0]; const cmpRef = this.vc.createComponent(tmpCmp); cmpRef.instance.name = 'dynamic'; }) }
为了更好的调试信息,你可使用任何类来替换上面代码中的匿名类。
上文中说到的编译器说的是 Just-In-Time(JIT) 编译器,你可能据说过 Ahead-of-Time(AOT) 编译器,实际上 Angular 只有一个编译器,它们仅仅是根据编译器使用在不一样阶段,而采用的不一样叫法。若是编译器是被下载到浏览器里,在运行时使用就叫 JIT 编译器;若是是在编译阶段去使用,而不须要下载到浏览器里,在编译时使用就叫 AOT 编译器。使用 AOT 方法是被 Angular 官方推荐的,而且官方文档上有详细的 缘由解释 —— 渲染速度更快而且代码包更小。
若是你使用 AOT 的话,意味着运行时不存在编译器,那上面的不须要编译的示例仍然有效,仍然可使用 ComponentFactoryResolver
来作,可是动态编译须要编译器,就无法运行了。可是,若是非得要使用动态编译,那就得把编译器做为开发依赖一块儿打包,而后代码被下载到浏览器里,这样作须要点安装步骤,不过也没啥特别的,看看代码:
import { JitCompilerFactory } from '@angular/compiler'; export function createJitCompiler() { return new JitCompilerFactory([{ useDebug: false, useJit: true }]).createCompiler(); } import { AppComponent } from './app.component'; @NgModule({ providers: [{provide: Compiler, useFactory: createJitCompiler}], ... }) export class AppModule { }
上面代码中,咱们使用 @angular/compiler
的 JitCompilerFactory
类来实例化出一个编译器工厂,而后经过标识 Compiler
来注册编译器工厂实例。以上就是所须要修改的所有代码,就这么点东西须要修改添加,很简单不是么。
若是你使用动态加载组件方式,最后须要注意的是,当父组件销毁时,该动态加载组件须要被销毁:
ngOnDestroy() { if(this.cmpRef) { this.cmpRef.destroy(); } }
上面代码将会从视图容器里移除该动态加载组件视图并销毁它。
对于全部动态加载的组件,Angular 会像对静态加载组件同样也执行变动检测,这意味着 ngDoCheck
也一样会被调用(注:可查看 Medium 这篇文章 If you think ngDoCheck means your component is being checked — read this article)。然而,就算动态加载组件申明了 @Input
输入绑定,可是若是父组件输入绑定属性发生改变,该动态加载组件的 ngOnChanges
不会被触发。这是由于这个检查输入变化的 ngOnChanges
函数,只是在编译阶段由编译器编译后从新生成,该函数是组件工厂的一部分,编译时是根据模板信息编译生成的。由于动态加载组件没有在模板中被使用,因此该函数不会由编译器编译生成。
本文的全部示例代码存放在 Github。
注:本文主要讲了组件b-comp
如何动态加载组件a-comp
,若是两个在同一个module
,直接调用 ComponentFactoryResolver 等 API 就行;若是不在同一个module
,就使用 SystemJsNgModuleLoader 模块加载器就行。