高性能JavaScript 笔记之 第2章 数据访问
今天终于是把这本书看完了,每一章都有不小的收获,之后有时间的话会陆续整理出每一章的笔记,^_^
言归正传,这一章讲到的是如何从数据访问层面上提高JS 代码的执行效率。总的来讲有以下几条原则:
- 函数中读写局部变量总是最快的,而全局变量的读取则是最慢的;
- 尽可能地少用with 语句,因为它会增加with 语句以外的数据的访问代价;
- 闭包尽管强大,但不可滥用,否则会影响到执行速度以及内存;
- 嵌套的对象成员会明显影响性能,尽量少用;
- 避免多次访问对象成员或函数中的全局变量,尽量将它们赋值给局部变量以缓存。
这么几句话看似简单,但要深刻理解其中的道理则需涉及到JS的 标识符解析、作用域链、运行期上下文(又称为执行环境)、原型链、闭包 等一系列概念,之前我有看过一篇网上翻译的 JavaScript 闭包,文中讲解了这些东东,但几遍下来还是似懂非懂。然而本书则是图文并茂,很好理解,不由的感慨一下,牛人就是牛~
作用域链和标识符解析
每一个JS 函数都表示为一个对象,该对象有一个内部属性[[Scope]],它包含了一个函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链(Scope chain),它决定哪些数据能被函数访问。函数作用域中的每个对象被称为一个可变对象(variable object)。当一个函数创建后,它的作用域链会被创建此函数的作用域中可访问的数据对象所填充。
例如下面这个全局函数:
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
由于此函数是在全局作用域下创建的,所以函数add() 的作用域链中只填入了一个单独的可变对象——全局对象:
而执行此函数时则会创建一个称为“运行期上下文(execution context)”的内部对象,它定义了函数执行时的环境。函数每次执行时对应的运行期上下文都是独一无二的,所以多次调用同一个函数就会导致创建多个运行期上下文。当函数执行完毕,执行上下文就被销毁。
而我们刚刚讲的函数作用域链和这个运行期上下文有什么关系呢?是这样的,每个运行期上下文都有自己的作用域链,用于标识符解析,而它的作用域链引用的则是函数的内部对象[[Scope]] 所指向的作用域链。此外还会创建一个“活动对象(Activation object)”,该对象包含了函数的所有局部变量、命名参数、参数集合以及this,当函数执行时它又被当作可变对象,和前面是一回事。然后此对象会被推入作用域链的前端,就这样运行期上下文也就创建好了,当运行期上下文被销毁,活动对象也随之销毁(闭包除外,后面会讲到)。
例如前面add() 函数运行时对应的运行期上下文和作用域链:
函数执行时,每遇到一个变量都会进行一次标识符解析的过程,该过程从头至尾搜索作用域链,从当前运行函数的活动对象到全局对象,直至查找到同名的标识符,如果没找到则被认为是undefined。
标识符解析的性能
标识符解析是有代价的,在运行期上下文的作用链中,一个标识符所在的位置越深,它的读写速度也就越慢,显然对局部变量的读写是最快的,因为它所在的对象处于作用链的最前端。一个好的经验法则是:如果某个跨作用域的值在函数中被引用一次以上,那么就把它存储到局部变量里。
此外代码执行时临时改变作用域链也会影响标识符解析的性能,有两个语句会造成这种情况——with 和catch,这两条语句被执行时都会将一个新的可变对象(with 的是语句中指定的对象、catch 的是异常对象)推入作用域链的头部,这样原有的可访问对象都被往后推了一个层次,这使得它们的访问代价更高了。因此对于with 语句最好避免使用,catch 语句要用的话可以定义一函数来进行错误的处理以减少catch 内的语句数量。
下图便是一个在with 语句中改变后的作用域链的例子:
闭包、作用域和内存
理解了作用域链之后,闭包也就好懂了。闭包是JavaScript 最强大的特性之一,它允许函数访问局部作用域之外的数据。然而,有一种性能问题与闭包有关,思考如下代码:
function assignEvents() {
var id = 'xdi9592';
document.getElementById('save-btn').onclick = function(event) {
saveDocument(id);
}
}
函数内部的onclick 事件处理器就是一个闭包,为了能让该闭包访问函数assignEvents() 内的数据,闭包被创建时,它的[[Scope]] 属性被初始化为其外部函数运行时的作用域链中的对象,即闭包的[[Scope]] 属性包含了与运行期上下文作用域相同的对象的引用。
下图为函数assignEvents() 运行期上下文的作用域链和闭包:
通常来说,函数的活动对象会随同运行期上下文一同销毁。但引入闭包时,由于引用仍然存在于闭包的[[Scope]] 属性中,因此激活对象无法被销毁。这意味着脚本中的闭包与非闭包函数相比,需要更多的内存开销。
而当闭包被执行时,一个活动对象会为闭包自身所创建并被置于作用域链的最前端:
此时对于闭包外数据(如id、saveDocument 等) 的访问开销就更大了,因为它们在作用域链的位置均被推后了一个层次。这就是使用闭包最主要的性能关注点:你要经常访问大量跨作用域的标识符,每次访问都会导致性能损失。
对象成员解析、原型、原型链
对象成员指的是对象的属性或方法,当我们要访问TestObj.abc 这样一个对象成员时,首先在标识符解析的过程中找到了TestObj 对象,接下来要访问abc 属性(或是方法)则要进行对象成员解析的过程了。
JavaScript 中的对象是基于原型的,原型是其他对象的基础,它定义并实现一个新对象必须包含的成员列表。对象通过一个内部属性__proto__(这个属性在Firefox、Safari 和Chrome 中对开发者可见) 绑定到它的原型。
对象可以有两种成员类型:实例成员和原型成员。实例成员存在于对象实例中,原型成员则由对象原型继承而来。一旦创建了一个内置对象(如Object 和Array) 的实例,它们就会自动拥有一个Object 实例作为原型,如下面的代码:
var book = {
title : 'High Performance JavaScript',
publisher : 'Yahoo! Press'
}
alert(book.toString()); //"[object Object]"
这个例子中book 并没有定义toString() 方法,然而却能被顺利执行,原因是方法toString() 是由对象book 继承而来的原型成员:
注意book 原型中也有__proto__ 属性,前面也说到了JavaScript 的对象是基于原型的,既然每个对象都具有原型,这自然便形成了一个“链”,我们称之为原型链,原型链终止于原型为null 的那个对象上。而对象成员的解析实际上就是原型链的遍历过程,从实例成员开始查找到原型成员。
此外也可以定义并使用构造器来创建另一种类型的原型,这样则插入了新定义的原型对象至原型链中,考虑下面这个例子:
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');
var book2 = new Book('JavaScript: The Good Parts', 'Yahoo! Press');
这里为Book 对象手动创建了一个原型,并定义了一个方法sayTitle(),对于实例book1 来说原型链是这样的:book1 的原型 -> Book.prototype, Book.prototype 的原型 -> Object, Object 的原型 -> null。
当要访问一个book1 中的一个成员时,检索其原型链的过程则是:首先查找book1 的实例成员(title, publisher),若没找到则接着查找book1 的原型Book.prototype 中的原型成员(sayTitle),若还没到则继续查找Book.prototype 的原型Object 的原型成员(toString, valueOf ...),若仍然没找到,则继续查找Object 的原型,但Object 的原型为null,则查找终止,此时该成员判定为undefined,若该过程中有查找到的话则立即中止查找并返回。原型链的关系如图:
对象成员解析的性能
和标识符解析一样,对象成员的解析也是有开销的,原型链的遍历过程中,每深入一层都会增加性能的损失,于是对象在原型链中存在的位置越深,找到它就越慢。
另外由于对象成员可能包含其他成员,例如window.location.href,每次遇到点操作符,该嵌套成员都会导致JavaScript 引擎搜索所有对象成员,显然对象成员嵌套得越深,访问速度就会越慢,因此尽量少用,例如执行location.href 总是要比window.location.href 要快。
很显然,当要频繁地访问对象成员时,最好用变量将它们缓存起来。