- A+
经典计算机科学的一个问题是确定数据应当存放在什么地方,以实现最佳的读写效率。数据存储在哪里,关系到代码运行期间数据被检索到的速度。
在JavaScript中只有四种基本的数据访问位置:
- 字面量:包括字符串、数字、布尔、RegExp(/xxx/gi)、Array([])、Object({})、undefined、null。
- 变量:使用var关键字创建用于存储数据值。
- 数组元素:具有数字索引。
- 对象成员:具有字符串索引。
大多数的浏览器字面量和局部变量的访问速度要快于数据元素和对象成员的访问速度。所以如果关心运行速度,应该尽量使用直接量和局部变量。
管理作用域
作用域概念对理解JavaScript而言十分重要。作用域不仅决定了哪些变量能够被函数访问,this指向哪里,而且也关系到了程序的性能。
作用域链和标识符解析
每个函数都是Function的实例。函数对象和其他对象一样,拥有可以编程访问的属性,还拥有一些仅供JavaScript引擎使用的内部属性。[[Scope]]就是这样一个属性,它在ECMA-262的第三版中定义。
[[Scope]]属性包含一个函数被创建的作用域中的对象集合。该集合被称为函数的作用域链,它决定着哪些数据可由函数访问。此函数作用域链中的每个对象被称为一个可变对象,每个可变对象都以“键值对”的形式存在。当一个函数创建后,它的作用域链被填充以对象,这些对象代表创建此函数的环境中可以访问的数据。
请看以下示例:
function add(num1, num2) { var sum = num1 + num2; return sum; }
add()是一个全局函数。当add()函数创建后,它的作用域链中填入一个单独的可变对象:用于表示所有全局范围定义的变量的全局对象。这个全局对象包含window、navigator和document等的访问接口。
假设运行下面的代码:
var total = add(1, 2);
在运行add()函数时将用到它的作用域链。运行函数时会创建一个内部对象,叫做执行上下文(execution context)。执行上下文定义了函数运行时的环境,每次运行函数都会创建一个单独的执行上下文。函数执行完毕后,执行上下文即被销毁。
执行上下文拥有自己的作用域链,用于标识符解析。当执行上下文被创建,它的作用域链就被包含在函数[[Scope]]属性中的对象初始化。这些对象将以他们在函数中的顺序被复制到执行上下文的作用域链中。
一旦复制完成,就会为执行上下文创建一个被称作“活跃对象(activation object)”的新对象。活跃对象作为函数执行过程中的可变对象,它包含了所有本地变量、命名参数、参数集合和this的接口。然后,这个对象被放到执行上下文作用域链的最前端。当执行上下文被销毁时,活跃对象也一同被销毁。
当函数运行时,它的执行上下文的作用域链中应该存在两个可变对象:第1个是活跃对象,第2个是全局对象。
在函数执行过程中,每遇到一个变量,标识符解析过程决定了从哪里获取或存储数据。在这个过程中,会在执行上下文的作用域链中搜索具有相同名称的标识符。从作用域链的前端(即活跃对象)开始搜索。如果找到同名标识符则使用该变量;如果没找到,则继续搜索作用域链中的下个对象。这个过程直到找到标识符或者作用域链中没有更多的可变对象用于搜索时截止(此时的标识符就是undefined)。
在作用域链中搜索标识符的过程影响了程序的性能。
标识符解析性能
在执行上下文的作用域链中,一个标识符所处的位置越深,它的读写速度越慢。所以,函数中局部变量的访问速度总是最快的,而全局变量通常是最慢的。因为全局变量总是处于作用域链的最后一个位置。
一个好的经验法则是:用局部变量存储本地范围之外的变量值,如果它们在函数中的使用多于一次。
考虑下面的例子:
function initUI() { var bd = document.body, links = document.getElementsByTagName("a"), i = 0, len = links.length; while (i < len) { update(links[i++]); } document.getElementById("go-btn").onclick = function() { start(); }; bd.className = "active"; }
上面的函数中包含三个对document的引用,document是一个全局对象。搜索此变量,必须遍历整个作用域链,直到最后在全局变量对象上找到它。可以通过这种方法减轻重复的全局变量访问对性能的影响:首先将全局变量的引用存储在一个局部变量中,然后使用这个局部变量代替全局变量。这种方式尤其适用于需要不断重新访问同一个全局变量的情况。上面的函数可以重写为:
上面的函数中包含三个对document的引用,document是一个全局对象。搜索此变量,必须遍历整个作用域链,直到最后在全局变量对象上找到它。可以通过这种方法减轻重复的全局变量访问对性能的影响:首先将全局变量的引用存储在一个局部变量中,然后使用这个局部变量代替全局变量。这种方式尤其适用于需要不断重复访问同一个全局变量的情况。上面的函数可以重写为:
function initUI() { var doc = document, var bd = doc.body, links = doc.getElementsByTagName("a"), i = 0, len = links.length; while (i < len) { update(links[i++]); } doc.getElementById("go-btn").onclick = function() { start(); }; bd.className = "active"; }
改变作用域链
能够改变作用域链的方式有两种:一是with表达式,二是try-catch中的catch子句。
使用with表达式重写initUI()函数:
function initUI() { with(document) { var bd = body, links = getElementsByTagName("a"), i = 0, len = links.length; while (i < len) { update(links[i++]); } getElementById("go-btn").onclick = function() { start(); }; bd.className = "active"; } }
使用with表达式,避免多次书写“document”。这看起来似乎更有效率,而实际上却产生了一个性能问题。
在代码运行到with表达式之前,initUI()函数的执行上下文的作用域链中只有两个对象,一个是在前端的活动对象,一个是在后端的全局对象。当代码执行到with表达式时,这时执行上下文的作用域链被临时改变了:一个新的可变对象被创建,它包含with表达式对象的所有属性,并且该对象被插入到作用域链的前端。这样函数执行上下文的作用域链中有三个对象:第一个是with变量对象,第二个是活动对象,第三个是全局对象。这样,虽然访问document的属性速度变快了,但所有对函数局部变量的访问代价变慢了。
所以,最好不要使用with表达式。只要简单地将document存储在一个局部变量中,就可以获取性能上的提升。
try-catch表达式的catch子句同样可以认为地改变执行上下文的作用域链。当try块发生错误时,程序流程自动转入catch块,并将异常对象推入作用域链前端的一个可变对象中。在catch块中,函数的所有局部变量被放在第二个作用域链对象中。只要catch子句执行完毕,作用域链就会返回到原来的状态。
如果使用得当,try-catch表达式是非常有用的语句,不建议完全避免。可以通过精缩代码的办法最小化catch子句对性能的影响。一个很好的模式是将错误交给一个专用函数来处理。如下所示:
try { //... } catch(ex) { handleError(ex); }
由于只有一条语句,没有局部变量访问,作用域链临时改变就不会影响代码的性能。
动态作用域
with表达式和try-catch表达式的catch子句以及包含()的函数,都被认为是动态作用域。一个动态作用域只因代码运行而存在,无法通过静态分析(查看代码结构)来确定。
有些优化的JavaScript引擎,例如Safari的Nitro引擎,企图通过代码分析来确定哪些变量应该在任意时刻被访问,来加快标识符识别过程。这些引擎企图避开传统作用域链查找,而是采用标识符索引的方式快速查找。当遇到动态作用域后,这种优化方法就不起作用了。引擎需要切回慢速的基于哈希表的标识符识别方法。
因此,为了能够使用优化后的标识符查找方式,只在绝对必要时才推荐使用动态作用域。
下面是使用()实现动态作用域的例子:
function execute(code) { (code); function subroutine() { return window; } var w = subroutine(); return w; }
大多数情况下,window等于全局的window对象,但请考虑下面的情况:
var win = execute("var window = {name:'mywindow'};");
这种情况下,window是个局部变量。
注意:上述execute()函数中的“(code);”需要改成“eval(code);”,否则传入的JavaScript语句不会被计算。
闭包、作用域和内存
闭包的使用通过Douglas Crockford的著作流行起来,当今在最复杂的网页应用中无处不在。不过,闭包也与程序的性能有关。
请看下面的例子:
function assignEvents() { var id = "abc"; document.getElementById("save-btn").onclick = function(event) { saveDocument(id); }; }
上述代码中的事件处理句柄就是一个闭包。这个闭包在assignEvents()执行时创建,并且能够访问其包含作用域的id变量。为了能够让闭包访问id,创建了一个特定的作用域链。
当assignEvents()被执行时,它的执行上下文的作用域链包含了两个对象:第一个是活动对象,第二个是全局对象。当闭包被创建时,闭包的[[Scope]]属性则被assignEvents()的作用域链上的两个对象初始化。也就是说,闭包的[[Scope]]属性与assgnEvents()执行上下文作用域链包含相同的对应引用。这样就会产生问题。通常,函数的活动对象与执行上下文一同销毁。但当涉及闭包时,活动对象就无法销毁了,因为引用仍然存在于闭包的[[Scope]]属性中。这意味着,使用闭包的函数与不使用闭包的函数相比,需要更多内存开销。在大型网页应用中,这可能是个问题。IE使用非本地JavaScript对象实现DOM对象,闭包可能导致内存泄露。
当闭包被执行时,一个执行上下文将被创建,它的作用域链被闭包的[[Scope]]中作用域链的两个对象初始化,然后一个针对闭包自身的新的活跃对象被插入到闭包的执行上下文作用域链的前端。因此,闭包的执行上下文作用域链包含三个对象:第一个是闭包自身的活跃对象,第二个是闭包外围函数的活跃对象,第三个是全局对象。
因此,每次闭包中的代码访问其作用域之外的标识符都会导致一些性能损失。
为了减轻闭包内访问域外变量的性能影响,可以将常用的域外变量存入局部变量中,然后直接访问局部变量。
对象成员
对象成员比字面量或局部变量访问速度慢,在某些浏览器上比访问数组元素还要慢。如要理解其中的原因,我们需要明白对象成员是如何访问的。
原型(Prototype)
JavaScript中的对象是基于原型的。原型是其他对象的基础,定义并实现了一个新对象所必须的具有的成员。这一概念完全不同于传统面向对象编程中“类”的概念。原型对象被所有给定类型的对象实例所共享,因此所有实例共享原型对象成员。
更多有关原型的介绍可以参看ECMAScript概述和JavaScript 中的__proto__和prototype。
创建内置类型的实例时,该实例会自动拥有一个Object作为它的原型。
处理对象成员的过程与变量处理十分相似。
