Những điều cần viết về dynamic component trong Angular

Bài viết được dịch từ Here is what you need to know about dynamic components in Angular của 

Nếu bạn từng làm qua Angularjs, có thể bạn từng quen với việc sinh những chuỗi HTML và thực thi chúng ngay lập tức thông qua service $compile và liên kết với scope để có thể sử dụng được binding 2 chiều.

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);

Trong AngularJs, một directive có thể thay đổi DOM theo rất nhiều cách và framework sẽ không biết được những thay đổi đó sẽ thay đổi gì. Và hướng tiếp cận về dynamic template cũng như những dynamic enviroment khác đề có điểm chung là khó để tối ưu performance về speed. Việc đánh giá 1 dynamic template tất nhiên sẽ không phải là nguyên nhân chính khiến AngularJS bị xem như là 1 framework chậm nhưng điều  đó cũng chắc chắn khiến những cái nhìn về AngularJS sẽ bị hạ xuống.

Sau khi nghiên cứu cốt lõi bên trong Angular, dường như là thiết kế của framework mới này hướng đến nhu cầu về tốc độ. Bạn có thể sẽ thấy những comment trong source code.

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.

Chính vì thê nhà phát triển của Angular quyết định giảm thiểu khả năng linh hoạt nhằm cải thiện tốc độ và cũng giới thiệu bộ compiler JIT và AOT, static templates, factory và factory resolver… và nhiều thứ khác nữa mà trông chúng có vẻ không quen thuộc với cộng động Angularjs. Nhưng đừng lo lắng, nếu bạn từng trải qua những khái niêm này trước đó, sau đó tự trải nghiệm và ngộ ra.

Component factory and compiler

Trong Angular, mỗi component được tạo từ factory và những factory này được tạo ra bằng trình biên dịch (compiler) mà sẽ sử dụng những data mà chúng ta cung cấp khi khai báo trong @Component 

Đi vào chi tiết hơn, Angular sử dụng khái niệm View, framework này khi chạy sẽ gồm cây của các View. Mỗi View bao gồm nhiều node với các loại khác nhau: Element node, text node…Mỗi node có đặc trưng riêng biệt sao cho quá trình xử lý chúng sẽ diễn ra nhanh nhất có thể. Và có nhiều provider được nhúng vào trong những node đó như ViewContainerRefTemplateRef. Và các loại node này sẽ biết cách đáp ứng trong những câu query như  @ViewChildren hay @ContentChildren.

Có rất nhiều thông tin trong mỗi node. Để tối ưu về speed, những thông tin này phải có sẵn khi các node đuợc khởi tạo và không thể thay đổi về sau. Đó là những gì mà giai đoạn compile chuẩn bị – thu thập thông tin bắt buộc và đóng gói chúng vào theo dạng các component factory.

Giả sử bạn xây dựng 1 component theo dạng sau:

@Component({
  selector: 'a-comp',
  template: '<span>A Component</span>'
})
class AComponent {}

Bằng những thông số – dữ liệu đuợc cung cấp, trình biên dịch sẽ sinh ra 1 factory có dạng như sau

function View_AComponent_0(l) {
  return jit_viewDef1(0,[
      elementDef2(0,null,null,1,'span',...),
      jit_textDef3(null,['A Component ',...])
    ]

Nó mô tả cấu trúc của component view và factory này đuợc dùng để khởi tạo component. Node đầu tiên mô tả vể element (span) và node sau mô tả text (A Component). Chúng ta có thể thấy rằng mỗi node sẽ lấy những thông tin cần thiết từ những dữ liệu đuợc truyền vào. Đó là việc của trình biên dịch để resolve những dependency và cung cấp trong quá trình runtime (Chưa rõ ví dụ nó làm ntn).

Nếu chúng ta lấy được hoặc truy cập được vào các component factory. Chúng ta có thể tạo ra các instance component từ chúng bằng cách sử dụng ViewContainerRef. Đây là cách chúng ta sẽ thực hiện

export class SampleComponent implements AfterViewInit {
    @ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;

    ngAfterViewInit() {
        this.vc.createComponent(componentFactory);
    }
}

Ta có thể tham khảo bài viết của tác giả gốc của tác giả về cách thực hiện

Vậy câu hỏi bây giờ chính là làm sao truy cập đến các component factory? Và tác giả sẽ giải đáp ngay sau đây.

Angular modules and ComponentFactoryResolver

Mặc dùng AngularJs cũng có khái niệm module, nhưng nó vẫn thiếu namespace đúng cho những directive . Sẽ luôn có những xung đột về tên khi chúng ta nhúng các module vào với nhau. Angular rút ra bài học đó và hiện cung cấp những namspace thích hợp để khai báo kiểu: directive, component vs pipe.

Mỗi component trong AngularJs là 1 phần của các module trong framework mới. Component không tồn tại độc lâp, và nếu chúng ta muốn sử dụng 1 component nào đó, chúng ta phải import module tương ứng chứa compoent đó.

@NgModule({
    // imports CommonModule with declared directives like
    // ngIf, ngFor, ngClass etc.
    imports: [CommonModule],
    ...
})
export class SomeModule {}

Nguợc lại, nếu 1 module muốn cung cấp những component để dùng cho những module khác, ta cần khai báo export những component đấy.

const COMMON_DIRECTIVES: Provider[] = [
    NgClass,
    NgComponentOutlet,
    NgForOf,
    NgIf,
    ...
];

@NgModule({
    declarations: [COMMON_DIRECTIVES, ...],
    exports: [COMMON_DIRECTIVES, ...],
    ...
})
export class CommonModule {
}

Chính vì thế, mỗi component sẽ thuộc về 1 module cụ thể và ta không thể khai báo cùng 1 component vào các module khác nhau. Nếu không ta sẽ gặp lỗi.

Type X is part of the declarations of 2 modules: ...

Khi Angular biên dịch ứng dụng. Nó lấy những component được định nghĩa trong entryComponents của 1 module hoặc được tìm thấy trong component template và sinh ra componentFactory cho chúng. Chúng ta sẽ thấy trong Source Tab của Chrome:

Trong phần trước, chúng ta định nghĩa là nếu ta truy cập đuợc component factory, chúng ta có thể dùng để tạo ra component và nhúng nó vào view. Mỗi module cung cấp 1 service cho tất cả component để lấy component factory. Chúng đuợc gọi là ComponentFactoryResolver . Vì thế nếu ta định nghĩa 1 component BComponent trên module và muốn lấy đuợc component factory của nó, ta có thể khai báo như sau:

export class AppComponent {
  constructor(private resolver: ComponentFactoryResolver) {
    // now the `f` contains a reference to the cmp factory
    const f = this.resolver.resolveComponentFactory(BComponent);
  }

Lưu ý rằng cách này chỉ đúng khi AppComponent BComponent đuợc định nghĩa trên cùng module hoặc module chứa component BComponent đuợc import vào module chứa component AppComponent.

Dynamic module loading and compilation

Nhưng điều gì xảy ra nếu component của bạn được định nghĩa trong module khác và bạn không muốn load cho đến khi component đó thưc sự cần? Chúng ta có thể làm bằng cách tương tự router làm thông qua loadChildren 

Có 2 cách để load module trong quá trình runtime. Cách đầu là dùng SystemJsNgModuleLoader được cung cấp bởi Angular. Nó đuợc dùng trong router để load child route nếu bạn dùng SystemJS như là bộ loader. Nó có 1 phương thức public load(…) sẽ load module vào browser và module cũng như toàn bộ các component đuợc khai báo trong đó. Phương thức này nhận vào đường dẫn đến tập tin chứa module và tên của đối tượng module export và trả về ModuleFactory

loader.load('path/to/file#exportName')

Lưu ý là nếu ta không cung cấp exportName, trình loader sẽ sử dụng tên export đuợc export default. Một lưu ý khác là SystemJsNgModuleLoader yêu cầu các cài đặt DI và ta sẽ khai báo như sau:

Ta tất nhiên có thể khai báo bất kỳ thứ nào khác trong field provide, nhưng module router khai báo như trên và tốt nhất là chúng ta nên tuân theo.

Và đây là full source code

@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);
    })
  }
}

Nhưng có vấn đề với việc dùng SystemJsNgModuleLoader. Nó sử dụng compileModuleAsync bên dưới để thực hiện biên dịch.Phương thức này sẽ tạo ra factory chỉ cho những component đuợc khai báo trong entryComponents của module hoăc tìm thấy trong component template. Nhưng nếu bạn không muốn khai báo component trong entryComponents? Có giải pháp cho cách này là tự mình load module và dùng phương thức compileModuleAndAllComponentsAsync. Nó sinh ra các factory cho tất cả component trong module và trả về như là 1 instance của kiểu ModuleWithComponentFactories.

Sau đây là toàn bộ source code

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);
        })
    })
}

Lưu ý rằng cách tiếp cận này tận dụng trình biên dịch mà chưa được hỗ trợ như 1 public API như đề cập trong tài liệu:

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.


(tạm dịch: Một thiếu sót từ danh sách này chính là @angular/compiler, mà hiện đang đuợc xem như là 1 api cấp thấp và tùy thuộc vào những thay đổi bên trong hệ thống. Những thay đổi này không ảnh huởng bất kì ứng dụng hay thư viện nào sử dụng các api cấp cao hơn (CLI hoặc bộ biên dịch JIT trong @angular/platform-browser-dynamic). Chỉ 1 vài trường hợp đặc biệt yêu cầu phải truy cập trực tiếp vào các API của trình biên dịch (Đa số là các công cụ tích hợp vào IDE hoặc linters..) Nếu bạn phải làm việc với những cách thức đó, hãy để chúng tôi biết truớc.

Creating components on the fly

Trong phần truớc, chúng ta biết đuợc cách để component động đuợc tạo trong angular. ta cũng biết rằng cách đó yêu cầu truy cập đến component factory của module. Đến bây giờ, ta đã có thể dùng những module đuợc định nghĩa trước quá trình runtime và có thể load eager hay lazy. Nhưng điều tốt là chúng ta không cần phải định nghĩa module truớc và load chúng. Chúng ta thậm chí có thể tạo ra module và component ngay khi cần như angularJs.

Hãy xem lại thí dụ chúng ta đã thấy ở đầu chương trình, chúng ta cũng sẽ xem cách thực hiện trong Angular

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);

Luồng đi tổng quát để tạo và đính content động vào view là theo cách sau:

  1. Định nghĩa class của component và decorate class.
  2. Định nghĩa class của module thêm component vào module và decorate module.
  3. Compile module và toàn bộ component để nắm đuợc component factory.

Module chỉ đơn giản là class dược decorate bằng @NgModule decorator. Điều đó cũng tương tự với component. Vì các decorator là những hàm đơn giản và đã có sẵn trong quá trình runtime. Chúng ta có thể dùng chúng để decorate class theo ý muốn. Và đây là cách chúng ta tạo và attach component vào view

@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(f);
      cmpRef.instance.name = 'dynamic';
    })
}

Chúng ta có thể đổi tên class của component và module để dễ debug.

Ahead-of-Time Compilation

Trình biên dịch chúng ta dùng ở trên là JIT (Just-In-Time). Chúng ta cũng nghe qua trình biên dịch AoT (Ahead-of-Time). Mặc dùng Angular chỉ có 1 trình biên dịch và nó đuợc hiểu là JIT hoặc AOT dựa trên khi nào nó đuợc dùng. Nếu nó đuợc dùng trong quá trình chạy trong browser, nó là JIT. Nếu chúng ta biên dịch component truớc khi thực hiện chạy trên browser, nó là AOT. Trình biên dịch AOT đuợc chuộng hơn và bài viết sau mô tả lí do – trong đó có nói đến việc render nhanh hơn và kích thuớc code nhỏ hơn.

Nếu ta dùng trình biên dịch AOT, nó có nghĩa là ta sẽ không có thực thể compiler trong giai đoạn runtime. Ví dụ trên không yêu cầu compiler và chỉ đơn giản là sử dụng ComponentFactoryResolver thì vẫn chạy được khi biên dịch bằng AOT compiler nhưng trong trường hợp sử dụng biên dịch động thì không đuợc. Nhưng đó không có nghĩa là không có cách, ta chỉ cần thiết lập 1 ít trong code

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 {
}

Ở đây ta sử dụng JitCompilerFactory trong gói @angular/compiler sẽ tạo ra compiler factory. Ta cấu hình compiler sử dụng JIT và tạo ra thực thể đó trong ứng dụng thông qua token Compiler. Ngoài ra không còn sự thay đổi nào khác,

Destroying components

Điều cuối cùng là nếu chúng ta đã thêm component thì đừng quên hủy chúng khi component cha hủy

ngOnDestroy() {
if(this.cmpRef) {
this.cmpRef.destroy();
}
}

ngOnChanges

Với tất cả các component đuợc load động, Angular thực hiện change detection như cách static, tức là ngDoCheck life cycle hook đuợc thực hiện. Tuy nhiên, ngOnChanges không đuợc thực hiện ngay cả khi nếu dynamic component khai báo @Input và component cha có thay dổi thuộc tính.Điều này là do hàm thực hiện kiểm tra input được sinh bởi trình biên dịch trong quá trình biên dịch. Hàm này là 1 phần của component factory và hiện tại nó chỉ có thê được sinh ra dựa vào thông tin của template. Và vì chúng ta không dùng dynamic component trong template, hàm đó sẽ không đuợc sinh ra bởi trình biên dịch.

Github

Toàn bộ source code mẫu đuợc publish tại Github

Give some ideas