Angular 新特性风暴,席卷前端 MAGA

原文地址:zhuanlan.zhihu.com/p/656461032

随便聊聊

Angular 最近一两年迭代速度真的是太快了,特别是 Ivy 渲染引擎稳定后,像是开挂般了一样,不断的发布一些好用的新特性,不管是大的(像独立组件、新响应式 Signals、指令组合 API),还是小的特性,都极大的提高了开发效率,Angular 在国外已经掀起了一波又一波高潮,2023 前端框架还是得看老大哥 Angular MAGA,比如 Twitter 上就有人说:

From v9-14 it was some good Updates 15-17 its been freaking amazing!
从 v9-14 它有一些不错的更新,15-17 它是惊人的!

甚至有人说 Angular 过去四个月进步了五年,虽然有点夸张,但是的确比较真实,Angular 官方团队还在下面调侃说不止四个月,应该是一年。

如果换作是其他的框架,估计早已经吹上天了,但是 Angular 框架在国内的确很少被讨论到,可能是使用 Angular 框架的公司都比较低调吧,可能是 Angular 前端开发者早早干完活下班逍遥去了,无瑕布道和宣传。

还有 Twitter 一哥们对 Angular 的评价如下:

Angular has always been the dark horse the just so happens to dominate enterprise. Solo devs hate it, large teams depend on it.

Angular 一直是一匹黑马,恰好主宰着企业,单个开发人员讨厌它,大型团队依赖它。

我觉得比较中肯,因为真正的框架是约束开发者,这不能做,那也不能做,所以讨厌它不够灵活,但是当前的 Angular 已然变得既稳重又灵活,如果你这两年没有一直使用可能都已经不认识了,那么回归正题,这篇文章主要分享一些那些你可能不知道的 Angular 新特性,因为国内无人布道,那就让我就来吹爆它

inject 函数增强

我之前单独写过一篇文章 Angular v14 被低估的一个 DI 特性 inject,专门介绍 inject 函数,已经阅读过的可以忽略这节(当然那篇文章的部分知识点目前看已经有点过时了,官方已经有了更好的方案)。

那么在 Angular 应用中,使用依赖注入一般都是通过构造函数参数注入,比如在 SomeComponent 注入 Car 服务:

less 复制代码
@Injectable({providedIn: 'root'})
export class Car { }

@Component({ ... })
export class SomeComponent {
  constructor(private car: Car) {}
}

那么在 v14 版本中,我们可以通过在构造函数或者属性初始化中直接使用 inject 函数注入一个服务:

less 复制代码
@Injectable({providedIn: 'root'})
export class Car { }

@Component({ ... })
export class SomeComponent {
  // 属性初始化注入
  car = inject(Car);
  constructor() {
    // 构造函数注入
    const car = inject(Car);
  }
}

其实 Angular 在 v14 版本之前就已经存在 inject 函数了,只不过过去只能在 InjectionToken 的 factory 函数的上下文中执行:

typescript 复制代码
class MyService {
  constructor(readonly myDep: MyDep) {}
}

const MY_SERVICE_TOKEN = new InjectionToken<MyService>('Manually constructed MyService', {
  providedIn: 'root',
  factory: () => {
    return new MyService(inject(MyDep))
  },
});

const instance = injector.get(MY_SERVICE_TOKEN);

在 v14 版本中进行了增强,目前 inject 函数可以在如下 依赖上下文 中执行:

  • Angular DI 实例化 Class 的构造器中,比如@Injectable或者 @Component标记的 Class
  • Angular DI 实例化 Class 的属性初始化,具体看上面的示例
  • 在 Provider 的useFactory或者@Injectable的工厂函数中
javascript 复制代码
providers: [
  {
    provide: Car, 
    useFactory: () => {
      // OK: a class factory
      const engine = inject(Engine);
      return new Car(engine);
   }
  }
]
  • 在 InjectionToken 的 factory 函数中(以前版本就支持)
  • In a stackframe of a function call in a DI context(不知如何翻译比较好)

Angular 某些 API 设计之初就是要在 Injection Context 中执行,比如路由守卫,我们可以在路由守卫函数中通过 inject 注入 Token 使用,比如 CanActivateFn 函数:

ini 复制代码
const canActivateTeam: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
  return inject(PermissionsService).canActivate(inject(UserToken), route.params.id);
};

如果在上述五种上下文之外使用 inject 函数是不可以的,比如在 ngOnInit 钩子使用:

scss 复制代码
@Component({ ... })
export class CarComponent {
  ngOnInit() {
    // ERROR: too late, the component instance was already created
    const engine = inject(Engine);
    engine.start();
  }
}

这样会报如下错误:

那么如何解决这个问题呢? Angular 引入了一个 EnvironmentInjector 注入器和 runInInjectionContext 函数可以解决此问题,在构造函数或者属性初始化的时候注入 EnvironmentInjector 并保存到属性中,之后通过调用 runInInjectionContext 函数传入 EnvironmentInjector,那么在回调函数中使用 inject 函数,这个回调函数注入上下文就是 EnvironmentInjector 实例化时候的上下文,这样就不会报错了,代码如下:

scss 复制代码
@Injectable({
  providedIn: 'root',
})
export class HeroService {
  private environmentInjector = inject(EnvironmentInjector);

  someMethod() {
    runInInjectionContext(this.environmentInjector, () => {
      inject(SomeService); // Do what you need with the injected service
    });
  }
}

正是由于 inject 函数底层支持了这样的特性,那么我们就可以通过组合的方式注入一切需要的 Provider,包括 Angular 视图相关的 ElementRef、TemplateRef、ViewContainerRef、ChangeDetectorRef 等等,可以做更上层的包装,同时也简化了继承实现功能复用,再也不用在构造函数中传递父类需要的参数了。

灵活的组件销毁 DestroyRef

在过去版本中,Angular 组件和服务都支持 ngOnDestroy 的生命周期进行销毁操作,只要实现 OnDestroy 接口的 ngOnDestroy 函数即可,实现如下:

typescript 复制代码
@Component({
    ...
})
export class SomeComponent implements OnDestroy {
    constructor() { }

    ngOnDestroy(): void {
        // 添加销毁逻辑
    }
}

这种方式的缺点是不太灵活,在组件继承场景下比较麻烦,子组件也有 ngOnDestroy 还需要调用父组件的 ngOnDestroy 函数,经常忘记而出错,如果想要通过组合封装一些上层 API 基本不可能,所以 Angular 在 v16 版本新增了 DestroyRef ,通过 inject 的能力可以实现更加灵活的销毁事件。

scss 复制代码
@Component(...)
class Counter {
  count = 0;
  constructor() {
    // Start a timer to increment the counter every second.
    const id = setInterval(() => this.count++, 1000);

    // Stop the timer when the component is destroyed.
    const destroyRef = inject(DestroyRef);
    destroyRef.onDestroy(() => clearInterval(id));
  }
}

这个 DestroyRef 为后续的一些 API 做基础,比如 takeUntilDestroyed 等。

afterRender 和 afterNextRender

除了 ngOnDestroy() 钩子函数外,组件还包含 ngOnInit()、ngAfterViewInit()、ngAfterViewChecked() 等等 lifecycle ,那么此次带来了新的函数 afterRenderafterNextRender Hooks 替代 ngAfterViewCheckedngAfterViewInit ,从而更加灵活的实现一些组件钩子。

  • afterNextRender 组件第一次渲染后执行,只执行一次, 类似 afterViewInit ,区别是在服务端渲染 (SSR) 时不执行
  • afterRender 每次变化检查后执行

最常用的就是我们要等待组件渲染完毕后操作 Dom,使用一些第三库等等:

kotlin 复制代码
@Component({
  selector: 'my-chart-cmp',
  template: `<div #chart>{{ ... }}</div>`,
})
export class MyChartCmp {
  @ViewChild('chart') chartRef: ElementRef;
  chart: MyChart | null;

  constructor() {
    afterNextRender(() => {
      this.chart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

这两个 API 其实是为 Signal-based Components 组件做铺垫的,目前也是开发预览版,Angular 在 RFC 中确定将来的 Signal-based 组件只会保留 ngOnInit 和 ngOnDestroy,其余的生命周期函数都通过 signal 函数代替,那么 afterRenderafterNextRender 就是代替的 View 初始化和变更检查的 signal 函数。

更优雅的取消订阅 takeUntilDestroyed

有了上面 inject 函数的增强 + 灵活的销毁钩子 DestroyRef,这样就可以解决 Angular 应用中一直存在的大难题:在组件销毁的时候如何取消 RxJS 订阅流,那么在过去我们只能通过 takeUntil + 一个 destroyed Subject,然后在 ngOnDestroy 调用 destroyed$.next 和 complete 实现:

typescript 复制代码
@Component({
   ...
})
export class SomeComponent implements OnDestroy {
    destroyed$ = new Subject<void>();

    constructor() {
        this.someService.some$.pipe(
          takeUntil(this.destroyed$)
        ).subscribe({
          next: () => {}
        });
    }

    ngOnDestroy(): void {
        this.destroyed$.next();
        this.destroyed$.complete();
    }
}

样板代码太多了,所以大家会想各种办法简化销毁取消订阅逻辑,有通过 Mixin 实现多继承,有通过在组件上单独加 Provider 实现等等,那么现在就可以通过 inject + DestroyRef 自己封装一个函数:

ini 复制代码
export function takeUntilDestroyed<T>(): MonoTypeOperatorFunction<T> {
  destroyRef = inject(DestroyRef);

  const destroyed$ = new Observable<void>(observer => {
    const unregisterFn = destroyRef!.onDestroy(observer.next.bind(observer));
    return unregisterFn;
  });

  return <T>(source: Observable<T>) => {
    return source.pipe(takeUntil(destroyed$));
  };
}

那么更好的消息是官方已经在 @angular/core/rxjs-interop 中帮我们封装好了,我们直接使用即可:

kotlin 复制代码
data$ = http.get('...').pipe(takeUntilDestroyed());

默认情况下,必须在注入上下文中调用 takeUntilDestroyed 以便它能访问 DestroyRef ,因为内部通过 inject 函数注入 DestroyRef 实现的,如果注入上下文不可用,你可以显式提供 DestroyRef

kotlin 复制代码
@Component({
   ...
})
export class SomeComponent implements OnInit {
    destroyRef = inject(DestroyRef);

    ngOnInit() {
        this.someService.some$.pipe(
          takeUntilDestroyed(this.destroyRef)
        ).subscribe();
    }
}

Directive 组合 API

我们发现不管是 inject 函数,还是灵活的 DestroyRef,Angular 都在灵活性和可组合性上持续加强,不像过去只能通过继承实现一些特性,那么 Directive 组合 API 更是把 Angular 的指令上升到了一个新高度。

我单独写了一遍 Angular 指令组合 API 使用指南 主要介绍什么是指令组合 API,以及使用场景。反正特别好用,欢迎大家阅读,简单的示例就是在 AppTitle 组件中通过 hostDirectives 组合了 AppColor 和 AppBgColor 两个指令的所有功能,使用者只关心 app-title 即可。

kotlin 复制代码
@Component({
  selector: 'app-title',
  template: 'PingCode',
  hostDirectives: [
    {
      directive: AppColor,
      inputs: ['color'],
    },
    {
      directive: AppBgColor,
      inputs: ['bgColor'],
    },
  ],
  standalone: true,
})
export class AppTitle {
  appColor = inject(AppColor);

  appBgColor = inject(AppBgColor);

  constructor() {
    this.appColor.color = this.appColor.color || '#fff';
    this.appBgColor.bgColor = this.appBgColor.bgColor || '#6698ff';
  }
}

@Input 支持更多参数

在 v16 之前, @Input() 装饰器,可接受string类型作为参数,也就是我们常用的属性别名,例如:

less 复制代码
@Input('thyTitle') title: string;

Angular 16 中,@Input()除了接收string类型外,支持了Input 对象类型:

typescript 复制代码
export interface Input {
  alias?: string;
  required?: boolean;
  transform?: (value: any) => any;
}
  • alias 输入参数别名
less 复制代码
@Input('thyTitle') title: string;
// 等价于
@Input({ alias: 'thyTitle' }) title: string;
  • required 控制输入参数是否必填
less 复制代码
@Input({ alias: 'thyTitle', required: true })  title: string;

当指定了某个 Input 属性为 required 时,使用组件时如果不传入相应属性,会抛出编译错误:

python 复制代码
Required input 'thyTitle' from component HelloComponent must be specified.
  • transform 转换输入函数

组件传递参数的时候如果是布尔值的话需要支持字符串的 false 或者不传任何值也是 true,期望如下:

如果对于 disabled 参数不做任何处理的话,上述的 1 和 3 可能会得到错误的预期,那么就需要对于布尔值的参数进行转换,那么过去要不通过 getter setter 转换一下,要不就通过新增一个 InputBoolean 装饰器就是解决类似的问题的。

那么 Angular v16.1 直接提供了最佳方案, @Input装饰器支持 transform 函数,同时把常用的 booleanAttribute 和 numberAttribute 实现好从 core 中导出为公开 API,这样就可以非常方便的使用:

css 复制代码
import { booleanAttribute } from "@angular/core";

@Input({ transform: booleanAttribute }) disabled = false;

自闭合标签 (Self-closing tags)

Angular v16 中,模板中的组件支持使用自闭合标签,这是一个小的开发体验改进,可以节省一些打字时间~

现在可以替换:

java 复制代码
<super-duper-long-component-name [prop]="someVar"></super-duper-long-component-name>

为:

java 复制代码
<super-duper-long-component-name [prop]="someVar" />

NgComponentOutlet 支持绑定 inputs

NgComponentOutlet 指令一般为动态创建组件使用,虽然 NgTemplateOutlet 支持了 context 传递上下文参数, 但是 NgComponentOutlet 一直不支持传入 Input 参数,那么在 v16 版本中也支持了。

php 复制代码
Component({
  selector: 'app-root',
  standalone: true,
  imports: [NgComponentOutlet],
  template: '<div *ngComponentOutlet="userComponent; inputs: userData"></div>'
})
class AppComponent {
  userComponent = UserComponent;
  userData = { name: 'Cédric' }
}

将路由数据作为组件输入进行传递

路由支持绑定参数为 Input,默认不开启,需要使用withComponentInputBinding 函数开启:

scss 复制代码
provideRouter(routes, withComponentInputBinding())

开启了这个选项,如果一个组件的 Input 参数和路由 parameter、query 参数或者 data 相同,那么 Angular 会自动绑定 Input 值为 parameter、query 或者 data。

typescript 复制代码
export class TaskComponent implements OnChanges {
  @Input({ required: true }) taskId!: string;
}

同时在 ngOnChanges 中也可以正常检测到数据的变化:

kotlin 复制代码
class SomeComponent {
  task$: Observable<Task>;

  constructor(private taskService: TaskService) {}

  ngOnChanges() {
    this.task$ = this.raceService.get(this.taskId);
  }
}

函数式路由守卫和解析器

Angular 在 v14.2 版本中开始可以通过函数定义路由守卫(guards)和解析器(resolvers):

yaml 复制代码
{
  path: '/user/:id/edit', 
  component: EditUserComponent,
  canDeactivate: [(component: EditUserComponent) => !component.hasUnsavedChanges]
}

并在 v15.2 版本中废弃了之前的CanActivateCanDeactivateResolve等接口,且在 v16.0 中彻底移除接口,目前路由守卫和解析器同时支持函数和带有特定函数的对象,同时官方还提供了一些函数转换对象为函数(个人感觉多此一举):

  • mapToCanActivate
  • mapToCanActivateChild
  • mapToCanDeactivate
  • mapToCanMatch
  • mapToResolve
css 复制代码
{ path: 'admin', canActivate: mapToCanActivate([AdminGuard]) };

注意CanMatch路由守卫是新特性,取代之前的CanLoad

路由自动打开默认导入

在过去开启懒加载的时候,我们一般需要在 loadChildren 或者 loadComponent 函数中导入并返回对应的模块或者组件。

javascript 复制代码
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
// of for routes
loadChildren: () => import('./admin/admin.routes').then(c => c.adminRoutes)
// or for component
loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent)

那么在 v15 版本中可以通过在 lazy-file 中 export default 后直接省略 then 后面的代码:

javascript 复制代码
loadChildren: () => import('./admin/admin.module')
// of for routes
loadChildren: () => import('./admin/admin.routes')
// or for component
loadComponent: () => import('./admin/admin.component')

创建组件简化

在 v14.2 版本中,Angular 新增了createComponent简化动态创建组件,废弃了过去的ComponentFactory:

ini 复制代码
const app = await bootstrapApplication(AppComponent);
const homeComponent = createComponent(HomeComponent, app.injector);
app.attachView(homeComponent.hostView);

同时新增了reflectComponentType可以反射出组件的元数据。

ini 复制代码
const mirror = reflectComponentType(UserComponent)!;

mirrorComponentMirror 对象类型,包含如下元数据:

  • selector , 比如:app-user
  • type , 比如:UserComponent
  • inputs , 比如:[{ propName: 'userModel', templateName: 'userModel' }]
  • outputs , 比如:[{ propName: 'userSaved', templateName: 'userSaved' }]
  • ngContentSelectors , 比如: ['*']
  • isStandalone , 比如: false

独立组件 APIs

Angular 在 v14 中,引入了新的独立(standalone) APIs,此时为开发者预览版,并在 v15 版本中正式稳定,同时可以确保独立组件在HttpClientAngular ElementsRouterFroms等模块使用。

arduino 复制代码
import {bootstrapApplication} from '@angular/platform-browser';
import {ImageGridComponent} from'./image-grid';

@Component({
  standalone: true,
  selector: 'photo-gallery',
  imports: [ImageGridComponent],
  template: `
    ... <image-grid [images]="imageList"></image-grid>
  `,
})
export class PhotoGalleryComponent {
  // component logic
}
bootstrapApplication(PhotoAppComponent);

如果在一个已经存在 Module 的项目使用部分独立组件,需要把独立组件从模块的 declarations 移动到 imports 中即可

less 复制代码
@NgModule({
  declarations: [AppComponent],
  // UserComponent 是独立组件
  imports: [BrowserModule, HttpClientModule, UserComponent], <--- 
  bootstrap: [AppComponent]
})
export class AppModule {}

同时官方提供了迁移独立组件的 Schematics,我们的组件库使用这个命令很快就升级成功

scss 复制代码
ng generate @angular/core:standalone

过去的组件库可能每个组件都会提供一个 Module,为了保持兼容,Module 还会继续保留,我们可以简单理解独立组件后每个组件都是一个 Module,都可以 Imports 别的独立组件或者模块,也可以声明 Providers, Module 其实是把多个独立组件合并在一起,避免使用的时候一个一个导入,比如 Angular 官方提供的 CommonModule,导致后就可以使用 NgIf,NgSwitch 等等在 CommonModule 模块 Imports 的独立组件,当然使用独立组件后还是推荐导入独立组件,这样会做更好的摇树优化。

那么这里有一个问题就是之前会在模块级别提供一些 Providers,如果都使用独立组件那么这些 Providers 并不会在根注入器提供,那么 Angular 也提供了一个importProvidersFrom函数直接从模块中导入声明的 Providers

css 复制代码
await bootstrapApplication(RootComponent, {
  providers: [
    importProvidersFrom(NgModuleOne, NgModuleTwo)
  ]
});

同时对于 Router 和 HttpClient 模块分别提供了provideRouterprovideHttpClient取代之前的NgModule,且为测试场景下提供provideLocationMocksprovideHttpClientTesting等函数。

独立组件是一个比较大的话题,有时间单独写篇文章介绍。

新响应式 Signals

在 v16 版本中,Angular 发布了新的响应式 Signals 开发者预览版,主要提供了 signal、computed 和 effect 几个基础函数:

javascript 复制代码
import { signal } from '@angular/core';
// define a signal
const count = signal(0);

获取 signal 的值:

scss 复制代码
// get the value of the signal
const value = count();

在模板中绑定:

css 复制代码
<p>{{ count() }}</p>

设置值:

c 复制代码
// set the value of the signal
count.set(1);

更改值:

javascript 复制代码
// update the value of the signal, based on the current value
count.update((value) => value + 1);

mutate 函数进行设置值,无需返回新的引用:

ini 复制代码
// mutate the value of the signal (handy for objects/arrays)
const user = signal({ name: 'JB', favoriteFramework: 'Angular' });
user.mutate((user) => user.name = 'Cédric');

计算属性:

scss 复制代码
const double = computed(() => count() * 2);

count.set(2);
console.log(double()); // logs 4

effect:

scss 复制代码
// log the value of the count signal when it changes
effect(() => console.log(count()));

这里需要注意一点,在 OnPush 的组件下使用 Signal,当 Signal 变化后,组件会重新渲染,无需手动调用ChangeDetectorRef#markForCheck()

scss 复制代码
@Component({
  ...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
  count = signal(0);

  constructor() {
    // set the counter to 1 after 2 seconds        
    setTimeout(() => this.count.set(1), 2000);
  }
}

同时在 core 模块新增了rxjs-interop与 RxJS 进行互操作,目前提供了上面说到的 takeUntilDestroyed 以及 toSignal、toObservable 进行 Signal 与 Observable 的转换。

关于新的响应式还有很多特性,这次就不详细介绍了,后面有机会单独写篇文章介绍。

写在最后

当然 Angular 这一两年发布的新特性不局限于这些,还比如:

  • 新的服务器端渲染和 Hydration 增强
  • 严格的类型表单
  • 内置 NgOptimizedImage 优化图片渲染组件
  • provideHttpClient 支持 Fetch API
  • 新的路由守卫 CanMatch,废弃 CanLoad
  • 路由配置支持 title 设置页面标题
  • ComponentRef 新增 setInput
  • ContentChild 支持 descendants
  • Router 事件支持 type 类型
  • 官方的 Angular DevTools 插件
  • CLI ng new 新项目的配置简化
  • 基于 esbuild 的构建系统
  • 使用 Jest 和 Web Test Runner 进行更好的单元测试
  • 测试的增强,比如 RouterTestingHarness
  • 更好堆栈跟踪
  • 语言服务中的自动导入
  • 性能的增强
  • ...

这很多其他特性我就不一一介绍了,这些都是 v14、v15、v16 版本带来的新特性,v17 已经在路上了,Angular Team 负责人已经开始写 v17 的博客了,一大波好特性即将到来。

比如即将要登场的 DevTools 可视化的改进:

我想说,选择 Angular 真好,五星级 VIP 保姆级伺候。

关于我们

PingCode 前端团队是一群热爱开源,热爱技术的小伙伴,我们立志于通过技术打造世界最好用的研发管理工具,我们团队还开源了多个项目:

  • docgeni :开箱即用的 Angular 组件文档生成工具
  • ngx-planet :Angular 框架下功能最全面的微前端解决方案
  • slate-angular : Slate 富文本编辑器框架的 Angular 视图适配层,使用 Angular 轻松开发 Slate 编辑器
  • ngx-gantt :最好用的 Angular 甘特图组件
  • @tethys/store :简单好用的 Angular 状态管理库

欢迎大家点赞收藏。

相关推荐
Attacking-Coder1 分钟前
前端面试宝典---webpack面试题
前端·面试·webpack
极小狐26 分钟前
极狐GitLab 容器镜像仓库功能介绍
java·前端·数据库·npm·gitlab
程序猿阿伟38 分钟前
《Flutter社交应用暗黑奥秘:模式适配与色彩的艺术》
前端·flutter
rafael(一只小鱼)42 分钟前
黑马点评实战笔记
前端·firefox
weifont42 分钟前
React中的useSyncExternalStore使用
前端·javascript·react.js
初遇你时动了情1 小时前
js fetch流式请求 AI动态生成文本,实现逐字生成渲染效果
前端·javascript·react.js
影子信息1 小时前
css 点击后改变样式
前端·css
几何心凉1 小时前
如何使用 React Hooks 替代类组件的生命周期方法?
前端·javascript·react.js
小堃学编程1 小时前
前端学习(1)—— 使用HTML编写一个简单的个人简历展示页面
前端·javascript·html
hnlucky2 小时前
通俗易懂版知识点:Keepalived + LVS + Web + NFS 高可用集群到底是干什么的?
linux·前端·学习·github·web·可用性测试·lvs