[TOC]

28.2 性能

28.2.1 作用域意识

随着作用域链中作用域数量的增加,访问当前作用域外部变量所需的时间也会增加。
访问全局变量始终比访问局部变量慢,因为必须遍历作用域链。
任何可以缩短遍历作用域链时间的举措都能提升代码性能。

1.避免全局查找

全局变量和函数相比于局部值始终是最费时间的,因为需要经历作用域链查找。

function updateUI() {
  let imgs = document.getElementsByTagName('img');
  for (let i = 0, len = imgs.length; i < len; i++) {
    imgs[i].title = `${document.title} image ${i}`
  }
  let msg = document.getElementById('msg');
  msg.innerHTML = '更新完成。';
}

其中三个地方引用了全局document对象。如果页面的图片非常多,那么for循环中就需要引用document几十甚至上百次,每次都要遍历一次作用域链。
通过在局部作用域中保存document对象的引用,能够明显提升这个函数的性能,因为只需要作用域链查找。
通过创建一个指向document对象的局部变量,可以通过将全局查找的数量限制为一个来提高这个函数的性能

function updateUI() {
  let doc = document;
  let imgs = doc.getElementsByTagName('img');
  for (let i = 0, len = imgs.length; i < len; i++) {
    imgs[i].title = `${doc.title} image ${i}`;
  }
  let msg = doc.getElementById('msg');
  msg.innerHTML('更新完成。');
}

先把document对象保存在局部变量doc中。然后用doc替代了代码中所有的document。这样调用这个函数只会查找一次作用域链,相对上一个版本,肯定会快很多。
因此,一个经验规则就是,只要函数中有引用超过两次的全局对象,就应该把这个对象保存为一个局部变量。

2.不使用with语句

在性能很重要的代码中,应避免使用with语句。
与函数类似,with语句会创建自己的作用域,因此也会加长其中代码的作用域链。
实际编码时很少有需要使用with语句的情况,因为它的主要用途是节省一点代码。
大多数情况下,使用局部变量可以实现同样的效果,无须增加新作用域。

function updateBody() {
  with(document.body) {
    console.log(tagName);
    innerHTML = 'Hello World!';
  }
}
// with语句让使用document.body更简单了。使用局部变量也可以实现同样的效果
function updateBody() {
  let body = document.body;
  console.log(body.tagName);
  body.innerHTML = 'Hello World!';
}

虽然这段代码多了几个字符,但比使用with语句还更容易理解了,因为tagName和innerHTML属于谁很明确。这段代码还通过把document.body保存在局部变量中来省去全局查找。

28.2.2 选择正确的方法

1.避免不必要的属性查找

在计算机科学中,算法复杂度使用大O表示法来表示。
最简单同时也最快的算法可以表示为:常量值或O(1)。
稍微复杂一些的算法同时执行时间也更长一些。
下表列出了JavaScript中常见算法的类型:
image.png
常量值或O(1),指字面量和保存在变量中的值,表示读取常量值所需的时间不会因值的多少而变化。
读取常量值是效率极高的操作,因此非常快。
在JavaScript中访问数组元素也是O(1)操作,与简单的变量查找一样。
使用变量和数组相比访问对象属性效率更高,访问对象属性的算法复杂度是O(n)。
访问对象的每个属性都比访问变量或数组花费的时间长,因为查找属性名要搜索原型链。简单来说,查找的属性越多,执行时间就越长。
特别要注意避免通过多次查找获取一个值。
只要使用某个object属性超过一次,就应该将其保存在局部变量中。
第一次仍然要用O(n)的复杂度去访问这个属性,但后续每次访问就都是O(1),这样就是质的提升了。
只要能够降低算法复杂度,就应该:
尽量通过在局部变量中保存值来替代属性查找。
另外,如果实现某个需求既可以使用数组的数值索引,又可以使用命名属性(比如NodeList对象),那就都应该使用数值索引。

2.优化循环

优化循环的基本步骤如下:
(1)简化终止条件。因为每次循环都会计算终止条件,所以它应该尽可能地快。这意味着要避免属性查找或其他O(n)操作。
(2)简化循环体。循环体是最花时间的部分,因此要尽可能优化。要确保其中不包含可以轻松转移到循环外部的密集计算。
(3)使用后测试循环。最常见的循环就是for和while循环,这两种循环都属于先测试循环。do-while就是后测试循环,避免了对终止条件初始评估,因此应该会更快。
一个简单for循环为例:

for (let i = 0; i < values.length; i++) {
  process(values[i]);
}

// 假如顺序不重要,那么循环变量变为递减的形式
for (let i = values.length - 1; i >= 0; i--) {
  process(values[i]);
}

// 整个循环可修改为后测试循环
let i = values.length - 1;
if (i > -1) {
  do {
    process(values[i]);
  } while (--i >= 0);
}

这里主要的优化是将终止条件和递减操作符合并成了一条语句。
使用后测试循环时要注意:一定是至少有一个值需要处理一次。如果这里的数组是空的,那么会浪费一次循环,而先测试循环就可以避免这种情况。

3.展开循环

如果循环的次数是有限的,那么通常抛弃循环而直接多次调用函数会更快。
仍以前面的循环为例,如果数组长度始终一样,则可能对每个元素都调用一次process()效率更高。
如果不能提前预知循环的次数,那么或许可以使用一种叫作达夫设备(Duff’sDevice)的技术。

4.避免重复解释

重复解释的问题存在于JavaScript代码尝试解释JavaScript代码的情形。
在使用eval()函数或Function构造函数,或者给setTimeout()传入字符串参数时会出现这种情况。

// 对代码求值 NO
eval('console.log("Hello World!")');
// 创建新函数 NO
let sayHi = new Function('console.log(("Hello World!")');
// 设置超时函数 NO
setTimeout('console.log("Hello World!")', 500);

上面所列的每种情况下,都需要重复解释包含JavaScript代码的字符串。
这些字符串在初始解析阶段不会被解释,因为代码包含在字符串里。这意味着在JavaScript运行时,必须启动新解析器实例来解析这些字符串中的代码。实例化新解析器比较费时间,因此这样会比直接包含原生代码慢。
解决:
很少有情况绝对需要使用eval(),因此应该尽可能不使用它。此时,只要把代码直接写出来就好了。
对于Function构造函数,重写为常规函数也很容易。
调用setTimeout()时则可以直接把函数作为第一个参数。

// 直接写出来
console.log("Hello World!")
// 创建新函数:直接写出来
let sayHi =  function() {
  console.log("Hello World!")
}
// 设置超时函数: 直接写出来
setTimeout(function() {
  console.log("Hello World!");
}, 500);

为了提升代码性能,应该尽量避免使用要当作JavaScript代码解释的字符串。

5.其他性能优化注意事项

❑ 原生方法很快。应该尽可能使用原生方法,而不是使用JavaScript写的方法。原生方法是使用C或C++等编译型语言写的,因此比JavaScript写的方法要快得多。JavaScript中经常被忽视的是Math对象上那些执行复杂数学运算的方法。这些方法总是比执行相同任务的JavaScript函数快得多,比如求正弦、余弦等。
❑ switch 语句很快。如果代码中有复杂的if-else语句,将其转换成switch语句可以变得更快。然后,通过重新组织分支,把最可能的放前面,不太可能的放后面,可以进一步提升性能。
❑ 位操作很快。在执行数学运算操作时,位操作一定比任何布尔值或数值计算更快。选择性地将某些数学操作替换成位操作,可以极大提升复杂计算的效率。像求模、逻辑AND与和逻辑OR或都很适合替代成位操作。

28.2.3 语句最少化

语句的数量影响操作执行的速度。
一条可以执行多个操作的语句,比多条语句中每个语句执行一个操作要快。
那么优化的目标就是:寻找可以合并的语句,以减少整个脚本的执行时间。
为此,可以参考如下几种模式:

1.多个变量声明

声明多个变量时很容易出现多条语句。
使用一个let声明了所有变量,变量之间以逗号分隔。

2.插入迭代性值

任何时候只要使用迭代性值(即会递增或递减的值),都要尽可能使用组合语句。

// 两条语句都只有一个作用:
// 第一条从values中取得一个值并保存到name中,第二条递增变量i。
let name = values[i];
i++;
// 改进:
// 把迭代性的值插入第一条语句就可以将它们合并为一条语句:
let name = values[i++];

因为递增操作符是后缀形式的,所以i在语句其他部分执行完成之前是不会递增的。只要遇到类似的情况,就要尽量把迭代性值插入到上一条使用它的语句中。

3.使用数组和对象字面量

本书代码示例中有两种使用数组和对象的方式:构造函数和字面量。
使用构造函数始终会产生比单纯插入元素或定义属性更多的语句,而字面量只需一条语句即可完成全部操作。

28.2.4 优化DOM交互

DOM操作和交互需要占用大量时间,因为经常需要重新渲染整个或部分页面。
此外,看起来简单的操作也可能花费很长时间,因为DOM中携带着大量信息。
理解如何优化DOM交互可以极大地提升脚本的执行速度。

1.实时更新最小化

访问DOM时,只要访问的部分是显示页面的一部分,就是在执行实时更新操作。
之所以称其为实时更新,是因为涉及立即(实时)更新页面的显示,让用户看到。
每次这样的更新,无论是插入一个字符还是删除页面上的一节内容,都会导致性能损失。
这是因为浏览器需要为此重新计算数千项指标,之后才能执行更新。
实时更新的次数越多,执行代码所需的时间也越长。反之,实时更新的次数越少,代码执行就越快。

let list = document.getElementById('myList'),
    item;
for (let i = 0; i < 10; i++) {
  item = document.createElement('li');
  list.appendChild(item);
  item.appendChild(document.createTextNode(`Item ${i}`));
}

向列表中添加了10项。
每添加1项,就会有两次实时更新:
一次添加

  • 元素,一次为它添加文本节点。
    因为要添加10项,所以整个操作总共要执行20次实时更新。
    为解决这里的性能问题,需要减少实时更新的次数。解决方法:
    是使用文档片段构建DOM结构,然后一次性将它添加到list元素。
    这个办法可以减少实时更新,也可以避免页面闪烁。

    let list = document.getElementById('myList'),
        fragment = document.createDocumentFragment(),
        item;
    for (let i = 0; i < 10; i++) {
      item = document.createElement('li');
      fragment.appendChild(item);
      item.appendChild(document.createTextNode('Item ' + i));
    }
    list.appendChild(fragment);
    

    这样修改之后,完成同样的操作只会触发一次实时更新。
    这是因为更新是在添加完所有列表项之后一次性完成的。
    文档片段在这里作为新创建项目的临时占位符。
    最后,使用appendChild()将所有项目都添加到列表中。
    别忘了,在把文档片段传给appendChild()时,会把片段的所有子元素添加到父元素,片段本身不会被添加。
    只要是必须更新DOM,就尽量考虑使用文档片段来预先构建DOM结构,然后再把构建好的DOM结构实时更新到文档中。

    2.使用innerHTML

    在页面中创建新DOM节点的方式有两种:
    使用DOM方法如createElement()和appendChild(),以及使用innerHTML。
    对于少量DOM更新,这两种技术区别不大;
    但对于大量DOM更新,使用innerHTML要比使用标准DOM方法创建同样的结构快很多。
    在给innerHTML赋值时,后台会创建HTML解析器,然后会使用原生DOM调用而不是JavaScript的DOM方法来创建DOM结构。
    原生DOM方法速度更快,因为该方法是执行编译代码而非解释代码。
    注:使用innerHTML可以提升性能,但也会暴露巨大的XSS攻击面。无论何时使用它填充不受控的数据,都有可能被攻击者注入可执行代码。此时必须要当心。

    3.使用事件委托

    大多数Web应用程序会大量使用事件处理程序实现用户交互。
    一个页面中事件处理程序的数量与页面响应用户交互的速度有直接关系。
    为了减少对页面响应的影响,应该尽可能使用事件委托。
    事件委托利用了事件的冒泡。任何冒泡的事件都可以不在事件目标上,而在目标的任何祖先元素上处理。
    基于这个认知,可以把事件处理程序添加到负责处理多个目标的高层元素上。只要可能,就应该在文档级添加事件处理程序,因为在文档级可以处理整个页面的事件。

    4.注意HTMLCollection

    任何时候,只要访问HTMLCollection,无论是它的属性还是方法,就会触发查询文档,而这个查询相当耗时。
    减少访问HTMLCollection的次数可以极大地提升脚本的性能。
    可能优化HTMLCollection访问最关键地方就是循环了:
    把length保存到了len变量中,而不是每次都读一次HTMLCollection的length属性;
    在循环中使用HTMLCollection时,应该首先取得对要使用的元素的引用
    编写JavaScript代码时,关键是要记住,只要返回HTMLCollection对象,就应该尽量不访问它。
    以下情形会返回HTMLCollection:
    ❑ 调用getElementsByTagName();
    ❑ 读取元素的childNodes属性;
    ❑ 读取元素的attributes属性;
    ❑ 访问特殊集合,如document.form、document.images等。

    28.3 部署

    在此之前我们已完成了很多工作,包括架构方面和优化方面的。现在到了把代码移出开发环境,发布到网上,让用户去使用它的时候了。不过,在发布之前,还需要解决一些问题。

    28.3.1 构建流程

    准备发布JavaScript代码时最重要一环是准备构建流程。
    开发软件的典型模式是编码、编译和测试。换句话说,首先要写代码,然后编译,之后运行并确保它能够正常工作。
    但因为JavaScript不是编译型语言,所以这个流程经常会变成编码、测试。
    你写的代码跟在浏览器中测试的代码一样。这种方式的问题在于代码并不是最优的。
    你写的代码不应该不做任何处理就直接交给浏览器,原因如下:
    知识产权问题:如果把满是注释的代码放到网上,其他人就很容易了解你在做什么,重用它,并可能发现安全漏洞。
    文件大小:你写的代码可读性很好,容易维护,但性能不好。浏览器不会因为代码中多余的空格、缩进、冗余的函数和变量名而受益。
    代码组织:为保证可维护性而组织的代码不一定适合直接交付给浏览器。
    为此,需要为JavaScript文件建立构建流程。

    1.文件结构

    构建流程首先定义在源代码控制中存储文件的逻辑结构。最好不要在一个文件中包含所有JavaScript代码。
    相反,要遵循面向对象编程语言的典型模式,把对象和自定义类型保存到自己独立的文件中。这样可以让每个文件只包含最小量的代码,让后期修改更方便,也不易引入错误。
    此外,在使用并发源代码控制系统(如Git、CVS或Subversion)的环境中,这样可以减少合并时发生冲突的风险。
    注意,把代码分散到多个文件是从可维护性而不是部署角度出发的。
    对于部署,应该把所有源文件合并为一个或多个汇总文件。
    Web应用程序使用的JavaScript文件越少越好,
    因为HTTP请求对某些Web应用程序而言是主要的性能瓶颈。而且,使用