前言
从这张图我们可以看出: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都是一个对象,它们的属性/方法就好比是对象的属性/方法,所以用"."来连接。
诶奇怪,这里我们看到新创建的实例对象 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. 函数也是一个对象,它有一些属性和方法
// 形象地理解就是这样:
F = {
F.length // 形参个数
F.arguments // 存放实参的类数组对象
F.name // 函数名称
F.prototype // 函数的原型
F.constructor
F.hasOwnProperty() // 判断属性是否为本身的方法
}
既然如此的话那我们就可以在函数里面添加属性和方法啦:
【添加属性】
F.age = 18
或
function 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'
}
}
按照这个想法,于是我们就有了【构造函数】这个东西
一个构造函数是长这样的:(我们也将其称为类的创建)
那这和对象有什么相似之处吗?有,name也叫属性,只不过是函数的属性。所以构造函数是看起来像对象的函数。而且相比于对象还有个好处,就是 —— 属性的值可以自定义:
如何自定义?就是使用 new 关键字去创建构造函数 F() 的一个实例对象 f,并在括号里传输参数"John"
这里可以看到通过 new 关键字对构造函数 F 生成了实例对象 f,它获得了 name 这个属性。所以这里我们可以看出:构造函数的作用在哪里
构造函数的意义在于:事先指定属性,然后使用 new 创建出无数拥有这些属性的对象(生来就有)
再看一个例子:
var obj = {"name":"nihao"};
------------------
其实可以看成是:
var obj = new Object
console.log(obj) // {}
obj.name = "nihao"
这样的过程
------------------
console.log(obj); // { name: 'nihao' }
所以由此可以看出像Array、Object、Function、Map、Set这些本质上都是一个构造函数。而我们平时使用的一个个数组、一个个对象都是由他们创造出来的实例对象。
另外,透过这个例子我们可以看出:在JavaScript中,对象是由构造函数生成的,所以对象和函数对象没有区别,对象只是函数对象的其中一个
给构造函数(类)添加属性/方法
给构造函数(类)添加属性/方法有两种方式:
- 方式一:将 say() 方法直接添加到构造函数 F 里面(叫做实例方法)
实例对象 f 将无法使用 say(),说明实例对象从构造函数 F 继承的属性是私有的
- 方式二:将say()方法添加到构造函数 F 的
prototype属性对象里面(叫做原型方法)
这里看到实例对象 f 可以使用say()方法里,说明实例对象从构造函数 F 的原型继承的属性是公有的
这里形象地描述一下原因:就好比——(构造函数F)是“真身”,它通过new的方式进行“分身”得到自己的兄弟(实例对象f、f1、f2、f3...)“影分身”,这个期间真身同时将自己从“师傅”(F.prototype)习得的“技能”(属性/方法)同时复制给了影分身,而影分身应该叫真身的师傅为(f.__ proto__),所以真身和影分身们的师傅是同一个。所以师傅有了新技能(新增属性/方法),真身和影分身们就能同时拥有【对应第2个方法成立】。
另外,由于影分身是由真身拷贝出来的,所以真身新增技能,已拷贝出来的影分身并不能也拥有【对应第1个方法失效】。然而此时真身再拷贝出影分身,新的影分身就能拥有新技能了(var f1 = new F(),f1将可以使用say()方法)。
于是:子类(分身)要继承父类(真身)的属性/方法:
- 不仅要继承父类本身的属性/方法(继承真身的属性)
- 还要继承父类的prototype下的属性/方法(继承真身师傅的属性) 这是由于JavaScript中给对象定义属性有两种不同方式所造成的需要。这就有了我们继承的概念
继承的定义
所谓继承,就是把函数对象及其原型的属性/方法继承给创建出来的实例对象。前者保证私有属性,后者保证有公有属性。
回看上面的例子,有三个疑问:
- 为什么直接在构造函数 F 里面添加新属性/方法,已经创建出来的实例对象 f 就不能享有呢?必须得重新再建几个实例对象才可以?
- 为什么在 F.prototype 新增 say() 方法后,实例对象 f 就可以用了呢?
- 而且此时如果打印一下实例对象 f:
诶,实例对象 f 中明明没有say()方法,为什么可以成功执行呢?
关于这个问题的解决,我们需要了解new关键字创建实例对象的过程发生了什么、关于__proto__属性的理解
使用new关键字创建实例对象的过程
这里参考文章 JS中的new操作符
function Base(id){
this.id = id
}
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”。
因为 Base 函数对象的 this 指针替换成 obj,所以一旦 obj 里面
对象的 __proto__属性 —— 被称为“对象的原型”
还是上面那张图,我们看到实例对象 f 除了有name属性,其实还有一个__proto__属性,它也是一个对象,跟prototype分别称为显式原型和隐式原型。我们点开查看__proto__对象的属性:
这里可以看到在实例对象 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()方法")
那么问题来了,那如果在原型里面找不到呢?
如果原型里面找不到,就去原型里的原型去找,还找不到就去原型的原型的原型里找...于是就有了“原型链”的概念
原型链
- 外界访问对象的属性或使用它的方法
- 对象可以通过“.”操作获取到它自身属性/方法
- 如果查不到会到原型对象(__ proto __) 中去查找,
- 如果原型对象中还没有就会把当前得到的原型对象当作实例对象,继续通过(__ proto__) 去查找当前原型对象的原型对象中去找,
- 直到 【找到想要的属性/方法(通过字符串名称去判断的)】或 【__proto __为
null】 时停止 - __ proto __ 为null之前的__ proto __ 是一个
Object
还是上面那个例子,假设我们要使用另外的属性/方法toString(),在实例对象 f 的原型中找不到
从这张图可以看出:实例对象 f 的原型的原型也是一个对象,我们查看这个对象(实例对象f的原型的原型)
发现诶有我们想要的toString()方法,于是停止向上查询。如果还找不到发现这个对象的__proto__为 null 或者说没有这个属性,我们也束手无策,返回报错,表示找不到
细心的小伙伴已经注意到:这个对象便是 Object.prototype ,事实上所有的原型链的 终点 都是 Object.prototype
于是可以得知:如果要判断一个对象 obj1 是否处于另一个对象 obj2 的原型链上,那么判断依据便是:
obj2.__proto__ === obj1.prototpye,如果为 true 便是,如果为 false 便不是
那么问题来了,为什么不直接在 f() 里面添加 say() 就好,要在 F.prototype 里面添加?
继承的意义在哪
其实在前言中已经回答了这个问题,arr、obj、map、set有千千万万个,我们不可能每次创建一个新的arr/obj/map/set都要给它们添加属性/方法,不仅浪费时间,而且还会浪费空间。
同理,如果直接在 f 里面添加 say(),那另外 new 出来的 f1、f2、f3、...等实例对象都不能享有 say() 方法,所以不可取。
虽然说在构造函数 F 里面添加 say() 方法,然后再 new 出来的 f1、f2、f3、...等实例对象都能享有 say() 方法,不过是私有的,互不干扰,但还是会浪费空间。在 F.prototype 里面添加属性/方法,这样的添加方式是公有的,不浪费空间,也就是说通过构造函数 F 创建出来的实例对象都享用这个共同的属性/方法,但也会带来弊端:互相干扰。
所以怎么既能保证实例对象们既有私有属性/方法,又有公有的属性/方法呢,于是我们就得研究一下继承的方式,在说继承的方式前我们得了解一下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__里面找,所以上式才会成立
我觉得constructor这个属性的作用在于:
保证原型链不会被破坏:
constructor属性与prototype属性相对,函数的prototype属性指向其原型,而函数的原型的prototype属性指向其构造函数
继承的方式有哪些
继承方式有:
- 构造函数继承
- 原型链继承
- 组合继承(组合了原型链继承和构造函数继承)
- 寄生组合继承
- 原型式继承 关于理解以上的继承方式,我觉得最重要的是分清私有属性和公有属性,以及继承最终想要的结果:
- 注意凡是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.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
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 instanceof Animal); // true
console.log(cat1 instanceof Cat); // true
- 解析:解释一下【1】和【3】
- cat1 和 cat2 的方法来自于 Cat.prototype 对象,而
Cat.prototype = new Animal()这一步 上面 已经说了:Cat.prototype 对象既能拿到实例方法创建的私有属性,也能拿到原型方法创建的公有属性,而 cat1 和 cat2 拿到的属性都是来自于 Cat.prototype 对象,这对于它们来说都是公有的,所以【1】和【3】成立
- 优点:
- 既能继承父类实例的属性和方法,也能继承父类原型的属性/方法。由【1】和【2】和【3】综合分析得出的。
- 可以复用。由【1】和【2】和【3】综合分析可看出。基于原型链,构造函数所创建的实例中属性就不再是私有属性了,而是在原型中能共享的属性。无论是【1】中的sleep()方法还是【2】中的eat()方法都是公有的。
- 缺点:
- 由【1】可以看出,要是其中一个实例cat1对sleep()方法进行修改,那么所有实例对象的sleep()方法也会跟着改变,这意味着实例对象cat1、cat2、...没有私有属性
- 创建子类实例时,无法向父类构造函数传参;
- 无法实现多继承(构造函数继承可解决)
构造函数继承
分身不同真身:有私有技能;但分身无法习得真身师傅的技能
- 核心:使用父类的构造函数来增强子类实例,等于是复制父类的实例属性给子类(没有用到原型)
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); // 调用 Animal 函数,生成 Cat 函数的属性和方法
-------------------------------
this.name = name || 'Tom';
}
// Test Code
var cat1 = new Cat();
var cat2 = new Cat()
console.log(cat1.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
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 instanceof Animal); // false
console.log(cat1 instanceof Cat); // true
- 解析:解释一下【1】和【2】
- 执行
Animal.call(this)其实就是相当于 new 过程中的一步而已,只是少了创建新对象和修改对象的__proto__两个步骤,这行代码的意思就是把 Animal 类的 this 指向规定为 Cat 类的 this 指向,就好比拷贝了一份 Animal 类的属性/方法给 Cat 类,所以【1】成立。 - 而这期间没有用到 new,也没有拷贝 Animal.prototype 的属性,所以【2】成立
- 特点:
- 由【1】可看出每个子类实例cat1、cat2、...的属性/方法都是私有的,非共享的
- 如果删去
this.name = name || 'Tom';则console.log(cat1.name); // "Animal",说明这里this.name = name || 'Animal';的name原本是"Tom"不是null,这说明了创建子类实例时,可以向父类传递参数,而且call多个父类对象可以实现多继承
- 缺点:
- 由【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 // 矫正constructor属性,不破坏原型链
-------------------------------
// Test Code
var cat1 = new Cat();
var cat2 = new Cat()
console.log(cat1.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
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 instanceof Animal); // true
console.log(cat1 instanceof Cat); // true
- 解析:解释一下【1】和【3】
- 【1】成立的原因是因为【4】,【3】成立的原因是因为【5】,上面已经都解释了
- 优点:
- 由【1】和【2】和【3】可看出:可以继承实例属性/方法,也可以继承原型属性/方法
- 由【1】可看出:可以拥有私有属性
- 由【2】和【3】可看出:可以实现函数复用
- 由【2】和【4】可看出:可以传参
- 缺点:调用了两次父类构造函数,生成了两份实例(不过也只是多耗了一点内存空间)
寄生组合继承
效果跟组合继承一样,但实现方式完全不同,寄生组合式继承是借助中间方来继承
- 核心:通过寄生方式(利用中间函数),拷贝一份父类原型,这样,在调用两次父类的构造的时候,就不会初始化两次实例方法/属性
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';
}
--------------------------------------------------------------
(function(){
var Super = function(){}; // 新建一个"空"属性的构造函数Super -------------------------4
Super.prototype = Animal.prototype; // 将Animal.prototype拷贝一份出来 -------------------4
Cat.prototype = new Super(); // Cat的原型指向Super创建的实例对象(分身拷贝真身) ------------4
Cat.prototype.constructor = Cat; // 矫正constructor属性,不破坏原型链
})();
--------------------------------------------------------------
// Test Code
var cat1 = new Cat();
var cat2 = new Cat()
console.log(cat1.name); // "Tom"
console.log(cat1.sleep()); // "Tom正在睡觉"
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 instanceof Animal); // true
console.log(cat1 instanceof Cat); // true
较为推荐
寄生组合式继承和组合继承的区别在于这里:
(function(){
var Super = function(){};
Super.prototype = Animal.prototype;
Cat.prototype = new Super();
Cat.prototype.constructor = Cat;
})();
与
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;
(相比于组合继承少了一份“真身”,可能是因为`垃圾回收机制`吧)
(这也是为什么寄生组合式继承的核心步骤要放在一个自执行function里面的原因吧)
解释构造函数继承的传参原理
//父类
function Super(){
this.sss=1
}
//子类
function Sub(){
//arguments是Sub收到的参数,将这个参数传给Super
Super.apply(this, arguments)
}
//实例
sub = new Sub()
Super.apply(this, arguments)这一句,将Super类作为一个普通函数来执行,但是Super类的this被换成了Sub类的this,Sub收到的参数也传给了Super 最后执行结果相当于sub.sss=1(这也就完成了继承)
class是ES6新增,是构造函数的语法糖
这是构造函数书写方式的类:
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.toString = function () {
return '(' + this.x + ', ' + this.y + ')';
};
基本上,ES6 的 class 可以看作只是一个语法糖,它的绝大部分功能 ES5 都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。上面的代码用 ES6 的 class 改写,就是下面这样:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
上面代码定义了一个“类”,可以看到里面有一个 constructor() 方法,这就是构造方法,而 this 关键字则代表实例对象。这种新的 Class 写法,本质上与本章开头的 ES5 的构造函数 Point 是一致的。
Point 类除了构造方法,还定义了一个 toString() 方法。注意,定义 toString() 方法的时候,前面不需要加上 function 这个关键字,直接把函数定义放进去了就可以了。另外,方法与方法之间不需要逗号分隔,加了会报错
面试题
写一个函数:function Person(name) {} 支持 const p1 = new Person('Deck') 和 const p2 = Person('Jack') 两种不同方式构造实例
function Person(name){
if(this !== window){
this.name = name
}
else{
let obj ={}
obj.name = name
obj.__proto__ = Person.prototype
return obj
}
}
const p1 = new Person("Deck")
const p2 = Person("Jack")