Angular: 最佳实践

·  阅读 641
Angular: 最佳实践

Note: 本文中,我将尽量避免官方在 Angular Style Guide 提及的模式和有用的实践,而是专注我自己的经验得出的东西,我将用例子来说明。如果你还没读过官网指引,我建议你在阅读本文之前读一下。因为官网涵盖了本文很多没介绍的东西。

本文将分为几个章节来讲解,这些章节根据应用核心需求和生命周期来拆分。现在,我们开始吧!

类型规范 Typing

我们主要是用 TypeScript 去编写 Angular(也许你只是用 JavaScript 或者谷歌的 Dart 语言去写),Angular 被称为 TYPEScript 也是有原因的。我们应该为我们数据添加类型限定,下面有些有用的知识点:

使用类型联合和交集官网解释了如何使用 TS 编译器组合类型以轻松工作。这在处理来自 RESTful API 数据的时非常有用。如下例子:

interface User {
  fullname: string,
  age: number,
  createDate: string | Date
}
复制代码

上面 createdDate 字段的类型不是 JS Date 就是字符串。这很有用,因为当服务端提供一个 User 实例数据给你,它只能返回字符串类型的时间给你,但是你可能有一个 datepicker 控件,它将日期作为有效的 JS Date 对象返回,并且为了避免数据被误解,我们需要在 interface 里面可选指明。

限制你的类型。在 TypeScript 中,你可以限制字段的值或者变量的值,比如:

interface Order {
  status: 'pending' | 'approved' | 'rejected'
}
复制代码

这实际上变成了一个标志。如果我们有一个 Order 类型的变量,我们只能将这三个字符串中的一个分配给 status 字段,分配其他的类型 TS 编辑器都会跑出错误。

enum Statuses {
  Pending = 1,
  Approved = 2,
  Rejected = 3
}

interface Order {
  status: Statuses;
}
复制代码

考虑设置 noImplicitAny: true。在应用程序的 tsconfig.json 文件中,我们可以设置这个标志,告诉编辑器在未明确类型时候抛出错误。否则,编辑器坚定它无法推断变量的类型,而认为是 any 类型。实际情况并非如此,尽管将该标志设置为 true 会导致发生意想不到的复杂情况,当会让你的代码管理得很好。

严格类型的代码不容易出错,而 TS 刚好提供了类型限制,那么我们得好好使用它。

组件 Component

组件是 Angular 的核心特性,如果你设法让它们被组织得井井有条,你可以认为你工作已经完成了一半。

考虑拥有一个或者几个基本组件类。如果你有很多重复使用的内容,这将很好用,我们可不想讲相同的代码编写多次吧。假设有这么一个场景:我们有几个页面,都要展示系统通知。每个通知都有已读/未读两种状态,当然,我们已经枚举了这两种状态。并且在模版中的每个地方都会显示通知,你可以使用 ngClass 设置未通知的样式。现在,我们想将通知的状态与枚举值进行比较,我们必须将枚举导入组件。

enum Statuses {
  Unread = 0,
  Read = 1
}

@Component({
  selector: 'component-with-enum',
  template: `
    <div *ngFor="notification in notifications" 
        [ngClass]="{'unread': notification.status == statuses.Unread}">
      {{ notification.text }}
    </div>
`
})
class NotificationComponent {
  notifications = [
    {text: 'Hello!', status: Statuses.Unread},
    {text: 'Angular is awesome!', status: Statuses.Read}
  ];
  statuses = Statuses
}
复制代码

这里,我们为每个包含未读通知的 HTML 元素添加了 unread 类。注意我们是怎么在组件类上创建一个 statuses 字段,以便我们可以在模版中使用这个枚举。但是假如我们在多个组件中使用这个枚举呢?或者假如我们要在不同的组件使用其他枚举呢?我们需要不停创建这些字段?这似乎很多重复代码。我们看看下面例子:

enum Statuses {
  Unread = 0,
  Read = 1
}

abstract class AbstractBaseComponent {
  statuses = Statuses;
  someOtherEnum = SomeOtherEnum;
  ... // lots of other reused stuff
}

@Component({
  selector: 'component-with-enum',
  template: `
    <div *ngFor="notification in notifications" 
        [ngClass]="{'unread': notification.status == statuses.Unread}">
      {{ notification.text }}
    </div>
`
})
class NotificationComponent extends AbstractBaseComponent {
  notifications = [
    {text: 'Hello!', status: Statuses.Unread},
    {text: 'Angular is awesome!', status: Statuses.Read}
  ];
}
复制代码

所以,现在我们有一个基本组件(实际上就是一个容器),我们的组件可以从中派生以重用应用程序的全局值和方法。

另一种情况经常在 forms 表单中被发现。如果在你的 Angular 组件中有个表单,你可能有像这样的字段或者方法:

@Component({
  selector: 'component-with-form',
  template: `...omitted for the sake of brevity`
})
class ComponentWithForm extends AbstractBaseComponent {
  form: FormGroup;
  submitted: boolean = false; // a flag to be used in template to indicate whether the user tried to submit the form
  
  resetForm() {
    this.form.reset();
  }
  
  onSubmit() {
    this.submitted = true;
    if (!this.form.valid) {
      return;
    }
    // perform the actual submit logic
  }
}
复制代码

当然,如果你正在大量组件中使用 Angular 表单,那么将这些逻辑移动到一个基础类会更友好...但是你不需要继承 AbstractBaseComponent,因为不是每个组件都有 form 表单。像下面这样做比较好:

abstract class AbstractFormComponent extends AbstractBaseComponent {
  form: FormGroup;
  submitted: boolean = false; // a flag to be used in template to indicate whether the user tried to submit the form

  resetForm() {
    this.form.reset();
  }

  onSubmit() {
    this.submitted = true;
    if (!this.form.valid) {
      return;
    }
  }
}

@Component({
  selector: 'component-with-form',
  template: `...omitted for the sake of brevity`
})
class ComponentWithForm extends AbstractFormComponent {
  
  onSubmit() {
    super.onSubmit();
    // continue and perform the actual logic
  }
  
}
复制代码

现在,我们为使用表单的组件创建了一个单独的类(注意:AbstractFormComponent 是如何继承 AbstractBaseComponent ,因此我们不会丢失应用程序的值)。这是一个不错的示范,我们可以在真正需要的地方广泛使用它。

容器组件。 这可能有些争议,但是我们仍然可以考虑它是否适合我们。我们知道一个路由对应一个 Angular 组件,但是我推荐你使用容器组件,它将处理数据(如果有数据需要传递的话)并将数据传递给另外一个组件,该组件将使用输入所包含的真实视图和 UI 逻辑。下面就是一个例子:

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



@Component({
  selector: 'user-container-component',
  template: `<app-user-component [user]="user"></app-user-component>`
})
class UserContainerComponent {
  
  constructor(userService: UserService) {}
  ngOnInit(){
    this.userService.getUser().subscribe(res => this.user = user);
    /* get the user data only to pass it down to the actual view */
  }
  
}

@Component({
  selector: 'app-user-component',
  template: `...displays the user info and some controls maybe`
})
class UserComponent {
  @Input() user;
}
复制代码

在这里,容器执行数据的检索(它也可能执行一些其他常见的任务)并将实际的工作委托给另外一个组件。当你重复使用同一份 UI 并再次使用现有的数据时,这可能派上用场,并且是关注点分离的一个很好的例子。

小经验:当我们在带有子元素的 HTML 元素上编写 ngFor 指令时,请考虑将该元素分离为单独的组件,就像下面:

<-- instead of this -->
<div *ngFor="let user of users">
  <h3 class="user_wrapper">{{user.name}}</h3>
  <span class="user_info">{{ user.age }}</span>
  <span class="user_info">{{ user.dateOfBirth | date : 'YYYY-MM-DD' }}</span>
</div>

<-- write this: -->

<user-detail-component *ngFor="let user of users" [user]="user"></user-detail-component>
复制代码

这在父组件中写更少的代码,让后允许委托任何重复逻辑到子组件。

服务 Services

服务是 Angular 中业务逻辑存放和数据处理的方案。拥有提供数据访问、数据操作和其他可重用逻辑的结构良好的服务非常重要。所以,下面有几条规则需要考虑下:

有一个 API 调用的基础服务类。将简单的 HTTP 服务逻辑放在基类中,并从中派生 API 服务。像下面这样:

abstract class RestService {

  protected baseUrl: 'http://your.api.domain';

  constructor(private http: Http, private cookieService: CookieService){}

  protected get headers(): Headers {
    /*
    * for example, add an authorization token to each request,
    * take it from some CookieService, for example
    * */
    const token: string = this.cookieService.get('token');
    return new Headers({token: token});
  }

  protected get(relativeUrl: string): Observable<any> {
    return this.http.get(this.baseUrl + relativeUrl, new RequestOptions({headers: this.headers}))
      .map(res => res.json());
    // as you see, the simple toJson mapping logic also delegates here
  }
  
  protected post(relativeUrl: string, data: any) {
    // and so on for every http method that your API supports
  }

}
复制代码

当然,你可以写得更加复杂,当用法要像下面这么简单:

@Injectable()
class UserService extends RestService {

  private relativeUrl: string = '/users/';

  public getAllUsers(): Observable<User[]> {
    return this.get(this.relativeUrl);
  }
  
  public getUserById(id: number): Observable<User> {
    return this.get(`${this.relativeUrl}${id.toString()}`);
  }

}
复制代码

现在,你只需要将 API 调用的逻辑抽象到基类中,现在就可以专注于你将接收哪些数据以及如何处理它。

考虑有方法(Utilites)服务。有时候,你会发现你的组件上有一些方法用于处理一些数据,可能会对其进行预处理或者以某种方式进行处理。示例可能很多,比如,你的一个组件中可能具有上传文件的功能,因此你需要将 JS File 对象的 Array 转换为 FormData 实例来执行上传。现在,这些没有涉及到逻辑,不会以任何的方式影响你的视图,并且你的多个组件中都包含上传文件功能,因此,我们要考虑创建 Utilities 方法或者 DataHelper 服务将此类功能移到那里。

使用 TypeScript 字符串枚举规范 API url。你的应用程序可以和不同的 API 端进行交互,因此我们希望将他们移动到字符串枚举中,而不是在硬编码中体现,如下:

enum UserApiUrls {
  getAllUsers = 'users/getAll',
  getActiveUsers = 'users/getActive',
  deleteUser = 'users/delete'
}
复制代码

这能更好得了解你的 API 是怎么运作的。

尽可能考虑缓存我们的请求Rx.js 允许你去缓存 HTTP 请求的结果(实际上,任何的 Observable 都可以,但是我们现在说的是 HTTP 这内容),并且有一些示例你可能想要使用它。比如,你的 API 提供了一个接入点,返回一个 Country 对象 JSON 对象,你可以在应用程序使用这列表数据实现选择国家/地区的功能。当然,国家不会每天都会发生变更,所以最好的做法就是拉取该数据并缓存,然后在应用程序的生命周期内使用缓存的版本,而不是每次都去调用 API 请求该数据。Observables 使得这变得很容易:

class CountryService {
  
  constructor(private http: Http) {}

  private countries: Observable<Country[]> = this.http.get('/api/countries')
    .map(res => res.json())
    .publishReplay(1) // this tells Rx to cache the latest emitted value
    .refCount(); // and this tells Rx to keep the Observable alive as long as there are any Subscribers

  public getCountries(): Observable<Country[]> {
    return this.countries;
  }
  
}
复制代码

所以现在,不管什么时候你订阅这个国家列表,结果都会被缓存,以后你不再需要发起另一个 HTTP 请求了。

模版 Templates

Angular 是使用 html 模版(当然,还有组件、指令和管道)去渲染你应用程序中的视图 ,所以编写模版是不可避免的事情,并且要保持模版的整洁和易于理解是很重要的。

从模版到组件方法的委托比原始的逻辑更难。请注意,这里我用了比原始更难的词语,而不是复杂这个词。这是因为除了检查直接的条件语句之外,任何逻辑都应该写在组件的类方法中,而不是写在模版中。在模版中写 *ngIf=”someVariable === 1” 是可以的,其他很长的判断条件就不应该出现在模版中。

比如,你想在模版中为未正确填写表单控件添加 has-error 类(也就是说并非所有的校验都通过)。你可以这样做:

@Component({
  selector: 'component-with-form',
  template: `
        <div [formGroup]="form"
        [ngClass]="{
        'has-error': (form.controls['firstName'].invalid && (submitted || form.controls['firstName'].touched))
        }">
        <input type="text" formControlName="firstName"/>
        </div>
    `
})
class SomeComponentWithForm {
  form: FormGroup;
  submitted: boolean = false;

  constructor(private formBuilder: FormBuilder) {
    this.form = formBuilder.group({
      firstName: ['', Validators.required],
      lastName: ['', Validators.required]
    });
  }


}
复制代码

上面 ngClass 声明看起来很丑。如果我们有更多的表单控件,那么它会使得视图更加混乱,并且创建了很多重复的逻辑。但是,我们也可以这样做:

@Component({
  selector: 'component-with-form',
  template: `
        <div [formGroup]="form" [ngClass]="{'has-error': hasFieldError('firstName')}">
            <input type="text" formControlName="firstName"/>
        </div>
    `
})
class SomeComponentWithForm {
  form: FormGroup;
  submitted: boolean = false;

  constructor(private formBuilder: FormBuilder) {
    this.form = formBuilder.group({
      firstName: ['', Validators.required],
      lastName: ['', Validators.required]
    });
  }
  
  hasFieldError(fieldName: string): boolean {
    return this.form.controls[fieldName].invalid && (this.submitted || this.form.controls[fieldName].touched);
  }


}
复制代码

现在,我们有了个不错的模版,甚至可以轻松地测试我们的验证是否与单元测试一起正常工作,而无需深入查看视图。

读者可能意识到我并没有写关于 DirectivesPipes 的相关内容,那是因为我想写篇详细的文章,关于 AngularDOM 是怎么工作的。所以本文着重介绍 Angular 应用中的 TypeScript 的内容。

希望本文能够帮助你编写更干净的代码,帮你更好组织你的应用结构。请记住,无论你做了什么决定,请保持前后一致(别钻牛角尖...)。

本文是译文,采用的是意译的方式,其中加上个人的理解和注释,原文地址是:medium.com/codeburst/a…

往期精彩推荐

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

收藏成功!
已添加到「」, 点击更改