《高性能JavaScript》阅读笔记

整本书不厚,写的很好,解释的很清楚,虽然实际过程中不一定能注意到。每一章节的侧重点都有小结,回顾的时候可以先看小结,再探究有所遗忘的具体细节。

第一章 加载和执行

JavaScript阻塞性

脚本位置

将脚本放在底部

按照惯例, <script> 标签用来加载出现在<head>中的外链JavaScript文件,挨着<link>标签用来加载外部CSS文件或者其他页面信息。
理论上来说,把与样式和行为有关的脚本放在一起,并先加载他们,这样做有助于确保页面渲染和交互的正确性。

  • 每个文件必须等到前一个文件下载并执行完成才会开始下载。

file1.js download –> file1.js execute –> file2.js download –> file2.js execute –> file3.js download –> file3.js execute –> style.css download –> style.css execute

  • 因此推荐将所有的<script>标签尽可能放到<body>标签的底部(也就是</body>之前),以尽量减少对整个页面下载的影响。

  • 这是雅虎特别性能小组提出的优化JavaScript的首要规则:将脚本放在底部。

组织脚本

  • 由于每个<script>标签初始下载时都会阻塞页面渲染,所以减少页面包含的<script>标签数量有助于改善这一情况。(下载单个100KB的文件将比下载4个25KB的文件更快)

  • 文件合并的工具可通过离线的打包工具或者实时在线服务。

无阻塞的脚本

  • 减少JavaScript文件大小并限制HTTP请求数仅仅是创建响应迅速的Web应用的第一步。

  • 无阻塞脚本的秘诀在于,在页面加载完成后才加载JavaScript代码。

延迟的脚本

不是一个理想的跨浏览器解决方案。带有defer属性的<script>标签可以放置在文档的任何位置。随影的JavaScript文件将在页面解析到<script>标签时开始下载,直到DOM加载完成(onload事件被触发前)才会执行。

1
<script type="text/javascript" src="file1.js" defer></script>

动态脚本元素

如果需要的话,你可以动态加载尽可能多的JavaScript文件到页面上,但一定要考虑清楚文件的加载顺序。

1
2
3
4
5
6
// 下载操作串联以保证下载顺序
loadScript("file1.js", function(){
loadScript("file2.js", function() {
...
});
});

如果多个文件的下载顺序很重要,更好的做法是把它们按正确顺序合并成一个文件。

动态脚本加载凭借着它在跨浏览器兼容性和易用的优势,成为最通用的无阻塞加载解决方案。

  • 动态脚本加载实现
1
2
3
4
5
6
7
8
9
10
// firefox\opera\chrome\safari
var script = document.createElement("script");
script.type = "text/javascript";

script.onload = function() {
alert("script loaded!");
};

script.src = "file1.js";
document.getElementByTagName("head")[0].appendChild(script);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// IE(封装版)
loadScript("file1.js", function(){
...
});

function loadScript(url, callback){
var script = document.createElement("script");
script.type = "text/javascript";

if(script.readyState) {
script.onreadystatechange = function() {
if(script.readyState == "loaded" || script.readyState == "complete") {
script.onreadystatechange = null;
callback();
}
}
} else { // 其他浏览器
script.onload = function() {
alert("script loaded!");
};
}
}

XMLHTTPRequest脚本注入

另一种无阻塞加载脚本的方法是使用XMLHTTPRequest对象获取脚本并注入页面中。优点是你可以下载JavaScript代码但不立即执行,并且没有浏览器兼容性问题。缺点是JavaScript文件必须与所请求的页面处于相同的域。

1
2
3
4
5
6
7
8
9
10
11
12
var xhr = new XMLHttpRequest();
xhr("get", "file1.js", true);
xhr.onreadystatechange = funtion() {
if(xhr.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);
}
}
}

推荐的无阻塞模式

推荐的无阻塞模式

向页面中添加大量JavaScript的推荐做法只需两步:先添加动态加载所需的代码,然后加载初始化页面所需的剩下的代码。

1
2
3
4
5
6
7
8
// 放在`</body>`前,不会阻碍页面其他内容的显示
// 也避免了用window.onload来检测页面是否准备好
<script src="loader.js" type="text/javascript"></script>
<script type="text/javascript">
loadScript(“the-rest.js”, function(){
Application.init();
});
</script>

小结

  • </body>闭合标签之前,将所有的<script>标签放到页面底部。
  • 合并脚本,尽可能少的加载次数。
  • 无阻塞下载JavaScript
    • 使用<script>标签的defer属性
    • 使用动态创建的<script>元素来下载并执行代码
    • 使用XHR对象下载JavaScript代码并注入页面中

第2章 数据存取

计算机科学中有一个经典问题是通过改变数据的存储位置来获得最佳的读写性能。

JavaScript有下面四种基本的数据存取位置。

  • 字面量

字面量只代表自身,不存储在特定位置。JavaScript中的字面量有:字符串、数字、布尔值、对象、数组、函数、正则表达式,以及特殊的null和undefined值。

  • 本地变量

开发人员使用关键字var定义的数据存储单元。

  • 数组元素

存储在JavaScript数组对象内部,以数字作为索引。

  • 对象成员

存储在JavaScript对象内部,以字符串作为索引。

通常的建议是:如果在乎运行速度,那么尽量使用字面量和本地变量,减少数组项和成员对象的使用。

管理作用域

作用域概念是理解JavaScript的关键所在,不仅从性能角度,还包括从功能的角度。

作用域链和标识符解析

每一个JavaScript函数都表示为一个对象,是Function对象的一个实例。Function对象和其他对象一样,拥有可以编程访问的属性,和一系列不能通过代码访问而仅供JavaScript引擎存取的内部属性。

其中一个内部属性是[[Scope]],包含一个函数北川兼得作用域中的对象的集合。这个集合被称为函数的作用域链。作用域链中的每一个可变对象都以键值对的形式存在。

例:

1
2
3
4
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}

创建了add函数对象,[[scope]] -> [0: {全局对象}],全剧对象:
{ this: window,
window: (object),
document: (object),
add: (function)
}

1
2
// 执行
var total = add(5, 10);

执行函数会创建一个称为执行上下文的内部对象。函数每次执行时对应的执行环境都是独一无二的,所以多次调用同一函数会创建多个执行环境。当函数执行完毕,执行环境就被销毁。

每个执行环境都有自己的作用域链,用于解析标识符。当执行环境被创建时,它的作用域链初始化为当前运行函数[[scope]]对象。这些值按照它们出现在函数中的顺序,被复制到执行环境的作用域链中,推到作用域链的顶端。当执行环境被销毁,活动对象也随之销毁。

(执行环境)var total = add(5, 10);的作用域链 -> [{活动对象}, {全局对象}] -> 活动对象:
{
this: window,
arguments: [5, 10],
num1: 5,
num2: 10,
sum: undefined
}

  • 如果活动对象{…, num: 0, num: 10,…}, 解析标识符会从作用域链头部开始,找到num: 0,就使用这个值。

标识符解析的性能

一个标识符在作用域链中所处位置越深,它的读写速度也就越慢,读写局部变量总是最快的,读写全局变量通常是很慢的,全局变量总存在于执行环境作用域链的最末端。一个好的经验法则是:如果某个跨作用域的值在函数中被引用一次以上,那么就把它存储到局部变量里。

改变作用域链

有两个语句可以在执行时临时改变作用域链,with和try… catch…。

  • with
1
2
3
4
5
with(document) {
...
links = getElementByTagName(‘a’);
...
}

with会创建一个新的变量推入作用域链的首位,这意味着函数所有的局部变量现在处于第二个作用域链对象中,因此访问的代价更高了。最好避免使用with语句,可以使用存入局部变量中来提升性能。

  • try… catch(e) …

跳转到Catch子句,会把异常对象推入一个变量对象并置于作用域的首位。在catch代码块内部,函数所有的局部变量将会放在第二个作用域链对象中。一旦catch子句执行完毕,作用域链就会返回到之前的状态。

1
2
3
4
5
6
// 委托给错误处理函数,没有访问局部变量,作用域链的临时改变就不会影响代码性能
try {
methodThatMightCauseAnError();
} catch(ex) {
handleError(ex);
}

动态作用域

无论是with语句还是try-catch语句的catch子句,或是包含eval()的函数,都被认为是动态作用域。只在确实有必要时才推荐使用动态作用域。

闭包、作用域和内存

闭包是JavaScript最强大的特性之一,它允许函数访问作用域之外的数据。闭包可能会导致性能问题。

例:

1
2
3
4
5
6
function assignEvents() {
var id = “xdi9592”;
document.getElementById(‘save-btn’).onclick = function(event) {
saveDocument(id);
};
// 这个事件处理函数就是一个闭包

为了让这个闭包能够方位id,必须创建一个特定的作用域链,闭包的[[scope]]属性包含了与执行环境作用域链相同的对象引用,因此会产生副作用。需要更多的内存开销。(函数的活动对象会随着执行环境一同销毁,但如果引用还存在于闭包的[[scope]]中,激活对象无法被销毁。)

assignEvents()执行 -> [{活动对象}, {全局对象}]
闭包被创建时,-> [{活动对象(assignEvents)}, {全局对象}]
闭包代码执行时, -> [{活动对象(闭包)}, {活动对象(assignEvents)}, {全局对象}]

闭包执行时,频繁访问跨作用句的标识符(assignEvents中的变量),会带来性能损失。

可以如先前提到的,讲常用的跨作用域变量存储在局部变量中,然后直接访问局部变量。

对象成员

大部分JavaScript代码是以面向对象风格编写的,这会导致非常频繁的访问对象成员。对象成员包含属性和方法。当一个被命名的成员引用一个函数,该成员就被称为一个方法,引用了非函数类型的成员,就被称为属性。
为什么访问对象成员的速度比访问字面量或者变量要慢?

原型

JavaScript中的对象是基于原型的。原型是其他对象的基础,它定义并实现了一个新创建的对象所必须包含的成员列表。这些对象实例也共享了原型对象的成员。对象通过一个内部属性绑定到它的原型。
对象可以有两种成员类型:实例成员和原型成员。实例成员直接存在于对象实例中,原型成员则从对象原型继承而来。

例:

1
2
3
4
5
var book = {
title: ‘High Performance JavaScript’,
publisher: ‘Yahoo! press’
};
alert(book.toString()); // ‘[object object]’

对象book中有两个实例成员,title和publisher,方法toString是原型成员.

book {‘proto’: {book原型}, title: ‘xxx’, publisher: ‘xxx’}.
book原型 {‘_proto’: null, hasOwnProperty: (function), …, toString: (function)}.

  • 解析成员对象

先从对象实例开始搜索,没有找到的话继续搜索其原型对象。

  • hasOwnProperty()判断对象是否包含特定实例成员,in操作符判断对象是否包含特定属性
1
2
3
4
5
6
7
8
9
10
var book = {
title: ‘High Performance JavaScript’,
publisher: ‘Yahoo! press’
};

alert(book.hasOwnProperty(‘title’)); // true
alert(book.hasOwnProperty(‘toString’)); // false

alert(‘title’ in book); // true
alert(‘toString’ in book); // true

原型链

对象的原型决定了实例的的类型。默认情况下,所有随想都是对象(Object)的实例,并继承了所有的基本方法。你可以定义并使用构造函数来创建另一种类型的原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 构造函数
function Book(title, publisher) {
this.title = title;
this.publisher = publisher;
}

Book.prototype.sayTitle = function() {
alert(this.title);
};

var book1 = new Book(‘High Performance JavaScript’, ‘Yahoo! Press’);
alert(book1 instanceof Book); // ture
alert(book1 instanceof Object); // ture

对象在原型链中的位置越深,找到它也就越慢。搜索实例成员比从字面量或局部变量中读取数据代价更高,再加上便利原型链带来的开销,这让性能问题更加严重。

嵌套成员

对象成员嵌套的越深,读取速度就会越慢。执行location.href总是比window.location.href要快。

缓存对象成员值

通常在函数中如果要多次读取同一个对象属性,最佳做法是将属性值保存到局部变量中。

小结

  • 访问字面量和局部变量的速度,快于访问数组元素和对象成员。
  • 变量在作用域链中的位置越深,访问所需时间就越长,局部变量快于跨作用域变量快于全局变量。
  • 避免使用改变执行环境作用域链的语句,with try-catch eval
  • 少用嵌套的对象成员
  • 属性和方法在原型链中的的位置越深,访问它的速度就越慢
  • 通常来说,你可以通过缓存成局部变量来改善JavaScript性能。

第三章 DOM编程

用脚本进行DOM操作的代价很昂贵,它是富Web应用中最常见的性能瓶颈。本章讨论一下三类问题:

  • 访问和修改DOM元素
  • 修改DOM元素的样式导致重绘和重排
  • 通过DOM事件处理与用户的交互

浏览器中的DOM

文档对象模型是一个独立于语言的,用来操作XML和HTML文档的程序接口(API)。

浏览器中通常会把DOM和JavaScript独立实现。

两个相互独立的功能只要通过接口彼此连接,就会产生消耗。推荐的做法是尽可能减少访问DOM的次数。

DOM访问与修改

访问DOM元素是有代价的,修改元素则更为昂贵,因为它会导致浏览器重新计算页面的几何变换。通常的经验法则是: 减少访问DOM的次数,把运算尽量留在ECMAScript这一端处理。(多于一次的话,缓存在局部变量中)

  • 在大多数浏览器中,克隆节点(element.cloneNode)比创建节点(document.createElement)更有效率,但也不是很明显。

  • HTML集合

    HTML集合是包含了DOM节点引用的类数组对象,以下方法和属性的返回值为HTML集合对象,这是个类似数组的列表,但并不是真正的数组(因为没有push()或slice()之类的方法),但提供了一个类似数组中的length属性,并且还能以数字索引的方式访问列表中的元素。

    1
    2
    3
    4
    5
    6
    7
    8
    document.getElementsByName()
    document.getElementsByClassName()
    document.getElementsByTagName()

    document.images
    document.links
    document.forms
    document.forms[0].elements

HTML集合一直与文档保持着连接,每当你需要最新的信息时,都会重复执行查询的过程,哪怕只是获取集合里的元素个数(length)也是如此。这正是低效之源。

很多情况下,如果只需要遍历一个相对较小的HTML集合,那么缓存length就够了。但由于遍历数组比遍历集合快,因此如果先将几何元素拷贝到数组中,那么访问它的属性会更快。根据情况使用。

访问集合元素时使用局部变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 较慢,每次读取全局document
function collectionGlobal() {
var coll = document.getElementByTagName('div'),
len = coll.length;
name = '';
for(var count = 0; count < len; count++) {
name = ducument.getElementByTagName('div')[count].nodeName;
name = ducument.getElementByTagName('div')[count].nodeType;
name = ducument.getElementByTagName('div')[count].tagName;
}
return name;
}
// 较快,缓存了一个集合的引用
function collectionGlobal() {
var coll = document.getElementByTagName('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;
}
// 最慢,当前集合元素存储到一个变量
function collectionGlobal() {
var coll = document.getElementByTagName('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;
}

  • 遍历DOM

获取DOM元素
childNodes、nextSibling

元素节点

大部分现代浏览器提供的API只返回元素节点(childNodes,firstChild和nextSibling并不区分元素节点和其他类型节点,例如注释和文本节点),实现的过滤效果要更高。

属性名 被替代的属性
children childNodes
childElementCount childNodes.length
firstElementChild firstChild
lastElementChild lastChild
nextElementSibling nextSibling
previousElementSibling previousSibling
  • 选择器API
    1
    2
    3
    4
    var element = document.querySelector('.myClass');
    var element = document.querySelectorAll('#menu a');

    var elements = document.getElementById('menu).getElementByTagName('a');

elements的值包含一个引用列表,指向位于id=“menu”的元素之中的所有a元素。方法返回一个NodeList——包含着匹配节点的类数组对象。这个方法不会返回HTML集合,因此返回的节点不会对应实时的文档结构。避免了HTML集合引起的性能问题。

重绘与重排

浏览器下载完页面中的所有组件——HTML标记、JavaScript、CSS、图片——之后会解析并生成两个内部数据结构

  • DOM树——表示页面结构
  • 渲染树——表示DOM节点如何显示

DOM树种的每一个需要显示的节点在渲染树中至少存在一个对应的节点(隐藏的DOM元素在渲染树中没有对应的节点)。渲染树中的节点被称为“boxes”,符合CSS模型的定义。一旦DOM和渲染树构建完成,浏览器就开始显示(绘制)页面元素。

重排——DOM的变化影响了元素的几何属性和位置,浏览器会使渲染树中受影响的部分失效,并重新构造渲染树。
重绘——完成重排后,浏览器会重新绘制受影响的部分到屏幕中。

  • 当页面布局和几何属性改变是就需要“重排”。
  • 修改样式的过程中,尽量不要调用需要返回最新的布局信息的属性和方法,浏览器会执行渲染队列中的“待处理变化”并触发重排以返回正确的值。将其移到修改样式的代码的末尾会比较高效。
  • 最小化重绘和重排

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    // 改变样式
    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;';

    // 批量修改DOM
    // 1. 使元素脱离文档流
    // 2. 对其应用多重改变
    // 3. 把元素带回文档中
    // 三种方法
    // 1. display: none隐藏元素
    // 2. 使用文档片段在当前DOM之外构建一个子树(推荐,最快)
    // 3. 拷贝元素到一个脱离文档的节点

    // 定义一个修改的方法appendDataToElement
    // 1
    var ul = document.getElementById('mylist');
    ul.style.display = 'none';
    appendDataToElemtent(ul, data);
    ul.style.display = 'block';
    // 2
    var fragment = document.createDocumentFragment();
    appendDataToElemtent(fragment, data);
    document.getElementById('mylist').appendChild(fragment);
    // 3
    var ul = document.getElementById('mylist');
    var clone = ul.cloneNode(true);
    appendDataToElemtent(clone, data);
    ul.parentNode.replace(clone, ul);
  • 缓存布局信息,例如用current把el.offsetLeft缓存下来。

  • 让元素脱离动画流(使用绝对定位使其脱离文档流)
  • IE中大量元素使用:hover会降低响应速度
  • 绑定事件处理器会加重页面负担,可以使用事件委托(只给外层元素绑定一个处理器,通过事件冒泡,就可以处理其子元素上触发的所有事件。也可以配合使用取消冒泡事件来过滤响应事件的元素)

小结

  • 减少DOM访问次数
  • 局部变量存储访问的DOM节点
  • 局部变量存储HTML集合的长度,甚至合适的话把它拷贝到一个数组中
  • 使用querySelectorAll()和querySelector会快
  • 重排与重绘
  • 动画中使用绝对定位
  • 使用事件委托

第四章 算法和流程控制

代码的整体结构是影响运行速度的主要因素之一。

循环

  • 循环的类型

JS中有四中循环类型,for,while,do…while,for…in,由于for…in每次迭代操作会同时搜索实例或原型属性,所以会产生可以更多开销。除非你明确需要迭代一个属性数量未知的对象,否则应避免使用for…in循环。

提高循环的性能有以下两个方面:

  • 减少迭代的工作量
1
2
3
4
5
6
7
8
9
10
11
for(var i = 0; i < items.length; i++) {...}
while(j < items.length) {}

// 缓存
for(var i = 0; i < items.length; i++) {...}
var count = items.length;
while(j < count) {}

// 倒序,和0比较不是和length比较
for(var i = items.length; i--;) {...}
// 此外,减少其中的操作
  • 减少迭代次数

    即使循环体重执行最快的代码,累计迭代上千次也会变慢下来,最广为人知的一种限制循环迭代次数的模式被称为“达夫设备”。如果迭代超过1000次,那么执行效率将明显提升。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var i = items.length % 8;
while(i) {
process(items[i--]);
}
i = Math.floor(items.length/8);
// 比switch更快
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--]);
}
  • 基于函数的迭代
    forEach(),遍历数组的所有成员,并在每个成员上执行一个函数,因此比基于循环的迭代要慢一些。

条件语句

  • 在条件数量较大(多与两个离散值)时使用switch,条件数量较少时使用if-else。

  • 优化if-else
    最小化到达正确分之前所需判断的条件数量,也就是说确保最可能出现的条件放在首位。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 多个至于需要测试时
    if(value < 6) {
    if(value < 3) {

    } else {

    }
    } else {
    if(value < 8) {

    } else {

    }
    }
    // ↑减少了判断次数
  • 当有大量离散值需要测试时,使用查找表会比if-else和switch从速度和可读性都好很多

1
2
3
4
5
6
7
8
9
switch(value) {
case 0: return result0;
case 1: return result1;
case 2: return result2;
case 3: return result3;
}
// ↓
var results = [result0, result1, result2, result3];
return results[value];

递归

递归函数的潜在问题是终止条件不明确,或缺少终止条件会导致函数长时间运行,并使得用户界面处于假死状态。还可能遇到浏览器的“调用栈大小限制”。

  • 调用栈限制

JavaScript引擎支持的递归数量与JavaScript调用栈大小直接相关(IE例外)。

  • 递归模式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 引起调用栈限制的两种递归模式
    function A() {
    A();
    }
    A();
    //
    function A() {
    B();
    }
    function B() {
    A();
    }
    A();
  • 迭代

    任何递归能实现的算法同样可以用迭代来实现,实现的要慢一些,但是可以避免栈溢出。

  • Memoization

    Memoization正是一种避免重复工作的方法,它缓存前一个计算结果供后续计算使用,避免了重复工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 创建一个缓存对象,
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];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 手动更新
function memoize(function, cache) {
cache = cache || {};
var shell = funcrion(arg) {
if(!cache.hasOwnProperty(arg)) {
cache[arg] = fundamental(arg);
}
return cache[arg];
}
return shell;
}

var memfactorial = memoize(factorial, {'0': 1, '1': 1});

手工的更好,可以针对性的手工实现优化(缓存特定的参数的函数调用结果)。

小结

  • for, while, do-while性能相当
  • 除非便利一个属性位置的对象,否则避免使用for-in
  • 改善循环性能的最佳方式是减少每次迭代的运算量和减少循环迭代次数。
  • switch总是比if-else快,但是不是最佳的(两次以内可以用if-else,性能差距不大,可读性强。还有改善的if-else对于在多个值域的判断会更好)。
  • 判断条件多的时候,使用查找表
  • 浏览器的调用栈
  • 遇到栈溢出错误,可以将递归改为迭代算法,或者使用Memoization来避免重复计算。

第五章 字符串和正则表达式

高效处理字符串和正则表达式的各种技巧。

字符串连接

  • 字符串合并的方法

+、+=、array.join()、string.concat()。

加(+)和加等(+=)操作符

这些操作符提供了连接字符串最简单的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
str += "one" + "two";

// 1. 在内存中创建一个临时字符串
// 2. 将连接后的“onetwo”赋值给临时字符串
// 3. 临时字符串和str当前值连接
// 4. 结果赋值给str

// ↓ 避免了产生临时字符串

str += "one";
str += "two";

// 等价于
str = str + "one" + "two";

// ×
str = "one" + str + "two";

数组项合并

大多数浏览器中,数组项合并比其他字符串连接方法更慢。

String.prototype.concat

很灵活,但是比简单的使用 + 和 += 稍慢。

1
str = String.prototype.concat.apply(str, array);

正则表达式优化

正则表达式工作原理

  • 编译

    转化成一个原生代码程序。如果你把正则对象赋值给一个变量,可以避免重复执行这一步骤。

  • 设置起始位置

    它是字符串的起始位置,匹配失败的话后移一位。浏览器厂商有进行一些优化来跳过一些不必要的步骤。

  • 匹配每个正则表达式字元

    知道了开始位置之后,会逐个检查文本和正则表达式模式。

  • 匹配成功或失败

理解回溯

回溯是影响正则表达式整体性能的其中一环,理解它的工作原理以及如何最少化的使用它可能是编写高效正则表达式的关键所在。

  • 分支与回溯
  • 重复与回溯

回溯失控

  • 解决方案: 具体化
  • 适用预查和反向引用的模拟原子组
  • 嵌套量词与回溯失控

基准测试的说明

建议总是用包含特殊匹配的长字符串来测试你的正则表达式,为你的正则表达式构建一些近似但不能完全匹配的字符串,并将他们用在你的测试中。

更多提高正则表达式效率的方法

  • 关注如何让匹配更快失败
  • 正则表达式以简单、必需的字元开始
  • 使用量词模式,使它们后面的字元互斥
  • 减少分支数量,缩小分支范围
  • 使用非捕获组
  • 只捕获感兴趣的文本以减少后处理
  • 暴露必要的字元
  • 使用合适的量词
  • 把正则表达式赋值给变量并重用他们

何时不使用正则表达式

只是搜索字面字符串时

去除字符串首尾空白

trim方法

总结

这一张的内容暂时遇到的情况比较上,虽然了解了正则表达式的回溯为什么会有性能的差异,但是不是很会运用正则表达式,等熟练运用了,再回过头看一下。

第六章 快速响应的用户界面

浏览器UI线程

用于执行JavaScript和更新用户界面的进程通常被称为“浏览器UI线程”,UI线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲,一旦空闲,队列中的下一个任务就被重新提取出来并执行。

// 点击按钮执行功能
ui更新 -> JS执行 -> ui更新

大多数浏览器在JavaScript运行时会停止把新任务加入UI线程的队列中,也就是说JavaScript任务必须尽快结束,以避免对用户体验造成不良影响。

浏览器限制

浏览器限制了JavaScript任务的运行时间,以确保某些恶意代码不能通过永不停止的密集操作锁住用户的浏览器或计算机。这种限制分为两类:调用栈大小限制和长时间运行脚本限制。

多久才算太久

单个JavaScript操作花费的总时间不应该超过100毫秒。

使用定时器让出时间片段

有一些复杂的JavaScript任务不能在100毫秒或更短时间内完成。停止执行JavaScript,使UI线程有机会更新,然后再继续执行JavaScript。

定时器基础

setTimeout() 只执行一次的定时器
setInterval() 一个周期性重复运行的定时器

1
2
3
4
5
6
7
function greeting() {
alert('Hello World');
}

setTimeout(greeting, 250);

// 在250毫秒向UI队列插入一个执行greeting函数的JavaScript任务,并不是在250毫秒之后执行,这250毫秒从setTimeout执行时开始计算。定时器代码只有在创建的它的函数执行完成后,才有可能被执行。

无论发生何种情况,创建一个定时器会造成UI线程暂停,如同它从一个任务切换到下一个任务。因此,定时器代码会重置所有相关的浏览器限制,包括长时间运行脚本定时器。此外,调用栈也在定时器的代码中重置为0.这一特性使得定时器称为长时间运行JavaScript代码理想的跨浏览器解决方案。

  • 如果setTimeout()中的函数需要消耗的比定时器演示更长的运行时间,那么定时器代码中的延时几乎是不可见的。

  • JavaScript虽然是单线程的,但是浏览器可以是多个线程,所以会出现上面的情况。

定时器的精度

JavaScript定时器延迟通常不太精准,相差大约几毫秒,所以定时器不可以用于测量实际时间。

建议将最小值设定为25毫秒以确保至少有15毫秒延迟(win系统中定时器)。

使用定时器处理数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var todo = items.concat(); // 克隆原数组

setTimeout(function() {
// 取得数组下个元素并进行处理
process(todo.shift());
// 如果还有需要处理的元素,创建另一个定时器
if(todo.length > 0) {
// argument.callee指向当前正在运行的匿名函数
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
}, 25);

// 比普通循环需要更多代码,可以将该功能封装起来
// 处理数组的总时长变长了,每一个条目处理完成后UI线程会空闲一段延迟的时间
// 但是避免了锁定浏览器

分割任务

如果函数运行时间太长,可以拆分成一系列更小的步骤,把每个独立的方法放在定时器中调用。你可以将每个函数都放入一个数组,然后使用前一小节的数组处理模式。

前提是任务可以异步处理而不影响用户体验或造成相关代码错误。

记录代码运行时间

最好不要让任何JavaScript代码持续运行50毫秒以上没这样做只是确保代码永远不会影响用户体验。

批量处理经历的延时比较少,总时间会短。

可以通过原生的Date对象来跟踪地阿妈的运行时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// do-while后测比前测更合理,因为执行时数组中始终会包含至少一个条目
function timedProcessArray(items, process, callback) {
var todo = items.concat();

setTimeout(function() {
var start = +new Date();

do{
process(todo.shift);
} while(todo.length > 0 && (+new Date() - start < 50));

if (todo.length > 0) {
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
}, 25);
}

定时器与性能

先前的代码使用了定时器序列,同一时间只有一个定时器存在,只有当这个定时器结束时才会新创建一个。通过这种方法使用定时器不会导致性能问题。当多个重复的定时器同时创建往往会出现性能问题。

Web Workers

Web Workers API 引入一个接口,能使代码运行且不占用浏览器UI线程的时间。作为HTML5最初的一部分,Web Workers API已经被分离出去成为独立的规范。Web Workers已经被FireFox3.5、 Chrome3和Safari4原生支持。

Web Workers给Web性能带来潜在的巨大性能提升,因为每个新的Worker都在自己的线程中运行代码。这意味着Worker运行代码不仅不会影响浏览器UI,也不会影响其他Worker中运行的代码。

Worker运行环境

每个Web Worker都有自己的全局运行环境,其功能只是JavaScript特性的一个子集。

1
var worker = new Worker("code.js");

与Worker通信

Worker与网页代码通过事件接口进行通信。网页代码可以通过postMessage()方法给Worker传递数据,Worker用onMessage()接受信息。

1
2
3
4
5
6
var worker = new Worker("code.js");
worker.onmeaage = function(event) {
alert(event.data);
};

worker.postMessage("Nicholas");

加载外部文件

1
2
importScripts("file1.js", "file2.js");
// importScripts()的调用过程是阻塞式的。

实际应用

任何超过100毫秒的处理过程,都应当考虑Worker方案是不是比基于定时器的方案更为合适。前提是浏览器支持Web Workers。

例:

  • 编码解码大字符串
  • 复杂数学运算(包括图像或视频处理)
  • 大数组排序
    。。。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 解析一个很大的JSON字符串

var worker = new Worker("jsonparser.js");

worker.onmessage = function(event) {
var jsonData = event.data;
evaluateData(jsonData);
};

worker.postMessage(jsonText);

self.onmessage = function(event) {
var jsonText = event.data;
var jsonData = JSON.parse(jsonText);

self.postMessage(jsonData);
};

总结

JavaScript和用户界面更新在同一个进程中进行,一次只能处理一件事情。

  • 任何JavaScript任务都不应当执行超过100毫秒。
  • 定时器可用来安排代码延迟执行,它使得你可以把长时间运行脚本分解成一些列的小任务。
  • Web Workers是新版浏览器支持的特性,它允许你在UI线程外部执行JavaScript代码,从而避免锁定UI。

第七章 Ajax

Ajax是高性能JavaScript的基础。

  • 延迟下载体积较大的资源文件来使得页面加载更快
  • 通过异步方式在客户端和服务器端之间传输数据
  • 甚至可以用一个HTTP请求就获取整个页面的资源

选择合适的传输方式和最有效的数据格式,可以显著改善用户和网站的交互体验。

数据传输

Ajax是一种与服务器通信而无需重载页面的方法,数据可以从服务器获取或发送给服务器。

请求数据

有常用的五种技术:

  • XMLHTTPRequest(XHR)
  • Dynamic script tag insertion(动态脚本技术)
  • iframes
  • Comet
  • Multipart XHR

XMLHttpRequest

是目前最常用的技术,它允许异步发送和接收数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var url = '/data.php';
var params = [
'id=934875',
'limit=20'
];

var req = new XMLHttpRequest();

req.onreadystatechange = function() {
if (req.readyState === 4) {
var responseHeaders = req.getAllResponseHeaders();
var data = req.responseText;
// ...do something
}
}

req.open('GET', url + '?' + parmas.join('&'), true) ;
req.setRequestHeader('X-Request-With', 'XMLHttpRequest');
req.send(null);

缺点:

  • 不支持跨域
  • 低版本IE不支持“流”,不提供readyState为3的状态
  • 传回的数据被当做字符串或者XML对象,这一位置处理大量数据将会很慢

使用XHR时,post和get的对比

  • 获取数据,不改变服务器状态的用get,经get请求的数据会被缓存起来,如果需要多次请求同一数据的话,它更有助于提升性能
  • URL加参数的长度超过1024个字符的时候,应该使用POST
  • (书上没提),不想放在URL上的(如密码),要用POST

动态脚本注入

能跨域,但只能GET(不需要实例化一个专用对象,所以有很多限制)

响应消息作为脚本标签的源码,它必须是可执行的JavaScript代码,所以速度非常的快(不用进行字符串处理),但引入外部来源的代码要小心。

1
2
3
4
5
6
7
8
9
10
11
var scriptElement = document.createElement('script');
scriptElement.src = 'http://any-domain.com/javascript/lib.js';
document.getElementByTagName('head')[0].appendChild(scriptElement);

// 任何格式的数据,都必须封装在一个回调函数里
function jsonCallback(jsonString) {
var data = eval('(' + jsonString + ')');
// 处理数据
}

jsonCallback({'status': 1, 'colors': ['#fff', '#000', '#ff0000']});

Multipart XHR

允许客户端只用一个HTTP请求就可以从服务端向客户端传送多个资源。它通过在服务端将资源(CSS、HTML片段、JavaScript片段或base64编码的图片)打包成一个由双方约定的字符串分割的长字符串并发送到客户端。

显著提升整体性能

发送数据

XMLHttpRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var url = '/data.php';
var params = [
'id=934875',
'limit=20'
];
var req = new XMLHttpRequest();

req.onerror = function() {
// error
};
req.onreadystatechange = function() {
if (req.readyState == 4) {
// success
}
};

req.open('POST', url, true);
req.setRequestHeader('Content-Type', 'application/x-www-urlencoded');
req.setRequestHeader('Content-Length', params.length);
req.send(params.join('&'));

Beacons

这项技术非常类似动态脚本注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var url = '/status_tracker.php';
var params = [
'step=2',
'time=1248027314'
];

var beacon = new Image();
beacon.src = url + '?' + params.join('&');

beacon.onload = function() {
if(this.width == 1) {
// success
} else if(this.width ==2) {
//fail
}
};

beacon.onerror = function() {
// 出错,稍后重试并创建另一个信标。
};

数据格式

考虑数据格式唯一需要比较的就是速度,一种可能下载更快,而另一种可能解析更快。

XML

优势: 极佳的通用性、格式严格、且易于验证。
劣势: 非常的冗长,有效数据比例非常低(依赖大量的结构),解析非常消耗精力(要了解结构)。

一个更有效的方法是把每个值转化为<user>标签的属性,文件尺寸变小,解析起来也更容易。(结构层次会变少)

1
2
3
4
5
6
7
8
9
<user id="2">
<username>bob</username>
<realname>Bob Jones</realname>
<email>bob@bobjones.com</email>
</user>

<!-- ↓ -->

<user id="2" username="bob" realname="Bob Jones" email="bob@bobjones.com" />

XPath在解析XML文档时比getElementsByTagName快许多,但它并未得到广泛支持。(所以必须使用DOM遍历方法编写降级的代码)

比起更先进的技术,不推荐使用XML格式

JSON

JSON是一种使用JavaScript对象和数组直接编写的轻量级且易于解析的数据格式。
当它被求值或分装在一个回调函数中时,JSON数据就是一段可执行的JavaScript代码,这意味着可以简单的使用eval()来解析(JSON数据是被当成字符串返回的!!!),但是不推荐,尽可能使用JSON.parse()方法解析字符串本身。

1
2
3
4
5
6
7
8
9
// 会解析更快,但是可读性变差的改写

[{"id": 1, "username": "alice", "realname": "Alice Smith", "email": "alice@alicesmith.com"}]

// ↓
[{"i": 1, "u": "alice", "r": "Alice Smith", "e": "alice@alicesmith.com"}]

// ↓
[[1, "alice", "Alice Smith", "alice@alicesmith.com"]]

JSON-P

在使用动态脚本注入时,JSON数据被当成另一个JavaScript文件并作为原生代码执行。为了实现这一点,这些数据必须封装在一个回调函数里。这就是所谓的“JSON填充(JSON with padding)”或JSON-P。(JSON-P数据是当做原生的JavaScript!!!)

1
2
3
4
5
// 回调包装的原因略微增大了文件尺寸,但与其解析性能的提升相比这点增加显得微不足道。

parseJSON([
{"id": 1, "username": "alice", "realname": "Alice Smith", "email": "alice@alicesmith.com"}
]);
  • 与XML相比,JSON有着许多优点,推荐使用。

HTML

服务器处理好简单的HTML传回客户端,JavaScript可以很方便的通过innerHTML属性把它插入页面响应的位置。

作为一种数据格式,它既缓慢,又臃肿。

自定义格式

当你创建自定义格式时,最重要的决定之一就是采用哪种分隔符。

1
1:alice:Alice Smith:alice@alicesmith.com

自定义格式可以很快速的下载,且易于解析,只需要简单地调用字符串split()并传入分隔符作为参数即可。

对于非常大的数据集,它是最快的格式,甚至在解析速度和平均加载时间上都能击败本地执行的JSON。当你需要在很短的时间内向客户端传送大量数据时可以考虑使用这种格式。

数据格式总结

  • JSON-P数据,使用动态脚本注入获取。他把数据当做可执行的JavaScript而不是字符串,解析速度极快。他能跨域使用,但涉及敏感数据时不应该使用它。
  • 字符分隔的自定义格式,使用XHR或动态脚本注入获取,用split()解析。这项技术解析大数据集比JSON-P略快,而且通常文件尺寸更小。

Ajax性能指南

缓存数据

最快的Ajax请求就是没有请求。有两种主要的方法可避免发送不必要的请求:

  • 在服务端,设置HTTP头信息以确保你的响应会被浏览器缓存

    1
    2
    必须是GET方式发出请求,HTTP头信息中设置
    Expires: Mon, 28 Jul 2014 23:30:00 GMT
  • 在客户端,把获取到的信息存储在本地,从而避免再次请求

    1
    2
    3
    4
    5
    var localCache = {};
    ...
    localCache[url] = req.responseText;
    ...
    delete localCache[url];

第一种技术使用最简单而且好维护,第二种则给你最大的控制权。

了解Ajax类库的局限

直接操作XHR对象减少了函数开销,进一步提升了性能,但是放弃使用Ajax类库,可能会遇到兼容性问题。

小结

高性能的Ajax包括以下方面:了解你项目的具体需求,选择正确的数据格式和与之匹配的传输技术。

第八章 编程实践

避免双重求值

JavaScript像其他很多脚本语言一样,允许你在程序中提取一个包含代码的字符串,然后动态执行它。有四中标准方法可以实现

1
2
3
4
5
6
7
8
9
10
11
12
13
var num1 =5,
num2 =6;

//
result = eval("num1 + num2"),
//
sum = new Function("arg1", "arg2", "return arg1 + arg2");

//
setTimeout("sum = num1 + num2", 100);

//
setInterval("sum = num1 + num2", 100);

当你在JavaScript代码中执行另一端JavaScript代码时,会导致双重求值的性能消耗。(先以正常的方式求值,然后在执行过程中对包含于字符串中的代码发起另一个求值运算)。双重求值比直接包含的代码执行速度慢许多。

1
2
3
4
5
// 避免使用eval()\Function()
// 使用setTimeout和setInterval时,传入函数而不是字符串
setTimeout(function(){
sum = num1 + num2;
}, 100);

使用Object/Array直接量

使用对象和数组直接量是最快的方式,且运行的更快,还节省代码量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var myObject = new Object();
myObject.name = "Nicholas";
myObject.count = 50;
myObject.flag = true;
myObject.pointer = null;

var myArray = new Array();
myArray[0] = "Nicholas";
myArray[1] = 50;
myArray[2] = true;
myArray[3] = null;

// ↓

var myObject = {
name = "Nicholas";
count = 50;
flag = true;
pointer = null;
}

var myArray = ["Nicholas", 50, true, pointer = null];

避免重复工作

别做无关紧要的工作,别重复做已经完成的工作

延迟加载

信息在被使用前不会做任何操作

调用延迟加载函数时,第一次总会消耗更长的时间,因为它必须运行检测接着再调用另一个函数完成任务。但随后调用相同的函数会更快,因为不需要在执行检测逻辑。当一个函数在页面中不会立刻调用时,延迟加载是最好的选择。

条件预加载

在脚本家在期间提前检测,不会等到函数被调用。检测的操作依然只有一次,只是它在过程中来的更早。

条件预加载确保所有函数调用消耗的时间相同。其代价是需要在脚本加载时就检测,而不是加载后。预加载适用于一个函数马上就要被用到,并且在整个页面的生命周期中频繁出现的场合。

使用速度快的部分

位操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 对2取模
for (var i=0, len=rows.length; i < len; i++){
if (i & 1) {
className = "odd";
} else {
className = "even";
}

//
}

// 位掩码

var OPTION_A = 1;
var OPTION_B = 2;
var OPTION_C = 4;
var OPTION_C = 8;
var OPTION_E = 16;

var options = OPTION_A | OPTION_C | OPTION_D;

// 选项B是否在列表中
if (options & OPTION_B){
//
}

原生方法

JavaScript的原生部分在你写代码前已经存在浏览器中了,并且都是用低级语言写的,诸如C++。特别是数学运算和DOM操作(querySelector())。

小结

第九章 构建并部署高性能的JavaScript应用

Apache Ant

一个软件构建自动化工具

合并多个JavaScript文件

可以减少请求数

预处理JavaScript文件

预处理你的JavaScript源文件并不会让应用变得更快,但它允许你做些其他事情,例如有条件的插入测试代码,来衡量你的应用程序的性能。

断言和和外的日志代码只出现在开发过程的DEBUG宏块中。这些语句并不会出现在最终产品中。

JavaScript压缩

JavaScript压缩指的是吧JavaScript文件中所有与运行无关的部分进行剥离的过程。剥离的内容包括注释和不必要的空白字符。该过程通常可以将文件大小减半,促使文件更快被下载,并鼓励程序员编写更好更详细的行内文档。

构建时处理对比运行时处理

开发高性能应用的一个普遍规则是,只要是能在构建时完成的工作,就不要留到运行时去做。

JavaScript的HTTP压缩

缓存JavaScript文件

处理缓存问题

当应用升级时,你需要确保用户下载到最新的静态内容。这个问题可以通过把改动过的静态资源重命名解决。

使用内容分发网络(CDN)

内容分发网络(CDN)是在互联网上按地理位置分布计算机网络,他负责传递内容给终端用户。

部署JavaScript资源

敏捷JavaScript构建过程

第十章 工具

  • 性能分析

在脚本运行期间执行各种函数和操作,找出需要优化的部分

  • 网络分析

检察图片、样式表和脚本的加载过程,以及它们对页面整体加载和渲染的影响。