Angular 应用无法正常运行的9个常见错误

天魂
Autor 天魂 一个死磕 Angular 的逼粉

(最后一次更新: )

Angular 应用无法正常运行的9个常见错误

当 Angular 应用无法正常工作时,会在浏览器控制台上报一些错。当初学者遇到这些报错时,可能会使开发过程陷入困境。

本文中列出了11个常见的错误,几乎每个 Angular 应用开发者都会遇到。文中会解释为什么会出现这个错误,并通过至少一种解决方案引导解决。

1.导入所需的角度模块

可能在开发中遇到最常见的错误是没有导入所需的模块

1
Can't bind to 'ngModel' since it isn't a known property of 'input'

这个错误表明没有将 Angular Forms Module 导入到当前模块中。

1
Unhandled Promise rejection: No provider for HttpClient!

这个错误表明没有将 Angular HttpClient Module 导入到(根)模块中。

解决方案

要解决类似上面的错误,需要将缺少的模块导入的当前或者根模块中。根模块就是应用目录中的 AppModule

src/app/app.module.ts

1
2
3
4
5
6
@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, FormsModule, HttpClientModule],
  bootstrap: [AppComponent],
})
export class AppModule {}
⚠️ 只导入真正需要的模块,导入不必要的模块会增加应用大小或者延长加载时间

该建议不仅适用于 Angular 框架模块(@angular/*),也适用于任何 Angular 模块,包括第三方模块。

以下是一些需求导入的常见模块:

1
2
3
4
5
BrowserModule 
FormsModule
HttpClientModule
RouterModule 
BrowserAnimationsModule

第三方库最好将模块拆分为尽可能多的模块,以使应用的大小保持较小。在 Angular Material 中必须为所使用的每个组件导入一个模块。

例如:

1
2
3
4
5
6
MatMenuModule 
MatSidenavModule 
MatCheckboxModule 
MatDatepickerModule 
MatInputModule 
...

2.不要在 DOM 引用存在之前使用 @ViewChild

@ViewChild 装饰器使引用组件的特点子元素(html 元素或者组件)变得非常容易。只需要在模板内部的 html 元素或子组件上添加引用标识符:# 加名称

1
2
3
<div #myDiv></div>

<demo-component #demoComponent></demo-component>

现在,可以从父组件中引用该元素。如果是一个组件,则可以调用其公共方法和访问属性。如果是 HTML 元素,则可以更改其样式,属性或子元素。

如果使用 @ViewChild 装饰器装饰该属性,Angular 会自动将引用分配给组件的属性。确保将参考名称传递给装饰器,例如:@ViewChild('myDiv', {static: true})

1
2
3
4
5
6
import { ViewChild } from '@angular/core';

@Component({})
export class ExampleComponent {
  @ViewChild('myDiv', {static: true}) divReference;
}
问题

只有在元素实际存在的情况下,才能使用对元素的引用!

所引用的元素无法实际存在的原因有很多。最常见的原因是浏览器尚未创建完成,也就是尚未将其添加到 DOM 中。

如果你在元素添加到 DOM 之前使用元素的引用,就会在导致应用报错无法执行。在 JavaScript 中也有可能会遇到这个问题,因为这个问题并不是特定于 Angular 的。

一个在 DOM 不存在时访问元素的引用的例子,这会导致应用报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { ViewChild, OnInit } from '@angular/core';

@Component({})
export class ExampleComponent implements OnInit {
  @ViewChild('myDiv',  {static: true}) divReference;

  constructor() {
    let ex = this.divReference.nativeElement; // divReference is undefined
  }

  ngOnInit() {
    let ex = this.divReference.nativeElement; // divReference is undefined
  }
}
解决方案

在原生 JavaScript 中有 DOMContentLoaded 事件,JQuery 中有 $(document).ready( ) 回调,Angular 中也有类似的机制,叫做 ngAfterViewInit 生命周期钩子。当所有组件视图和子视图都初始化时会触发这个钩子,可以在这个钩子回调里安全的访问 viewChild 引用。

1
2
3
4
5
6
7
8
9
10
import { ViewChild, AfterViewInit } from '@angular/core';

@Component({})
export class ExampleComponent implements AfterViewInit {
  @ViewChild('myDiv', {static: true}) divReference;

  ngAfterViewInit() {
    let ex = this.divReference.nativeElement; // divReference is NOT undefined
  }
}

在正常情况下上面代码会正常运行,但还有一个问题。就像上面说的,只能访问实际存在元素的引用。当元素或者组件上有 *ngIf 指令,并且值为 false 时元素会被从 DOM 中删除。为了防止在这种情况下应用报错,在使用引用之前应先判断引用是否为 null

1
2
3
4
5
6
7
8
9
10
11
12
import { ViewChild, AfterViewInit } from '@angular/core';

@Component({})
export class ExampleComponent implements AfterViewInit {
  @ViewChild('myDiv', {static: true}) divReference;

  ngAfterViewInit() {
    if (this.divReference) {
      let ex = this.divReference.nativeElement; // divReference is NOT undefined
    }
  }
}

不要直接操作 DOM

直接用 Angular 操作 DOM 被认为是不好的做法。这也可能导致 Angular 应用无法在浏览器以外的其他平台中运行。比如在 Angular Universal 项目中,Angular 应用可以在服务端渲染。

在服务端渲染中不能正常运行的示例:

1
2
3
4
5
6
7
8
9
10
11
import { ViewChild, AfterViewInit } from '@angular/core';

@Component({})
export class ExampleComponent implements AfterViewInit {
  @ViewChild('myDiv', {static: true}) divReference;

  ngAfterViewInit() {
    let ex = this.divReference.nativeElement;
    ex.style.color = 'red'; // does not work on the server
  }
}
解决方案

既然不能操作 DOM,那就间接操作。为此,Angular 提供了 (Renderer2)[https://angular.io/api/core/Renderer2] 类形式的渲染 API。

通过使用渲染器,可以确保代码在服务端渲染和浏览器环境中都能正常运行。

使用 Renderer2 的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { ViewChild, Renderer2, AfterViewInit } from '@angular/core';

@Component({})
export class ExampleComponent implements AfterViewInit {
  @ViewChild('myDiv', {static: true}) divReference;

  constructor(private renderer: Renderer2) {}

  ngAfterViewInit() {
    if (this.divReference)
      this.renderer.setStyle(this.divReference.nativeElement, 'color', 'red');
  }
}

Renderer2 有很多不同的方法更改元素,对于熟悉 JavaScript DOM API 的人来说很容易就能猜到每个 API 的作用。

4.避免重复的提供者互相覆盖

Angular 使用了一种称为依赖注入的概念。借助依赖注入可以在构造函数中获取 service 实例。

为此,必须在 @Component@NgModule 装饰器的 providers 数组中添加 service 或其他 injectable。最常见的做法是在模块级别提供。

Angular 使用了分层依赖注入系统,在根模块(AppModule)中提供的 serviceinjectable 可以用于根模块的所有组件中。并且由于根模块应包含所有组件和模块,因此,在根模块中提供的 service 在整个应用中都可用。

如果在子模块中提供 service,则该 service 仅可用于该子模块。这也意味着,如果在两个子模块中都提供 service,则子模块的组件构造函数中获取的 service 实例与其他组件不同。如果假设 service 是应用中的唯一实例(单例),则可能导致各种错误。

解决方案

在 AppModule 中只提供一次 service。除非明确的知道自己在做什么,否则应该遵守这个约定,尤其是刚开始接触 Angular 时。

推荐使用以下这种方式注册 service

1
2
3
@Injectable({
  providedIn: 'root'
})

providedIn: 'root' 告诉 Angular 在根注入器中注册service,这也是使用 Angular CLI 生成 service 时默认的方式。 这种方式注册,不需要在 @NgModule 装饰器中写 providers,而且在代码编译打包时,可以执行摇树优化,会移除所有没在应用中使用过的服务。

💡其他疑问可以在「web 前端骚操作」知识星球 一起探索

5.Angular Guard 是不安全的

Angular Guard 是人为地对指定路由限制访问的最佳方式。例如:要在用户访问页面之前检查是否已经登录。以下是一个简单示例:

auth.guard.ts

1
2
3
4
5
6
7
8
9
10
11
12
import { Injectable } from '@angular/core'
import { AuthenticationService } from './authentication.service'
import { CanActivate } from '@angular/router'

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthenticationService) {}

  canActivate() {
    return this.authService.isAuthenticated()
  }
}

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@NgModule({
  imports: [
    RouterModule.forRoot([
    {
        path: '',
        component: SomeComponent,
        canActivate: [AuthGuard]
    ])
  ],
  providers: [
    AuthGuard,
    AuthenticationService
  ]
})
export class AppModule {}
问题

因为应用程序在客户端可以以任何方式被更改,这意味着,通过注释某些代码可以很轻易的绕开 Guard。这样就可以自由访问仅用路由限制的客户端数据。

解决方案

因此,如果需要保护任何敏感数据,则还需要一个真正安全的,基于服务端的解决方案。例如,带有签名的 JSON Web Token

6.只声明一次组件

使用组件的前提是必须在对应的模块中声明它们。开发中普遍遇到的一个问题:一个组件只能在一个模块中声明!

很明显在开发中组件要在多个模块中使用,但又不能在多个模块中声明,这种场景中应该怎么处理?。

解决方案

只需将组件封装到模块中即可,使用组件时在当前模块中导入组件所在的模块。需要注意的是不仅要在组件模块中声明组件,还需要导出它们。

1
2
3
4
5
6
@NgModule({
  imports: [CommonModule],
  declarations: [LoginComponent, RegisterComponent, HelpComponent],
  exports: [LoginComponent, RegisterComponent, HelpComponent],
})
export class AuthenticationModule {}

7.用 [hidden] 代替 *ngIf 以提高应用渲染性能

另一个常见的错误就是混淆了 [hidden]*ngIf

[hidden] 属性

hidden 属性切换元素的可见性,有时候这就是在应用中想要的效果。当 [hidden] 的值设置为 true,则 css 属性 display 的值会变为 none,但是这个元素仍然在 DOM 中存在。

使用 hidden 属性有两个问题需要注意。

第一,其他 css 属性会意外的覆盖用 hidden 属性切换的 css 属性。例如,在组件样式中将元素 display 的值设置为 block,这将覆盖 display: none,会导致元素始终可见。

第二,如果有大量的(成百上千个)元素需要控制可见性,当这些元素不可见时依然会保留在 DOM 中,那么这些元素会降低浏览器性能。如果不需要这些元素,就把它们删了。

*ngIf 指令

*ngIf 指令的主要区别就是不仅隐藏了元素,还会从 DOM 中完全删除元素。

当使用 *ngIf 指令隐藏元素后,这些元素将很难调试,因为已经从 DOM 中删除的元素无法再使用浏览器的 DOM Explorer 检查。

8.通过封装 Service 避免可维护性问题

将核心业务逻辑抽出来放到 service 中是一种好习惯,这样以后维护会变得更加容易,因为可以在短时间内就可以将其替换为新的实现。

在需要从外部获取数据的时候,这很有用。当服务端在还没有完全提供数据的时候就可以在 service 中 mock 数据,以保证组件能正常运行。当服务端能提供完整数据的时候再把 mock 数据替换为 HTTP 请求,这样可以节省很多时间在修改代码上。

9.记得取消订阅

在处理 RxJ 的 Observables 和 Subscriptions 时,使用不当很容易会泄漏一些内存。组件被销毁了,但是在可观察对象内部注册的功能却未被销毁。这样,不仅会泄漏内存,可能还会遇到一些奇怪的行为。

解决方案

为防止发生这种情况,确保在组件销毁后取消订阅。

推荐下面这种方法:

1
2
3
4
5
6
7
8
9
10
11
12
...

private unsubscribe$ = new Subject<void>();

ngOnInit() {
    something.pipe(takeUntil(this.unsubscribe$)).subscribe();
}

ngOnDestroy() {
  this.unsubscribe$.next();
  this.unsubscribe$.complete();
}

还有一种方式是使用第三方库实现,比如:ngx-take-until-destroy

comments powered by Disqus