《高性能JavaScript》读书笔记

(此文为本人学习《高性能JavaScript》的读书笔记)
(”@” 开头的句子为个人补充)

脚本加载与执行

<script> 标签每次出现都会让页面等待脚本的解析和执行。

原因:

  • 浏览器渲染页面的操作是单一进程
    (@: 页面的样式和脚本的解析执行不是同一个线程)
  • 脚本可能会修改页面,故页面的渲染会等待脚本执行完成

在这个过程中,页面渲染和用户交互是被完全阻塞的。
(@: 比如脚本执行时,页面的 dom 还未被渲染出来;js 执行 alert 的时候,会阻塞到用户交互及脚本的执行)

脚本位置

浏览器解析 HTML 文档时是自上而下进行渲染的,这种方式下,在解析到 <body> 标签前,页面上不会渲染任何内容。

高版本浏览器中,js 文件可以并行下载,但是下载时会阻塞其他资源(如 css、图片,etc)。并且,下载后脚本的执行过程中也会阻塞页面的渲染。

也因为如此,一般会要求 <script> 标签放在 <body> 后。

组织脚本

js 除了通过外链脚本引入的方式以外,还有通过内嵌的形式存在。但是,内嵌脚本如果放在 <link> 后(@: 我觉得应该是前面),会导致页面阻塞去等待 css 的下载。

实际开发中,外链脚本文件数量可能会很多。减少页面中外链脚本的数量,可以有效地改善性能。

使用 CDN 技术,也能有效提升性能。
(什么是 CDN)

无阻塞脚本

减少 js 文件大小和数量并限制 HTTP 请求数仅仅是创建响应迅速的 web 应用的第一步。

真正的秘诀是,在页面加载完成后才加载 js 代码,也就是 window.onload 事件。

延迟的脚本

script 标签有两个属性,可以使脚本并行下载

  • defer 属性
    脚本并行下载,页面加载后执行(window.onload 事件触发之前)
  • async 属性
    脚本并行下载,加载完成后自动执行

动态脚本元素

使用动态生成 script 标签来引入外链文件,无论何时启动,文件的下载和执行过程不会阻塞页面其他进程(@: 是进程还是线程)。

可以使用 script.onload 事件来捕获加载完成的时机。

推荐的无阻塞模式

步骤:

  1. 先添加用于启动动态加载的代码
  2. 加载并初始化剩余 js

旧方法:YUI3、LazyLoad、LABjs

数据存取

大多数情况下,从一个字面量和一个局部变量中存取数据的性能差异是微不足道的;但访问数组元素和对象成员的代价就比较高。

p.s.
js 的字面量包括: string, number, boolean, object, array, function, regexp, null, undefined

作用域

内部属性 [[Scope]]

包含了一个函数被创建的作用域中对象的集合。

这个集合就是作用域链,决定哪些数据能在函数中访问。

执行环境(执行上下文)

所谓执行环境,就是函数执行时的内部对象。

每次执行时对应的执行环境都是独一无二;因此多次调用同一个函数时也会创建多个执行环境。

而当函数执行完毕后,对应的执行环境就会销毁。

标识符解析

这里说的标识符,是指函数执行过程的变量名。

在函数执行过程中,每遇到一个变量,会沿作用域链,从活动对象开始搜索同名标识符,直到找到该标识符或者全局对象。

标识符解析优化

没有优化 js 引擎的浏览器中,尽可能使用局部变量。

如果某个跨作用域的值在函数中引用一次以上,可以把它存储到局部变量中

闭包

闭包的概念就不复习了,主要来讲讲闭包对性能上的影响。

创建闭包后,为了让闭包能访问局部变量,js 引擎会为它创建一个新的作用域链,这个(闭包)作用域链会指向源函数执行环境上作用域链上相同对象的引用。

因此,函数的活动对象不会因为函数执行完而销毁。

此外,闭包自己也会创建自己的活动对象。

所以,这就引起了内存泄漏问题,在 IE 浏览器中尤为明显。

但是,闭包最需要关注的性能点并不是这个,而是:在频繁跨作用域去解析标识符时,每次都会带来性能损失

优化:
将常用的跨作用域变量存储在局部变量中,然后直接访问局部变量。

对象

对象成员

对象成员,就是对象的属性和方法。

成员类型:

  • 实例成员
    (直接存在于对象实例中)
  • 原型成员
    (从对象原型继承而来)

可以通过 hasOwnProperty 来判断某个成员是否实例成员。

原型

构造函数的 prototype 属性指向其原型对象;
而实例对象的 __proto__ 指向其原型对象。

所谓原型链,简单来讲,就是一个实例对象的原型对象是另一个实例对象,往上追朔,直到 null。

对象成员对性能的影响

获取成员时,搜索过程会从当前实例对象开始,不断深入原型链中,直到找到目标。

因此,该成员在原型链中存在的位置越深,找到成员也就越慢。

所以,在函数中,点操作符(也就是说嵌套对象,如 window.location.href)会导致 js 引擎搜索所有对象成员。如果这些属性不是对象的实例属性,成员解析还需搜索原型链,时间开销更大。

数据存取的优化

由于所有类似的性能问题都与对象成员有关,因此应该尽可能避免使用。

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

(注意,是读取操作;并且这个优化也不适用于成员方法,因为会改变 this)

DOM 编程

主要从三个方面进行讨论:

  • 访问和修改 DOM 元素
  • 修改 DOM 元素的样式会导致重绘和重排

    浏览器中的 DOM

    文档对象模型(DOM)是一个独立于语言的,用于操作 XML 和 HTML 文档的程序接口(API)。
  • 用来与 HTML 文档交互
  • 用于在 Web 程序中获取 XML 文档
  • 访问文档中的数据

浏览器通常会把 DOM 和 JavaScript 独立实现。如 IE 中分别为 mshtml.dll 和 jscript.dll 两个库;Safari 中的 DOM 和渲染是使用 Webkit 中的 WebCore 实现,JavaScript 则由 JavaScriptCore(update:SquirrelFish),etc

DOM 对性能的影响

简单理解,两个相互独立的功能只要通过接口彼此连接,就会产生消耗。

将 DOM 和 JavaScript(这里指 ECMAScript) 各自想象成一个岛屿,它们之间用收费桥梁连接。ECMAScript 每次访问 DOM,都要途经这座桥,并交纳“过桥费”。访问 DOM 的次数越多,费用越高。

因此,推荐的做法是尽可能减少过桥的次数,努力待在 ECMAScript 岛上

DOM 访问与修改

访问 DOM 元素有代价,修改元素则更为昂贵,因为它会导致浏览器重新计算页面的几何变化。如:

function innerHTMLLoop() {
for(var count = 0; count < 15000; count++) {
document.getElementById('here').innerHTML += 'a';
}
}

在这个循环里,每次循环,都会对该元素访问两次:一次获取 inner HTML 的值,一次是更新它的值。

优化方法:用局部变量存储修改中的内容,在循环结束后一次性写入。

function innerHTMLLoop2() {
var content = '';
for(var count = 0; count < 15000; count++) {
content += 'a';
}
document.getElementById('here').innerHTML += content;
}

其实,总结起来,减少访问 DOM 的次数,把运算尽量留在 ECMAScript 这一端处理

innerHTML 对比 DOM 方法

性能上,两者相差无几。前者非标准但支持良好,后者为原生 DOM 方法。在除开最新版的 Webkit 内核之外的所有浏览器中,innerHTML 会更快一些。

如果在一个对性能有着严苛要求的操作中更新一大段 HTML,推荐使用 innerHTML,因为它在绝大部分浏览器中都运行得很快。

(@: innerHTML 方法要注意过滤攻击内容和转义来防范 xss 攻击)

《高性能》中还提到另一种更新页面内容的方法,克隆已有元素。即用 element.cloneNode() 来代替 document.createElement()。但效率的提升不是很明显。

HTML 集合

HTML 集合是包含了 DOM 节点引用的类数组对象。

比如,下列方法的返回值就是一个 HTML 集合:

  • document.getElementsByName()
  • document.getElementsByClassName()
  • document.getElementsByTagName()

此外,document 对象还提供了一些属性,可以返回页面上特定的 HTML 集合:

  • document.images
    页面中所有 img 元素
  • document.links
    所有 a 元素
  • document.forms
    所有表单 <form> 元素
  • document.forms[0].elements
    页面中第一个表单的所有字段

HTML 集合以一种“假定实时态”实时存在。也就是说,如果底层的文档对象更新时,相应的集合也会自动更新。因为它与文档一直保持连接。每次需要获取最新信息,都要重复执行查询的过程,这正是低效之源。
保持连接容易引起的问题:

var alldivs = document.getElementByTagName('div');
for(var i = 0; i < alldivs.length; i++) {
document.body.appendChild(document.createElement('div'));
}

在这段代码中会形成一个死循环,alldivs 保存的是一个 HTML 集合,在 appendChild 操作后,会进行实时更新。因此,alldivs.length 的值不是固定的,而是不断增加,所以导致死循环。

HTML 集合的优化

  • 对于任何类型的 DOM 访问,需要多次访问同一个 DOM 属性,或方法需要多次访问时,最好使用一个局部变量缓存此成员
  • 遍历一个集合时,第一优化原则是把集合存储在局部变量中,并把 length 缓存在循环外部,然后用局部变量替代这些需要多次读取的元素。

查找 DOM

获取 DOM 元素

  • childNodes 元素集合
  • nextSibling 下一个相邻元素

两种方式都可以进行 DOM 子元素的查找。

一般来说,两种方法的运行时间几乎相等,但是在 IE 中,nextSibling 会比 childNode 表现优异,特别是在老版本 IE 中。
(@: 自己在 chrome 测试时,好像 childNodes 稍微比较快)

但是有个问题,这两个属性,还有 firstChild,是不会区分元素节点和其他节点(如注释节点、文本节点,etc)。在很多时候,我们只想要访问元素节点,这样的话就需要对这些属性返回结果进行检查和过滤。

大部分现代浏览器提供了只返回元素节点的 API,可用的话推荐使用这些 API,因为过滤的效率会比自己在 js 代码中的高。

属性名 被替代的属性
children childNodes
childElementCount childNodes.length
firstElementChild firstChild
lastElementChild lastChild
nextElementSibling nextSibling
previousElementSibling previousSibling

新属性在 IE 中速度提升明显。
元素节点 API 返回的是 HTML 集合,而被替代的属性是返回 NodeList。NodeList 是一个包含着匹配节点的类数组对象,和 HTML 集合不同,它不会对应实时的文档结构,可以避免上文提及的 HTML 集合引起的性能等问题。
(注:IE8 只支持 children)

选择器 API

包括:

  • querySelector: 第一个匹配的节点
  • querySelectorAll: 匹配的节点列表

使用选择器 API 来查找元素可以提高查找效率,它返回的是 NodeList,同样避免了一些 HTML 集合的问题。

重绘与重排

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

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

(@: 才两个吗?没有 CSSOM 样式树?)

对于这块的性能优化,关键就是在于减少这类过程

(@: 其他方面的内容具体可以看之前写的文章

事件委托

给大量元素的每一个,一次或多次绑定事件处理器时,除了因为浏览器要跟踪每个事件处理器,占用了大量内存以外;由于事件绑定通常发生在 onload(或 DOMContentReady)时,占用了处理时间。

事件委托可以简单而优雅地处理 DOM 事件。原理是:事件逐层冒泡并能被父级元素捕获

注意需要做一些跨浏览器兼容:

  • 访问事件对象,并判断事件源。
  • 取消文档树中的冒泡(可选)。
  • 阻止默认动作(可选)。

算法和流程控制

代码的整体结构是影响运行速度的主要因素之一。代码数量少并不意味着运行速度就快,代码数量多也不意味着运行速度一定慢。代码的组织结构和解决具体问题的思路是影响代码性能的主要因素

循环

包括 for、while、do-while、for-in 四种循环,其中前三者性能上差不多,而 for-in 相比明显较慢,因为每次迭代操作会同时搜索实例或原型的属性。

如果需要遍历一个数量有限的已知属性列表,可以使用其他循环类型。类似 Object.keys() 那样。

var props = ['prop1', 'prop2']; // 已知属性列表
var i = 0;
while (i < props.length) {
foo(object[props[i++]]);
}

对于其他类型的话,要提升循环的整体性能,有两个可选因素:

  • 每次迭代处理的事务
  • 迭代的次数

减少迭代的工作量

因素一(每次迭代处理的事务),优化手段当然就是限制循环中耗时操作的数量。

例子(遍历数组操作):

for(var i = 0; i < items.length; i++) {
foo(items[i]);
}

在这里的循环中,每次执行循环体时会产生下列操作:

  1. 在控制条件中查找一次属性(item.length)
  2. 在控制条件中执行一次数值比较(i < items.length)
  3. 一次比较操作来查看控制条件的计算结果是否为 true(i < items.length === true)
  4. 一次自增操作(i++)
  5. 一次数组查找(items[i])
  6. 一次函数调用(foo(items[i]))

优化循环的第一步是要减少对象成员及数组项的查找次数

for(var i = 0, len = items.length; i < len; i++) {
foo(items[i]);
}

此外,倒序循环可以略微提升性能。

for(var i = items.length; i--;) {
foo(items[i]);
}

在这段代码中,还把减法操作放在控制条件中,于是每个控制条件只是简单地与零比较。控制条件与 true 值比较时,任何非零数会自动转换为 true,而零值等同于 false。实际上,控制条件已经从两次比较(迭代数少于总数吗?它是否为 true?)减少到一次比较(它是 true 吗?)。
通过倒序循环和减少属性查找,加上减少比较次数,运行速度可以加快 50%~60%。

减少迭代次数

最广为人知的一种限制循环迭代次数的模式被称为“达夫设备(Duff’s Device)”。

所谓 Duff’s Device 是一个循环体展开技术,使得一次迭代中实际上执行了多次迭代的操作。

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 (--iteration);

它的基本理念是:每次循环中最多可调用8次 process()。循环的迭代次数为总数除以8整除,变量 startAt 用来存放余数,表示第一次循环中应调用多少次 process()。如果是 12 次,那么第一次循环会调用 process() 4次,第二次循环调用 process() 8次,用两次循环替代 12 次循环。

还有一个稍快的版本取消 switch 语句,并将余数处理和主循环分开:

var i = items.length % 8;
while(i) {
process(items[i--]);
}
i = Math.floor(items.length / 8);
while(i) {
process(items[--]);
process(items[--]);
process(items[--]);
process(items[--]);
process(items[--]);
process(items[--]);
process(items[--]);
process(items[--]);
}

循环迭代次数小于 1000,性能比常规循环结构相比只有微不足道的性能提升。当超过 1000,那么 Duff’s Device 的执行效率将明显提升。

当循环复杂度为O(n)时,减少每次迭代的工作量是最有效的方法。当复杂度大于O(n),建议着重减少迭代次数。

基于函数的迭代

如:forEach,etc。这些方法提供了便利,但是它对每个数组项调用外部方法所带来的开销使得它速度比基于循环的迭代慢 8 倍。

条件语句

事实证明,大多数情况下,switch 比 if-else 运行得更快,条件数量越大才快得明显。

通常来说,if-else 适用于判断两个离散值或几个不同的值域。当判断多于两个离散值时,switch语句是更佳选择。

优化 if-else

目标:最小化到达正确分支前所需判断的条件数量

最简单的优化方法是确保最可能出现的条件放在首位
另一种减少条件判断次数的方法是把 if-else 组织成一系列嵌套的 if-else 语句

而在条件语句数量很大的时候,优化条件语句的最佳方案是避免使用 if-else 和 switch,在 js 中可以使用数组和普通对象来构建查找表,效率更快(@: 而且便于理解)。
(@: 在学习设计模式时了解到一个策略模式,就是这种形式)

递归

——将复杂的算法简单化。

潜在问题:

  • 终止条件不明确或缺少终止条件会导致函数长时间运行,并使得用户界面处于假死状态。
  • 可能会遇到“调用栈大小限制”

两种递归模式:

  • 直接递归(函数在内部调用自身)
  • 隐伏模式(两个函数相互调用)

优化

ES6 的 strict 模式下,可以通过“尾递归调用”来优化性能。

不过,正如在上面提到的潜在问题所述,一般建议改用迭代、Memoization。

Memoization

这是一种避免重复工作的方法——将前一个计算结果缓存下来供后续计算使用,避免了重复工作。

比如,在斐波列契数列中,由于公式 f(n) = f(n-1) + f(n-2),有很多计算是重复的。
比如求 f(4),会变成 f(3) + f(2),再推倒出 f(2) + f(1) + f(2),这里开始就有重复计算了,f(2) 就计算了两次。

字符串和正则表达式

字符串连接

在平时开发的使用中,构建字符串的常用方法:通过一个循环,向字符串末尾不断地添加内容
e.g.

var html = '<ul>'
for(var i = 10; i--;) {
html += '<li>No: ' + (10 - i) + '</li>';
}
html += '</ul>';

而对于上面的操作(合并字符串),有多种方法可以实现。

方法 示例
The + operator str = ‘a’ + ‘b’ + ‘c’;
The += operator str = ‘a’;
str += ‘b’;
str += ‘c’;
array.join() str = [‘a’, ‘b’, ‘c’].join(‘’)
string.concat() str = ‘a’;
str = str.concat(‘b’, ‘c’);

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

一般来说,可以直接使用,不需要寻找其他方法来优化。但是在一些细节上的处理,可以使加和加等的操作效率最大化。

举个例子:

str += 'one' + 'two'

在这句代码中,会经历四个步骤:

  1. 在内存中创建一个临时字符串
  2. 连接后的字符串 ‘onetwo’ 被赋值给临时字符串
  3. 临时字符串与 str 当前的值连接
  4. 结果赋值给 str

可以通过避免生成临时字符串来提速:

str += 'one';
str += 'two';

甚至这么做:

str = str + 'one' + 'two';

上面这句赋值表达式由 str 开始作为基础,每次给它附加一个字符串,由左向右依次连接。
(注意,如果变换了顺序,如 str = ‘one’ + str + ‘two’,优化就会失效。)

数组项合并

Array.prototype.join 在IE 7-才比较快