Javascript 性能优化

Javascript最初是解释型语言,现在,主流浏览器内置的Javascript引擎基本上都实现了Javascript的编译执行,即使如此,我们仍需要优化自己写的Javascript代码,以获得最佳性能。

注意作用域

避免全局作用域

在之前的文章Javascript 变量、作用域和内存问题提到过,由于访问变量需要在作用域链上进行查找,相比于局部变量,访问全局变量的开销更大,因此以下代码:

var person = {
    name: "Sue",
    hobbies: ["Yoga", "Jogging"]
};
function hobby() {
    for(let i=0; i<person.hobbies.length; i++) {
        console.log(person.hobbies[i]);
    }
}

可以进行如下优化:

function hobby() {
    let hobbies = person.hobbies;
    for(let i=0; i<hobbies.length; i++) {
        console.log(hobbies[i]);
    }
}

把需要频繁访问的全局变量赋值到局部变量中,可以减小查找深度,进而优化性能。
当然,上述优化过的代码仍然有不足的地方,后面的部分会提到。

避免使用with

为什么避免使用with?

  1. with并不是必须的,使用局部变量可以达到同样的目的
  2. with创建了自己的作用域,相当于增加了作用域内部查找变量的深度

举一个例子:

function test() {
    var innerW = "";
    var outerW = "";
    with(window) {
        innerW = innerWidth;
        outerW = outerWidth;
    }
    return "Inner W: " + innerW + ", Outer W: " + outerW;
}
test()
// "Inner W: 780, Outer W: 795"

上述代码中,with作用域减小了对全局变量window的查找深度,不过与此同时,也增加了作用域中局部变量innerWouterW的查找深度,功过相抵。
因此我们不如使用局部变量替代with

function test() {
    var w = window;
    var innerW = w.innerWidth;
    var outerW = w.outerWidth;
    return "Inner W: " + innerW + ", Outer W: " + outerW;
}

上述代码仍然不是最优的。

算法复杂度

一下表格列出了几种算法复杂度:

复杂度名称描述
O(1)常数无论多少值,执行时间恒定,比如使用简单值或访问存贮在变量中的值
O(lg n)对数总执行时间与值的数量相关,但不一定需要遍历每一个值
O(n)线性总执行时间与值的数量线性相关
O(n2)平方总执行时间与值的数量相关,每个值要获取n次

O(1)

如果我们直接使用字面量,或者访问保存在变量中的值,时间复杂度为O(1),比如:

var value = 5;
var sum = 10 + value;

上述代码进行了三次常量查找,分别是5,10,value,这段代码整体复杂度为O(1)
访问数组也是时间复杂度为O(1)的操作,以下代码整体复杂度为O(1):

var values = [1, 2];
var sum = values[0] + values[1];

避免不必要的属性查找

在对象上访问属性是一个O(n)的操作,Javascript 面向对象的程序设计(原型链与继承)文中提到过,访问对象中的属性时,需要沿着原型链追溯查找,属性查找越多,执行时间越长,比如:

var persons = ["Sue", "Jane", "Ben"];
for(let i=0; i<persons.length; i++) {
    console.log(persons[i]);
}

上述代码中,每次循环都会比较i<persons.length,为了避免频繁的属性查找,可以进行如下优化:

var persons = ["Sue", "Jane", "Ben"];
for(let i=0, len = persons.length; i<len ; i++) {
    console.log(persons[i]);
}

即如果循环长度在循环开始时即可确定,就将要循环的长度在初始化的时候声明为一个局部变量。

优化循环

由于循环时反复执行的代码,动辄上百次,因此优化循环时性能优化中很重要的部分。

减值迭代

为什么要进行减值迭代,我们比较如下两个循环:

var nums = [1, 2, 3, 4];
for(let i=0; i<nums.length; i++) {
    console.log(nums[i]);
}
for(let i=nums.length-1; i>-1; i--) {
    console.log(nums[i]);
}

二者有如下区别:

  1. 迭代顺序不同
  2. 前者支持动态增减数组元素,后者不支持
  3. 后者性能优于前者,前者每次循环都会计算nums.length,频繁的属性查找降低性能

因此,出于性能的考虑,如果不在乎顺序,迭代长度初始即可确定,使用减值迭代更优。

简化终止条件

上述情况,我们也可以不使用减值迭代,即像上文提到过的,在初始化时即将迭代长度赋值给一个局部变量。

简化循环体

循环体应最大程度地被优化,避免进行不必要的密集的计算

使用while循环

为什么使用while循环,我们可以比较如下两个循环:

var len = nums.length;
for(let i=0; i<len; i++) {
    console.log(nums[i]);
}
var i = nums.length ;
while(--len > -1) {
    console.log(nums[len]);
}

以上两个循环有一个很明显的不同点:while循环将每次循环终止条件的判断和index的自增合并为一个语句,在后续部分会讲解语句数量与性能优化的关系。

展开循环

由于建立循环和处理终止条件需要额外的开销,因此如果循环次数比较少,而且可以确定,我们可以将其展开,比如:

process(nums[0]);
process(nums[1]);

如果迭代次数不能事先确定,可以使用Duff装置,其中比较著名的是Andrew B. King提出的一种Duff技术,通过计算迭代次数是否为8的倍数将循环展开,将“零头”与“整数”分成两个单独的do-while循环,在处理大数据集时优化效果显著:

var iterations = Math.floor(values.length / 8);
var leftover = values.length % 8;
var i = 0;
if (leftover > 0){
do {
process(values[i++]);
} while (--leftover > 0);
}
do {
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
} while (--iterations > 0);

避免双重解释

eval() Function() setTimeout()可以传入字符串,Javascript引擎会将其解析成可以执行的代码,意味着,Javascript执行到这里需要额外开一个解释器来解析字符串,会明显降低性能,因此:

  1. 尽量避免使用eval()
  2. 避免使用Function构造函数,用一般Function来代替
  3. setTimeout()传入函数作为参数

其他

使用原生方法

原生方法都是用C/C++之类的编译语言写出来的,比Javascript快得多。

使用switch语句

多个if-else可以转换为switch语句,还可以按照最可能到最不可能排序case

使用位运算符
当进行数学运算的时候,位运算操作要比任何布尔运算或者算数运算快。选择性地用位运算替换算数运算可以极大提升复杂计算的性能。诸如取模,逻辑与和逻辑或都可
以考虑用位运算来替换。

书中的这段话笔者表示不能理解,由于使用&& ||做逻辑判断时,有的时候只需要求得第一个表达式的结果便可以结束运算,而& |无论如何都要求得两个表达式的结果才可以结束运算,因此后者的性能没有占太大优势。
这里,补充一下位运算符如何发挥逻辑运算符的功能,首先看几个例子:

7 === 7 & 6 === 6
1
7 === 7 & 5 === 4
0
7 === 7 | 6 ===6
1
7 === 7 | 7 ===6
1
7 === 6 | 6 === 5
0

也许你会恍然大悟,位运算符并没有产生truefalse,它只是利用了Number(true) === 1 Number(false) === 0 Boolean(1) === true Boolean(0) === false

最小化语句数

Javascript代码中的语句数量会影响执行的速度,尽量组合语句,可以减少脚本的执行时间。

多个变量声明

当我们需要声明多个变量,比如:

var name = "";
var age = 18;
var hobbies = [];

可以做如下优化:

var name = "",
    age = 18,
    hobbies = [];

合并迭代值

上文中我们提到一个例子,使用while循环可以合并自减和判断终止条件,我们还可以换一种写法:

var i = nums.length ;
while(len > -1) {
    console.log(nums[len--]);
}

即将自减与使用index取值合并为一个语句。

使用字面量创建数组和对象

即将如下代码:

var array = new Array();
array[0] = 1;
array[1] = 2;

var person = new Object();
person.name = "Sue";
person.age = 18;

替换成:

var array = [1, 2];
var person = { name:"Sue", age:18 };

省了4行代码。

优化DOM操作

DOM操作是最拖累性能的一方面,优化DOM操作可以显著提高性能。

最小化现场更新的次数

如果我们要修改的DOM已经显示在页面,那么我们就是在做现场更新,由于每次更新浏览器都要重新计算,重新渲染,非常消耗性能,因此我们应该最小化现场更新的次数,比如我们要向页面添加一个列表:

var body = document.getElementsByTagName("body")[0];
for(let i=0; i<10; i++) {
    item = document.createElement("span");
    body.appendChild(item);
    item.appendChild(document.createTextNode("Item" + i));
}

每次循环时都会进行两次现场更新,添加div,为div添加文字,总共需要20次现场更新,页面要重绘20次。
现场更新的性能瓶颈不在于更新的大小,而在于更新的次数,因此,我们可以将所有的更新一次绘制到页面上,有以下两个方法:

文档片段

可以使用文档片段先收集好要添加的元素,最后在父节点上调用appendChild()将片段的子节点添加到父节点中,注意,片段本身不会被添加。

<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <div id="container" style="with: 100px; height: 100px; border: 1px solid black;">
            <div id="child">this</div>
        </div>
        <script>
            var container = document.getElementById("container"),
                fragment = document.createDocumentFragment(),
                item,
                i;
            for (i=0; i < 10; i++) {
              item = document.createElement("li");
              fragment.appendChild(item);
              item.appendChild(document.createTextNode("Item " + i));
            }
            container.appendChild(fragment);
        </script>
    </body>
</html>
innerHTML

使用innerHTML与使用诸如createElement() appendChild()方法有一个显著的区别,前者使用内部的DOM来创建DOM结构,后者使用JavaScript的DOM来创建DOM结构,前者要快得多,之前的例子用innerHTML改写为:

var ul = document.getElementById("ul"),
    innerHTML = "";
for(let i=0; i<10; i++) {
    innerHTML += "<li>Item " + i + "</li>";
}
ul.innerHTML = innerHTML;

整合冒泡事件处理

页面上的事件处理程序数量与页面相应用户交互的速度之间存在负相关,具体原因有多方面:

  1. 创建函数会占用内存
  2. 绑定事件处理方法时,需要访问DOM

因此对于冒泡事件,尽可能由父元素甚至祖先元素代子元素处理,这样一个事件处理方法可以负责多个目标的事件处理,比如:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <div id="container" style="with: 100px; height: 100px; border: 1px solid black;">
            <div id="child">this</div>
        </div>
        <script>
            var container = document.getElementById("container");
            container.addEventListener("click", function(e) {
                switch(e.target.id) {
                    case "container":
                        console.log("container clicked");
                        break;
                    case "child":
                        console.log("child clicked");
                        break;
                }
            },false);
        </script>
    </body>
</html>

注意HTMLCollection

访问HTMLCollection的代价非常昂贵。
下面的每个项目(以及它们指定的属性)都返回 HTMLCollection:

  1. Document (images, applets, links, forms, anchors)
  2. form (elements)
  3. map (areas)
  4. select (options)
  5. table (rows, tBodies)
  6. tableSection (rows)
  7. row (cells)

相关推荐