JavaScript 数据访问(翻译自High Performance Javascript 第二章)

  计算机科学中一个经典的问题是决定如何存储数据,以便进行快速的读取和写入操作。 在代码执行期间,数据如何存储将会关系到它们的检索速度。在Javascript中,由于只存在少数的操作来进行数据存储, 这个问题似乎 变得简单了。但是,与其他语言一样,Javascript中数据的存储方式将决定它们访问速度。下面是Javascript中可以进行数据存储的四种基本方式:

  • 字面量值(Literal values)
    任何仅仅描述自身,且没有被存储在一个特定位置上的值。Javascript可以将字符串、数字、布尔值、对象、数组、函数、正则表达式以及特殊值null和undefined作为字面量。

  • 变量
    任何开发者使用var关键字定义的数据存储位置。
  • 数组项
    Javascript数组中使用数字进行索引的位置。
  • 对象成员
    Javascript对象中使用字符串进行索引的位置。

  对于上述数据存储位置而言,它们每个都有其特定的读写花费。虽然实际上的性能差异是强烈依赖于代码所运行的浏览器的。但在大多数情况下,从字面量访问信息与从本地变量访问信息的性能差异是微不足道的。而数组项和对象成员的访问则较昂贵。



  虽然某些JS引擎对数组项访问进行了优化,使其能变得更快。但即使如此,通常的建议是尽可能的使用字面值和本地变量,并限制数组项和对象成员的使用. 为了达到这个目的,有如下几个模式可用来查找和避免问题,并优化你的代码。

  一、管理作用域(Manaing Scope)

  在Javascript中,作用域(Scope)是一个关键的概念。其不仅是从性能的角度,而且也从函数的角度解释了各种问题。作用域在Javascript中产生了诸多影响,从确定函数可以访问那些变量到this上值的分配。在使用Javascript 作用域的时候,也有一些性能上的考虑。但为了理解其如何关联到速度上,首先需要理解作用域是如何工作的。

  • 作用域链(Scope Chain)与标识符解析
      Javascript中的每个函数都被表示成一个对象——更具体的说,是作为函数的实例。就像其他对象一样,函数对象也可以包含属性(properties),这些属性包括可以编程访问的常规属性以及一系列Javascript引擎所使用到的内部属性。内部属性无法通过代码来访问。其中一个内部属性是在ECMA-262,第三版规范中定义的[[Scope]]属性。
      [[Scope]]内部属性包含了函数被创建时表示其所在作用域的对象集合(The internal [[Scope]] property contains a collection of objects representing the scope in which the function was created)。该集合被称为函数的作用域链,它决定了一个函数所能访问到的数据。函数作用域链中的每个对象都称为可变对象。每个可变对象包含一些键值对(Key-Value Pairs)。当一个函数被创建时,它的作用域链会填充一些在其创建环境内可以访问到的数据对象。例如,请考虑下面的全局函数:
    function add(num1, num2){
    	var sum = num1 + num2;
    	return sum;
    }
      当 add() 函数被创建时,他的作用域链将会填充一个单独的可变对象:即全局范围内包含所有值的全局对象(global object)、该全局对象包含了诸如window、navigator和document等。下图显示了该关系(注意,图中的全局对象只显示了部分属性值,但实际上它还包含了许多其他属性):
     
    add 函数的作用域链将会在其执行时用到。例如假设运行以下代码:
    var total = add(5, 10);
      执行add函数的时候,将会创建一个称为执行上下文(execution context)的内部对象。执行上下文定义了函数执行的环境。每个执行上下文都是唯一的,所以对相同函数的多次调用将会产生多个执行上下文。当函数执行完成后,执行上下文将会被销毁。
      一个执行上下文自身也包含了作用域链,该作用域链将用来进行标识符解析。当执行上下文创建时,首先会把其执行函数的[[Scope]] 属性中的对象复制到自身的作用域链中。该复制过程将会以对象在 [[Scope]]属性中出现的位置依次进行。当该过程完毕后,将会为执行上下文创建一个称为激活对象(activation object)的新对象。该激活对象包含了所有的本地变量,命名参数,参数集合(arguments)以及this。接着,激活对象将会被推入作用域链的最顶端,作为该次执行中的可变对象。当执行上下文被销毁时,该激活对象也同时销毁。下图显示了前面代码中的执行上下文和作用域链。


      在函数执行时,每遇到一个变量,将会产生一个标识符解析的过程,该过程将决定数据检索和存储的位置。在这个过程中,将会在执行上下文的作用域链中查找一个与变量名称相同的标识符。查找将会从作用域链的顶端开始(即激活对象),依次遍历作用域链。当找到相同名称的标识符时,将使用该标识符。而当遍历完整个作用域链后均没有找到标识符时,标识符将会被就看做是未定义的(undefined)。函数执行时,每个标识符的查找都将经历上面的过程。以前面的例子来说,add函数中的sum、num1和num2将会产生这一查找过程。而正是这个搜索过程影响了性能。
      注意在作用域链中不同的部分可能会存在两个名称相同的变量。此时,标识符解析将会以首先找到的对象为准。而后面部分中的对象将会被遮蔽(shadow)。
  • 标识符解析的性能
      标识符解析并不是不消耗资源的,因为事实上有没哪项计算操作可以不产生性能开销。当在执行上下文的作用域链中进行深度查找时,读写操作将会变得缓慢。因此,本地变量是函数内部访问数据最快的方式。而一般情况下全局变量的访问则是最慢的(优化过的Javascript引擎会在一些条件下优化该过程)。请记住,全局变量总是处于执行上下文的作用域链中最后一个,所以总是产生最多的解析花费。下面2张图显示了标识符在作用域链上不同深度的解析速度。深度为1则表示本地变量。

    读操作:
      

    写操作:
      

      对所有浏览器而言,总的趋势是标识符在作用域链中的位置越深,它的读写操作将会变得更慢。虽然一些优化过Javascript引擎的浏览器,例如Chrome和Safari 4在访问外部作用域(out-of-scope)中的标识符时并没有这种性能损耗,然而IE、Safari 3.2以及其他浏览器则产生了较大的影响。值得一提的时,一些早期的浏览器,例如IE 6以及Firefox 2将会产生非常大的性能差距。
      有了这些信息,我们最好尽可能的使用本地变量来在未优化JS引擎的浏览器中增强性能。一个好的经验是当外部作用域的值在函数中使用了不止一次时,总是将其保存为本地变量。请考虑下面的例子:
    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";
    }
    
      该函数包含了3个对document的引用。因为document是全局对象,对该对象的搜索将会遍历整个作用域链。你可以通过将document保存为本地变量来减少重复的全局变量访问,进而增强代码的性能。
    function initUI(){
    	var doc = document,
    	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";
    }
    
      修改过后的initUI()函数会先使用本地对象来保存document的引用。而不是原来那样进行3次全局对象的访问。当然,在这个简单的函数中这么做可能并不会显示出巨大的性能增强,但可以想象,在一个大量编码的函数中许多全局变量被重复访问的情况下,该方式将会带来可观的性能增强。
  • 作用域链扩大(Scope Chain Augmentation)
      一般来说,一个执行上下文的作用域链并不会改变。但是,有两个语句可以在函数执行时临时地扩大执行上下文的作用域链。第一个语句是with。
      With语句可以用来对指定对象的所有属性创建一个默认操作变量。该特性是模仿其他语言中相似的特性。其本意是避免重复编写相同的代码。前面的initUI函数可以被改写为下面这样:
    function initUI(){
    	with (document){ //avoid!
    		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";
    	}
    }
    
      这个修改后的initUI函数使用了with语句来避免到处使用document引用。虽然这看起来似乎更有效率,但它实际上却产生了一个性能问题。
      当代码执行进入with语句时,执行上下文的作用域链将会临时地扩大。这将产生一个新对象,该新对象包含了with语句所指定对象的所有属性值。接着该新对象将会被推入作用域链的顶端。这也意味着此时函数中所有的本地变量变成了作用域链中的次级节点,因此带来了额外的访问开销。

     

      当将document对象传递进with语句时,一个新的包含了document所有属性的可变对象将会被推入作用域链的顶端。这会使对document的访问变得更快但是却降低了对本地变量的访问。处于这个理由,最好避免使用with语句,而是使用前面所述的只是简单地将document存储在一个本地变量中,并以此获得性能上的提升。

      With语句并不是Javascript中唯一一个可以扩大执行上下文的作用域链的方法。Try-catch语句中的catch子句也会产生相同的效果。当try块中出现一个错误时,执行将会自动地转入catch块且异常对象将会被推入一个可变对象,并放在作用域链的顶端。因此在catch块中,函数中所有的本地变量将会变成次级作用域对象。
    try {
    	methodThatMightCauseAnError();
    } catch (ex){
    	alert(ex.message); // 此处作用域链已被扩大
    }
    
      需要注意的是,只要catch子句结束执行,作用域链将会回到前面的状态。
    在使用得当的情况下,try-catch语句是非常有用的。所以完全避免使用try-catch是没有意义的。如果你正准备使用try-catch语句,请确保你理解了出错的可能性。Try-catch语句不应该作为Javascript错误的解决方案。如果你已经知道了一个错误经常出现,那么说明代码本身出现了问题,并且应该被修正。
      你可以通过只在catch子句中执行少量必要的代码来最小化性能冲击。一个好的模式是在catch子句中使用一个委托方法来处理错误。如下面的例子:
    try {
    	methodThatMightCauseAnError();
    } catch (ex){
    	handleError(ex); //delegate to handler method
    }
    
      此处的catch子句中只使用一个handleError()方法来处理。而handleError可以自由地选择适宜的处理方式。因为此时只包含了单条语句执行并且没有本地变量地访问,临时的作用域链扩大并没有影响代码的性能。
  • 动态作用域
      with语句和try-catch中的catch子句,以及一个包含evel()调用的函数, 均被认为是动态作用域。动态作用域只存在于代码执行期间,因此并不能简单地通过静态分析(查看代码结构)来决定。例如:
    function execute(code) {
    	eval(code);
    	function subroutine(){
    		return window;
    	}
    	var w = subroutine(); //w 如何取值?
    };
    
      这里execute()函数使用到了evel()函数,因此它是一个动态作用域。此处w的值是否改变是基于参数code的值。在大多数情况下,w将等于全局对象window,但请考虑下面的代码:
    execute("var window = {};")
      在这种情况下,evel()在execute()内部创建了一个名为window的本地变量。所以w也最终等于该本地变量而非全局的window。这种情况在代码执行之前是无法知晓,也意味着标识符window的值无法预先决定。
      某些优化后的Javascript引擎,例如Safari’s Nitro试图通过分析代码中给定时间内那些变量可以被访问来加速标识符解析。这些引擎尝试使用索引标识符的方式来加速解析过程,并避免采用传统的作用域链查找. 但是,当出现一个动态作用域时,这些优化技术将变得无效,Javascript引擎不得不切换回较慢的哈希查找方式来进行标识符解析。这更类似于传统的作用域链查找。
      处于这个理由,只应该在绝对必要的情况下使用动态作用域。
  • 闭包,作用域与内存
      闭包是Javascript最强大的方面之一,它允许一个函数访问其本地作用域之外的数据。闭包的使用已由Douglas Crockford所写的文章普及,并且在大多数复杂的Web程序中无处不在。不过,闭包的使用也关联了一些性能影响。为了理解闭包的性能问题,请考虑下面的代码:
    function assignEvents(){
    	var id = "xdi9592";
    	document.getElementById("save-btn").onclick = function(event){
    		saveDocument(id);
    	};
    }
    
      assignEvents函数为DOM元素分配了事件处理器,这个事件处理器既是一个闭包,因为它是在assignEvents执行时创建的,但能够在其包含范围内访问到外部的id变量。为了使这个闭包访问到id变量,Javascript引擎必须创建一个特殊的作用域链。
      当assignEvents执行时,将会创建一个只包含ID变量的激活对象。该激活对象将会成为执行上下文的作用域链中的第一个对象,全局对象后处在第二位。当闭包被创建时,它的 [[Scope]]属性将被初始化赋值为与外部执行上下文相同的对象。如下图所示:

     
      因为闭包的[[Scope]]属性包含了其外部环境中对象引用,这也产生了一个负面影响。典型情况下,一个函数的激活对象在其执行上下文销毁时随之销毁,但当有闭包存在时,激活对象并没及时销毁,因为此时闭包的[[Scope]]属性仍然保存这一个对该对象的引用。这也意味着,使用闭包的函数相对于非闭包函数将会带来更多的内存开销。对于大型Web应用而言,这将会是一个问题。 对于IE而言更是如此。IE将DOM对象作为非本地Javascript对象来实现,因而使用闭包时将会可能产生内存泄露。
      当闭包被执行时,将会创建一个执行上下文。该执行上下文的作用域链将会被初始化为其[[Scope]]属性中所引用的对象(此处为2个)。且一个针对闭包自身的激活对象将会被创建。如下图所示:


      请注意,在闭包中所使用到的id和saveDocument等标识符,此时均处于作用域链的尾部,这也是闭包性能问题的主要关注点:你经常访问一个最远距离的标识符,并因此带来了性能损失。
      在编写你自己的脚本时,最好时刻关注闭包的使用,因为他们可能带来内存泄露和执行速度的问题。但是,你可以通过本文前面所讨论的,将外部作用域中常用到的变量存储为本地变量来降低对执行速度的影响。

二、对象成员(Object Members)

  大多数Javascript脚本都使用了面向对象的风格。无论是自定义的对象还是像DOM或BOW(Browser Object Model)中的嵌入对象。在这些情况下,都会产生许多针对对象成员的访问操作。
  在此处,对象成员既指属性,也可以指方法。在Javascript中,对象的属性和方法之间并没有多大的区别。一个对象的命名成员可以包含任何类型的数据。因为函数是被表示为对象的关系,对象成员也可以包含一个函数,就像包含传统数据类型那样。当一个命名成员引用了一个函数时,该成员被称为方法。而当引用的是非函数的数据类型时,该成员被称为属性。
  本文前面曾经讨论过,对象成员的访问是慢于字面量访问和变量访问的。并且在某些浏览器中,它也慢于数组项的访问。为了理解为什么会发生这种情况,首先需要理解Javascript中对象的本质。

  • 原型(Prototypes)
      Javascript中的对象是基于原型的。原型是一个作为其他对象基础的对象,它定义并实现了新对象必须拥有的成员。这与传统面向对象编程中”类”的概念是完全不同的。OOP中的”类”定义的是创建新对象的处理过程。
      对于一个给定的类型,其原型对象被所有的实例所共享,因此所有的实例均可以对原型对象中的成员进行访问。
      对象是使用一个内部属性来关联到其原型的。在Firefox、Safari以及Chrome中,这个属性被开放为 [_proto_] 属性,并允许开发者访问。但其他浏览器则不允许脚本访问该属性。
      由此可见,一个对象自身所包含的成员可以分为两类:实例成员(也称为”所有(own)”成员)和原型成员。实例成员直接存在于对象自身,但原型成员则是继承自原型对象。请考虑下面的例子:
    var book = {
    	title: "High Performance JavaScript",
    	publisher: "Yahoo! Press"
    };
    
    alert(book.toString()); //"[object Object]"
    
      在上面的代码中,book对象拥有两个实例成员:title与publisher。请注意这里并没有定义toString()方法,但在toString()方法调用时并没有出现错误。因为此处的toString()方法是book对象继承的原型成员。下图显示了此关系:

      
      对象成员的解析过程非常相似于变量的解析。当book.toString()被调用时,将会首先从对象自身搜索一个名为”toString”的成员,当没有找到时,将会继续搜索对象的原型对象。而在原型对象中,将会找到并执行这个toString方法。通过这种方式,book对象可以访问其原型对象上的每个属性或方法。
      你可以使用hasOwnProperty方法来确定一个对象是否拥有所给名称的实例成员。当需要确定对象是否可以访问所给名称的成员时(无论是实例成员还是原型成员),可以使用 in 操作。如下所示:
    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
    
      在上面的代码中,因为title是对象的实例成员,所以当传入”title”给hasOwnProperty方法时,该方法返回true。而因为toString是一个原形成员,所以传入“toString”时返回false。但当对二者进行in操作时,均返回true。因为in操作并不区分实例成员和原型成员。
  • 原型链
      对象的原型决定了对象实例的类型。默认情况下,所有的对象均是Object的实例,并因此继承了Object中所有的基础方法。例如toString()。你可以通过定义和使用构造式来创建一个新的原型。如下所示:
    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");
    
    alert(book1 instanceof Book); //true
    alert(book1 instanceof Object); //true
    
    book1.sayTitle(); //"High Performance JavaScript"
    alert(book1.toString()); //"[object Object]"
      Book构造式用来创建一个新的Book实例。此时book1实例的原型(_proto_)为Book.prototype,而Book.prototype的原型则是Object。该过程创建了一个原型链,使得book1和book2继承了该原型链上所有的方法。下图显示了这种关系:


      注意此时Book的2个实例均是共享相同的原型链。每个实例拥有自己的title和publisher属性,但其他所有的属性都是继承而来的。
      现在当book1.toString()被调用时,搜索过程必须深入到原型链的最底层(Object)处来解析toString。如你所料,成员属性在原型链中的位置越深,它的查找速度将会越慢。下图显示了原型链中成员深度与访问时间的关系:

     
      虽然优化过Javascript引擎的浏览器在执行时表现很好,但较老的浏览器,例如IE和Firefox 3.5将会随着原型链查找深度的增加而带来性能损耗。请注意,原型链中成员的查找过程将会比从字面量或变量中访问数据更昂贵。而对原型链进行遍历将会扩大这种差距。
  • 嵌套成员
      因为对象的成员可以包含其他成员,所以经常可以见到诸如window.location.href这类的Javascript代码。这些嵌套成员导致Javascript引擎在每遇到一个点号(.)后都会进行成员解析处理。下图显示了对象成员深度和访问时间之间的关系:

     
      结果并不使人吃惊,成员的嵌套数越多,其数据访问速度将越慢。因此location.href将会快于window.location.href, 相似地,window.location.href将快于window.location.href.toString()。如果这些属性不存在于对象的实例中,成员的解析还将会持续到对象的原型链上。
  • 缓存对象成员的值
      由于对象成员关联了以上性能问题,你应该在可能的情况下避免使用它们。更精确地说,你应该只在必要的情况下使用对象成员。例如,在单个函数中是没有理由从成员变量中进行多于一次的访问操作的。
    function hasEitherClass(element, className1, className2){
    	return element.className == className1 || element.className == className2;
    }
    
      在上面的代码中,对element.className进行了两次访问。明显地,在这段代码的执行过程中,className属性的值将不会改变,但此处却产生了两次成员查找的性能开销。你可以通过将属性值保存为本地变量来减少一次查找过程。
    function hasEitherClass(element, className1, className2){
    	var currentClassName = element.className;
    	return currentClassName == className1 || currentClassName == className2;
    }
    
      上面修改后的函数将对成员的查找减少到了1次。因为两次读取的都是相同的属性值,所以值读取一次并将其保存为本地变量是有意义的。在后面对本地变量的访问操作将会快很多。
      一般来说,如果你在一个函数中多次访问了对象的属性,最好将该属性保存为本地变量。在随后的处理中使用这个本地变量来代替对属性的访问。以此来避免查找过程所带来的性能开销。这在处理嵌套对象成员时尤其重要,它将会对执行速度产生可观的影响。
      Javascript命名空间,例如YUI中所使用的技术,是经常进行嵌套属性访问的来源,如下所示:
    function toggle(element){
    	if (YAHOO.util.Dom.hasClass(element, "selected")){
    		YAHOO.util.Dom.removeClass(element, "selected");
    		return false;
    	} else {
    		YAHOO.util.Dom.addClass(element, "selected");
    		return true;
    	}
    }
    
      上面的代码重复了三次YAHOO.util.Dom的使用,其以此来获取对不同方法的访问。对于每个方法,该操作都产生了3此成员查找。那么总共产生了9次成员查找处理。这使得上述代码效率很低。一个更好的方式是将YAHOO.util.Dom保存为本地变量,并在之后的操作中访问该本地变量。
    function toggle(element){
    	var Dom = YAHOO.util.Dom;
    	if (Dom.hasClass(element, "selected")){
    		Dom.removeClass(element, "selected");
    		return false;
    	} else {
    		Dom.addClass(element, "selected");
    		return true;
    	}
    }
      上面修改后的代码将对成员的查找处理从9次降低到了5次。除了在所需值肯可能变化的情况下,你不应该在单个函数中进行多于一次的对象成员查找。

三、总结

  在Javascript中如何存储和访问数据将会对代码的总体性能产生重要的影响。可以从以下4个地方对数据进行访问:字面量、变量、数组项以及对象成员。这些位置均有不同的性能考虑。

  • 访问字面量以及本地变量的速度是非常快的,数组项和对象成员的访问则较慢。
  • 本地变量的访问将快于外部范围内的变量。因为本地变量存在于函数作用域链中的第一个可变对象内(激活对象)。变量在作用域链中的位置越深。其访问的时间也就越长。因为全局变量处在作用域链中的最后位置,所以对它的访问总是最慢的。
  • 避免使用with语句,因为他们扩大了执行上下文的作用域链。同时,也需要注意try-catch语句中的catch子句,它也会产生相同的效果。
  • 嵌套对象成员遭受着重大的性能影响,应当最小化它的使用。
  • 属性或方法在原型链中的位置越深,访问它们的速度亦越慢。
  • 一般来说,你可以通过将经常使用的对象成员,数组项以及外部变量保存为本地变量来增强代码的性能。针对本地变量的访问将快于原始的访问方式。

  通过使用这些策略,你可以极大地增强Web应用程序的实际性能。对于那些需要大量JavaScript代码的应用而言,性能提升将更加可观。

展开阅读全文

没有更多推荐了,返回首页