Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

优化Angular应用的性能 #23

Open
xufei opened this issue Nov 9, 2015 · 14 comments
Open

优化Angular应用的性能 #23

xufei opened this issue Nov 9, 2015 · 14 comments

Comments

@xufei
Copy link
Owner

xufei commented Nov 9, 2015

优化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; i<10000; 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框架,也大致可以用类似的几种方式,只是部分细节有差异,可以触类旁通。

@xufei
Copy link
Owner Author

xufei commented Nov 9, 2015

这篇是上周五在IBM交流时候讲的,幻灯片地址在这里

@rockcoder23
Copy link

赞民工叔,但是又不打算用那么老圡的方式放个分页条在下面 错别字·老土·把 :)
还有减少手动 $apply次数,应该也能提高性能。

@hax
Copy link

hax commented Nov 9, 2015

“比对两个树形结构是很麻烦的,它的” 后面没了。。。

@xufei
Copy link
Owner Author

xufei commented Nov 9, 2015

@hax 纪晓岚来修好了。。。

@shanghaikid
Copy link

如果你的场景中出现非常多的性能问题,很可能是不适合用这类框架的业务形态。

请问,angular适用哪些业务

@TedWei
Copy link

TedWei commented Nov 9, 2015

又跟民工叔叔学到了。

@xufei
Copy link
Owner Author

xufei commented Nov 9, 2015

@shanghaikid 中小型管控系统的pc端,这类形态是肯定没有问题的,其他的,有的不推荐,有的需要开发者有较高的意识和技能。

@shenqihui
Copy link

  1. 降低数据比对的开销
    中的第二个代码出错了。
    get fullName: function() {  // bug

bug 。

@xufei
Copy link
Owner Author

xufei commented Nov 13, 2015

@shenqihui 感谢提醒,已修改

@yxnino
Copy link

yxnino commented Nov 16, 2015

@xufei 有一点不理解,将数据的结构扁平化这一节提到:

我们在界面绑定的时候仍然使用source,而在操作的时候使用dest。

请教一下,template要怎么写可以生成树形结构,谢谢 😄

@Jason-mor
Copy link

疑问:优化的第一点应该是angular1.3.x之后的特性,但是1.3.x之后已经不承诺兼容ie8了。象苏宁的业务肯定要在ie8下面跑的,你们是如何升级这个特性的呢???
优化2:需要根据不同的模型,选择监视的程度。一般不要使用深度监视,性能耗不起
优化3:设值索引,感觉必须要这么做,这是angular 默认提供的一种方式。
优化4:这个在于table 和tree场景,应该都可以使用。
优化5:数据扁平化。肯定需要有个地方处理,最好页面渲染需要使用时候数据已经安要求准备好。

另外还有一些,封装自定义指令的时候尽量减少dom替换,在ie上面效果还是很明显的。

@dolymood
Copy link

@Jason-mor 之前还有bindonce可用

@famingjia
Copy link

好像不错

@Isabelzxy
Copy link

请问PPT怎么只有一页?使用的Chrome最新版本浏览器。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests