原型-原型链-new的二三事

Huy大约 8 分钟javascriptjavascript

先说结论

在 JavaScript 中,每个函数都有一个 prototype 属性和一个 __proto__ 属性。它们两者之间有以下区别:

  1. prototype 是函数独有的属性,而 __proto__ 是每个对象(包括函数对象)都有的属性。
  2. prototype 属性是用于实现基于原型的继承的。它指向一个对象,该对象被用作构造函数创建的所有对象的原型。而 __proto__ 属性则指向该对象的原型,即该对象继承自哪个对象。
  3. 在构造函数中,prototype 属性通常用于添加方法和属性,以便通过该构造函数创建的所有对象都可以访问这些方法和属性。而 __proto__ 属性则用于从父对象继承属性和方法。
  4. 由于 prototype 是函数特有的属性,因此只能在函数内部使用;而 __proto__ 属性是每个对象都有的属性,因此可以在任何对象上使用。
  5. prototype 属性不会随着对象创建而自动赋值给该对象的 __proto__ 属性,需要使用 new 关键字来创建对象并将其 __proto__ 属性指向构造函数的 prototype 属性。而 __proto__ 属性则会自动指向构造函数的 prototype 属性所引用的对象。

总之,prototype 属性用于定义构造函数创建的所有对象共享的属性和方法,而 __proto__ 属性用于实现继承和访问对象原型链上的属性和方法。

1. 到底是什么?

笔者在 彻底理解 this 指向 一文中,简单描述了 new 一个对象的过程。在此,再进行进一步的梳理。

new 关键字到底做了什么事情?

  • 首先创建一个空对象,这个空对象将会作为执行构造函数(constructor)之后的返回的对象实例。
  • 对创建的空对象的原型(newObj.__proto__)指向构造函数的原型属性(Function.prototype)。
  • 将这个空对象赋值给构造函数内部的 this,并执行构造函数逻辑。
  • 依据构造函数执行逻辑,返回第一步所创建的对象或构造函数的显示返回值(必须是对象)。

文字是苍白的,我们看看用代码如果来简单模拟一遍。

function new(parentFn, args) {
    // 1.新建一个空对象(或后续返回的实例)
    const obj = {};
    // 2.将新对象的__proto__属性赋值为构造函数的prototype指向的值
    // 也可以用 obj = Object.create(parentFn.prototype) 实现
    obj.__proto__ = parentFn.prototype;
    // 3.在新对象的作用域下执行构造函数
    const result = parentFn.apply(obj, args);
    // 4.返回这个新对象,或构造函数显示返回值
    return (typeof result === 'object' && result !== null) ? result : obj
}

从上面可以很清楚的看到,new构造函数__proto__prototype三者联系起来了。

再来看一下 JavaScript Object Layout 的原图:

JavaScript Object Layout
JavaScript Object Layout

原博文open in new window 也推荐看一下噢,大体就能知道整个过程了。

简单梳理一下图中的定义关系和和需要记忆的关键点:

  • 构造函数是创建 f1/f2 对象的 Foo( )
  • 构造函数 Foo( )有一个原型对象叫 Foo.prototype,构造函数 Foo( )[[prototy]] 属性就指向它;
  • 被构造函数 Foo( ) 所创建的 f1/f2 对象有一个 __proto__ 属性,它指向构造函数 Foo( )的原型对象 Foo.prototype;
  • 原型对象 Foo.prototype 自身有一个特有属性 constructor 指回构造函数 Foo( )

我们对照图,来详细说说。构造函数 Foo( ) 每次创建一个新的实例/对象的时候,实例/对象 中都有一个 [[prototy]] 的内部属性,它指向了构造函数 Foo( ) 的原型对象( Foo.prototype )。关键点! 关键点!! 关键点!!! 这个 创建出来的 实例/对象的 **[[prototy]]**内部属性 (区别于构造函数,直接通过 prototype 属性访问)该怎么访问它呢? 现代浏览器中的 JS 引擎都用__proto__这个属性暴露出来。

然后再来看啊,构造函数 Foo( )原型对象( Foo.prototype )。 它叫原型对象是吧,它也是一个对象,是由 Object( )构造函数创建出来的! 所以它的__proto__ 指向 Object( )构造函数的原型对象(Object.prototype)。Object.prototype 这个原型对象已经到头了,没有其它构造函数创建它了,所以指向 null

再看啊,构造函数 Foo( ) 的原型对象( Foo.prototype ) 的另一个关键点!这个原型对象除了因为 __proto__ 这个原型链能够继承到 Object属性和方法外,还有一个重要的属性 [[constructor]] 。这个属性它指回 构造函数 Foo( )本身。

对关于 Object 构造函数其实也是这样,不再做说明。

最后,我们来看看 Foo 和 Function 的关系。

上面,我们看到 所有的 原型对象 都是由 Object( )构造函数创建的,而 Foo( ) 这样的构造函数呢?除去一个一个父级的构造函数套娃创建外(function Foo created via new Function),我们能最终看到它是最终被 Function 构造函数所创建。像 Object 这样的构造函数,也是被 最终的 Function 构造函数所创建。在图中我们可以看到,就连 Function 构造函数自己也是被自己所创建的(Function via new Function(so points to it’s own proto))。所以 它的 __proto__ 指向自身的原型对象(实际上就是一个东西啦, __proto__ 是被浏览器所造出来的东西)。

到这里,这张图,其实就解释的差不多了,然后,我们可以得到如下验证:

// 定义一个构造函数 Foo()
function Foo() {}

// 使用Foo创建一个实例
const fooInstance = new Foo()

// 三者之间的相互关系
console.log(Foo.prototype) // {constructor: ƒ}
console.log(Foo.prototype.constructor === Foo) // true
console.log(fooInstance.__proto__ === Foo.prototype) // true

最后说点题外话,江湖中有这样描述 __proto__prototype 的:对象有 __proto__ ,而函数还有一个 prototype 属性。这句话对,但并不是很准确。笔者也是弯弯绕绕学了很多次,记不清的时候便想想 new 一个构造函数的过程,其实就知道这三者的关系啦。

2. 原型链

从上面的原型一个接一个的 __proto__ 的套用,所形成的链条,就是原型链。

我们来再来看看一些其它的知识(串串香呀(~ ̄ ▽  ̄)~)。

JS 的七大内置类型是: Null、Undefined、Boolean、String、Number、Object 和 Symbol。下划线的前五个是基本数据类型。

其中的 Object 又包含了 Function、Array 和 Date 等。(BigInt 作为一种新的数据类型,不做讨论)

检验数据类型的方法是 typeof

typeof undefined === 'undefined'
typeof null === 'object' // 这个除外, 是一个 bug
typeof [] === 'object'

可以看到,像 Function、Array 这些会输出 object,这并不是我们想要的。

解决办法:使用 instanceof 判断数据类型。利用原型链来搞定,a instanceof B 判断的是 a 的原型链是否存在 B 的构造函数。我们来手写一个 instanceof :

function instanceofMock(son, parent) {
  if (typeof son !== 'object') {
    // 判断是否需要 instanceof 出手,不是 object 就是基本数据类型呀,用 typeof 判断
    return false
  }
  while (true) {
    if (son === null) {
      // 走到头了, 遍历到顶端也没有找到符合要求的原型链
      return false
    }
    if (son.__proto__ === parent.prototype) {
      return true
    }
    son = son.__proto__ // 递归向上查找
  }
}

提示

24678 instanceof Number !== true 这种基本数据类型,它不是由 Number 构造函数直接创建的,结果返回是 false,需要将数字包装一下: new Number(24678) instanceof Number === true

好了,说回正题,我们来看看利用原型链来判断数据类型的终极方法:Object.prototype.toString

但是,我们还是先来看个简单的调用:

const arr = [24678, 24678]
console.log(arr.toString()) // 24678,24678

通过上面的原型链我们可以知道,数组为什么可以使用 Object 对象的 toString 方法,但是这里有几个小细节。通过 MDNopen in new window 我们可以知道: “ Arrayopen in new window 对象覆盖了 Objectopen in new windowtoString 方法。对于数组对象,toString 方法在内部调用 join()open in new window 方法拼接数组中的元素并返回一个字符串,其中包含用逗号分隔的每个数组元素。如果 join 方法不可用,或者它不是一个函数,将使用 Object.prototype.toStringopen in new window 代替,返回 [object Array]。”

const arr = []
arr.join = 1 // re-assign `join` with a non-function
console.log(arr.toString()) // Logs [object Array]

console.log(Array.prototype.toString.call({ join: () => 1 })) // Logs 1

再看看 Object.prototype.toString() 的定义:toString() 方法返回一个表示该对象的字符串。该方法旨在重写(自定义)派生类对象的类型转换open in new window的逻辑。也就是说每个对象都有一个toString方法,当对象被表示为一个文本值或一个字符串方式引用时,自动被调用。默认情况下,toString( )方法是被每个 Object 对象所继承,如果该方法未被自定义覆盖Object.prototype.toString() 就返回 "[object Type]",这里的 Type 是对象的类型。

所以,因为像 Array 这样的 toString 方法已经被修改过了,因此直接调用 Object 原型上的 toString 方法来检测, 便有了 Object.prototype.toString.call( arr ) === '[object Array]' 这样的方法来判断数据类型啦。

另外像 arr.valueOf() 实际上是通过原型链进行查找: arr.__proto__ 找到了数组 Array ( )构造函数,但是没有这个方法,所以继续向上查找。arr.__proto__.__ proto__ 找到了 Object.prototype 上的 valueOf( ) 方法,但是如果获取 Object.prototype .valueOf( obj ) 所需要运行的 obj 内容呢。实际上是做了 call 绑定,即 arr.valueOf( ) 等价于 Object.prototype.valueOf.call( arr )。也就是将 arr 传递给了 valueOf 方法了。我们可以进行简单验证:

const arr = [24678, 24678]
arr.valueOf.call(arr) // [24678, 24678]
arr.valueOf() === arr.valueOf.call(arr) // true
arr.valueOf.call(arr) === window.Object.prototype.valueOf.call(arr) // true
Loading...