JavaScript原型链,范围链和性能:你需要知道的

本文概述

JavaScript:不仅仅吸引眼球

首先, JavaScript似乎是一种非常容易学习的语言。也许是因为其灵活的语法。也许是因为它与Java等其他知名语言的相似之处。也许是因为与Java, Ruby或.NET等语言相比, 它的数据类型很少。

但是, 实际上, JavaScript比大多数开发人员最初意识到的要简单得多, 也没有太多细微差别。即使对于有更多经验的开发人员, JavaScript的一些最突出的功能仍会被误解并导致混乱。这样的功能之一就是执行数据(属性和变量)查找的方式以及需要注意的JavaScript性能后果。

在JavaScript中, 数据查找受两件事控制:原型继承和作用域链。作为开发人员, 必须清楚地了解这两种机制, 因为这样做可以改善代码的结构, 并且通常可以改善代码的性能。

通过原型链进行属性查找

当使用基于原型的语言(例如JavaScript)访问属性时, 将进行动态查找, 该查找涉及对象原型树中的不同层。

在JavaScript中, 每个函数都是一个对象。使用new运算符调用函数时, 将创建一个新对象。例如:

function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

var p1 = new Person('John', 'Doe');
var p2 = new Person('Robert', 'Doe');

在上面的示例中, p1和p2是两个不同的对象, 每个对象都是使用Person函数作为构造函数创建的。它们是Person的独立实例, 如以下代码片段所示:

console.log(p1 instanceof Person); // prints 'true'
console.log(p2 instanceof Person); // prints 'true'
console.log(p1 === p2);            // prints 'false'

由于JavaScript函数是对象, 因此它们可以具有属性。每个函数具有的一个特别重要的属性称为原型。

原型本身就是一个对象, 它是从其父级的原型继承而来的, 该原型是从其父级的原型继承而来的, 依此类推。这通常称为原型链。 Object.prototype始终位于原型链的末尾(即在原型继承树的顶部), 其中包含诸如toString(), hasProperty(), isPrototypeOf()等方法。

JavaScript原型和范围链之间的关系很重要

每个函数的原型可以扩展为定义自己的自定义方法和属性。

当实例化一个对象时(通过使用new运算符调用该函数), 该对象将继承该函数原型中的所有属性。但是请记住, 这些实例将不能直接访问原型对象, 而只能直接访问其属性。例如:

// Extending the Person prototype from our earlier example to
// also include a 'getFullName' method:
Person.prototype.getFullName = function() {
  return this.firstName + ' ' + this.lastName;
}

// Referencing the p1 object from our earlier example
console.log(p1.getFullName());            // prints 'John Doe'
// but p1 can’t directly access the 'prototype' object...
console.log(p1.prototype);                // prints 'undefined'
console.log(p1.prototype.getFullName());  // generates an error

这里有一个重要且有点微妙的要点:即使p1是在定义getFullName方法之前创建的, 它仍然可以访问, 因为它的原型是Person原型。

(值得注意的是, 浏览器还在__proto__属性中存储了对任何对象的原型的引用, 但是通过__proto__属性直接访问原型是一种非常糟糕的做法, 因为它不是标准ECMAScript语言规范的一部分, 所以请不要不做!)

由于Person对象的p1实例本身无法直接访问原型对象, 因此, 如果要覆盖p1中的getFullName, 我们可以这样做:

// We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist:

p1.getFullName = function(){
  return 'I am anonymous';
}

现在p1具有自己的getFullName属性。但是p2实例(在我们之前的示例中创建)没有任何此类属性。因此, 调用p1.getFullName()会访问p1实例本身的getFullName方法, 而调用p2.getFullName()会将原型链向上移至Person原型对象, 以解析getFullName:

console.log(p1.getFullName()); // prints 'I am anonymous'
console.log(p2.getFullName()); // prints 'Robert Doe'
在此JavaScript原型示例中,了解P1和P2与Person原型之间的关系。

要注意的另一件事是, 还可以动态更改对象的原型。例如:

function Parent() {
  this.someVar = 'someValue';
};

// extend Parent’s prototype to define a 'sayHello' method
Parent.prototype.sayHello = function(){
    console.log('Hello');
};

function Child(){
  // this makes sure that the parent's constructor is called and that
  // any state is initialized correctly. 
  Parent.call(this);
};

// extend Child's prototype to define an 'otherVar' property...
Child.prototype.otherVar = 'otherValue';

// ... but then set the Child's prototype to the Parent prototype
// (whose prototype doesn’t have any 'otherVar' property defined, //  so the Child prototype no longer has ‘otherVar’ defined!)
Child.prototype = Object.create(Parent.prototype);

var child = new Child();
child.sayHello();            // prints 'Hello'
console.log(child.someVar);  // prints 'someValue'
console.log(child.otherVar); // prints 'undefined' 

使用原型继承时, 记住从父类继承或指定备用原型后, 请在原型中定义属性。

此图显示了原型链中JavaScript原型之间的关系的示例。

总而言之, 通过JavaScript原型链进行的属性查找如下:

  • 如果对象具有具有给定名称的属性, 则返回该值。 (hasOwnProperty方法可用于检查对象是否具有特定的命名属性。)
  • 如果对象不具有named属性, 则检查对象的原型
  • 由于原型也是对象, 因此如果原型也不包含该属性, 则将检查其父代的原型。
  • 此过程将继续执行原型链, 直到找到该属性。
  • 如果到达Object.prototype并且它也不具有该属性, 则该属性被视为未定义。

通常, 了解原型继承和属性查找的工作方式对开发人员来说很重要, 但由于其JavaScript性能(有时很重要)的影响, 也是至关重要的。如V8文档(Google的开源, 高性能JavaScript引擎)所述, 大多数JavaScript引擎都使用类似于字典的数据结构来存储对象属性。因此, 每个属性访问都需要在该数据结构中进行动态查找以解析该属性。这种方法通常使访问JavaScript中的属性比访问诸如Java和Smalltalk这样的编程语言中的实例变量慢得多。

通过作用域链进行可变查找

JavaScript中的另一种查找机制是基于范围的。

要了解其工作原理, 有必要介绍执行上下文的概念。

在JavaScript中, 执行上下文有两种类型:

  • 全局上下文, 在启动JavaScript进程时创建
  • 局部上下文, 在调用函数时创建

执行上下文被组织到一个堆栈中。在堆栈的底部, 总是有全局上下文, 这对于每个JavaScript程序都是唯一的。每次遇到函数时, 都会创建一个新的执行上下文并将其推入堆栈的顶部。函数执行完毕后, 其上下文将从堆栈中弹出。

考虑以下代码:

// global context
var message = 'Hello World';

var sayHello = function(n){
  // local context 1 created and pushed onto context stack
  var i = 0;
  var innerSayHello = function() {
    // local context 2 created and pushed onto context stack
    console.log((i + 1) + ':  ' + message);
    // local context 2 popped off of context stack
  }
  for (i = 0; i < n; i++) {
    innerSayHello();
  }
  // local context 1 popped off of context stack
};

sayHello(3);
// Prints:
// 1:  Hello World
// 2:  Hello World
// 3:  Hello World

在每个执行上下文中都有一个称为作用域链的特殊对象, 该对象用于解析变量。范围链本质上是从最直接上下文到全局上下文的当前可访问范围的堆栈。 (更确切地说, 位于堆栈顶部的对象称为激活对象, 其中包含对正在执行的函数的局部变量的引用, 命名的函数参数以及两个”特殊”对象:this和arguments。 ) 例如:

该JavaScript示例概述了作用域链与对象的关联方式。

请注意, 在上图中, 默认情况下它如何指向窗口对象, 以及全局上下文如何包含其他对象(例如控制台和位置)的示例。

尝试通过作用域链解析变量时, 将首先检查立即上下文以查找匹配的变量。如果找不到匹配项, 则检查作用域链中的下一个上下文对象, 依此类推, 直到找到匹配项。如果找不到匹配项, 则引发ReferenceError。

同样重要的是, 当遇到try-catch块或with块时, 会将新的作用域添加到作用域链。在这两种情况下, 都会创建一个新对象并将其放置在作用域链的顶部:

function Person(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
};

function persist(person) {
  with (person) {
    // The 'person' object was pushed onto the scope chain when we
    // entered this "with" block, so we can simply reference
    // 'firstName' and 'lastName', rather than person.firstName and
    // person.lastName
    if (!firstName) {
      throw new Error('FirstName is mandatory');
    }
    if (!lastName) {
      throw new Error('LastName is mandatory');
    }
  }
  try {
    person.save();
  } catch(error) {
    // A new scope containing the 'error' object is accessible here
    console.log('Impossible to store ' + person + ', Reason: ' + error);
  }
}

var p1 = new Person('John', 'Doe');
persist(p1);

为了完全理解基于范围的变量查找是如何发生的, 重要的是要记住, 在JavaScript中当前没有块级范围。例如:

for (var i = 0; i < 10; i++) {
  /* ... */
}
// 'i' is still in scope!
console.log(i);  // prints '10'

在大多数其他语言中, 上面的代码将导致错误, 因为变量i的”寿命”(即作用域)将限于for块。但是, 在JavaScript中并非如此。而是, 我被添加到作用域链顶部的激活对象中, 它将一直停留在该对象中, 直到将该对象从作用域中删除为止, 这是在将相应的执行上下文从堆栈中删除时发生的。此行为称为可变提升。

不过, 值得注意的是, 对块级范围的支持正通过新的let关键字进入JavaScript。 let关键字已经在JavaScript 1.7中可用, 并且从ECMAScript 6开始将成为正式支持的JavaScript关键字。

JavaScript性能后果

分别使用原型链和作用域链的属性和变量查找在JavaScript中的工作方式是该语言的主要功能之一, 但它也是最棘手且最难以理解的语言之一。

每次访问属性或变量时, 都会重复执行本示例中描述的查找操作(无论是基于原型链还是范围链)。当此查找发生在循环或其他密集操作中时, 它可能会对JavaScript性能产生重大影响, 尤其是考虑到该语言的单线程性质, 它可以防止多个操作同时发生。

考虑以下示例:

var start = new Date().getTime();
function Parent() { this.delta = 10; };

function ChildA(){};
ChildA.prototype = new Parent();
function ChildB(){}
ChildB.prototype = new ChildA();
function ChildC(){}
ChildC.prototype = new ChildB();
function ChildD(){};
ChildD.prototype = new ChildC();
function ChildE(){};
ChildE.prototype = new ChildD();

function nestedFn() {
  var child = new ChildE();
  var counter = 0;
  for(var i = 0; i < 1000; i++) {
    for(var j = 0; j < 1000; j++) {
      for(var k = 0; k < 1000; k++) {
        counter += child.delta;
      }
    }
  }
  console.log('Final result: ' + counter);
}

nestedFn();
var end = new Date().getTime();
var diff = end - start;
console.log('Total time: ' + diff + ' milliseconds');

在此示例中, 我们有一个长继承树和三个嵌套循环。在最深的循环内, 计数器变量将随delta的值递增。但是增量几乎位于继承树的顶部!这意味着每次访问child.delta时, 都需要从下到上导航整个树。这可能会对性能产生真正的负面影响。

理解这一点, 我们可以通过使用局部增量变量将值缓存在child.delta中(从而避免重复遍历整个继承树)来轻松提高上述nestedFn函数的性能, 如下所示:

function nestedFn() {
  var child = new ChildE();
  var counter = 0;
  var delta = child.delta;  // cache child.delta value in current scope
  for(var i = 0; i < 1000; i++) {
    for(var j = 0; j < 1000; j++) {
      for(var k = 0; k < 1000; k++) {
        counter += delta;  // no inheritance tree traversal needed!
      }
    }
  }
  console.log('Final result: ' + counter);
}

nestedFn();
var end = new Date().getTime();
var diff = end - start;
console.log('Total time: ' + diff + ' milliseconds');

当然, 只有在知道for循环执行时child.delta的值不会改变的情况下, 这种特殊技术才可行。否则, 本地副本将需要使用当前值进行更新。

好的, 我们运行两个版本的nestedFn方法, 看看两者之间是否存在明显的性能差异。

我们将从在node.js REPL中运行第一个示例开始:

[email protected]:~$ node test.js 
Final result: 10000000000
Total time: 8270 milliseconds

这样大约需要8秒钟。好久不见

现在让我们看看运行优化版本时会发生什么:

[email protected]:~$ node test2.js 
Final result: 10000000000
Total time: 1143 milliseconds

这次只花了一秒钟。快多了!

请注意, 使用局部变量来避免昂贵的查找是一种可同时用于属性查找(通过原型链)和变量查找(通过范围链)的技术。

此外, 在使用某些最常见的JavaScript库时, 这种类型的值”缓存”(即, 在本地范围内的变量中)也可能是有益的。以jQuery为例。 jQuery支持”选择器”的概念, “选择器”基本上是一种用于检索DOM中一个或多个匹配元素的机制。可以在jQuery中指定选择器的简便性可能会导致人们忘记每个选择器查找的代价(从性能的角度来看)。因此, 将选择器查找结果存储在局部变量中可能对性能极为有利。例如:

// this does the DOM search for $('.container') "n" times
for (var i = 0; i < n; i++) {
    $('.container').append("Line "+i+"<br />");
}

// this accomplishes the same thing...
// but only does the DOM search for $('.container') once, // although it does still modify the DOM "n" times
var $container = $('.container');
for (var i = 0; i < n; i++) {
    $container.append("Line "+i+"<br />");
}

// or even better yet...
// this version only does the DOM search for $('.container') once
// AND only modifies the DOM once
var $html = '';
for (var i = 0; i < n; i++) {
    $html += 'Line ' + i + '<br />';
}
$('.container').append($html);

尤其是在包含大量元素的网页上, 上面的代码示例中的第二种方法可能比第一种方法具有明显更好的性能。

本文总结

JavaScript中的数据查找与大多数其他语言中的数据查找有很大不同, 并且非常细微。因此, 至关重要的是要完全正确地理解这些概念, 以便真正掌握该语言。应尽可能避免数据查找和其他常见的JavaScript错误。这种理解可能会产生更干净, 更健壮的代码, 从而提高JavaScript性能。

相关:作为JS开发人员, 这就是让我彻夜难眠/弄清ES6类混乱的原因

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?