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 性能。