Last updated: November 15, 2020 8:44 PM

Angular 8 Demo Project Log and Experience Part 4.

Issue about Component nesting

Component nesting is inevitable. Think about the structure:

  • AppComponent

    • ChildComponent
      • GrandComponent
        • GrandGrandComponent

    We can use @Input() and @Output to realize the data transfer between nesting components or between components and outside world.

    However, if we need to transfer between AppComponent and GrandGrandComponent, we need to transfer the data or event in two middle components too.

    This over nesting can lead to complexity and redundancy in our codes.

    To avoid the redundant data and events brought by component nesting, we can use

    • content projection
    • router
    • directive
    • service

Content Projection

Using ng-contentto realize content projection.

  • what: dynamic content
  • syntax: <ng-content select="style classes / HTML tags / directives"></ng-content>
  • possible scenarios:
    • display dynamic contents
    • as a component container

Previously we just call the ChildComponent in AppComponent, such as

<app-scrollable-tab></app-scrollable-tab>
<app-image-slider></app-image-slider>
<app-horizontal-grid></app-horizontal-grid>

In horizontal-grid.component.html, we display the content as follow:

<span appGridItem *ngFor="let item of channels">
  <img
    [src]="item.icon"
    alt=""
    [appGridItemImage]="'2rem'"
     />
  <span appGridItemTitle="0.6rem">{{ item.title }}</span>
</span>

This is the normal way to use components.

Refactor to ng-content

We now change the horizontal-grid.component.html to the code:

<ng-content></ng-content>

then, change the app.component.html to modify the app-horizontal-grid, just simply move the code from horizontal-grid.component.html, we also need to change some css or imports.

<app-horizontal-grid>
  <span appGridItem *ngFor="let item of channels">
  <img
    [src]="item.icon"
    alt=""
    [appGridItemImage]="'2rem'"
     />
  <span appGridItemTitle="0.6rem">{{ item.title }}</span>
</span>
</app-horizontal-grid>

Thus, the Content Projection DONE.

We can also use some syntax:

<ng-content select="span"></ng-content>
<ng-content select=".special"></ng-content> <!-- special is class name -->
<ng-content select="[appGridItem]"></ng-content>

Router

Routes includes:

  • path
  • component
  • child router

Router to Home Page

The best practice is to create individual file to deal with the routes.

Create app-routing.module.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeContainerComponent } from './home';

const routes: Routes = [
  { path: '', component: HomeContainerComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

Each Route maps a URL path to a component. There are no leading slashes(/) in the path. The router parses and builds the final URL for you, allowing you to use both relative and absolute paths when navigating between application views.

In app.component.html add <router-outlet></router-outlet>. Then, import AppRoutingModule and HomeModule in app.module.ts.

Child Router in Home Page

Create a child router in the home page as follows. In home-routing.module.ts,

const routes: Routes = [
  {
    path: 'home',
    component: HomeContainerComponent,
    children: [
      { path: '', redirectTo: 'hot', pathMatch: 'full' },
      { path: ':tabLink', component: HomeDetailComponent }
    ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class HomeRoutingModule {}

Beware we use RouterModule.forChild(routes).

When we visit the path like localhost:4200/home/, it will redirectTo hot.

The :tabLink in the second route is a token for a route parameter. In a URL such as /home/toy, “toy” is the value of the id parameter. The corresponding HomeDetailComponent will use that value to find and present the content whose id is toy.

In home-container.component.html, we also need to remove the following code:

<app-image-slider [sliders]="imagesSliders" #imageSlider></app-image-slider>
<app-horizontal-grid>
  <span appGridItem *ngFor="let item of channels">
    <img appGridItemImage="2rem" [src]="item.icon" alt="" />
    <span appGridItemTitle="1rem" class="title">{{ item.title }}</span>
  </span>
</app-horizontal-grid>

and add <router-outlet></router-outlet>.

Because all contents will display in HomeDetailComponent, we need to move the removed code to home-container.component.html.

Params in Router URL

config

{ path: ':tabLink', component: HomeDetailComponent }

Activation

Two way to activate the router.

The tab.link means the link property in interface tab;

<!-- single param -->
<a [routerLink="['/home', tab.link]"]>...</a>

<!-- the params can also carry multi  key-value pairs-->
<a [routerLink="['/home', tab.link, {name: 'val1'}]"]></a>

<!-- the params can also carry query params -->
<a [routerLink="['/home', tab.link, [queryParams]={name: 'val1'}]"]></a>

handle the event in ts file

// single param 
this.router.navigate(['home', tab.link]);

// the params can also carry multi key-value pairs
this.router.navigate(['home', tab.link, {name: 'val1'}]);

// the params can also carry query params
this.router.navigate(['home', tab.link, {queryParams: {name: 'val1'}}]);

Corresponding URL

  • http://localhost:4200/home/sports
  • with key-value pairs: http://localhost:4200/home/sports;name=val1
  • with query params: http://localhost:4200/home/sports?name=val1

Read Params in URL

// with or without key-value pair params
this.route.paramsMap.subscribe(param => {...});

// with query params
this.route.queryParamsMap.subscribe(param => {...});

Router code sample

In home-container.component.html, we use app-scrollable-tab component.

<app-scrollable-tab
  [menus]="topMenus"
  (tabSelected)="handleTabSelected($event)"
  [backgroundColor]="'#fff'"
  titleColor="#3f3f3f"
  titleActiveColor="red"
  indicatorColor="red"
>
</app-scrollable-tab>

<router-outlet></router-outlet>

In HomeContainerComponent, we have a function to navigate the router:

constructor(private router: Router) {}
handleTabSelected(topMenu: TopMenu) {
  this.router.navigate(['home', topMenu.link]);
}

Modify the HomeDetailComponent html:

<div *ngIf="selectedTabLink === 'hot'; else other">
  <app-image-slider [sliders]="imagesSliders" #imageSlider></app-image-slider>
  <app-horizontal-grid>
    <span appGridItem *ngFor="let item of channels">
      <img appGridItemImage="2rem" [src]="item.icon" alt="" />
      <span appGridItemTitle="1rem" class="title">{{ item.title }}</span>
    </span>
  </app-horizontal-grid>
</div>
<ng-template #other>
  Other works
</ng-template>

In ts file:

// get the ActivatedRoute
constructor(private route: ActivatedRoute) {}

  ngOnInit() {
    this.route.paramMap.subscribe(params => {
      console.log('path params: ', params);
      this.selectedTabLink = params.get('tabLink');
    });
    this.route.queryParamMap.subscribe(params => {
      console.log('query params: ', params);
    });
  }

The console output is:


We can have many `router-outlet`, but only one can without name, others must have name.
<a
  [routerLink]="['grand']"
  routerLinkActive="active"
  [queryParams]="{ name: 'Joe', gender: 'male' }"
  >link to grand</a
>

<a [routerLink]="[{ outlets: { second: ['aux'] } }]">link to second</a>
<router-outlet></router-outlet>
<router-outlet name="second"></router-outlet>
{
  path: ':tabLink',
  component: HomeDetailComponent,
  children: [
    { path: 'aux', component: HomeAuxComponent, outlet: 'second' },
    { path: 'grand', component: HomeGrandComponent }
  ]
}

Pipe

The detail about pipe can see https://angular.io/guide/pipes.

Chaining pipes

<div>The chained hero's birthday is
{{ birthday | date | uppercase}}</div>

Custom pipes

See https://angular.cn/guide/pipes#custom-pipes.

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({name: 'appAgo'})
export class AgoPipe implements PipeTransform {
  transform(value: any): any {
    if (value) {
      const seconds = Math.floor((+new Date - +new Date(value)) / 1000);
      if (seconds < 30) {
        return 'A few seconds ago';
      }

      const intervals = {
        years: 3600 * 24 * 365,
        months: 3600 * 24 * 30,
        weeks: 3600 * 24 * 7,
        days: 3600 * 24,
        hours: 3600,
        minutes: 60,
        secondes: 1
      };

      let counter = 0;
      for (const unitName in intervals) {
        if (intervals.hasOwnProperty(unitName)) {
          const unitValue = intervals[unitName];  
          counter = Math.floor(seconds / unitValue);
          if (counter > 0) {
            return `${counter} ${unitName} ago`;
          }           
        }
      }
    }

    return value;
  }
}

Use the Custom pipes:

<p> {\{ date | agoPipe }} </p>
export class HomeGrandComponent implements OnInit {
  date: Date;

  constructor() { }
  
  ngOnInit() {
    this.date = this.minusDays(new Date(), 60);
  }

  minusDays(date: Date, days: number) {
    const result = new Date(date);
    result.setDate(result.getDate() - days);
    return result;
  }

}

Dependency Injection

What is DI?


The instance created through DI is **singleton**. If component A and component B both inject a service, they are using **the same instance**.

In angular, if we want to make some class injectable, we just need to add decorator @Injectable() above that class.

@Injectable()
class Product {
  constructor(private name: string, private color: string) {}
  // the above line combine the field declaration and constructor together.
}

@Injectable()
class PurchaseOrder {
  private amount: number;
  constructor(private product: Product) {}
}

Use it in complex way:

ngOnInit() {
  this.price = 1234.56;
  const injector = Injector.create({
    providers: [
      {
        provide: Product,
        //useClass: Product // inject a class
        // or we can directly inject a specific object using useFactory
        useFactory: () => {
          return new Product('Phone', 'black');
        },
        deps: [] 
      },
      {
        provide: PurchaseOrder,
        useClass: PurchaseOrder,
        deps: [Product]
      }
    ]
  });
  console.log(injector.get(Product));
  console.log(injector.get(PurchaseOrder));
}

Actually, we don’t need to use the complex way. The easy way is:

  1. add @Injectable() to the class
  2. add providers in module.ts
    • if it is useClass, just add the class in the providers array: providers: [PurchaseOrder]

Service

The best practice is to create service for each module to do the data transfer job. Thus, to decouple data and components. Create a home service and see how to use DI to decouple data and our components.

Create home.service.ts:

import { Injectable } from '@angular/core';
import { TopMenu, ImageSlider, Channel } from 'src/app/shared/components';

@Injectable()
export class HomeService {
  menus: TopMenu[] = [...];

  imageSliders: ImageSlider[] = [...];
  
  getTabs() { return this.menus; }

  getBanners() { return this.imageSliders; }

  getChannels() { return this.channels; }
}

In HomeContainerComponent:

constructor(
   private router: Router,
   private service: HomeService,
   @Inject(token) private baseUrl: string
 ) {}

 ngOnInit() {
   this.topMenus = this.service.getTabs();
 }

In HomeDetailComponent:

 constructor(private route: ActivatedRoute, private service: HomeService) {}

ngOnInit() {
  // ..
  this.imagesSliders = this.service.getSliders();
  this.channels = this.service.getChannels();
}

MUST declare this service in .module.ts.

  @NgModule({
  declarations: [...],
  providers: [HomeService],
  imports: [...]
})

transfer a string value

We can also just transfer a value using DI.

ngOnInit() {
  const injector = new Injector.create({
    providers: [{
      {
        provider: 'baseUrl',
        useValue: 'http://localhost'
      }
    }]
  })	
}

We can use a string ‘baseUrl’ to be the indicator of a injectable value, however in large project this may cause conflict. So Angular wants us to use Token as the string type indicator.

const token = new InjectionToken<string>('baseUrl');
const injector = new Injector.create({
  providers: [{
    {
      provide: token,
      useValue: 'http://localhost'
    }
  }]
})

DI a string to other components

  1. export it and declare in .module.ts.
    export const token = new InjectionToken<string>('baseUrl');
providers: [HomeService, { provide: token, useValue: 'http://localhost' }]
  1. use it with @Inject(token)
    constructor(
      @Inject(token) private baseUrl: string
    ) {}

New @Injectable() syntax

Add the decorator at the beginning of the service

@Injectable({
  providedIn: 'root'
})
export class HomeService{}

Thus, we DO NOT need to add this service in providers array in the .module.ts .

Dirty Check (Change Detection)

  • What? view is refreshed when the data is changed.
  • When? when Dirty Check is triggered?
    • browser events (e.g. click, mouseover, keyup,… )
    • setTimeout() and setInterval()
    • HTTP request
  • How? Compare current status and new status.

The Dirty Check (Change Detection) procedure is synchronous. And it is done twice by Angular framework.

Dirty Check (Change Detection) done before AfterViewChecked and AfterViewInit hook, so we should change property values in functions ngAfterViewChecked() and ngAfterViewInit().

子组件的所有的行为依赖于其自己定义的 @Input 属性。 而这个 @Input 属性是父组件在用。换言之,父组件的通过设置 @Input 属性,从而控制了子组件的所有行为。子组件并没有可以控制自己行为的方法。子组件所有的状态,都依赖于父组件的控制。子组件成为一个笨组件。==》 最佳实践:使用该方式,通过onPush策略。

The child component’s status is relay on parent component.

Change Detection done before AfterViewChecked and AfterViewInit hook, so we should change property values in functions ngAfterViewChecked() and ngAfterViewInit().