Better

Ethan的博客,欢迎访问交流

Angular 懒加载实践

最近对公司项目开启懒加载,还是踩到不少坑的,简单记录下吧

enableProdMode

开发关键不要调用 enableProdMode,会导致很多错误被屏蔽掉。

NgModule 作用域

应用最先加载的是 AppModule 模块。NgModule 目的就是声明你创建的每个东西,然后将他们组合起来。两个主要部分

  • declarations:你会在模板中用到的东西,主要是 components,还有 directivespipes
  • providers:services

第一个困惑点:components 和 services 有不同的作用于

  • declarations 是 local scope,相当于 private visibility
  • providers 通常是 global scope,相当于 public visibility

这说明你声明的组件只能在当前 module 可用,如果你需要在其他 module 中使用,你需要 exports 出去。

相反,你声明的 services 在整个 app 都是可用的,也就是所有的 modules 中。

你通常不会只有一个 module,而是多个 modules,Angular 本身就分割成多个 modules,比如 core、common、http 等。因此另一件主要事情就是 import 其他你需要的 modules

你需要知道为什么引入其他 moudule

  • 为了使用 components、directives、pipes?
  • 使用 services?

为什么?主要还是由于这两者的区别

  • 如果是为了使用 components,你需要在每个需要他们 module 中导入
  • 如果是为了使用 services,你只需要导入一次,在 app module 中

如果没有遵循这两点,针对组件,可能会有组件不可用错误,针对服务,在高级场景中会导致一些错误,比如 lazy-loading

何时导入主要的 angular 模块

以下是你需要时,每次都需要导入的 modules

  • CommonModule,app module 中不需要,因为已经被包含在 BrowserModule
  • FormsModule / ReactiveFormsModule
  • UI modules
  • 其他给你提供 components、directives、pipes 的 module

以下模块你只需要导入一次

  • HttpClientModule
  • BrowserAnimationsModule or NoopAnimationsModule
  • 其他提供服务的 module

混合模块:既提供组件,又提供服务,比如 RouterModule,通常这里的混乱交给模块自身处理,比如 Router 提供 forRoot 和 forChild。

  • forRoot:提供组件和服务
  • forChild:仅提供组件

懒加载

  • 对于组件而言,不需要改变任何东西
  • 对于服务而言
    • 仍然可以访问到 app provider 的服务,比如 HttpClient
    • 懒加载模块中提供的服务,只能在懒加载模块中使用

通常的 Module 结构为

AppModule
|- BrowserModule (includes CommonModule)
|- AppRoutingModule
|- GlobalServicesModules (HTTP, auth…)
|- FeatureModule
   |- CommonModule
   |- UIModules (Material, forms, your own UI...)
   |- FeatureRoutingModule

开发环境错误

生命周期

这里主要涉及到 ngOnInitngAfterViewInit,不要在 ngAfterViewInit 做一些同步修改 UI 数据的事情,即使 subscribe 也可能不行,因为其有可能是同步执行。

具体原因就是:ngAfterViewInit 的触发时机为脏检测之后,组件已经准备渲染,如果此时触发数据修改,就会报错 ExpressionChangedAfterItHasBeenCheckedError

动态加载组件

一般场景为显示弹窗,因为 materialdialog 采用动态加载组件的方式,如果在 ngOnInit 加载组件,同样会报上述错误。具体见:Opening MdDialog in ngOnInit throws ExpressionChangedAfterItHasBeenCheckedError

共享服务

如果父组件模块使用 shared service,然后子组件在某个情况下,修改了 service 中的值,同样会导致上述报错。

ViewChild 报错

ViewChild 获取子组件示例的方式,如果该组件被包裹在条件判断中,比如 ngIf,如果该实例同时作为 props 进行传递,则会导致上述错误,在 Angular 7.x 中没有找到合适的办法,但在 Angular 8.x 中提供了 static 属性可以帮到我们,大概内容如下

// query results available in ngOnInit
@ViewChild('foo', {static: true}) foo: ElementRef; 
// query results available in ngAfterViewInit
@ViewChild('foo', {static: false}) foo: ElementRef;

更多内容见 doc:Static Query Migration Guide

ngFor 遍历

ngFor 直接遍历 iterable 对象,会导致错误

踩坑集合

服务与懒加载组件

如果使用 root 级别的 service,动态加载懒模块中的组件,会导致组件无法被加载,提示 entryComponent 没有注册,但实际上是注册了的。

解决办法就是:提供一个模块级别的 service,具体见:Getting "No component factory" error while opening a Material Dialog in lazy loaded Component

路由参数

这里有个小坑需要注意,懒加载的实现依赖于路由,因此路由层级会有所调整,涉及到路由参数的地方可能存在问题。

比如项目中的 p/projectId/plan/planId 的获取,会通过 activatedRoute.parent 获取参数,修改之后层级增加一层,需要修改为 activatedRoute.parent.parent 来获取。

懒加载与预加载

懒加载应用场景为提高首屏渲染速度,应用被我们分成多个 chunk 之后,后续业务级别 chunk 只有在需要的时候才会加载。

这里存在一个问题就是,如果 chunk 过大,会导致进入组件速度过慢,因此 chunk 的颗粒度很重要。

有懒加载就还有预加载,用于提高用于的后续交互体验,在 Angular 中支持全部预加载,也至于自定义预加载,具体使用见 doc 即可。

懒加载之后,如果 chunk 过大,可能会导致一段时间的白屏,考虑是否需要添加加载动画

Angular 变化检测

Angular 在变化监测阶段会按以下顺序对每个组件执行操作

  1. 更新所有绑定在子 component/directive 上的属性
  2. 调用所有子 component/directive 的 ngOnInitngOnChangesngDoCheck 生命周期函数
  3. 解析、更新当前组件 DOM 上的 value
  4. 运行子组件的变化监测流程
  5. 调用所有子 component/directive 上的 ngAfterViewIint 生命周期

每一步操作后,Angular 会报错与这次操作有关的 value 值,这个 值被存在组件的 oldValues 属性中,开发模式下,所有组件完成变化监测之后会开始下一个监测流程,第二次监测流程有所不同,而是比较之前监测循环保存的值与当前监测的值是否一致

  1. 检查被传递到子组件的 values(oldValues) 与当前组件要被用于更新的 values(instance.value) 是否一致
  2. 检查被用于更新 DOM 元素的 values(oldValues) 与当前要被用于这些组件更新的 values(instance.value) 是否一致
  3. 对所有子组件执行相同的检查

这些额外的检查只发生在开发模式下,第二次监测中,如果 oldValues !== instance.value,报错 ExpressionChangedAfterItHasBeenCheckedError 也就产生了,这是你需要重构代码信息。这也是为什么生产环境运行快一些的原因

数据改变原因

可能会觉得好好的数据,怎么会在第二次监测被改变了呢,通常原因为

  1. 子组件或指令修改了父组件的值
  2. 组件 ngAfterViewInit 进行修改
  3. 共享服务:父组件和子组件共用一个共享服务,子元素通过共享服务设置一个属性的值并反映到父元素上
  4. 同步事件广播:给 EventEmitter 传一个 true 能使事件的 emit 变为异步
  5. 动态的组件实例化:前两种模式都是 List2 中的第一步检测抛出的错误,而这种模式是由 DOM 更新检测(List2第二步)抛出的错误。父组件在 ngAfterViewInit 生命周期中动态添加子组件,该生命周期发生在当前组件 DOM 初次更新之后,而添加子组件将会修改 DOM 结构,那么前后两次 DOM 中所使用的 values 值就不同了

解决方案

常见解决办法

  1. ngOnInit 进行处理
  2. 异步更新 setTimeoutPromise.resolve().then(() => { code here })
  3. 强制变化检测 detectChanges
  4. 关闭自动变化检测,手动告知变化检测

将组件的 ChangeDetectionStrategy 声明为 OnPush,此时仅仅会监测 Input 改变,其余的就要靠你自己了,这也是针对复杂组件的性能优化手段

为什么需要

Angular 强制使用至上而下的单向数据流,在父元素完成变化监测之后不允许内部子组件在第二次变化监测前改变父组件的属性。这能确保第一次变化监测后的组件树是稳定的。

为什么第二次循环监测只在开发模式下运行?我猜想这是因为数据层不稳定在框架运行时并不会产生引人关注的错误,毕竟数据在下一次监测循环后就会稳定下来。当然,在开发时期将可能得错误解决总好过在上线后的应用中排查错误。

扩展阅读

Angular 项目结构设计,具体见末尾资料

资料



留言