JavaScriptプロトタイプチェーン、スコープチェーン、およびパフォーマンス:知っておくべきこと

公開: 2022-03-11

JavaScript:目に見える以上のもの

JavaScriptは、最初は非常に簡単に習得できる言語のように思えます。 おそらくそれはその柔軟な構文のためです。 あるいは、Javaのような他のよく知られた言語との類似性のためかもしれません。 あるいは、Java、Ruby、.NETなどの言語と比較してデータ型が非常に少ないためかもしれません。

しかし実際には、JavaScriptは、ほとんどの開発者が最初に認識しているよりもはるかに単純ではなく、微妙な違いがあります。 より経験豊富な開発者でさえ、JavaScriptの最も顕著な機能のいくつかは誤解され続けており、混乱を招いています。 そのような機能の1つは、データ(プロパティと変数)のルックアップを実行する方法と、JavaScriptのパフォーマンスへの影響に注意することです。

JavaScriptでは、データルックアップはプロトタイプの継承スコープチェーンの2つによって管理されます。 開発者は、これら2つのメカニズムを明確に理解することが不可欠です。そうすることで、コードの構造を改善し、多くの場合、パフォーマンスを向上させることができるからです。

プロトタイプチェーンを介したプロパティルックアップ

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');

上記の例では、 p1p2は2つの異なるオブジェクトであり、それぞれがコンストラクターとして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() ()などのメソッドを含みます。

JavaScriptプロトタイプとスコープチェーンの関係は重要です

各関数のプロトタイプを拡張して、独自のカスタムメソッドとプロパティを定義できます。

オブジェクトをインスタンス化すると( 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

ここには重要でやや微妙な点がありますgetFullNameメソッドが定義される前にp1が作成された場合でも、そのプロトタイプはPersonプロトタイプであるため、アクセスできます。

(ブラウザはオブジェクトのプロトタイプへの参照も__proto__プロパティに格納することに注意してください。ただし、標準のECMAScript言語仕様の一部ではないため__proto__プロパティを介してプロトタイプに直接アクセスすることは非常に悪い習慣です。やらないで!

Personオブジェクトのp1インスタンス自体はprototypeオブジェクトに直接アクセスできないため、 p1getFullNameを上書きする場合は、次のようにします。

 // 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 p2.getFullName()を呼び出すと、プロトタイプチェーンをPersonプロトタイプオブジェクトに移動して、 getFullNameを解決します。

 console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe' 

このJavaScriptプロトタイプの例で、P1とP2がPersonプロトタイプにどのように関連しているかを確認してください。

注意すべきもう1つの重要な点は、オブジェクトのプロトタイプを動的に変更することも可能であるということです。 例えば:

 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メソッドを使用して、オブジェクトに特定の名前付きプロパティがあるかどうかを確認できます。)
  • オブジェクトに名前付きプロパティがない場合、オブジェクトのプロトタイプがチェックされます
  • プロトタイプもオブジェクトであるため、プロパティも含まれていない場合は、親のプロトタイプがチェックされます。
  • このプロセスは、プロパティが見つかるまでプロトタイプチェーンを上っていきます。
  • Object.prototypeに到達し、プロパティも含まれていない場合、プロパティはundefinedと見なされます。

プロトタイプの継承とプロパティルックアップがどのように機能するかを理解することは、開発者にとって一般的に重要ですが、JavaScriptのパフォーマンスに影響を与える(場合によっては重要な)ためにも不可欠です。 V8(Googleのオープンソースで高性能なJavaScriptエンジン)のドキュメントに記載されているように、ほとんどのJavaScriptエンジンは、辞書のようなデータ構造を使用してオブジェクトのプロパティを格納します。 したがって、各プロパティアクセスでは、プロパティを解決するためにそのデータ構造を動的に検索する必要があります。 このアプローチにより、JavaScriptでのプロパティへのアクセスは、通常、JavaやSmalltalkなどのプログラミング言語でのインスタンス変数へのアクセスよりもはるかに遅くなります。

スコープチェーンを介した変数ルックアップ

JavaScriptのもう1つのルックアップメカニズムは、スコープに基づいています。

これがどのように機能するかを理解するには、実行コンテキストの概念を導入する必要があります。

JavaScriptには、次の2種類の実行コンテキストがあります。

  • 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

各実行コンテキスト内には、変数を解決するために使用されるスコープチェーンと呼ばれる特別なオブジェクトがあります。 スコープチェーンは、基本的に、最も直接的なコンテキストからグローバルコンテキストまで、現在アクセス可能なスコープのスタックです。 (もう少し正確に言うと、スタックの最上位にあるオブジェクトはアクティベーションオブジェクトと呼ばれ、実行中の関数のローカル変数への参照、名前付き関数の引数、および2つの「特別な」オブジェクト( thisarguments )が含まれます。 ) 例えば:

スコープチェーンがオブジェクトに関連する方法は、このJavaScriptの例で概説されています。

上の図では、 thisがデフォルトでwindowオブジェクトを指していることと、グローバルコンテキストにconsolelocationなどの他のオブジェクトの例が含まれていることに注意してください。

スコープチェーンを介して変数を解決しようとすると、最初に直接のコンテキストで一致する変数がチェックされます。 一致するものが見つからない場合は、スコープチェーン内の次のコンテキストオブジェクトがチェックされ、一致するものが見つかるまで同様にチェックされます。 一致するものが見つからない場合、 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キーワードはJavaScript1.7ですでに使用可能であり、ECMAScript6で正式にサポートされるJavaScriptキーワードになる予定です。

JavaScriptのパフォーマンスへの影響

プロトタイプチェーンとスコープチェーンをそれぞれ使用するプロパティルックアップと変数ルックアップがJavaScriptで機能する方法は、言語の重要な機能の1つですが、理解するのが最も難しい、最も微妙な方法の1つです。

この例で説明したルックアップ操作は、プロトタイプチェーンまたはスコープチェーンに基づいているかどうかに関係なく、プロパティまたは変数にアクセスするたびに繰り返されます。 このルックアップがループまたは他の集中的な操作内で発生する場合、特に複数の操作が同時に発生するのを防ぐ言語のシングルスレッドの性質に照らして、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');

この例では、長い継承ツリーと3つのネストされたループがあります。 最も深いループ内では、カウンター変数は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の値が変更されないことがわかっているシナリオでのみ実行可能です。 そうしないと、ローカルコピーを現在の値で更新する必要があります。

OK、 nestedFnメソッドの両方のバージョンを実行して、2つの間にかなりのパフォーマンスの違いがあるかどうかを確認しましょう。

node.jsREPLで最初の例を実行することから始めます。

 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

今回はたった1秒でした。 はるかに高速!

コストのかかるルックアップを回避するためにローカル変数を使用することは、プロパティルックアップ(プロトタイプチェーンを介して)と変数ルックアップ(スコープチェーンを介して)の両方に適用できる手法であることに注意してください。

さらに、このタイプの値の「キャッシュ」(つまり、ローカルスコープの変数)は、最も一般的なJavaScriptライブラリのいくつかを使用する場合にも役立ちます。 jQueryを例にとってみましょう。 jQueryは、基本的にDOM内の1つ以上の一致する要素を取得するためのメカニズムである「セレクター」の概念をサポートします。 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);

特に多数の要素を含むWebページでは、上記のコードサンプルの2番目のアプローチにより、最初のアプローチよりもパフォーマンスが大幅に向上する可能性があります。

要約

JavaScriptでのデータ検索は、他のほとんどの言語とはかなり異なり、非常に微妙な違いがあります。 したがって、言語を真に習得するには、これらの概念を完全かつ適切に理解することが不可欠です。 データルックアップやその他の一般的なJavaScriptの間違いは、可能な限り回避する必要があります。 この理解により、JavaScriptのパフォーマンスを向上させる、よりクリーンで堅牢なコードが得られる可能性があります。

関連: JS開発者として、これが私を夜更かしするものです/ES6クラスの混乱を理解する