JS的原型和原型链历程|青训营笔记

123 阅读10分钟

这是我参与「第四届青训营 」笔记创作活动的的第3天

今天分享的是关于JS在面向对象上尤其是类和继承方面的实现历史,便于各位理解。

JS的原型演变之路

场景问题驱动JS在继承和类的方向上不断前进。

我们将函数上的prototype属性称为原型属性(显示原型),prototype属性对应的值是一个对象,我们称为原型对象。每一个对象上都有一个[[prototype]]属性称为原型指针(隐士原型),它指向构造函数的prototype属性,即指向构造函数的原型对象。

我们在很多场景下都需要某一自定义类型数据,每此使用的时候再去构造,代码虽然可以CV,但是有违背减少重复代码、抽象的思想,也不利于我们后期的维护工作。因此我们开始效仿C++、Java等面向对象的思想。

偷懒是第一生产力

1.0 工厂模式立大功

众所周知,工厂模式都是返回一个特定的对象,实现批量生产,实际使用还是比较广泛的一种。

        function factory(name='张三',age=18,job='老师'){
            let o=new Object();
            o.name=name;
            o.age=age;
            o.job=job;
            o.say=function(){
                console.log(this.name);
            }
            return o;
        }
        let person=factory();
        person.say();//张三
        console.log(person);//{name: '张三', age: 18, job: '老师', say: ƒ}
优点

可以通过函数式(没有new的过程),将new操作内嵌到函数中来生成我们想要的对象,而且也支持参数的传递来初始化对象。

缺点

由于内部通过new一个object对象,再在其身上添加我们想要的属性和方法,所以我们工厂实例化出的对象都是Object,缺少类型标识符,比如Person类。

1.1 构造函数解决实例对象标识问题

针对工厂模式的不足,我们利用构造函数这一特殊函数类解决实例对象标识问题。其原理是构造函数在声明的时候,会默认在内存中开辟一个原型对象,根据规则原型对象中constructors属性指向构造函数,两者构成一个闭合的回环。

        function Person(name,age,job)
        {
            this.name=name;
            this.age=age;
            this.job=job;
            this.sayName=function(){
                console.log(this.name)
            }
        }
        let p1=new Person('张三','18','老师');
        console.log(p1);//Person {name: '张三', age: '18', job: '老师', sayName: ƒ}

优点

通过构造函数以new一个对象的形式解决了实例化对象的所属类别的标识问题,支持实例初始化时参数传递。

缺点

虽然实现了批量生产,new的方式来完成对象属性的添加,但是方法没有实现共用,每创建一个对象,就会创建一个对应的sayName方法。即方法不能达到重用,跟我们期望的显然不符。

1.2构造函数的方法重用

通过引用值的方式传递方法的地址,解决一个实例对应一个方法的问题。实现了方法的重用。

        function sayHi(){
            console.log(this.name)
        }
        function People(name,age,job){
            this.name=name;
            this.age=age;
            this.job=job;
            this.sayName=sayHi;
        }
优点

在原来的基础上,只需要在全局定义方法,无需其他操作就实现了方法的重用。

缺点

成也萧何败萧何,通过全局书写重用的方法,解决了重用的问题。但每写一个构造函数,对应共用的方法却要写在全局作用域内,破坏了局部和全局的划分,且该方法只能在对应构造函数实例化对象上使用。

1.3原型模式

我们知道构造函数、原型对象、实例化对象三者间的关系,即同一个构造函数下若干个实例化对象对应着同一个原型对象,那么我们将共有的属性和方法放到原型对象上就可以实现“类”的概念。

        function Person(){};
        Person.prototype.name='张三';
        Person.prototype.age=18;
        Person.prototype.job='老师';
        Person.prototype.arr=[];    
        Person.prototype.say=function(){
            console.log(`${this.name},${this.age},${this.job}`)
        };
        let p1=new Person();
        let p2=new Person();
        p1.say();//张三,18,老师
        console.log(p1.__proto__==p2.__proto__);//true
优点

实现了对于公共部分属性和方法在所有实例化对象间的共用。

缺点

1、每次通过.prototype.一个个添加属性和方法比较麻烦,但是通过.prototype=new Object()这样又会修改默认下constructor属性的指向,自然也可以在外部通过.语法给他添加上指向问题,但又会造成该属性变为可枚举属性,解决方法是通过object.definProperty()来定义constroctor属性。虽然指不指定constructor对于原型模式没有什么太大影响,但保持初心总归是好的。

2、最大问题是原型模式将所有的属性和方法在实例化对象共用,这对于方法和原始值没有太大影响,但是对于引用值,却造成了错误的修改。即引用值在多个实例化对象间是同一个,能够互相影响。

        p2.arr.push('abc');
        console.log(p1.arr);//['abc']

1.4原型链

原型链级JS实现继承的唯一方式。主要是利用的JS引擎搜索对象属性时,如果对象本身没有就会去proto对象搜索,是一个递归搜索的过程,其次原型对象还可以是其他构造函数的实例化对象,即原型对象也有proto属性,通过proto属性构成一个链结构——原型链。

然而实际原型在实现过程中还是遭遇了不少问题,慢慢才演化为比较理想的类状态。

        function SuperType(){
            this.colors=['red','black','blue'];
        }   
        function SubType(){ 
        }
        //继承SuperType
        SubType.prototype=new SuperType();
        let ins1=new SubType();
        let ins2=new SubType();
        ins1.colors.push('pink');
        console.log(ins1.colors);// ['red', 'black', 'blue', 'pink']
        console.log(ins2.colors);// ['red', 'black', 'blue', 'pink']
优点

原型链的操作,无非是新增了继承的概念,即完成了类的派生的概念,将一个构造函数的实例对象作为另一个构造函数的原型对象,前者的实例的属性和方法变成了后者共有的原型对象中的属性和方法。

缺点

原型链其实也是原型模式的延伸罢了,既存在原型对象中引用值在实例对象中共有(不是一个对象一份)的问题,又造成了新原型对象问题,与我们设想的原型链相差甚远,每次“继承”都是覆盖原有的原型对象,上一个构造函数的原型对象不能应用到下派生类上。

1.5盗用构造函数

盗用构造函数实际是通过改变构造函数的执行上下文实现了在实例对象中相互隔离。

        function SuperType(){
            this.colors=['red','black'];
        }
        function Subtype(){
            SuperType.call(this);
        }
        let ins1=new Subtype();
        let ins2=new Subtype();
        ins1.colors.push('blue');
        console.log(ins1.colors);// ['red', 'black', 'blue']
        console.log(ins2.colors);// ['red', 'black']
优点

解决了原型模式和原型链下引用值属性问题,同时通过盗用构造函数可以很方便的进行参数传递来初始化实例对象的属性值。

缺点

虽然解决了属性在继承中的问题,但是方法的继承又出现了新的问题。因为盗用构造函数是改变了执行上下文,this值为新开辟的对象(new出的对象),所以实例间属性才会分割开,自然方法也是一样,这就造成了初代构造函数的问题,方法没有得到重用。

1.6组合继承

原型解决了方法的重用,盗用构造函数解决了熟悉的隔离。这两个结合起来使用就是组合继承。

        function SuperType(name){
            this.name=name;
            this.colors=['red','black'];
        }
        SuperType.prototype.say=function(){
            console.log(this.name);
        }
        function SubType(name,age){
            //继承属性
            SuperType.call(this,name);
            this.age=age;
        }
        //继承方法
        SubType.prototype=new SuperType();
        SubType.prototype.sayAge=function(){
            console.log(this.age);
        }
        let s1=new SubType('张三',18);
        let s2=new SubType('李四',20);
        s1.colors.push('blue');
        console.log(s1.colors);//['red', 'black', 'blue']
        console.log(s2.colors);// ['red', 'black']
        s1.say();//张三
        s2.say();//李四
优点

基本完成了类的概念和实现。

缺点

对于基类,我们在构建继承的时候调用了两边构造函数,一次在盗用构造函数继承属性,一次是原型继承继承方法。同时继承的属性在实例本身上存在一份,在原型对象上还有一份,只不过被覆盖了而已,有点浪费。存在效率问题

1.7原型式继承

原型式继承适合于在已有的对象的基础上再创建一个对象。且新对象和旧对象没有分割开来,即不在乎原型模式下引用值的问题。

	function object(o){
		function F(){}
		F.prototype=o;
		return new F();
	}
	let person={
		name:'张三',
		colors:['red','black']
	};
	let anotherPerson=object(person);
	anotherPerson.name='李四';
	console.log(anotherPerson);//{name:李四}

原型式继承其实就是套用了一个中间件F用来返回包含指定对象(参数·)作为原型对象的新对象。后面可以通过Object.create()方法来做objcet()的事情,相当于官方给规范化了。Object.create(object,object)如果只有一个参数的话就与一开始的方法没有区别,但是加上第二个参数的时候与Object,definProperties()第二个参数一样。

1.8寄生式继承

寄生式继承利用的是寄生式构造函数和工厂模式,创建一个实现继承的函数,可以自定义方法来增强对象,然后返回这个对象。

		function createAnther(original){
			//原型继承
			let clone=Object.create(original);
			//增强
			clone.sayHi=function(){
				console.log('hi');	
			}
			return clone;
		}
		let person={
			name:'张三',
			colors:['red','black']
		}
		let anther=createAnther(person);
		anther.sayHi();//hi

寄生式继承更像原型式继承的加强版,虽然原型式也能够做到增强对象。同样所谓的增强也是通过外部新增函数来控制与构造函数具有同样的缺点,外部增强的函数不能复用。

1.9寄生式组合继承

寄生式组合继承要解决的是组合继承中的效率问题。对于盗用构造函数我们先不管,主要是针对原型继承,这里我们和之前所做的内容不一样,不再是直接new一个对象作为扩展类的原型对象,而是通过寄生式的方法,将基类和扩展类作为参数内部通过将基类的原型对象赋值给扩展类。

		function inheritPrototype(sub,sup){
			let prototype=Object.create(sup.prototype);//创建父构造函数原型对象的副本
			prototype.constructor=sub;//指正constructor指向问题
			sub.prototype=prototype;//修改子类原型对象
		}
		function SuperType(name){
			this.name=name;
			this.colors=['red','black'];
		}
		SuperType.prototype.sayName=function (){
			console.log(this.name);
		}
		function SubType(name,age){
			SuperType.call(this,name);
			this.age=age;
		}
		inheritPrototype(SubType,SuperType);
		SubType.prototype.sayAge=function(){
			console.log(this.age);
		}
		let a=new SubType('张三',18);
		console.log(a);

分析:整体的演变过程下来,我们发现实现类、实现继承都是一步一步在向面向对象的方式靠近,我们通过JS独特的语法、属性,底层原理来模拟了这一过程。其中最为主要的就是将数据属性和方法属性分散开来,分别实现继承,通过new的方式实例化对象,既能够做到属性隔离要要求方法的共享。这里最好的做法就是将方法放到原型对象上,因为方法一般不会改变,属性就通过改变this的指向来将原始值和引用值分别在新对象上创建对应位置,实现分离互不影响。

2.0 过度到类

包括ES6在正式提出关键字class、extends来推动类和继承的发展也都是基于前面的努力,做了语法糖的效果。接下来请看下面例子:

		class Person{
			constructor(){
				this.name='张三';
			}
			sayName(){
				console.log(this.name);
			}
		}
		class SuperPerson extends Person{
			constructor(){
				super();
				this.age=18;
			}
			sayAge(){
				console.log(this.age);
			}
		}
		let a=new SuperPerson();
		console.log(a);

image.png

在constructor中的数据和this就是new的时候调用的默认的“构造函数”,这一点在继承后通过super()调用父类的constructor函数,然后再添加自己的属性和值。这一点我们可以根据截图观察到,name和age属性是直接挂在实例对象下的,因此继承的时候父类的属性时通过盗用构造函数更改执行上下文来完成的,再看其父类方法位于原型对象的原型对象下,可知其是通过寄生式的优化方案inheritprototype()方法实现的,将父类的原型对象包装一个不含属性的对象副本再返回作为子类的原型对象。

至于版本更高点的JS、类、继承可以去直接参考对应的类和继承即可。