浅析 JavaScript 中的原型和原型链

181 阅读16分钟

JavaScript 常被描述为一种基于原型的语言 (prototype-based language) ——每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain) ,它解释了为何一个对象会拥有定义在其他对象中的属性和方法。、

一、什么是原型(prototype)

在 JavaScript 中函数也是对象,可以拥有属性。其中,每个函数都有一个特殊的属性叫作原型(prototype)。只要创建一个函数,就会按特定规则为这个函数创建一个 prototype 属性。
简单来说:原型(prototype)就是函数对象的一个属性,该属性是一个对象,包含特定引用类型的实例共享的属性和方法

1.1 原型(prototype)、构造函数(constructor)、实例对象的关系

每个构造函数都有一个原型(prototype)属性(指向原型对象),原型对象有一个属性(constructor)指回构造函数。此外,通过构造函数创建的实例对象有一个内部属性(__proto__)指向原型对象。

上述这段话非常重要,但是感觉很绕,但是不要怕,下面从代码和图两种方式来解释这段话。先看如下一段代码:

// 定义构造函数Fn
function Fn(name) {
    this.name = name;
}

// 通过构造函数 Fn 新建一个实例对象
let obj = new Fn();

// 构造函数的 prototype 属性
console.log(Fn.prototype)

// Fn 的原型对象的 constructor 属性指回构造函数
console.log(Fn.prototype.constructor === Fn)  // true

// 通过 Fn 创建出的实例对象内部属性 __proto__ 指向原型对象
console.log(obj.__proto__ === Fn.prototype)  // true

// 实例对象的 constructor 指向构造函数
console.log(obj.constructor === Fn)
console.log(obj.constructor === Fn.prototype.constructor)

上述代码中呈现出的关系如下图:

基于知识表示学习的整体框架图.png 图中只是简单展示了构造函数、原型、实例对象之间的关系,并未完全涉及到原型链,相关知识会在后文介绍。

1.2 对象原型 和 构造函数 prototype 属性的区别

虽然,当实例对象是由对应构造函数创建时,对象原型和构造函数 prototype 属性指向同一个对象,但是两者之间并非同一个东西。

(1)对象原型

对象原型时每个实例对象都拥有的属性,可以如下两种方式获得:

  • 通过Object.getPrototypeOf(obj)方法
  • 实例对象的 __proto__ 属性(已弃用)。
let obj = {};

console.log(obj.__proto__)  // {constructor: ƒ, …}
console.log(Object.getPrototypeOf(obj))  // {constructor: ƒ, …}

(2)prototype 属性

prototype 属性是构造函数特有的属性

// 构造函数Foo
function Foo(name) {
    this.name = name;
}
// foo的实例对象
let obj = new Foo();
console.log(Foo.prototype);  // {constructor: f}
console.log(obj.prototype);  // undefined

上述代码中 obj.prototype 的值为 undefined, 说明实例对象obj并不包含 prototype 属性。

此外,需要注意的一点,构造函数也是一个对象,所以构造函数也有对象原型。(其实在JavaScript中,每个构造函数都是Function实例对象,这个在后面原型链中详述)

// 定义构造函数foo
function Foo(name) {
    this.name = name;
}
// 构造函数的对象原型
console.log(Object.getPrototypeOf(Foo))  // ƒ () { [native code] }

(3)小结

对象原型是所有实例对象(包括构造函数)的属性,而prototype 是构造函数的属性。
当实例对象是由对象构造函数创建的,那么实例对象的原型和构造函数的prototype指向同一个对象。如:
Object.getPrototypeOf(new foo())foo.prototype 指向同一个对象。

实例对象和构造函数原型之间有直接的联系,但实例与构造函数之间没有。

二、构造函数 和 new 运算符

在介绍原型链之前,为了更好理解第一节中的一些概念,这里下介绍一下构造函数和 new 运算符

2.1 构造函数

不同于其他面向对象的编程语言,JavaScript 的构造函数并不是作为类的一个特定方法存在的;当任意一个普通函数用于初始化实例对象,他就被称作构造函数 或 构造器。构造函数都是和 new 一起使用的。
严格来说,一个函数作为真正意义上的构造函数,一般需要满足以下条件:

  • 默认函数首字母大写
  • 在函数内部对新对象(this)的属性进行设置,通常是添加属性和方法。
  • 一般构造函数没有返回值,new 操作符会自动创建并返回。若构造函数中有返回值,返回值是一个对象,会代替新创建的对象实例返回,返回值是一个原始类型,则会被忽略。 此外需要注意的一点:构造函数也是函数,所以也可以被直接调用,当没有返回值并不会自动创建对应的对象实例并返回。而且函数中的this在非严格模式下为全局对象,严格模式下为 undefined
// 构造函数Foo
function Foo(name) {
    this.name = name;
}
let obj = new Foo("haha");
// new 关键字会自动创建对象
console.log(obj);  // Foo {name: 'haha'}
console.log(typeof obj);  // object 

// 非严格模式下,直接调用构造函数,其中this为全局对象
Foo('dali');
console.log(name);  // dali
/*****************************************************************************/
// 当构造函数返回值为原始值时被忽略
function Foo(name) {
    this.name = name;
    return 1;
}
let obj = new Foo("haha");
console.log(obj);  // Foo {name: 'haha'}

/*****************************************************************************/
// 当构造函数返回值为对象时,替换新创建的对象实例返回
function Foo(name) {
    this.name = name;
    return {};
}
let obj = new Foo("haha");
console.log(obj);  // {}

所以,综上所述:
构造函数是是一种用于初始化对象实例的特殊函数,与 new 运算符一起使用。

2.2 new 运算符

构造函数都是和 new 运算符一起使用的,那么 new 的到底执行了哪些操作呢。

(1)官方定义

new 运算符创建一个用户定义的对象类型的实例 或 具有构造函数的内置对象的实例。

(2)语法

new constructor[([arguments])]
  • constructor:一个指定对象实例的类型的类或函数。
  • arguments:一个用于被 constructor 调用的参数列表。

(3)new 关键字执行的操作

  1. 创建一个空的简单JavaScript对象(即{})
  2. 为 1 中创建的对象添加 __proto__ 属性,并将该属性链接至构造函数的原型对象。
  3. 将 1 中创建的对象作为 this 上下文
  4. 如果该函数有返回值,且对象类型,则返回该对象。否则返回 this

(4)手动实现

function _new(fn, ...args) {
    // 创建一个JavaScript空对象,并将其__proto__ 属性链接到构造函数的原型
    let instance = Object.create(fn.prototype);

    // 不使用 Object.create()
    // let instance = {}
    // instance.__proto__ = fn.prototype

    // 改变构造函 fn 的 this 指向
    let res = fn.apply(instance, args);
    
    // 如果 fn 返回值为对象,则返回该对象,否则返回this(指的是构造函数中的this,已经绑定为instance)
    return typeof(res) === 'object' ? res : instance;
}

三、原型链

前两节中我们知道了原型(prototype)其实就是函数对象的一个特殊的属性,但是它是如何做到共享属性和方法的呢?这就要引出原型链了,它解释了为何一个对象会拥有定义在其他对象中的属性和方法。
ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。其主要思想就是通过原型链继承多个引用类型的属性和方法。

3.1 原型链是什么

(1)官方术语

首先我们看MDN中的几段话:

(1)JavaScript 只有一种结构:对象。每个实例对象(object)都有一个私有属性(称之为 proto )指向它的构造函数的原型对象(prototype)。该原型对象也有一个自己的原型对象(proto),层层向上直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个原型链中的最后一个环节。
(2)几乎所有JavaScript中的对象都是位于原型链顶端的 Object 的实例。
(3)JavaScript 对象是动态的属性“包”(指其自己的属性)。JavaScript 对象有一个指向一个原型对象的链。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。

(1)告诉我们通过每个对象的私有属性__proto__构成了原型链,并且原型链的末端是null。(2)告诉我们,原型链的顶端是Object,几乎所有对象通过原型链都能找到Object,即:Object.prototype.__proto__ === null。(3)告诉我们原型链的查找规则,即如何共享属性和方法的。

(2)代码示例

// 构造函数 Father
function Father(name) {
    this.name = name;
}
Father.prototype.sayName = function() {
    console.log(this.name);
}

// 构造函数 Son
function Son(name) {
    this.name = name;
}
// 设置Son的原型对象Father(即继承自Father)
Son.prototype = new Father;

let father = new Father('father');
let son = new Son('son');

console.log(son.__proto__ === Son.prototype)  // true
console.log(Son.prototype.__proto__ === Father.prototype)  // true
console.log(Father.prototype.__proto__ === Object.prototype)  // true
console.log(Object.prototype.__proto__ === null)  // true

对于上述代码,从Son对象实例son开始分析,son是通过构造函数Son初始化的对象实例,所以son.__proto__指向构造函数Son的原型Son.prototype,因为 Son.prototype = new Father;将构造函数Son的原型属性设置为Father对象实例,所以Son.prototype.__proto__指向构造函数的Father的原型 Father.prototype,构造函数Father的原型prototype并未显示设置指向哪个对象,所以默认指向Object的原型,即Father.prototype.__proto__指向Object.prototype,原型链的顶端是Object对象,所以Object原型的原型指向null,即Object.prototype._proto__指向null。具体关系如下图所示:

未命名文件 (1).png

father.sayName();  // father
console.log(father.toString())  // [object Object]

son.sayName();  // son
console.log(son.toString());  // [object Object]

接着看这段代码中,Son对象实例son中并没有定义sayName()方法,而且sonfather中也没有定义toString()方法,但为啥可以访问呢?
这是因为:

JavaScript 中,访问一个对象的属性时,不仅搜寻当前的对象,还会在该对象所在的原型链上进行搜寻,直到找到标识符匹配的属性或到达原型链末尾。

所以访问son.sayName时,会首先搜寻son,未找到,接着搜寻son.__proto__还未找到,再搜索son.__proto__.__proto__(即Father.prototype)找到了sayName。同理因为Object.prototype中定义了toString方法,而且sonfather的原型链上都包含Object.prototype,所以都能访问到toString

(3)属性遮蔽

根据上述 JavaScript 中访问对象属性的规则,若遇到匹配的标识符就会停止搜寻。所以:
给对象实例添加一个属性,这个属性就会遮蔽原型对象上的同名属性,但不会修改原型对象上的同名属性,仅仅屏蔽对他的访问。

Foo.prototype.say = function() {
    console.log("我是原型上的say方法");
}

let foo1 = new Foo();
let foo2 = new Foo();
foo2.say = function() {
    console.log("我是foo2上的say方法");
}

foo1.say();  // 我是原型上的say方法
foo2.say();  // 我是foo2上的say方法

如上代码,Foo对象实例foo2上添加了say属性,其原型对象上包含同名属性,但是访问foo2.say遮蔽了对原型属性的访问。

(4)prototype 属性:继承成员被定义的地方

继承的属性和方法是定义在 prototype 属性之上的,prototype 属性的值是一个对象,我们希望被原型链下游的对象继承的属性和方法,都被储存在其中。
所以只有 prototype 对象内的成员才会被对象实例继承。

function Foo() {

}
Foo.f = function() {
    console.log("非原型上的方法");
}

let foo1 = new Foo();
foo1.f();  // TypeError
Foo.f();  // 非原型上的方法

3.2 图解原型链

未命名文件 (4).png

注意\color{red}{注意}:图中实例与构造函数存在一个constructor的指向,是为了方便理解实例是由哪个构造函数初始化得到的,但实际上实例中并不包含 constructor 属性,而是继承自原型上的constructor

图中的箭头看起来有点杂乱,下面我将用对应代码解释上图:
(1)首先自定义一个构造函数Person,并初始化一个对象实例person

// 普通构造函数
function Person() {
}
// 对象实例
let person = new Person();

(2)图的顶部表示了构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。

// 原型 constructor 属性指回构造函数
console.log(Person.prototype.constructor=== Person)  // true
// 实例内部属性 __proto__ 指向原型
console.log(person.__proto__ === Person.prototype)  // true

(3)再看图的右边,展示了一个完整的原型链。即:
person -> Person.prototype -> Object.prototype -> null

console.log(person.__proto__ === Person.prototype);  // true
console.log(Person.prototype.__proto__ === Object.prototype);  // true
console.log(Object.prototype.__proto__ === null);  // true

(4)其实(2)(3)已经展示了原型链了,但是构造函数本身也是一个对象,除了拥有prototype函数之外,也拥有内部属性__proto__。那么构造函数的内部属性__proto__指向哪呢?从图中可以看出,构造函数的内部属性__proto__指向Function.prototype

因为,每个JavaScript 函数实际上都是一个Function对象

所以看如下代码:

console.log(Person.__proto__ === Function.prototype);  // true
console.log(Function.__proto__ === Function.prototype);  // true
console.log(Object.__proto__ === Function.prototype);  // true
console.log(Function.prototype.__proto__ === Object.prototype)  // true

PersonFunctionObject都是函数,所以都是Function对象实例,那么他们的内部属性__proto__都是指向Function.prototype。但是原型链的顶层永远都是Object,所以Function构造函数原型的原型(Function.prototype.__proto__)是Objcet.prototype

(5)上图中容易混淆的两个点在于:

  • Function 本身也是Function对象实例,所以Function内部属性__protp__指向Function.protype,并非Object.prototypeFunction原型(prototype)的原型(__proto__)才指向Object.prototype
console.log(Function.__proto__ === Function.prototype);  // true
console.log(Function.constructor === Function)  // true
console.log(Function.prototype.__proto__ === Object.prototype)  // true
  • Object 其实也是 Function 对象实例,所以Object.__proto__Function.protype,并非nullObject原型的原型(Object.prototype.__proto__)才是null
console.log(Object.__proto__ === Function.prototype);  // true
console.log(Object.prototype.__proto__ === null);  // true
  • Objcet() 和 Objcet 不同,Object()是Object对象实例,而Objcet是一个函数对象。

未命名文件 (2).png

即:JavaScript 中的所有函数还是自定义函数还是内置函数,都是Function 对象实例,所以他们的内置属性 __proto__都指向 Function.prototype
而由构造函数初始化的实例,其内部属性__proto__都指向对应构造函数的 prototype属性。 Object.prototype几乎存在于每个JavaScript对象实例的原型链上(由Object.create(null)创建 或者 设置F.prototype = null除外)。所以几乎最终都会指向Object.prototype

小结

所以,对于原型链要清楚以下几点:

  • 构造函数、原型、实例的关系
  • 原型也是一个对象,也有自己的原型。
  • 构造函数 prototype 属性 和 对象内部属性__proto__的区别
  • 原型链顶端对象是 Objcet,Object原型的原型是 null,即原型链的末尾
  • 构造函数本质上是一个Function对象。

四、原型相关问题的七嘴八舌

4.1 判断对象属性是否存在于原型上

(1)in 操作符

in 操作符会在可以通过对象访问指定属性时返回true,无论属性是在实例上还是在原型上。

// 普通构造函数
function Person() {
}
Person.prototype.name = ""
// 对象实例
let person = new Person();
person.age = 22;

![未命名文件 (2).png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/25ca4b6c236b4cc3a0e9d47b9ff23fd5~tplv-k3u1fbpfcp-watermark.image?)
// 原型上的属性
console.log('name' in person)  // true
// 实例上的属性
console.log('age' in person)  // true

(2)Objective.prototype.hasOwnProperty()

hasOwnProperty()  方法会返回一个布尔值,指示对象自身属性中是否具有指定的属性(也就是,是否有指定的键)。只有属性是存在于实例上才会返回true。

// 普通构造函数
function Person() {
}
Person.prototype.name = ""
// 对象实例
let person = new Person();
person.age = 22;

console.log(person.hasOwnProperty('name'))  // false
console.log(person.hasOwnProperty('age'))  // true

(3)判断一个属性是否为原型属性

综上所述:

  • in 操作符返回 true
  • hasOwnProperty() 返回 false 那么该属性就是一个原型属性,例如上述代码中的 name属性。即
'property' in obj  === true
obj.hasOwnProperty('property') === false

但是需要注意的是:若实例存在和原型的同名属性时,并不能通过该方法来判断。

4.2 原型和实例的关系判断

(1)instanceof

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象原型链上。

object instanceof constructor

补充:instanceof 手动实现

function _instanceof(obj, Func) {
    // 获取 obj 对象原型
    let proto = Object.getPrototypeOf(obj);

    // 原型链末尾是 null
    while(proto !== null) {
        // 如果对象原型匹配函数原型属性
        if (proto === Func.prototype) {
            return true;
        }
        // 继续沿着原型链查找
        proto = proto.getPrototypeOf(proto);
    }

    return false;
}

上述手动实现的方法和原生instanceof 有两个区别:

  • 错误处理
// 检验right 是否是一个对象(Object)
if (right is not Object){
    throw new TypeError(" Uncaught TypeError: Right-hand side of 'instanceof' is not Object")
}

// 检验 right 是否可被调用
if (right is not callable) {
    throw new TypeError(" Uncaught TypeError: Right-hand side of 'instanceof' is not callable")
}
  • 对原始值类型的判断
console.log(_instanceof(1, Number))  // true
console.log(1 instanceof Number)  // false

可以尝试在上述实现中添加以下代码:

// 原始值类型都返回false
if (typeof obj != 'function' && typeof obj != 'object') {
    return false;
}

(2)Object.prototype.isPropertyOf()

isPrototypeOf()  方法用于测试一个对象是否存在于另一个对象原型链上。

prototypeObj.isPrototypeOf(object)

instanceof 运算符 和 Object.prototype.isPropertyOf() 的作用差不多,只是**“顺序”**不同。instanceof判断一个对象是否为一个构造函数的实例,prototypeObj.isPrototypeOf(object)判断一个对象是否为另一个对象的原型。

4.3 不同方法创建对象以及生成的原型链情况

(1)语法结构创建(字面量)

  • 对象字面量
let obj = {}

console.log(obj.__proto__ === Object.prototype)  // true
console.log(Object.prototype.__proto__ === null)  // true

obj -> Object.prototype -> null

  • 数组字面量
let arr = []

console.log(arr.__proto__ === Array.prototype)  // true
console.log(Array.prototype.__proto__ === Object.prototype)  // true
console.log(Object.prototype.__proto__ === null)  // true

arr -> Array.prototype -> Object.prototype -> null

  • 函数声明
function foo() {

}

console.log(foo.__proto__ === Function.prototype)  // true
console.log(Function.prototype.__proto__ === Object.prototype)  // true
console.log(Object.prototype.__proto__ === null)  // true

foo -> Function.prototype -> Object.prototype -> null

(2)使用构造函数(constructor)创建

常见的对象定义模式是,在构造器(函数体)中定义属性、在 prototype 属性上定义方法。如此,构造器只包含属性定义,而方法则分装在不同的代码块,代码更具可读性

// 构造器及其属性定义
function Test(a,b,c,d) {
  // 属性定义
};

// 定义第一个方法
Test.prototype.x = function () { ... }

// 定义第二个方法
Test.prototype.y = function () { ... }

// 等等……

若test为Test对象实例,那么原型链为:
test -> Test.prototype -> Object.prototype -> null

因为原型会导致所有实例默认都取得相同的属性值,所以一般在构造器(函数体)中定义属性、在 prototype 属性上定义方法。
例如:Person对象拥有name属性,对于每个实例应该拥有不同的name, 如果将name定义在原型上,所有实例都默认同一个name值,不符合实际。

(3)使用 Object.create() 创建

Object.create() 方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。即新对象的原型就是传入的第一个参数

Object.create(proto,[propertiesObject])
var a = {a: 1};
// a ---> Object.prototype ---> null

var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (继承而来)

var c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null

var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty); // undefined, 因为d没有继承Object.prototype

补充:Object.create()的手动实现

function objectCreate(proto, propertiesObject=undefined){
    // 构造函数
    function F() {

    }
    // 构造函数原型 prototype 链接到proto对象
    F.prototype = proto;

    // 创建对象
    const obj = new F();

    // 若参数 propertiesObject 被指定且不为 undefined
    if (propertiesObject !== undefined) {
        // 新创建的对象添加指定的属性值和对应的属性描述符。
        Object.defineProperties(obj, propertiesObject);
    }

    return obj;
}

(4)使用 class 关键字创建

class 是 ES6引入的新特性,其实就是一个语法糖。其继承思想仍然基于原型,这里抛出一个例子,暂且不细说了。

class Polygon {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
}

class Square extends Polygon {
  constructor(sideLength) {
    super(sideLength, sideLength);
  }
  get area() {
    return this.height * this.width;
  }
  set sideLength(newLength) {
    this.height = newLength;
    this.width = newLength;
  }
}

var square = new Square(2);

4.4 性能相关问题

  • 原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。另外,试图访问不存在的属性时会遍历整个原型链。 所以注意代码中原型链的长度,并在必要时将其分解,以避免可能的性能问题
  • 此外,不要扩展 Object.prototype 或其他内置原型

以上就是个人学习原型和原型链的一些杂七杂八的记录了,大部分也是官方文档的摘抄,有错误也请大佬们指出。其中存在很多冗余的部分,也是个人了解不够透彻,就先这样了。后续学习了更多再更新。

主要参考
[1] 《JavaScript高级程序设计 第四版》
[2] MDN 继承与原型链
[3] MDN 对象原型