2023 重学 Angular

作者:徐海峰
就在前几天(2022-11-07) Angular 正式发布了 v15 版本,本人第一时间用我那不专业的英文翻译了一下  [[译] Angular 15 正式发布!](https://zhuanlan.zhihu.com/p/...) 文章一出就遭到社区部分人的质疑,什么 "Angular 落寞很久了,劝我们换框架",还有什么 "这玩意居然可以活到 2022 年,远古生物突然复活"。 对此呢我也不想过多的评价,我只在乎通过工具能否改变我司前端的开发效率,让每位同事早点下班,高质量完成目标,让 PingCode 远超竞品。
稍微熟悉 Angular 框架的人应该都知道, Angular 是一个完全遵循语义化版本的框架,每年会固定发布2个主版本,目前虽然已经是 v15,但基本和 v2 版本保持一致的主旋律,那么 v15 可以说是 Angular 框架在尝试改变迈出的一大步,独立组件 APIs 正式稳定,指令的组合 API 以及很多特性的简化。
虽然很多 Angular 开发者已经习惯了 NgModules,但是 Angular 模块对于新手来说的确会带来一些学习成本,对于很多小项目而言带来的收益比并不高,所以支持独立组件(无 Modules) 也算是覆盖了更多的场景,同时也简化了学习成本,那么今天我想通过这篇文章以最小化的示例重新学习一下 Angular 框架,让不了解 Angular 的人重新认识一下这个 "~远古的生物~"

创建一个独立组件的项目

首先通过如下ng new 命令创建一个 ng-relearning 的示例项目:

ng new ng-relearning --style scss --routing false
ng 命令需要通过 npm install @angular/cli -g 全局安装 @angular/cli 模块才可以使用。

创建后的项目目录如下:

.
├── README.md
├── angular.json
├── package.json
├── src
│   ├── app
│   │   ├── app.component.html
│   │   ├── app.component.scss
│   │   ├── app.component.ts
│   │   ├── app.component.spec.ts
│   │   └── app.module.ts
│   ├── assets
│   ├── favicon.ico
│   ├── index.html
│   ├── main.ts
│   └── styles.scss
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json

默认生成文件的介绍见下方表格,已经熟悉 Angular 的开发者可以跳过,相比较其他框架 CLI 生成的目录结构而言,我个人觉得 Angular 的最合理也是最优雅的(纯个人见解)。

目前 ng new 命令初始化的项目还是带 Modules 的,支持 --standalone 参数创建独立组件的项目特性正在开发中,可以关注 此 Issue 。
把默认的项目改为独立组件需要做如下几件事:
手动删除 app.module.ts 
启动组件 AppComponent 中 @Component 元数据添加  standalone: true 并添加 imports: [CommonModule] 
修改 main.ts 代码为:

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent).catch((err) => console.error(err));

这样执行  npm start ,会在本地启动一个 4200 端口的服务,访问  http://localhost:4200/  会展示 Angular 默认的站点:

bootstrapApplication 函数启动应用,第一个参数 AppComponent 组件是启动组件,一个应用至少需要一个启动组件,也就是根组件,这个根组件选择器为  app-root ,那么生成的 index.html 会有对应的   占位元素,Angular 启动时会把它替换为 AppComponent 渲染的 DOM 元素。




  
  NgRelearning
  
  
  


  

为了让 Angular 更容易学习和上手,Angular CLI 在 v15 初始化的项目做了一些简化(大家可以忽略):

  • 去掉了独立的 polyfills.ts 文件,使用 angular.json  build 选项  "polyfills": [ "zone.js"] 代替
  • 去掉了 environments  环境变量, 这个功能还在,当你需要的时候单独配置 fileReplacements 即可
  • 去掉了 main.ts 中的 enableProdMode 
  • 去掉了 .browserslistrc
  • 去掉了 karma.conf.js
  • 去掉了 test.ts

    Hello Angular Relearning

    由于  app.component.html 的示例太复杂,为了简化学习,我们尝试删除 html 所有内容,修改为绑定 title 到 h2 模板元素中,使用双花括号 {{ 和 }} 将组件的变量 title 动态插入到 HTML 模板中,这种叫 文本插值 ,花括号中间的部分叫插值表达式。

    {{title}}


    同时修改组件类的代码,添加 title 属性,初始化值为 'Hello Angular Relearning!' 

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  title = 'Hello Angular Relearning!';
}

这样打开浏览器,发现 title 被渲染在界面上:

Angular 的组件就是一个普通的 class 类,通过 @Component 装饰器装饰后就变成了组件,通过装饰器参数可以设置选择器(selector)、模板(templateUrl 或者 template)、样式(styleUrls 或者 styles),组件模板中可以直接绑定组件类的公开属性。
默认模板和样式是独立的一个 html 文件,如果是一个很简单的组件,也可以设置内联模板,上述示例可以简化为:

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `

{{title}}

`, standalone: true, styleUrls: ['./app.component.scss'], }) export class AppComponent { title = 'Hello Angular Relearning!'; }

条件判断

在实际的应用中会经常用到的就是条件判断,我们修改一下  app.component.ts 为:

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  styleUrls: ['./app.component.scss'],
  imports: [
    CommonModule,
  ],
})
export class AppComponent {
  relearned = false;

  constructor() {
    setTimeout(() => {
      this.relearned = true;
    }, 2000);
  }
}

组件类中新增了 relearned 属性,默认为 false,setTimeout 2s 后设置  relearned 值为 true。
 app.component.html 修改为:

很高兴看到你重新学习 Angular 这款优秀的框架!

我还没有接触过 Angular 框架

  • *ngIf 为 Angular 内置的条件判断结构型指令,当 ngIf 的表达式为 true 时渲染此模板,否则不渲染,那么示例中的表达式为 "relearned" ,也就是 AppComponent 组件中的 relearned 属性
  • else 表示表达式为 false 后的模板,通过 ng-template 定义了一个默认模板,并通过 #default 声明这个模板变量为 default,这样  ngIf else 就可以使用这个变量 default
  • ng-template 是 Angular 定义的一个模板,模板默认不会渲染,ngIf 指令会在表达式为 else 的时候通过 createEmbeddedView 创建内嵌视图渲染这个模板,同时也可以通过  NgTemplateOutlet 指令渲染模板
    展示效果为:

    在 AppComponent 中我们设置了 imports: [CommonModule] ,如果去掉这行代码会报错:

    这是因为独立组件的模板中使用其他指令或者组件的时候需要显示的通过  imports 声明, ngIf 结构性指令是在  @angular/common 模块中提供的,如需使用需要导入:
import { Component } from '@angular/core';
import { NgIf } from '@angular/common';

@Component({
  ...
  imports: [NgIf]
})
export class AppComponent {
}

 @angular/common 模块除了提供 NgIf 内置指令外还有大家常用的  NgClass 、 NgFor 、 NgSwitch 、 NgStyle 等等,所以直接把整个 CommonModule 都导入进来,这样在组件模板中就可以使用 CommonModule 模块的任意指令。

事件绑定

我们接下来通过如下 ng 命令创建一个新组件  event-binding 改善了一下上述的示例:

ng generate component event-binding --standalone // 简写 ng g c event-binding --standalone

执行后会在 src/app 文件夹下创建一个 event-binding 文件夹存放新增的 event-binding 组件

├── src
│   ├── app
│   │   ├── app.component.html
│   │   ├── app.component.scss
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   └── event-binding
│   │       ├── event-binding.component.html
│   │       ├── event-binding.component.scss
│   │       └── event-binding.component.ts
│   ├── ...

修改 event-binding.component.html 添加一个按钮,绑定一个点击事件,同时在模板中通过  *ngIf="relearned" 语法添加一个条件判断。

很高兴看到你重新学习 Angular 这款优秀的框架!

EventBindingComponent 组件中添加一个 relearned 属性和 startRelearn 函数:

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-event-binding',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './event-binding.component.html',
  styleUrls: ['./event-binding.component.scss']
})
export class EventBindingComponent {
  relearned = false;

  startRelearn() {
    this.relearned = true;
  }
}

最后在 AppComponent 组件中导入 EventBindingComponent,并在模板中插入  ,运行的效果如下:

当用户点击按钮时会调用 startRelearn 函数,设置 relearned 属性为 true,模板中的 ngIf 结构指令检测到数据变化,渲染 p 标签。
 (click)="startRelearn()" 为 Angular 事件绑定语法, () 内为绑定的事件名称, = 号右侧为模板语句,此处的模板语句是调用组件内的 startRelearn() 函数,当然此处的模板语句可以直接改为  (click)="relearned = true" ,浏览器所有支持的事件都可以通过 () 包裹使用。

循环渲染列表

除了条件判断与事件绑定外,应用中最常用的就是循环渲染元素,我们通过如下命令创建一个 ng-for 组件

ng generate component ng-for --standalone

同时在组件中新增  items 属性,设置为数组。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-ng-for',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './ng-for.component.html',
  styleUrls: ['./ng-for.component.scss'],
})
export class NgForComponent {
  items = [
    {
      id: 1,
      title: 'Angular 怎么不火呢?',
    },
    {
      id: 2,
      title: 'Angular 太牛逼了!',
    },
    {
      id: 3,
      title:
        '优秀的前端工程师和框架无关,但是 Angular 会让你更快的成为优秀前端工程师!',
    },
  ];
}

最后在 AppComponent 中导入  NgForComponent 后在模板中通过   渲染 NgForComponent 组件。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgForComponent } from './ng-for/ng-for.component';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  imports: [CommonModule, NgForComponent],
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  message = 'Hello Angular Relearning!';

  relearned = false;

  startRelearn() {
    this.relearned = true;
  }
}

渲染后的效果为:

路由

以上我们简单添加了三个示例组件:

  •  event-binding  展示事件绑定
  •  ng-for 展示循环渲染一个列表
  • 我们再把条件判断的示例从 AppComponent 中移动到独立的示例  ng-if 组件中

接下来通过 router 路由模块分别展示这三个示例,首先需要修改 main.ts,bootstrapApplication 启动应用的第二个参数,通过 provideRouter(routes) 函数提供路由赋值给 providers ,routes 设置三个示例组件的路由,输入根路由的时候跳转到 ng-if 路由中,代码如下:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { AppComponent } from './app/app.component';
import { NgForComponent } from './app/ng-for/ng-for.component';
import { EventBindingComponent } from './app/event-binding/event-binding.component';
import { NgIfComponent } from './app/ng-if/ng-if.component';

const routes: Routes = [
  {
    path: '',
    redirectTo: 'ng-if',
    pathMatch: 'full',
  },
  {
    path: 'ng-if',
    component: NgIfComponent,
  },
  {
    path: 'event-binding',
    component: EventBindingComponent,
  },
  {
    path: 'ng-for',
    component: NgForComponent,
  },
];

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes)],
}).catch((err) => console.error(err));

在 AppComponent 根组件中导入  RouterModule 模块。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  styleUrls: ['./app.component.scss'],
  imports: [CommonModule, RouterModule],
})
export class AppComponent {
  title = 'Hello Angular Relearning!';

  constructor() {}
}

这样在根组件的模板中就可以使用  router-outlet 和 routerLink 组件或者指令。

{{ title }}

  • router-outlet 为路由占位器,Angular 会根据当前的路由器状态动态渲染对应的组件并填充它的位置
  • routerLink 让 a 标签元素成为开始导航到某个路由的链接,打开链接会在页面上的 router-outlet 位置上渲染对应的路由组件
    运行效果如下:

    示例代码:  ng-relearning v0.4 分支

    HttpClient 远程调用

    在 Angular 中内置了远程调用的 HttpClient 模块,可以直接通过此模块调用 API。
    修改 ng-for 的示例,在 NgForComponent 组件中通过构造函数注入 HttpClient 服务,并调用 HttpClient 的 get 函数获取 todos 列表。

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';

export interface Todo {
  id?: string;
  title: string;
  created_by?: string;
}

@Component({
  selector: 'app-ng-for',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './ng-for.component.html',
  styleUrls: ['./ng-for.component.scss'],
})
export class NgForComponent implements OnInit {
  todos!: Todo[];

  constructor(private http: HttpClient) {}

  ngOnInit(): void {
    this.http
      .get('https://62f70d4273b79d015352b5e5.mockapi.io/items')
      .subscribe((items) => {
        this.todos = items;
      });
  }
}

一般组件初始化的工作推荐放在 ngOnInit 生命周期函数中,比如初始化数据等。Angular 会在组件所有的 Input 属性第一次赋值后调用 ngOnInit 函数,生命周期更多了解参考: lifecycle-hooks 。
然后在模板中通过 *ngIf 判断数据是否有值显示加载状态。

  1. {{ item.title }}

加载中...

运行后发现代码报错,没有 HttpClient 的 provider 。

我们需要修改 main.ts 添加  provideHttpClient() 到 providers 中去,这样才可以在系统中通过依赖注入使用 http 相关的服务。

注意:http 服务在  @angular/common/http 模块中。
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
...

const routes: Routes = [
  ...
];

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes), provideHttpClient()],
}).catch((err) => console.error(err));

运行效果为:

表单

通过如下命令创建一个 forms 表单示例组件
ng g c forms --standalone --skip-tests
修改 FormsComponent 为如下代码,添加 user 对象包含 name 和 age,同时添加 save 函数传入 form,验证通过后弹出提交成功提示。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-forms',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './forms.component.html',
  styleUrls: ['./forms.component.scss'],
})
export class FormsComponent {
  user = {
    name: '',
    age: '',
  };

  save(form: NgForm) {
    if (form.valid) {
      alert('Submit success');
    }
  }
}

forms.component.html 编写一个表单,包含 name 输入框和 age 数字输入框,通过 [(ngModel)] 双向绑定到 user 对象的 name 和 age,设置 name 输入框为 required 必填,age 数字输入框最大值和最小值为 100 和 1,最终添加 type="submit" 的提交按钮并在 form 上绑定  (submit)="save(userForm)" 提交事件。

用户名不能为空
年龄必须在 1-100 之间

运行效果为:

[()] 是 Angular 双向绑定的语法糖, [(ngModel)]="value" 相当于

只要组件有一个输入参数和输出事件,且命名符合  xxx 和  xxxChange ,这个 xxx 可以是任何值,这样就可以通过  [(xxx)]="value" 这样的语法糖使用,ngModel 是 Angular Forms 表单内置的一个符合这种规则的语法糖指令,了解更多查看: two-way-binding 。

使用第三方类库 material

通过如下命令引入 material 组件库。
ng add @angular/material
根据提示选择样式以及其他选项,最终安装依赖并自动修改代码:

  • 修改 package.json 的 dependencies 添加  @angular/cdk  和  @angular/material 
  • 会自动引入选择的 theme 主题,在 angular.json 文件中添加 styles
  • 修改 main.ts 导入 BrowserAnimationsModule (这是因为选择了支持动画)
  • 引入 google 字体和样式

主要变更如下:

让我们在之前的 forms 示例中导入  MatButtonModule 和  MatInputModule 模块,使用表单和按钮组件美化一下界面。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';

@Component({
  selector: 'app-forms',
  standalone: true,
  imports: [CommonModule, FormsModule, MatButtonModule, MatInputModule],
  templateUrl: './forms.component.html',
  styleUrls: ['./forms.component.scss'],
})
export class FormsComponent {
  user = {
    name: '',
    age: '',
  };

  save(form: NgForm) {
    if (form.valid) {
      alert('Submit success');
    }
  }
}

forms.component.html 修改为:

Name Age
用户名不能为空
年龄必须在 1-100 之间

最终的美化效果为:

同时也使用 MatTabsModule 替换了之前导航链接。
注意:因为通过  ng add @angular/material 安装  material 后修改了 angular.json 文件,需要重新启动才可以看到新的样式,Angular CLI 目前还没有做到 angular.json 变化后实时更新。

使用服务

在 Angular 中推荐使用服务把相关业务逻辑从组件中独立出去,让组件只关注视图,我们改造一下 ng-for 示例,先通过以下命令创建一个服务:
ng g s todo --skip-tests
Angular CLI 会自动帮我们在 app 目录创建一个  todo.service.ts 文件,代码为:

import { Injectable } from '@angular/core';

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

  constructor() { }
}

修改代码通过构造函数注入 HttpClient 服务,并添加 fetchTodos  函数调用 HttpClient 的 get 函数获取 todos 列表,并在最后赋值给服务的 todos 属性。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs';

export interface Todo {
  id?: string;
  title: string;
  created_by?: string;
}

@Injectable({
  providedIn: 'root',
})
export class TodoService {
  todos!: Todo[];

  constructor(private http: HttpClient) {}

  fetchTodos() {
    return this.http
      .get('https://62f70d4273b79d015352b5e5.mockapi.io/items')
      .pipe(
        tap((todos) => {
          this.todos = todos;
        })
      );
  }
}

然后我们修改 ng-for 的示例,这次通过 inject 函数在属性初始化的时注入 TodoService 服务,在初始化时调用 fetchTodos 获取数据。

Angular 在 v14 之前只能通过构造函数参数注入服务,在 v14 版本之后可以在属性初始化、构造函数以及 factory 函数中通过 inject 注入服务或者其他供应商,了解更多参考: inject
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Todo, TodoService } from '../todo.service';

@Component({
  selector: 'app-ng-for',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './ng-for.component.html',
  styleUrls: ['./ng-for.component.scss'],
})
export class NgForComponent implements OnInit {

  todoService = inject(TodoService);

  constructor() {}

  ngOnInit(): void {
    this.todoService.fetchTodos().subscribe();
  }
}

然后在模板中直接使用  todoService 的 todos 数据。

  1. {{ item.title }}

加载中...

通过上述示例我们发现,在 Angular 中把数据和逻辑抽取到服务中,只要设置为组件的属性就可以在模板中绑定服务的数据(也就是状态),这些状态变化后,Angular 视图会实时更新,同时只要多个组件注入同一个服务就实现了数据共享,这样轻松解决了前端的逻辑复用数据共享两大难题,这一点和其他框架有很大的不同:

  • React 必须要通过 setState 或者 Hooks 的 set 函数设置状态才会重新渲染
  • Vue 必须定义在 data 数据中或者通过 ref 标记

Angular 什么也不需要做是因为通过 Zone.js 拦截了所有的 Dom 事件以及异步事件,只要有 Dom Event、Http 请求,微任务、宏任务都会触发脏检查,从根组件一直检查到所有叶子组件,只要有数据变化就会更新视图,那么上述的示例中,fetchTodos 函数有一个 API GET 请求,这个请求被 Angular 拦截,请求结束后赋值 todos 数据后,Angular 就开始从 app-root 根组件向下检查,发现 todos 数据变化了,更新视图。

指令组合 API (Directive Composition API)

通过服务在 Angular 中可以很好做逻辑复用,但是对于一些偏 UI 操作的逻辑复用,有时候使用服务会多加一些样板代码,因为在 Angular 中除了组件还有一个指令的概念,指令是对已经的组件或者元素添加行为,我们在前面示例中使用的 NgIf 、 NgFor 、 NgModel 都是内置的指令,有时候需要重复利用不同的指令组合,如果要实现逻辑复用只能通过 Class 继承和 Mixin 实现类似多重继承的效果,那么指令组合 API 就是解决此类问题的。
让我先通过如下命令创建一个 color 设置文本颜色的指令:
ng g d color --standalone --skip-tests
然后修改 color.directive.ts 代码如下:

import { Directive, ElementRef, Input, OnInit, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appColor]',
  standalone: true,
})
export class ColorDirective implements OnInit {
  @Input() color!: string;

  constructor(private elementRef: ElementRef, private renderer: Renderer2) {}

  ngOnInit(): void {
    this.renderer.setStyle(this.elementRef.nativeElement, 'color', this.color);
  }
}

主要通过注入获取 ElementRef,并通过 Renderer2 服务设置 DOM 元素的 color 样式。

elementRef.nativeElement 就是当前指令绑定的 DOM 元素,通过 Renderer2 操作 DOM 主要是为了兼容不同的环境,比如服务端渲染等。

这样在 AppComponent 组件中导入 AppColor 后就可以通过如下模板使用:

我是红色

展示效果为:

那么我们再创建一个 directive-composition 组件,这个组件的选择器是 app-directive-composition ,如果这个组件也想要具备设置字体颜色的功能,我们只能这样使用

 我的字体颜色时红色

如果是多个指令,等于需要在使用的地方自行组合,我们改造一下这个组件,通过  hostDirectives 设置  ColorDirective 并指令 inputs color。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ColorDirective } from '../color.directive';

@Component({
  selector: 'app-directive-composition',
  standalone: true,
  imports: [CommonModule, ColorDirective],
  template: '',
  styleUrls: ['./directive-composition.component.scss'],
  hostDirectives: [
    {
      directive: ColorDirective,
      inputs: ['color'],
    },
  ],
})
export class DirectiveCompositionComponent {}

这样直接使用  app-directive-composition 传入参数 color 就具备了 appColor 指令的功能。

我是红色
我的字体颜色时红色

展示效果如下:

以上就是组合指令 API 的魅力所在。

总结

以上我是想通过一种渐进式的 Angular 入门让大家初步了解 Angular 这款我认为特别优秀的框架,抛弃了 Modules 后也更加适合新手入门,站在 2022 乃至 2023 年的时间点来说,它并不是一个落后的框架,反而是更加的先进,同时 Angular 也在不断的变得更好。 同时上述的功能只是 Angular 框架的冰山一角,深入后还有更有的宝藏等着你去挖掘。
以上所有示例仓储地址为: https://github.com/why520crazy/ng-relearning 
Open in StackBlit: https://stackblitz.com/github/why520crazy/ng-relearning

你可能感兴趣的:(angular前端框架)