JS原型与继承

前言

如果你觉得JS的继承写起来特别费劲,特别艰涩,特别不伦不类,我想说,我也有同感。尤其是作为一个学过Java的人,看到JS的继承简直要崩溃。至于为什么JS的继承让人如此困惑,根源当然在于JS本身的设计,即半函数式编程,半面向对象。对历史感兴趣的人可以参考以下文章,虽然不会有恍然大悟——“原来继承可以这么写”的感觉,至少可以让你对于自己的困惑找到一丝安慰。

http://www.ruanyifeng.com/blog/2011/06/designing_ideas_of_inheritance_mechanism_in_javascript.html

至于我写这篇文章的目的,当然是记录一下自己的思考过程(有些东西是看了别人的代码,在试图理解其写法的目的),方便以后回头重温,毕竟人的忘性是很大的,另一方面则是有缘人看到这篇文章,希望它多少能帮助你在思考继承的问题上少走一点弯路。

另外免责声明:本文写的是自己的思考过程,虽然力求正确,不至于误人子弟,但是难免有疏漏和错误,请谅解。如果能通过评论指正出来,十分感谢。

正文

1.关于原型和原型链

说到JS的继承,当然离不开原型和原型链,因为它们本身就是为了抽取构造函数的共通部分而存在的。

1-1.困惑点

在原型和原型链的相关的问题中,很多人比较困惑的大概是以下几个。

<1>constructor,prototype和__proto的关系

<2>Function instanceof Object 和Object instanceof Function的结果为什么都是true

<3>为什么所有的对象的原型最终都指向Object.prototype而不是Object

(这个问题可能不是大多数人都有的,但是我自己对理解这一点很是费了一番功夫。)

1-2.需要知道的点

接下来说一说关于原型和原型链需要知道的一些点

<1>通过构造函数创建对象的内部原理
如下面的代码:

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayName = function () {
        console.log(this.name);
    }
}
var Tony = new Person('tony', 18);

其实通过new操作符创建对象的过程是这样的。

function Person(name, age) {
    //1.创建一个对象,用this指向它。 this = {};
    //2.执行以下方法
    this.name = name;
    this.age = age;
    this.sayName = function () {
        console.log(this.name);
    }
    //3.return this
}
var Tony = new Person('tony', 18);

<2>原型是函数的属性prototype指向的内容,原型本身是对象
首先必须要声明一点,虽然在JS里一切皆对象,所以函数也是对象。但是方便起见,说函数的时候仍然指普通意义上的函数,即通过function关键字声明的类型,而说对象的时候指的是带一对儿大括号的类型。
说到这里有必要提一下,函数本质上是一个代码块,是一块普通代码的集合,所以函数内部是一行行的执行语句,语句之间用分号隔开。而对象本质上是属性名值对儿的集合,属性名和属性值之间用冒号连接在一起,不同的属性名值对儿之间用逗号隔开。
函数身上才有prototype属性,用以显式地声明一个函数的原型。而原型本身必须是一个对象。这一点可以通过简单的代码得以验证,不再贴图。

<3>声明prototype的含义
如以下代码:

Person.prototype.sayName = function () {
    console.log(this.name);
}
function Person(name, age) {
    this.name = name;
    this.age = age;
}
var Tony = new Person('tony', 18);

这里给Person函数加了一个原型。当通过new操作符创建Person对象的时候,会在构造函数内部创建空对象后,立刻在里面放了一个隐式属性proto指向了Person的原型对象。

Person.prototype.sayName = function () {
    console.log(this.name);
}
function Person(name, age) {
    //this = {__proto__ : Person.prototype}
    this.name = name;
    this.age = age;
}
var Tony = new Person('tony', 18);

需要注意的是,这里虽然只是写空对象里放了一个隐式属性proto指向Person.prototype,从本质上来说,Person.prototype这个词本身并不是一个变量,也不是一个对象,并没有夹在Person对象和Person的原型中间。当创建Person对象后,Tony里面有一个proto直接指向一个对象,即:

{
    sayName : function() {console.log(this.name);},
    constructor : function Person(name, age) {this.name=name;this.age=age},
    __proto__ : Object.prototype
}

使用prototype关键字把一个对象声明为一个函数的原型,只是意味着通过该函数创建对象时,该对象内部会有一个隐式属性proto指向这个原型。这一点很重要。

<4>不显式地声明一个构造函数的原型地话,系统会构建一个隐式的原型
如以下代码:

function Son(name, age, hobby) {
    this.name = name;
    this.age = age;
    this.hobby = hobby;
    this.getFullName = function () {
        console.log(this.lastName + this.name);
    }
}

var Datou = new Son('Datou', 18, 'scapegoat');

系统会隐式地为Son构造函数创建一个原型对象

{
    constructor : function Son() {...},
    __proto__ : Object.prototype
}

这里还是想再强调一下,虽然我写了Object.prototype,千万不要把它当成一个存在的变量或对象,它甚至都算不上一个“指向”,而只是一个“指代”,指代的是Object的原型对象本身,那个带大括号的对象。只不过由于它内部的代码很多,也没有一个名字,所以我用prototype指代了一下。总而言之,我不想让你误以为存在一个叫Son.prototype的中间变量和对象,从而形成以下错误印象:
Datou.proto → Son.prototype → {(隐式创建的)原型对象} → Object.prototype → {一大堆系统提供方法的集合}
而真实的情况是:
Datou.proto → {(隐式创建的)原型对象} → {一大堆系统提供方法的集合}

<5>实例对象的constructor是从原型对象复制过来的
个人感觉,和prototype以及proto相比,constructor在继承中的作用不是很大。
再来一个声明,在本文讨论的范围内,对象分为三种,即实例对象,原型对象和Object.prototype。实例对象指的是通过new操作符创建出来的对象,原型对象指的是通过prototype关键字声明的对象,而Object.prototype,指的是哪个一对儿大括号,里面有一堆系统自定义的方法的对象。
还以下面的代码举例:

function Son(name, age, hobby) {
    this.name = name;
    this.age = age;
    this.hobby = hobby;
    this.getFullName = function () {
        console.log(this.lastName + this.name);
    }
}

var Datou = new Son('Datou', 18, 'scapegoat');

Datou这个对象里有一个constructor指向系统隐式创建的Son的原型这一点我们再熟悉不过了,以至于我们可能会忽略其实Datou内部并没有一个叫constructor的属性,它只是通过隐式属性proto调用的原型上的constructor。这一点通过在控制台打印Datou.hasOwnProperty('constructor')返回结果为false得以验证。
结论就是只有原型对象上才有constructor属性,把一个对象当做是谁的原型,这个原型对象的constructor就指向谁。

<6>原型对象为什么最终都链接到Object.prototype上
以下面这个简化版的代码举例:

Son.prototype.sayName = function () {
    console.log(this.name);
}

function Son(name) {
    this.name = name;
}

var Datou = new Son('Datou');

很显然,系统会首先创建一个Son的原型对象,即:

{
    sayName : function() {console.log(this.name)},
    constructor : function Son(name) {this.name=name},
    __proto__ : Object.prototype
}

里面有我们自定义的函数属性sayName,同时会有一个constructor指向Son函数,最后还有一个proto指向Object的原型。
在这里你会很自然的想到两点。第一点是这个原型对象虽然看上去是我们手动通过字面量形式写出来的,但其实一定是通过new出来的所以它内部才有一个隐式属性__proto。第二点是既然proto指向Object的原型,那Son的原型对象一定是通过Object函数new出来的。
事实确实如此。通过字面量的形式创建对象跟通过new Object()的方式创建对象本质上是一样的。所以通过以下代码创建Datou的过程,以一种比较全面的角度来解读是下面这样的:

  1. 执行var obj = new Object();
    1-1)this = {};
    1-2)this.proto = {一大堆系统提供方法的集合,由于当前对象在Object函数中创建,所以该隐式属性指向Object的原型对象};
    1-3)this.sayName = function() {console.log(this.name)};
    1-4)由于声明了prototype所以this.constuctor = function Son(name) {this.name=name};
    1-5)return this
  2. 执行var Datou = new Son('Datou');
    2-1)this = {};
    2-2)this.proto={由于当前对象在Son函数中创建,所以该隐式属性指向Son的原型对象,即obj指代的对象}
    2-3)this.name = 'Datou';
    2-4)return this;
    最终,Datou就代表了如下一个对象
{
    name : "Datou",
    __proto__ : {包含sayName方法的那个原型对象}
}

看到这儿,应该就能明白为什么所有的对象最终都会连接到Object的原型上了。
而这种通过隐式属性proto不断往上找原型的链条就是我们通常意义上所说的原型链。

2.JS继承的实现方式

如果看这篇文章之前你已经查询过百度很多遍,想必一定看到过继承实现方式的演变历史。下面谈谈自己对继承的理解,为此我准备了一个例子,按照这个例子把代码写出来,基本上就能学会JS的继承了。
顶部有一个Animal函数,它里面有name,food和eat三个属性,其中eat属性是一个方法。Cat函数和Dog函数分别继承自Animal,而Cat函数还有一个自己的属性catMouse,最后通过Cat函数创建加菲猫,通过Dog函数创建欧弟。通过加菲猫调用eat方法,执行的结果是加菲猫正在吃千层面,通过加菲猫调用catchMouse方法的话则打印我抓到了一只老鼠。通过欧弟调用eat方法,执行的结果是欧弟正在啃骨头。


pic.png
2-1.首先想到的写法

通过百度或者加入一点自己的思考,最开始得出的写法可能是这样的。

function Animal(name, food) {
    this.name = name;
    this.food = food;
    this.eat = function () {
        console.log(this.name + " is eatting " + this.food);
    }
}

function Cat(name, food) {
    Animal.call(this, name, food);
    this.catchMouse = function () {
        console.log("Hey, John! I got a big mouse!")
    }
}

function Dog(name, food) {
    Animal.call(this, name, food);
}

var Garfield = new Cat("Garfield", 'lasagne');
var Odie = new Dog("Odie", "bone");

Garfield.eat();
Garfield.catchMouse();
Odie.eat();

这种写法从功能实现的角度来说已经没有问题了,但是没有用到原型很让人不爽。怎么说呢,之所以出现原型,就是为了提取共通的部分,这也是继承的题中之义。上面这种写法,单纯是使用Animal函数的call方法为自己初始化变量,其实本质上是“借腹生子”。而且看起来是写的代码少了,但实际上执行的步骤可是一步都不少。而且,函数类的属性一般都要写到原型里,不然要原型干嘛。像现在这种写法的话,相当于每个Cat对象里都会存放一份eat函数和catchMouse函数,而每个Dod对象里都会放一份eat函数,这根本没有显示出继承的特点。

2-2.加上原型后的效果

接下来把共通的部分抽取出来放到原型里,并用原型链链接起来。

Animal.prototype.eat = function () {
    console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
    this.name = name;
    this.food = food;
}

Cat.prototype.catchMouse = function () {
    console.log("Hey, John! I got a big mouse!")
}
Cat.prototype = new Animal();
function Cat(name, food) {
    Animal.call(this, name, food);
}

Dog.prototype = new Animal();
function Dog(name, food) {
    Animal.call(this, name, food);
}

var Garfield = new Cat("Garfield", 'lasagne');
var Odie = new Dog("Odie", "bone");

Garfield.eat();
Garfield.catchMouse();
Odie.eat();

这个时候就开始遇到一个很严重的问题。
假如没有加菲猫,只有欧弟,即子函数的原型上没有自己独有的方法。

Animal.prototype.eat = function () {
    console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
    this.name = name;
    this.food = food;
}

Dog.prototype = new Animal();
function Dog(name, food) {
    Animal.call(this, name, food);
}

var Odie = new Dog("Odie", "bone");
Odie.eat();

这就没问题,因为Dog的原型是Animal对象,即Odie里面有一个隐式属性proto指向一个Animal对象(它没有名字),而Animal的原型是一个包含eat函数的对象,所以Animal对象里面有一个隐式属性proto指向那个包含eat函数的对象。这样的话,Odie调用eat方法的话,自己没有,通过自己的proto找到那个Animal对象,结果它也没有,再通过Animal对象的proto找到那个包含eat函数的对象,执行其中的eat方法。
然而换种想法,其实这种情况下还是有一点值得思考的,就是为什么不直接写Dog.prototype = Animal.prototype呢。可以直接这么写,而且直接写其实比中间通过一个Animal对象要好。这么写的话,Odie的proto直接指向包含eat方法的对象,正好就是自己想要的效果。刚才那种写法反而每次都创建出一个多余的Animal对象,里面有两个属于自己的属性(没有通过call改变this指向,通过new创建的时候可以写参数也可以不写参数,一般不写,没必要),这两个参数的值最终都是undefined。实际上这个对象唯一的作用就是里面有一个proto指向自己的构造函数的原型。
Dog.prototype = Animal.prototype其实有一个专业的叫法——共享原型。但是它不是总能奏效,比如加菲猫这头,它还需要有一个专属于猫科动物的特性——抓老鼠。
Cat既要有自己的一个原型(是一个对象),里面包含一个catchMouse方法,以便Garfield的proto指向这个原型,同时又要让加菲猫的原型直接或间接地指向Animal的原型。以共享原型的方式直接指向的话是没戏的,因为Cat.prototype=Animal.prototype的话就没有中间的对象了,而我恰好需要一个中间的对象在这儿。以开始的那种先执行new Animal()对象再间接指向最终目标的话,会出现以下3中结果。
1)先写Cat.prototype = new Animal();再写Cat.prototype={catchMouse:function() {...}}的话,前者会被后者覆盖掉,导致最终没法指向Animal的eat方法。
2)先写Cat.prototype = new Animal();再写Cat.prototype.catchMouse=function() {...}的话,每个创建出来的Cat对象里仍然会有一份无用的Cat对象的属性值这一点并没有变。而且明明是Cat的方法,却写到了Animal对象里,语义上好像不好。
3)先写Cat.prototype.catchMouse=function() {...}再写Cat.prototype = new Animal();的话,前者会被后者覆盖,导致实际上并没有添加属于自己的特有方法。
这个时候静下心来想一想,自己到底想要达到什么效果?其实就是Cat函数的原型对象确实存在,它里面有一个Cat函数才有的catchMouse方法,这个原型对象里应该有一个proto直接指向Animal的原型。但是就是办不到。于是我手动地在创建Cat的原型对象后,给他显式地添加一个proto属性,让它指向Animal的原型,效果如下:

Animal.prototype.eat = function () {
    console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
    this.name = name;
    this.food = food;
}

Cat.prototype.catchMouse = function () {
    console.log("Hey, John! I got a big mouse!")
}
Cat.prototype.__proto__ = Animal.prototype;
function Cat(name, food) {
    Animal.call(this, name, food);
}

var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();

试了一下,结果居然是可以的。然而,IE浏览器下并不好使,其它浏览器倒是好使。
其实这个时候,退而求其次,既然看起来没法同时做到这两点,把Cat函数的catchMouse属性不用原型实现,而是老老实实地写到函数内部也还好。代码重复就重复好了,不是彻底的继承就不彻底好了,至少从功能上来讲也实现了要求。

Animal.prototype.eat = function () {
    console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
    this.name = name;
    this.food = food;
}

Cat.prototype = Animal.prototype;
function Cat(name, food) {
    Animal.call(this, name, food);
    this.catchMouse = function () {
        console.log("Hey, John! I got a big mouse!")
    }
}

var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();

退而求其次也行,但是这看起来是不可调和的矛盾,真的没有办法了么?答案是有的。

2-3.最后改进后的结果

重新捋一下需求和思路。写到这种地步,Animal的非函数属性(name和food)采用了一种非本质上继承,但是也还好的方式实现了,现在主要是想链接到Animal的原型上,获取到eat方法。Cat函数要有自己的方法,所以属于自己的原型对象必须存在。即Cat.prototype.catchMouse=function(){...}是一定有的,这个对象必须实实在在地存在。然后这个对象需要有一个proto链接到Animal的原型,强制加proto是不现实的。其实仔细想行,为什么让这个原型对象里有一个proto直接指向Animal的原型那么难(其实是不可能的),这又回到开头的问题上,显式声明prototype和proto是怎么回事儿,以及什么关系上。
使用prototype关键字把一个对象声明为一个函数的原型,只是意味着通过该函数创建对象时,该对象内部会有一个隐式属性proto指向这个原型。
所以Animal的原型只会在new Animal()的对象里才会被proto引用。而Cat的原型,本身已经是一个对象(里面有一个catchMouse函数属性)了,它就不可能是Animal的对象。
既然我没法直接指向你,而我所需要的又只是你(Animal的原型),而不是Animal对象(指向Animal对象会有多余的属性)。既然无论如何都只能是间接地指向你,现有的那个我又不喜欢,那我干脆创建一个新的空对象作为间接内容指向你好了。于是可以创建一个新的函数(内容为空),让这个函数的原型也是你,这个函数构造出的对象里其它什么都没有,只有一个proto指向你,然后我自己加我特有的内容时加到这个新的对象上了。虽然仍然有语义上不好的感觉,至少没有多余的Animal对象的属性了。

Animal.prototype.eat = function () {
    console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
    this.name = name;
    this.food = food;
}

function F() {}
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.catchMouse = function () {
    console.log("Hey, John! I got a big mouse!")
}

function Cat(name, food) {
    Animal.call(this, name, food);
}

var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();

其实这一块代码还可以设计成一个单独的函数:

function extend(son, father) {
    function F() {};
    F.prototype = father.prototype;
    son.prototype = new F();
}

Animal.prototype.eat = function () {
    console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
    this.name = name;
    this.food = food;
}

extend(Cat, Animal);
Cat.prototype.catchMouse = function () {
    console.log("Hey, John! I got a big mouse!")
}

function Cat(name, food) {
    Animal.call(this, name, food);
}

var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();

这样的写法已经很好地完成了任务了。再锦上添花一下的话,需要思考一下constructor的事儿。从这里可以看出来,constructor对应JS的原型以及原型链来讲作用远不及prototype和proto重要。但是为了更符合JS原型和原型链的体系,有必要再加点东西。
上面的代码中,通过Garfield访问constructor属性会打印出Animal函数。原因是Cat的prototype是新建的空对象,而那个空对象自己也没有constructor,它的proto才有,它的proto是Animal的prototype,结果可想而知。所以为了,体现出Garfield是在Cat函数中创建出来地这一点,有必要加上以下代码:
son.prototype.constructor = son;
结果代码如下:

function extend(son, father) {
    function F() {};
    F.prototype = father.prototype;
    son.prototype = new F();
    son.prototype.constructor = son;
}

Animal.prototype.eat = function () {
    console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
    this.name = name;
    this.food = food;
}

extend(Cat, Animal);
Cat.prototype.catchMouse = function () {
    console.log("Hey, John! I got a big mouse!")
}

function Cat(name, food) {
    Animal.call(this, name, food);
}

var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,290评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,107评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,872评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,415评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,453评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,784评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,927评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,691评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,137评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,472评论 2 326
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,622评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,289评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,887评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,741评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,977评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,316评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,490评论 2 348

推荐阅读更多精彩内容