JS原型链与继承理解

99 阅读15分钟

原型链

基础

构造函数创建对象:

function Person() {

}
var person = new Person();
person.name = 'Kevin';
console.log(person.name) // Kevin

// Person 就是一个构造函数,我们使用 new 创建了一个实例对象 person

prototype

每个函数都有一个 prototype 属性

每一个JavaScript对象(null除外)在创建的时候就会与之关联另一个对象,这个关联对象即==原型==,每一个对象都会从原型中”继承“属性

为了方便理解,将Person函数理解为类,将实例化person理解为对象,只有类拥有prototype属性,而对象没有

function Person() {

}

// prototype是函数才会有的属性
Person.prototype.name = 'Kevin';
Person.prototype.age = '24';
Person.sex = 'boy';

var person1 = new Person();		// 实例化
var person2 = new Person();

console.log(person1.name)       // Kevin
console.log(person2.age)        // 24	每一个成员都会继承原型中的所有属性

proto

每一个JavaScript实例对象(除了 null )都具有的一个属性proto,这个属性会指向该类的原型

对象拥有_proto_属性,指向原型

function Person() {

}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true  实例化对象的proto属性等于原型

constructor

每个原型都有一个 constructor 属性指向关联的构造函数,实例原型指向构造函数

function Person() {

}
console.log(Person === Person.prototype.constructor); 	// true

==图里没画上的线路:对象person可通过constructor得到类Person==

原型、类、对象

原型

可理解为该类拥有的属性,若一个类想要操作一个属性,不能直接进行操作,必须通过原型来进行(函数对象里声明的变量不是属性),即原型、类、对象的关系可以进一步缩减理解为类和对象之间的关系,原型只是对象拥有的属性,类和对象能通过一些方法(prototype、proto)来访问到这个属性

function Person() {

}
Person.prototype.name = 'Kevin';		// 通过原型
Person.sex = 'boy';						// 直接操作

console.log(Person.prototype)			// Person { name: 'Kevin'}
console.log(Person.sex)					// undefined	直接设置属性失败
console.log(Person.name)				// undefined	直接读取属性失败
console.log(Person.prototype.name)		// Kevin		

声明对象功能并实例化一个对象

对象

每一个对象都拥有类的全部功能,当然也可拥有类的属性,而类的属性存放在原型,通过原型进行操作,因此当对象要拿到类属性,只能在原型中拿(当对象中添加了同名属性时,优先度对象>类),当对象中找不到指定属性时,才会往上层寻找

function Person() {

}

Person.prototype.name = 'Kevin';

var person = new Person();

person.name = 'Daisy';
console.log(person.name) 	// Daisy

delete person.name;			// 删除成员person中刚添加的name属性
console.log(person.name) 	// Kevin

对象、类、原型、Object之间的关系

Object.prototype.age = '24';

function Person() {

}

var person = new Person();
Person.prototype.age_ = '23';

console.log(Person.prototype);   		// Person { age_: 23 }
console.log(Person.prototype.age) 	 	// 上面显示的是原型自己内部的属性,实际自己也继承了Object原型的age 
console.log(Person.age);         		// 24
console.log(person.age_);        		// 23
可以分为两条链路理解:
Object原型 → Object类 → Object对象(Object成员即其他对象的原型)
原型 → 类 → 对象

这样的好处就是只需要记住原型、类、对象的关系三者之间固有的关系即可(加上记住其他类原型即Object三者关系中的对象)

完整的原型链

实例可以直接找到原型中的属性,而构造函数不行

function Test () {

}

Test.prototype.names = 'lucy'

const test = new Test()

console.log(Test.names)             // undefined
console.log(test.names)             // lucy
console.log(Test.prototype.names)   // lucy

Test.names = 'jack'                 // 在构造函数自身新建一个属性(非原型上)
console.log(Test.names)             // jack

new的过程

使用new操作符创建对象实例时发生的事情:

  1. 创建一个新对象
  2. 将构造函数的作用域赋给新对象(this指向该对象)
  3. 执行构造函数的代码(因此构造函数中使用this声明的属性和方法会复制给新对象)
  4. 返回新对象
function Person() {
    this.name = 'lucy'
    const age = 12
}

const person = new Person()
// 等同于
const person = new Object()
person.name = 'lucy'	// 没有this声明的属性不会进行赋值

// 可以理解成,因为有this的存在,new的时候this实际是person,执行一遍构造函数就是this.name = 'lucy' 等同于 person.name
// 因此person里就有了name对象

任何函数,只要通过new操作符来调用,那么它就可以作为构造函数;任何函数,如果不通过new操作符来调用,那它与普通函数并无区别


继承

主要实现思想:

  • 将父实例作为子构造函数的原型,这样子实例就能顺着原型链找到上级的原型使用到Father原型的属性和方法(实例化子类时,优先调用的是父类的构造函数,因此实际上实例constructor的结果是父类构造函数,但是执行的是子类构造函数中的方法)
function Father() {
	this.age = 12
    this.action = function() {}
}
function Child() {
	console.log('执行子类构造方法') 
}

Father.prototype.name = 'lucy'

Child.prototype = new Father()			
const child = new Child()				// 执行子类构造方法

console.log(child)						// Father {}  优先调用父类构造函数,执行子类构造函数的方法,因此是Father类型,子类构造函数无this传递内容,因此内容为空
console.log(child.name)                 // lucy
console.log(child.constructor)          // [Function: Father]	优先调用父类构造函数
console.log(child.__proto__)            // Father {}
console.log(child.__proto__.__proto__)  // Father { name: 'lucy' }

注意:Child.prototype = new Father()是将构造函数中this的属性和方法传到子类的原型中而非实例,实例自身找不到该属性和方法时可以顺着原型链往上找到原型中的属性和方法

function Person() {
    this.name = 'lucy'
    this.action = function() {}
}

function PersonChild() {
    
}

PersonChild.prototype = new Person()		    
const person = new PersonChild()

console.log(person)             // Person {}
console.log(person.__proto__)   // Person { name: 'lucy', action: [Function] }
  • 在子类的构造函数中使用更改this指向,这样在new一个子类实例的时候,执行子类构造函数方法,调用Person.call(this, name, age),this指向子类,因此会在子类实例中创建父类this暴露的属性
function Person(name, age) {
    this.name = name
    this.age = age 
    this.info = function() {
        console.log(name, age)
    }
}

function PersonChild(name,age) {
    Person.call(this, name, age)	// 执行一遍Person构造函数的方法,在new实例时this指向实例,因此相当于给子实例设置了一遍父类的this属性和方法,因此子类实例拥有了父类构造函数中的属性方法
}

PersonChild.prototype = new Person()
const person = new PersonChild('lucy',12)	

console.log(person)     // Person { name: 'lucy', age: 12, info: [Function] } 
person.info()           // lucy 12

  • 原型链继承

优点:能通过instanceOfisPrototypeOf的检测,因为构成了原型链

缺点:无法继承父类构造函数中想要public的属性,因为不用this声明,PersonChild.prototype = new Person()不会在子类原型中创建相应属性,子实例就无法通过原型寻找到该属性

使用情况:

function Person(name, age) {
    this.name = name
    this.age = age 
    this.run = function() {
        console.log(`${name}, ${age}`)
    }
}
Person.prototype.sex = '男'
Person.prototype.obj = {x:1}

function PersonChild(name, age) {
    this.name = name
    this.age = age
}

PersonChild.prototype = new Person()		    // 将实例作为PersonChild的原型	
const person = new PersonChild('张三', 20)

person.run()                                    // undefined, undefined(没有读取到父类构造函数中的属性)
console.log(person.sex)                         // 男(可以继承原型链的属性)
          
person.__proto__.__proto__.sex = '女'           // 通过子类实例修改原型方法
const person_ = new PersonChild('李四',30)
console.log(person_.sex)                        // 女(因为原型被修改了,新的实例本身没有sex属性,从原型里找,找到被修改后的sex属性)


const per = new Person()
console.log(person.constructor)                 // [Function: Person]
console.log(person.__proto__)                   // Person { name: undefined, age: undefined, run: [Function] }(因为没有直接使用Person构造函数,因此undefined)
console.log(person.__proto__.__proto__)         // Person { sex: '女', obj: { x: 1 } }
console.log(per.__proto__)                      // Person { sex: '女', obj: { x: 1 } }    

console.log(person instanceof PersonChild)		// true
console.log(person instanceof Person)			// true

  • (借用构造函数)对象冒充实现继承

优点:创建子实例的时候,父类构造函数里this的属性会在子实例中复制一遍

缺点:子实例无法通过原型共享原型属性

function Person(name, age) {
    // 构造函数里的方法和属性
    this.name = name
    this.age = age 
    this.run = function() {
        console.log(`${this.name}, ${this.age}`)
    }
}

Person.prototype.sex = '男'


// 对象冒充实现继承
function PersonChild(name, age) {    
    Person.call(this, name, age)   
}

const person = new PersonChild('张三', '20')
person.run()	// 张三 20
console.log(person.sex)     // undefined
console.log(person instanceof PersonChild)		// true
console.log(person instanceof Person)			// false

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

优点:继承两者优点

缺点:两次调用父类构造函数,浪费内存

function Person(name, age) {
    this.name = name
    this.age = age 
    this.run = function() {
        console.log(`${name}, ${age}`)
    }
}
function PersonChild(name, age) {    
	Person.call(this, name, age)
}

PersonChild.prototype = new Person()

const person = new PersonChild('lucy', '20')

  • 原型式继承

使用场合:没必要构建构造函数,仅仅是想模拟一个对象的时候

// 传进一个需要被继承的父对象,将父对象绑定为构造函数的原型,这样实例就能通过原型找到父对象属性
function createChild(Father){
    function Child() { 
       
    };
    Child.prototype = Father
    return new Child()  // 传回一个实例
}
var Father = {
    name: 'jack',
    friends: 'mary'
};

const child = createChild(Father);
console.log(child.__proto__)    // { name: 'jack', friends: 'mary' }
console.log(child.name)         // jack
console.log(child)              // {} 只是一个空对象,主要属性从原型里拿

  • 寄生继承

缺点:方法在函数中定义,无法得到复用

// 创建一个仅用于封装继承过程的函数,传入一个对象,为对象增加特定的一些属性或方法,然后再将改动的对象返回,这样执行过这个方法的对象都会拥有新增的属性和方法
function createAnother(original){
    var clone = Object.create(original)       // 令clone.__proto__ = original 这样clone的属性如果自身找不到就从原型original上获取
    
    clone.sayHi = function(){ 
       console.log("hi");
    };
    
    console.log(clone)     // {name: 'jack', friends: 'mary', sayHi: [Function]}
    return clone; 
}

var person = {
    name: "jack",
    friends: 'mary'
};

const anotherPerson = createAnother(person);
anotherPerson.sayHi()

  • 寄生组合继承(最理想):

最理想的原因(对比组合继承):

  1. 只调用了一次父类构造函数
  2. 父类的构造函数和子类原型是没必要有联系的,寄生组合切断了这种联系,减轻原型链
function createChild(Child, Father){
    var ChildPrototype = Object.create(Father.prototype)
    ChildPrototype.constructor = Child   
    Child.prototype = ChildPrototype
}

function Father(name){
    this.name = name
    
}

Father.prototype.attribute = '这里是父类的原型属性'

function Child(name, age){
    Father.call(this, name)
    this.age = age
}

createChild(Child, Father)  //实现继承

const child = new Child('mary',12)
console.log(child)                          // Child { name: 'mary', age: 12 }
console.log(child.constructor)              // [Function: Child]  因为没有直接与父类构造函数绑定,因此构造函数的类型为Child而不为Father
console.log(child.__proto__)                // Child { constructor: [Function: Child] }
console.log(child.__proto__.__proto__)      // Father { attribute: '这里是父类的原型属性' }

createChild()方法内部干的事情


原型链的缺点:

一个实例操作一个自身没有,但是原型拥有的属性时,如果是值类型,实例会在自身新增该属性并赋值,但是如果是引用类型,会直接修改到原型中

可以用const来复制理解,const是一个常量

cosnt num = 1
const obj = {value: 1}

const num = 2 				// 报错
const obj.value = 2			// {value: 2}

即理论上原型不允许实例对自身的属性进行修改,如果修改的值类型,因为变量和值都存放在栈中,因此当实例想要修改一个原型中的值类型属性时,会默认在实例自身中进行修改而不是修改原型的,但是如果修改的是原型的引用类型,原型只能判断变量的引用地址有没有发生变化,因此修改引用类型是不会被原型发觉的,且默认在实例中找不到指定属性时便往原型中搜索,因此便直接修改到了原型的引用类型属性而非在实例自身中增加属性

即:实例想对一个属性进行操作

  • 判断实例中有没有该属性

  • 如果没有,往上寻找原型中是否有

    • 判断是否是读操作,如果是读操作,则直接读取

    • 如果是写操作,则默认不可写,不会再往上级原型寻找,退回并实例中创建并写入该属性

const Prototype =  {
	num : 1,
	obj : {
		value : 1
	},

}

const Instance = Object.create(Prototype)


console.log(Instance)			// { }
console.log(Instance.num)		// 1  
console.log(Instance.obj)		// { value: 1 }


Instance.num = 2				// 修改值类型,发现原型不会改变,实例内部增加属性
console.log(Prototype)			// { num: 1, obj: { value: 1 } }
console.log(Instance)			// { num: 2 }


Instance.obj.value = 2			// 修改引用类型里的值,发现原型属性被更改,实例内部不会增加属性
console.log(Prototype)			// { num: 1, obj: { value: 2 } }
console.log(Instance)			// { num: 2 }


Instance.obj = {value : 3}		// 修改obj的引用地址,发现原型不会改变,实例内部增加属性
console.log(Prototype)			// { num: 1, obj: { value: 2 } }
console.log(Instance)			// { num: 2, obj: { value: 3 } }

如何解决这个问题:

思路:既然实例写操作非自身属性的引用类型时会更改到原型,那解决方式就是,在实例构造出来的时候,将该引用类型的属性在new实例时在实例中也建一个,这样实例修改该属性时,便会找到自己的修改非原型上的

function Father() {
	this.num = 1
    this.obj = {value:1}
}
function Child() {
	Father.call(this)			// 继承时实例要使用call函数的另一种原因
}

// 调用Father(),this指向Child构造函数原型,因此此时会在Child的原型中创建num和obj属性
Child.prototype = new Father()

// 调用Child(),执行Father.call(this),this指向child实例,因此会在实例中创建num和obj
const child = new Child()				

console.log(child)				// Father { num: 1, obj: { value: 1 } }
console.log(child.__proto__)	// Father { num: 1, obj: { value: 1 } }

child.obj.value = 2				// 实例操作引用类型属性,发现只会更改自己的
console.log(child)				// Father { num: 1, obj: { value: 2 } }
console.log(child.__proto__)	// Father { num: 1, obj: { value: 1 } }

类和构造函数的区别

ES5中函数定义类(构造函数):

function Foo(x,y) {
    this.x = x;
    this.y = y;
}

Foo.prototype.toString = function(){
	console.log(this.x, this.y)  
}

var foo = new Foo(1,2)
foo.toString()  

// 1 2

ES6中定义类:

调用类的方法也就是调用原型(prototype)上的方法

class Foo {   
    constructor(x,y){
     this.x = x;
     this.y = y;
    }
    
    toString(){
     console.log(this.x, this.y)
    }
}

var foo = new Foo(1,2)
foo.toString()  

// 1 2

ES6中,class实际上也是一个function对象,其原型链与es6一致,但有几点需要注意

  • 类里的constructor()方法表示构造函数,与原型链中的.constructor无关
  • 在class中声明的函数会直接添加到原型中,可以直接使用,不需要经过原型
  • 通过new关键字生成实例时,只会执行constructor构造函数里的内容
class Foo {   
    constructor(x,y){
     this.x = x;
     this.y = y;
    }
    
    toString(){
     console.log(this.x, this.y)
    }

    
}

Foo.prototype.name = 'Jack'
var foo = new Foo(1,2)

console.log(Foo)                            // Function: Foo]
console.log(foo)                            // Foo { x: 1, y: 2 }

console.log(Foo === foo.constructor)        // true    成员访问对象     foo.__proto__.constructor

console.log(Foo.prototype)                  // Foo { name: 'Jack' }     对象访问原型
console.log(foo.__proto__)                  // Foo { name: 'Jack' }     成员访问原型

es5的构造函数和静态方法

function Person(name, age) {
    // 构造函数里的方法和属性
    this.name = name
    this.age = age 
    this.run = function() {
        console.log(`${this.name}, ${this.age}`)
    }
}

// 设置原型属性
Person.prototype.sex = '男'
Person.prototype.work = function() {
    console.log('work')
}

// 设置静态方法
Person.setName = function() {
    console.log('静态方法')
}

var person = new Person('张三', '23')
person.run()    // 实例共享原型的属性
person.work()


Person.setName()    // 用构造函数来执行静态方法

ES6里的类和静态方法

类里直接声明的函数实际上是在原型中进行声明,如果要特地指明静态方法,需要用static关键字,这样实际上就是Person.xxx直接添加类的一个属性

class Person {
    
    say() {
        console.log('实例方法')
    }
    
    static work() {
       	console.log('静态方法')
    }
}

const person = new Person()
person.say()	// 实例内部找不到该方法时,可以通过原型找到该方法并调用
Person.work()	// 静态方法相当于类的一个属性,因此可以通过类来直接调用

类的继承

子类构造函数的原型为父类构造函数

  • 类中直接定义的方法,实际上是在原型中定义,但是它是隐式的
class Demo {
	classFun() {
        
    }   
}
console.log(Demo.prototype)				// Demo {}
console.log(Demo.prototype.classFun)	// [Function: classFun] 实际上是有的
Demo.prototype.other = 'other'			// 直接通过原型声明的话会显示
console.log(Demo.prototype)				// Demo { other: 'other' }
  • 使用extentds关键字实现继承时,实例化子类时,会执行父类的构造函数constructor()方法
class Father {
    constructor() {
		console.log('执行父类构造函数')
		this.fatherAttribute = '这里是父类的属性'
	}
}

class Child extends Father{

	
}

const child = new Child()	// 打印:'执行父类构造函数'
// 相当于视实例child去调用了父类的构造函数,this指向child,因此会在child创建 fatherAttribute
console.log(child)		// Child { fatherAttribute: '这里是父类的属性' }
  • 在一个使用extentds关键字实现继承的类中,如果不声明constructor()方法,在实例化子类时会默认调用父类的构造方法,但是如果声明了constructor(),必须要手动去调用父类构造函数(super()),否则子类constructor()构造函数会报错
class Father {
    constructor() {
		console.log('执行父类构造函数')
		this.fatherAttribute = '这里是父类的属性'
	}
}

class Child extends Father{
	constructor() {
		super()		// super()表示调用父类的构造函数,声明构造函数而不调用super()会报错
	}	
}
  • 可以在子类构造函数里使用super.xxx去调用父类在原型中声明的方法
class Father {
    constructor() {
		console.log('执行父类构造函数')
		this.fatherAttribute = '这里是父类的属性'
	}

	implicitFunction() {
		console.log('调用父类原型中的隐式方法')
	}
}

Father.prototype.explicitFunction = () =>{
	console.log('调用父类原型中的显式方法')
}

class Child extends Father{
	constructor() {
		super()					 
		super.implicitFunction()	 // 调用父类原型中的隐式方法
		super.explicitFunction()	 // 调用父类原型中的显式方法
	}	
}
  • 继承在原型链中的关系:

    和ES5原型链继承有所不同,类(类也是构造函数)在继承中,子类构造函数的原型不是父类的实例,且__proto__指向父类构造函数而非Function的原型

    父类构造函数的__proto__仍指向Function原型

class Father {
	constructor(){		// 注明:如果父类中没有声明构造函数,则无法成功被继承

	}
}

class Child extends Father{

}

Child.prototype.pro = '子类原型属性'

const child = new Child()	

console.log(child.constructor == Child)							// true
console.log(child.__proto__.constructor == Child)				// ture					
console.log(child.__proto__.__proto__ == Father.prototype)		// true
console.log(child.constructor.__proto__ == Father)				// ture
console.log(child.__proto__.__proto__.constructor == Father)	// true
console.log(Child.__proto__ ==  Father)							// true
console.log(Child.prototype)									// Child { pro: '子类原型属性' }

ES5使用原型链继承中,子类和父类构造函数的__proto__都是指向Function原型的

function Father() {
	this.num = 1
    this.obj = {value:1}
}
function Child() {
	Father.call(this)			
}

Child.prototype = new Father()
const child = new Child()				
	
Function.prototype.app = 'app'

console.log(Child.__proto__)		// { [Function] app: 'app' }
console.log(Father.__proto__)		// { [Function] app: 'app' }

类的单例设计

一个类可以生成多个实例,每次new的实例都是互不干扰的,单例就是让类生成一个固定的实例

class Person {
    
    constructor() {
        this.name = 'lucy'
    }
    
    // 一个静态方法,用于生成一个单例
    static getInstance() {
        if(!Person.instance){   // 如果尚未创建实例,则进行创建,如果存在,则一直返回该实例
            Person.instance = new Person()
        }
        return Person.instance
    }
}
const person = Person.getInstance()		// 注意,这里既然想用一个单例,则就不用new关键字去生成实例了,如果直接使用new还是会生成互不干扰的各个实例

console.log(person.name)	    // lucy
person.name = 'jack'          // 对实例属性进行修改

const person_ = Person.getInstance()
console.log(person_.name)     // jack,可以证明用该方法生成的实例,会返回同一个实例

const personA = new Person()  // 使用new关键字可以创建其他实例,实例间互不干扰
console.log(personA.name)     // lucy