别在问我什么是原型链

470 阅读12分钟

前言

记得初学JavaScript时, 我一直对这门语言感到疑惑. 众所周知, 面向对象有三大特性封装、继承和多态. 就从我当时的理解来讲, 我认为JavaScript是没有继承的, 因为它没有"类"(class)和"实例"(instance)的区分, 但它有一个叫做 原型链prototype chain)的神奇模式, 来实现继承.

(在2015年6月最新发布的 ECMAScript 6.0 标准中, 已经有了 class 以及 extends 关键字, 但是基本上来说, ES6的class可以看做是一个语法糖, 新的写法只是让对象原型写法更清晰、更加贴近面向对象编程的语法而已. 具体可以看到下文介绍)

认识原型

我们都知道, Java和C++是使用广泛的面向对象语言, 他们都使用new命令, 来生成实例:

Foo *f1 = new Foo();	// c++
Foo f1 = new Foo(); 	// java

其中上图的Foo在这两门语言中均是指的class, 他们在调用new命令时, 都会调用构造函数(constructor). 而在JavaScript中, new命令后面跟的不是, 而是构造函数.

我们从一个最简单的对象初始化开始:

function Foo() {
  this.name = 'xxx'
}
let f1 = new Foo()

上面我们创建了一个构造函数 Foo(), 并用new关键字实例化该构造函数得到一个实例化对象 f1.

我们首先来了解一下new操作符将函数作为构造器进行调用时的过程:

  1. 创建一个空对象 f1
  2. f1的**__proto__**属性指向构造函数Foo的原型, 即 obj.__proto__ = Foo.prototype
  3. 将构造函数 Foo 内部的 this 绑定到新建的对象f1, 执行Foo(此时函数的this指向了f1, 所以相当于 f1.Foo()f1.name = 'xxx'
  4. 若构造函数没有返回非原始值(即不是引用类型的值), 则返回该新建的对象obj(默认会添加 return this). 否则, 返回引用类型的值

由上面的构造过程, 我们可以知道最终构造出来的对象 f1 拥有一个属性 __proto__, 该属性指向了他的构造函数的一个属性prototype, 这也就是我们常说的原型. 当我们在浏览器中打印可以看到他们的指向是相同的:

image.png

每一个JavaScript对象(null除外)在创建的时候就会与之关联另一个对象, 这个对象就是我们所说的原型, 每一个对象都会从原型 "继承" 属性. 但是与Java、C++ 等语言不同的是, 这里说的继承指的是实例与他的父类共享一个属性, 不论是实例或者父类修改这个属性, 都会影响共享这个属性的所有实例

由此我们可以先得出一个简单的关系图:

1.png

上图我们可以看到这是一个非常简单清晰的指向关系, 实际上打印一下我们可以看到他们其中还包含有一个constructor属性, 这个属性对于js的原型链的构成也是不可或缺、非常重要.

WX20200618152737.png

接下来我们就基于这个例子拓展一下这个指向图, 来详细了解一下 __proto__, prototype 以及 constructor 他们三者复杂的"三角关系"

__proto__ 属性

首先, __proto__ 它是对象所独有的;

它的作用是当访问一个对象属性时, 如果该对象内部不存在这个属性, 那么就会去它的**__proto__属性所指向的那个对象(可以理解为父对象)里找, 如果父对象也不存在, 则继续往父对象的__proto__属性所指向的那个对象里找, 如果还没有找到, 则继续往上找, 直到原型链顶端null**;

我们可以继续拓展上面的指向关系图:

2.png

这种通过**__proto__属性来连接对象直到null的一条链即为我们平时所谓的原型链(null为原型链的终点). 我们平时所经常使用的字符串方法、数组方法、对象方法、函数方法等都是靠__proto__**继承而来的.

__proto__ 属性在ES标准定义中的名字应该是**[[Prototype]], 但是具体实现是由浏览器代理自己实现, Chrome就是将其命名为__proto__**. 可以通过Object.getPrototypeOf({__proto__: null}) === null检测是否支持属性

prototype 属性

prototype属性它是函数所独有的;

但是由于在JavaScript中函数也是一个对象, 所以函数也拥有**__proto__constructor**属性.

prototype 的含义是函数的原型对象, 也就是这个函数所创建的实例对象的原型对象, 他的作用就是包含可以由特定类型的所有实例共享的属性和方法, 也就是让该函数所实例化的对象们都可以找到公有的属性和方法, 任何函数创建的时候, 都会默认同时创建该函数的prototype对象.

为了更直观的表示, 我们继续将函数原型也补充到图中:

3.png

constructor 构造函数

我们现在已经知道了, 我们刚刚初始化的f1对象中有一个**__proto__属性, 指向他的父类Fooprototype**, 二者共享这个属性. 那么我们现在来打印一下这个属性, 我们来看看他其中包含什么

WX20200618152737.png

可以看到其中有一个constructor, 这就是我们常说的构造函数, 而且每个原型都有一个 constructor 属性指向关联的构造函数.

constructor属性是从一个对象指向一个函数, 含义就是指向该对象的构造函数, 每个对象都有构造函数, 也就是说:

f1.__proto__.constructor === Foo	// true; Foo()是f1的构造函数

但是Function这个对象比较特殊, 它的构造函数就是它自己(见下方), 所有函数和对象最终都是由 Function 构造函数得来, 所有 constructor 属性的终点就是Function这个函数

Foo.prototype.constructor === Foo		// true; Function对象的构造函数是它自己

为了更直观的体现, 我们把constructor的指向也输入到图中:

WX20200618172728.png

(图上虚线部分指的是继承(共享)而来的属性, 因为实例对象f1不具有constructor属性;)

constructor部分也加入到其中之后, 我们就得到了一张完整的原型链. 有不明白的可以结合上文看一下, 其实梳理完成之后还是很简单、清晰的.

至此原型链的介绍就结束了, 我们来总结一下几个重点:

1. __proto__ 和 constructor 它是对象所独有的 
2. __proto__的作用是当访问一个对象属性时, 如果该对象内部不存在这个属性, 那么就会去它的__proto__属性所指向的那个对象(可以理解为父对象)里找, 如果父对象也不存在, 则继续往父对象的__proto__属性所指向的那个对象里找, 如果还没有找到, 则继续往上找, 直到原型链顶端null
3. prototype 是函数所独有的.  但是由于JavaScript中函数也是一个对象, 所以函数也拥有 __proto__ 和 constructor 属性
4. prototype 的含义是函数的原型对象, 也就是这个函数所创建的实例对象的原型对象,他的作用就是包含可以由特定类型的所有实例共享的属性和方法, 也就是让该函数所实例化的对象们都可以找到公有的属性和方法, 任何函数创建的时候, 都会默认同时创建该函数的prototype对象
5. constructor 属性是从一个对象指向一个函数, 含义就是指向该对象的构造函数, 每个对象都有构造函数. Function这个对象比较特殊, 它的构造函数就是它自己, 所有函数和对象最终都是由 Function 构造函数得来, 所有 constructor 属性的终点就是Function这个函数

原型链污染

在2019年, 著名的 'lodash' 库就被曝出一个安全漏洞, 就是由于原型使用不当会造成的原型污染 (原文链接), 具体是涉及到了 _.defaultsDeep() 这个方法, 该方法被用来递归合并对象.

_defaultsDeep({ 'a': { 'b': 2 } }, { 'a': { 'b': 1, 'c': 3 } })
// result => { 'a': { 'b': 2, 'c': 3 } }

可以看到, 该方法用来将一个对象里面另一个对象中不存在的键深拷贝到后者当中. 下面的示例展示了该方法是如何污染原型的:

const mergeFn = require('lodash').defaultsDeep
const payload = '{"constructor": {"prototype": {"a0": true}}}'

function check () {
  mergeFn({}, JSON.parse(payload))
  if (({})[`a0`] === true) {
		console.log(`原型被污染! ${payload}`)
  }
}

check()

上面代码展示了defaultsDeep通过复制payload, 在空对象的constructor的原型中写入了一个键值对"a0: true". 此后所有的对象的原型上都会多出这个键值对, 而会引起的安全问题包括从属性注入代码注入等, 我们在开发中也要避免这种问题的出现.

我们已经了解原型链的一个基本的概念, __proto__prototype属性指向一个包含所有实例共享的属性和方法的对象. 既然它是**'共享'的, 那么就一定会出现'污染'**的这么一个问题:

WX20200622171536.png

上图可以看到我们修改f1.name属性, 结果更改却影响到了另一个实例f2, 这在大部分时候不是我们想看到的. 那么在我们如何来实现一个不会被污染的原型继承呢 , 下面来改造一下这个例子:

function Foo () {
  this.name = [1, 2, 3]
}
function FooSon () {
  Foo.call(this)				// added; 当FooSon作为构造函数执行到这里的时候, 此时函数内部的this指向已经绑定到对象 `f1`
}
FooSon.prototype = new Foo()
let f1 = new FooSon()
f1.name.push(4)
console.log(f1.name)		// print [1,2,3,4]
let f2 = new FooSon()
console.log(f2.name)		// print [1,2,3]

这里来解释一下我们加的唯一一行代码Foo.call(this)的作用, 我们上面介绍过new关键字所做的事情, 其中有一步是 将构造函数 FooSon 内部的 this 绑定到新建的对象f1, 执行FooSon. 也就是说:

  1. 当我们构造函数FooSon执行的时候, 函数内部的this指向已经变成了f1, 那我们添加的代码Foo.call(this)就等同于Foo.call(f1)
  2. 继续向下走执行Foo的时候, 起内部的this指向也已经被指定为f1, 所以最终在Foo函数内部的 this.name = [1,2,3] 就等同于 f1.name = [1,2,3]

这种方式被称为 ''组合继承''(又叫伪经典继承), 他解决了属性共用的问题, 同时解决了函数无法共用的问题, 实际上现在已经成为了js中最常用的继承模式

ES6 中的 class

ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。

基本上,ES6 的class可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已, JavaScript 仍然是基于原型的。

ES6 的类,其实完全可以看作构造函数的另一种写法。

class Point {
	// ...
}

typeof Point 	// "function"
Point === Point.prototype.constructor	// true

上面代码表明,类的数据类型就是函数,类本身就指向构造函数。

也就是说, 使用ES6语法的类来构造对象, 其原型原型链的表述, 与我们传统的对象声明没有什么区别. 关于class这里就不具体展开了, 感兴趣的同学可以直接去参考阮一峰老师的ES6入门, 里面介绍的已经很详细了.

babel是如何转换ES6中的class、extends

上文讲到了ES6的class语法, 我们知道我们所常用的的React框架就是使用es6的Class以及extends来实现组件之间的继承. 但是由于兼容性的问题, 我们在项目中大多数时候, 还是会使用babel将es6代码转换成es5, 最后我们来看一下, babel是如何转换es6的classextends

我们打开babel在线解析器, 输入使用es6语法编写的我们上述的例子, 然后执行转换:

WX20200623114024.png

我们可以看到右侧是转换之后的es5的代码, 那我们现在就来一起简单解析一下他做了什么(下面代码去除了一些类型判断、非空判断的函数, 只保留了主要流程, 方便我们整理)

"use strict";

/*********** 辅助方法 ************/

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function");
  }
  // 1. 将subClass(FooSon)的prototype指向一个根据superClass(Foo)的prototype新创建的对象, 其中包含一个指向自己的构造函数constructor
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: { value: subClass, writable: true, configurable: true }
  });
  // 2. 将superClass(Foo)的__proto__属性指向superClass
  if (superClass) _setPrototypeOf(subClass, superClass);
}


function _createSuper(Derived) {
  // derived = FooSon

  // 判断是否支持Reflect, 如果不支持接下来将降级使用apply, 来继承父类的内部属性和方法
  var hasNativeReflectConstruct = _isNativeReflectConstruct();

  return function _createSuperInternal() {
    // 获取FooSon的原型指向__proto__, 在上面步骤中已经指向到Foo函数
    var Super = _getPrototypeOf(Derived),
      result;
    if (hasNativeReflectConstruct) {
      // 获取 this 的__proto__取得其中constructor (执行到这里的this实际上已经是对象实例 var f1 了)
      var NewTarget = _getPrototypeOf(this).constructor;
      // ES6新语法, 等同于 new Foo(arguments, NewTarget)
      result = Reflect.construct(Super, arguments, NewTarget);
    } else {
      // 不支持Reflect, 降级使用apply, 这里的Super = Foo
      result = Super.apply(this, arguments);
    }
    // 判断result可用性 最终return的是result
    return _possibleConstructorReturn(this, result);
  };
}


/*********** 构造函数 ***********/

// 父类函数Foo
var Foo = function Foo(name) {
  // 检测构造函数的prototype属性是否出现在某个实例对象的原型链上 内部使用了instanceof
  // 也就是说检测是否是使用new关键字执行, 而不是直接调起构造函数
  _classCallCheck(this, Foo);

  this.name = name;
};

// 子类函数FooSon 立即执行函数
var FooSon = /*#__PURE__*/ (function(_Foo) {

  // 修改原型指向, 等同于:
  // 将FooSon.prototype = Object.create(_Foo, {constructor: { value: _Foo }})
  // 将FooSon.__proto__ = _Foo
  _inherits(FooSon, _Foo);

  // _super等同于: _Foo.apply(this, arguments)
  var _super = _createSuper(FooSon);

  function FooSon(name) {
    // 使用instanceof检测FooSon的prototype属性是否出现在f1(this此时等于f1)的原型链
    _classCallCheck(this, FooSon);
    // 最后等同于 _Foo.call(this, name)
    return _super.call(this, name);
  }

  return FooSon;
})(Foo);

var f1 = new FooSon('xxx')

通过上述分析我们发现, 他其实也是一个组合继承的写法, 只不过实现的更加完备, 基本原理同我们上面所写过的简单例子其实没有什么太大差别.

以上就是本文全部内容了, 如果有问题还请各位大佬指出...

ps: 我爱新裤子乐队, 这个标题取自<别在问我什么是迪斯科>, 墙裂推荐给大家, 有助于加快代码速度