Angular 性能优化实战

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Angular 性能优化实战相关的知识,希望对你有一定的参考价值。

Angular 性能优化实战

Angular 是一个非常强大的前端框架,但是如果不注意性能优化,应用程序可能会变得非常慢并增加加载时间。 以下是一些Angular性能优化经验的实战建议:

1. 使用 OnPush 变更检测策略

默认情况下,Angular检查应用程序中发生的所有数据更改,从而导致性能下降。为了解决这个问题,可以使用OnPush变更检测策略,这将只在输入绑定值发生更改时才启动变更检测。这个策略只适用于具有@Input() properties的组件,并且需要手动设置。

import Component, ChangeDetectionStrategy from @angular/core;

@Component(
    selector: app-sample-component,
    templateUrl: ./sample.component.html,
    styleUrls: [./sample.component.scss],
    changeDetection: ChangeDetectionStrategy.OnPush
)
export class SampleComponent 
    // ...

定义一个组件并设置 changeDetection 选项为 OnPush ,如上所述。

2. 使用轻量级的管道

Angular中的管道可以用来转换数据,并在模板中显示不同的输出。使用轻量级的管道可以提高Angular应用的性能。

一个经典的例子是将日期格式化为特定的字符串形式。我们可以使用内置的DatePipe管道来实现这一点,但是它可能会导致性能问题。相反,我们可以编写一个自定义的轻量级管道来执行相同的任务:

import  Pipe, PipeTransform  from @angular/core;

@Pipe(
  name: customDate
)
export class CustomDatePipe implements PipeTransform 
  transform(value: any): string 
    const date = new Date(value);
    return `$date.getDate()/$date.getMonth() + 1/$date.getFullYear()`;
  

在模板中使用这个自定义管道:

<p> myDate | customDate </p>

在 Angular 中使用管道来转换数据是很方便的,但是需要注意的是,某些管道可能会对性能产生负面影响。建议遵循以下规则使用轻量级的管道:

  • 尽可能使用纯管道: 纯管道指输入不变时,输出永远不变的管道,它们只在输入发生变化时进行计算,在模板中绑定的表达式将不会多次被执行。
  • 避免使用复杂管道: 复杂管道需要更多的计算资源,因此应该尽可能避免使用它们。当您必须使用复杂管道时,应该将其结果缓存起来,以便在需要时可以重用它们。
import Pipe, PipeTransform from @angular/core;

@Pipe(name: uppercase)
export class UpperCasePipe implements PipeTransform 
    transform(value: string): string 
        return value.toUpperCase();
    

这样做的好处是这个自定义管道没有过多的附加操作或计算,因此它比内置的DatePipe稍微快一些,从而提高了整个应用程序的性能。

3. 使用懒加载模块

在 Angular 中,懒加载模块可以帮助你分割应用程序并提高加载时间。使用懒加载模块可以将某些代码推迟到用户需要时才加载。

为了使模块成为懒加载模块,你需要在路由中使用 loadChildren 而不是 component 属性。

const routes: Routes = [
  
    path: lazy,
    loadChildren: () => import(./lazy/lazy.module).then(m => m.LazyModule)
  
];

4. 使用 trackBy 帮助 ngFor 优化

当使用 ngFor 循环渲染数组或列表时,如果数据发生变化,“脏检查”机制会触发重新渲染所有列表项。

通过使用 trackBy 函数,可以告诉 Angular 如何跟踪列表项的变化,从而避免不必要的渲染。

<ul>
  <li *ngFor="let item of items; trackBy: itemTrackByFn"> item </li>
</ul>
itemTrackByFn(index, item) 
  return item.id;

5. 避免在引用类型中改变对象的属性

在 Angular 应用程序中,通过在组件和服务之间传递引用类型,可以轻松地共享数据。

但是,如果你试图修改已经在其他地方使用的对象的属性,则所有对该对象的引用都将受到影响,这可能导致不必要的变更检测。

为了避免这种情况,尽量避免直接修改对象的属性,而是使用 Object.assign()spread 运算符创建新对象。

const user =  id: 1, name: John Doe, email: john@example.com ;

// 不好的写法
this.userService.updateUser(user.id, user.name);
user.email = new-email@example.com;

// 好的写法
this.userService.updateUser(user.id, user.name,  email: new-email@example.com );

6. 使用虚拟滚动

当你需要处理大量数据时,虚拟滚动可以帮助你实现快速、流畅的滚动体验,而无需渲染整个列表。

Angular CDK 提供了一个名为 CdkVirtualScrollViewport 的指令,它可以帮助你实现虚拟滚动。

<cdk-virtual-scroll-viewport itemSize="50" class="example-viewport">
  <div *cdkVirtualFor="let item of items" class="example-item">item</div>
</cdk-virtual-scroll-viewport>

以上是一些 Angular 性能优化的实战,其中一些技术可以单独应用,同时使用全部技巧可以帮助你最大程度地提高 Angular 应用程序的性能并改善用户体验。

7. 开启 AOT 编译

开启 AOT 编译可以大大提高应用程序的性能。AOT 编译将在构建期间对组件/指令和模板进行编译,并将生成的 javascript 代码直接注入到 HTML 模板中。这意味着在运行时,浏览器不再需要编译模板,从而减少启动时间和加载时间。

具体来说,以下是如何开启 AOT 编译:

  • 在 Angular CLI 中使用 --aot 选项构建您的应用程序:ng build --aot
  • 在 Angular 应用程序中配置 JIT 编译器,以便像 AOT 所做的那样提前编译组件:
@NgModule(
  // ...
  providers: [
    
      provide: COMPILER_OPTIONS,
      useValue: 
        providers: [useClass: JitCompiler]
      ,
      multi: true
    
  ],
  // ...
)
export class AppModule 

以上是一些 Angular 性能优化的实战,其中一些技术可以单独应用,也可以同时使用,它可以帮助你最大程度地提高 Angular 应用程序的性能并改善用户体验。

优化Angular应用的性能

 

MVVM框架的性能,其实就取决于几个因素:

  • 监控的个数

  • 数据变更检测与绑定的方式

  • 索引的性能

  • 数据的大小

  • 数据的结构

我们要优化Angular项目的性能,也需要从这几个方面入手。 

1. 减少监控值的个数

监控值的个数怎么减少呢?

考虑极端情况,在不引入Angular的时候,监控的个数是为0的,每当我们有需要绑定的数据项,就产生了监控值。

我们注意到,Angular里面使用了一种HTML模板语法来做绑定,开发业务项目非常方便,但考虑一下,这种所谓的“模板”,其实与我们常见的那种模板是不同的。

传统的模板,是静态模板,将数据代入模板之后生成界面,之后数据再有变化,界面也不会变。但Angular的这种“模板”是动态的,当界面生成完毕,数据产生变更的时候,界面还是会更新。

这是Angular的优势,但我们有时候也会因为使用不当,反而增加困扰。因为Angular采用了变动检测的方式来跟踪数据的变化,这些事情都是有负担的,很多时候,有些数据在初始化之后就不再会变化,但因为我们没有把它们区分出来,Angular还是要生成一个监听器来跟踪这部分数据的变化,性能也就受到牵累。

在这种情况下,可以采用单次绑定,仅在初始化的时候把这些数据绑定,语法如下:

<div>{{::item}}</div>

<ul>  

  <li ng-repeat="item in ::items">{{item}}</li>

</ul>

这样的数据就不会被持续观测,也就有效减少了监控值的数目,提高了性能。 

2. 降低数据比对的开销

这一个环节是从数据变更检测与绑定的方式入手。细节不说太多了,之前都说过。从数据到界面的更新,一般就两种方式:推、拉。

所谓推,就是在set的时候,主动把与之相关的数据更新,大部分框架是这种方式,低版本浏览器用defineSetter之类。

function Employee() {

    this._firstName = "";

    this._lastName = "";

 

    this.fullName = "";

}

 

Employee.prototype = {

    get firstName(){

        return this._firstName;

    },

    set firstName(val){

        this._firstName = val;

        this.fullName = val + " " + this.lastName;

    },

    get lastName(){

        return this._lastName;

    },

    set lastName(val){

        this._lastName = val;

        this.fullName = this.lastName + " " + val;

    }

};

所谓拉,就是set的时候只改变自己,关联数据等到用的时候自己去取。比如:

function Employee() {

    this.firstName = "";

    this.lastName = "";

}

 

Employee.prototype = {

    get fullName() {

        return this.firstName + " " + this.lastName;

    }

};

有些框架中,两种方式都可以用。这时候可以自己考虑下适合用哪种方式,比如说,可能有些框架是合并变更,批量更新的,可能就用拉的方式效率高;有些框架是实时变动,差异更新的,那可能就是用推的效率高些。

上面的代码能看出来,从代码编写的简洁性来说,拉模式要比推模式简单很多,如果能预知数据量较小,可以这样用。

在实际开发过程中,这两种方式是需要权衡的。我们举的这个例子比较简单,如果说某个属性依赖于很多东西,例如,一个很大的购物列表,有个总价,它是由每个商品的单价乘以购买个数,再累加起来的。

在这种情况下,如果使用拉模式,也就是在总价的get上做这个变动,它需要遍历整个数组,重新作计算。但是如果使用推模式,每次有商品价格或者商品购买个数发生变更的时候,都只要在原先的总价上,减去两次变动的差价即可。

此外,不同的框架用不同方式来检测数据的变动,比如Angular,如果有一个数组中的元素发生变化了,它是怎样知道这个数组变了呢?

它需要保持变动之前的数据,然后作比对:

  • 首先比对数组的引用是否相等,这一步是为了检测数组的整体赋值,比如this.arr = [1, 2, 3]; 直接把原来的替换掉了,如果出现这种情况,就认为它肯定变化了。(其实,如果内容与原先相同,是可以认为没有变的,但因为这些框架的内部实现,往往都需要更新数据与DOM元素的索引关系,所以不能这样)

  • 其次,比较数组的长度,如果长度跟原先不相等了,那肯定也产生变化了

  • 然后只能挨个去比对里面元素的变化了

所以,会有人考虑在Angular中结合immutable这样的东西,加速变更的判定过程,因为immutable的数据只要发生任何变化,其引用都一定会变,所以只要第一步判定引用就足以知道数据是否改变了。

有人说,你这个判定降低的开销并不大啊,因为引入immutable要增加复制的开销,跟这里的新旧数据比对开销相比,也低不到哪里去。但这个地方要注意,Angular在有事件产生的时候,会把所有监控数据都重新比对,也就是说,如果你在界面上有个大数组,你从未对它重新赋值,而是经常在另外一个很小的表单项绑定的数据上进行更新,这个数组也是要被比对的,这就比较坑了,所以如果引入immutable,可以大幅降低平时这种不受影响时候的比对成本。 

但是引入immutable也会对整个应用造成影响,需要在每个赋值取值的地方都使用immutable的封装方式,而且还要在绑定的时候,对数据作解包,因为Angular绑定的数据是pojo。

所以,用这种方式还是要慎重,除非框架自身就构建在immutable的基础上。或许,我们可以期望有一套与ng-model平行的机制,ng-immutable之类,实现的难度也还是挺大的。

在使用ES5的场景下,可以利用一些方法加速判断,比如数组的:

  • filter

  • map

  • reduce

它们能够返回一个全新的数组,与原先的引用不等,所以在第一步判断就可以得出结果,不必继续后面几步的比较。

不过,这个环节的优化其实很不明显,最关键的优化在于与之配套的索引优化,参见下一节。

3. 提升索引的性能

在Angular中,可以通过ng-repeat来实现对数组或者对象的遍历,但这个遍历的机制,其实有很多技巧。

在使用简单类型数组的时候,我们很可能会碰到这么一个问题:数组中存在相同的值,比如:

this.arr = [1, 3, 5, 3]; 

<ul>

    <li ng-repeat="num in arr">{{num}}</li>

</ul> 

这时候会报错,然后如果去搜索一下,会发现一个解决方式:

<ul>

    <li ng-repeat="num in arr track by $index">{{num}}</li>

</ul>

为什么这就能解决呢?

我们先思考一下,如果自己实现类似Angular这样的功能,因为要在DOM和数据之间建立关联,这样,当改变数据的时候,才能刷新到对应的界面,所以,必然有个映射关系。

映射关系需要唯一的索引,在刚才那个例子中,Angular默认对简单类型使用自身当索引,当出现重复的时候,就会出错了。如果指定$index,也就是元素在数组中的下标为索引,就可以避免这个问题。

那么,对于对象数组,又是怎样呢?

比如说这么一个数组,我们用不同的两个方式来绑定:

function ListCtrl() {

    this.arr = [];

    for (var i=0; i10000; i++) {

        this.arr.push({

            id: i,

            label: "Item " + i

        });

    }

 

    var time = new Date();

    $timeout(function() {

        alert(new Date() - time);

        console.log(this.arr[0]);

    }.bind(this), 0);

}

<ul ng-controller="ListCtrl as listCtrl">

    <li ng-repeat="item in listCtrl.arr">{{item}}</li>

</ul>

<ul ng-controller="ListCtrl as listCtrl">

    <li ng-repeat="item in listCtrl.arr track by item.id">{{item}}</li>

</ul>

看示例地址,多点击几下:

我们惊奇地发现,这两个时间有不小差别。

关注一下在绑定之后,arr里面的数据,发现在没有加track by $index的时候,原始数据被改变了,添加了一些索引信息,这些索引是当数据产生变更时,Angular能够找到关联界面的重要线索。

Object {id: 0, label: "Item 0", $$hashKey: "object:4"}

如果我们知道数据的唯一性由什么保证,并且手动指定其为索引,可以减少不必要的添加索引的过程。

4. 降低数据的大小

看到这个标题,可能有人会感到奇怪。业务数据的大小并不是由程序员控制的,怎么降低呢?这里的降低,指的是降低那些被用于绑定到界面的数据大小。

数据的大小也会影响绑定效率,我们考虑一个屏幕能展示的数据有限,并不需要把所有东西都立即展示出来,可以从数据中截取一段进行展示,比如大家都熟悉的数据分页就是这么一种方式。 

很传统的那种数据分页,是会有一个分页条,上面写着总共多少数据,然后上一页,下一页,这样切换。后来出现了一些变种,比如滚动加载,当滚动条滚到底部的时候,再去加载或生成新的界面。

如果说,我们有上万条数据形成的一个列表,但是又不打算用那么老圡的方式放个分页条在下面,如何在性能与体验中取得一个平衡呢?

接触过Adobe Flex的人,可能会对其中的列表控件印象深刻,因为就算你给它上百万数据,它也不会因此而慢下来,为什么呢?因为它的滚动条是假的。

同理,我们也可能在浏览器中使用DOM来模拟一个滚动条,然后利用这个滚动条的位置,从全量数据中获取对应的那一段数据,并且绑定渲染到界面上。

这种技术一般称为Virtual List,在很多框架中都有第三方实现,可以参见这篇文章:AngularJS virtual list directive tutorial

上面这篇文章做到的,只是初步的优化,并不精细,因为它假定列表中所有项的大小是一致的,而且要在创建阶段即已预知,这样就很不灵活了。如果需要做更精细的优化,需要做实时的度量,对每个已创建并渲染的子项作度量,然后以此来更新滚动区的位置。

参见demo:http://codepen.io/xufei/pen/avRjqV

5. 将数据的结构扁平化 

那么,数据的结构又是怎样影响到执行效率的呢?我举一个常见的例子就是树形结构,这个结构一般人会使用ul和li之类的结构做,然后不可避免地要用递归的方式来使用MVVM框架。

我们考虑一下,为什么非要使用这种方式呢?其原因有二:

  • 给定的数据结构就是树形的

  • 我们习惯于使用树形DOM结构来表达树形数据

这个树形数据对我们来说,是什么?是数据模型。但是我们知道,比对两个树形结构是很麻烦的,它的层级使得监控变得复杂,无论是数据的逐一比对,还是存取器、或者刚被取消的observe提案,都会比单层数据麻烦很多。

如果我们想要用一种更加扁平的DOM结构来展示它,而不是层级结构,怎么办呢?所谓的树形DOM结构,能展现给我们的无非是位置的偏移,比如所有下级节点比上级更靠右,这些东西其实可以很轻易使用定位来模拟,这么一来,就有可能适用平级DOM结构来表达树的形状了。

回忆一下,MVVM,这几个字母什么意思?

Model View ViewModel

我们看了前两者了,但从未关注过视图模型。在很多人眼里,视图模型只是模型的一个简单封装,其实那只是特例,Angular官方的demo形成了这种误导。视图模型的真正作用应当包括:把模型转化为适合视图展示的格式。

如果说我们需要在视图层有比较扁平的数据结构,就必须在这一层把原始数据拍扁,举个栗子,我们要做一个动态的组织架构图,这个展开会像一个树,内部肯定也会有树形的数据结构,但我们可以同时维护树形和扁平的两种结构,并且随时保持同步。 

原始数据如下:

var source = [

    {id: "0", name: "a"},

    {id: "1", name: "b"},

    {id: "013", name: "abd", parent: "01"},

    {id: "2", name: "c"},

    {id: "3", name: "d"},

    {id: "00", name: "aa", parent: "0"},

    {id: "01", name: "ab", parent: "0"},

    {id: "02", name: "ac", parent: "0"},

    {id: "010", name: "aba", parent: "01"},

    {id: "011", name: "abb", parent: "01"},

    {id: "012", name: "abc", parent: "01"}

];

转换代码如下:

var map = {};

var dest = [];

 

source.forEach(function(it) {

    map[it.id] = it;

});

 

source.forEach(function(it) {

    if (!it.parent) {

        //根节点

        dest.push(it);

    }

    else {

        //叶子节点

        map[it.parent].children = map[it.parent].children || [];

        map[it.parent].children.push(it);

    }

});

转换之后的dest变成了这样:

[

    {

        "id": "0",

        "name": "a",

        "children": [

            {

                "id": "00",

                "name": "aa",

                "parent": "0"

            },

            {

                "id": "01",

                "name": "ab",

                "parent": "0",

                "children": [

                    {

                        "id": "013",

                        "name": "abd",

                        "parent": "01"

                    },

                    {

                        "id": "010",

                        "name": "aba",

                        "parent": "01"

                    },

                    {

                        "id": "011",

                        "name": "abb",

                        "parent": "01"

                    },

                    {

                        "id": "012",

                        "name": "abc",

                        "parent": "01"

                    }

                ]

            },

            {

                "id": "02",

                "name": "ac",

                "parent": "0"

            }

        ]

    },

    {

        "id": "1",

        "name": "b"

    },

    {

        "id": "2",

        "name": "c"

    },

    {

        "id": "3",

        "name": "d"

    }

]

我们在界面绑定的时候仍然使用source,而在操作的时候使用dest。因为,绑定的时候,不必去经过深层检测,而操作的时候,需要有父子关系来使得操作便利。

比如说,我们要做一个树状拓扑图,或者是MindMap这类产品,如果不作这样的考虑,很可能会直接把界面结构绑定到树状数据上,这时候效率相对会比较低些。 

但我们也可以作这种优化:

  • 同时保存扁平化的原始数据,也生成树状数据

  • 把展示结构绑定到扁平化的数据上

  • 每当结构变更的时候,在树状数据上更新,并且在数据模型内部计算出界面坐标

  • 展示结构的扁平数据因为跟树状数据是相同引用,也被更新了,也就引发界面刷新

  • 这时候,界面是单层刷新,无需跟踪层级数据,效率可以提高不少,尤其在层次较深的时候

6. 小结

MVVM存在的意义就是尽可能提高开发效率,只有很极端情况下值得去优化性能。如果你的场景中出现非常多的性能问题,很可能是不适合用这类框架的业务形态。

总结一下我们的几种优化方式,他们的机制分别是:

  • 减少监控项

  • 加快变更检测速度

  • 主动设置索引

  • 缩小渲染的数据量

  • 数据的扁平化  

可以看到,我们所有的优化都是在数据层面,不必刻意去优化界面。如果你用了一个MVVM框架,却为它作了各种各样相当多的优化,那还不如不要用它,全手工写。

针对其他MVVM框架,也大致可以用类似的几种方式,只是部分细节有差异,可以触类旁通。

本文转自作者:徐飞(@民工精髓V) 网址:https://github.com/xufei/blog/issues/23
如有侵权请联系公众号:数通畅联,将会第一时间删除。

以上是关于Angular 性能优化实战的主要内容,如果未能解决你的问题,请参考以下文章

JVM性能优化服务发生OOM故障定位方案

Android一线互联网大厂的优化实战解析+360°性能调优指南(2022年最新版)

Day799.优化垃圾回收机制 -Java 性能调优实战

Linux性能优化从入门到实战:01 Linux性能优化学习路线

ELK性能优化实战总结:java程序员月薪多少

Spark性能优化--数据倾斜调优与shuffle调优