问题描述
项目目前遇到了一个比较大的问题,每次我使用ng build
打包系统上传到服务器更新之后,同事打开项目需要加载非常久的时间,加载约10分钟左右。
并且国内网和国外网的加载时间不一样,国外网4-10分钟,工厂的同事用国内网甚至一直加载不出来。
于是我开始查资料学习并实践,在此记录自己的优化历程,所以此文章仅用于分享自己的经验,并不是全面的专业技术文章,仅供参考!
文章中有大部分优化的思路和防线都来自于这篇文章Angular:性能优化清单,这篇文章讲的会更专业、细致很多,有需要可以自行阅读。
项目信息
环境配置
OS:Windows10x64
Angular版本:Angular-11.0.0
NodeJS版本:NodeJs-14.15.4
我测试使用的浏览器:FireFox 同事使用的浏览器:Chrome
项目原始大小
ng build打包
打包后大小:
打包时间:242416ms(4.04分钟)
加载时间
加载时间主要以以下三种为参考,是最初使用ng build
指令且没有任何优化的情况:
- 我自己的电脑:国内网约4min,国外网约0.5min
- 产品经理的电脑:国内网10min起步,国外网4min起步
- 工厂同事的电脑:国内网:无法打开,无国外网
电脑配置:我>产品经理>工厂同事
在进入系统的时候,可以在控制台看到加载中其实一直在加载一个文件: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打包
打包后大小:
打包时间: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打包
打包后大小:
打包时间:863208ms(14.38分钟)
可以看到打包后文件小了整整六分之一,但打包时间增加了三倍多,但系统的加载时间得到了显著提升,这新增的打包时间是完全值得的。
- 我自己的电脑:国内网1.9min,登录进入系统30s左右
- 产品经理的电脑:国内网2.1min,登录进入系统1min
- 工厂同事的电脑:暂时还未使用
但产品经理只是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);
}
}
更新至服务器后进行测试,得到如下加载时间:
- 我自己的电脑:首页加载国内网14.57s,登录进入系统27.54s
- 产品经理的电脑:首页加载国内网56s,登录进入系统58s
- 工厂同事的电脑:暂时还未使用
可以看到首页加载的速度提升了很多,可见预加载产生了作用。
但由于前同事给系统内模块的路由配置是这样的(也就是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
但我在监控系统加载的时候,发现在登录界面的时候就已经在预加载整个系统了
也就是说懒加载并没有生效,为什么呢?带着疑问我去详细阅读了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,
],
})
更新至服务器后进行测试,得到如下加载时间:
- 我自己的电脑:首页加载国内网41s,登录进入系统8s
- 产品经理的电脑:首页加载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 快速入门 | Router ( 懒加载 / 预加载 )
Angular - How to improve bundle size. And make your Angular app load faster