JavaScript系列 — 原型、原型链、new、构造函数、继承

前言

image.png

image.png

从这张图我们可以看出:Array、Object、Map、Set等等这些本质上是一个构造函数,其原型prototype(本质上其实是一个对象,详见下方)有很多的属性/方法,这些属性/方法都是我们平常会用到的:

var arr = [1,2,3,4]
arr.concat([5,6]) // [1,2,3,4,5,6]
arr.length // 6
var map = new Map()
map.set("name","John") // Map(1) {"name" => "John"}
map.set("age",18) // Map(2) {"name" => "John", "age" => 18}
map.has("name") // true
map.has("gender") // false
复制代码

很明显我们看到使用这些方法的时候我们是通过”.”来连接arr/map和其对应的属性/方法的,这有点像是对象访问属性/方法。记得之前看过一句话:万物皆对象,这就对上了。其实我们创建的arr/map都是一个对象,它们的属性/方法就好比是对象的属性/方法,所以用”.”来连接。

image.png

image.png

诶奇怪,这里我们看到新创建的实例对象 arr/map 并没有那些属性/方法,那为什么可以用呢?而且为什么都能看到有__proto__这个东西呢?

原因有两点:

  • 创建的实例对象都有一个__proto__属性,这个属性指向其对应构造函数的ptototype属性(prototype属性见下方)
  • 当我们想使用实例对象的属性或方法时我们先从这个实例对象本身找,如果这个实例对象没有想要的属性或方法,就会去__proto__对象里面找,再找不到就会沿着原型链去找,一直到【找到想要的属性/方法】或【__proto__属性的值为null】(__proto__属性和原型链见下方)

那为什么要这样呢?很显然arr、obj、map、set有千千万万个,我们不可能每次创建一个新的arr/obj/map/set都要给它们添加属性/方法,这样不仅会效率低下,还会造成空间上的浪费。所以我们利用这个机制(原理),这样就可以直接使用JavaScript中自带的数组/对象/Set/Map的属性/方法,也不会造成空间上的浪费。

为了解释这一点,我们需要清楚:构造函数、new、原型、继承、原型链,我们一步一步来解读:

函数对象的属性 prototype 对象 —— 被称为 “函数的原型”

我们先理解一下函数本质上是个对象

1. 创建函数F

function F(){}
复制代码

2. 函数也是一个对象,它有一些属性和方法

image.png

// 形象地理解就是这样:
F = {
    F.length // 形参个数
    F.arguments // 存放实参的类数组对象
    F.name // 函数名称
    F.prototype // 函数的原型
    F.constructor 
    F.hasOwnProperty() // 判断属性是否为本身的方法
}
复制代码

既然如此的话那我们就可以在函数里面添加属性和方法啦:

【添加属性】
F.age = 18function F(age){
    this.age = 18
}

【添加方法】
F.say(){
    // say()函数代码
}
或
function F(){
    this.say(){
        // say()函数代码
    }
}
复制代码

prototype 对象

// F()函数对象里有个prototype属性,它也是一个对象
F = {
    prototype: {}
}
复制代码

prototype 对象的属性

prototype 既然是一个对象,那么就也会有一些属性,它有一个默认的属性constructor,并且它默认指向当前函数

F = {
    prototype: {
        constructor: F    // 指向当前函数
    }
}
复制代码

既然prototype是个对象,那我们也同样可以给它添加属性,例如:

F.prototype.name = 'BatMan';

// 那F就变成如下:
F = {
    prototype: {
        constructor: F,
        name: 'BatMan'
    }
}
复制代码

按照这个想法,于是我们就有了【构造函数】这个东西

构造函数是这样创建出来的:(我们也将其称为类的创建

image.png

此时我们使用 new 关键字来创建构造函数 F() 的一个实例对象 f,并在括号里传输参数"John"

image.png

这里可以看到通过 new 关键字对构造函数 F 生成了实例对象 f,它获得了 name 这个属性

再看一个例子:

var obj = {"name":"nihao"};
------------------
其实本质上是: 
var obj = new Object()
console.log(obj) // {}
obj.name = "nihao"
这个过程
------------------
console.log(obj); // { name: 'nihao' }
复制代码

这里我们可以看到Object原本是一个函数,我们创建的obj是由Object()这个构造函数生成的,这也照应了Array、Object、Map、Set等等这些本质上是一个构造函数

同时(通过这个思想的模仿),从本质上来讲:在JavaScript中,对象是由构造函数生成的,所以对象和函数对象没有区别,对象只是函数对象的其中一个

给构造函数(类)添加属性/方法

  • 方式一:将 say() 方法直接添加到构造函数 F 里面(叫做实例方法

image.png

实例对象 f 将无法使用 say()

  • 方式二:将say()方法添加到构造函数 F 的prototype属性对象里面(叫做原型方法

image.png

这里看到实例对象 f 可以使用say()方法里

这里解释一下原因:打个比方——(构造函数F)是“真身”,它通过new的方式进行“分身”得到自己的兄弟(实例对象f、f1、f2、f3…)“影分身”,这个期间真身同时将自己从“师傅”(F.prototype)习得的“技能”(属性/方法)同时复制给了影分身,而影分身应该叫真身的师傅为(f.__ proto__),所以真身和影分身们的师傅是同一个。所以师傅有了新技能(新增属性/方法),真身和影分身们就能同时拥有(对应第2个方法成立)。

另外,由于影分身是由真身拷贝出来的,所以真身新增技能,已拷贝出来的影分身并不能也拥有(对应第1个方法失效)。然而此时真身再拷贝出影分身,新的影分身就能拥有新技能了(var f1 = new F(),f1将可以使用say()方法)。

其实这两种不同方式的给构造函数添加属性/方法分别对应的是构造函数继承原型链继承(这后面会详解)

继承

所谓继承,就是把对象的属性/方法(函数)进行扩展后,可以继承给其他函数

像Array、Object、Map、Set这些函数对象的prototype属性对象里面就有很多的属性和方法,如果想使用数组/对象/Set/Map的自带属性/方法原理上就是在这里“拿”的,更恰当的说法是继承的

由上面的例子可以看到,F.prototype新增了say()方法后,实例对象 f 就可以使用say()方法了

此时如果打印一下实例对象 f:

image.png

诶,实例对象 f 中明明没有say()方法,为什么可以执行console.log("构造函数的say()方法")

关于这个问题的解决,我们需要了解new关键字创建实例对象的过程发生了什么、关于__proto__属性的理解

使用new关键字创建实例对象的过程

这里参考文章 JS中的new操作符

function Base(name){
    this.name = name
}
var obj = new Base("base");
复制代码

这样代码的结果是什么,我们在Javascript引擎中看到的对象模型是:

new操作符具体干了什么呢?其实很简单,就干了三件事情

var obj  = {};
obj.__proto__ = Base.prototype;
Base.call(obj);
复制代码
  • 第一行,我们创建了一个空对象obj
  • 第二行,我们将这个空对象的__proto__成员指向了Base函数对象prototype成员对象
  • 第三行,我们将Base函数对象的this指针替换成obj,然后再调用Base函数,于是我们就给obj对象赋值了一个id成员变量,这个成员变量的值是”base”,(关于call函数的用法见 JavaScript系列 — this关键字

对象的 __proto__属性 —— 被称为“对象的原型”

image.png

还是上面那张图,我们看到实例对象 f 除了有name属性,其实还有一个__proto__属性,它也是一个对象,跟prototype分别称为显式原型和隐式原型。我们点开查看__proto__对象的属性:

image.png

这里可以看到在实例对象 f 的__proto__这个属性对象里有say()方法,而这个say()方法哪来的呢?结合new的其中一个步骤:将实例对象的__proto__成员对象指向了构造函数的prototype成员对象和我们之前的一个操作:

F.prototype.say = function(){
    console.log("构造函数的say()方法")
}
复制代码

这个say()方法就是这么来的。我们大胆猜测实例对象 f 的say()方法就是从其__proto__对象中继承而来的

这里补充一下其实对象的属性有两种:自身属性原型属性:我们所创建的实例对象f1,有自身属性name,还有从原型上找到的say()方法,我们可以使用hasOwnProperty方法检测一下:

console.log(f.hasOwnProperty('name'));  // true 说明是自身属性(方法)
console.log(f.hasOwnProperty('say')); // false 说明不是自身方法(属性)
复制代码

所以当我们在寻找/使用实例对象 f 的属性/方法时,它会先从自身属性/方法找有没有,如果没有就去__proto__对象,也就是它的原型里面找属性/方法。这就可以解释为什么实例对象 f 中明明没有say()方法,却可以执行console.log("构造函数的say()方法")

那么问题来了,那如果在原型里面找不到呢?

如果原型里面找不到,就去原型里的原型去找,还找不到就去原型的原型的原型里找…于是就有了“原型链”的概念

原型链

  1. 外界访问对象的属性或使用它的方法
  2. 对象可以通过“.”操作获取到它自身属性/方法
  3. 如果查不到会到原型对象(__ proto __) 中去查找,
  4. 如果原型对象中还没有就会把当前得到的原型对象当作实例对象,继续通过(__ proto__) 去查找当前原型对象的原型对象中去找,
  5. 直到 【找到想要的属性/方法(通过字符串名称去判断的)】或 【__proto __为null】 时停止
  6. __ proto __ 为null之前的__ proto __ 是一个Object

还是上面那个例子,假设我们要使用另外的属性/方法toString(),在实例对象f的原型中找不到

image.png

从这张图可以看出:实例对象 f 的原型的原型也是一个Object,我们查看这个Object(实例对象f的原型的原型)

image.png

发现诶有我们想要的toString()方法,于是停止向上查询。如果还找不到发现这个Object__proto__为null或者说没有这个属性,我们也束手无策,返回报错,表示找不到

细心的小伙伴已经注意到:所有的原型链的终点都是Object.prototype,也就是上面那个f的原型的原型

那么问题来了,为什么不直接在 f()里面添加 say()就好,要在 F.prototype 里面添加

继承的作用在哪

其实在前言中已经回答了这个问题,arr、obj、map、set有千千万万个,我们不可能每次创建一个新的arr/obj/map/set都要给它们添加属性/方法,不仅浪费时间,而且还会浪费空间

同理,如果直接在 f里面添加 say(),那另外new出来的f1、f2、f3、…等实例对象都不能享有 say() 方法,所以不可取。

其次就算我们不在f里面添加 say() 方法,我们在构造函数F里面添加say()方法,然后再new出来的f1、f2、f3等实例对象虽然都能享有 say() 方法,但还是会浪费空间。那应该怎么做呢?

constructor 属性

在说继承的方式前我们看一下constructor这个属性,构造函数 F 的原型prototype属性对象里面就有constructor这个属性,它的属性值就是构造函数 F

F.prototype.constructor === F // true
复制代码

又因为有

f.__proto__ === F.prototype // true
复制代码

所以

f.__proto__.constructor === F // true
复制代码

值得注意的是

f.constructor === F // true
复制代码

原因上面已经解释了:f没有constructor这个属性,所以会去f.__proto__里面找,所以上式才会成立

继承的方式有哪些

继承方式有:

  • 构造函数继承
  • 原型链继承
  • 组合继承(组合了原型链继承和构造函数继承)
  • 寄生继承
  • 寄生组合继承
  • 原型式继承

关于构造函数和原型链继承,我觉得有一句话总结得不错:

注意凡是this.-的,都是类的私有属性和方法,凡是-prototype.-的都是公有属性和方法

原型链继承

  • 核心:子类的原型 = 父类的实例
function Animal (name) {
    this.name = name || 'Animal';// 属性
    this.sleep = function(){ // 实例方法
        console.log(this.name + '正在睡觉!');
    }
}
Animal.prototype.eat = function(food) {  // 原型方法
    console.log(this.name + '正在吃:' + food);
};
--------------------------------------------------------------
function Cat(){}
Cat.prototype = new Animal(); // 原型链继承(会造成 Cat.prototype.constructor === Animal)
Cat.prototype.constructor = Cat; // 记得矫正constructor属性,是为了不破坏原型链
--------------------------------------------------------------
Cat.prototype.name = "Tom";
// Test Code
var cat1 = new Cat();
var cat2 = new Cat();
console.log(cat1.sleep===cat2.sleep) // true ------------------------- 1
console.log(cat1.eat('fish')); // "Tom正在吃fish"  --------------------- 2
console.log(cat1.eat===cat2.eat) // true  ---------------------------- 3
console.log(cat1.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
console.log(cat1 instanceof Animal); // true
console.log(cat1 instanceof Cat); // true
复制代码
  • 优点:
  1. 既能继承父类实例的属性和方法,也能继承父类原型的属性/方法。由【1】和【2】和【3】综合分析得出的。
  2. 可以复用。由【1】和【2】和【3】综合分析可看出。基于原型链,构造函数所创建的实例中属性就不再是私有属性了,而是在原型中能共享的属性。无论是【1】中的sleep()方法还是【2】中的eat()方法都是公有的。
  • 缺点:
  1. 由【1】可以看出,要是其中一个实例cat1对sleep()方法进行修改,那么所有实例对象的sleep()方法也会跟着改变,这意味着实例对象cat1、cat2、…没有私有属性
  2. 创建子类实例时,无法向父类构造函数传参;
  3. 无法实现多继承(构造函数继承可解决)

构造函数继承

  • 核心:使用父类的构造函数来增强子类实例,等于是复制父类的实例属性给子类(没有用到原型)
function Animal (name) {
    this.name = name || 'Animal';// 属性
    this.sleep = function(){ // 实例方法
        console.log(this.name + '正在睡觉!');
    }
}
Animal.prototype.eat = function(food) {  // 原型方法
    console.log(this.name + '正在吃:' + food);
};
function Cat(name){
-------------------------------
    Animal.call(this);
-------------------------------
    this.name = name || 'Tom';
}
// Test Code
var cat1 = new Cat();
var cat2 = new Cat()
console.log(cat1.sleep===cat2.sleep) // false ---------------------------------- 1
console.log(cat1.eat('fish')); // Uncaught TypeError: cat1.eat is not a function --------------- 2
console.log(cat1.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
console.log(cat1 instanceof Animal); // false
console.log(cat1 instanceof Cat); // true
复制代码
  • 特点:
  1. 由【1】可看出每个子类实例cat1、cat2、…的属性/方法都是私有的,非共享的
  2. 如果删去this.name = name || 'Tom';console.log(cat1.name); // "Animal",说明这里this.name = name || 'Animal';的name原本是”Tom”不是null,这说明了创建子类实例时,可以向父类传递参数,而且call多个父类对象可以实现多继承
  • 缺点:
  1. 由【2】可看出,子类的实例只能继承父类的实例属性和方法,不能继承父类原型的属性/方法
  2. 无法实现函数复用:也是由【2】可看出的(因为无法继承父类原型所以无法做到复用)。

组合继承(原型链继承+构造函数继承)

  • 核心:相当于构造继承和原型链继承的组合体。
    • 通过调用父类构造(对应【4】),继承父类的属性并保留传参的优点(构造函数继承的优点)
    • 通过将父类实例作为子类原型(对应【5】),实现函数复用(原型链继承的优点)
function Animal (name) {
    this.name = name || 'Animal';// 属性
    this.sleep = function(){ // 实例方法
        console.log(this.name + '正在睡觉!');
    }
}
Animal.prototype.eat = function(food) {  // 原型方法
    console.log(this.name + '正在吃:' + food);
};
function Cat(name){
-------------------------------
    Animal.call(this); // --------------------------------- 4
-------------------------------
    this.name = name || 'Tom';
}
-------------------------------
Cat.prototype = new Animal();  // --------------------------------- 5
Cat.prototype.constructor = Cat; // --------------------------------- 5
-------------------------------
// Test Code
var cat1 = new Cat();
var cat2 = new Cat()
console.log(cat1.sleep===cat2.sleep) // false --------------------------- 1
console.log(cat1.eat('fish')); // "Tom正在吃:fish" -------------------------- 2
console.log(cat1.eat===cat2.eat) // true --------------------------------- 3
console.log(cat1.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
console.log(cat1 instanceof Animal); // true
console.log(cat1 instanceof Cat); // true
复制代码
  • 优点:
  1. 由【1】和【2】和【3】可看出:可以继承实例属性/方法,也可以继承原型属性/方法
  2. 由【1】可看出:可以拥有私有属性
  3. 由【2】和【3】可看出:可以实现函数复用
  4. 由【2】和【4】可看出:可以传参
  • 缺点:调用了两次父类构造函数,生成了两份实例(不过也只是多耗了一点内存空间)

寄生组合继承

  • 核心:通过寄生方式(利用中间函数),砍掉父类的实例属性,这样,在调用两次父类的构造的时候,就不会初始化两次实例方法/属性
function Cat(name){
-------------------------------
    Animal.call(this);
-------------------------------
    this.name = name || 'Tom';
}
-------------------------------
(function(){
    var Super = function(){}; // 新建一个"空"属性的构造函数Super
    Super.prototype = Animal.prototype;  // 将Super的原型指向Animal的原型
    Cat.prototype = new Super(); // Cat的原型指向Super创建的实例对象
})();
-------------------------------
// Test Code
var cat1 = new Cat();
var cat2 = new Cat()
console.log(cat1.sleep===cat2.sleep) // false --------------------------- 1
console.log(cat1.eat('fish')); // "Tom正在吃:fish" -------------------------- 2
console.log(cat1.eat===cat2.eat) // true --------------------------------- 3
console.log(cat1.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
console.log(cat1 instanceof Animal); // true
console.log(cat1 instanceof Cat); // true
复制代码

较为推荐

class是ES6新增,是构造函数的语法糖

ES6 中 class 与构造函数的关系

参考文章

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享