JavaScript 原型(prototype)、原型链、原型继承全解析

1. 什么是原型,为啥要有原型?

什么是原型,为啥要有原型呢?这问题一上来确实不好回答。不妨换个思路思考:如果JS中没有原型,会怎么样?

首先,如果没有原型,对象的一些方法我们可能无法调用了

比如:

let str = 'hello, world';
str.split(',');  // ['hello', ' world']
str.hasOwnProperty('length'); // true

上面的代码我们定义了一个字符串str,并调用了它splithasOwnProperty方法。你是否想过,为什么str会有这些方法,它们是在哪里定义的?

我们能调用这两个方法,借助的就是原型的力量。

我们能调用split方法是因为strString类型的,而String的原型上就定义了split方法。
你可使用Object.getOwnPropertyNames发现它们:

String.prototype上并没有发现hasOwnProperty方法,为啥也能调用呢?这是因为StringObject的子类,而Object的原型上定义了hasOwnProperty方法:

其次,如果没有原型,很多方法可能都要以全局方法的方式存在,就像parseIntparseFloat一样。记忆这些方法是件难事,而原型像是容器,将一个类的方法都聚到一起,使我们可以直接通过对象访问它们。

如果你有面向对象的基础知识的话,你会发现上面说的就是对象三大特性的“封装”和“继承”。

所以什么是原型,为啥要有原型?简单说就是为了实现面向对象。条条大路通罗马,有些编程语言的面向对象是基于类(class)的,像Java,有些是基于原型(prototype)的,像JS。

上面我故意避开原型、原型链的一些知识(后面会讲),就是为了方便大家理解。

2. prototype、proto、[[Prototype]]

JS里面的对象都是有原型的(Object.create(null)除外),一些书籍、标准或规范喜欢用[[Prototype]]来表示。
而对象又分为函数对象,以及函数对象new出来的普通对象。

  • prototype 是函数对象的属性,代表函数的原型;
  • __proto__是普通对象的属性,用来指向对应函数构造器的原型;

我们用Fn代表函数对象,用obj代表它创建的普通对象,那么:

obj.__proto__ === Fn.prototype;  // obj 是 Fn创建的
Fn.__proto__ === Function.prototype; // Fn 是Function创建

不管是prototype,还是__proto__,它们都是原型对象[[Prototype]]的引用,或者说访问器。只是prototype是函数用来访问原型的,而__proto__是普通对象用来访问原型的。它俩指向的都是同一个原型对象。

3. 用现代方法操作原型

__proto__ 并不推荐使用,一是只有浏览器才支持的比较好,二是__proto__可能被误当做对象的键而被修改,导致代码产生bug。

我们应该使用更现代的方法代替__proto__,比如:

  • Object.create(proto, [descriptors]) —— 利用给定的原型和可选的属性描述来创建一个新对象。
  • Object.getPrototypeOf(obj) —— 返回指定对象的原型。
  • Object.setPrototypeOf(obj, proto) —— 设置对象的原型。

使用Object.setPrototypeOf方法要求传入的原型要么是null要么是一个对象。这里的“对象”指的是引用类型的对象,如果是非null的基本类型就会报错:

但是直接使用__proto__修改原型就能绕过限制:

所以使用__proto__并不是很安全,但是为了方便演示,下面内容可能还会出现它的身影。

4. 原型链

前面说了对象都有__proto__属性,而在JS中万物皆对象,obj.__proto__也是个对象,它也有__proto__属性,也就是obj.__proto__.__proto__,只要__proto__存在,就可以这么一直访问下去,直到__proto__返回null。这样就形成了一个链条,叫做原型链。

上面是原型链的简单示意图,可以看到:

  1. 所有对象的原型最终都会追溯到Object.prototype,所以对象都是Object的实例
  2. 所有函数对象都是Function的实例,Object也是函数,所以FunctionObject互为父亲.
  3. Object.prototype.__proto__值为null,因为原型链不能形成闭环。如果Object.prototype.__proto__为某个非空对象,那这个对象的原型链上最终还会出现Object.prototype,就形成了一个环,原型链就无法结束了。

为了避免成环,JS不允许用户向Object.prototype.__proto__赋值:

自定义的函数也不能让原型链成环:

那原型链有什么用途呢,我理解主要有两点:

  1. 属性继承,当访问对象的属性时,首先从对象自身查找,没有就沿着原型链查找,直到找到或者原型链结束为止;
  2. instanceof,instanceof的原理就是借助原型链;

instanceof在我的另一篇文章《js typeof、instanceof区别一次给你讲明白》中有详细介绍,感兴趣的可以去看,这里重点介绍原型和继承。

5. 原型继承

JS采用的是原型继承机制,这个和Java等编程语言的类继承机制不同,原型继承机制是通过原型对象继承,而类继承机制则是通过类继承。

不要带着对类继承的认识,看待JS的原型继承,这样你会很别扭。

JS的继承可以发生在两个普通对象之间,比如:

let animal= { name: 'animal' };
let dog = { __proto__: animal };
dog.name; // animal

我们可以说:对象dog从原型对象animal上继承属性。

不过为了大家理解方便,我下面还是用AnimalDog这两个类,来演示原型继承。

直接使用原型链实现继承:

function Animal() { };  // 定义一个Animal类
Animal.prototype.run = function () { console.log('奔跑...'); } // 定义父类方法

function Dog() {} // 定义一个Dog类

// 让Dog继承Animal
Dog.prototype.__proto__ = Animal.prototype; // 利用原型链实现继承
let dog = new Dog(); // 定义子类实例
// 调用父类方法
dog.run(); // 奔跑...

继承关系如图所示:

这是学完原型链能直接想到的方式,它还有一种变体:

Dog.prototype = Object.create(Animal.prototype); 
Dog.prototype.constructor = Dog;  // 修复constructor的指向问题

这两种方式本质都是利用原型链,只是由于第二种方式是直接覆盖子类原型,需要注意修复constructor指向问题,而且给子类原型添加属性和方法时,需要在Object.create调用之后,否则刚添加的属性和方法又被覆盖了。

直接使用原型链的继承都存在一点问题:只能从原型对象上继承,对于那些定义在父类构造函数中的属性和方法,是无法继承的。。

function Animal() {
	this.eat = function() { console.log('疯狂进食...'); } // 实例方法
};  // 定义一个Animal类
Animal.prototype.run = function () { console.log('奔跑...'); } // 原型方法

function Dog() { } // 定义一个Dog类

// 让Dog继承Animal
Dog.prototype.__proto__ = Animal.prototype; // 利用原型链实现继承
let dog = new Dog(); // 定义子类实例
// 调用父类方法
dog.run(); // 奔跑...
dog.eat(); // Uncaught TypeError: dog.eat is not a function

这里的eat方法是直接在父类构造方法中定义的,Animal.prototype中并不存在此方法,因此不能被基于原型的继承方式继承。我们可以通过“借用父类构造方法”的方式修复这个问题:

function Animal() {
	this.eat = function() { console.log('疯狂进食...'); } // 实例方法
};  // 定义一个Animal类
Animal.prototype.run = function () { console.log('奔跑...'); } // 原型上定义的方法

function Dog() { // 定义一个Dog类
   Animal.apply(this, arguments); // 父类构造函数初始化子类的实例,这样父类的实例属性就会赋值给this
} 

// 让Dog继承Animal
Dog.prototype.__proto__ = Animal.prototype; // 利用原型链实现继承
let dog = new Dog(); // 定义子类实例
// 调用父类方法
dog.run(); // 奔跑...
dog.eat(); // 疯狂进食...

Animal.apply(this, arguments)(或者Animal.call)的作用就是调用父类的构造方法来初始化子类的实例,这样在父类构造方法中定义的属性和方法,就在子类实例上重新赋值了一遍。

不过这里还有一个问题,就是静态属性的继承问题。静态属性和方法就是直接定义在类上的属性和方法,它即不属于原型对象,也不属于实例,而是属于类(或函数)本身,因此无法被继承。

function Animal() {
	this.eat = function() { console.log('疯狂进食...'); } // 实例方法
};  // 定义一个Animal类

Animal.KIND = '哺乳动物'; // 静态属性
Animal.printKind = function() { console.log(Animal.KIND); } // 静态方法

其实这也不算是个问题,因为静态属性在一些编程语言中本就无法继承,比如Java。不过JS这门语言太过动态且灵活了,要实现静态属性继承,也是很容易的。

function Animal() {
	this.eat = function () { console.log('疯狂进食...'); } // 实例方法
};  // 定义一个Animal类
Animal.KIND = '哺乳动物'; // 静态属性
Animal.printKind = function () { console.log(Animal.KIND); } // 静态方法
Animal.prototype.run = function () { console.log('奔跑...'); } // 原型上定义的方法

function Dog() { // 定义一个Dog类
	Animal.call(this); // 借用构造方法,解决实例属性的继承问题
}

// 让Dog继承Animal
Dog.prototype.__proto__ = Animal.prototype; // 利用原型链实现继承
// 给Dog类添加方法
Dog.prototype.woof = function() { console.log('汪汪汪...'); }
// 静态属性继承
Dog.__proto__ = Animal; // 这里是把函数当成对象看待
let dog = new Dog(); // 定义子类实例

// 调用父类方法
dog.run(); // 奔跑...
dog.eat(); // 疯狂进食...
// 调用子类方法
dog.woof(); // 汪汪汪...
// 调用从父类继承的静态成员
Dog.KIND;    // 哺乳动物
Dog.printKind(); // 哺乳动物

关键代码就一行:

Dog.__proto__ = Animal; 

我们把Dog当成一个普通对象,将Dog.__proto__设置成父类,这样当我们访问Dog的静态属性和方式时,如果Dog类上没有,就会到Animal上找。

上面的写法差不多是不借助ES6语法情况下,写出的比较完美的继承了。

下面是完整的继承关系图:

我们使用三个技巧分别实现了原型属性、实例属性和静态属性的继承,最后我们再总结一下。

实现原型继承:

// 方式1:使用__proto__
Dog.prototype.__proto__ = Animal.prototype;
// 方式2:使用ES6的Object.setPrototypeOf代替__proto__
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
// 方式3:使用Object.create
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

实现父类实例属性继承:

// 在子类构造方法中执行
Animal.apply(this, arguments);

实现静态属性继承:

Dog.__proto__ = Animal;
// 或使用ES6的方法
Object.setPrototypeOf(Dog, Animal);

原型属性继承和静态属性继承本质都是基于原型链,区别在于原型链是作用于类上,还是作用于原型对象上。

实例属性的继承除了上面说的用applycall方法,还有一种写法:

Dog.prototype = new Animal(); // 用父类示例作为子类原型
Dog.prototype.constructor = Dog; // 修复constructor指向

这种方式使用父类的一个实例作为子类的原型,因为是父类的实例,这个实例自然继承了父类的原型属性,同时又包含实例属性。尽管看着很棒,但是由于无法向父类构造方法传参,因此也是个“鸡肋”的方法,了解即可。

6. ES6的面向对象

单就原型的知识,上面的内容已经足够了。本节是一些扩展,不感兴趣,可以不用了解。
我们都知道ES6引入了基于class的面向对象,你可以用更标准的方式来编写面向对象代码。前面的例子,可以用class改写一下:

class Animal { // 父类
	constructor() {
		this.eat = function() { console.log('疯狂进食...'); } // 实例方法
	}
	run() { console.log('奔跑...'); }
}
class Dog extends Animal { // 子类继承父类
	woof() { console.log('汪汪汪...') }
}
let dog = new Dog(); // 定义子类实例
// 调用父类方法
dog.run(); // 奔跑...
// 调用父类的实例方法
dog.eat(); // 疯狂进食...
dog.woof(); // 汪汪汪...

看着简洁很多。

当然本节的内容不是介绍ES6的class,而是告诉你,ES6的class只是语法糖,其本质还是原型。上面的代码可以使用Babel将其编译成ES6之前的写法:

"use strict";

function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; _setPrototypeOf(subClass, superClass); }

function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }

function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

var Animal = /*#__PURE__*/function () {
  // 父类
  function Animal() {
    this.eat = function () {
      console.log('疯狂进食...');
    }; // 实例方法

  }

  var _proto = Animal.prototype;

  _proto.run = function run() {
    console.log('奔跑...');
  };

  return Animal;
}();

  // 静态属性
_defineProperty(Animal, "KIND", '哺乳动物');
  // 静态方法
_defineProperty(Animal, "printKind", function () {
  console.log(Animal.KIND);
});

var Dog = /*#__PURE__*/function (_Animal) {
  _inheritsLoose(Dog, _Animal);

  function Dog() {
    return _Animal.apply(this, arguments) || this;
  }

  var _proto2 = Dog.prototype;

  // 子类继承父类
  _proto2.woof = function woof() {
    console.log('汪汪汪...');
  };

  return Dog;
}(Animal);

代码虽多,但仔细看就会发现,使用的技巧都是前文讲过的,如果有不懂的,再反复看看第5小结的内容。

7. 结语

最后,码字不已,还望支持,如果有讲的不对的地方也希望留言更正。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值