前言
本文为 《高性能 JavaScript》 读书笔记,是利用中午休息时间、下班时间以及周末整理出来的,此书虽有点老旧,但谈论的性能优化话题是每位同学必须理解和掌握的,业务响应速度直接影响用户体验。
一、加载和运行
脚本位置
将所有script
标签放在页面底部,紧靠</body>
上方,以保证页面脚本运行之前完成解析
<html>
<head> </head>
<body>
<p>Hello World</p>
<!-- -->
<script type="text/javascript" src="file.js"></script>
</body>
</html>
defer & async
常规script
脚本浏览器会立即加载并执行,异步加载使用async
与defer
二者区别在于aysnc
为无序,defer
会异步根据脚本位置先后依次加载执行
<!-- file1、file2依次加载 -->
<script type="text/javascript" src="file1.js" defer></script>
<script type="text/javascript" src="file2.js" defer></script>
<!-- file1、file2无序加载 -->
<script type="text/javascript" src="file1.js" async></script>
<script type="text/javascript" src="file2.js" async></script>
动态脚本
无论在何处启动下载,文件的下载和运行都不会阻塞其他页面处理过程。你甚至可以将这些代码放在<head>
部分而不会对其余部分的页面代码造成影响(除了用于下载文件的 HTTP
连接)
var script = document.createElement("script");
script.type = "text/javascript";
script.src = "file1.js";
document.getElementsByTagName("head")[0].appendChild(script);
监听加载函数
function loadScript(url, callback) {
var script = document.createElement("script");
script.type = "text/javascript";
if (script.readyState) {
//IE
script.onreadystatechange = function() {
if (script.readyState == "loaded" || script.readyState == "complete") {
script.onreadystatechange = null;
callback();
}
};
} else {
//Others
script.onload = function() {
callback();
};
}
script.src = url;
document.getElementsByTagName("head")[0].appendChild(script);
}
XHR 注入
前提条件为同域,此处与异步加载一样,只不过使用的是 XMLHttpRequest
总结
- 将所有
script
标签放在页面底部,紧靠 body 关闭标签上方,以保证页面脚本运行之前完成解析 - 将脚本成组打包,页面 script 标签越少加载越快,响应也就更迅速。不论外部脚本文件或者内联代码都是如此
二、数据访问
在 JavaScript 中有四种基本的数据访问位置:
- 直接量
直接量仅仅代表自己,而不存储于特定位置。 JavaScript 的直接量包括:字符串,数字,布尔值,对象,数组,函数,正则表达式,具有特殊意义的空值,以及未定义 - 变量
使用 var / let 关键字创建用于存储数据值 - 数组项
具有数字索引,存储一个 JavaScript 数组对象 - 对象成员
具有字符串索引,存储一个 JavaScript 对象
总结
直接量与局部变量访问速度非常快,数组项和对象成员需要更长时间
局部变量比域外变量访问速度快,因为它位于作用域链的第一个对象中。变量在作用域链的位置越深,访问所需要的时间越长。全局变量总是最慢的,因为它们总位于作用域链的最后一环。
避免使用 with 表达式,因为它改变了运行期上下文的作用域链,谨慎对待 try-catch 表达式中 catch 子句,因为它具有同样的效果
嵌套对象成员会造成重大性能影响,尽量少用
属性在原型链中的位置越深,访问速度越慢
将对象成员、数组项、域外变量存入局部变量能提高 js 代码的性能
三、dom 编程
对 DOM 操作代价昂贵,在富网页应用中通常是一个性能瓶颈。通常处理以下三点
- 访问和修改 DOM 元素
- 修改 DOM 元素的样式,造成重绘和重新排版
通过 DOM 事件处理用户响应
DOM 访问和修改
访问或修改元素最坏的情况是使用循环执行此操作,特别是在 HTML 集合中使用循环
function innerHTMLLoop() {
for (var count = 0; count < 15000; count++) {
document.getElementById("here").innerHTML += "a";
}
}
此函数在循环中更新页面内容。这段代码的问题是,在每次循环单元中都对 DOM 元素访问两次:一次
读取 innerHTML 属性能容,另一次写入它
优化如下
function innerHTMLLoop2() {
var content = "";
for (var count = 0; count < 15000; count++) {
content += "a";
}
document.getElementById("here").innerHTML += content;
}
你访问 DOM 越多,代码的执行速度就越慢。因此,一般经验法则是:轻轻地触摸 DOM,并尽量保持在 ECMAScript 范围内
节点克隆
使用 DOM 方法更新页面内容的另一个途径是克隆已有 DOM 元素,而不是创建新的——即使用 element.cloneNode()(element 是一个已存在的节点)代替 document.createElement();
当布局和几何改变时发生重排版,下述情况会发生:
- 添加或删除可见的 DOM 元素
- 元素位置改变
- 元素尺寸改变(边距、填充、边框宽度、宽、高等属性)
- 内容改变(文本或者图片被另一个不同尺寸的所替代)
- 最初的页面渲染
- 浏览器窗口尺寸改变
减少重排次数
- 改变 display 属性,临时从文档上移除然后再恢复
- 在文档之外创建并更新一个文档片段,然后将它进行附加
- 先创建更新节点的副本,再操作副本,最后用副本更新老节点
总结
- 最小化 DOM 访问,在 JavaScript 端做尽可能多的事情
- 在反复访问的地方使用局部变量存放 dom 引用
- 谨慎处理 HTML 集合,因为它们表现‘存在性’,总对底层文档重新查询。将 length 属性缓存到一个变量中,在迭代中使用这个变量。如果经常操作这个集合,可以将集合拷贝到数组中
- 如果可以,使用速度更快的 API,比如 document.querySelectorAll()和 firstElementChild()
- 注意重绘和重排,批量修改风格,离线操作 DOM,缓存或减少对布局信息的访问
- 动画中使用绝对坐标,使用拖放代理
- 使用事件托管技术中的最小化事件句柄数量
四、算法与流程控制
代码整体结构是执行速度的决定因素之一。代码量少不一定执行快,代码量多,也不一定执行慢,性能损失与代码组织方式和具体问题解决办法直接相关。
Loops
在大多数编程语言中,代码执行时间多数在循环中度过。在一系列编程模式中,循环是最常见的模式之一,提高性能必须控制好循环,死循环和长时间循环会严重影响用户体验。
Types of Loops
- for
- while
- do while
- for in
前三种循环几乎所有编程语言都能通用,for in 循环遍历对象命名属性(包括自有属性和原型属性)
Loop Performance
减少每次迭代的操作总数可以大幅提高循环的整体性能
优化循环:
- 减少对象成员和数组项的查找,比如缓存数组长度,避免每次查找数组 length 属性
- 倒序循环是编程语言中常用的性能优化方法
编程中经常会听到此说法,现在来验证一下,测试样例
var arr = [];
for (var i = 0; i < 100000000; i++) {
arr[i] = i;
}
var start = +new Date();
for (var j = arr.length; j > -1; j--) {
arr[j] = j;
}
console.log("倒序循环耗时:%s ms", Date.now() - start); //约180 ms
var start = +new Date();
for (var j = 0; j < arr.length; j++) {
arr[j] = j;
}
console.log("正序序循环耗时:%s ms", Date.now() - start); //约788 ms
基于函数的迭代
尽管基于函数的迭代显得更加便利,它还是比基于循环的迭代要慢一些。每个数组项要关联额外的函数调用是造成速度慢的原因。在所有情况下,基于函数的迭代占用时间是基于循环的迭代的八倍,因此在关注执行时间的情况下它并不是一个合适的办法。
条件表达式
if-else VS switch
使用 if-else 或者 switch 的流行理论是基于测试条件的数量:条件数量较大,倾向使用 switch,更易于阅读
当条件体增加时,if-else 性能负担增加的程度比 switch 更多。
一般来说,if-else 适用于判断两个离散的值或者几个不同的值域,如果判断条件较多 switch 表达式将是更理想的选择
优化 if-else
最小化找到正确分支:将最常见的条件放在首位
查表法 当使用查表法时,必须完全消除所有条件判断,操作转换成一个数组项查询或者一个对象成员查询。
递归
会受浏览器调用栈大小的限制
迭代
任何可以用递归实现的算法可以用迭代实现。使用优化的循环替代长时间运行的递归函数可以提高性能,因为运行一个循环比反复调用一个函数的开销要低
斐波那契
function fibonacci(n) {
if (n === 1) return 1;
if (n === 2) return 2;
return fibonacci(n - 1) + fibonacci(n - 2);
}
制表
//制表
function memorize(fundamental, cache) {
cache = cache || {};
var shell = function(args) {
if (!cache.hasOwnProperty(args)) {
cache[args] = fundamental(args);
}
return cache[args];
};
return shell;
}
//动态规划
function fibonacciOptimize(n) {
if (n === 1) return 1;
if (n === 2) return 2;
var current = 2;
var previous = 1;
for (var i = 3; i <= n; i++) {
var temp = current;
current = previous + current;
previous = temp;
}
return current;
}
//计算阶乘
var res1 = fibonacci(40);
var res2 = memorize(fibonacci)(40);
var res3 = fibonacciOptimize(40);
//计算出来的res3优于res2,res2优于res1
总结
- for, while, do while 循环的性能特性相似,谁也不比谁更快或更慢
- 除非要迭代遍历一个属性未知的对象,否则不要使用 for-in 循环
- 改善循环的最佳方式减少每次迭代中的运算量,并减少循环迭代次数
- 一般来说 switch 总比 if-else 更快,但总不是最好的解决方法
- 当判断条件较多,查表法优于 if-else 和 switch
- 浏览器的调用栈大小限制了递归算法在 js 中的应用,栈溢出导致其他代码不能正常执行
- 如果遇到栈溢出,将方法修改为制表法,可以避免重复工作
五、字符串和正则表达式 String And Regular Expression
在 JS 中,正则是必不可少的东西,它的重要性远远超过烦琐的字符串处理
字符串链接 Stirng Concatenation
字符串连接表现出惊人的性能紧张。通常一个任务通过一个循环,向字符串末尾不断地添加内容,来创建一个字符串(例如,创建一个 HTML 表或者一个 XML 文档),但此类处理在一些浏览器上表现糟糕而遭人痛恨
当连接少量的字符串,上述的方式都很快,可根据自己的习惯使用;
当合并字符串的长度和数量增加之后,有些函数就开始发挥其作用了
+ & +=
str += "a" + "b";
此代码执行时,发生四个步骤
- 内存中创建了一个临时字符串
- 临时字符串的值被赋予'ab'
- 临时串与 str 进行连接
- 将结果赋予 str
下面的代码通过两个离散的表达式直接将内容附加在 str 上避免了临时字符串
str += "a";
str += "b";
事实上用一行代码就可以解决
str = str + "a" + "b";
赋值表达式以 str 开头,一次追加一个字符串,从左至右依次连接。如果改变了连接顺序(例如:str = 'a' + str + 'b'
),你会失去这种优化,这与浏览器合并字符串时分配内存的方法有关。除 IE 外,浏览器尝试扩展表达式左端字符串的内存,然后简单地将第二个字符串拷贝到它的尾部。如果在一个循环中,基本字符串在左端,可以避免多次复制一个越来越大的基本字符串。
Array.prototype.join
Array.prototype.join 将数组的所有元素合并成一个字符串,并在每个元素之间插入一个分隔符字符串。若传递一个空字符串,可将数组的所有元素简单的拼接起来
var start = Date.now();
var str = "I'm a thirty-five character string.",
newStr = "",
appends = 5000000;
while (appends--) {
newStr += str;
}
var time = Date.now() - start;
console.log("耗时:" + time + "ms"); //耗时:1360ms
var start = Date.now();
var str = "I'm a thirty-five character string.",
strs = [],
newStr = "",
appends = 5000000;
while (appends--) {
strs[strs.length] = str;
}
newStr = strs.join("");
var time = Date.now() - start;
console.log("耗时:" + time + "ms"); //耗时:414ms
这一难以置信的改进结果是因为避免了重复的内存分配和拷贝越来越大的字符串。
String.prototype.concat
原生字符串连接函数接受任意数目的参数,并将每一个参数都追加在调用函数的字符串上
var str = str.concat(s1);
var str = str.concat(s1, s2, s3);
var str = String.prototype.concat.apply(str, array);
大多数情况下 concat 比简单的+或+=慢一些
Regular Expression Optimization 正则表达式优化
许多因素影响正则表达式的效率,首先,正则适配的文本千差万别,部分匹配时比完全不匹配所用的时间要长,每种浏览器的正则引擎也有不同的内部优化
正则表达式工作原理
编译
当你创建了一个正则表达式对象之后(使用一个正则表达式直接量或者 RegExp 构造器),浏览器检查你的模板有没有错误,然后将它转换成一个本机代码例程,用执行匹配工作。如果你将正则表达式赋给一个变量,你可以避免重复执行此步骤。设置起始位置
当一个正则表达式投入使用时,首先要确定目标字符串中开始搜索的位置。它是字符串的起始位置,或者由正则表达式的 lastIndex 属性指定,但是当它从第四步返回到这里的时候(因为尝试匹配失败),此位置将位于最后一次尝试起始位置推后一个字符的位置上匹配每个正则表达式的字元
正则表达式一旦找好起始位置,它将一个一个地扫描目标文本和正则表达式模板。当一个特定字元匹配失败时,正则表达式将试图回溯到扫描之前的位置上,然后进入正则表达式其他可能的路径上匹配成功或失败
如果在字符串的当前位置上发现一个完全匹配,那么正则表达式宣布成功。如果正则表达式的所有可能路径都尝试过了,但是没有成功地匹配,那么正则表达式引擎回到第二步,从字符串的下一个字符重新尝试。只有字符串中的每个字符(以及最后一个字符后面的位置)都经历了这样的过程之后,还没有成功匹配,那么正则表达式就宣布彻底失败。
理解回溯
在大多数现代正则表达式实现中(包括 JavaScript 所需的),回溯是匹配过程的基本组成部分。它很大程度上也是正则表达式如此美好和强大的根源。然而,回溯计算代价昂贵,如果你不够小心的话容易失控。虽然回溯是整体性能的唯一因素,理解它的工作原理,以及如何减少使用频率,可能是编写高效正则表达式最重要的关键点。
示例分析
/h(ello|appy) hippo/.test("hello there, happy hippo");
此正则表达式匹配“hello hippo”或“happy hippo”。测试一开始,它要查找一个 h,目标字符串的第一个字母恰好就是 h,它立刻就被找到了。接下来,子表达式(ello|appy)提供了两个处理选项。正则表达式选择最左边的选项(分支选择总是从左到右进行),检查 ello 是否匹配字符串的下一个字符。确实匹配,然后正则表达式又匹配了后面的空格。然而在这一点上它走进了死胡同,因为 hippo 中的 h 不能匹配字符串中的下一个字母 t。此时正则表达式还不能放弃,因为它还没有尝试过所有的选择,随后它回溯到最后一个检查点(在它匹配了首字母 h 之后的那个位置上)并尝试匹配第二个分支选项。但是没有成功,而且也没有更多的选项了,所以正则表达式认为从字符串的第一个字符开始匹配是不能成功的,因此它从第二个字符开始,重新进行查找。它没有找到 h,所以就继续向后找,直到第 14 个字母才找到,它匹配 happy 的那个 h。然后它再次进入分支过程。这次 ello 未能匹配,但是回溯之后第二次分支过程中,它匹配了整个字符串“happy hippo”(如图 5-4)。匹配成功了。
回溯失控
当一个正则表达式占用浏览器上秒,上分钟或者更长时间时,问题原因很可能是回溯失控。正则表达式处理慢往往是因为匹配失败过程慢,而不是匹配成功过程慢。
var reg = /<html>[\s\S]*?<head>[\s\S]*?<title>[\s\S]*?<\/title>[\s\S]*?<\/head>[\s\S]*?<body>[\s\S]*?<\/body>[\s\S]*?<\/html>/;
//优化如下
var regOptimize = /<html>(?=([\s\S]*?<head>))\1(?=([\s\S]*?<title>))\2(?=([\s\S]*?<\/title>))\3(?=([\s\S]*?<\/head>))\4(?=([\s\S]*?<body>))\5(?=([\s\S]*?<\/body>))\6[\s\S]*?<\/html>/;
现在如果没有尾随的