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 状态管理库

欢迎大家点赞收藏。

相关推荐
~甲壳虫几秒前
说说webpack proxy工作原理?为什么能解决跨域
前端·webpack·node.js
Cwhat2 分钟前
前端性能优化2
前端
熊的猫1 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
瑶琴AI前端1 小时前
uniapp组件实现省市区三级联动选择
java·前端·uni-app
会发光的猪。1 小时前
如何在vscode中安装git详细新手教程
前端·ide·git·vscode
我要洋人死3 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人3 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人3 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR3 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香3 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel