前些天收到了HTML5中国送来的《高性能javascript》一书,便打算将其做为假期消遣,顺便也写篇文章记录下书中一些要点。
个人觉得本书很值得中低级别的前端朋友阅读,会有很多意想不到的收获。
第一章 加载和执行
基于UI单线程的逻辑,常规脚本的加载会阻塞后续页面脚本甚至DOM的加载。如下代码会报错:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<!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加载完成的先后没有一个绝对顺序)。
第二章 数据存储
本章在一开始提及了作用域链,告诉了读者“对浏览器来说,一个标识符(变量)所在的位置越深,它的读写速度也就越慢(性能开销越大)”。
我们知道很多库都喜欢这么做封装:
1 2 3 4 5 |
(function(win, doc, undefined) { // TODO })(window, document, undefined) |
以IIFE的形式形成一个局部作用域,这种做法的优势之一当然是可避免产生污染全局作用域的变量,不过留意下,我们还把 window、document、undefined 等顶层作用域对象传入该密封的作用域中,可以让浏览器只检索当层作用域既能正确取得对应的顶层对象,减少了层层向上检索对象的性能花销,这对于类似 jQuery 这种动辄几千处调用全局变量的脚本库而言是个重要的优化点。
我们常规被告知要尽量避免使用 with 来改变当前函数作用域,本书的P22页介绍了该原因,这里来个简单的例子:
1 2 3 4 5 6 7 |
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 的处理,但我觉得不太受用:
1 2 3 4 5 |
try { methodMightCauseError(); } catch (ex){ handleError(ex) //留意此处 } |
书中的意思是,希望在 catch 中使用一个独立的方法 handleError 来处理错误,减少对 catch 外部的局部变量的访问(catch代码块内的作用域首层变为了ex作用域层)。
我们来个例子:
1 2 3 4 5 6 7 8 9 10 11 |
(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源码里也很常见:
这种做法一来在最终构建脚本的时候可以大大减小文件体积,二来可以提升对这些对象属性的读取速度,一石二鸟。
第三章 DOM编程
本章提及的很多知识点在其它书籍上其实都有描述或扩展的例子。如在《Webkit内核技术内幕》的开篇(第18页)就提到JS引擎与DOM引擎是分开的,导致脚本对DOM树的访问很耗性能;在曾探的《javascript设计模式》一书中也提及了对大批量DOM节点操作应做节流处理来减少性能花销,有兴趣的朋友可以购入这两本书看一看。
本章在选择器API一处建议使用 document.querySelectorAll 的原生DOM方法来获取元素列表,提及了一个挺重要的知识点——仅返回一个 NodeList 而非HTML集合,因此这些返回的节点集不会对应实时的文档结构,在遍历节点时可以比较放心地使用该方法。
本章重排重绘的介绍可以参考阮一峰老师的《网页性能管理详解》一文,本章不少提及的要点在阮老师的文章里也被提及到。
我们需要留意的一点是,当我们调用了以下属性/方法时,浏览器会“不得不”刷新渲染队列并触发重排以返回正确的值:
1 2 3 4 |
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,用于在页面滚动时能实时吸附导航条,大致代码如下:
1 2 3 4 5 6 |
var myscroll = new iScroll("wrapper", { onScrollMove : dealNavBar, onScrollEnd : dealNavBar } ); |
其中的 dealNavBar 方法用于处理导航条,让其保持吸附在viewport顶部。
这种方式的处理导致了页面滚动时出现了非常严重的卡顿问题,原因是每次 iscroll 的滚动就会执行非常多次的 dealNavBar 方法计算(当然我们还需要获取容器的scrollTop来计算导航条的吸附位置,导致不断重排重绘,这就更加悲剧了)。
对于该问题有一个可行的解决方案—— 节流,在iscroll容器滚动时舍得在某个时间单位(比如300ms)里才执行一次 dealNavBar:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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 组件里就运用了该缓存理念,每次从服务器获得的响应数据都缓存起来,下次同样的请求参数则直接从缓存里取响应,减少冗余的服务器请求,也加快了响应速度。
第五章 字符串和正则表达式
开头提及的“通过一个循环向字符串末尾不断添加内容”来构建最终字符串的方法在“某些浏览器”中性能糟糕,并推荐在这些浏览器中使用数组的形式来构建字符串。
要留意的是在主流浏览器里,通过循环向字符串末尾添加内容的形式已经得到很大优化,性能比数组构建字符串的形式还来的要好。
接着文章提及的字符串构建原理很值得了解:
1 2 3 4 5 6 7 |
var str = ""; str += "a"; //没有产生临时字符串 str += "b" + "c"; //产生了临时字符串! /* 上一行建议更改为 str = str + "b" + "c"; 避免产生临时字符串 */ str = "d" + str + "e" //产生了临时字符串! |
“临时字符串”的产生会影响字符串构建过程的性能,加大内存开销,而是否会分配“临时字符串”还是得看“基本字符串”,若“基本字符串”是字符串变量本身(栈内存里已为其分配了空间),那么字符串构建的过程就不会产生多余的“临时字符串”,从而提升性能。
以上方代码为例,我们看看每一行的“基本字符串”都是谁:
1 2 3 4 5 6 7 |
var str = ""; str += "a"; //“基本字符串”是 str str += "b" + "c"; //“基本字符串”是"b" /* 上一行建议更改为 str = str + "b" + "c"; //“基本字符串”是 str 避免产生临时字符串 */ str = "d" + str + "e" //“基本字符串”是"d" |
以最后一行为例,计算时浏览器会分配一处临时内存来存放临时字符串”b”,然后依次从左到右把 str、”e”的值拷贝到”b”的右侧(拷贝的过程中浏览器也会尝试给基础字符串分配更多的内存便于扩展内容)。
至于前面提到的“某些浏览器中构建字符串很糟糕”的情况,我们可以看看《高三》一书(P33)是怎么描述这个“糟糕”的原因:
1 2 3 4 |
var lang = "Java"; //在内存开辟一个空间存放"Java" lang = lang + "script"; //创建一个能容纳10个字符的空间, //拷贝字符串"Java"和"script"(注意这两个字符串也都开辟了内存空间)到这个空间, //接着销毁原有的"Java"和"script"字符串 |
我们继续扩展一个基础知识点——字符串的方法是如何被调用到的?
我们知道字符串属于基本类型,它不是对象为何咱们可以调用 concat、substring等字符串属性方法呢?
别忘了万物皆对象,在前面我们提及原型链时也提到了最顶层是 Object.prototype,而每个字符串,实际上都属于一个包装对象。
我们分析下面的例子,整个过程发生了什么: