为 JavaScript 的面向对象设计与传统类驱动语言(如 Java、C#)有着本质的不同。 这种差异不仅塑造了 JS 独特的语法风格和语言特性,更影响了开发者对对象、继承和复用的理解。 本文将深入探讨 JavaScript 中的原型与原型链机制,解析其核心特性与常见问题。- JS 是否是面向对象的语言?
- 原型、原型链是什么?
- 原型继承和类继承有什么区别?
- JS 是如何基于原型链实现继承的?
遇到这些问题时, 我们常常会感到困惑,因为 JavaScript 的面向对象设计与传统类驱动语言(如 Java、C#)有着本质的不同, 文章从JS的设计哲学、原型与原型链、从ES5的继承理解原型链三个方面进行阐述。
一、JS 的设计哲学:无类而有序
早期(ES6 之前)的 JavaScript 都没有
Class关键字,只有对象和函数的概念。
相比于 Java,C#, JS 对象为什么可以动态甚至随意地添加属性和方法?
简而言之地说:JavaScript 是基于对象而非传统类驱动的面向对象语言
什么是基于对象?为何如此设计?
博客对 JS 的设计理念进行了详细讲述,这里进行简单概括:
📑 JavaScript 的创建史
1994 年,Netscape Navigator网络浏览器的出现轰动一时,但当时的浏览器功能单一,真就是名义上的**“浏览器”**,不具备交互性。
因此,迫切需要一种轻量级的脚本语言来增强浏览器的交互能力。
当时,面向对象编程(OOP)已经成为一种流行的编程范式,JS 的设计者 Brendan Eich 也受其影响,希望借鉴 OOP 的优点,但又不想让 JS 变得过于复杂。
C++ 用 new 关键字创建对象, JS 同样采用 new 关键字基于构造函数创造对象。
但抛弃了类的概念后, 两个实例无法共享属性和方法, 变成了两个独立的对象。
function DOG(name) {
this.name = name;
this.species = '犬科';
}
let dogA = new DOG('大毛');
let dogB = new DOG('二毛');
// 两两独立的对象,无法共享属性和方法, 更改一个对象的属性,另一个对象不会受到影响。
dogA.species = '猫科';
log(dogB.species); // 显示"犬科",不受dogA的影响因此,JS 设计者决定引入原型 prototype来实现对象属性和方法的复用。
function DOG(name) {
this.name = name;
}
DOG.prototype = { species: '犬科' };
var dogA = new DOG('大毛');
var dogB = new DOG('二毛');
DOG.prototype.species = '猫科';
log(dogA.species); // 猫科
log(dogB.species); // 猫科最终,JS 支持封装、继承、多态, ES6 也带来了 Class 关键字,核心机制依然是原型链。例如:
// 构造函数方式创建对象
function Person(name) {
this.name = name;
}
Person.prototype.say = function () {
console.log(this.name);
};
const p1 = new Person('Alice');
// es6
class Person {
constructor(name) {
this.name = name;
}
say() {
console.log(this.name);
}
}
const p2 = new Person('Alice');二、原型与原型链:JS 的继承基石
1. 原型的本质
在面向对象编程中,继承是指将特性从父代传递给子代,以便新代码可以重用并基于现有代码的特性进行构建。
JavaScript 使用原型链机制实现继承,每个对象都有一条链接到另原型对象的内部链。通过__proto__或Object.getPrototypeOf()访问。
该原型对象有自己的原型,依此类推,直到原型为 null 的对象,null 没有原型,是原型链中最后的一环。
const arr = [1, 2, 3];
console.log(arr.map); // 继承自Array.prototype 的 map方法
const x = 'hello world';
console.log(x.split(' ')); // 继承自String.prototype 的 split方法2. 函数的原型 VS 对象的原型
函数的原型 和 对象的原型 是原型链机制的核心。
上文提到,JS 用 new 创建对象时,基于构造函数而不是类,JS 中的函数具有prototype属性 直白地说,当采用一个函数创造对象时,就是把该函数的 prototype 属性给挂到对象的proto上
01. 函数的原型(Function.prototype 和 fn.prototype)
每个函数(包括构造函数)都有一个
prototype属性这个
prototype是一个对象,会被用作由该函数创建的对象的原型。即:
jsfunction Person() {} let p = new Person(); console.log(p.__proto__ === Person.prototype); // trueFunction.prototype是所有函数的原型对象所有函数本身(包括你自定义的)都是
Function的实例:jsconsole.log(Person.__proto__ === Function.prototype); // true
02. 对象的原型(__proto__)
Object.getPrototypeOf(person) === person.__proto__ // true
每个对象都有一个隐藏属性
__proto__(标准叫[[Prototype]])它指向创建该对象的构造函数的
prototype属性:jslet obj = {}; console.log(obj.__proto__ === Object.prototype); // true原型链查找机制
JS 在查找属性或方法时会沿
__proto__一层层往上找,直到null为止。这构成了原型链,例如:
jsfunction Person() { this.sayhi = function () { console.log('hi'); }; } const p = new Person(); console.log(p.__proto__ === Person.prototype, p.__proto__); // true {} console.log( p.__proto__.__proto__ === Object.prototype, Person.prototype.__proto__ ); // true [Object: null prototype] {} console.log( p.__proto__.__proto__.__proto__ === null, Person.prototype.__proto__.__proto__ ); // true null console.log(Person.__proto__ === Function.prototype, Person.__proto__); // true {} console.log( Person.__proto__.__proto__ === Object.prototype, Person.__proto__.__proto__ ); // true [Object: null prototype] {} console.log( Person.__proto__.__proto__.__proto__ === null, Person.__proto__.__proto__.__proto__ ); // true null如果我们简单模拟一下 js 引擎查找对象的属性的过程:
jsfunction getProperty(obj, propName) { // 在对象本身查找 if (obj.hasOwnProperty(propName)) { return obj[propName]; } else if (obj.__proto__ !== null) { // 如果对象有原型,则在原型上!递归!查找 return getProperty(obj.__proto__, propName); } else { // 直到找到Object.prototype,Object.prototype.__proto__为null,返回undefined return undefined; } }
03. 区别与联系
| 对象/函数 | .prototype | .__proto__ |
|---|---|---|
| 普通对象 | 无 | 指向其构造函数的 prototype |
| 函数 | 有 | 指向 Function.prototype |
| 构造函数实例 | 无 | 指向构造函数的 prototype |
三、原型链驱动的继承机制
在ES6之前,JS 没有 class 关键字,实现继承的方式经过了几代演变,最终通过寄生组合式继承实现。
1 原型链继承
原理:让子类的原型对象指向父类的实例。这样,子类实例就能访问到父类原型上的属性和方法。
优点:子类实例可以访问父类原型上的方法。
缺点:所有子类实例共享父类实例属性(如数组、对象等引用类型), 创建子类实例时,无法向父类构造函数传参
说白了,子类永远持有父类的同一个实例,导致父类属性被错误共享。
function Parent() {
this.name = 'parent';
}
Parent.prototype.sayName = function() {
console.log(this.name);
}
function Child() {
this.type = 'child';
}
Child.prototype = new Parent();
var child = new Child();
child.sayName(); // 'parent'2 借用构造函数继承
这种方式的初衷在于解决原型链继承的引用类型被错误共享的问题,通过在子类构造函数中调用父类构造函数来实现。
- 原理:在子类构造函数中调用父类构造函数,这样每个子类实例都有自己的父类实例属性,互不干扰。
- 优点:每个子类实例都有自己的父类属性,互不影响。可以向父类构造函数传参。
- 缺点:父类原型上的方法无法被子类继承。每个实例都单独拷贝一份父类属性,方法无法复用。
function Parent() {
this.name = 'parent';
}
Parent.prototype.sayName = function() {
console.log(this.name);
}
function Child() {
Parent.call(this);
this.type = 'child';
}
var child = new Child();
console.log(child.name); // 'parent'3 组合继承
- 原理:将原型链继承和借用构造函数继承结合起来,既可以继承父类原型上的方法,又可以避免属性共享的问题。
- 优点:每个子类实例有自己的父类属性,互不影响 | 父类原型上的方法可以复用
- 缺点:父类原型上的方法被调用两次,一次在原型链继承时,一次在借用构造函数继承时。
function Parent() {
this.name = 'parent';
}
Parent.prototype.sayName = function() {
console.log(this.name);
}
function Child() {
Parent.call(this);
this.type = 'child';
}
Child.prototype = new Parent();
Child.prototype.constructor = Child;
var child = new Child();
child.sayName(); // 'parent'
console.log(child.name); // 'parent'4. 原型式继承(这不像是继承,像是直接梭哈的js对象)
Douglas Crockford 提出的一种继承方式,基于一个现有对象创建新对象。
- 原理:使用
Object.create()方法(或者模拟实现)来创建一个新对象,并将新对象的原型指向一个已存在的对象。 - 优点:简单灵活
- 缺点:和原型链继承一样,引用类型共享问题
function createObject(obj) {
function F() {}
F.prototype = obj;
return new F();
}
var parent = {
name: 'parent',
sayName: function() {
console.log(this.name);
}
};
var child = createObject(parent);
child.name = 'child';
child.sayName(); // 'child'5. 寄生式继承(搞笑呢,属于凑数的)
在原型式继承的基础上,增强对象。
- 原理:创建一个函数,该函数接收一个对象作为参数,然后增强该对象,并返回该对象。
- 优点:在原型式继承的基础上增强子对象,加个方法属性啥的,WTF这也能成为一类吗?
- 缺点:和原型式继承一样,存在引用类型共享问题,隔靴搔痒。
function createObject(obj) {
function F() {}
F.prototype = obj;
return new F();
}
function createChild(parent) {
var child = createObject(parent);
child.name = 'child';
child.sayName = function() {
console.log(this.name);
};
return child;
}
var parent = {
name: 'parent',
sayName: function() {
console.log(this.name);
}
};
var child = createChild(parent);
child.sayName(); // 'child'6. 寄生组合式继承
这是最理想的继承方式,使用Object.create()方法来创建父类原型的副本,避免了调用父类构造函数时创建不必要的实例。
- 原理:创建父类原型的一个副本,并将其赋值给子类原型,避免了调用两次父类构造函数。
- 优点:解决了所有组合继承的缺点,引用类型共享和父类构造函数被调用两次的问题。
function inherit(child, parent) {
var prototype = Object.create(parent.prototype); // 创建父类原型的一个副本
prototype.constructor = child;
child.prototype = prototype;
}
function Parent() {
this.name = 'parent';
}
Parent.prototype.sayName = function() {
console.log(this.name);
}
function Child() {
Parent.call(this);// 只调用1次父类构造函数
this.type = 'child';
}
inherit(Child, Parent);
var child = new Child();
child.sayName(); // 'parent'
console.log(child.name); // 'parent'7. ES6 Class 继承
ES6 引入了 class 关键字,但它本质上仍然是基于原型链和寄生组合式继承的语法糖。它并没有引入新的继承模型,只是让 JavaScript 的继承写法更接近传统面向对象语言。
- 核心:
extends关键字和super关键字封装了寄生组合式继承的底层逻辑。
class Parent {
constructor(name) {
this.name = name;
this.hobbies = ['reading', 'coding'];
}
sayHello() {
console.log('Hello, I am ' + this.name);
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 相当于 Parent.call(this, name),调用父类构造函数
this.age = age;
}
sayAge() {
console.log('My age is ' + this.age);
}
}在 ES6 class 中: 所有在 constructor 中使用 this.propertyName = value 定义的属性,以及直接在类体中使用类字段语法 (propertyName = value;) 定义的属性,都是实例属性。 它们存在于每个独立的实例上,不共享。
所有在类体中以方法形式定义的 function methodName() { ... },以及使用 static 关键字定义的静态方法/属性,都是原型属性或直接挂在类构造函数上的属性。 它们在实例之间是共享的,通过原型链访问。
class X {
a = 0 // 类字段,是实例属性
constructor() {
this.b = 1 // 实例属性
}
// 原型方法
c() {
}
// d 也是类字段,是实例属性
d = () => {
}
}
const xx = new X()
console.log('c' in xx, 'c' in xx.__proto__) // true, true
console.log('d' in xx, 'd' in xx.__proto__) // false, true