带你搞懂JavaScript中的原型和原型链

简介

原型和原型链是JavaScript中与对象有关的重要概念,但是部分前端开发者却不太理解,也不清楚原型链有什么用处。其实,学过其他面对对象语言的同学应该了解,对象是由类生成的实例,类与类之间有继承的关系。在ES6之前,JavaScript中并没有class,实现类和继承的方法就是使用原型。在我个人看来,JS中类和原型链的设计和语法由于一些历史或包袱问题而不易用,也不易于理解。因此在ES6中推出了class相关的语法,和其他语言更接近,也更易用。

ES6的class可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。(ECMAScript6入门教程 阮一峰)

虽然有了class,但是原型链相关的内容我们依然要掌握。不仅是因为作为前端开发者,我们要深入理解语法。而且在查看源码,以及实现一些复杂的面对对象写法时,依然是有用的。因此在这篇文章中,我们一起搞懂JavaScript中的原型和原型链。(这篇文章并不会涉及class相关语法)

构造函数与原型

构造函数

在JS中创建实例的方法是通过构造函数。在构造函数中通过this实现对实例的操控,比如赋值各种属性和方法。我们看个例子:

js 复制代码
// Person构造函数
function PersonFun(name) {
  this.name = name;
  this.getName = function() {
    return this.name;
  }
}
// 创建实例
const p1 = new PersonFun('jz');
console.log(p1.name, p1.getName());
// 输出结果:
// jz jz

我们创建了PersonFun构造函数,使用new关键字创建了实例p1。可以看到,在构造函数中对this增加了属性和方法,最后成为了实例的属性。注意构造方法必须使用new调用。但是这样所有的属性都是实例属性,包括那个getName方法:

js 复制代码
const p1 = new PersonFun('jz');
const p2 = new PersonFun('jz');
console.log(p1.getName === p2.getName);
// 输出结果:
// false

原型对象

只用上面的构造函数,依然没有"类"的存在。这时候我们增加原型这一概念,可以理解为是实例对象的类。原型对象可以通过构造函数的prototype属性访问。

js 复制代码
// Person构造函数
function PersonFun(name) {
  this.name = name;
}
// Person原型对象
PersonFun.prototype.getName = function() {
  return this.name;
}
// 创建实例
const p1 = new PersonFun('jz');
const p2 = new PersonFun('jz');
console.log(p1.name, p1.getName());
console.log(p1.getName === p2.getName);
// 输出结果:
// jz jz
// true

可以看到,我们没有在构造函数中添加实例对象的属性方法getName,仅仅在原型对象上添加。但实例对象上依然能使用属性方法getName,而且对于不同的实例来说,这个方法是共享的,是同一个。通过原型,我们不仅能共享方法名也能共享属性值:

js 复制代码
// Person原型对象
PersonFun.prototype.title = 'hello';
const p1 = new PersonFun('jz');
const p2 = new PersonFun('jz');
console.log(p1.title, p1.title);
p1.title = '你好'
console.log(p1.title, p2.title);
// 输出结果:
// hello hello
// 你好 你好

可以看到,在实例中修改原型上提供的属性,实际上是增加实例中的属性值,因此这个修改是不在实例中共享的。但如果原型提供的属性是个对象,我们修改对象内部的值,这个值是实例间共享的。

js 复制代码
PersonFun.prototype.obj = {};
p1.obj.a = 1;
console.log(p2.obj.a);
// 输出结果:
// 1

构造函数/原型的获取

通过上面的描述,我们了解了实例,构造函数和原型对象以及他们之间的关系。那么在代码中,如何获取构造函数和原型对象呢?我们列出了一些方法:

js 复制代码
// 构造函数 PersonFun
// 获取原型对象
Person = PersonFun.prototype
// 创建实例对象
p1 = new PersonFun()

// 原型对象 Person
// 获取构造函数
PersonFun = Person.constructor

// 实例对象 p1
// 获取原型对象
Person = p1.__proto__
Person = Object.getPrototypeOf(p1)
// 获取构造函数
PersonFun = p1.constructor

其中的__proto__最好使用Object.getPrototypeOf代替:

__proto__并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的JS引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。(ECMAScript6入门教程 阮一峰)

字面量的原型

字面量对象的原型

如果我们创建对象的时候,没有使用构造函数,而是直接是用大括号,以字面量的形式创建的对象,那么它的原型是什么?它有没有构造函数呢?是有的。我们一起来看一下:

js 复制代码
const obj = { a: 1 };
console.log(obj.__proto__)
console.log(obj.constructor)
// 输出结果:
// {constructor: ƒ, __defineGetter__: ƒ, ...}
// ƒ Object() { [native code] }

输出了一些奇怪的东西,我们还是不知道字面量对象的原型是什么。我们换个角度想一想,以字面量形式创建的对象,是不是就相当于直接使用new Object()形式创建的对象?这里的Object也是一个构造函数。我们来试验下:

js 复制代码
const obj1 = { a: 1 };
const obj2 = new Object({ b: 2 });
console.log(obj1.__proto__ === obj2.__proto__)
console.log(Object.prototype === obj1.__proto__)
console.log(obj1.constructor === Object)
// 输出结果:
// true
// true
// true

可以看到,使用字面量形式和new Object()形式,创建出来对象的原型是一样的。既然Object是个构造函数,那么Object.prototype即是Object实例的原型对象。

至于obj.constructor实际上就是构造函数Object()。它是JS内部生成的,因此这里展示[native code]

new Function()

在JS中函数实际上也是个对象。既然它是个对象,那么它应该也有构造函数和原型吧。我们来试验下:

js 复制代码
// Person构造函数
function PersonFun(name) {
  this.name = name;
}
console.log(PersonFun.__proto__)
console.log(PersonFun.constructor)
// 输出结果:
// ƒ () { [native code] }
// ƒ Function() { [native code] }

又输出了一些奇怪的东西,其中还有个Function()。我们继续联想下:对象可以用new Object()形式创建,那么函数是不是也可以?可以的!使用new Function()可以创建函数。我们来看下:

js 复制代码
const fun = new Function('a', 'b', 'return a + b');
console.log(fun(1, 2));
// 输出结果:
// 3

new Function()可以使用字符串作为代码执行的函数体,感觉有点像eval。但是eval是局部作用域,new Function()一直都是全局作用域。我们来看下例子:

js 复制代码
const a = 1;
function envir() {
  const a = 2;
  eval('console.log(a)');
  const fun = new Function('console.log(a)');
  fun();
}
envir();
// 输出结果:
// 2
// 1

可以看到,eval输出的是局部作用域中的a值2,而new Function()虽然在局部作用域的位置中,但是内部获取到的依然是全局的变量。不过这些区别和我们要讨论的原型链无关,因此不再继续讨论。

字面量函数的原型

了解了new Function(),我们再回来看看字面量函数的原型。

js 复制代码
// Person构造函数
function PersonFun(name) {
  this.name = name;
}
const fun = new Function('a', 'b', 'return a + b');
console.log(PersonFun.__proto__ === fun.__proto__)
console.log(PersonFun.__proto__ === Function.prototype)
console.log(PersonFun.constructor === Function)
// 输出结果:
// true
// true
// true

与对象类似,Function.prototype是函数的原型,我们函数字面量的原型都是它。函数的构造函数即是Function()。(构造函数与普通函数并无区别,都是函数)。在上面的输出中,函数的原型对象Function.prototype也是一个函数:ƒ () { [native code] }。关于这点我们会在后面讨论。

JS中的原型关系

了解了字面量相关的原型,现在我们再来刨根问底,看看JS中对象的原型关系。

对象的原型关系

首先看下Object原型的关系。

对象的尽头

首先看看对象的尽头。上面讲过字面量对象的原型即是Object.prototype。它也是个对象,那么它有没有原型呢?我们试一下:

js 复制代码
const obj = { a: 1 };
console.log(obj.__proto__)
console.log(obj.__proto__.__proto__)
// 输出结果:
// {constructor: ƒ, __defineGetter__: ƒ, ...}
// null

答案是没有的,Object.prototype是没有原型的。

自定义构造函数与原生对象的关系

我们的自定义构造函数与对应的实例原型和Object.prototype有关系么?我们试验下:

js 复制代码
// Person构造函数
function PersonFun(name) {
  this.name = name;
}
// Person原型对象
PersonFun.prototype.getName = function() {
  return this.name;
}
console.log(PersonFun.prototype.__proto__);
console.log(PersonFun.prototype.__proto__ === Object.prototype);
console.log(PersonFun.prototype.constructor === Object);
// 输出结果:
// {constructor: ƒ, __defineGetter__: ƒ, ...}
// true
// false

可以看到,Person构造函数对应实例的原型对象,它的原型即是Object.prototype。但是它与字面量对象不同的是,它的constructor属性表示的是它对应实例的构造函数,而不是字面量对象的Object()

原生类型的原型关系

在前面我们聊过了函数的原型,即是Function.prototype。但当时我们输出它,发现它是一个函数,那么它究竟是什么?它还有没有原型?

js 复制代码
// Person构造函数
function PersonFun(name) {
  this.name = name;
}
console.log(PersonFun.__proto__);
console.log(PersonFun.__proto__.__proto__);
console.log(PersonFun.__proto__.__proto__ === Object.prototype);
console.log(PersonFun.__proto__.prototype);
// 输出结果:
// ƒ () { [native code] }
// {constructor: ƒ, __defineGetter__: ƒ, ...}
// true
// undefined

可以看到,直接打印函数的原型也是一个函数,里面是[native code],即它也是由JS内部生成的。它的再深一层原型,居然又是Object.prototype。函数的原型虽然也是个函数,但是它并没有更深一层的prototype。

这时候我们返回去看看对象原型的构造函数,即Object()。作为一个函数,它的原型是什么?

js 复制代码
console.log(Object);
console.log(Object.__proto__);
console.log(Object.__proto__ === Function.prototype);
// 输出结果:
// ƒ Object() { [native code] }
// ƒ () { [native code] }
// true

看来这些原生类型的构造函数的原型,都同一个来源。我们再试一下其他的原生类型:

js 复制代码
console.log(Number);
console.log(Number.__proto__);
console.log(Number.__proto__ === Function.prototype);
console.log(Array);
console.log(Array.__proto__);
console.log(Array.__proto__ === Function.prototype);
console.log(String);
console.log(String.__proto__);
console.log(String.__proto__ === Function.prototype);
new Function.prototype();
// 输出结果:
// ƒ Number() { [native code] }
// ƒ () { [native code] }
// true
// ƒ Array() { [native code] }
// ƒ () { [native code] }
// true
// ƒ String() { [native code] }
// ƒ () { [native code] }
// true
// Uncaught TypeError: Function.prototype is not a constructor

果然如此,原生类型的构造函数的原型都是同一个。而如上面实验得出的结论,这个原型是一个函数,它没有构造函数,它的原型是Object.prototype。我还尝试直接用这个构造函数原型创建实例,结果提示这不是一个构造函数。

原型链

有了上面这些关系,我们发现不同类型对象的原型似乎都是有关系的,好像有一条线可以把他们穿起来。这条线就是我们所说的原型链。在文章一开始的简介中说过,原型和原型链是JavaScirpt中实现类和继承的一种方式。原型就相当于实例的类,继承就像是原型链。因此,原型的特点也很像父类,即实例可以访问原型的属性,也可以覆盖原型属性。在浏览器的控制台中,我们打印一个对象,展示的[[Prototype]]即是它的原型。

原型链示意图

不仅我们自定义的类型有原型链的关系,JS内部的原生类型也存在原型链,且可以和我们自定义的类型串起来。这里我们用一个图片描述原型链之间的关系(图片来源MollyPages.org):

原型链文字版

假设我们有这样一些对象,我们来搞清楚它们的原型关系。

  • 构造函数 PersonFun
  • 实例对象 p1
  • 原型对象(类) Person
js 复制代码
// 构造函数 生成 实例对象
p1 = new PersonFun()
// 构造函数 -> 原型对象
Person = PersonFun.prototype

// 实例对象 -> 构造函数
PersonFun = p1.constructor
// 实例对象 -> 原型对象
Person = p1.__proto__
Person = Object.getPrototypeOf(p1) // 推荐

// 原型对象 -> 构造函数
PersonFun = Person.constructor

再看一下字面量的原型关系,以及更深层次的关系:

  • 字面量对象 obj1
  • 字面量函数 fun1
js 复制代码
// 字面量对象 -> Object构造函数
Object = obj1.constructor
// 字面量对象 -> Object原型
Object.prototype = obj1.__proto__
// Object原型的原型 为 null
null = Object.prototype.__proto__

// 字面量函数 -> Function构造函数
Function = fun1.constructor
// 字面量函数 -> Function原型
Function.prototype = fun1.__proto__
// Function原型作为一个构造函数时的实例原型 -> undefined
undefined = Function.prototype.prototype
// Function原型的原型 为 Object原型
Object.prototype = Function.prototype.__proto__

然后我们就可以完整的得到原型链:

js 复制代码
// 构造函数链
// 实例对象 -> 构造函数 -> Function构造函数
PersonFun = p1.constructor
Function  = p1.constructor.constructor
Function  = p1.constructor.constructor.constructor

// 原型对象链
// 实例对象 -> 原型对象 -> Object原型 -> null
Person            = p1.__proto__
Object.prototype  = p1.__proto__.__proto__
null              = p1.__proto__.__proto__.__proto__

// 字面量对象的原型对象链
// 字面量函数 ->  Object原型 -> null
Object.prototype  = obj1.__proto__
null              = obj1.__proto__.__proto__

// 字面量函数的原型对象链
// 字面量函数 -> Function原型 -> Object原型 -> null
Function.prototype  = fun1.__proto__
Object.prototype    = fun1.__proto__.__proto__
null                = fun1.__proto__.__proto__.__proto__

// Number对象的原型链
const n1 = new Number(1);
// Number对象 -> Number原型 -> Object原型 -> null
Number.prototype  = n1.__proto__
Object.prototype  = n1.__proto__.__proto__
null              = n1.__proto__.__proto__.__proto__

总结

通过原型链,我们可以了解JS中一些原生对象的原理和机制,比如为什么Function的实例也是对象,Number的实例也是对象,因为这些对象的原型都继承了Object原型,因此可以使用对象类型的方法。

使用原型链,也可以实现很多类的继承模式,后面有机会我们可以讨论一下。总体看来,虽然使用原型链确实可以实现类和继承的等面对对象特性,但是相比于其他语言更晦涩且不容易理解。

参考

相关推荐
你挚爱的强哥9 分钟前
✅✅✅【Vue.js】sd.js基于jQuery Ajax最新原生完整版for凯哥API版本
javascript·vue.js·jquery
y先森44 分钟前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy44 分钟前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189111 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿2 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡3 小时前
commitlint校验git提交信息
前端
虾球xz3 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇3 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒3 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员4 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js