{JS} Javascript 性能优化

前端工程师不但要保证完成界面的规划与开发,并且同时需要保证代码的质量,其中Javscript的解析和运行速度则变得非常重要,此篇文章从工程师的角度入手,结合了开发者工具进行分析, 总结了一些常用的优化手段和法则.

分析执行时间

使用Chrome DevTools中的 Preformance 面板来分析 一个js库的执行时间。通常来说,一个轻量级的框架js框架(小于20k),它的解析速度最好是控制在10ms以内,如果一个库的解析时间大于16ms(1帧),则需要对他内部的执行函数进行性能分析。

使用Preformance之前需要注意三点

  • 开启隐身模式 避免其他插件的分析数据和对浏览器造成的不必要的干扰
  • 在Chrome Developer Tools 的 Network 面板中取消 Disable Cache 的选项,让JS文件尽量缓存在本地
  • 多次反复提取测试数据,分析结果,减少获取信息的误差
  • 尽量使用本地js文件从而避免网络延迟带来的影响

捕捉分析数据:

devtools Preformance1

查看火焰图中对应脚本的Evalute Time, 脚本的评估时间:

devtools Preformance2

devtools Preformance3

通过Bottom-up选项卡,可以查看较为耗时的执行函数

由此图可以分析出,DOOM 这个函数在ax.js 文件中被调用时,消耗的时间占比可高达10%,如果这个函数不是必须的, 或者可以被替代和优化的,需要对其进行进一步的修改和排查.

devtools Preformance4

除此之外需要注意的是,同时可通过Memory(Profile)面板来收集部分函数的执行周期对于内存的占用,从而进行对应的优化. 使用Memory面板的好处在于,你可以检测到单次执行某个操作时,所消耗的内存,跟踪到具体的Object,Array创建时所占用的内存, 单位是(byte)

devtools Preformance5

代码层面的优化

变量提升

这个优化层面,一般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的转化往往会增大浏览器的负担。例如slice(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并没有不妥当,而且如果运用恰当的话,可以写出很多一般代码无法实现的效果(John Resign,jQuery的作者就提到过这样的观点,eval也被用于cubec的模板引擎支持多参数编译) 当然慎用,多数场景下这两者用不上. 并且注意严格模式下的问题, 另外需要注意的是new Function 并没有能力代替eval, 因为eval在编译动态参数的时候,比new Function要简单直观的多

// 解决在严格模式下使用eval的问题

'use strict';

var ev = eval;

ev(...);

加速Object的访问速度

通过 Object.freeze(obj), 可以对obj的访问进行加速, 这个优化仅限于Chrome, 另外,事先定义好构造函数所创建的的key,Chrome才会使用hide-class机制. 一个小的hack方法是,如果对null(null也是一个对象)执行了create方法,可以创建一个不带原型链的原始对象,这样的对象在执行任何操作的时候否不会受到构造函数原型链扩展的影响,因此,速度更快

// 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;

// 通过加速b对象的属性访问
Object.freeze(b);

// 更快的对象
var c = Object.create(null);

预先创建函数调用(createBounder)

对于已经绑定函数作用域的函数(bind),在forEach,等一些会创建self-block的方法执行中可以减少一次创建self-block的消耗,从而获得更大的性能提升。在 Underscore 和 Lodash 等工具函数库中就大量的运用到了这样的技巧

// Bad
arr.forEach(function(item,index){
    ...
}.bind(this))

// Better
function a(item,index){
    ...
}

var fn = a.bind(this);

arr.forEach(fn);

DOM事件的合成

如果你有深度阅读jQuery的源码,就会发现,它的事件系统是合成事件系统(compound event system), 举个简单的例子来做比划:

从以下的例子中我们可以看出,合成事件的情况下,a真正只绑定了一次点击事件,而相较于传统的绑定事件来说,a合成的事件里只是单纯的增加了对a触发点击操作的钩子。 从而极大的提升了性能(这就是为什么jQuery推荐使用事件代理的方式而不是直接对指定DOM进行事件绑定的原因, 因为在代理节点上只会绑定一次DOM合成事件)

// 早期的绑定事件方式
a.addEventListener("click", fn1);
a.addEventListener("click", fn2);
a.addEventListener("click", fn3);
a.addEventListener("click", fn4);

// 合成事件的机制
fns = [fn1];
a.addEventListener("click",fns)

fn2 = ,
fn3
fn4 = function(){
  a.dispatch("click",...args);
}

fns.push(fn2,fn3,fn4);

运行时代码压缩(new Function, eval)

对于很多使用了new Function,eval的模板引擎来说, 执行时期对需要执行的字符串进行代码压缩是一个非常重要的环节,很多市面上的模板没有对这一点做处理,然而我们可以在doT(最快的JS模板引擎)中找到类似的压缩代码

  let res = `
    ...template string...;
    a b c
    d e
    f
    g
  `

  const optimze = {
    line     : /[\r\n\f]/gim,
    quot     : /\s*;;\s*/gim,
    space    : /[\x20\xA0\uFEFF]+/gim,
    assert   : /_p\+='(\\n)*'[^+]/gim,
    comment  : /<!--(.*?)-->/gim,
    tagleft  : /\s{2,}</gim,
    tagright : />\s{2,}/gim
  };

 res = res.replace(optimze.line,'').
 replace(optimze.comment,'').
 replace(optimze.assert,'').
 replace(optimze.quot,';').
 replace(optimze.space,' ').
 replace(optimze.tagright,'> ').
 replace(optimze.tagleft,' <');

 // 经过优化后的运行时代码
 // res = `...template string...; a b c d e f g`;

传输体积和执行顺序的优化

减少http请求,直接将js embed在页面中输出(适合业务单一,例如Google Baidu的主搜首页)

这样的话,只要按照顺序的 vendor 和后续对js片段进行embed即可,效率比较高,但是会阻塞页面的加载,最好是embed在页面的尾部. 从而达到不会阻塞页面渲染的效果。这种非常适合同构场景。

前端层面合并http请求代码,和后端合并请求

前端的合并请求其实很简单,将a,b,c 三个请求串联成Promise All就可以了。但是这样的做法还是会发送3个请求,不过这样从代码层面上来说,很好的提高了可维护性.

后端合并请求的做法则是,真正将三个请求,a,b,c 合并成一个请求 abc。做聚合处理,将数据以约定的格式返回。这样的技术在大公司得到了充分的运用.

// 未合并请求时
ajax(a, function(){
  ajax(b, function(){
    ajax(c, callback);
  });
});

// 合并请求
Promise.all(
  ajax(a),
  ajax(b),
  ajax(c)
).then(callback)

使用 async 对加载进行优化 (推荐做法)

优先加载vendor, 将主要的js库和框架优先加载过来,其他的js逻辑,如果没有相互顺序的依赖,则可以使用async异步加载的做法,优化页面加载速度, 例如:

jsloader optimze

通过Webpack-Bundle-Analyzer分析依赖的必要性, 并进行拆块引入

在使用Webpack作为主要构建工具的开发流程下,使用Bundle-Analyzer对打包进行分析,从而剔除一些不必要的代码文件或者js模块

jsloader optimze

对于拆快引入的解释是,某个库,某个插件,你可能只需要用到其中的某个部分,而不是整个包,那么拆块引入可以很大程度上减少代码体积,例如,引用react-virtualized插件,我们只需要用到其中的AutoSizer,和List模块,那么我们只需要相如下这么做,代码体积缩小了近20k

// 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. 如图:

并且将node和shell的task进行合成。shell的优点就在于,执行速度极快,并且可以开多个子线程,而node是单线程。

关于webpack的一些打包优化,很多做法在我的私人脚手架源码里有体现: Ax-CLi;

cli commit

CSS hot reload (CSS热更新)

React Hot Reload 是 React开发组为 Webpack 中间件提供了这一项技术,可以以热更新的方式 在不刷新页面的情况下 进行组件的更新。 这样极大了便利了开发。节约时间和效率.

同样CSS的热更新就更加高效了,提供更加高效和舒适的调试体验。

css hot reload

Proxy 请求转发

本地开发调试的时候可以利用Webpack的Proxy特性对请求进行转发,将接口地址统一管理成一个或多个js文件

比如利用webpack的转发就可以轻松的实现本地请求国外开放股票API:

demo: cubec-example-stocks

stock

扩展阅读

BlueBrid-wiki/Optimization-killers

25 Techniques for Javascript Performance Optimization

Javascript 打破所有规则(Break the rule)

Unix的缔造者, 老怪物Rob Pike 的5个编程原则