Chapter 2: 数据访问
在JavaScript里,数据可以有四种存放方式:
- 字面(literal)
- 变量()
- 数组
- 对象属性
前两者的访问速度基本没有区别。疑问:我觉得变量主要指本地变量吧,也就是当下的执行上下文的变量体上面的,不然就涉及到作用域链查找,那样是否还这么快,就很难说了吧。
后两者明显比前两者慢很多。
====================================================================
管理作用域
作用域与标识符解析
基本上这部分的内容和Dmitry的解释是相同的。只是Dmitry有暗示说[[Scope]]链的实现其实有可能是它只包含一个元素,就是上一级的触发体,而它有一个再指向上一级触发体的引用。这个地方的细节我要在研究下。
标识符解析的效率
变量所在的作用域在作用域链上的位置越远,查找时间就越久。
所以作者建议,对于访问一次以上的值,缓存所有能缓存的东西,比如:
function initUI() {
var bd = document.body,
links = document.getElementsByTagName("a"),
i = 0,
len = links.length;
while (i < len) {
update(links[i++]);
}
document.getElementById("go-btn").onclick = function() {
start();
};
bd.className = "active";
}
不如写成:
function initUI() {
var doc = document,
bd = doc.body,
links = doc.getElementsByTagName("a"),
i = 0,
len = links.length;
while (i < len) {
update(links[i++]);
}
doc.getElementById("go-btn").onclick = function() {
start();
};
bd.className = "active";
}
作用域链的增长
两个操作会在执行过程中动态修改作用域链,就是with和catch语句。这个现象在《YDtKJS》和Dmitry的文章里都有详细说过了。
with
作者不推荐使用它是因为它会在作用域链里插入一个新的对象,导致原本在最前面的触发体被挤到了第二位,于是函数本地内的变量的访问反而变慢了。《YDtKJS》也建议不要使用with,原因是它导致了JS引擎预先做的优化失效了,这个问题在下面一点本书也介绍了。另外,《Effective JavaScript》也不建议,原因则是潜在的命名冲突,参见Item10。
catch
作者并没有建议完全杜绝使用catch,因为try-catch是很有用的操作。在执行到catch代码块内的时候,作用域链的前端会被插入一个新的对象,所以也会导致和上面with语句一样的问题,可是作者觉得只要在catch语句里避免使用本地变量,就可以解决这个操作带来的效率问题了,比如:
try {
methodThatMightCauseAnError();
} catch (ex) {
handleError(ex); //delegate to handler method
}
动态作用域
with,catch和eval所造成的效果统称为动态作用域(dynamic scope)。而作者不建议使用eval。因为多数JavaScript引擎所做的优化主要是针对标识符解析过程中的作用域链查找,而动态作用域会导致这些预先的优化都失效。
疑问:那catch会不会导致优化失效?它同样会导致动态作用域,却不见任何作者建议不要使用它。
====================================================================
对象成员
原型
不再多说了,以前翻译过的博客讨论得很多了:JavaScript原型的工作原理(以及如何利用它来实现类的继承) 与白话详解JavaScript原型,这一段的介绍没有超出那些文章提到的东西。只是注意in与hasOwnProperty()的区别。
原型链
通过原型链进行的变量查找会随着继承的深度增加,下面的统计数据必然是个过时的结果,可是我没有找到比较新的:
嵌套的对象成员属性
嵌套的成员变量也有这个问题,随着深度增加性能降低:
这本书出版于2010年,七年前,有一点值得注意,七年前的Chrome基本上都已经可以忽略这些问题了,Chrome的优化可以做到两种形式的深度都几乎影响不到效率,另外就是Safari和Firefox也很优秀。
缓存成员变量
关于这个话题的解决方案就是,用本地变量缓存有深度的成员变量。
Chapter 3: 文档对象模型(DOM)操作
一般来说,一个浏览器里面的DOM实现和JavaScript的实现是两个分开的模块,JavaScript是独立的脚本语言,它自身的规范里不包含DOM,所以JavaScript的引擎是个独立的部分,实现DOM的部分通常叫渲染引擎(rendering engine)。由于两者相互独立,所以所有与DOM有关的操作都会引起两个模块之间的沟通,这个通信导致了效率被降低。
首先,与文档对象模型有关的影响效率的操作有:
- 访问与修改DOM元素;
- 修改DOM元素的样式(style),导致界面重新渲染;
- 通过DOM事件处理用户交互。
====================================================================
DOM元素的访问与修改
第一个疑点是:在插入新HTML元素时,是通过非标准化的innerHTML属性性能好,还是通过DOM方法好,即document.createElement()与document.createTextNode()这样的方法?
结论是:普遍地说,innerHTML会高效一些,但是这个结果只在旧版本浏览器上试用,新的浏览器对DOM方法的优化越来越多,所以在新版的Chrome和Firefox上,已经是DOM方法更快一些,注意,这本书出版于2010年,今年是2017年。总之当时作者的结论是差别不是很大,不必太在意,我的感觉是,DOM方法应该在现在是首选了。
第二个疑点是:复制HTML节点是否会快过直接创建所有新节点?
结论:比较的方法是,一,用createElement()创建200个新td节点;二,用用createElement()创建一个td节点,然后用element.cloneNode()复制其余199个。结果是element.cloneNode()确实快一点点,不过这个差距很不明显。
第三个疑点是:HTML元素的集合,通过下列方法会得到这类集合:
- document.getElementsByName()
- document.getElementsByClassName()
- document.getElementsByTagName()
还有下列属性:
- document.images
- document.links
- document.forms
- document.forms[0].elements
下列代码会导致一个无限循环:
// an accidentally infinite loop
var alldivs = document.getElementsByTagName('div');
for (var i = 0; i < alldivs.length; i++) {
document.body.appendChild(document.createElement('div'))
}
原因是HTML元素集合总是动态更新的,实际上每次访问alldivs都会导致一个在DOM上的查询操作,所以在每次循环里插入一个新div的同时,会导致alldivs集合被重新计算,它的length也会增长,于是这个循环会永远执行下去。即使是JavaScript的普通数组都有这样的问题,这一点在<Effective JavaScript>里也提到了:Item49。
结论是用一个数组保存下来查询的结果在基于它来遍历会快很多,演示代码如下:
function toArray(coll) {
for (var i = 0, a = [], len = coll.length; i < len; i++) {
a[i] = coll[i];
}
return a;
}
var coll = document.getElementsByTagName('div');
var ar = toArray(coll);
//slower
function loopCollection() {
for (var count = 0; count < coll.length; count++) {
/* do nothing */
}
}
// faster
function loopCopiedArray() {
for (var count = 0; count < arr.length; count++) {
/* do nothing */
}
}
作者建议的方法是一般情况下缓存下length就可以了:
function loopCacheLengthCollection() {
var coll = document.getElementsByTagName('div'),
len = coll.length;
for (var count = 0; count < len; count++) {
/* do nothing */
}
}
除非你要访问的集合很大,那样的情况下,也要考虑进将集合元素缓存的操作带来的额外开销。如果在循环里面访问到DOM元素的话(这也是通常的情况),作者比较了下面三个方法,一个比一个快:
// slow
function collectionGlobal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < len; count++) {
name = document.getElementsByTagName('div')[count].nodeName;
name = document.getElementsByTagName('div')[count].nodeType;
name = document.getElementsByTagName('div')[count].tagName;
}
return name;
};
// faster
function collectionLocal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < len; count++) {
name = coll[count].nodeName;
name = coll[count].nodeType;
name = coll[count].tagName;
}
return name;
};
// fastest
function collectionNodesLocal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '',
el = null;
for (var count = 0; count < len; count++) {
el = coll[count];
name = el.nodeName;
name = el.nodeType;
name = el.tagName;
}
return name;
};
总结起来就是,能缓存就缓存,尤其是通过上面提到的方法和属性得到的元素集合,比如代码中的coll,其实每次访问它时都会导致重新查询,所以缓存它是很有帮助的。
====================================================================
搜索DOM元素
第一个问题是用childNodes还是nextSibling来遍历某个元素的所有子元素,
结论:从现状来看,这个争论已经没有意义了,随便吧,都差不多。
第二个疑问是关于节点类型(node type):HTML的节点有元素(element),文本(text),注解(comment)等等。像childNodes,firstChild和nextSibling这些API返回的节点是不分类的,它们是把所有的节点都返回,然而一般来说注解节点是没有用的,文本节点其实时常返回的就是个不同标签之前的空格。
结论,用新提供的API,它们返回的节点只是元素节点,比如:children,childElementCount,firstElementChild,lastElementChild,nextElementSibling,previousElementSibling。只是说,注意下它们是不是被目标环境支持,一般来说新的浏览器都支持。
选择器
浏览器开始提供原生态的基于CSS选择器的DOM方法来搜索元素,比如:querySelectorAll和querySelector。它们更高效。注意:这个方法返回的东西不是HTML集合,而是节点列表(NodeList),它没有前面提到过的那些会动态更新的问题。querySelectorAll已经越来越多地被浏览器支持,所以如果要使用像jQuery这样的JS库的话,确保它们的实现是依赖这些性能更好的方法。
====================================================================
回流(reflow)与重绘(repaint)
浏览器内部(我猜是渲染引擎rendering engine)维护两个数据结构来保存DOM,一个就是DOM树,另外一个是渲染树(rendering tree)。渲染树上面会保存的是那些被显式的节点,被隐藏的节点不会出现在上面。
如果JS修改了DOM元素,这个修改导致图形上的变化,比如大小,位置,那么浏览器会通知渲染树,渲染树会重新计算上面的节点,这个过程叫回流(reflow),回流之后渲染树再通知浏览器(我猜可能是渲染引擎里的布局管理器?)重新渲染,这个重新渲染的过程叫重绘(repaint)。只修改颜色不会导致回流,而直接进入重绘的步骤。
下列操作会导致回流:(暂略)
渲染树的修改的排队等候与队列刷新
出于优化的目的,对于渲染树的修改会被浏览器缓存起来,放到一个队列中,然后集中一次性处理某些修改,处理的过程就是刷新(flushing)。但是有些操作会强迫浏览器刷新这个队列,比如访问下列系列的属性:
- offsetTop
- scrollTop
- clientTop
或者调用方法getComputedStyle()。
而重绘或者回流的操作非常影响效率。
所以为了提高效率,最好不要在修改样式属性的过程中访问以上属性,即使你访问的属性与你修改的元素不相干。比如下面的代码就演示了一个比较失败和成功的策略:
bodystyle.color = 'red';
tmp = computed.backgroundColor;
bodystyle.color = 'white';
tmp = computed.backgroundImage;
bodystyle.color = 'green';
tmp = computed.backgroundAttachment;
bodystyle.color = 'red';
bodystyle.color = 'white';
bodystyle.color = 'green';
tmp = computed.backgroundColor;
tmp = computed.backgroundImage;
tmp = computed.backgroundAttachment;
减少回流与重绘
修改样式
策略就是尽量在一个语句中完成所有对样式属性的修改,对比下列代码:
var el = document.getElementById('mydiv');
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
el.style.padding = '5px';
与:
var el = document.getElementById('mydiv');
el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px;';
在增加属性的情况下,第二段代码也可以是:
el.style.cssText += '; border-left: 1px;';
或者另外一个方法是使用CSS class,通过修改class名来修改样式。
批处理修改
或者如果说你要修改一些东西,而且避免不了要用多个语句,那么一个策略是把要修改的DOM元素移出渲染树,对其进行所有修改,再把它插入回去。这样做能保证引起的回流与重绘只会有两次。
大致有三种方式做到这种操作:
- 隐藏一个元素;
- 用文档片段(使用document.createDocumentFragment)在DOM树以外建立一个子树,然后复制它,把复件插进DOM树;
- 把一个DOM树里的节点克隆到一个复件上,对其修改,再插回DOM树。
演示一下,假设已有如下方法:
function appendDataToElement(appendToElement, data) {
var a, li;
for (var i = 0, max = data.length; i < max; i++) {
a = document.createElement('a');
a.href = data[i].url;
a.appendChild(document.createTextNode(data[i].name));
li = document.createElement('li');
li.appendChild(a);
appendToElement.appendChild(li);
}
};
上述的三个方法分别为:
var ul = document.getElementById('mylist');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';
var fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
document.getElementById('mylist').appendChild(fragment);
var old = document.getElementById('mylist');
var clone = old.cloneNode(true);
appendDataToElement(clone, data);
old.parentNode.replaceChild(clone, old);
作者最推荐的是第二个方法。
缓存布局信息
如果一定要在修改样式的过程中访问到上面提到的会导致回流和重绘的属性,比如offsetTop,那就将这些属性缓存到一个临时变量里,演示如下:
// inefficient
myElement.style.left = 1 + myElement.offsetLeft + 'px';
myElement.style.top = 1 + myElement.offsetTop + 'px';
if (myElement.offsetLeft >= 500) {
s
}
不如:
current++
myElement.style.left = current + 'px';
myElement.style.top = current + 'px';
if (current >= 500) {
stopAnimation();
}
实现动画效果时,把元素从流中移出
这个处理的思路其实类似于前面提到过的批处理,就是将一个视觉元素的位置先修改为absolute,再对它进行动画操作,然后再把位置修改回去。这样导致的对于重绘的计算最小。
IE和:hover
略。旧版IE已经过时。
====================================================================
事件代理
这个涉及到JavaScript里面用户事件的处理过程,分为三个阶段,捕捉(capturing),中标(at target)和冒泡(bubbling)。
假如有很多类似的元素上面都要绑定相似的用户事件句柄函数(比如onclick),那么一种高效的做法是在这些元素共有的父元素上绑定一个单独的句柄函数,利用事件的冒泡过程,在父元素上处理所有的用户事件。
Chapter 4: 算法与流程控制
====================================================================
循环
JavaScript有四种循环:for,while,do-while和for-in。for-in比较特殊,它的作用是遍历对象上的属性,包括对象自身的和通过原型链继承来的。
循环的效率
- 首先,四种循环里,只有for-in有明显的性能问题,其余三个之间的效率是等同的;
- 尽量避免for-in;
- 如果已经确定了其余三个循环可以胜任,那么影响性能的就只是算法。
影响性能的算法无非有两个因素:
- 每个循环里的计算量;
- 循环次数。
减少每次循环的计算量
- 缓存数组长度到一个本地变量;
- 从后往前倒过来遍历数组。
// original
for (var i=0; i < items.length; i++){
process(items[i]);
}
// cache the array length
for (var i=0, len=items.length; i < len; i++){
process(items[i]);
}
// reverse the order
for (var i=items.length; i--; ){
process(items[i]);
}
减少循环次数
作者给出一个技巧,是由Jeff Greenberg提出:
//credit: Jeff Greenberg
var items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
var iterations = Math.floor(items.length / 8),
startAt = items.length % 8,
i = 0;
do {
switch(startAt){
case 0: process(items[i++]);
case 7: process(items[i++]);
case 6: process(items[i++]);
case 5: process(items[i++]);
case 4: process(items[i++]);
case 3: process(items[i++]);
case 2: process(items[i++]);
case 1: process(items[i++]);
}
startAt = 0;
} while (--iterations);
function process(v) {
console.log(v);
}
一开始光是看代码有点懵,我提示一下,其实重点在于switch里没有break语句,每个case里的语句都会依次执行,所以到底用switch的用处在哪里。
作者有一个改良版:
var i = items.length % 8;
while(i){
process(items[i--]);
}
i = Math.floor(items.length / 8);
while(i){
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
}
没有了误导人的switch,也是倒序执行。
这个优化的方法只有当循环次数超过1000时才开始有明显效果。
基于函数的循环遍历
从ES3开始,一个遍历数组的函数被提出:forEach(),它在多数浏览器里都是被原生态支持的,但是它其实还是慢过正常的循环很多的。
====================================================================
条件
if-else还是switch?
switch其实在大多数情况下都是快过if-else的,可是并不明显,这个优势只有当条件非常多的时候才显著。一般情况下没有必要刻意避免if-else。
if-else的优化
基本上就是将可能性对折以后嵌套,这样每条路径会经过的判断就减少了,思维类似二分查找。比如有如下代码:
if (value == 0) {
return result0;
} else if (value == 1) {
return result1;
} else if (value == 2) {
return result2;
} else if (value == 3) {
return result3;
} else if (value == 4) {
return result4;
} else if (value == 5) {
return result5;
} else if (value == 6) {
return result6;
} else if (value == 7) {
return result7;
} else if (value == 8) {
return result8;
} else if (value == 9) {
return result9;
} else {
return result10;
}
改成:
if (value < 6) {
if (value < 3) {
if (value == 0) {
return result0;
} else if (value == 1) {
return result1;
} else {
return result2;
}
} else {
if (value == 3) {
return result3;
} else if (value == 4) {
return result4;
} else {
return result5;
}
}
} else {
if (value < 8) {
if (value == 6) {
return result6;
} else {
return result7;
}
} else {
if (value == 8) {
return result8;
} else if (value == 9) {
return result9;
} else {
return result10;
}
}
}
查询对照表(lookup table)
或者如果条件非常多的话,不如改成使用查询对照表,就是用一个类似字典的数据结构将条件和返回值作为键-值对存放在一起。
//define the array of results
var results = [result0, result1, result2, result3, result4, result5, result6,
result7, result8, result9, result10
]
//return the correct result
return results[value];
====================================================================
递归
函数调用栈的限制
多数浏览器都有这个限制,不同的浏览器限制的大小不同,对栈溢出的处理也不同,多数是抛出一个异常。递归函数有可能会超过这个限制。
递归模式
有两种,一种是一个函数调用自身,另一种是两个函数相互呼叫。
迭代
作者的建议是,能用迭代代替递归的话,就用迭代。举了个合并排序的例子:
印象法(memoization)
算法暂略