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
- GrandComponent
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
andGrandGrandComponent
, 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
- ChildComponent
Content Projection
Using ng-content
to 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
;
use routerLink in template(html file).
<!-- 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:
- add
@Injectable()
to the class - add
providers
inmodule.ts
- if it is
useClass
, just add the class in theproviders
array:providers: [PurchaseOrder]
- if it is
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
- export it and declare in
.module.ts
.export const token = new InjectionToken<string>('baseUrl');
providers: [HomeService, { provide: token, useValue: 'http://localhost' }]
- 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()
.