12k
All articles

JavaScriptのプロトタイプチェーンの仕組み

JavaScriptのプロトタイプチェーン検索、prototypeと[[Prototype]]の違い、継承プロパティの隠蔽、安全なリンクの確認方法を解説します。

OpenReplay Team
OpenReplay Team
JavaScriptのプロトタイプチェーンの仕組み

プロパティを読み取るとき、JavaScriptはまずオブジェクト自身のプロパティ(独自プロパティ)を確認し、次に内部の [[Prototype]] リンクをたどって次のオブジェクトへ移ります。この処理を、プロパティが見つかるか null に到達するまで繰り返します。null に到達した場合、読み取り結果は undefined になります。

ルール自体はシンプルですが、DevToolsのパネルや面接の質問で prototype、__proto__、[[Prototype]] が並んで登場すると、途端にわかりにくくなります。Firefoxに至っては、コンソールでオブジェクトを展開するとこのリンクを <prototype> と表示します。

本記事では、プロパティの探索がチェーンをどのようにたどるかに絞って解説します。具体的には、どのオブジェクトが読み取りに応答するのか、書き込みはどこに反映されるのか、プロトタイプを調べるにはどのAPIを使えばよいのか、という点です。コンストラクター関数、クラス、Object.create による継承パターンについては、A Beginner’s Guide to OOP in JavaScript および Inheritance in JavaScript Object-Oriented Programming を参照してください。

重要なポイント

  • プロパティの読み取りでは、まず独自プロパティを確認し、次に各 [[Prototype]] を順に確認します。チェーンが null で終わると undefined を返します。
  • 関数の prototype プロパティは、new で生成されたインスタンスの [[Prototype]] になります。関数自身のプロトタイプではありません。
  • __proto__ は Object.prototype から継承される非推奨のアクセサーです。代わりに Object.getPrototypeOf() と Object.setPrototypeOf() を使用してください。
  • 継承されたプロパティへの代入は、通常、継承されたプロパティをシャドーイングする独自プロパティを作成するだけで、プロトタイプは変更されません。
  • Object.create(null) はチェーンを持たないオブジェクトを生成するため、任意の文字列キーを扱う辞書として安全に使えます。

JavaScriptのプロトタイプチェーンにおける探索ルールとは?

JavaScriptのプロトタイプチェーンとは、[[Prototype]] によって連結されたオブジェクトの連なりであり、プロパティの読み取りは一致するものが見つかるか null に到達するまで、このチェーンをたどります。通常のオブジェクトはすべて、別のオブジェクトまたは null を指す内部スロット [[Prototype]] を持っています。読み取りの動作は、仕様の OrdinaryGetアルゴリズム で定義されています。まず独自プロパティを探し、見つからなければプロトタイプに対して同じ探索を繰り返します。ほとんどのオブジェクトでは、チェーンは Object.prototype で終わり、そのプロトタイプは null です。

const animal = { eats: true };
const rabbit = Object.create(animal);
rabbit.jumps = true;

console.log(rabbit.jumps);   // true      (own property)
console.log(rabbit.eats);    // true      (found on animal)
console.log(rabbit.toString === Object.prototype.toString); // true
console.log(rabbit.flies);   // undefined (not found anywhere)
console.log(Object.getPrototypeOf(Object.prototype)); // null
// rabbit -> animal -> Object.prototype -> null

存在しないプロパティを読み取ってもエラーにはなりません。TypeError がスローされるのは rabbit.flies() を呼び出したときで、これは undefined に対して呼び出しが行われるためです。MDNの 継承とプロトタイプチェーン ガイドでも、同じモデルがより詳しく解説されています。

関数の prototype とオブジェクトの [[Prototype]] の違いは?

関数の prototype プロパティは、new で生成されたインスタンスの [[Prototype]] となるオブジェクトです。関数自身のプロトタイプではなく、関数自身のプロトタイプは Function.prototype です。任意のオブジェクトの実際のリンクを読み取るには、Object.getPrototypeOf() を使用します。

class Greeter { hello() { return 'hi'; } }
const g = new Greeter();

console.log(Object.getPrototypeOf(g) === Greeter.prototype);        // true
console.log(Object.getPrototypeOf(Greeter) === Function.prototype); // true
console.log(Object.getPrototypeOf(Greeter) === Greeter.prototype);  // false

const arrow = () => {};
console.log(arrow.prototype);                   // undefined
console.log(Greeter.prototype.hello.prototype); // undefined

アロー関数や通常のクラスメソッドは prototype プロパティをまったく持たないため、それらに対して .prototype を読み取ると undefined が返ります。

名前概要存在する場所読み取り方法
[[Prototype]]探索がたどる内部リンクすべてのオブジェクト(内部スロット)Object.getPrototypeOf(obj)
obj.__proto__そのリンクに対するレガシーなゲッター/セッターObject.prototype使用を避け、getPrototypeOf を使う
Fn.prototypenew Fn() で生成されたインスタンスに割り当てられるオブジェクトコンストラクターとして呼び出せる関数およびクラスFn.prototype

proto とは?

__proto__ は Object.prototype 上に定義されたアクセサープロパティです。各オブジェクトに格納されているわけではないため、Object.prototype を継承していないオブジェクトはこれを持ちません。MDNは Object.prototype.__proto__ を非推奨(Deprecated)としており、古いWebサイトが引き続き動作するようにするためだけに標準に含まれていると説明しています。MDNは代わりに Object.getPrototypeOf() と Object.setPrototypeOf() の使用を推奨しています。

const proto = { greet() { return 'hello'; } };
const obj = Object.create(proto);

console.log(Object.getPrototypeOf(obj) === proto);          // true
console.log(Object.hasOwn(obj, '__proto__'));               // false
console.log(Object.hasOwn(Object.prototype, '__proto__'));  // true

const bare = Object.create(null);
console.log(bare.__proto__);                                // undefined

const lit = { __proto__: proto };
console.log(Object.getPrototypeOf(lit) === proto);          // true

オブジェクトリテラル 内の { __proto__: value } というキーは標準構文であり、アクセサーとは別物です。こちらは非推奨ではありません。プロトタイプを設定するのはコロン形式のみで、計算プロパティ名の ["__proto__"] キーは通常の独自プロパティを作成します。

適切なAPIを使う場合でも、オブジェクトの生成後にプロトタイプを変更するのはできるだけ避けてください。MDNは、それが「現在、すべてのブラウザーとJavaScriptエンジンにおいて非常に遅い操作である」と警告しています。プロトタイプはオブジェクトの生成時に設定しましょう。

シャドーイング:独自プロパティは継承されたプロパティを隠す

探索は最初に一致した時点で停止するため、独自プロパティはチェーンの上位にある同名のプロパティを隠します。代入も同様に動作します。継承されたセッターや書き込み不可の継承プロパティが妨げにならない限り、仕様の OrdinarySet は対象オブジェクト上に独自プロパティを作成または更新します。継承されたプロパティはシャドーイングされるだけで、変更はされません。

const defaults = { theme: 'light' };
const settings = Object.create(defaults);

console.log(settings.theme);                    // 'light' (inherited)
settings.theme = 'dark';                        // creates an own property
console.log(defaults.theme);                    // 'light' (prototype untouched)
console.log(Object.hasOwn(settings, 'theme'));  // true

delete settings.theme;
console.log(settings.theme);                    // 'light' (inherited again)
// settings -> defaults -> Object.prototype -> null

Object.hasOwn() を使えば、どの階層が読み取りに応答したかがわかります。MDNは hasOwnProperty() の代わりにこれを使うことを推奨しています。

Array.prototype のメソッドはどのようにしてすべての配列に届くのか?

配列リテラルで生成された配列はすべて、Array.prototype を [[Prototype]] として持ちます。そのため、map、filter、push は各配列にコピーされなくても、すべての配列で動作します。組み込みオブジェクトもシャドーイングを利用しています。Array.prototype.toString は Object.prototype.toString を隠しています。

const nums = [1, 2, 3];

console.log(Object.getPrototypeOf(nums) === Array.prototype);             // true
console.log(Object.hasOwn(nums, 'map'));                                  // false
console.log(nums.map === Array.prototype.map);                            // true
console.log(Object.getPrototypeOf(Array.prototype) === Object.prototype); // true

console.log(nums.toString());                       // '1,2,3'
console.log(Object.prototype.toString.call(nums));  // '[object Array]'
// nums -> Array.prototype -> Object.prototype -> null

チェーンを持たないオブジェクトを作る Object.create(null)

Object.create(null) は、[[Prototype]] が null のオブジェクトを生成します。このオブジェクトは何も継承しません。toString も hasOwnProperty も __proto__ アクセサーもありません。したがって、nullプロトタイプオブジェクトは、ユーザー入力に由来するキーを格納するための安全なコンテナとなります。通常の {} では、何も格納していない時点ですでに constructor などの継承された名前が値を返してしまいます。

const words = ['a', 'constructor', 'a'];

const plain = {};
for (const w of words) plain[w] = (plain[w] ?? 0) + 1;
console.log(plain.constructor); // a string like "function Object() { [native code] }1"

const counts = Object.create(null);
for (const w of words) counts[w] = (counts[w] ?? 0) + 1;
console.log(counts.constructor);   // 1
console.log(counts.a);             // 2
console.log('toString' in counts); // false
// counts -> null

nullプロトタイプオブジェクト は継承されたメソッドを持たないため、counts.hasOwnProperty(key) ではなく Object.hasOwn(counts, key) を呼び出してください。また、テンプレートリテラルに埋め込むと、文字列に変換するための toString が存在しないため TypeError がスローされます。キーが文字列でない場合は Map を使用してください。

クラスも同じプロトタイプチェーンを使うのか?

はい。クラス構文が新しい継承モデルを追加するわけではありません。クラス本体で宣言されたメソッドは ClassName.prototype 上に存在し、インスタンスは他のオブジェクトと同じ [[Prototype]] の探索を通じてそれらに到達します。extends は、あるクラスの prototype オブジェクトを親クラスの prototype オブジェクトに連結します。

class Animal { speak() { return 'sound'; } }
class Dog extends Animal { bark() { return 'woof'; } }
const d = new Dog();

console.log(typeof Dog);                                                // 'function'
console.log(Object.hasOwn(d, 'speak'));                                 // false
console.log(Object.getPrototypeOf(d) === Dog.prototype);                // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(d.speak());                                                 // 'sound'
// d -> Dog.prototype -> Animal.prototype -> Object.prototype -> null

まとめ

プロパティの読み取りは、常に同じ経路をたどります。JavaScriptはまずオブジェクト自身を確認し、次に各 [[Prototype]] を順に確認して、プロパティが見つかるか null に到達するまで続けます。書き込みは代入先のオブジェクトにとどまります。次にどこから来たのかわからない値に出会ったら、まずそのオブジェクトに対して Object.hasOwn() を実行し、続いて各リンクで Object.getPrototypeOf() を呼び出してチェーンを書き出し、実際にそのプロパティを保持しているオブジェクトを突き止めてください。

よくある質問

in 演算子と Object.hasOwn() の違いは何ですか?

in 演算子はプロトタイプチェーン全体を確認するため、toString は Object.prototype から継承されているので 'toString' in {} は true を返します。Object.hasOwn(obj, key) はオブジェクトの独自プロパティのみを確認し、継承されたものについては false を返します。継承されたプロパティも含めて判定したい場合は in を、オブジェクト自身がそのキーを保持しているかを知りたい場合は Object.hasOwn() を使用してください。

instanceof はプロトタイプチェーンをどのように利用していますか?

デフォルトでは、obj instanceof C は、obj のプロトタイプチェーンのどこかに C.prototype が存在すれば true を返します。チェーンに C.prototype を含むオブジェクトであれば何でも判定を通過するため、C がそのオブジェクトを生成したことの証明にはなりません。クラスは静的メソッド Symbol.hasInstance を定義することで、この判定を置き換えることができます。デフォルトの判定では、左辺がプリミティブ値の場合は常に false を返します。

for...in ループや Object.keys() には継承されたプロパティも含まれますか?

for...in ループは、オブジェクト自身およびそのプロトタイプチェーンをさかのぼったすべてのオブジェクト上にある、列挙可能な文字列キーのプロパティを走査します。Object.keys()、Object.entries()、およびオブジェクトのスプレッド構文には、オブジェクト自身の列挙可能なプロパティのみが含まれます。Object.prototype や Array.prototype などの組み込みプロトタイプ上のメソッドは列挙不可のため、for...in には現れません。ただし、独自に定義したプロトタイプオブジェクト上の列挙可能なプロパティは現れます。

JavaScriptのプロトタイプチェーンに循環が生じることはありますか?

通常のオブジェクトでは生じません。Object.setPrototypeOf() は、新しいプロトタイプによって循環が生じる場合、たとえば b がすでに a を継承しているときに a のプロトタイプを b に設定しようとした場合に TypeError をスローします。__proto__ セッターを介した代入でも同じエラーがスローされます。循環は拒否されるため、通常のオブジェクトのチェーンは常に有限で null で終わり、プロパティの探索は必ず停止します。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.