前端工程师不但要保证完成界面的规划与开发,并且同时需要保证代码的质量,其中Javscript的运行速度则变得非常重要,此篇文章从工程师的角度入手,结合了开发者工具进行分析, 总结了一些常用的优化手段和法则.
分析执行时间
使用Chrome DevTools中的 Preformance 面板来分析 一个js库的执行时间。通常来说,一个轻量级的框架js框架(小于20k),它的解析速度最好是控制在10ms以内,如果一个库的解析时间大于16ms(1帧),则需要对他内部的执行函数进行性能分析。
使用Preformance之前需要注意三点
- 开始隐身模式 避免其他插件的分析数据和对浏览器造成的不必要的干扰
- Network面板中取消 Disable Cache 的选项,让JS文件尽量缓存在本地
- 多次反复提取测试数据,分析结果,减少获取信息的误差
捕捉分析数据:
查看火焰图中对应脚本的Evalute Time, 脚本的评估时间:
通过Bottom-up选项卡,可以查看较为耗时的执行函数
有此图可以分析出,DOOM这个函数在ax.js 文件中被调用时,消耗的时间占比可高达10%,如果这个函数不是必须的, 或者可以被替代和优化的,需要对其进行进一步的修改和排查.
除此之外需要注意的是,同时可通过Memory(Profile)面板来收集部分函数的执行周期对于内存的占用,从而进行对应的优化. 使用Memory面板的好处在于,你可以检测到单次执行某个操作时,所消耗的内存,跟踪到具体的Object,Array创建时所占用的内存, 单位是(byte)
代码层面的优化手段
变量提升
这个优化层面,一般UglifyJS会在打包压缩的时候自动做,当然需要配置。一般来说,把需要使用的变量进行预先定义,又可以一定程度上优化执行的速度,例如如下一段代码:
// Normal 正常做法
for(var i=0,l=arr.length; i<l; i++){
...
}
// Better 最好可以
var i=0, l = arr.length;
for(; i<l; i++){
...
}
循环
从框架层面来讲,forEach不是一个很好的选择,对于2016年以及之前的JavaScript执行引擎来说,forEach的效率是明显低于使用纯for循环的效率,因为forEach每次执行时创建一个self-block的区域,会产生额外的消耗。当然,在目前的Chrome 60之后,forEach和for循环的性能差异已经被缩短到很小了。 不过依旧是for循环比较快. 这也就是为什么Lodash和Underscore都需要对循环进行封装的原因.
// Normal forEach
arr.forEach(function(val,index){
...
});
// Better
var i=0, l=arr.length, val;
for(;i<l;i++){
val=arr[i];
...
}
直接访问arguments
直接访问arguments其实并没有什么问题,甚至直接作为另外一个函数的参数也是可行的,任何函数在执行时都会创建arguments类数组,这个是无法避免的,对arguments的转化往往会增大浏览器的负担
function a(){ }
// Bad
function b(){
// const args = [ ...arguments ]; ES6
var args = [].slice.call(arguments);
a.apply(null,args);
}
// Better
// 不需要进行复制的时候直接传递arguments
function b(){
a.apply(null,arguments);
}
// Better
// 直接访问可是最快的
// 当然了,arguments[1]最好是个有效值,而不是undefined
function c(){
a.call(null,arguments[0],arguments[1])
}
数组的操作
直接访问数组的index往往比做 pop 或者 shift 要快得多. 同理,如果是添加数组元素,直接通过array[index]=val往往比push,更有效.很多时候,如果可以直接改变原数组就能得到需要的结果,就不要新建一个数组然后去push结果值
// Bad 取数组最后一位
arr.pop();
// Bad 等于创建了一个新的数组,没必要的情况下不需要这样干
arr = arr.map(function(item){
return item+1;
})
// Better 直接取最快
arr[arr.length-1];
// Better 直接写入
var i=arr.length;
for(;i--;){
arr[i] = arr[i]+1;
}
使用 eval 和 with
使用eval和with并没有不妥当, 当然慎用,多数场景下这两者用不上. 并且注意严格模式下的问题, 另外需要注意的是new Function 并没有能力代替eval, 因为eval在编译动态参数的时候,比new Function要简单直观的多
// 在严格模式下使用eval
'use strict';
var ev = eval;
ev(...);
加速Object的访问速度
通过 Object.freeze(obj), 可以对obj的访问进行加速, 这个优化仅限于Chrome, 另外,事先定义好构造函数所创建的的key,Chrome才会使用hide-class机制
// Bad
var a = function(){}
var b = new a();
b.x = 1;
b.y = 2;
// Better
var a = function(x,y){
this.x = x;
this.y = y;
}
var b = new a(1,2);
b.x = 3;
b.y = 4;
预先创建函数调用(createBounder)
// Bad
arr.forEach(function(item,index){
...
}.bind(this))
// Better
function a(item,index){
...
}
var fn = a.bind(this);
arr.forEach(fn);
优化的误区
错误1: 代码越短性能越好
很多人认为,代码库的体积越小,性能越好,, 其实这个认识绝对错误的, 例如很多框架打的招牌体积特别小,其实然并卵, 例如最典型的React的轮子, Preact, 这货吹捧的就是size比较小,然后同时兼容React API, 所谓性能优越? 那么我们来看看Slant上的评分, 看看老外怎么看: Slant React VS Preact, React以绝对的优势碾压,
再看看司徒正美是怎么评价的? 如何看待 React 的替代框架 Preact? 司徒正美的回答, 两者并不是一个量级的东西.
另外Preact的动态规划算法有React那么屌么? 答案是没有的,老外已经在issue里开喷
动态规划算法在React中的应用表现在最小差异更新,但是从issue来看,Preact 则是没有经过优化的二道贩子, React可是最短路径的精确更新,Preact是么?
另外,Google的工程师在2006年实现的 diff算法是 text diff 动态规划中目前实现最快的:
当然,同类型的diff有没有? 有, 例如:
但是,你看看issue啊,体积小能代表性能好么? 最终还是会抄Google diff match patch的
错误2: 模块加载器没有多大差别,webpack和requirejs差不多
如今很多工程已经迁移到webpack了,两种模块化的方案都可以解决问题, 但是两者存在本质的区别:
- 首先,Webapck的模块加载器在编译阶段就已经将依赖分析好了, 而RequireJS的依赖顺序需要在浏览器端才能得到解析,所以Webpack的调用速度比RequireJS要快,因为依赖都是按照索引存储在数组中的,全闭包贮存,通过id可以直接得到引用
- 即使webpackJsonp被修改,被攻击,也不影响现有的页面逻辑,照常执行,但是RequireJS 则在全局暴露了 define, require 等多个API,通过require甚至可以将定义的包导出,查看源码
- webpack 提供了从打包工具到模块化的一致性体验,而requirejs 打包则需要依赖rjs,并且不太容易和gulp等自动化工具进行整合
- 由于加载代码布局和全闭包贮存,webpack的打包在UglifyJS的压缩下变得更加彻底, 打包的体积更小
- loader 生态的问题
错误3: 算法越复杂,越高级,性能越好,效率越高
JavaScript 运行的场景和特殊性,决定了有些算法并不适用于js,从常用的排序算法来说, 快排(quick sort)和插入(insert sort)成为了js性能效率排序的首选,可以看benchmark:
所以每当有人面试问你排序算法的时候,我说我只记quick sort 和 insert sort, 因为测试过了,至于当对面问heap sort,merge sort…,我就会笑一笑,说明这个面试官对js根本不了解.
另外,关于Rob Pike给出的意见,值得一读,看看老怪物是怎么理解和建议的, 原文链接
Rob Pike 的 5 个编程原则
原则 1. 你没有办法预测每个程序的运行时间,瓶颈会出现在出乎意料的地方,所以在分析瓶颈原因之前,先不要盲目猜测。
原则 2. 测试(measure)。在测试之前不要优化程序,即使在测试之后也要慎重,除非一部分代码占据绝对比重的运行时间。
原则 3. 花哨的算法在 n 比较小时效率通常比较糟糕,而 n 通常是比较小的,并且这些算法有一个很大的常数。除非你确定 n 在变大,否则不要用花哨的算法。(即便 n 不变大,也要先遵循第 2 个原则。)
原则 4. 相对于朴素的算法来说,花哨的算法更容易出现Bug,更难调试。尽量使用朴素的算法和数据结构。
原则 5. 数据占主导地位(Data dominates)。如果你选择了正确的数据结构,并且已把事情组织好,那么算法的效率显而易见。编程的核心是数据结构是,不是算法。
Pike的第 1 条和第 2 条原则实际上重新强调了Tony Hoare那句名言,“过早的优化是万恶的根源”。Ken Thompson将Pike的第 3 条和第 4 条原则改写为“当遇到麻烦时,试试最简单粗暴的办法”,原则 3 和原则 4 也是 KISS 哲学的体现。Fred Brooks在《人月神话》中首先阐述了原则 5 ,原则 5 常常会被概括为“用最佳结构,写简单代码”。
优化执行顺序与代码体积
避免http请求,直接将js embed在页面中输出
这样的话,只要按照顺序的 vendor 和后续对js片段进行embed即可,效率比较高,但是会阻塞页面的加载,最好是embed在页面的尾部
使用 async 对加载进行优化 (推荐做法)
优先加载vendor, 将主要的js库和框架优先加载过来,其他的js逻辑,如果没有相互顺序的依赖,则可以使用async异步加载的做法,优化页面加载速度, 例如:
通过Webpack-Bundle-Analyzer分析依赖的必要性
在使用Webpack作为主要构建工具的开发流程下,使用Bundle-Analyzer对打包进行分析,从而剔除一些不必要对库或者代码文件
拆块引入
对于拆快引入的解释是,首先,一个库,一个插件,你可能只需要用到其中的某个部分,而不是整个包,那么拆块引入可以很大程度上减少代码体积,例如,引用react-virtualized插件,我们只需要用到其中的AutoSizer,和List模块,那么:
// Bad
import virtualized form 'react-virtualized';
// Better
// 只引用需要的,最小依赖原则
// 这样的做法减少接近50%的包体积
import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'
import List from 'react-virtualized/dist/commonjs/List'
深度优化打包速度
通过cache 和 HappyPack的手段可充分利用cpu资源,加快打包速度,以往需要16s打包的代码4s完成,重新打包只需要2s. 如图:
不过相关的代码是我私人项目,所以暂不开源
相关链接
BlueBrid-wiki/Optimization-killers
25 Techniques for Javascript Performance Optimization