JavaScript 原型链、作用域链和性能:你需要知道的
已发表: 2022-03-11JavaScript:不止于眼前
起初,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 函数是对象,因此它们可以具有属性。 每个函数都有一个特别重要的属性称为prototype
。
prototype
本身是一个对象,它继承自其父原型的原型,而原型又继承自其父原型的原型,以此类推。 这通常被称为原型链。 Object.prototype
始终位于原型链的末端(即原型继承树的顶部),包含诸如toString()
、 hasProperty()
、 isPrototypeOf()
等方法。
每个函数的原型都可以扩展来定义自己的自定义方法和属性。
当您实例化一个对象(通过使用new
运算符调用函数)时,它会继承该函数原型中的所有属性。 但是请记住,这些实例不能直接访问prototype
对象,而只能访问其属性。 例如:
// 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
实例本身并不能直接访问prototype
对象,因此如果我们想覆盖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'
另一个需要注意的重要事情是动态更改对象的原型也是可能的。 例如:
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 原型链进行属性查找的工作如下:
- 如果对象具有给定名称的属性,则返回该值。 (
hasOwnProperty
方法可用于检查对象是否具有特定的命名属性。) - 如果对象没有命名属性,则检查对象的原型
- 由于原型也是一个对象,如果它也不包含该属性,则检查其父原型。
- 这个过程继续原型链,直到找到属性。
- 如果达到
Object.prototype
并且它也没有该属性,则该属性被认为是undefined
。
了解原型继承和属性查找的工作原理对开发人员来说通常很重要,但也很重要,因为它(有时很重要)对 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
。 ) 例如:

请注意上图中this
默认如何指向window
对象,以及全局上下文如何包含其他对象的示例,例如console
和location
。
当试图通过作用域链解析变量时,首先检查直接上下文是否有匹配的变量。 如果未找到匹配项,则检查作用域链中的下一个上下文对象,依此类推,直到找到匹配项。 如果未找到匹配项,则会引发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 中,情况并非如此。 相反, i
被添加到作用域链顶部的激活对象中,并且它将一直保留在那里,直到该对象从作用域中删除,这发生在相应的执行上下文从堆栈中删除时。 这种行为称为变量提升。
不过值得注意的是,对块级作用域的支持正在通过新的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
的值递增。 但是delta
几乎位于继承树的顶部! 这意味着每次访问child.delta
时,都需要从下到上导航整个树。 这会对性能产生非常负面的影响。
了解了这一点,我们可以很容易地通过使用局部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 中运行第一个示例开始:
diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds
所以运行大约需要 8 秒。 那是很长一段时间。
现在让我们看看当我们运行优化版本时会发生什么:
diego@alkadia:~$ 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 性能。