Thử Nghiệm Với Angular: Content Projection Trong Angular

I. Content Projection Trong Angular

Làm thế nào để sử dụng lại các component trong Angular 2+, hay làm sao để có thể nhúng content của một component cho một component khác. Bài học này sẽ giới thiệu cho các bạn về Content Projection trong Angular sử dụng ng-content directive. Content Projection Trong Angular

1: Nhúng một phần content vào một component

1.1: ng-content directive:

Chúng ta sẽ nhúng ng-content directive vào phần component template mà chúng ta mong muốn content sẽ được nhúng vào.

card.component.html

<div class="tp-card">
  <ng-content></ng-content>
</div>

card.component.ts

import { Component, ViewEncapsulation } from '@angular/core';
 
@Component({
  selector: 'tp-card',
  templateUrl: './card.component.html',
  styleUrls: ['./card.component.scss'],
  encapsulation: ViewEncapsulation.None
})
export class CardComponent { }

Khi ở trong Component khác mà chúng ta muốn nhúng template vào phần đã định nghĩa của Component cho phép nhúng thì chúng ta có thể làm như sau.

app.component.html

<tp-card>
  <header class="tp-card__title">Title</header>
  <div class="tp-card__content">
    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam, molestiae.
  </div>
  <footer class="tp-card__footer">
    <button class="tp-button btn btn-danger">Action</button>
  </footer>
</tp-card>

1.2: Sử dụng selector:

Khi bạn sử dụng selector, tất cả các selector nào thỏa mãn sẽ được nhúng vào đúng vị trí đã chọn, dù có nhúng vào nhiều hơn một lần.

– Tag selector:

card.component.html

<div class="tp-card">
  <ng-content select="header"></ng-content>
</div>

– Class selector:

card.component.html

<div class="tp-card">
  <ng-content select=".tp-card__content"></ng-content>
</div>

– Attribute selector:

card.component.html

<div class="tp-card">
  <ng-content select="[data-footer=cool-footer]"></ng-content>
</div>

– Sử dụng nhiều selector:

card.component.html

<div class="tp-card">
  <ng-content select="footer[data-footer=cool-footer]"></ng-content>
</div>

Template từ Component muốn nhúng vào.

app.component.html

<tp-card>
  <header class="tp-card__title">Title</header>
  <div class="tp-card__content">
    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam, molestiae.
  </div>
  <footer class="tp-card__footer" data-footer="cool-footer">
    <button class="tp-button btn btn-danger">Action</button>
  </footer>
</tp-card>

2: Nhúng nhiều phần content vào một component

Việc nhúng nhiều phần content hoàn toàn được phép, bạn có thể nhúng nhiều phần khác nhau ví dụ như:

card.component.html

<div class="tp-card">
 <ng-content select="header"></ng-content>
 <ng-content select=".tp-card__content"></ng-content>
 <ng-content select="footer"></ng-content>
</div>

Lưu ý: thứ tự đặt của ng-content sẽ có tác động đến thứ tự truyền vào từ Component khác, nếu Component khác truyền Template vào với thứ tự khác, thì thứ tự hiển thị sẽ tuân theo thứ tự của Component khai báo ng-content.

Ví dụ như bạn nhúng content như sau:

app.component.html

<tp-card>
  <header class="tp-card__title">Title</header>
  <footer class="tp-card__footer" data-footer="cool-footer">
    <button class="tp-button btn btn-danger">Action</button>
  </footer>
  <div class="tp-card__content">
    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Veniam, molestiae.
  </div>
</tp-card>

II. Thực Hành Content Projection

1: Các thành phần cơ bản của app

Trong bài học này chúng ta sẽ tạo mới hai component là CollapseGroupComponentCollapseComponent. Trong đó CollapseGroupComponent sẽ chịu trách nhiệm quản lý các component CollapseComponent như sau:

<tp-collapse-group [multiple]="false">
  <tp-collapse [title]="'First block'" [selected]="true">
    <span>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
      Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
    </span>
  </tp-collapse>
  <tp-collapse [title]="'Second block'" [selected]="false">
    <span>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
      Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
    </span>
  </tp-collapse>
</tp-collapse-group>

2: Cài đặt

Cài đặt chi tiết của các component như sau:

2.1: CollapseGroupComponent

collapse-group.component.html

<div class="collapsible">
  <ng-content select="tp-collapse"></ng-content>
</div>

collapse-group.component.ts

import { Component, OnInit, AfterContentInit, Input, ContentChildren, QueryList, OnDestroy } from '@angular/core';
import { CollapseComponent } from "../collapse/collapse.component";
import { Subscription } from "rxjs/Rx";
 
@Component({
  selector: 'tp-collapse-group',
  templateUrl: './collapse-group.component.html',
  styleUrls: ['./collapse-group.component.scss']
})
export class CollapseGroupComponent implements OnInit, AfterContentInit, OnDestroy {
  @ContentChildren(CollapseComponent) collapses: QueryList<CollapseComponent>;
  @Input() multiple: boolean = true;
 
  private _subscriptions: Subscription[] = [];
  constructor() { }
 
  ngOnInit() {
  }
 
  ngAfterContentInit() {
    this.collapses.forEach(collapse => {
      let subscription = collapse.selectedChange.subscribe(coll => {
        if (!this.multiple && coll.selected) {
          this.toggleCollapse(coll);
        }
      });
      this._subscriptions.push(subscription);
    });
  }
 
  toggleCollapse(collapse) {
    this.collapses.forEach(c => {
      if (c.collapseId != collapse.collapseId) {
        c.selected = false;
      }
    });
  }
 
  ngOnDestroy() {
    if (this._subscriptions && this._subscriptions.length) {
      this._subscriptions.forEach(sub => sub.unsubscribe());
    }
    this._subscriptions = [];
  }
 
}

Ở dòng 11, chúng ta sử dụng @ContentXXX để query các phần tử được nhúng vào thông qua ng-content mà chúng ta đã cài đặt ở template. Trong trường hợp ở trên, chúng ta muốn query tất cả các phần tử là CollapseComponent.

Bây giờ, để sử dụng các element, chúng ta cần hook vào lifecycle là ngAfterContentInit, kết quả là chúng ta có một mảng element để thao tác.

Về mặt logic, lúc này chúng ta kiểm tra xem Component này có cho phép multiple CollapseComponent được open cùng một lúc không, nếu không thì chúng ta phải thực hiên việc đóng các component khác mà đang open khi muốn open một thằng khác.

Dòng 22 chúng ta subscribe vào event emitter của CollapseComponent để thực hiện việc quản lý trạng thái của chúng.

Khi component CollapseGroupComponent bị hủy, chúng ta cũng thực hiện xóa bỏ các phần dữ liệu không cần thiết như: hủy các event listener đang tồn tại chẳng hạn.

2.2: CollapseComponent

collapse.component.html

<header class="collapsible-header" (click)="toggleSelected()">
    { {  title }}
</header>
<section class="collapsible-body" [class.active]="selected">
  <ng-content></ng-content>
</section>

collapse.component.ts

import { Component, OnInit, Input, ViewEncapsulation, Output, EventEmitter } from '@angular/core';
 
export interface DataCollapseChange {
  collapseId: string;
  selected: boolean;
}
 
let uuid: number = 1;
 
@Component({
  selector: 'tp-collapse',
  templateUrl: './collapse.component.html',
  styleUrls: ['./collapse.component.scss'],
  encapsulation: ViewEncapsulation.None
})
export class CollapseComponent implements OnInit {
  @Input() title: string = '';
  @Input() selected: boolean = false;
  @Output() selectedChange: EventEmitter<DataCollapseChange> = new EventEmitter<DataCollapseChange>();
  private _id: number;
  constructor() { }
 
  ngOnInit() {
    this._id = ++uuid;
  }
 
  get collapseId() {
    return 'tp-collapse-' + this._id;
  }
 
  toggleSelected() {
    this.selected = !this.selected;
    this.selectedChange.emit({
      collapseId: this.collapseId,
      selected: this.selected
    });
  }
}

Dòng 3 đến 6 chúng ta định nghĩa một kiểu dữ liệu để thực hiện trả về khi component thay đổi trạng thái của nó. Component này đơn giản chỉ nhận đầu vào, hiển thị và có thể gửi lại một event là selectedChange.

2.3: ContentChild vs ContentChildren vs ViewChild vs ViewChildren

Content: là các phần tử được truyền vào theo ng-content hay content projection.

View: là các phần tử nội tại của component đó, có thể hiểu là phần DOM mà bạn cài đặt.

Ví dụ trong CollapseComponent thì phần header là view, còn phần được truyền vào qua ng-content là content.

XXXChild vs XXXChildren: thì XXXChild nó sẽ trả về 1 phần tử, còn XXXChildren trả về một list các phần tử.

3: QueryList Changes Event Trong Angular

Với phiên bản code phía trên liệu rằng khi chúng ta làm việc với dữ liệu async thì app của chúng ta sẽ chạy đúng. Lúc này, chúng ta gặp phải một vấn đề đó là mặc dù chúng ta đã để [multiple]="false" nhưng app lại chạy không như chúng ta mong muốn. app.component.html

<tp-collapse-group [multiple]="false">
  <tp-collapse
    *ngFor="let post of posts" [title]="post.title" [selected]="false">
    <div>
        { {  post.body }}
    </div>
  </tp-collapse>
</tp-collapse-group>

app.component.ts

import { Component, OnInit } from '@angular/core';
import { POSTS } from './services/post';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  posts: any[] = [];
  ngOnInit() {
    setTimeout(() => {
      this.posts = POSTS.slice();
    }, 500);
  }
}

Bây giờ, chúng ta cần quan sát đối tượng của QueryList thay đổi như thế nào để có các hành động tương ứng bằng việc subscribe vào Changes Event của đối tượng đó như sau:

this.collapses.changes.subscribe((object) => {
  // do something here
});

Ví dụ như trong app của chúng ta đang phát triển, chúng ta cần quan sát sự thay đổi của list CollapseComponent để thực hiện quản lý ở mode accordion như sau:

this.collapses.changes.subscribe(() => {
  this.clearListener();
  this.initListener();
});

Việc quan sát này thực sự cẩn thiết khi chúng ta cần phải áp dụng một phần logic nào đó để Component của chúng ta chạy đúng, chẳng hạn như mong muốn của chúng ta là CollapseGroupComponent chỉ thực hiện cho phép một CollapseComponent được phép mở ở tại một thời điểm nếu chúng ta set cho mode của group là accordion.

Thêm vào đó, khi thực hiện subscribe bằng code như trên, chúng ta đã tạo ra một object của class Subscription, vậy nên khi Component bị destroy thì chúng ta nên (phải) hủy object đó đi như sau:

// some method
this._changeSubs = this.collapses.changes.subscribe(() => {
  this.clearListener();
  this.initListener();
});
 
// on destroy
ngOnDestroy() {
  if (this._changeSubs) {
    this._changeSubs.unsubscribe();
  }
}

Đơn giản phải không nào! Và code hoàn thiện của CollapseGroupComponent như sau:

import { Component, OnInit, AfterContentInit, Input, ContentChildren, QueryList, OnDestroy } from '@angular/core';
import { CollapseComponent } from "../collapse/collapse.component";
import { Subscription } from "rxjs/Rx";

@Component({
  selector: 'tp-collapse-group',
  templateUrl: './collapse-group.component.html',
  styleUrls: ['./collapse-group.component.scss']
})
export class CollapseGroupComponent implements OnInit, AfterContentInit, OnDestroy {
  @ContentChildren(CollapseComponent) collapses: QueryList<CollapseComponent>;
  @Input() multiple: boolean = true;

  private _subscriptions: Subscription[] = [];
  private _changeSubs: Subscription;
  constructor() { }

  ngOnInit() {
  }

  ngAfterContentInit() {
    this.initListener();
    this._changeSubs = this.collapses.changes.subscribe(() => {
      this.clearListener();
      this.initListener();
    });
  }

  initListener() {
    this.collapses.forEach(collapse => {
      let subscription = collapse.selectedChange.subscribe(coll => {
        if (!this.multiple && coll.selected) {
          this.toggleCollapse(coll);
        }
      });
      this._subscriptions.push(subscription);
    });
  }

  toggleCollapse(collapse) {
    this.collapses.forEach(c => {
      if (c.collapseId != collapse.collapseId) {
        c.selected = false;
      }
    });
  }

  clearListener() {
    if (this._subscriptions && this._subscriptions.length) {
      this._subscriptions.forEach(sub => sub.unsubscribe());
    }
    this._subscriptions = [];
  }

  ngOnDestroy() {
    this.clearListener();
    if (this._changeSubs) {
      this._changeSubs.unsubscribe();
    }
  }

}

III. Resources

1: Video bài học

2: Tham khảo

Blog post: http://www.tiepphan.com/thu-nghiem-voi-angular-content-projection-trong-angular/ http://www.tiepphan.com/thu-nghiem-voi-angular-thuc-hanh-content-projection-va-lifecycle-angular/ http://www.tiepphan.com/thu-nghiem-voi-angular-querylist-changes-event-trong-angular/

Source code: https://github.com/tieppt/try-angular-2/tree/lesson-12 https://github.com/tieppt/try-angular-2/tree/lesson-13 https://github.com/tieppt/try-angular-2/tree/lesson-14

Docs: https://angular.io/docs/ts/latest/guide/lifecycle-hooks.html https://angular.io/docs/ts/latest/api/core/index/ContentChild-decorator.html https://angular.io/docs/ts/latest/api/core/index/ContentChildren-decorator.html https://angular.io/docs/ts/latest/api/core/index/ViewChild-decorator.html https://angular.io/docs/ts/latest/api/core/index/ViewChildren-decorator.html https://angular.io/docs/ts/latest/api/core/index/Query-class.html https://angular.io/docs/ts/latest/api/core/index/QueryList-class.html

Tiep Phan: 04/14/2017 http://www.tiepphan.com