最近在阅读这本Nicholas C.Zakas(javascript高级程序设计作者)写的最佳实践、性能优化类的书。记录下主要知识。
加载和执行
脚本位置
放在<head>中的javascript文件会阻塞页面渲染:一般来说浏览器中有多种线程:UI渲染线程、javascript引擎线程、浏览器事件触发线程、HTTP请求线程等。多线程之间会共享运行资源,浏览器的js会操作dom,影响渲染,所以js引擎线程和UI渲染线程是互斥的,导致执行js时会阻塞页面的渲染。
<b>最佳实践:所有的script标签应尽可能的放在body标签的底部,以尽量减少对整个页面下载的影响。</b>
组织脚本
每个<script>标签初始下载时都会阻塞页面渲染,所以应减少页面包含的<script>标签数量。内嵌脚本放在引用外链样式表的<link>标签之后会导致页面阻塞去等待样式表的下载,建议不要把内嵌脚本紧跟在<link>标签之后。外链javascript的HTTP请求还会带来额外的性能开销,减少脚本文件的数量将会改善性能。
无阻塞的脚本
无阻塞脚本的意义在于在页面加载完成后才加载javascript代码。(window对象的load事件触发后)
延迟的脚本
带有defer属性的<script>标签可以放置在文档的任何位置。对应的javascript文件将在页面解析到<script>标签时开始下载,但并不会执行,直到DOM加载完成(onload事件被触发前)。当一个带有defer属性的javascript文件下载时,它不会阻塞浏览器的其他进程,可以与其他资源并行下载。执行的顺序是script、defer、load。
动态脚本元素
使用javascript动态创建HTML中script元素,例如一些懒加载库。
<b>优点:动态脚本加载凭借着它在跨浏览器兼容性和易用的有时,成为最通用的无阻塞加载解决方式。</b>
XHR脚本注入
创建XHR对线个,用它下载javascript文件,通过动态创建script元素将代码注入页面中
var xhr = new XMLHttpRequest();
xhr.open("get","file.js",true);
xhr.onreadystatechange = function() {
if(xht.readyState === 4) {
if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
var script = document.createElement("script");
script.type = "text/javascript";
script.text = xhr.responseText;
document.body.appendChild(script);
}
}
};
xhr.send(null);
<b>优点:可以下载javascript但不立即执行,在所有主流浏览器中都可以正常工作。
缺点:javascript文件必须与所请求的页面处于相同的域,意味着不能文件不能从CDN下载。</b>
数据存取
存储的位置
数据存储的位置会很大程度上影响读取速度。
- 字面量:字面量只代表自身,不存储在特定的位置。包括:字符串、数字、布尔值、对象、数组、函数、正则表达式、null、undefined。(个人理解:对象的指针本身是字面量)
- 本地变量:var定义的数据存储单元。
- 数组元素:存储在javascript数组内部,以数字为引。
- 对象成员:存储在javascript对象内部,以字符串作为索引。
大多数情况下从一个字面量和一个局部变量中存取数据的差距是微不足道的。访问数据元素和对象成员的代价则高一点。如果在乎运行速度,尽量使用字面量和局部变量,减少数组和对象成员的使用。
管理作用域
作用域链
每个javascript函数都表示为一个对象,更确切的说是Function对象的一个实例。它也有仅供javascript引擎存储的内部属性,其中一个内部属性是[[Scope]],包含了一个被创建的作用域中对象的集合即作用域链。作用域链决定哪些数据能被函数访问。作用域中的每个对象被称为一个可变对象。
当一个函数被创建后,作用域链会被创建函数的作用域中可访问的数据对象所填充。执行函数时会创建一个称为执行上下文的内部对象。执行上下文定义了函数执行时的环境。每次函数执行时对应的执行环境都是独一无二的,多次调用同一个函数也会创建多个执行上下文,当函数执行完毕,执行上下文就会被销毁。每个执行上下文都有自己的作用域链,用于解析标识符。当执行上下文被创建时,它的作用域链初始化为当前运行函数的[[Scope]]属性中的对象。这些值按照它们出现在函数中的顺序,被复制到执行环境的作用域链中。这个过程一旦完成,一个被称为活动对象的新对象就为执行上下文创建好了。
活动对象作为函数运行时的变量对象,包含了所有局部对象,命名函数,参数集合以及this。然后此对象被推入作用域链的最前端。当执行环境被销毁时,活动对象也随之销毁。执行过程中,每遇到一个变量,都会经历一次标识符解析过程以决定从哪里获取或存储数据。该过程搜索执行环境的作用域链,查找同名的标识符。搜索过程从作用域链头部开始,也就是当前运行函数的活动对象。如果找到,就使用这个标识符对应的变量,如果没找到,继续搜索作用域链的下一个对象知道找到,若无法搜索到匹配的对象,则标识符被当作未定义的。这个搜索过程影响了性能。
标识符解析的性能
一个标识符所在的位置越深,读写速度就越慢,全局变量总是存在于执行环境作用域的最末端,因此它是最深的。
<b>最佳实践:如果某个跨作用域的值在函数中被引用一次以上,那么就把它存储到局部变量中。</b>
改变作用域链
一般来说一个执行上下文的作用域链是不会改变的。但是,with语句和try-catch语句的catch子语句可以改变作用域链。
with语句用来给对象的所有属性创建一个变量,可以避免多次书写。但是存在性能问题:代码执行到with语句时,执行环境的作用域链临时被改变了,创建了一个新的(包含了with对象所有属性)对象被创建了,之前所有的局部变量现在处于第二个作用域链对象中,提高了访问的代价。建议放弃使用with语句。
try-catch语句中的catch子句也可以改变作用域链,当try代码块中发生错误,执行过程会自动跳转到catch子句,把异常对象推入一个变量对象并置于作用域的首位,局部变量处于第二个作用域链对象中。简化代码可以使catch子句对性能的影响降低。
<b>最佳实践:将错误委托给一个函数来处理。</b>
动态作用域
无论with语句还是try-catch语句的子句catch子句、eval()语句,都被认为是动态作用域。经过优化的javascript引擎,尝试通过分析代码来确定哪些变量是可以在特定的时候被访问,避开了传统的作用域链,取代以标识符索引的方式快速查找。当涉及动态作用域时,这种优化方式就失效了。
<b>最佳实践:只在确实有必要时使用动态作用域。</b>
闭包、作用域和内存
由于闭包的[[Scope]]属性包含了与执行上下文作用域链相同的对象的引用,因此会产生副作用。通常来说,函数的活动对象会随着执行环境一同销毁。但引入闭包时,由于引用仍然存在闭包的[[Scope]]属性中,因此激活对象无法被销毁,导致更多的内存开销。
最需要关注的性能点:闭包频繁访问跨作用域的标识符,每次访问都会带来性能损失。
<b>最佳实践:将常用的跨作用域变量存储在局部变量中,然后直接访问局部变量。</b>
对象成员
无论是通过创建自定义对象还是使用内置对象都会导致频繁的访问对象成员。
原型
javascript中的对象是基于原型的。解析对象成员的过程与解析变量十分相似,会从对象的实例开始,如果实例中没有,会一直沿着原型链向上搜索,直到找到或者到原型链的尽头。对象在原型链中位置越深,找到它也就越慢。搜索实例成员比从字面量或局部变量中读取数据代价更高,再加上遍历原型链带来的开销,这让性能问题更为严重。
嵌套成员
对象成员可能包含其他成员,每次遇到点操作符"."会导致javascript引擎搜索所有对象成员。
缓存对象成员值
由于所有类似的性能问题都与对象成员有关,因此应该尽可能避免使用他们,只在必要时使用对象成员,例如,在同一个函数中没有必要多次读取同一个对象属性(保存到局部变量中),除非它的值变了。这种方法不推荐用于对象的方法,因为将对象方法保存在局部变量中会导致this绑定到window,导致javascript引擎无法正确的解析它的对象成员,进而导致程序出错。
DOM编程
浏览器中的DOM
文档对象模型(DOM)是一个独立于语言的,用于操作XML和HTML文档的程序接口API。DOM是个与语言无关的API,在浏览器中的接口是用javascript实现的。客户端脚本编程大多数时候是在和底层文档打交道,DOM就成为现在javascript编码中的重要组成部分。浏览器把DOM和javascript单独实现,使用不同的引擎。
天生就慢
DOM和javascript就像两个岛屿通过收费桥梁连接,每次通过都要缴纳“过桥费”。
<b>推荐的做法是尽可能减少过桥的次数,努力待在ECMAScript岛上。</b>
DOM访问与修改
访问DOM元素是有代价的——前面的提到的“过桥费”。修改元素则更为昂贵,因为它会导致浏览器重新计算页面的几何变化(重排)。最坏的情况是在循环中访问或修改元素,尤其是对HTML元素集合循环操作。
<b>在循环访问页面元素的内容时,最佳实践是用局部变量存储修改中的内容,在循环结束后一次性写入。</b>
<b>通用的经验法则是:减少访问DOM的次数,把运算尽量留在ECMAScript中处理。</b>
节点克隆
大多数浏览器中使用节点克隆都比创建新元素要更有效率。
选择API
使用css选择器也是一种定位节点的便利途径,浏览器提供了一个名为querySelectorAll()的原生DOM方法。这种方法比使用javascript和DOM来遍历查找元素快很多。使用另一个便利方法——querySelector()来获取第一个匹配的节点。
重绘与重排
浏览器下载完页面中的所有组件——HTML标记、javascript、CSS、图片——之后会解析并生成两个内部的数据结构:DOM树(表示页面结构)、渲染树(表示DOM节点如何显示)。当DOM的变化影响了元素的几何属性,浏览器会使渲染树中受到影响的部分失效,并重构,这个过程成为重排,完成后,会重新绘制受影响的部分到屏幕,该过程叫重绘。并不是所有的DOM变化都会影响几何属性,这时只发生重绘。重绘和重排会导致web应用程序的UI反应迟钝,应该尽量避免。
重排何时发生
当页面布局的几何属性改变时就需要重排:
1. 添加或删除可见的DOM元素
2. 元素位置改变
3. 元素尺寸改变(包括:外边据、内边距、边框厚度、宽度、高度等属性改变)
4. 内容改变,例如:文本改变或图片被另一个不同尺寸的图片代替
5. 页面渲染器初始化
6. 浏览器窗口尺寸改变
渲染树变化的排队与刷新
由于每次重排都会产生计算消耗,大多数浏览器通过队列化修改并批量执行来优化重排过程。但是有些操作会导致强制刷新队列并要求任务立刻执行:
1. offsetTop,offsetLeft,offsetWidth,offsetHeight
2. scrollTop,scrollLeft,scrollWidth,scrollHeight
3. clientTop,clientLeft,clientWidth,clientHeight
4. getComputedStyle()
以上属性和方法需要返回最新的布局信息,因此浏览器不得不执行渲染队列中的修改变化并触发重排以返回正确的值。
<b>最佳实践:尽量将修改语句放在一起,查询语句放在一起。</b>
最小化重绘和重排
为了减少发生次数,应该合并多次DOM的样式的修改,然后一次处理掉。
批量修改DOM
当你需要对DOM元素进行一系列操作时,可以通过以下步骤来减少重绘和重排的次数:
1. 使元素脱离文档
2. 对其应用多重改变
3. 把元素带回文档流
该过程会触发两次重排——第一步和第三步,如果忽略这两步,在第二步所产生的任何修改都会触发一次重排。
有三种基本的方法可以使DOM脱离文档:
1. 隐藏元素,应用修改,重新显示
2. 使用文档片段,在当前DOM之外构建一个子树,再把它拷贝回文档
3. 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素
<b>推荐使用文档片段,因为它们所产生的DOM遍历和重排次数最少。</b>
缓存缓存布局信息
当你查询布局信息时,浏览器为了返回最新值,会刷新队列并应用所有变更。
<b>最佳实践:尽量减少布局信息的获取次数,获取后把它赋值给局部变量,然后操作局部变量。</b>
让元素脱离动画流
用展开、折叠的方式来显示和隐藏部分页面是一种常见的交互模式。通常包括展开区域的几何动画,并将页面其他部分推向下方。一般来说,重排只影响渲染树中的一小部分,但也可能影响很大的部分,甚至整个渲染树。浏览器所需要重排的次数越少,应用程序的响应速度就越快。当一个动画改变整个页面的余下部分时,会导致大规模重排。节点越多情况越差。避免大规模的重排:
1. 使用绝对定位页面上的动画元素,将其脱离文档流。
2. 应用动画
3. 当动画结束时回恢复定位,从而只会下移一次文档的其他元素。
这样只造成了页面的一个小区域的重绘,不会产生重排并重绘页面的大部分内容。
:hover
如果有大量元素使用了:hover,那么会降低响应速度。此问题在IE8中更为明显。
事件委托
当页面中存在大量元素,并且每一个都要一次或多次绑定事件处理器时,这种情况可能会影响性能,每绑定一个事件处理器都是有代价的,它要么加重了页面负担(更多的代码、标签),要么增加了运行期的执行时间。需要访问和修改的DOM元素越多,应用程序就越慢,特别是事件绑定通常发生在onload时,此时对每一个富交互应用的网页来说都是一个拥堵的时刻。事件绑定占用了处理事件,而且浏览器要跟踪每个事件处理器,这也会占用更多的内存。这些事件处理器中的绝大部分都可能不会被触发。
<b>事件委托原理:事件逐层冒泡并能被父级元素捕获。使用事件代理,只需要给外层元素绑定一个处理器,就可以处理在其子元素上触发的所有事件。</b>
根据DOM标准,每个事件都要经历三个阶段:
1. 捕获
2. 到达目标
3. 冒泡
IE不支持捕获,但是对于委托而言,冒泡已经足够。
<body>
<div>
<ul id="menu">
<li>
<a href="menu1.html">menu #1</a>
</li>
<li>
<a href="menu1.html">menu #2</a>
</li>
</ul>
</div>
</body>
在以上的代码中,当用户点击链接“menu #1”,点击事件首先从a标签元素收到,然后向DOM树上层冒泡,被li标签接收然后是ul标签然后是div标签,一直到达document的顶层甚至window。
委托实例:阻止默认行为(打开链接),只需要给所有链接的外层UL"menu"元素添加一个点击监听器,它会捕获并分析点击是否来自链接。
document.getElementById('menu').onclick = function(e) {
//浏览器target
e=e||window.event;
var target = e.target||e.srcElement;
var pageid,hrefparts;
//只关心hrefs,非链接点击则退出,注意此处是大写
if (target.nodeName !== 'A') {
return;
}
//从链接中找出页面ID
hrefparts = target.href.split('/');
pageid = hrefparts[hrefparts.length-1];
pageid = pageid.replace('.html','');
//更新页面
ajaxRequest('xhr.php?page='+id,updatePageContents);
//浏览器阻止默认行为并取消冒泡
if (type of e.preventDefault === 'function') {
e.preventDefault();
e.stopPropagation();
} else {
e.returnValue=false;
e.cancelBubble=true;
}
};
跨浏览器兼容部分:
1. 访问事件对象,并判断事件源
2. 取消文档树中的冒泡(可选)
3. 阻止默认动作(可选)
算法和流程控制
循环
循环的类型
ECMA-262标准第三版定义了javascript的基本语法和行为,其中共有四种循环。
1. 第一种是标准的for循环。它由四部分组成:初始化、前测条件、后执行体、循环体。
for (var i=0;i<10;i++){
//do something
}
for循环是javascript最常用的循环结构,直观的代码封装风格被开发者喜爱。
2. while循环。while循环是最简单的前测循环,由一个前测条件和一个循环体构成。
3. do-while循环是javascript唯一一种后测循环,由一个循环体和一个后测条件组成,至少会执行一次。
4. for-in循环。可以枚举任何对象的属性名。
循环的性能
javascript提供的四种循环类型中,只有for-in循环比其他几种明显要慢。因为每次迭代操作会同时搜索实例或原型属性,for-in循环的每次迭代都会产生更多开销。速度只有其他类型循环的七分之一。除非你明确需要迭代一个属性数量未知的对象,否则应该避免使用for-in循环。如果你需要遍历一个数量有限的已知属性列表,使用其他循环类型会更快,比如数组。
除for-in外,其他循环类型的性能都差不多,类型的选择应该基于需求而不是性能。
提高循环的性能
1. 减少每次迭代处理的事务
2. 减少迭代的次数
减少迭代的工作量
减少对象成员及数组项的查找次数。
在不影响的结果的情况下,可以使用倒序来略微提升性能。因为控制条件只要简单的与零比较。控制条件与true比较时,任何非零数会自动转换为true,而零值等同于false,实际上从两次比较(迭代数少于总数么?是否为true?)减少到一次比较(它是true么)。当循环复杂度为O(n)时,减少每次迭代的工作量是最有效的方法。当复杂度大于O(n)时,建议着重减少迭代次数。
减少迭代次数
Duff's Device是一个循环体展开技术,使得一次迭代中实际上执行了多次迭代的操作。一个典型的实现如下:
//credit:Jeff Greenberg
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);
Duff's Device背后的基本理念是:每次循环中最多可以调用8此process()。循环的迭代次数除以8。由于不是所有数字都能被8整除,变量startAt用来存放余数,表示第一次循环中应该调用多少次process()。
此算法稍快的版本取消了switch语句,并将余数处理和主循环分开
//credit:Jeff Greenberg
var i = items.length % 8;
while(i){
process(item[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,可能它与常规循环结构相比只有微不足道的性能提升。如果迭代数超过1000,那么执行效率将明显提升。例如在500000此迭代中,其运行时间比常规循环减少70%
基于函数的迭代
ECMA-262第四版加入的数组方法:forEach()方法。此方法遍历一个数组的所有成员,并在每个成员上执行一个函数。要运行的函数作为参数传给forEach(),并在调用时接受三个参数,分别是当前的值、索引以及数组本身。尽管基于函数的迭代提供了一个更为便利的迭代方法,但它仍比基于循环的迭代要慢一些。对每个数组项调用外部方法所带来的开销是速度慢的主要原因。
条件语句
if-else对比switch
条件数数量越大,越倾向于使用switch,主要是因为易读性。事实证明,大多数情况下switch比if-else运行得要快,但只有条件数量很大时才快得明显。
优化if-else
最小化到达正确分支前所需要判断的条件数量。最简单的优化方法是确保最可能出线的条件放在首位。if-else中的条件语句应该总是按照从最大概率到最小概率的顺序排列,以确保运行速度最快。假设均匀分部,可使用二分法的思想,重写为一系列嵌套的if-else语句。
查找表
有些时候优化条件语句的最佳方案是避免使用if-else和switch。可以使用数组和普通对象来构建查找表,通过查找表访问数据比用if-else或switch快很多。当单个键值存在逻辑映射时,构建查找表的优势就能体现出来。(比如把按照顺序的键值映射放到数组里)
递归
使用递归可以把复杂的算法变的简单。潜在问题是终止条件不明确或缺少终止条件会导致函数长时间运行,并使得用户界面处于假死状态和浏览器的调用栈大小限制。
调用栈限制
javascript引擎支持的递归数量与javascript调用栈大小直接相关。
递归模式
当你遇到调用栈大小限制时,第一步应该检查代码中的递归实例。有两种递归模式,第一种是调用自身,很容易定位错误。第二种是互相调用,很难定位。
迭代
任何递归能实现的算法同样可以使用迭代来实现。使用优化后的循环代替长时间运行的递归函数可以提升性能,因为运行一个循环比反复调用一个函数的开销要少的多。
归并排序算法是最常见的用递归实现的算法:
function merge(left, right) {
var result = [];
while (left.length > 0 && right.length > 0){
if (left[0] < right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
return result.concat(left).concat(right);
}
function mergeSort(items){
if (items.length == 1){
return items;
}
var middle = Math.floor(items.length / 2),
left = items.slice(0, middle),
right = items.slice(middle);
return merge(mergeSort(left),mergeSort(right));
}
使用迭代实现归并算法:
//使用和上面相同的merge函数
function mergeSort(items){
if (items.length == 1){
return items;
}
var work = [];
for (var i=0, len=items.length;i < len; i++){
work.push([items[i]]);
}
work.push([]);
for (var lim=len; lim>1; lim = (lim+1)/2){
for (var j=0,k=0; k < lim; j++, k+=2){
work[j] = merge(work[k],work[k+1]);
}
work[j] = [];
}
return work[0];
}
尽管迭代版本的归并排序算法比递归实现得要慢一些,但它不会像递归版本那样受到调用栈限制的影响。把递归算法改用迭代实现是避免栈溢出错误的方法之一
Memoization
Memoization是一种避免重复工作的方法,它缓存前一个计算结果供后续计算使用,避免了重复工作。
使用Memoization技术来重写阶乘函数:
function memfactorial(n){
if(!memfactorial.cache){
memfactorial.cache={
"0":1,
"1":1
};
}
if(!memfactorial.cache.hasOwnProperty(n)){
memfactorial.cache[n] = n * memfactorial (n-1);
}
return memfactorial.cache[n];
}
字符串和正则表达式
字符串链接
+和+=
不应在等号右边进行和被赋值的量无关的字符串拼接运算,这样会创造临时字符串。
例如:
str += "one" + "two";
会经历四个步骤:
1. 在内存中创建一个临时字符串
2. 连接后的字符串“onetwo”被赋值给该临时字符串
3. 临时字符串与str当前的值连接
4. 结果赋值给str
使用这种方式来代替:
str = str + "one" + "two";
//等价于 str = ((str + "one") + "two")
赋值表达式由str开始作为基础,每次给它附加一个字符串,由做到右一次连接,因此避免了使用临时字符串。
数组项合并
Array.prototype.join方法将数组的所有元素合并成一个字符串,它接受一个字符串参数作为分隔符插入每个元素的中间。大多数浏览器中,数组项合并比其他字符串连接的方法更慢。
String.prototype.concat
字符串的原生方法concat能接收任意数量的参数,并将每一个参数附加到所调用的字符串上。这是最灵活的字符串合并方法。多数情况下,使用concat比使用简单的+和+=稍慢。
正则表达式优化
部分匹配比完全不匹配所用的时间要长。
正则表达式工作原理
1. 第一步编译
浏览器会验证正则表达式,然后把它转换为一个原生代码程序,用于执行匹配工 作。如果把正则对象赋值给一个变量,可以避免重复这一步。
2. 第二步设置起始位置
3. 第三步匹配每个正则表达式字元
4. 第四步匹配成功或失败
回溯
当正则比到达时匹配目标字符串时,从左到右逐个测试表达式的组成部分,看是否能找到匹配项。在遇到量词和分支时,需要决策下一步如何处理。如果遇到量词,正则表达式需决定何时尝试匹配更多字符;如果遇到分支,那么必须从可选项中选择一个尝试匹配。每当正则表达式做类似的决定时,如果有必要的话,都会记录其他选择,以备返回时使用。如果当前选项匹配成功,正则表达式继续扫描表达式,如果其他部分也匹配成功,尼玛匹配结束。但是如果当前选项找不到匹配值,或后面的部分匹配失败,那么正则表达式会回溯到最后一个决策点,然后在剩余的选项中选择一个。这个过程会一直进行,知道找到匹配项,或者正则表达式中量词和分支选项的所有排列组合都尝试失败,那么它将放弃匹配从而移动到字符串的下一个字符,再重复此过程。
重复和回溯
贪婪匹配是段尾一个个回溯接下来的匹配内容,惰性正好相反;
回调失控
<b>最佳实践:如果你的正则表达式包含了多个捕获组,那么你需要使用适当的反向引用次数。</b>
嵌套量词与回溯失控
所谓的嵌套量词需要格外的关注且小心使用,以确保不会引发潜在的回溯失控。嵌套两次是指两次出线在一个自身被重复量词修饰的组中。确保正则表达式的两个部分不能对字符串的相同部分进行匹配
更多提高正则表达式效率的方法
1. 关于如何让正则匹配更快失败
正则表达式慢的原因通常是匹配失败的过程慢。
2. 正则表达式以简单、必需的字元开始
一个正则表达式的起始标记应当尽可能快速的测试并排除明显不匹配的位置。尽量以一个锚、特定字符串、字符类和单词边界开始,尽量避免以分组或选择字元开头,避免顶层分支。
3. 使用量词模式,使它们后面的字元互斥
当字符与字元相邻或子表达式能够重叠匹配时,正则表达式尝试拆解文本的路径数量将增加。
4. 减少分支数量,缩小分支范围
分支使用竖线|可能要求在字符串的每一个位置上测试所有的分支选项。你通常可以通过使用字符集和选项组件来减少对分支的需求,或将分支在正则表达式上的位置推后。
5. 使用非捕获组
捕获组消耗时间和内存来记录反向引用,并使它保持最新。如果你不需要一个反向引用,可以使用非捕获组来避免这些开销。
6. 只捕获感兴趣的文本以减少后处理
如果需要引用匹配的一部分,应该才去一切手段捕获那些片段,再使用反向引用来处理。
7. 暴露必需的字元
尝试让正则表达式引擎更容易判断哪些字元是必需的。
8. 使用合适的量词
9. 把正则表达式赋值给变量并重用它们
避免在循环体中重复编译正则表达式。
10. 将复杂的正则表达式拆分为简单的片段
何时不使用正则表达式
当只是搜索字面字符串,尤其是事先知道字符串的哪一部分将要被查找时。正则表达式无法直接跳到字符串末尾而不考虑沿途的字符。
快速响应的用户界面
浏览器UI线程
用于执行Javascript和更新用户界面的进程通常被称为“浏览器UI线程”。UI线程的工作基于一个见到那的队列系统,任务会被保存到队列中直到线程空闲。
浏览器限制
浏览器限制了javascript的运行时间。此类限制分为两种:调用栈的大小限制和长时间运行脚本限制。
多久算太久
单个Javascript操作话费的总时间不应该超过100毫秒。
<b>最佳实践:限制所有的Javascript任务在100毫秒或更短的时间内完成。</b>
使用定时器让出时间片段
当Javascript不能在100毫秒或更短的时间内完成。最理想的方法是让出UI线程的控制权,使得UI可以更新。
定时器基础
在Javascript中可以使用setTimeout()和setInterval()创建定时器,它们接收相同的参数:要执行的函数和执行前的等待时间。定时器与UI线程的交互:定时器会告诉Javascript引擎先等待一定时间,然后添加一个Javascript任务到UI队列。定时器代码只有在创建它的函数执行完之后,才有可能执行。无论发生何种情况,创建一个定时器会造成UI线程暂停,如同它从一个任务切换到下一个任务。因此,定时器代码会重置所有相关的浏览器限制,包括 长时间运行脚本定时器。此外,调用栈也会在定时器中重置为0。setTimeout()和setInterval()几近相同,如果 UI队列中已经存在由同一个setInterval()创建的任务,那么后续任务不会被添加到UI队列中。如果setTimeout()中的函数需要消耗比定时器延时更长的运行时间,那么定时器代码中的延时几乎是不可见的。
定时器的精度
Javascript定时器延迟通常不太准确,相差大约为几毫秒,无法用来精确计算时间。而且还存在最小值的限制。
使用定时器处理数组
是否可以用定时器取代循环的两个决定性因素:处理过程是否必须同步;数据是否必须按照顺序处理;如果两个答案都是否,那么代码适用于定时器分解任务。
var todo = items.concat();
// 克隆原数组
setTimeout(function(){
// 取得数组的下一个元素并进行处理
process(todo.shift());
// 如果还有需要处理的元素,创建另一个定时器
if(todo.length > 0){
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
}, 25);
每个定时器的真实延时在很程度上取决于具体情况。普遍来讲,最好使用至少25毫秒,因为再小的延时,对大多数UI更新来说不够用。
记录代码运行使劲啊
通过定时器创建Date对象并比较它们的值来记录代码运行事件。加号可以将Date对象转换成数字,那么在后续的运算中就无须转换了。避免把任务分解成过于零碎的碎片,因为定时器之间有最小间隔,会导致出线空闲事件。
定时器与性能
当多个重复的定时器同时创建往往会出线性能问题。因为只有一个UI线程,而所有的定时器都在争夺运行时间。那些间隔在1秒或1秒以上的低频率的重复定时器几乎不会影响Web应用的响应速度。这种情况下定时器延迟远远超过UI线程产生瓶颈的值,可以安全的重复使用。当过个定时器使用较高的频率(100到200毫秒之间)时,会明显影响性能。在web应用中限制高频率重复定时器的数量,作为代替方案,使用一个独立的重复定时器每次执行多个操作。
Web Worker
引入了一个接口,能使代码运行并且不占用浏览器UI线程的时间。
Worker
没有绑定UI线程,每个Web Worker都有自己的全局环境,其功能只是Javascript特性的一个子集。运行环境由如下部分组成:一个navigator对象,值包括四个属性:appName、appVersion、userAgent和platform。
一个location对象(与window.location相同,不过所有属性都是只读的。)。
一个self对象,指向全局worker对象。
一个importScipt()方法,用来加载Worker所用到的外部javascript文件。
所有的ECMAScript对象
XMLHttpRequest构造器
setTimeout()方法和setInterval()方法
一个close()方法,它能立刻停止Worker运行
由于Web Worker有着不同的全局运行环境,因此你无法从javascript代码中创建它。需要创建一个完全独立的javascript文件,其中包含了需要在Worker中运行的代码。要创建网页人工线程,你必须传入这个javascript文件的URL;
与Worker通信
通过事件接口进行通信。网页代码可以通过postMessage()方法给Worker传递数据,它接受一个参数,即需要传递给Worker的数据。此外,Worker还有一个用来接收信息的onmessage事件处理器。Worker可通过它自己的postMessage()方法把信息回传给页面。消息系统是网页和Worker通信的唯一途径。只有特定类型的数据可以使用postMessage()传递。你可以传递原始值(字符串、数字、布尔值、null和undefined),也可以传递Object和Array的实例,其他类型就不允许了。有效数据会被序列化,传入或传出Worker,然后反序列化。虽然看上去对象可以直接传入,但对象实例完全是相同数据的独立表述。
加载外部文件
Worker 通过importScript()方法加载外部javascript文件,该方法接收一个或多个javascript文件URL作为参数。importScript()的调用过程是阻塞式的,知道所有所有文件加载并执行完成之后,脚本才会继续运行。由于Worker在UI线程之外运行,所以这种阻塞并不会影响UI响应。
Web Worker适合用于那些处理纯数据,或者与浏览器UI无关的长时间运行脚本。尽管它看上去用处不大,但Web应用中通常有一些数据处理功能将收益于Worker而不是定时器。
可能的用处:
- 编码/解码大字符串
- 复杂数学运算
- 大数组排序
- 任何超过100毫秒的处理过程,都应当考虑Worker方案是不是比基于定时器的方案更为合适。
Ajax
Ajax是高性能javascript的基础。它可以通过延迟下载体积较大的资源文件来使得页面加载速度更快。它通过异步的方式在客户端和服务端之间传输数据,避免同时传输大量数据。
数据传输
请求数据
有五种常用技术用于想服务器请求数据:
- XMLHttpRequest
- Dynamic script tag insertion(脚本动态注入)
- iframes
- Comet
- Multipart XHR
现代高性能Javascript中使用的三种技术是:XHR、动态脚本注入和Multipart XHR
XMLHttpRequest
XMLHttpRequest是目前最常用的技术,它允许异步发送和接收数据。由于XHR提供了高级的控制,所以浏览器对其增加了一些限制。你不能使用XHR从外域请求数据。对于那些不会改变服务器状态,只会获取数据(幂等行为)的请求,应该使用GET。经GET请求的数据会被缓存起来,如果需要多次请求统一数据的话,它会有助于提升性能。只有当请求的URL加上参数的长度接近或超过2048个字符时,才应该用POST获取数据。因为IE限制URL长度,过长将导致请求的URL被截断。
动态脚本注入
这种技术客服了XHR的最大限制:它能跨域请求数据。这是一个Hack,你不需要实例化一个专用对象,而可以使用javascript创建一个新的脚本标签,并设置它的src属性为不同域的URL。与XHR相比,动态脚本注入提供的控制是有限的。只能使用GET方法而不是POST方法。不能设置请求的超时处理或重试;不能访问请求的头部信息,不能把整个响应信息作为字符串来处理。因为响应消息作为脚本标签的源码,它必须是可执行的javascript代码。你不能使用纯XML、纯JSOn或其他任何格式的数据,无论哪种格式,都必须封装在一个回调函数中。这项技术的速度却非常快。响应消息是作为javascript执行,而不是作为字符串需要进一步处理。正因如此,它有潜力成为客户端获取并解析数据最快的方法。
Multipart XHR
允许客户端只用一个HTTP请求就可以从服务端向客户端传送多个字元。它通过在服务端将字元打包成一个由双方约定的字符串分割的长字符串并发送到客户端。然后用javascript代码处理这个长字符串,并根据它的mime-type类型和传入的其他“头信息”解析出每个资源。缺点:资源不能被浏览器缓存。
能显著提高性能的场景:
页面包含了大量其他地方用不到的资源,尤其是图片;
网站已经在每个页面中使用了一个独立打包的Javascript或CSS文件以减少http请求;
发送数据
XMLHttpRequest
当使用XHR发送数据到服务器时,GET方式会更快。这是因为,对少量数据而言一个GET请求只发送一个数据包。而一个POST请求至少要发两个数据包,一个装载头信息,另一个装载POST正文。POST更适合发送大量数据到服务器,因为它不关心额外数据包的数量,另一个原因是URL长度有限制,它不可能使用过长的GET请求。
Beacons
类似动态脚本注入。使用Javascript创建一个新的Image对象,并把src属性设置为服务器上脚本的URL。该URL包含了我们要通过GET传回的键值对数据。服务器会接受数据并保存下来,无须向客服端发送任何回馈信息,因此没有图片会实际显示出来。这是回传信息最有效的方式。性能消耗更小,而且服务器端的错误不影响客户端。缺点:无法发送POST数据,而URL的长度有最大值,所以可以发送的数据的长度被限制的相当小。
数据格式
考虑数据格式时唯一需要比较的标准就是速度
XML
当Ajax最先开始流行时,它选择了XML作为数据格式。优势:极佳的通用性、格式严格,且易于验证。缺点:冗长,依赖大量结构、有效数据的比例很低、语法模糊,如果有其他格式可选不要使用它。
JSON
是一种使用Javascript对象和数组直接量编写的轻量级且易于解析的数据格式。
JSON-P
事实上,JSON可以被本地执行会导致几个重要的性能影响。当使用XHR时,JSON数据被当成字符串返回。在使用动态脚本注入时,JSON数据要被当成另一个Javascript文件并作为原生代码执行,为实现这一点必须封装在一个回调函数中。JSON-P因为回调包装的原因略微增大了文件尺寸,但性能提升巨大。由于数据是当作原生的Javascript,因此解析速度跟原生Javascript一样快。最快的JSON格式是使用数组形式的JSON-P。不要把敏感数据编码在JSON-P中,因为无法确认它是否保持着私有调用状态。
HTML
通常你请求的数据需要被转换成HTML以显示到页面上。Javascript可以较快地把一个较大的数据结构转换成简单的HTML,但在服务器处理会快很多。一种可考虑的技术是在服务器上构建好整个HTML再传回客户端,Javascript可以很方便地通过innerHTML属性把它插入页面相应的位置。取点:臃肿的数据格式、比XML更繁杂。在数据本身的最外层,可以嵌套HTML标签,每个都带有id、class和其他属性。HTML格式可能比实际数据占用更多空间。应当在客户端的瓶颈是CPU而不是带宽时才使用此技术。
自定义格式
理想的数据格式应该只包含必要的结构,以便你可以分解出每个独立的字段。最重要的决定就是采用哪种分隔符,它应当是一个单字符,而且不应该存在你的数据中。
Ajax性能指南
缓存数据
在服务端,设置HTTP头信息以确保你的响应会被浏览器缓存。
在客户端,把获取到的信息存储到本地,从而避免再次请求。
设置HTTP头信息
如果希望ajax能被浏览器缓存,那么你必须使用GET方式发送请求并且需要在响应中发送正确的HTTP头信息。Expires头信息会告诉浏览器应该缓存多久。它的值是一个日期。
本地数据存储
直接把从服务器接收到的数据储存起来。可以把响应文本保存到一个对象中,以URL为键值作为索引。
Ajax类库的局限性
所有的Javascript类库都允许你访问一个Ajax对象,它屏蔽了浏览器之间的差异,给你一个统一的接口。为了统一接口的功能,类库简化接口,使得你不能访问XMLHttpRequest的完整功能。
编程实践
避免双重求值
Javascript允许你在程序中提取一个包含代码的字符串,然后动态执行它。有四种标准方法可以实现:eval()、Function()构造函数、setTimeout()和setInterval()。首先会以正常的方式求值,然后在执行的过程中对包含于字符串的代码发起另一个求值运算。每次使用这些方法都要创建一个新的解释器/编译器实例,导致消耗时间大大增加。
大多数时候没有必要使用eval()和Function(),因此最好避免使用它们。定时器则建议传入函数而不是字符串作为第一个参数。
使用Object/Array直接量
Javascript中创建对象和数组的方法有多种,但使用对象和数组直接量是最快的方式。
避免重复工作
别做无关紧要的工作,别重复做已经完成的工作。
延迟加载
第一次被调用时,会先检查并决定使用哪种方法去绑定或取消绑定事件处理器。然后原始函数被包含正确操作的新函数覆盖。最后一步调用新的函数,并传入原始参数。随后每次调用都不会再做检测,因为检测代码已经被新的函数覆盖。调用延迟加载函数时,第一次总会消耗较长的费时间,因为它必须运行检测接着再调用另一个函数完成任务。但随后调用相同的函数会更快,因为不需要再执行检测逻辑。当一个函数在页面中不会立刻调用时,延迟加载是最好的选择。
条件预加载
它会在脚本加载期间提前检测,而不会等到函数被调用。检测的操作依然只有一次,知识它在过程中来的更早。条件预加载确保所有函数调用消耗的时间相同。其代价是需要在脚本加载时就检测,而不是加载后。预加载适用于一个函数马上就要被用到,并且在整个页面的生命周期中频繁出现的场合。
使用快的部分
<b>运行速度慢的部分实际上是代码,引擎通常是处理过程中最快的部分。</b>
位操作
使用位运算代替纯数学操作:对2的取模运算可以被&1代替,速度提高很多。位掩码:处理同时存在多个布尔选项时的情形,思路是使用单个数字的每一位来判定是否选项成立,从而有效得把数字转换为布尔值标记组成的数组。
原生方法
原生方法更快,因为写代码前就存在浏览器中了,并且都是用底层语言比如c++编写的。经验不足的Javascript开发者经常犯的错误就是在代码中进行复杂的数学运算,而没有使用内置的Math对象中那些性能更好的版本。另一个例子是选择器API,它允许使用CSS选择器来查找DOM节点。原生的querySelector()和querySelectorAll()方法完成任务平均所需时间是基于Javascript的CSS查询的10%。
构建并部署高性能Javascript应用
<b>合并多个Javascript文件,网站提速指南中第一条也是最重要的一条规则,就是减少http请求数。</b>
预处理Javascript文件
预处理你的Javascript源文件并不会让应用变的更快,但它允许你做些其他的事情,例如有条件地插入测试代码,来衡量你的应用程序的性能。
Javascript压缩
指的是把Javascript文件中所有与运行无关的部分进行剥离的过程。剥离的内容包括注释和不必要的空白字符。该过程通常可以将文件大小减半,促使文件更快被下载,并鼓励程序员编写更好的更详细的注释。
构建时处理对比运行时处理
普遍规则是只要能在构建时完成的工作,就不要留到运行时去做。
Javascript的http压缩
当Web浏览器请求一个资源时,它通常会发送一个Accept-Encoding HTTP头来告诉Web服务器它支持哪种编码转换类型。这个信息主要用来压缩文档以更快的下载,从而改善用户体验。Accept-Encoding可用的值包括:gzip、compress、deflate和identity。gzip是目前最流行的编码方式。它通常能减少70%的下载量,成为提升Web应用性能的首选武器。记住Gzip压缩主要适用于文本,包括Javascript文件。
缓存Javascript文件
缓存HTTP组件能极大提高网站回访用户的体验。Web服务器通过Expires HTTP响应头来告诉客户端一个字元应当缓存多长事件。它的值是一个遵循RFC1123标准的绝对时间戳。
处理缓存问题
适当的缓存控制能提升用户体验,但它有一个缺点:当应用升级时,你需要确保用户下载到最新的静态内容。这个问题可以通过把改动过的静态资源重命名来解决。
使用内容分发网络(CDN)
内容分发网络是在互联网上按地理位置设置分部计算机网络,它负责传递内容给终端用户。使用CDN的主要原因是增强Web应用的可靠性、可扩展性,更重要的是提升性能。事实上,通过向地理位置最近的用户输入内容,CDN能极大减少网络延时。
网友评论