Angular项目渲染、加载速度缓慢的性能优化方案

1,253 阅读14分钟

问题描述

项目目前遇到了一个比较大的问题,每次我使用ng build打包系统上传到服务器更新之后,同事打开项目需要加载非常久的时间,加载约10分钟左右。

并且国内网和国外网的加载时间不一样,国外网4-10分钟,工厂的同事用国内网甚至一直加载不出来。

于是我开始查资料学习并实践,在此记录自己的优化历程,所以此文章仅用于分享自己的经验,并不是全面的专业技术文章,仅供参考!

文章中有大部分优化的思路和防线都来自于这篇文章Angular:性能优化清单,这篇文章讲的会更专业、细致很多,有需要可以自行阅读。

项目信息

环境配置

OS:Windows10x64
Angular版本:Angular-11.0.0
NodeJS版本:NodeJs-14.15.4
我测试使用的浏览器:FireFox 同事使用的浏览器:Chrome

项目原始大小

image.png

ng build打包

打包后大小:

image.png

打包时间:242416ms(4.04分钟)

加载时间

加载时间主要以以下三种为参考,是最初使用ng build指令且没有任何优化的情况:

  1. 我自己的电脑:国内网约4min,国外网约0.5min
  2. 产品经理的电脑:国内网10min起步,国外网4min起步
  3. 工厂同事的电脑:国内网:无法打开,无国外网

电脑配置:我>产品经理>工厂同事

在进入系统的时候,可以在控制台看到加载中其实一直在加载一个文件:vendor-es2015.js(其他很快就加载完了)

我自己的理解是,Angular将所有代码打包成一个js文件,在进入系统的时候需要将这个非常大的js文件加载完才可以进入系统。
那么有没有方法可以缩小js文件的大小、或采用分布式加载(只加载当前页面,不加载未使用的页面)呢,带着这个疑问我开始在网络上去查资料

第一步优化:优化编译机制

了解Angular的编译原理

在优化之前先需要知道Angular是怎么编译的, Angular的编译就是将Angular项目中的组件、管道、指令、HTML模板、Typescript编译成ES5文件,这样在浏览器JavaScript Virtual Machines (VM)就可以直接运行了。

了解Angular的两种编译机制:JiT和AoT

这里不深入说明,只大致讲解区别。 从网络查询资料得到两种编译机制的具体流程如下 JiT的编译机制:

  • 基于 TypeScript 开发 Angular 项目
  • 用 tsc 编译 Angular 项目
  • 打包
  • Minification
  • 部署

当用户访问这个网站的时候:

  • 下载相关的静态资源文件,包括 Angular 编译器(@angular/compiler)
  • 启动 Angular
  • Angular 编译器执行编译(ngc)
  • 页面渲染

AoT的编译机制:

  • 基于 TypeScript 开发 Angular 项目
  • 编译 Angular 项目
  • 先把模板和 component 编译成 TypeScript/es6(ngc)
  • 再把 TypeScript 编译成 es5 (tsc)
  • 打包
  • Minification
  • 部署

当用户访问网站的时候:

  • 下载相关的静态资源文件,不需要下载 Angular 编译器(@angular/compiler)
  • 启动 Angular
  • 页面渲染

简单来说,AoT相比于JiT,在打包运行后不需要再下载Angular编译器,也不需要执行编译,而是直接渲染页面,在性能上优于JiT不少。

那么网络上就得到优化,因为AoT不需要再下载Angular编译器,所以因为国内外网差距导致的下载网速问题就解决了。

AoT渲染的速度和时机也都优于JiT,那么重新进入系统导致长时间加载的问题也得到了解决。

如何使用AoT编译

将打包指令修改为ng build --aot即可

需要值得注意的是,通过查询资料我得知Angular6版本的时候,本地运行也可以使用AoT编译,也就是ng serve --aot指令

但我在Angular11项目下使用该指令,显示已经弃用该指令,而是在浏览器中设置:

Option "aot" is deprecated: Use the "aot" option in the browser builder instead.

怎么在本地使用AoT编译我目前也暂时没找到使用方法

ng build --aot打包

打包后大小:

image.png
打包时间:215567ms(3.59分钟)

可以看到打包后的大小并没有区别,但我自己实际体验之后系统的加载速度明显快了不少,我接下来直接进行了第二步优化

第二步优化:Tree Shaking

了解Dead Code

在项目中,前同事的架构做的不是很成熟,公司在代码方面没有很高的要求,外加我刚进公司的时候也是比较萌新

所以导致了很多Dead Code(无效代码)的产生,例如某些并没有使用的引用、函数、css等

这些在打包编译的时候都会打包进文件中,会占用很大的空间和无用的内存

我最开始其实有想过花一段时间去整理并删除那些无用代码,但工作量着实太大,并且也没法从根源上解决问题,毕竟我无法保证Dead Code的完全不产生,时间久之后自然也会堆积

了解Tree Shaking

这时候我了解到了Tree Shaking:可以把项目想象成一棵树,有效的代码就是树叶,无效、未调用的代码就是无用的枯树叶,Tree Shaking就是通过摇晃树使枯叶掉落(忽略无效代码)

而Angular的某个编译指令自带Tree Shaking:ng build --prod,并且该指令是使用AoT编译的

ng build --prod打包

打包后大小:

image.png
打包时间:863208ms(14.38分钟)

可以看到打包后文件小了整整六分之一,但打包时间增加了三倍多,但系统的加载时间得到了显著提升,这新增的打包时间是完全值得的。

  1. 我自己的电脑:国内网1.9min,登录进入系统30s左右
  2. 产品经理的电脑:国内网2.1min,登录进入系统1min
  3. 工厂同事的电脑:暂时还未使用

但产品经理只是2.1min加载完登录界面,点击登录后等待了1min才进入系统主页

其中体现了两个问题:1.系统登录界面加载速度还是有点慢 2.1min的时间应该是在预加载整个系统,时间过长

目前我的思路就是试着尝试结合懒加载和预加载,预加载系统主页和登录界面,其他模块功能则采用懒加载,其中还可能用到WebPack(需要从0开始学习,以前没用过)。

第三步优化:LazyLoading和PreLoading

LazyLoading和PreLoading的定义

LazyLoading:懒加载,也就是按需加载,系统中的某些模块在未被使用的时候不会被加载,当点击模块进入的时候才会开始加载。
PreLoading:预加载,在访问页面之前就加载好所有需要的文件。

实现预加载

我们需要知道,系统的第一个页面一定是登录页面,第二个页面一定是DashBoard页面(登录后进入),所以这两个页面一定是优先加载的

而其他页面(或者说是模块)非常多,并且根据同事的权限不同并不是所有人都会用所有功能,那么其他页面设置为按需加载就很合适了。

根据资料Angular 快速入门 | Router ( 懒加载 / 预加载 )实现PreLoading,将app.module.ts文件代码修改为:

const appRoutes: Routes = [
  {
    path: 'apps',
    loadChildren: () => import('./main/apps/apps.module').then(m => m.AppsModule)
  },
  {
    path: '**',
    redirectTo: 'login',
    data: { preloading: true }
  },
];
@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    ……
    RouterModule.forRoot(appRoutes, { preloadingStrategy: CustomPreloadingStrategy }),
    ……
    ]

CustomPreloadingStrategy文件代码如下:

import { Injectable } from '@angular/core';
import { Route } from '@angular/router';
import { PreloadingStrategy } from '@angular/router';
import { Observable, of } from 'rxjs';

const PRELOADING = true;

@Injectable({
    providedIn: 'root',
})
// CanLoad 会阻塞预加载
export class CustomPreloadingStrategy implements PreloadingStrategy {
    // 例如用上文中的 data 中的 preloading
    preload(route: Route, fn: () => Observable<any>): Observable<any> {
        if (PRELOADING) {
            // 加载资源
            return fn();
        }
        // 不加载资源
        return of(null);
    }
}

更新至服务器后进行测试,得到如下加载时间:

  1. 我自己的电脑:首页加载国内网14.57s,登录进入系统27.54s
  2. 产品经理的电脑:首页加载国内网56s,登录进入系统58s
  3. 工厂同事的电脑:暂时还未使用

可以看到首页加载的速度提升了很多,可见预加载产生了作用。
但由于前同事给系统内模块的路由配置是这样的(也就是app.module.ts中的AppsModule)

const routes = [
  {
    path: 'dashboards',
    loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule),
    data: { preloading: true }
  },
    {
    path: 'warehousing',
    canActivateChild: [RouterGuard],
    loadChildren: () => import('./warehousing/warehousing.module').then(m => m.WarehousingModule)
  },
  ……
  ]
@NgModule({
  imports: [
    RouterModule.forChild(routes),
    FuseSharedModule,
  ],
  declarations: [],
})

如代码所示我同样给dashboard设置了预加载,但却没有生效(或者说没有提升加载速度)

资料中懒加载使用的是loadComponent,而我的代码中是loadChildren,下方路由为forChild(只能传一个参数,不能和forRoot一样传预加载),接下来是阅读文档了解一下这里的区别,是否对加载有影响。

如果dashboard无法预加载,那么后续可能会考虑代码切割的方式去优化。

实现懒加载

从上面的代码其实有看到,前同事是有意去做懒加载的,也就是loadChildren

但我在监控系统加载的时候,发现在登录界面的时候就已经在预加载整个系统了

image.png

也就是说懒加载并没有生效,为什么呢?带着疑问我去详细阅读了Angular官方文档中讲懒加载的部分:惰性加载特性模块(非常推荐去阅读一下)

虽然系统代码中确实使用了loadChildren的格式去实现懒加载,但是却忽视了文档中非常重要的一点,文档中是这么说的:

输入下列命令,其中的 customer-app 表示你的应用名称。

content_copyng new customer-app --routing

这会创建一个名叫 customer-app 的应用,而 --routing 标识生成了一个名叫 app-routing.module.ts 的文件.它是你建立惰性加载的特性模块时所必须的。输入命令 cd customer-app 进入该项目。

app-routing.module.ts是必须文件,而系统中关于路由的定义却在app.module.ts中,自然没法实现懒加载了。

在实现预加载的时候我发现了系统其实是分为两个路由的,主路由AppModule(存放AppsModule和登录界面)和子路由AppsModule(存放功能页面),这样导致了我无法对某个功能页面实现预加载。

所以在实现懒加载的同时,我顺便重构了一下路由的配置:

新建app-routing.module.ts文件,给dashboards和登录界面设置预加载

……
const routes: Routes = [
    {
        path: 'apps/dashboards',
        loadChildren: () => import('./main/apps/dashboard/dashboard.module').then(m => m.DashboardModule),
        data: { preloading: true }
    },
    {
        path: 'apps/warehousing',
        canActivateChild: [RouterGuard],
        loadChildren: () => import('./main/apps/warehousing/warehousing.module').then(m => m.WarehousingModule)
    },
    ……
    {
        path: '**',
        redirectTo: 'login',
        data: { preloading: true }
    },
];

@NgModule({
    imports: [
        RouterModule.forRoot(routes)
    ],
    exports: [RouterModule],
    providers: []
})
export class AppRoutingModule { }


/*
Copyright Google LLC. All Rights Reserved.
Use of this source code is governed by an MIT-style license that
can be found in the LICENSE file at https://angular.io/license
*/

app.module.ts中引入即可

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    ……
    AppRoutingModule,
  ],
})

更新至服务器后进行测试,得到如下加载时间:

  1. 我自己的电脑:首页加载国内网41s,登录进入系统8s
  2. 产品经理的电脑:首页加载47s,登录进入系统20s

为啥首页加载还变慢了呢,因为现在系统设置预加载dashboard和登录界面两个页面,所以登录页面加载会变慢一些,但登录进系统(也就是进dashboard)变快了不少,总体来说加载速度是提升了。

Angular8及以上使用懒加载的注意事项

我在网络上查资料的时候有看到字符串实现懒加载的方式,例如下面的代码:

const routes: Routes = [
  {
    path: 'customers',
    loadChildren: './customers/customers.module#CustomersModule'
  },
  {
    path: 'orders',
    loadChildren: './orders/orders.module#OrdersModule'
  },
  {
    path: '',
    redirectTo: '',
    pathMatch: 'full'
  }
];

这样的实现在8以下的版本实现没有问题的,但8及以上版本这样使用就会报错,Angular文档中有讲:

在 Angular 版本 8 中,loadChildren 路由规范的字符串语法已弃用,建议改用 import() 语法。你可以通过在 tsconfig 文件中包含惰性加载的路由来选择使用基于字符串的惰性加载(loadChildren: './path/to/module#Module'),这样它就会在编译时包含惰性加载的文件。 默认情况下,会用 Angular CLI 生成项目,这些项目将更严格地包含旨在与 import() 语法一起使用的文件。

最后一步优化:webpack打包

其实完成前面的步骤之后,产品就觉得已经足够了,毕竟每次更新系统等个一两分钟还是可以接受的。

但是我有注意到其实在系统加载的时候其实一直是在加载main.js文件,那么有没有方法能缩小main文件呢,这样也可以提升加载速度,还真有

webpack

从webpack中文文档可以得知:

webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容

简单来说,webpack可以优化、自定义打包方式。

webpack-bundle-analyzer

webpack-bundle-analyzer的功能是使用交互式可缩放树图可视化 webpack 输出文件的大小

main.js文件过大的其中一个原因就是在AppModule中引入了过多无用的资源,例如组件、图标等等(在我记忆中我引入NG-ZORRO组件的时候是直接引入的所有图标),讲这些无用多余的引入删除也可以缩小main.js的大小。

那么通过webpack-bundle-analyzer就可以很清晰的看到哪些组件、哪些引用占用了大部分资源,针对性的优化即可。

但这次我偷懒了一下(毕竟产品说已经够了),就不对引入进行优化了,只导入webpack

导入并使用webpack和webpack-bundle-analyzer

在网上查询了非常多webpack的使用流程、安装的文章,发现基本都没法用(因为我偷懒没去看文档)

后来等全部搞定了去查文档才发现,文档中讲的非常清晰……建议还是去跟着文档操作

从 v4.0.0 开始,webpack 可以不用再引入一个配置文件来打包项目,然而,它仍然有着 高度可配置性,可以很好满足你的需求

因为我是Angular11,从NPM网可以看到webpack最新为5.x,凭我的经验来看,最新的那款必定和我的不兼容,那么下载量最高的4.46.0(说明最稳定)肯定就适合我了

npm install webpack@4.46.0
npm install --save-dev webpack-bundle-analyzer

安装完成之后如果进行编译或打包会发现报错了No template for dependency: ConstDependency

通过查询资料输入

npm i worker-plugin --save-dev

即可解决

中途还安装了一个webpack-cli@5.1.4不清楚有没有作用

在项目根目录下创建一个文件webpack.config.js,使用默认配置

// webpack.config.js 文件

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
  entry: './src/index.html',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  plugins: [
    new BundleAnalyzerPlugin()  // 使用默认配置
    // 默认配置的具体配置项
    // new BundleAnalyzerPlugin({
    //   analyzerMode: 'server',
    //   analyzerHost: '127.0.0.1',
    //   analyzerPort: '8888',
    //   reportFilename: 'report.html',
    //   defaultSizes: 'parsed',
    //   openAnalyzer: true,
    //   generateStatsFile: false,
    //   statsFilename: 'stats.json',
    //   statsOptions: null,
    //   excludeAssets: null,
    //   logLevel: info
    // })
  ]
}

依次运行

ng build --stats-json

npx webpack-bundle-analyzer dist/your-project-name/stats.json 

第一步是生成文件

因为我要模拟打包上传的情况,所以我使用的是ng buil --prod --stats-json

第二步是webpack-bundle-analyzer打开生成的文件,会自动打开浏览器网页就可以使用啦(地址是http://127.0.0.1:8888

成果

当我重新使用ng buil --prod打包的时候惊喜的发现,我的main.js文件已经从3.32MB变成了2.46MB,加载时间也有所提升(但不多)

最后总结

经过本次优化,在国内网下系统加载的时间(加载完登录界面、点击登录进入首页的总时间)优化成果如下:

我:从4min+优化为40s左右
产品经理:从10min+优化为1min左右
工厂同事:暂定(还未使用)

整个项目肯定是还有更多的优化空间的,例如RxJS缓存、代码切割等,但产品给我这个任务留的时间就这么点,以后有需要再说了,在这次过程中也是学到很多东西,整体还是很满意的!

参考资料

Angular:性能优化清单

Angular 快速入门 | Router ( 懒加载 / 预加载 )

Angular - How to improve bundle size. And make your Angular app load faster

Angular 项目过大?合理拆分它!

Analyzing Angular bundle with Webpack Bundle Analyzer

Angular中文文档:惰性加载特性模块

webpack中文文档