听风是风

学或不学,知识都在那里,只增不减。

导航

精读JavaScript模式(八),JS类式继承与现代继承模式其一

一、前言

这篇开始主要介绍代码复用模式(原书中的第六章),任何一位有理想的开发者都不愿意将同样的逻辑代码重写多次,复用也是提升自己开发能力中重要的一环,所以本篇也将从“继承”开始,聊聊开发中的各种代码复用模式。

其实在上一章,我感觉这本书后面很多东西是我不太理解的,但我还是想坚持读完,在以后知识逐渐积累,我会回头来完善这些概念,算是给以前的自己答疑解惑。

二、类式继承VS现代继承模式

1.什么是类式继承

 谈到类式继承或者类classical,大家都有所耳闻,例如在java中,每个对象都是一个指定类的实例,且一个对象不能在不存在对应类的情况下存在。

而在JS中其实并没有原生的类的概念,且JS的对象都可以随意的创建修改,并不需要依赖类。如果真要说,JS也有与类相似的构造函数,其语法也是通过new运算符得到一个实例。

假设工厂要生产一批杯子,接到的图纸信息是,杯子高12cm,杯口直径8cm,按照常识,我们不可能按照信息一个个的去做,最好的方法是直接做一个模具出来,然后灌浆批量生产。

这里每个杯子就是一个对象,一个实例,而这个提前定义好杯子信息的模具就是一个“类”,通过这个模具(类),我们就可以快速生产多个继承了模具信息(高度,直径等)的杯子(实例)了。

//不合理做法
let cup1 = {
  height:12,
  diameter:8
};
let cup2 = {
  height:12,
  diameter:8
};
// ......
let cupn1000= {
  height:12,
  diameter:8
};


//构造函数的做法
function MakeCup () {
  this.length = 12,
  this.diameter = 8;
};
let cup1 = new MakeCup();
let cup2 = new MakeCup();
//.........
let cup1000 = new MakeCup();

在上述代码中,MakeCup就是一个包含了所有实例共有信息的“类”,当然在JS中,我们更喜欢将这个类称为构造函数,毕竟MakeCup只是一个函数,而这种做法也只是与类相似,在这里我们将这种实现方式称为“类式继承”。

虽然我们在讨论类式继承,但还是尽量避免使用类这个字,在JS中构造函数或者constructor更为精准,毕竟每个人对于类的理解可能不同,将类与构造函数混合在一起容易混淆。

2.1类式继承1--默认模式(借用原型)

现在有下面两个构造函数Child()与Parent(),要求是通过Child来创建一个实例,并且这个实例要获得构造函数Parent的属性。我们假设通过inherit函数实现了需求。

function Parent(name) {
  this.name = name || 'Adam';
};
Parent.prototype.say = function () {
  console.log(this.name);
};

//空的child构造函数
function Child(name) {};

//继承
inherit(Child, Parent);

那么这个inherit函数如何实现,第一种思路,我们通过new Parent()得到一个实例,然后将Child函数的prototype指向该实例。

function inherit(C, P) {
  C.prototype = new P();
}
inherit(Child, Parent);

let kid = new Child();
kid.say();//Adam

很明显,构造函数Child继承了构造函数Parent的属性,所以由构造函数Child创建的实例自然也继承了这些属性,那么这个过程中间到底发生了什么?我们尝试跟踪原型链。

提前说明,为了方便理解,我们就假设对象啊,原型啊,都在同一空间内,当我们new Parent()时,就得到了一个实例,此时在内存中也新开了一个空间存放这个实例(下图中的2区域)。

构造函数Parent的原型链

现在我们尝试访问say()方法,但是2号空间并没有这个方法,但是通过_proto_指向Parent构造函数的prototype属性时,居然可以访问这个方法(1区域),这也是为什么我们总在前面说,建议将所有实例都需要用到的属性添加在prototype上,因为这样在每次new时,不用每次新开内存时都创建一次。

我们再来看看在使用inherit函数后,再使用let kid = new Child()创建实例时发生了什么,如下图。

继承之后的原型链

一开始Child构造函数是空的,什么属性都没有(上图1区域),当inherit函数执行时,Child函数的prototype属性指向了new Parent()对象,也就是2区域。

当我们new Child()得到一个实例kid并使用say方法时,由于自身没有,只能顺着_proto_找到了new Parent(),结果此对象也没有,重复了我们上面的图解步骤,继续顺着_proto_找到了Parent.prototype,终于找到了say()方法。

当say()方法被调用时,我们输出this.name,而此时this指向的是new Child(),结果new Child()又没有这个name属性,跟say一样,再找到2,再到1区域,顺利输出了Adam。这样是不是很清晰了呢?

我们再来为实例kid加点属性,看看原型链的变化,如下图

let kid = new Child();
kid.name = 'Patrick'
kid.say();//Patrick

继承并给实例添加属性后的原型链 

当我们为实例添加了name属性时,其实只是为new Child()添加了name属性(区域3),并不会影响到new Parent(),这也是为什么说,每个实例都是一个独立的个体。当我们再次寻找say()方法时,还是一样的顺着_proto_找到了Parent.prototype,而当我们调用say方法输出name属性时,由于当前this指向kid,且kid自己有了name属性,于是顺利输出了Patrick。

而当我们delete kid.name删除掉之前赋值的Patrick时,再次调用,可以发现又输出了Adam,所以原型链继承就是,先从自己身上找,找不到,顺着_proto_向上,直至找到null停止(原型链的顶端是null)。

2.1原型链的优点与弊端

原型链继承的坏处在于,在继承父对象中你想要的属性的同时,也会继承父对象你不想要的属性,比如上方代码,我只想要父对象原型链上的say方法,结果你还是把构造函数中的name属性打包给我了。

上面这种模式的第二个坏处是,我不能给我最终的实例kid传递形参,假设我想最终输出时间跳跃,要么kid.name = ‘时间跳跃’,要么在父构造函数时就传递好参数Parent(‘时间跳跃’)。但这样我们得不停的修改Parent对象。

let kid = new Child('时间跳跃');
kid.say();//Adan

但如果一个属性或方法需要复用,它还是应该被添加在构造函数的原型prototype上;两点理由,第一,加在原型链上,new实例时不需要反复创建属性造成内存浪费,第二,简化构造函数的属性能减轻对不需要这些属性的实例的困扰,这也是原型链继承的好处。 

3.类式继承2---借用构造函数

我们在上个例子中,遇到了无法通过子对象传参到父对象的问题,我们修改Child构造函数,如下,就可以实现子对象传参了。

 function Child(a, b, c, d) {
   Parent.apply(this, arguments);
 };
 let kid = new Child('时间跳跃');
 console.log(kid.name);//时间跳跃

实现原理很简单,当我们new Child()时,通过apply再次应用了Parent函数,但Parent执行时此时的this指向了Child,也就是说Child想有name属性,可是我没有this.name的赋值操作,于是通过apply改变this的原理,借用了Parent函数中的this.name = name || 'Adam'这句代码,变相的来为Child构造函数添加属性,它等同于Child.name = '时间跳跃' || 'Adam'。

注意,此处只是借用这句代码来为Child构造函数添加属性,并没有修改Parent构造函数的属性,我们尝试输出Parent的实例,可以发现name属性仍为Adam。

 let parent = new Parent();
 let kid = new Child('时间跳跃');
 console.log(kid, parent);//时间跳跃  Adam

我们在上面原型链的例子中,Child的实例去继承Parent的属性,说是继承,其实是通过原型链去找,虽然能拿到,但本质上这个属性还是别人的,自己手里没有,哪天Parent心情不好,把name属性给删了,Child啃老的行为也基本到头了。

但下面Child构造函数中使用apply的做法就不同了,我直接借用Parent的代码来为自己添加只属于自己的name属性,管你Parent怎么操作name属性,都跟我不相关。如果说第一种继承是引用,那么这种做法就更像是复制,我复制你有的属性,就不用引用了。

有点授人以鱼不如授人以渔的寓意,也有点深浅拷贝的意思。

 我稍微修改了上面的代码,使用原型链指向继承得到了实例kid与使用call复制属性得到的实例son,分别输出了它们的hasOwnProperty判断,这里答案应该能明白了。

function Parent(name) {
  this.name = ["echo", "时间跳跃", "听风是风"];
};
Parent.prototype.say = function() {
  console.log(this.name);
};
//得到一个实例
let parent = new Parent();
function Child() {};
//修改Chilkd的原型指向
Child.prototype = parent;
function Son() {
  Parent.call(this);
};
let kid = new Child();
let son = new Son();
console.log(parent.hasOwnProperty('name'));//true
console.log(kid.hasOwnProperty('name'));//false
console.log(son.hasOwnProperty('name'));//true

照理说,实例parent与实例son的name属性是自身的,不像kid这个没骨气的是靠引用地址借来的,我们分别修改三个实例的name属性,这段代码是我自己改的,当出个题,看看下面三个console分别输出什么,学继承,也当原型链的题来考考自己。

function Parent() {
  this.name = ["echo", "时间跳跃", "听风是风"];
};

Parent.prototype.say = function() {
  console.log(this.name);
};

let parent = new Parent();
function Child() {};

Child.prototype = parent;

let kid = new Child();

function Son() {
  Parent.call(this);
};

let son = new Son();
parent.name.push('二狗子');
son.name.push('狗剩');
kid.name.push('狗蛋');
console.log(parent.name);//?
let parent1 = new Parent();
let kid1 = new Child();
console.log(parent1.name);//?
console.log(kid1.name);//?

 有没有觉得使用call或者apply的构造函数方式很厉害,但这种模式也有自己的弊端,虽然它借用了父构造函数的属性创建代码,很遗憾它并没办法继承父构造函数的prototype属性。我们写个简单的例子:

function Parent(name) {
  this.name = name || "Adam";
};
Parent.prototype.say = function () {
  console.log(this.name);
};
function Child (name) {
  Parent.apply(this,arguments);
};
let kid = new Child('Patrick');
console.log(kid)//undefined

跟上面一样,我们通过原型图来看看这段代码继承关系。

尽管我们通过改变this指向为kid创建了name属性,但当找say方法时,由于此时的this指向Child,而Child的prototype并没有提供这个方法,所以无法找到。

有了解过new一个函数究竟发生了什么的同学,应该能发现借用构造函数的类式继承,做的就是我们模拟new运算符过程的一部分,具体可以阅读博主这篇文章:new一个对象的过程,实现一个简单的new方法

3.1利用构造函数模式实现多继承

 利用构造函数加apply的方式,我们可以同时继承多个构造函数的属性,像这样:

function Cat () {
  this.legs = 4;
  this.say = function () {
    console.log('喵~')
  }
};
function Bird() {
  this.wings = 2;
  this.fly = true;
}
function CatWings() {
  Cat.apply(this);
  Bird.apply(this);
};
let miao = new CatWings();
console.dir(miao);

简直不能在方便,那么到这里位置,我们大概介绍了类式继承,默认模式,也就是构造函数的property指向你需要继承的实例,构造函数模式(结合call或apply)。

第二种构造函数模式的弊端在于不能继承原型,而添加在原型上的往往又是可复用的方法,这点比较遗憾。

但它也有好处,例如它能获得父对象成员的拷贝,不存在子对象修改能影响父对象的风险。那么这个遗憾我们能不能解决呢,如果在构造函数的模式上继承原型呢。下面的一种模式来解决这个问题。

JS模式这本书我可能最近,至少一周需要放放了,昨天跟组长说我们现在前端ES6规范都没用,确实low了点,所以我这边想尽快把ES6实践到项目中,这几天打算把ES6过一遍,所以想写写ES6的笔记。反正不管学什么,只要愿意学,总是没坏处的。

我为什么要写这段话呢,说的像我有很多读者,要提前说明一样。其实根本没人看我的博客啊...

那么这篇就写到这里了,接下来先放置一下,这本书还剩下两章,我会坚持读完,接下来好好学习一下ES6,为四月项目重构做准备。

posted on 2019-03-20 23:13  听风是风  阅读(1363)  评论(4编辑  收藏  举报