前言:
Javascript绝对是最火的编程语言之一,一直具有很大的用户群,具有广泛的应用前景。而在前端开发中,它也是三驾马车之一,并且是最重要的一环。要想给用户提供更流畅的操作体验,更友好的交互,对Javascript程序进行优化、提高执行效率也就必不可少。那么我们怎么样才能编写出高性能的JS程序呢?本文是在阅读《高性能网站建设进阶指南》和《高性能JavaScript》之后写的一篇总结,自己也加深一下印象,希望可以帮助大家!
一、数据访问
1、高效数据存储
数据在脚本中的存储位置会影响脚本的执行时间。一般而言有四种方式可以存储数据:
- 字面量值
- 变量
- 数组元素
- 对象属性
用不同的方式存储数据会带来不同的性能开销。在大部分浏览器中,从字面量中读取数据和从局部变量中读取的开销最小,几乎可以忽略不计。真正的差异在从数组或对象中读取数据,经过对以上四种不同数据存储位置的实验表明,数组和对象存储方式性能消耗是大于其他两种的。
为提高数据存储效率,我们需要把那些需要频繁存取的值存储到局部变量中。
function process(data){ if (data.count>0){ for (var i=0;i<data.count;i++){ processData(data.item[i]); } } } //把data.count存储到局部变量,只需要读取一次即可,其他都在局部变量里读取 function process(data){ var count = data.count; if (count>0){ for (var i=0;i<count;i++){ processData(data.item[i]); } } }
还有就是随着数据结构的加深,也会对数据存取带来负面影响。如data.item.subitem.count要比data.count慢。
在Js程序中使用超过一次的对象属性或数组元素存储为局部变量是一种不错的方法。特别是在处理HTMLCollection对象(如getElementByTagName这样的DOM方法返回的值或element.childNodes这样的属性值)时使用局部变量更加重要,因为每次存取HTMLCollection对象时都会进行动态查询,也就是说每一次读取都会重新查询的,这是一个消耗很大的行为。
2、管理作用域
我们知道js里有作用域链的概念,在程序执行时,Javascript引擎会通过搜索上下文的作用域链来解析诸如变量和函数名这样的标识符。其会从作用域链的最里面开始检索,按照由内到外的顺序,直到完成查找,一旦完成查找就结束搜索。很明显如果JS引擎可以直接找到标识符要比向外回溯查找要快不少,也就是说标识符在作用域链中的位置越深,查找和访问需要的时间也就越长;如果作用域链管理的不合理,会给脚本的执行带来负面影响。目前来说,有些浏览器已经优化过js引擎,比如chrome和safari4,访问跨作用域标识符时性能损失不明显,其他浏览器依然有极大的影响。为了使程序在众多浏览器上都能高效运行,我们必须对作用域进行良好的管理,下面是一些具体的方法仅供参考:
- 1.1使用局部变量
局部变量是JS种读写最快的标识符,我们应尽可能的使用局部变量。如果在程序中,任何非局部变量在函数中使用次数超过一次,我们最好将其存储为局部变量来提高速度。例如:
function createChildFor (elementID){ var element = document.getElementById(elementID), newElement=document.createElement("div"); element.appendChild(newElement); } //将document存储到一个局部变量里 function createChildFor (elementID){ var doc=document, element = doc.getElementById(elementID), newElement=doc.createElement("div"); element.appendChild(newElement); }
原函数是要到全局作用域里查找两次document,而改写后的函数只需要查找一次全局作用域,其他的通过查找局部作用域就可以了。 改写后的函数无疑是会提高速度的,要切记全局对象始终是作用域链中的最后一个对象,要尽量避免使用过多的全局变量。
- 1.2增长作用域链
在代码执行时,对应的作用域链常常是保持静态的。然而当遇到with语句和try-catch中的catch时,会改变作用域链的。在遇到with语句时,会将对象属性作为局部变量来显示,使其便于访问,也就是说把一个新的对象添加到了作用域链的顶端,这样必然影响对局部标志符的解析。当with语句执行完毕后,会把作用域链恢复到原始状态。在编程中要避免这种情况。执行catch语句时遇到的情况与with语句差不多,然而它只会在捕捉到错误时才会执行,对性能的影响要小于with语句。
管理好作用域链是一种高效便捷的方法,只需要少量的工作就可以提高性能,在平时编写JS时要注意这个问题。
二、DOM编程
在浏览器内部进行页面呈现和JS解析的不是一个引擎,DOM为JS操作HTML和XML提供了API,要想把JS对DOM的修改呈现出来,无疑需要页面呈现引擎和JS引擎进行沟通,两个相互独立的功能只要通过接口彼此连接,就会产生消耗。而且它在浏览器中是以JavaScript实现的,JS本身的执行效率就低于JAVA或者C(JS是解释型语言,边编译边执行;JAVA或者C是编译型语言,一次编译,直接执行),所以DOM有点慢。可能给程序带来负面影响的DOM操作,大致可以分为三类:访问和修改DOM元素,修改DOM元素的样式导致重绘(repaint)和重排(reflow),通过DOM事件处理与用户交互。
- 在访问与修改DOM时开销很大,特别是修改元素时,会导致浏览器重新渲染页面。所以,要尽量减少修改次数,可以多次累积一次修改。在获取HTML元素集合时(如,getElementByName(),getElementByClassName(),getElementByTagName()等),也是非常耗能的,因为动态查询的,随时反映元素状态,多次使用元素集合时应缓存为局部变量,以减少查询次数。还有在遍历DOM时,最好使用最合适的最高效的API,比如选择器API(querySelectorAll(),querySelector())已经被很多浏览器提供了原生的支持,原生的API在任何时候都要比其他非原生方式快速。
- 重绘与重排开销也非常大。特别是重排,在DOM的变化影响了元素的几何属性时,浏览器会重新计算元素的几何属性,其他相关元素也会受到影响,受到影响的元素就会被重新构造渲染树。完成重排之后,浏览器会重新绘制受影响的元素到屏幕中,也就是重绘。每次重排都是非常耗能的,浏览器通过队列化修改并批量的执行来优化重排过程。然而,获取这些布局信息的时候(offsetTop,scrollTop,clientTop等)会导致队列刷新,也就是立即执行,不能完全的批量执行。所以,在需要获取这些布局信息的时候,可以放在批量重排之后。总的来说,要想提高性能,可以批量修改样式、离线操作DOM树、缓存布局信息减少访问布局信息的次数等。
- 如果页面上元素很多,而且很多元素上也绑定有事件处理程序,也会加重浏览器负担。事件绑定越多,由于浏览器要跟踪每个事件处理器,也会占用更多的内存。可以用事件委托,减少大量的事件绑定,减轻浏览器的压力。
至于改进嘛,个人感觉按照现在这个趋势就行。随着DOM0、DOM2、DOM3公布,新的API也在不断增加,虽然这些新的API中有的会带来性能问题,但给我们提供了极大的便利。其实,随着硬件水平的快速发展,很多问题将不再是问题。
三、算法和流程控制
代码的数量并不是衡量一个程序运行快慢的指标,影响性能的最直接因素是代码的组织方式,以及具体问题的解决方法。
1.循环
循环是编程中最常见的模式之一,死循环和长时间的循环会影响函数的执行效率。在js里有四种类型的循环,for循环、while循环、do-while循环和for-in循环,其中只有for-in循环比其他循环模式要慢。因此,除非你需要迭代的是一个未知数量的对象,否则要避免使用for-in循环。
在选取合适的循环类型之后,可以优化的因素就剩下每次迭代的事务和迭代次数了。我们需要分析每次迭代时,所做的操作,找出可以可以减少工作量的编程方式。关于减少迭代次数方面,可以采用“Duff‘s Device”循环体展开技术,它可以使得在一次循环中执行多次迭代操作。
2.条件语句
- 2.1快速条件判断
使用switch语句还是一串if-else语句是很多编程语言里经典问题。
if语句:当这样的语句较多时,会有很大的开销,因为语句的执行流越深,需要判断的条件也就越多。使用大量的if条件语句是一件很糟糕的事情,但可以采用下面的方法来提高整体性能。
- 将条件按频率降序排列;
- 优化if语句,将条件语句拆分成几个分支。这样就会在少量的判断次数的情况下完成匹配。
- 2.2switch语句
switch简化了多重条件判断的结构,并提升了性能。switch具有很强的可读性,而且很多语言推荐使用它,并不是因为它的本身,而是很多语言的编译器可以优化switch语句,使它能更快的求值。在JS中,当仅判断一两个条件时,if语句常常比switch语句更快;当两个以上比较简单的条件时,switch语句往往更快。这是因为大多数情况下,switch语句执行单个条件所需的时间比if语句少,那么当大量判断时,switch更优秀。
- 2.3数组查询
当有大量的离散值需要测试时,以上两种方式是比较低效的,我们可以把需要查询的值用字面量方式赋值给数组或者对象,把以前的条件查询变为数组查询或者对象成员查询。它有个很大的优点就是:不用书写任何条件判断语句,即使候选值增加时,也几乎不会产生额外的性能开销。
3.递归
递归函数的潜在问题是终止条件不明确或缺少终止条件会导致函数长时间运行,并使得用户界面处于假死状态。而且,递归函数还可能遇到浏览器的“调用栈大小限制”。
js引擎支持的递归数量与js调用栈大小直接相关,当你使用了太多的递归甚至超过了最大栈容量,浏览器会报错。关于调用栈大小限制,只有IE是和系统空闲内存有关,其他浏览器是数量固定的。最常见导致栈溢出的原因是不正确的终止条件,因此递归模式错误的第一步是验证终止条件。如果终止条件没有错误,那么可能是算法中包含了太多层递归,可以改用迭代、Memoization,或者二者结合使用。其中,Memoization是一种避免重复工作的方法,它缓存前一个计算结果供后续计算使用,避免了重复工作。
四、正则表达式
没有经过合理优化的正则表达式也可能是造成性能瓶颈的重要因素。在一些需要正则表达式来处理字符串的地方,可以采用以下方式来优化表达式。
- 关注如何让匹配快速失败:正则表达式慢的原因通常是匹配失败的过程慢,而不是匹配成功的过程慢;
- 正则表达式以简单、必需的字元开始:一个正则表达式的起始标记应当尽可能快速地测试并排除明显不匹配的位置;
- 使用量词模式,使它们后面的字元互斥:当字符与字元相邻或者子表达式能够重叠匹配时,正则表达式尝试拆解文本的路径数量增加,为了避免这种情况,尽量具体化匹配模式;
- 减少分支数量,缩小分支范围:分支可能要求在字符串的每一个位置上测试所有分支选项,可以通过使用字符集和选项组来减少对分支的需求,或者将分支在正则表达式上的位置推后。
- 使用非捕获组:捕获组消耗时间和内存来记录反向引用,并使它保存最新。如果你不需要一个反向引用,可以使用非捕获组来避免这些开销,比如用(?:...)来替代(...)。
- 只捕获感兴趣的文本以减少后处理:如果你需要引用匹配的部分,应该采取一切手段捕获哪些片段,再使用反向引用来处理。
- 暴露必须的字元:帮助正则表达式引擎在查询优化过程时做出明智的决策,可以尝试让它更容易地判断哪些字元是必须的。
- 使用合适的量词;
- 把正则表达式赋值给变量并重用它们;
- 将复杂的正则表达式拆分为简单的片段:避免在一个正则表达式中处理太多的任务。
五、快速响应用户界面
大多数浏览器让一个单线程共用于执行js和更新用户界面,也就是说当执行js时是不能更新用户界面,反之亦然。关于共同执行js和更新用户界面的进程通常被称为“浏览器UI线程”。UI线程的工作基于一个简单的队列系统,任务会保存到队列中直到进程空闲,一点空闲队列的下一个任务就被执行。这些任务要么是执行JS,要么执行UI更新,包括重绘和重排。然而,空闲状态是理想的,因为用户的所有操作都会立刻触发UI更新。如果用户试图在任务运行期间与页面交互,不仅没有及时的UI更新,甚至可能新的UI更新任务都不会被创建并加入队列。事实上,大多数浏览器在JS运行时会停止把新任务加入UI线程的队列中,也就是说JS任务必须尽快结束,以免给用户体验造成不良影响。
对于浏览器自身来说会限制js执行时间的,此限制分为:调用栈大小限制,长时间运行脚本限制。当程序超过这些限制时会终止执行。有研究指出,如果界面在100ms内响应用户输入,用户会认为自己在“直接操纵界面中的对象”,超过100ms时用户会感到自己与界面失去了联系,不能给用户及时的反馈。由于当js运行时无法更新UI,所以如果js执行超过100ms的话会给用户带来不好的用户体验,所以要极力避免js执行超过100ms。
有时候即使用尽了各种办法,js执行时间依然大于100毫秒,为了提供好的用户体验,我们可以用定时器空出时间片段给UI更新,更新完成后再继续执行程序。除了使用定时器,我们也可以使用HTML5中新提供的Web Workers API,使程序不占用UI线程资源,给用户友好的交互体验。
要想写出高效的JS代码,以上所述并不是所有的注意事项,还有很多其他的方式使我们的程序更加简洁优美。个人感觉优化代码的方式不可穷尽,掌握好原生的js,对原生js加深了解,根据js的特性我们平时多加注意,应该是可以写出高效的代码的。还有一些知识点,在这里不能一一列举,大家想要有细致的了解,最好还是去读原著。原著写的非常好,很赞,值得一读!