重学JS(1):原型链(__proto__/prototype/construct)

409 阅读6分钟

在项目中经常会接触到JS中原型链相关的知识,但是迫于当时的项目进度压力,通常只能迅速地从搜索引擎/个人博客等地方汲取一些碎片化知识,不利于系统化的学习整理。最近从《Professional JavaScript for Web Developers》一书中重学JS中的对象,现在尝试通过自己的理解和体会把JS中的原型链说清楚。

要说清楚JS中的原型链,其实要先把以下三个概念理解清楚:

  1. 什么是JS中的构造函数?
  2. 什么是prototype
  3. 什么是__proto__?

什么是JS中的构造函数?

关于这个问题,可能大家想到的首先是“构造函数就是一个搭配上new关键字就能生成一个新的实例的函数”,这个描述并没有说到点子上,其实ECMAScript规范里有明确规定了满足这三点的函数可以称为构造函数

  • 函数内没有显式地创建对象
  • 属性和方法都直接赋值给了this
  • 没有return

JS中有许多内置的构造函数,例如FunctionObjectArrayString等等,可以搭配new去生成一个新的实例,这里特别说到,所有函数(包括构造函数)都是Function的一个实例,这里可能会有很多人不理解,目前,声明一个函数主要有以下三种方式:

第一种是函数声明式:

function foo(){
    // ...
}

第二种是表达式:

const foo = function(){
    // ...
}

第三种是用的比较少的new Function的形式

// 在new Function中除了最后一个参数,其余参数都作为新声明函数的参数,最后一个参数作为新声明函数的函数体
const foo = new Function(arg1, arg2, ..., `return ${arg1} + ${arg2}`)

以上三种方式结果是等价的,从第三种一眼就能看出fooFunction的一个实例,但是为什么我们习惯于用前两种来创建一个函数呢?一个重要的原因是new Function的方式会执行两遍,第一次是将它当作常规ECMAScript 代码,第二次是解释传给构造函数的字符串,这会影响性能,在追求极致性能的项目中这种方式并不可取。另外一个原因是前两种创建函数的方式更加直观简洁,便于阅读、理解。

到这里可能还没发现构造函数和原型链之间有什么关系,那我们可以来看一下执行new关键字时做了些什么工作:

function new(parentFn) {
    // 1.新建一个空对象
    let obj = {};
    // 2.将新对象的__proto__属性赋值为构造函数的prototype指向的值
    obj.__proto__ = parentFn.prototype;
    // 3.在新对象的作用域下执行构造函数
    parentFn.call(obj);
    // 4.返回这个新对象
    return obj;
}

此时可以很清楚的看到,new构造函数__proto__prototype三者联系了起来!

什么是JS中的原型对象

JS中每创建一个函数,都会按照特定规则添加一个prototype属性,指向它的原型对象。特别地,原型对象是由Object构造函数按照特定规则创建的一个对象,所以原型对象中除了包含一些继承自Object属性和方法外,还有一个名为constructor的属性,指回原函数。

什么是__proto__?

在构造函数每次创建一个新的实例时,实例中都有一个名为[[prototype]]的内部属性,它指向构造函数的原型对象。特别地,[[prototype]]是一个内部属性,JS中并没有提供直接访问这个属性的方法,但是现代浏览器中的JS引擎时都会将这个属性暴露出来供开发者访问,取名为__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

我们也可以将他们的关系可视化表示为: 【图】

prototype/__proto__/construct三者关系

上面已经介绍完概念,现在来用一个具体例子来看看,这里引用了知乎答主doris回答里的图:

已导入的文件 3.png

1.先看到中间Functions一列,FooObjectFunction都是构造函数,其中,FooObject也是Function的一个实例,特别地,构造函数Function本身也是一个函数。

对于蓝色部分路径:

可以很容易理解到,f1/f2Foo的实例,f1/f2中有__proto__属性指向构造函数Foo的原型对象Foo.prototype,同时,构造函数Foo自身有prototype属性指向其原型对象Foo.prototype,原型对象Foo.prototype中有constructor属性指回构造函数Foo

对于Object与其实例o1/o2也同理。

对于橙色部分路径:FooFuction的关系

因为通过function Foo()的方式声明了构造函数Foo,所以由概念可知,Foo中有__proto__属性指向构造函数Function的原型对象Function.prototype,同时,构造函数Functionprototype属性指向其原型对象Function.prototype,原型对象中有constructor属性指回构造函数Function

对于Object和其他一些JS内置的构造函数例如ArrayNumberString等与Function的关系也同理。

对于绿色部分路径

可以发现构造函数Function__proto__属性指向Function.prototype,同时构造函数Functionprototype属性也指向Function.prototype,那这是不是说明,Function实例化了自己?可以验证一下:

// Foo是Function的一个实例时有:
const Foo = function(){}
console.log(Foo instanceof Function) // true
console.log(Object.getPrototypeOf(Foo) === Function.prototype) // true

// 测试Function是不是Function的一个实例:
console.log(Function instanceof Function) // true
console.log(Object.getPrototypeOf(Function) === Function.prototype) // true

难道这是一个先有鸡还是先有蛋的问题?我觉得并不需要钻牛角尖,只要把Function理解为JS引擎按照ECMAScript规范生成的一个对象,并且为它赋予了上面这些特征就可以了。

对于红色部分路径:

这一部分可以这样理解,所有原型对象都是一个对象实例,都是由Object创建的(Object.prototype除外),所以这些原型对象上都会继承Object原型对象上的一些属性和方法。对于Object原型对象Object.prototype它也是一个对象,但它不是由Object创建的,这一点从图中的Object.prototype.__proto__ === null就可以知道,可以理解为Object.prototype也是JS引擎按照ECMAScript规范生成的一个对象,并且在它上面定义了一个共用的属性和方法,例如toStringhasOwnProperty等等。

这一些就是我对JS中原型链的理解,学完了原型链,与其最相关的应用场景就是继承了,那么,下一篇我将会写一写学习JS中的继承的总结。