原型相关的内容是 JavaScript 中的重要概念, 它类似于经典面向对象语言中的类, 但又不完全相同, 原型的主要作用是实现对象的继承。这篇文章将讨论 JavaScript 中的原型、原型链、利用原型链完成继承、ES6中的 class 语法糖在内的几个内容.
1. 原型和原型链
1. 原型和原型链
JavaScript 中每个对象都有原型的引用, 当查找一个对象的属性时, 如果在对象本身上找不到, 就会去他所链接到的原型上查找, 它链接到的原型上也会链接到其他的原型, 这样就形成了所谓的原型链, 沿着原型链一路寻找下去, 如果找到头还是没有这个属性, 则认为这个对象上不存在此属性。
例如下面的代码中创建了3个对象, 每个对象都有自己特有的属性, 而且在每个对象上找不到其他对象的属性:
// demo. 1 - in
let obj1 = { prop1: 1 };
let obj2 = { prop2: 2 };
let obj3 = { prop3: 3 };
// // 判断对象是不是有各自的属性
console.log(obj1.prop1, obj2.prop2, obj3.prop3); // 1 2 3
// 判断对象是不是有其他对象的属性
console.log(obj1.prop2, obj1.prop3); // undefined undefined
上面的代码可以看出一个对象上只有自己定义的属性, 没有其他对象的属性。
通过内置的方法 Object.setPrototypeOf(A, B) 可以把对象 A 的原型设成对象 B, 也就是说对象 B 成了对象 A 的原型。 此后如果要查找对象 A 的某个属性时, 如果在A上找不到, 就会去对象B中寻找。
例如下面的代码通过 Object.setPrototypeOf 方法将 对象2 设成 对象1 的原型, 将 对象3 设成 对象2 的原型, 然后去访问 对象1 和 对象2 本来没有的属性:
// demo.2 - setprototypeof
let obj1 = { prop1: 1 };
let obj2 = { prop2: 2 };
let obj3 = { prop3: 3 };
// 将 对象2 设成 对象1 的原型, 将 对象3 设成 对象2 的原型
Object.setPrototypeOf(obj1, obj2);
Object.setPrototypeOf(obj2, obj3);
// 现在检查 对象1 上面是否有属性 prop2 和 prop3
console.log(obj1.prop2, obj1.prop3); // 2 3
// 检查 对象2 上是不是有属性 prop3
console.log(obj2.prop3); // 3
通过上面的代码发现将一个对象设为原型后, 就可以访问这个原型对象上的属性了。上面的这段代码, 将 obj2 设置成了 obj1 的原型, 将 obj3 设置成了 obj2 的原型, 这样就产生了一条原型链: obj1 -> obj2 -> obj3。通过图片可以更加具象的理解这个问题:

从图中可以看到,Obj3的原型属性仍然指向了其他方向,这说明Obj3也是有原型的,不仅如此,除非手动的将一个对象的原型指向null,否则除了一个对象之外,所有对象都有自己的原型。那么这个没有原型的对象是谁呢?这个问题的答案和下面的问题的答案一样:就是Object.prototype,也就是Object对象的原型对象。这要结合下面的问题来说明。
那么,上面图中查找的过程有没有终点呢?答案是肯定的,终点是上面提到的 Object对象的原型对象,也就是Object.prototype,这是属性查找的终点, 如果在Object.prototype仍找不到希望的属性,则对象就被认为不拥有这个属性。
这说明了一个重要的问题:所有对象的原型最终都会链接到Object.prototype,无论中间经过多少其他的原型对象。
从前有句话叫: 顺着网线去打你; 现在可以叫: 顺着原型链去找你。
1.2 当要找的属性存在于自身时
上面说, 当查找一个对象的属性时, 会在对象上查找, 如果找不到就顺着原型链接着找, 知道找到或者原型链到头了为止。
也就是说当能在自己身上找到想要的属性时,就不用去原型链上寻找了。例如下面的例子:
let obj1 = {
prop1: 1,
prop2: '呵呵呵' // 增加一个名为 prop2 的属性并将值设置成字符创类型
};
// 注意 obj2 的 prop2 属性值是 2, 数值类型
let obj2 = {
prop2: 2
};
// 将 对象2 设成 对象1 的原型
Object.setPrototypeOf(obj1, obj2);
// 这个时候 obj2 的 prop2 属性已经在原型链上了
// 实验看 obj1.prop2 属性到底是哪个
console.log(obj1.prop2); // '呵呵呵'
上面的代码将 obj2 设置成 obj1 的原型, 当 obj1 想找 prop2 属性时, 在自身就找到了, 所以不用去它的原型 obj2 中寻找了。这个实验说明了在寻找属性时会从对象自身开始, 向着原型的方向去找, 找到的第一个就拿来直接用。
这种行为也是利用原型链实现继承的基础之一。
2. 构造函数的原型
2.1 实例的原型指向的是构造函数的原型
同过上面的部分已经知道对象有自己的原型, 而函数也是对象, 所以函数也有原型, 同样的, 构造函数也有原型。
构造函数的用法是配合 new 操作符创建一个类的实例,创建出的实例也是对象, 所以也是有原型的, 而且实例的原型就指向构造函数的原型。
还有一点是, 构造函数的原型中有个属性名为 constructor, 这是个引用类型的属性, 它指向构造函数本身。
利用实例的原型指向构造函数的原型可以实现一种巧妙的继承方式: 将要创建的实例的不共享的属性放在构造函数内部, 而公用的属性定义在构造函数的原型上, 这样创建出的实例即有各自不同的属性, 还能顺着原型链找到公用的属性。以下面的代码为例:
// 1. 定义一个构造函数 Person
function Person(name){
this.name = name;
}
Person.prototype.sayName = function(){
console.log('我的名字是: ', this.name);
}
// 2. 利用构造函数实例化两个 Person 的实例
let xm = new Person('小明');
let xh = new Person('小红');
// 3. 实例调用公有的函数来输出私有的属性
xm.sayName(); // 我的名字是: 小明
xh.sayName(); // 我的名字是: 小红
上面代码中定义的 Person 构造函数有两个属性: name 和 sayName, 而且分别将这两个属性定义在了构造函数体内和构造函数的原型上, 这样通过构造函数 new 出来的实例拥有各自的私有属性 name, 还可以使用公有的属性 sayName 函数来访问自己的私有属性, 通过代码的运行结果可以看出这种方法的有效性。
可以将上面的例子中构造函数、实例与原型之间的关系用图表示出来:

将 sayName 定义在构造函数的原型中的好处是,产生的每个实例都会共享这个 sayName, 如果在构造函数自身上定义 sayName 的话, 每个实例就都会有一个 sayName 函数了, 这样没有增加功能, 反而更加浪费内存了。
2.2 改变构造函数的原型指向
上节说到通过构造函数产生的实例的原型指向的也是构造函数的原型, 如果令构造函数的原型指向一个新的对象, 那么之后再创建的实例的原型会指向哪呢? 是指向老的原型还是新的对象呢? 答案是之后创建的实例的原型会指向新的对象, 之前创建的实例的原型会指向旧的对象。下面通过代码来看一下:
function Person(){}
Person.prototype.fn1 = function(){
console.log('fn1 reporting in old proto');
}
// 创建实例
let oldOne = new Person();
// 修改构造函数的原型指向
Person.prototype = {
fn2(){ // 新原型对象中定义一个新函数
console.log('fn2 in new proto');
}
};
// 创建新实例
let newOne = new Person();
// 在修改原型指向之前创建的实例无法访问新原型中的方法
oldOne.fn1(); // fn1 reporting in old proto
oldOne.fn2(); // TypeError: oldOne.fn2 is not a function
console.log(oldOne.fn2); // undefined
// 修改之后的原型的实例无法访问旧原型中的方法
newOne.fn1(); // TypeError: newOne.fn1 is not a function
console.log(newOne.fn1); // undefined
newOne.fn2(); // fn2 in new proto
从上面的代码可以看出在创建一个实例时, 会将实例的原型引用设置成构造函数当前的原型上。这样新实例无法访问旧原型里的属性, 旧实例也无法访问新原型里的属性。
上面的代码可以概括为下图:

3. 实现继承
很多书上都说实现利用原型链实现继承的最佳方案是将一个对象的原型设置成另一个对象的实例, 即 SubClass.prototype = new SuperClass(), 例如 Student.prototype = new Person()。
例如下面的代码:
// 定义类 Person 的构造函数
function Person(){}
Person.prototype.walk = function(){
console.log('I am walking freely ...');
}
// 定义 Student 类的构造函数
function Student(){}
Student.prototype = new Person(); // 继承 Person
let xm = new Student();
xm.walk(); // I am walking freely ... 成功调用继承来的方法
通过上面的代码发现, 将 Student 类构造函数的原型设置成 Person 的一个实例, 可以实现继承。通过原型链可以更加清晰的看出这种继承方式的实现原理:

如上图所示, 令构造函数 Student 的原型指向 Person 的实例, 之后生成的 Student 实例的原型属性就会自动指向 Person 实例了。而且 Person 有的属性, Student 都有了。这就是 Student 的实例 xm 能调用它自身没有的函数 walk 的原因。
同时由于旧的原型没有被引用, 所以会被清理删除。
但是在图中还可以看到这样实现的继承有个问题: 就是 Student 本来的原型上有个 constructor 属性, 现在没有了。虽然可以通过原型链找到 Person 原型里的 constructor 属性, 但是这并不是我们想要的继承方式。索性这个问题可以用 Object.defineProperty 来解决, 代码如下:
Object.defineProperty(Student.prototype, 'constructor', {
enumerable: false, // 设置不可枚举
value: Student, // 指向 Student 构造函数
writable: true // 可写
});
这样, Student 的原型就有了 constructor 属性, 而且指向了它该指向的地方。
4. class 语法糖
ES6 引入的 class 关键字可以使继承的实现更加简洁, 而且更加像 Java 之类的经典 OO 语言对类的定义。
4.1 利用 class 关键字创建一个类
class Person {
constructor(name){ // 构造函数
this.name = name;
}
sayName(){
console.log(this.name);
}
}
let xm = new Person('小明');
xm.sayName(); // '小明'
上面的代码定义了一个 Person 类, 并利用 constructor 传入了 name 属性, 并定义了一个 sayName 函数。通过实验说明了这段代码可以正常执行。
上面的这段代码的底层仍然是基于原型实现的, 可以按照文章第 3 部分的内容转换成如下的代码:
function Person(name){
this.name = name;
}
Person.prototype.sayName = function(){
console.log(this.name);
}
let xm = new Person('小明');
xm.sayName(); // '小明'
4.2 利用 class 关键字实现继承
通过文章第3部分可以看出, 实现继承是比较麻烦的事情, 但是利用 class 这个语法糖中的 extends和super 关键字可以很简洁的实现继承,例如下面的代码:
class Person {
constructor(name){ // 构造函数
this.name = name;
}
sayName(){
console.log(this.name);
}
}
class Student extends Person {
constructor(name, grade){ // 构造函数, 传入子类的参数
super(name); // 利用 super 关键字调用父类的构造函数
this.grade = grade;
}
getGrade(){ // 定义子类自己的函数
console.log(this.grade);
}
}
let xm = new Student('小明', 99);
xm.sayName(); // '小明', 说明可以调用父类的方法
xm.getGrade(); // 99, 正常调用子类的方法
参考:
《JavaScript忍者秘籍》
MDN
如有错误,感谢指正~