AngularJS 拾遗(三)

172 阅读12分钟

本文总结了 AngularJS 使用的时候容易忽略的一些坑点。适合学完 Angular 基础并且有一定事件经验的工程师阅读,喜欢的话收藏起来吧,这是一个系列文章,大概在 5 篇左右。

40. 指令

在介绍指令之前,先写一个 css 样式,这个样式覆盖浏览器 input 元素的默认样式,使其被选中的时候不显示边框。

input:focus {
  outline: none;
}

input 表单元素的指令

不同于其它元素上的指令,表单元素上的指令可以通过在构造函数注入ngControl依赖的方式获取其对应的 FormControl 实例。

import { Directive, ElementRef } from '@angular/core';
import { NgControl } from '@angular/forms';

@Directive({
    selector:'[appAnswerHighlight]'
})
export class AnswerHighlightDirective {
    constructor(private el: ElementRef, private controlName: NgControl) {
        this.controlName.control.parent.valueChanges.subscribe(value => {
            console.log(value);
        });
    }
}
<form [formGroup]="mathForm">
  <div class="equation">{{ a }} + {{ b }} =</div>
  <input appAnswerHighlight formControlName="answer" />
</form>

<div class="stats">{{ secondsPerSolution | number:'1.1-3' }}</div>

之前讲了对表单元素状态改变的监听,上述代码中则展示了如何监听表单元素值的改变

41. cookie 鉴权原理

Cookie 鉴权策略是一种常见的身份验证和授权方式,广泛应用于各类网站和应用程序中。以下是 Cookie 鉴权策略的简要叙述:

  1. Cookie 生成与存储:当用户首次访问网站时,服务器会生成一个唯一标识符(Cookie),并通过 HTTP 响应头中的 Set-Cookie 字段发送给用户的浏览器。浏览器接收到 Cookie 后,会将其保存在本地。
  2. Cookie 发送与验证:在后续的访问中,浏览器会自动将之前保存的 Cookie 附加到 HTTP 请求头中发送给服务器。服务器接收到带有 Cookie 的请求后,会根据 Cookie 中的信息进行身份验证和授权操作。
  3. 优势:Cookie 鉴权机制简单易用,用户无需在每次访问时都输入用户名和密码;同时,Cookie 可以跨页面和跨设备传递,保持用户的登录状态。
  4. 安全性与隐私问题:尽管 Cookie 鉴权机制具有诸多优势,但也存在安全性和隐私问题,如 Cookie 被窃取、篡改或过期等。因此,需要采取加密、设置安全标志等措施来提高安全性。

42. 同步表单校验

先睹为快

import { Component, OnInit } from "@angular/core";
import { FormGroup, FormControl, Validators } from "@angular/forms";

@Component({
  selector: "app-signup",
  templateUrl: "./signup.component.html",
  styleUrls: ["./signup.component.css"],
})
export class SignupComponent implements OnInit {
  authForm = new FormGroup({
    username: new FormControl("", [
      Validators.required,
      Validators.minLength(3),
      Validators.maxLength(20),
      Validators.pattern(/^[a-z0-9]+$/),
    ]),
    password: new FormControl(""),
    passwordConfirmation: new FormControl(""),
  });

  constructor() {}

  ngOnInit() {}
}

自定义校验规则

除了使用类的静态方法作为校验准则,我们还可以用类的实例,这种情况下其本质就是实现了 Validator 接口的一个类。

import {
  Validator,
  AbstractControl,
  ValidationErrors,
  NG_VALIDATORS,
} from "@angular/forms";
import { Directive, Input } from "@angular/core";

// 自定义验证器类
export class CustomValidator implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    // 验证逻辑:例如,检查控件的值是否为特定的字符串
    const isInvalid = control.value === "invalidValue";
    return isInvalid ? { customError: true } : null;
  }
}

注意,这里有一点可能与直觉相反,那就是如果我们校验成功,则返回 null,否则返回一个 Object 对象。这里就是用了我自己悟出来的根据返回之类型的不同来表示不同的可能性的问题。

方法或者步骤总结:

根据您提供的信息,以下是一个整理后的关于如何创建基于类的自定义同步验证器的说明:

  1. 创建一个新类: 创建一个新的类来实现您的自定义验证器逻辑。
  2. [可选] 实现 “Validator” 接口: 让这个类实现 Angular 的 Validator 接口。这不是强制性的,但有助于确保您的验证器遵循预期的格式。
  3. 添加 “validate” 方法: 在类中添加一个 validate 方法。这个方法将被传入一个 FormGroupFormControl 实例,用于执行验证逻辑。
  4. 实现验证逻辑: 在 validate 方法中编写自定义的验证逻辑。如果数据有效,方法应该返回 null 。如果数据无效,方法应该返回一个对象,该对象描述了错误。

将校验规则作为依赖注入

在 Angular 中,如果我们需要在一个类中使用另外一个类的实例,那么我们的第一反应就应该是使用其强大的注入系统。因此,既然我们的校验器现在由实例上的方法提供,那么自然而然的,我们就应该使用注入的方式。

在 Angular 中,将一个类变成注入非常的简单,只需要使用 @Injectable 对其进行装饰即可,如下所示:

import {
  Validator,
  AbstractControl,
  ValidationErrors,
  NG_VALIDATORS,
} from "@angular/forms";
import { Directive, Input } from "@angular/core";

// 自定义验证器类
@Injectable({
  providedIn: "root",
})
export class CustomValidator implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    // 验证逻辑:例如,检查控件的值是否为特定的字符串
    const isInvalid = control.value === "invalidValue";
    return isInvalid ? { customError: true } : null;
  }
}

将校验规则进化成指令

对于 input 表单,如果想要表示其必填,则可以写成这样: <input type="password" required /> 我们通过一个 required 而不是在 FormModule 上设置校验规则。那么我们自定义的校验规则能否事先相同的功能呢?我们只需要在上面的代码上多加几行即可定义一个校验指令出来:

// 如果你希望将这个验证器作为指令使用,你可以创建一个指令
@Directive({
  selector: "[appCustomValidator]",
  providers: [
    {
      provide: NG_VALIDATORS,
      useExisting: CustomValidatorDirective,
      multi: true,
    },
  ],
})
export class CustomValidatorDirective extends CustomValidator {}

然后就可以像使用 required 一样使用它了:

<input type="text" formControlName="myFormControl" appCustomValidator />

<form [formGroup]="myFormGroup">
  <input type="text" formControlName="myControlName" appCustomValidator />
</form>

完全体校验类

我们的完全体校验类可以写成如下形式:

import {
  Validator,
  AbstractControl,
  ValidationErrors,
  NG_VALIDATORS,
} from "@angular/forms";
import { Directive, Input } from "@angular/core";

@Injectable({
  providedIn: "root",
})
export class CustomValidator implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    const isInvalid = control.value === "invalidValue";
    return isInvalid ? { customError: true } : null;
  }
}

@Directive({
  selector: "[appCustomValidator]",
  providers: [
    {
      provide: NG_VALIDATORS,
      useExisting: CustomValidatorDirective,
      multi: true,
    },
  ],
})
export class CustomValidatorDirective extends CustomValidator {}

43. 异步表单校验

尽管很少见,但是在制作异步校验函数的时候,我们还是有可能直接使用 HttpClient 的实例而不是包装其的服务的。

同步、异步校验的规则

由于异步校验相对而言比较消耗性能,所以 Angular 已经内置了如下策略:有限进行同步校验,如果通过,才进一步进行异步校验。不仅如此:Angular 中还会对异步校验进行自动的节流,反应在浏览器上就是会可能取消已经发出的网络请求

构建一个异步校验类的步骤

  1. 创建一个新类: 创建一个新的类来实现您的自定义异步验证器逻辑。
  2. [可选] 实现“AsyncValidator”接口: 让这个类实现 Angular 的AsyncValidator接口。这不是强制性的,但有助于确保您的验证器遵循预期的异步格式。
  3. 添加“validate”方法: 在类中添加一个validate方法。这个方法将被传入一个FormGroupFormControl实例,并应返回一个Observable,用于执行异步验证逻辑。
  4. 实现异步验证逻辑: 在validate方法中编写自定义的异步验证逻辑。如果数据有效,Observable应该发出null。如果数据无效,Observable应该发出一个对象,该对象描述了错误。

以下是一个简单的示例代码,展示了如何实现一个基于类的自定义异步验证器:

import {
  AsyncValidator,
  AbstractControl,
  ValidationErrors,
  Observable,
} from "@angular/forms";
import { of } from "rxjs"; // 引入RxJS的of函数来创建Observable

export class CustomAsyncValidator implements AsyncValidator {
  validate(control: AbstractControl): Observable<ValidationErrors | null> {
    // 在这里编写自定义的异步验证逻辑
    // 如果验证通过,使用of(null)来返回一个发出null的Observable
    // 如果验证失败,使用of({ 'customAsyncError': true })来返回一个发出错误对象的Observable
    return of(/* 你的异步验证逻辑,返回null或错误对象 */);
  }
}

校验用户名是否重复的校验方法

下面这段代码完整展示了一个异步的表单校验方法。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AsyncValidator, AbstractControl, ValidationErrors, Observable } from '@angular/forms';
import { map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class UniqueUsernameValidator implements AsyncValidator {
  constructor(private http: HttpClient) {}

  validate(control: AbstractControl): Observable<ValidationErrors | null> {
    const { value } = control;
    return this.http.post<any>(
      'https://api.angular-email.com/auth/username',
      { username: value },
    ).pipe(
      map(response => {
        if (response.available) {
          return null; // 如果用户名可用,则返回null表示没有错误
        } else {
          return { uniqueUsername: false }; // 如果用户名不可用,则返回错误对象
        }
      }),
      catchError(err => {
        console.log(err); // 打印错误信息
        if (err.error && err.error.username) {
          return of({ nonUniqueUsername: true }); // 如果错误中包含username字段,则返回nonUniqueUsername错误
        } else {
          return of({ noConnection: true }); // 否则,返回noConnection错误
        }
      })
    );
  }
}

利用上面的这段代码,我们注意到:网络请求是可能会失败的,所以在整个数据链路中我们必须手动的处理错误,防止网络错误外溢导致程序崩溃,这一点体现在 Observable 处理联调的 catchError 上,但却是和框架无关的,即在 Vue 和 React 或者原生中,也需要采用相同的策略。

44. 表单提交

首先,在 Angular 中,默认情况下,即使校验没有通过也是可以提交的,所以我们的 submit 回调中必须加以处理。

onSubmit(){
  if(this.form.invalid) return;
  this.authService.signup(this.form.value).subscribe(()=>{});
}

提交失败之后手动指定错误原因

我们可以通过 Form 实例的 setErrors 方法手动指定 Form 的状态信息:

this.form.setErrors({
  noConnection: true,
});

同样的,我们需要增加相应的试图层面的内容:

<p *ngIf="form.errors.noConnection">
  No internet connection detected, failed to sign up
</p>

45. 能够主动发出数据的 Observable 对象

使用下面的代码,可以主动发出数据:

const { Subject } = Rx;
const subject = new Subject();

setTimeout(() => {
  subject.next(1);
}, 1000);

subject.subscribe((data) => {
  console.log("data:", data);
});

subject;

你可能会很疑惑为什么要将其包裹在 setTimeout 中。这会牵扯出一个关于 Subject 很重要的信息,那就是使用 Subject 对象发出的信息是 hot 的,如果没有订阅者接受的话数据就消失了,也就是说下面代码是不会打印出数据的:

const { Subject } = Rx;
const subject = new Subject();

subject.next(1);

subject.subscribe((data) => {
  console.log("data:", data);
});

subject;

上面的两组代码充分说明了使用 Subject 是不够的。因为订阅者必须在其发出数据之前已经订阅它,所以我们需要换一个更加强力的工具,需要满足下面三个要求:

  1. 必须像 Subject 那样主动发出数据;
  2. 必须有一个默认的,初始的值;
  3. 就算订阅者在其发出数据之后也能够得到上次的数据。 为了满足上述要求,我们使用 BehaviorSubject 来实现。
const { BehaviorSubject } = Rx;
const subject = new BehaviorSubject("initial value");

subject.subscribe((data) => {
  console.log("before first emiting:", data);
});

subject.next("first emiting");

subject.subscribe((data) => {
  console.log("after first emiting:", data);
});

subject;
before first emiting: initial value
before first emiting: first emiting
after first emiting: first emiting

46. 设计登录、登出功能

基本上的思路为:新用户注册,调用 signup 方法,成功之后浏览器被注入 cookie 同时从接口获取到已经登录成功的指令;接口信息被订阅,订阅回调中使用内置 BehaviorSubject 对象发出 true 指令,其所有订阅者可根据此指令更新视图等。

用户登出,调用网络接口,接口数据被订阅,订阅回调中使用同一个内置 BehaviorSubject 对象发出 false 指令,其所有订阅者可根据此指令更新视图等。

我们定义一个 AuthService 来完成相关的逻辑:

// in service

// 定义一个指令发送者
signedin$ = new BehaviorSubject(false);
rootUrl = 'https://api.angular-email.com';

// 在网络接口的回调中使用它发出最新的指令
signup(credentials: SignupCredentials) {
  return this.http.post<SignResponse>(`${this.rootUrl}/auth/signup`, credentials)
  .pipe(
    tap(()=>{
      this.signedin$.next(true);
    })
  )
}

signedin$ 相当于是秘书,是信息的传递者。

受控的组件只需要订阅 AuthService 实例对象中的 signedin$ 属性值就可以得到最新的指令了。

// in component
signedin = false;

ngOnInit() {
  this.authService.signedin$.subscribe(signedin => {
    this.signedin = signedin;
  })
}

或者我们结合 async 管道可以将上面的过程简化为:

// in component
signedin$: BehaviorSubject<boolean>;

constructor(private authService: AuthService){
  this.signedin$ = this.authService.signedin$;
}

这样一来,我们的组件就可以根据 signedin$ | async 的值判断哪些组件需要展示,而哪些不需要展示了。

<div class="right menu">
  <ng-container *ngIf="signedin$ | async"></ng-container>
  <ng-container *ngIf="!(signedin$ | async)"></ng-container>
</div>

47. 一个小技巧

  1. 我们使用名为 [routerLinkActiveOptions]="{exact: true}" 的指令及其值来保证路由的匹配是精确匹配。
  2. 使用 routerLink="/" 让锚点和按钮能够进行路由跳转。
  3. 使用 routerLinkActive="active" 在路由匹配成功之后在相应的组件上添加名为 active 的类名。

48. 登录或者登出的路由

PathMethodBodyDescription
/auth/signupPOST{username: String, password: String, passwordConfirmation: String}Signs up for a new account with the provided username, password, and password confirmation.
/auth/signinPOST{username: String, password: String}Signs in with the provided username and password.
/auth/usernamePOST{username: String}Checks to see if a username is already in use.
/auth/sianedinGET-Checks to see if the user is currently signed in.
/auth/signoutPOST{}Signs the user out.

可以看出,我们登出的时候使用一个 get 请求,那么服务器是怎么知道是谁登出了呢?实际上用户信息通过 cookie 传递给了服务器,而这些 cookie 信息则是请求 /auth/signup 或者 /auth/signin 的时候被服务器写入浏览器的。

49. Angular 的 HttpClient 类的一个问题

使用 HttpClient 进行网络通信的时候需要对其进行配置,之前我们是这样发送请求的:

signup(credentials: SignupCredentials) {
  return this.http.post<SignResponse>(`${this.rootUrl}/auth/signup`, credentials)
  .pipe(
    tap(()=>{
      this.signedin$.next(true);
    })
  )
}

这样,即使登录成功,服务器也无法将 cookie 写入浏览器,原因在于 HttpClient 会将响应报文中的所有 set-cookie 头都过滤掉,这样一来,服务器就无法给浏览器写 cookie 了,我们通过如下的配置更改这一行为:

signup(credentials: SignupCredentials) {
  return this.http.post<SignResponse>(`${this.rootUrl}/auth/signup`, credentials, {
    withCredentials: true,
  })
  .pipe(
    tap(()=>{
      this.signedin$.next(true);
    })
  )
}

withCredentials: true 就是告诉 Angular 保留 cookie 注入的功能。

理论上,我们在很多请求中都需要配置 withCredentials: true 如果我们每一个都写那是非常烦人的,为了一次性解决问题,我们使用 网络请求、响应拦截器 来统一添加此配置。

50. 网络拦截器

使用下面的方法我们为所有网络请求增加相同的配置。

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpHeaders } from '@angular/common/http';

@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // 克隆请求并修改请求头,添加凭证信息
    const modifiedReq = req.clone({
      withCredentials: true,
      // headers: req.headers.set('Authorization', 'Bearer ' + this.getToken())
    });

    // 调用下一个拦截器或最终的后端
    return next.handle(modifiedReq);
  }

  private getToken(): string {
    // 这里应该是获取token的逻辑,例如从localStorage获取
    // 示例代码,实际使用时需要根据实际情况进行调整
    return localStorage.getItem('token') || '';
  }
}

实际上,在 Angular 中我们可以将网络请求拦截器和响应拦截器写在一起,类似于洋葱模型,如下所示:

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent,
} from "@angular/common/http";
import { tap } from "rxjs/operators";

@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    // 克隆请求并设置凭证
    const modifiedReq = req.clone({
      withCredentials: true,
      // 这里可以添加其他请求头,例如 Authorization
    });

    // 通过管道操作符处理请求和响应
    return next.handle(modifiedReq).pipe(
      tap((event) => {
        if (event.type === HttpEventType.Sent) {
          console.log("Request was sent to server");
        }
        if (event.type === HttpEventType.Response) {
          console.log("Got a response from the API", event);
        }
      }),
      catchError((error) => {
        // 可以在这里处理错误
        console.error("There was an error!", error);
        throw error; // 重新抛出错误以便调用栈可以继续处理
      })
    );
  }
}

在解决 cookie 无法写入问题之后,我们就可以仿照 signup 方法写出 checkAuth 方法来:

checkAuth() {
  return this.http.get<SignedinResponse>(`${this.rootUrl}/auth/signedin`).pipe(
    tap(({ authenticated }) => {
      this.signedin$.next(authenticated);
    })
  )
}

同理写出登出的方法:

signout() {
  return this.http.post(`${this.rootUrl}/auth/signout`, {})
    .pipe(
      tap(()=>{
        this.signedin$.next(false);
      })
    )
}

51. 更加优雅的登出方式

比起调用 AuthService 中的 signout 函数,在企业级项目中,我们更加常用的方式是通过路由的方式登出,具体来说:

  1. 我们点击登出按钮之后,跳转到 /signout 这个路由,同时加载对应的组件。
  2. 在这个登出路由对应的组件的构造函数中我们再调用 AuthService 中的 signout 方法。
  3. 调用 signout 方法之后再跳转到根路由。
import { Component, OnInit } from '@angular/core';
import { AuthService } from './auth.service'; // 假设 AuthService 位于此路径
import { Router } from '@angular/router';

@Component({
  selector: 'app-signout',
  templateUrl: './signout.component.html',
  styleUrls: ['./signout.component.css']
})
export class SignoutComponent implements OnInit {
  constructor(
    private authService: AuthService,
    private router: Router
  ) {}

  ngOnInit() {
    this.authService.signout().subscribe(() => {
      this.router.navigateByUrl('/');
    });
  }
}

这样做的好处是实现了逻辑的封装,只要我们来到 /signout 这个路由我们就会退出,并且只有这一个出口;同时在登出的时候给了一个页面,虽然时间很短也提升了交互性。

52. 路由守卫

在鉴权机制搭建起来的基础之上,我们就可以使用 Angular 中提供的另外一大工具 -- 路由守卫了。我们使用路由守卫对任何路由跳转进行鉴权,如果鉴权失败就不发生跳转。这听起来很简单,但是实际上这里涉及到一个非常大的坑,且听我细细道来。

52.1 先看一个路由守卫的基本结构

尽管路由守卫实现的 canLoad 接口的返回值可以是 boolean Promise<boolean> 或者 Observable<boolean> 类型的,但是使用 Observable 类型的能够赋予我们更加灵活和强大的手段。

import { Injectable } from '@angular/core';  
import { CanLoad, Route, UrlSegment, Router } from '@angular/router';  
import { Observable } from 'rxjs';  
  
@Injectable({  
  providedIn: 'root'  
})  
export class AuthGuard implements CanLoad {  
  
  constructor(private router: Router) {}  
  
  canLoad(  
    route: Route,  
    segments: UrlSegment[]  
  ): Observable<boolean> | Promise<boolean> | boolean {  
    // 这里只是一个简单的示例,实际项目中你可能需要根据路由或其他条件来决定是否允许加载  
    return new Observable((subscriber) => {  
      // 假设检查用户是否登录,然后决定是否允许加载  
      // 这里直接返回true作为示例  
      subscriber.next(true);  
      subscriber.complete();  
    });  
  }  
}

52.2 坑来了

使用 Observable<boolean> 有一个要求,那就是只有等到 Observable 的状态为 completed 的时候,canLoad 才会处理,而在此之前的等待时间中我们的页面就会一直卡在跳转的过程中,假如我们将 canLoad 写成如下形式,那么我们的跳转就永远不会完成。因为没有 completed.

  canLoad(  
    route: Route,  
    segments: UrlSegment[]  
  ): Observable<boolean> | Promise<boolean> | boolean {  
    return new Observable((subscriber) => {  
      subscriber.next(true);  
    });  
  }  
  1. 用户导航到根路由('/')。
  2. App 组件调用 AuthService 中的 'checkLogin' 方法。
  3. 'checkLogin' 方法发出请求,以检查用户是否已登录。
  4. 等待请求完成...
  5. AuthService 通过其 'signedin$' 可观察对象发出 'true''false',表示用户是否已登录。

既然我们能否跳转根据的是 'signedin$' 可观察对象发出的值,那么你就不能要求数据流变成 completed 状态,由此我们引出三个坑点:

从你提供的信息中,我可以识别出关于 Auth Guard(认证守卫)的几个问题,并整理出相应的解决策略。以下是整理后的内容:

  1. signedin$(BehaviorSubject)不可能是结束状态(complete)。
  2. 如果守卫在 checkAuth 函数完成之前运行,我们将提供默认的false值,从而将用户标记为未认证。
  3. 如果我们将 checkAuth 调用移动到守卫中,那么如果用户只是加载另一个路由,我们可能会面临根本不运行该函数的风险。

解决策略

  1. 使用一些 RxJs 的技巧来标记 BehaviorSubject 为完成。
  2. 更改行为主题的默认值为 null(或者更合适的值,取决于你的业务逻辑)。
  3. checkAuth 调用保留在 App 组件中。

详细解决方案

  1. 对于第一点:我们使用 skipWhile 和 take 操作符根据无限流 mock 一个具有 completed 状态的有限流来。
  2. 对于第二点:我们的 signedin$ 现在有三种取值:null(表示我们的鉴权从未运行过), true(表示用户登录), false(表示明确的知道用户未登录)
import { Observable } from 'rxjs';  
import { skipWhile, take, tap } from 'rxjs/operators';  
import { Route, UrlSegment, Router } from '@angular/router';  
import { AuthService } from './auth.service'; // 假设AuthService的路径和名称  
  
// 假设这个函数定义在某个服务或守卫中  
canLoad(route: Route, segments: UrlSegment[]): Observable<boolean> | Promise<boolean> | boolean {  
  return this.authService.signedin$.pipe(  
    skipWhile(value => value === null),  
    take(1),  
    tap((authenticated) => {  
      if (!authenticated) {  
        this.router.navigateByUrl('/');  
      }  
    })  
  );  
}

小 tip:

我们一般将专用的 service 直接放在相关 module 目录下面,如下所示:

├── email-create  
├── email-index  
├── email-reply  
├── email-show  
├── home  
├── email.service.spec.ts  
├── email.service.ts  
├── inbox-routing.module.ts  
└── inbox.module.ts

53. 组件使用服务接受信息的时候不好的写法和好的写法

53.1 不好的写法

import { Component, OnInit } from '@angular/core';  
import { EmailService } from './email.service'; // 假设EmailService在这个路径  
  
@Component({  
  selector: 'app-email-index',  
  templateUrl: './email-index.component.html',  
  styleUrls: ['./email-index.component.css']  
})  
export class EmailIndexComponent implements OnInit {  
  emails = [];  
  
  constructor(private emailService: EmailService) {}  
  
  ngOnInit() {  
    this.emailService.getEmails().subscribe((emails) => {  
      this.emails = emails;  
    });  
  }  
}

53.2 好的写法

import { Component, OnInit, OnDestroy } from '@angular/core';  
import { Subscription } from 'rxjs';  
import { EmailService } from './email.service';  
import { Email } from './email.model'; // 假设有一个Email模型  
  
@Component({  
  selector: 'app-email-index',  
  templateUrl: './email-index.component.html',  
  styleUrls: ['./email-index.component.css']  
})  
export class EmailIndexComponent implements OnInit, OnDestroy {  
  emails: Email[] = [];  
  private subscription: Subscription = new Subscription();  
  
  constructor(private emailService: EmailService) {}  
  
  ngOnInit() {  
    this.subscription = this.emailService.getEmails().subscribe({  
      next: (emails: Email[]) => {  
        this.emails = emails;  
      },  
      error: (err) => {  
        console.error('Failed to load emails:', err);  
      }  
    });  
  }  
  
  ngOnDestroy() {  
    this.subscription.unsubscribe();  
  }  
}

54. 占位子路由的简洁写法

像下面这种根和子路由的 path 都是 '' 的是可以的,这就避免了我们写 redirectTo 相关。

import { Routes } from '@angular/router';  
import { HomeComponent } from './home.component';  
import { EmailShowComponent } from './email-show.component';  
import { PlaceholderComponent } from './placeholder.component';  
  
const routes: Routes = [  
  {  
    path: '',  
    component: HomeComponent,  
    children: [  
      { path: ':id', component: EmailShowComponent },  
      { path: '', component: PlaceholderComponent } // 假设您想为默认子路由设置 PlaceholderComponent  
    ]  
  }  
];  
  
export const ROUTES = routes;

55. 深度双向绑定

我们的双向绑定的深度可以是很深的,如下说是:

<div class='ui celled list'>  
  <div class="item" *ngFor="let email of emails" [routerLink]="['/email', email.id]">  
    <a [routerLink]="email.id">Deep two ways binding</a>
  </div>  
</div>

56. Behavior Subject 出没

ActivatedRoute 类型中的 url params queryparams fragment data 等属性值的类型都是 Behavior Subject.

57. 将组件渲染到代码指定的元素中的技巧

通过 ElementRef 类上的实例,我们可以将标签渲染到我们指定的元素中,当然它可以是 body 元素。

import { Component, OnInit, ElementRef } from '@angular/core';

@Component({
  selector: 'app-modal',
  templateUrl: './modal.component.html',
  styleUrls: ['./modal.component.css']
})
export class ModalComponent implements OnInit {
  @Output()dismiss = new EventEmitter();

  constructor(private el: ElementRef) {}

  ngOnInit() {
    // 请注意,直接操作 DOM 不是 Angular 推荐的做法
    // Angular 有更声明式的方法来处理组件的动态添加和删除
    document.body.appendChild(this.el.nativeElement);
  }

  ngOnDestroy() {
    // 同样,这里直接从 DOM 中移除元素
    // 但在实际的 Angular 应用中,通常不需要这样做
    this.el.nativeElement.remove();
  }

  onDismissClick(){
    this.dismiss.emit();
  }
}

58. 双管齐下

当我们从路由中获取信息的时候,如果使用快照则可以以同步方式获取信息,而如果我们使用 Observable 的方式,则可以源源不断或者说及时更新路由信息。

那么我们当然可以将它们结合起来,如下所示:

  1. 我们在 ngOnInit 钩子函数中订阅路由信息
ngOnInit() {
    this.route.params.subscribe((params: { id: string }) => {
      console.log(id);
    });
}
  1. 我们也可以在 constructor 中直接获取档期那的路由信息
constructor(private route: ActivatedRoute) {
  const { id } = this.route.snapshot.params;
  console.log(id);
}
  1. 如果我们想要使用路由上的一些信息发出网络请求,则进入了下面这种场景:从一个 Observable 到另一个 Observable 情况。此时我们应该使用 switchMap,这很重要。
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { switchMap } from 'rxjs/operators';
import { EmailService } from './email.service'; // 假设这是你的服务

interface Email {
  // 假设的 Email 接口,根据实际结构进行调整
  id: string;
  subject: string;
  content: string;
  // 其他可能的属性...
}

@Component({
  selector: 'app-your-component',
  template: `...` // 你的组件模板
})
export class YourComponent implements OnInit {
  email: Email; // 声明一个属性来存储邮件数据

  constructor(
    private route: ActivatedRoute,
    private emailService: EmailService
  ) {}

  ngOnInit() {
    this.route.params
      .pipe(
        switchMap((params: { id: string }) => {
          return this.emailService.getEmail(params.id);
        })
      )
      .subscribe((email: Email) => {
        this.email = email;
      });
  }
}

同步+异步获取组件数据

经过 resolver 处理之后,我们可以直接在构造函数中获取到组件所需的数据。

constructor(
  private route: ActivatedRoute,
  private emailService:EmailService
){
  console.log(this.route.snapshot.data);
}

当然,也可以使用订阅的方式:

this,route.data.subscribe(data =>{
  console.log(data);
});

或者将它们结合起来:

constructor(
  private route: ActivatedRoute,
  private emailService: EmailService
){
  this.email= this.route.snapshot.data.email;i
  this.route.data.subscribe(data => {
    console.log(data);
  })
}

小 tip: resolver 中不亲自发起网络请求,仍然由 service 代劳,毕竟使用服务的目的就是为了让我们的 component 变得干净:

import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';
import { ActivatedRouteSnapshot } from '@angular/router';
import { EmailService } from './email.service'; // 假设这是你的服务

interface Email {
  // 假设的 Email 接口,根据实际结构进行调整
  id: string;
  subject: string;
  content: string;
  // 其他可能的属性...
}

@Injectable({
  providedIn: 'root', // 使服务在根 injector 中可用
})
export class EmailResolverService implements Resolve<Email> {
  constructor(private emailService: EmailService) {}

  resolve(route: ActivatedRouteSnapshot) {
    const { id } = route.params; // 从路由参数中获取 id
    return this.emailService.getEmail(id); // 使用服务获取电子邮件数据
  }
}

59. Module 中的接口

在一个单独的模块中,我们通常会独立的写出接口文件来:ng g interface inbox/Email.

60. 错误页

当我们使用网络请求的时候,它当然有可能会失败,这个时候我们采用的策略就是跳转到预置的错误页,为此,我们可以给每一个 Module 设置一个 NotFound 路由,一旦发生错误,我们就使用代码跳转到这个路由中去。

61. EMPTY

使用 EMPTY 的铁律:一个函数需要返回 Observable 类型的返回值,但是却不关心这个值具体是多少,而只是为了满足类型上的一直和正确,这个时候就可以直接返回 EMPTY.

62. 公共组件模块的导入导出示范

下面是一个公共模块(shared.module.ts)向外提供公共组件的示范代码:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { InputComponent } from './input/input.component';
import { ModalComponent } from './modal/modal.component';

@NgModule({
  declarations: [InputComponent, ModalComponent],
  imports: [CommonModule, ReactiveFormsModule],
  exports: [InputComponent, ModalComponent]
})
export class SharedModule {}

也就是比 React 多了一层而已。

63. 设计模态框(Modal)的架构

当设计自定义模态框(Modal)时,其架构设计对于确保用户界面的直观性和交互的流畅性至关重要。以下是实现点击按钮触发模态框的一个高效架构策略:

  1. 组件整合:将模态框和触发它的按钮设计为同一个组件的一部分。这样做可以保持逻辑的一致性并简化状态管理。在此架构中,按钮始终可见,用于明确指示用户可以进行的操作;而模态框则根据条件进行显示,以避免占用不必要的视图空间。
  2. 布局考量:按钮应保留在文档流中,以便它自然地融入页面布局并保持页面的连贯性。相对地,模态框应采用 fixed 布局,这样它就可以在不滚动页面的情况下,覆盖在页面的其它内容之上。固定定位允许模态框从视图中的其它元素独立出来,使用户注意力集中在模态框展示的内容上。
  3. 交互设计:按钮应设计为清晰地传达其功能,比如通过标签或图标。当用户点击按钮时,模态框应该以非侵入性的方式出现,比如使用动画效果平滑地进入视图,增强用户体验。
  4. 可访问性:确保模态框遵循无障碍设计原则,例如,提供键盘可访问性和适当的 ARIA 属性,以确保所有用户都能方便地使用。
  5. 灵活的触发条件:虽然按钮是触发模态框的一种方式,但架构应该允许灵活定义其它触发条件,比如悬停或聚焦某个元素。
  6. 关闭机制:提供明确且容易访问的关闭模态框的方法,比如在模态框内部设置一个关闭按钮,或者通过点击模态框外部区域来关闭。

通过遵循这些架构准则,我们可以创建一个既美观又功能完备的模态框,它不仅能够提升用户体验,还能无缝集成到现有的应用架构中。

Modal 中的表单

一般来说我们会将一个表单组件封装之后再放在 Modal 中,下面是一个简单的示意:

import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';

interface Email {
  subject: string;
  from: string;
  to: string;
  text: string;
  // 可以根据需要添加更多属性
}

@Component({
  selector: 'app-email-form',
  template: `
    <!-- 这里可以放置表单的 HTML 结构 -->
  `
})
export class EmailFormComponent implements OnInit {
  emailForm: FormGroup;
  @Input() email: Email; // 使用 @Input() 接收父组件传入的 Email 对象

  constructor() {}

  ngOnInit() {
    // 使用 email 对象的属性初始化 FormGroup
    const { subject, from, to, text } = this.email;
    this.emailForm = new FormGroup({
      to: new FormControl(to),
      from: new FormControl(from),
      subject: new FormControl(subject), // 属性名应保持一致
      text: new FormControl(text)
    });
  }

  onSubmit(){
    if(this.emailForm.invalid) return;

    console.log(this.emailForm.value);
  }
}

64. 统一的组件库

Ant Design 为 AngularJS 提供了风格统一的样式组件库,称为:NG-ZORRO 其官方地址为:https://ng.ant.design/components/form/zh 这样我们的网页可以使用不同的框架完成相同的风格样式。

65. 一种向封装的 input 组件中传递 control 的方法

如下所示,当我们需要向封装起来的组件中传递 control handle 的时候,是非常灵活的,我们可以将一部分逻辑以内联的方式写在模板中,而不必为此制作一个函数:

<form [formGroup]="emailForm">
  <app-input [control]="emailForm.get('to')" inputType="email" label="To"></app-input>
</form>

66. AngularJS 中的表单禁用和问题

我们使用如下的方式在表单 module 上面指定某个表单元素是 disabled:

this.emailForm =new FormGroup({
  to: new FormControl(to, [Validators.required, Validators.email])
  from: new FormControl({ value: from, disabled:true }),
})

这样做的话会出现一个问题:如果我们指定某个 control 为 disabled. 那么在使用 .value 获取表单数据的时候,被禁用的元素的值是获取不到的,也就是说:console.log(this.emailForm.value); 打印不出 from 字段的值。

这个时候,我们采用下面的方法解决问题:this.emailForm.getRawValue();

67. Angular 中的 props 不是只读的

这一点和 React Vue 都不同,在 Angular 中 prop 的值是可以改变的,原因很简单,那就是 @Input 已经映射了,只不过简写成同名很容易被忽略罢了,相当于 this.name = props.name

import { Component, OnInit, Input } from '@angular/core';

interface Email {
  from: string;
  to: string;
  subject: string;
  text: string;
  // 其他可能的属性...
}

@Component({
  selector: 'app-email-component',
  template: `<!-- 组件模板 -->`,
  // styleUrls: ['./email-component.css'],
})
export class EmailComponent implements OnInit {
  @Input() email: Email; // 输入属性,用于接收外部传入的Email对象

  constructor() {}

  ngOnInit() {
    // 处理email对象,格式化文本并更新属性
    const text = this.email.text.replace(/\n/gi, '\n> '); // 替换换行符为带缩进的换行符
    this.email = {
      ...this.email,
      from: this.email.to, // 将发件人设置为原收件人
      to: this.email.from, // 将收件人设置为原发件人
      subject: `RE: ${this.email.subject}`, // 重新设置主题,添加"RE:"前缀
      text: `\n\n\n---\nFrom: ${this.email.from}\nTo: ${this.email.to}\nSubject: ${this.email.subject}\n\n> ${text}\n` // 格式化文本,添加引用回复的头部信息
    };
    // 假设存在一个onSubmit方法用于提交更新后的email对象
    // this.onSubmit(this.email);
  }

  // 假设的onSubmit方法,用于处理表单提交
  onSubmit(email: Email) {
    // 处理提交逻辑,例如发送更新后的Email对象
  }
}

上面的代码中,我们对 this.email 的值进行了修改。