前些天收到了 HTML5中国 送来的《高性能javascript》一书,便打算将其做为假期消遣,顺便也写篇文章记录下书中一些要点。
个人觉得本书很值得中低级别的前端朋友阅读,会有很多意想不到的收获。
基于UI单线程的逻辑,常规脚本的加载会阻塞后续页面脚本甚至DOM的加载。如下代码会报错:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <script src="http://libs.baidu.com/jquery/1.11.1/jquery.min.js"></script> </head> <body> <script> console.log($); document.querySelector('div').innerText='中秋快乐'; </script> <div>9999999999999</div> </body> </html>
原因是 div 被置于脚本之后,它还没被页面解析到就先执行了脚本 (当然这属于最基础的知识点了) 。
书中提及了使用 defer 属性可以延迟脚本到DOM加载完成之后才执行。
我们常规喜欢把脚本放到页面的末尾,并裹上 DOMContentLoaded 事件,事实上只需要给 script 标签加上 defer 属性会比前者做法更简单也更好 (只要没有兼容问题) ,毕竟连 DOMContentLoaded 的事件绑定都先绕过了。
书中没有提及 async 属性,其加载执行也不会影响页面的加载,跟 defer 相比,它并不会等到 DOM 加载完才执行,而是脚本自身加载完就执行(但执行是异步的,不会阻塞页面,脚本和DOM加载完成的先后没有一个绝对顺序)。
本章在一开始提及了作用域链,告诉了读者“ 对浏览器来说,一个标识符(变量)所在的位置越深,它的读写速度也就越慢 (性能开销越大) ”。
我们知道很多库都喜欢这么做封装:
(function(win, doc, undefined) { // TODO })(window, document, undefined)
以IIFE的形式形成一个局部作用域,这种做法的优势之一当然是可避免产生污染全局作用域的变量,不过留意下,我们还把 window、document、undefined 等顶层作用域对象传入该密封的作用域中,可以让浏览器只检索当层作用域既能正确取得对应的顶层对象,减少了层层向上检索对象的性能花销,这对于类似 jQuery 这种动辄几千处调用全局变量的脚本库而言是个重要的优化点。
我们常规被告知要尽量避免使用 with 来改变当前函数作用域,本书的P 22 页介绍了该原因,这里来个简单的例子:
function a(){ var foo = 123; with (document){ var bd = body; console.log(bd.clientHeight + foo) } }
在 with 的作用域块里面,执行环境(上下文)的作用域链被指向了 document,因此浏览器可以在 with 代码块中更快读取到 document 的各种属性(浏览器最先检索的作用域链层对象变为了 document)。
但当我们需要获取局部变量 foo 的时候,浏览器会先检索一遍 document,检索不到再往上一层作用域链检索函数 a 来取得正确的 foo,由此一来会增加了浏览器检索作用域对象的开销。
书中提及的对同样会改变作用域链层的 try-catch 的处理, 但我觉得不太受用 :
try { methodMightCauseError(); } catch (ex){ handleError(ex) //留意此处 }
书中的意思是,希望在 catch 中使用一个独立的方法 handleError 来处理错误,减少对 catch 外部的局部变量的访问 (catch代码块内的作用域首层变为了ex作用域层) 。
我们来个例子:
(function(){ var t = Date.now(); function handleError(ex){ alert(t + ':' +ex.message) } try { //TODO:sth } catch (ex){ handleError(ex); } })()
我觉得不太受用的原因是,当 handleError 被执行的时候,其作用域链首层指向了 handleError 代码块内的执行环境,第二层的作用域链才包含了变量t。
所以当在 handleError 中检索 t 时,事实上浏览器还是依旧翻了一层作用域链 (当然检索该层的速度还是会比检索ex层的要快一些,毕竟ex默认带有一些额外属性) 。
后续提及的原型链也是非常重要的一环,无论是本书抑或《高三》一书均有非常详尽的介绍,本文不赘述,不过大家可以记住这么一点:
对象的内部原型 __proto__ 总会指向其构造对象的原型 prototype ,脚本引擎在读取对象属性时会先按如下顺序检索:
对象实例属性 → 对象prototype → 对象__proto__指向的上一层prototype → .... → 最顶层(Object.prototype)
想进一步了解原型链生态的,可以查看这篇我收藏已久的文章。
在第二章最后提及的“避免多次读取同一个对象属性”的观点,其实在JQ源码里也很常见:
这种做法一来在最终构建脚本的时候可以大大减小文件体积,二来可以提升对这些对象属性的读取速度,一石二鸟。
本章提及的很多知识点在其它书籍上其实都有描述或扩展的例子。如在《Webkit内核技术内幕》的开篇(第18页)就提到JS引擎与DOM引擎是分开的,导致脚本对DOM树的访问很耗性能;在曾探的《javascript设计模式》一书中也提及了对大批量DOM节点操作应做节流处理来减少性能花销,有兴趣的朋友可以购入这两本书看一看。
本章在选择器API一处建议使用 document.querySelectorAll 的原生DOM方法来获取元素列表,提及了一个挺重要的知识点——仅返回一个 NodeList 而非HTML集合,因此这些 返回的节点集不会对应实时的文档结构 ,在遍历节点时可以比较放心地使用该方法。
本章重排重绘的介绍可以参考阮一峰老师的《 网页性能管理详解 》一文,本章不少提及的要点在阮老师的文章里也被提及到。
我们需要留意的一点是,当我们调用了以下属性/方法时, 浏览器会“不得不”刷新渲染队列并触发重排 以返回正确的值:
offsetTop/offsetLeft/offsetWidth/offsetHeight scrollTop/scrollLeft/scrollWidth/scrollHeight clientTop/clientLeft/clientWidth/clientHeight getComputedStyle()
因此如果某些计算需要频繁访问到这些偏移值,建议先把它缓存到一个变量中,下次直接从变量读取,可有效减少冗余的重排重绘。
本章在介绍批量修改DOM如何减少重排重绘时,提及了三种让元素脱离文档流的方案,值得记录下:
方案⑴:先隐藏元素(display:none),批量处理完毕再显示出来(适用于大部分情况);
方案⑵:创建一个文档片段(document.createDocumentFragment),将批量新增的节点存入文档片段后再将其插入要修改的节点(性能最优,适用于新增节点的情况);
方案⑶:通过 cloneNode 克隆要修改的节点,对其修改后再使用 replaceChild 的方法替换旧节点。
在这里提个扩展,即DOM大批量操作节流的,指的是当我们需要在一个时间单位内做很大数量的重复的DOM操作时,应主动减少DOM操作处理的数量。
打个比方,在手Q公会大厅首页使用了iscroll,用于在页面滚动时能实时吸附导航条,大致代码如下:
var myscroll = new iScroll("wrapper", { onScrollMove : dealNavBar, onScrollEnd : dealNavBar } );
其中的 dealNavBar 方法用于处理导航条,让其保持吸附在viewport顶部。
这种方式的处理导致了页面滚动时出现了非常严重的卡顿问题,原因是每次 iscroll 的滚动就会执行非常多次的 dealNavBar 方法计算(当然我们还需要获取容器的scrollTop来计算导航条的吸附位置,导致不断重排重绘,这就更加悲剧了)。
对于该问题有一个可行的解决方案—— 节流,在iscroll容器滚动时舍得在某个时间单位(比如300ms)里才执行一次 dealNavBar:
var throttle = function (fn, delay) { var timer = null; return function () { var context = this, args = arguments; clearTimeout(timer); timer = setTimeout(function () { fn.apply(context, args); }, delay); }; }; var myscroll = new iScroll("wrapper", { onScrollMove : throttle.bind(this, dealNavBar, 300) } );
当然这种方法会导致导航条的顶部吸附不在那么实时稳固了,会一闪一闪的看着不舒服,个人还是倾向于只在 onScrollEnd 里对其做处理即可。
那么什么时候需要节流呢?
常规在会频繁触发回调的事件里我们推荐使用节流,比如 window.onscroll、window.onresize 等,另外在《设计模式》一书里提及了一个场景 —— 需要往页面插入大量内容,这时候与其一口气插入,不妨节流分几次 (比如每秒最多插入80个) 来完成整个操作。
本章主要介绍了一些循环和迭代的算法优化,适合仔细阅读,感觉也没多余可讲解或扩展的地方,不过本章提及了“调用栈/Call Stack”,想起了我面试的时候遇到的一道和调用栈相关的问题,这里就讲个题外话。
当初的问题是,如果某个函数的调用出错了,我要怎么知道该函数是被谁调用了呢?注意只允许在 chrome 中调试,不允许修改代码。
答案其实也简单,就是给被调用的函数设断点,然后在 Sources 选项卡查看“Call Stack”区域信息:
另外关于本章最后提及的 Memoization 算法,实际上属于一种代理模式,把每次的计算缓存起来,下次则绕过计算直接到缓存中取,这点对性能的优化还是很有帮助的,这个理念也不仅仅是运用在算法中,比如在我的smartComplete 组件里就运用了该缓存理念,每次从服务器获得的响应数据都缓存起来,下次同样的请求参数则直接从缓存里取响应,减少冗余的服务器请求,也加快了响应速度。
开头提及的“通过一个循环向字符串末尾不断添加内容”来构建最终字符串的方法在“某些浏览器”中性能糟糕,并推荐在这些浏览器中使用数组的形式来构建字符串。
要留意的是在主流浏览器里,通过循环向字符串末尾添加内容的形式已经得到很大优化,性能比数组构建字符串的形式还来的要好。
接着文章提及的字符串构建原理很值得了解:
var str = ""; str += "a"; //没有产生临时字符串 str += "b" + "c"; //产生了临时字符串! /* 上一行建议更改为 str = str + "b" + "c"; 避免产生临时字符串 */ str = "d" + str + "e" //产生了临时字符串!
“临时字符串”的产生会影响字符串构建过程的性能,加大内存开销,而是否会分配“临时字符串”还是得看“基本字符串”,若“基本字符串”是字符串变量本身 (栈内存里已为其分配了空间) ,那么字符串构建的过程就不会产生多余的“临时字符串”,从而提升性能。
以上方代码为例,我们看看每一行的“基本字符串”都是谁:
var str = ""; str += "a"; //“基本字符串”是 str str += "b" + "c"; //“基本字符串”是"b" /* 上一行建议更改为 str = str + "b" + "c"; //“基本字符串”是 str 避免产生临时字符串 */ str = "d" + str + "e" //“基本字符串”是"d"
以最后一行为例,计算时浏览器会分配一处临时内存来存放临时字符串"b",然后依次从左到右把 str、"e"的值拷贝到"b"的右侧 (拷贝的过程中浏览器也会尝试给基础字符串分配更多的内存便于扩展内容) 。
至于前面提到的“某些浏览器中构建字符串很糟糕”的情况,我们可以看看《高三》一书(P 33 )是怎么描述这个“糟糕”的原因:
var lang = "Java"; //在内存开辟一个空间存放"Java" lang = lang + "script"; //创建一个能容纳10个字符的空间, //拷贝字符串"Java"和"script"(注意这两个字符串也都开辟了内存空间)到这个空间, //接着销毁原有的"Java"和"script"字符串
我们继续扩展一个基础知识点——字符串的方法是如何被调用到的?
我们知道字符串属于基本类型,它不是对象为何咱们可以调用 concat、substring等字符串属性方法呢?
别忘了万物皆对象,在前面我们提及原型链时也提到了最顶层是 Object.prototype,而每个字符串,实际上都属于一个包装对象。
我们分析下面的例子,整个过程发生了什么:
var s1 = "some text"; var s2 = s1.substring(2); s1.color = "red"; alert(s1.color);
在每次调用 s1 的属性方法时,后台总会在这之前默默地先做一件事——执行 s1=new String('some text') ,从而让我们可以顺着原型链调用到String对象的属性 (比如第二行调用了 substring) 。
在调用完毕之后,后台又回默默地销毁这个先前创建了的包装对象。这就导致了在第三行我们给包装对象新增属性color后,该对象立即被销毁,最后一行再次创建包装对象的时候不再有color属性,从而alert了undefined。
在《高三》一书里是这么描述的:
“引用类型与基本包装类型的主要区别就是对象的生存期。使用new操作符创建的引用类型的实例,在执行流离开当前作用域之前都一直保存在内存中。而自动创建的基本包装类型的对象,则只存在于一行代码的执行瞬间,然后立即被销毁。这意味着我们不能在运行时为基本类型值添加属性和方法。”
正则的部分提及了“回溯法”,在维基百科里是这样描述的:
回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况: 1. 找到一个可能存在的正确的答案 2. 在尝试了所有可能的分步方法后宣告该问题没有答案 在最坏的情况下,回溯法会导致一次复杂度为指数时间的计算。
常规我们应当尽可能减少正则的回溯,从而提升匹配性能:
var str = "<p>123</p><img src='1.jpg' /><p>456</p>"; var r1 = /<p>.*<//p>/i.test(str); //贪婪匹配会导致较多回溯 var r2 = /<p>.*?<//p>/i.test(str); //推荐,惰性匹配减少回溯
对于书中建议对正则匹配优化的部分,我总结了一些比较重要的点,也补充对应的例子:
正则匹配中最耗时间的部分往往不是匹配成功,而是匹配失败,如果能让匹配失败的过程更早结束,可以有效减少匹配时间:
var str = 'eABC21323AB213', r1 = //bAB/.test(str), //匹配失败的过程较长 r2 = /^AB/.test(str); //匹配失败的过程很短
前者指的是尽可能避免条件分支,比如 (.|/r|/n) 可替换为等价的 [/s/S];
具体化量词则是为了让正则更精准匹配到内容,比如用特定字符来取代抽象的量词。
这两种方式都能有效减少回溯。来个示例:
var str = 'cat 1990'; //19XX年出生的猫或蝙蝠 var r1 = /(cat|bat)/s/d{4}/.test(str); //不推荐 var r1 = /[bc]at/s19/d{2}/.test(str); //推荐
捕获组会消耗时间和内存来记录反向引用,因此当我们不需要一个反向引用的时候,利用非捕获组可以避免这些开销:
var str = 'teacher VaJoy'; var r1 = /(teacher|student)/s(/w+)/.exec(str)[2]; //不推荐 var r2 = /(?:teacher|student)/s(/w+)/.exec(str)[1]; //推荐
很多时候可以利用分组来直接取得我们需要的部分,减少后续的处理:
var str = 'he says "I do like this book"'; var r1 = str.match(/"[^"]*"/).toString().replace(/"/g,''); //不推荐 var r2 = str.replace(/^.*?"([^"]*)"/, '$1'); //推荐 var r3 = /"([^"]*)"/.exec(str)[1]; //推荐
可能会有个误区,觉得能尽量在单条正则表达式里匹配到结果总会优于分多条匹配。
本章则告诉读者应“避免在一个正则表达式中处理太多任务。复杂的搜索问题需要条件逻辑,拆分成两个或多个正则表达式更容易解决,通常也会更高效”。
这里就不举复杂的例子了,直接用书上去除字符串首尾空白的两个示例:
//trim1 String.prototype.trim = function(){ return this.replace(/^/s+/, "").replace(//s+$/, "") } //trim2 String.prototype.trim = function(){ return this.replace(/^/s+|/s+$/, "") }
事实上 trim2 比 trim1 还要慢,因为 trim1 只需检索一遍原字符串,并再检索一遍去除了了头部空白符的字符串。而 trim2 需要检索两遍原字符串。
主要还是条件分支导致的回溯问题,常规复杂的正则表达式总会带有许多条件分支,这时候就很有必要对其进行拆解了。
当然去掉了条件分支的话,单条正则匹配结果还是一个优先的选择,例如书中给出 trim 的建议方案为:
String.prototype.trim = function(){ return this.replace(/^/s*([/s/S]*/S)?/s*$/, "$1") }
本书上半部分就先总结到这里,共勉~