likes
comments
collection
share

永不过时的话题:原型与原型链的实战详解(过万字总结)

作者站长头像
站长
· 阅读数 18

前言

最近在跟技术群里的一些同学进行了关于原型与原型链的讨论,于是我进行了归纳总结,查漏补缺,便了有这篇文章。

首先在 JavaScript 中有一切皆对象的说法,就说明 对象 在 JavaScript 中非常重要,而在 JavaScript 中的面向对象的实质是基于原型的对象系统,而不是基于类。当然现在ES6 之后的 JavaScript 已经拥有基于类的编程方式了,但通过 babel 编译之后,本质还是基于原型与原型链来实现的。所以对 JavaScript 中的原型与原型链的理解与掌握是前端进阶的重要组成部分。

本文将先讲解关于原型与原型链的基础知识,然后再延伸讲解它们的实际应用场景,比如在 Vue2 源码中的应用,在 Vue3 源码中应用,及其他一些场景的应用加深对原型与原型链的理解。

关于原型与原型链的典型使用场景

Vue 技术栈的同学,在做 Vue2 项目的时候,相信都有过或者见过以下操作。有时我们在做项目的时候某个方法或某些方法需要重复时候,这个时候我们可以将它挂载到构造函数 Vue 的原型上,然后就可以在组件内部直接 this.方法 进行使用。

首先在 main.js 写入以下代码:

import Vue from 'vue'
import util from './utils/util'
Vue.prototype.$util = util
const app = new Vue(App)
app.$mount('#app')

然后在页面(组件)就可以直接这样 this.$util 操作,你就可以使用你挂载的方法了。

在写 Vue2 的插件的时候,也需要往构造函数 Vue 的原型上挂相关的方法。

function install(_vue) {
    Vue = _vue
    Vue.mixin({
        beforeCreate() {
            if(this.$options.store) {
                // 往构造函数 Vue 的原型上挂相关的方法
                Vue.prototype.$store = this.$options.store
            }
        }
    })
}

为什么把方法挂载 Vue 的原型上,就可以在组件内部使用了呢?其实除了需要了解原型与原型链的有关知识之外,也需要了解一下 Vue 的底层实现原理,我们后续也将进行详细解读。

再看一道经典的关于原型与原型链的面试题。

class Person {
    run() {
        console.log('跑步1')
    }
}

Person.prototype.run = function () {
    console.log('跑步2')                                                         
}

const p = new Person()

p.run() // '跑步2'

最终打印的是后面重写在原型上的方法里面的内容,那么为什么会这样的呢?这是因为类里面的方法其实也是挂在类的原型上的,后面再重写等于是覆盖了,那么为什么类里面的方法要挂在原型上呢?这就需要我们详细地去了解原型与原型链及 JavaScript 中继承的相关知识了,而 JavaScript 中继承也是基于原型与原型链的方式实现的,所以说到底还是把原型与原型链的知识搞明白。

原型与原型链的基础定义理解

关于原型与原型链下面这些基础 JavaScript 知识一定要 “死记硬背” 的。只有 “死记“,才能 “用活”。

  1. 对象是某个特定引用类型的实例,可以理解为对象要通过构造函数实例化实现的,而构造函数本身又是一个对象,构造函数本身又需要通过构造函数实例化实现。
  2. JavaScript 提供了很多原生引用类型:Object、Array、Function、String、Number、Boolean、Date、RegExp,Map、WeakMap、Set、Symbol、BigInt 同时它们也都是原生构造函数
  3. 每个函数都是 Function 类型的实例,因此函数也是对象
  4. 对象都拥有隐式原型(__proto__ 属性)指向它的构造函数的原型对象(prototype 属性)
  5. 每个构造函数都有一个 prototype 属性,叫原型对象(注意:原型对象,本质是对象),也叫显式原型
  6. 原型对象上有一个 constructor 属性指向构造函数本身
  7. 通过 new 实例化出来的对象没有 prototype 属性
  8. 对象都具有 __proto__ 属性
  9. 宇宙的尽头:Object.prototype.__proto__ === null

上面的规则 1、2、3 结合一起理解。

我们知道对象是可以这样实现的 const obj = new Object() 那么 obj 就是引用类型 Object 的实例,也就是说 obj instanceof Object 为 true,而 Object 又是原生构造函数,即 typeof Object === "function",每个函数都是 Function 类型的实例,也就是 Object 也是 Function 的实例,即 Object instanceof Function 为 true

通过原型与原型链进行理解。

const obj = new Object() ,那么根据上面的规则 4 和 5 可以得知,obj.__proto__ === Object.prototype 根据规则 2、3、4 可以知道 Object 本身是引用类型也就是对象,根据规则 8,Object 拥有隐式原型 __proto__ 同时 Object 也是一个函数,而函数都是 Function 的实例,也就是 Object 是 Function 的实例,因为对象的隐式原型(__proto__ 属性)指向它的构造函数的原型对象(prototype 属性) 所以:Object.__proto__ === Function.prototype,根据上面的规则 5,Function.prototype 本质是对象,根据规则 8,Function.prototype 拥有隐式原型 __proto__,而对象是通过原生构造函数 Object 实现的,所以 Function.prototype.__proto__ === Object.prototype,最后根据规则 9,Object.prototype.__proto__ === null

至此,这整一个链路的过程也就是原型与原型链的原理解析过程,本质就是通过属性 __proto__ 进行链接每一个节点对象。

代码完整实现理解:

const obj = new Object()
// 实例对象的隐式原型指向它的构造函数的原型对象
obj.__proto__ === Object.prototype
// Object 本身是原生引用类型也就是对象,而对象都拥有隐式原型,同时 Object 又是原生构造函数,而函数都是 Function 的实例,可以简单理解为 Object 是通过构造函数 Function 实例化实现。
Object.__proto__ === Function.prototype
// 原型对象本质是对象,而对象是通过原生构造函数 Object 实例化实现的
Function.prototype.__proto__ === Object.prototype
// 宇宙的尽头
Object.prototype.__proto__ === null

根据上面的规则 2,除了 Object 还有很多其他原生构造函数,因为都是函数所以可以得出下面的结论:

typeof Array === 'function' // true
typeof Function === 'function' // true
typeof String === 'function' // true
typeof Number === 'function' // true
typeof Boolean === 'function' // true
typeof Date === 'function' // true
typeof RegExp === 'function' // true
typeof Map === 'function' // true
typeof WeakMap === 'function' // true
typeof Set === 'function' // true
typeof Symbol === 'function' // true
typeof BigInt === 'function' // true

通过上面代码我们可以加深理解它们都是函数类型。

根据上面的规则 3,因为每个函数都是 Function 类型的实例,所以可以得出下面的结论:

Object instanceof Function === true // true
Function instanceof Function === true // true
String instanceof Function === true // true
Number instanceof Function === true // true
Boolean instanceof Function === true // true
Date instanceof Function === true // true
RegExp instanceof Function === true // true
Map instanceof Function === true // true
WeakMap instanceof Function === true // true
Set instanceof Function === true // true
Symbol instanceof Function === true // true
BigInt instanceof Function === true // true

通过上面代码我们可以加深理解它们都是 Function 类型的实例。

通过上面 Object 的例子,我们也可以很快写出其他引用类型的原型与原型链的关系代码。比如 String:

const txt = new String('Coboy')
// 实例对象的隐式原型指向它的构造函数的原型对象
txt.__proto__ === String.prototpe
// String 本身是原生的引用类型,也就是 String 可以看成是对象,所以拥有隐式原型,又因为 String 是原生构造函数,而每一个函数都是 Function 的类型实例,所以 String 的隐式原型就指向了构造函数 Function 的原型对象
String.__proto__ === Function.prototype
// 原型对象的本质是对象,所以原型对象也拥有隐式原型,因为对象是通过构造函数 Object 实现的,所以 Function 的原型对象的隐式原型就指向 Object 的原型对象
Function.prototype.__proto__ === Object.prototype
// 宇宙的尽头
Object.prototype.__proto__ === null

上述代码代码还有一个地方需要说明一下的,就是 String.prototpe.__proto__ === Object.prototype ,根据上文,也可以得知,原生构造函数 String 的原型对象本质是对象,而对象是通过原生构造函数 Object 实例化实现的,所以 String.prototpe.__proto__ === Object.prototype

有意思的一个类型 Function

其中最有意思的一个类型 Function,根据上文的规则 3,每个函数都是 Function 的实例,同时原生构造函数 Function 自己也是 Function 的实例,首先通过上文我们已经可以得知:

typeof Function === 'function' // true
Function instanceof Function === true // true

我们再通过原型与原型链去理解。

首先 Function 是原生的引用类型,也就是对象,也就拥有隐式原型,每个函数都是 Function 的实例,所以 Function 的隐式原型就指向了构造函数 Function 的原型对象。

Function.__proto__ === Function.prototype 
// 接下来跟上文一样
Function.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

根据上文的规则 3,每个函数都是 Function 的实例,所以普通函数也是 Function 的实例。

例如使用函数声明语法进行定义:

function fn() {}
// 因为每个函数都是 Function 的实例,函数实例对象 fn 的隐式原型指向它的构造函数的原型对象
fn.__proto__ === Function.prototype // true
// 接下来跟上文一样
Function.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

还可以使用下面这种函数表达式的方式进行定义:

const fn = function() {}
// 因为每个函数都是 Function 的实例,函数实例对象 fn 的隐式原型指向它的构造函数的原型对象
fn.__proto__ === Function.prototype // true
// 接下来跟上文一样
Function.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

更加具体形象地理解 Function

关于所有函数对象都是通过构造函数 Function 实例化实现这个怎么理解

比如你声明一个函数

function Fn() {
    console.log('cobyte')
}
Fn() // cobyte

其实还可以这样实现

const Fn = new Function("console.log('cobyte')")
Fn() // cobyte

这个时候根据上面的规则:对象的隐式原型指向它的构造函数的原型对象,就可以很清楚得知

Fn.__proto__ === Function.prototype

所以你再声明一个普通函数

function Test() {}

这个时候,你就很清楚知道

Test.__proto__ === Function.prototype

其他原生构造函数的原型与原型链关系

String 作为字符串的构造函数对象,String 也是通过的 Function 的实例化而来。

所以:String.__proto__ === Function.prototype

Number 作为数字的构造函数对象,Number 也是通过 Function 的实例化而来。

所以:Number.__proto__ === Function.prototype

Boolean 作为布尔类型的构造函数对象,Boolean 也是通过 Function 的实例化而来。

所以:Boolean.__proto__ === Function.prototype

同样其它的:

Map.__proto__ === Function.prototype

WeakMap.__proto__ === Function.prototype

Set.__proto__ === Function.prototype

拓展延伸,Function 的使用场景

在 Vue2 和 Vue3 中,在编译运行时模式的时候,需要将 template 模版编译成组件的 render 函数,其中就需要使用到了 Function 原生构造函数。

在 Vue2 源码中:

function createFunction (code, errors) {
  try {
    return new Function(code)
  } catch (err) {
    errors.push({ err, code })
    return noop
  }
}

在 Vue3 源码中:

const render = (__GLOBAL__
                ? new Function(code)()
                : new Function('Vue', code)(runtimeDom)) as RenderFunction

可以简单理解为,在 Vue 的编译运行时的时候,先将 template 模版内容编译成一个函数字符串,同时赋值给变量 code,然后通过 Function 原生构造函数将 template 编译的结果实例化成 render 函数。

// 变量 code 为 template 模版内容编译成的函数字符串
const render = new Function(code)

通过上述例子,我们可以更加深刻理解上文所说的规则,每个函数都是 Function 类型的实例

字面量中的原型与原型链

Object 类型

创建 Object 实例的方式有三种。第一种就是上面提到的使用 new 操作符后跟 Object 构造函数。例如:

const obj = new Object()

这种方式创建的对象很容易知道它的原型与原型链的关系。根据上文规则:

obj.__proto__ === Object.prototype
Object.prototype.__proto__ === null

创建 Object 实例还可以通过对象字面量的方式。对象字面量是对对象定义的一种简写形式,目的在于简化创建包含大量属性的对象的过程。

const obj = {}

通过这种对象字面量方式创建的对象,它的原型与原型链的关系实际跟上面使用 new 操作符后跟 Object 构造函数方式实现是一样的。

obj.__proto__ === Object.prototype
Object.prototype.__proto__ === null

第三种就是通过 Object 构造函数,像普通函数那样直接执行。

const obj = Object()
// 通过 typeof 类型检测,很明显通过这种方式创建的变量也属于对象类型
typeof obj === 'object' // true
obj.__proto__ === Object.prototype

通过 typeof 类型检测,很明显通过这种方式创建的变量也属于对象类型。

Array 类型

创建数组的实例方式同样有三种。第一种就是使用 new 操作符后跟 Array 构造函数。例如:

const arr = new Array()

这种方式创建的对象很容易知道它的原型与原型链的关系。根据上文规则:

arr.__proto__ === Array.prototype
Array.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

第二种就是通过数组字面量方式创建。

const arr = []

通过这种数组字面量方式创建的数组对象,它的原型与原型链的关系实际跟上面使用 new 操作符后跟 Array 构造函数方式实现是一样的。

arr.__proto__ === Array.prototype
Array.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

第三种就是通过 Array 构造函数,也是像普通函数那样直接执行。

const arr = Array()
// 通过 Array.isArray 检测,很明显通过这种方式创建的变量也属于数组类型
Array.isArray(arr) // true
arr.__proto__ === Array.prototype
Array.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

通过 Array.isArray 检测,很明显通过这种方式创建的变量也属于数组类型。

基本包装类型中的原型与原型链

在 JavaScript 数据类型分为:基础数据类型和引用数据类型。我们上面讲到的都是引用数组类型的情况,接下来我们讲讲基础数据类型中的原型与原型链关系。

为了方便操作基本类型值, JavaScript 提供了 3 个特殊的原生引用类型:String、Number、Boolean。

我们同样可以像上文创建 Object 和 Array 类型的数据的方法去创建基本数据类型。

使用 new 操作符进行创建:

// String
const s = new String('cobyte')
// 经过上文的学习,我们可以很清楚知道
s.__proto__ === String.prototype // true
String.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true
// Number
const n = new Number(10)
n.__proto__ === Number.prototype // true
Number.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true
// Boolean
const b = new Boolean(true)
b.__proto__ === Boolean.prototype // true
Boolean.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true

同样也可以使用构造函数,像普通函数那样直接执行:

// String
const s = String('cobyte')
// 经过上文的学习,我们可以很清楚知道
s.__proto__ === String.prototype // true
String.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true
// Number
const n = Number(10)
n.__proto__ === Number.prototype // true
Number.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true
// Boolean
const b = Boolean(true)
b.__proto__ === Boolean.prototype // true
Boolean.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true

同样可以通过字面量的方式创建:

// String
const s = 'cobyte'
// 经过上文的学习,我们可以很清楚知道
s.__proto__ === String.prototype // true
String.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true
// Number
const n = 10
n.__proto__ === Number.prototype // true
Number.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true
// Boolean
const b = true
b.__proto__ === Boolean.prototype // true
Boolean.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true

值得一说的是布尔表达式中所有对象都会被转换为 true,因此我们建议不要使用 Boolean 对象。

引用类型与基本包装类型的主要区别就是对象的生存期。使用 new 操作符创建的引用类型的实例对象,在执行流离开当前作用域之前都一直保存在内存中。而自动创建的基本包装类型的对象,就只存在代码执行的一瞬间,然后立即被销毁。这意味着我们不能在运行时为基本类型值添加属性和方法。而为基本类型在代码执行的时候创建对应的基本包装类型,只是为了方便数据的操作。

关于在 JavaScript 中一切皆对象的理解

相信有很多同学都听过 “在JavaScript 中一切皆对象” 的这种说法。那么怎么去理解这句话呢?是否真的在在JavaScript 中一切皆对象呢?

通过上文我们知道在 JavaScript 中有两种数据类型:基础数据类型和引用数据类型。而引用类型就是我们俗称的对象。

基础数据类型,它们并不是对象,但为了方便数据的操作,JavaScript 对一些基础的数据类型进行类型包装,使得它们拥有对象才有的一些方法,比如 toSting, valueOf,基本的包装类型有上面我们说到的 String、Number、Boolean 还有最新的 BigInt、Symbol。它虽然拥护一些对象才拥有的方法,但它们的 typeof 判断出来的并不是 object 类型。

const s = 'Cobyte'
typeof s === 'string' // true
s.__proto__ === String.prototype // true
s instanceof String // false

从上面代码我们可以看到字符串变量的隐式原型是等于构造函数的 String 的原型对象的,根据上文的规则 4:对象都拥有隐式原型(__proto__ 属性)指向它的构造函数的原型对象(prototype 属性),那么字符串变量 str 应该是构造函数 String 的实例才对,但通过 instanceof 运算符运算结果,并不是 true,这是为什么呢?

其实这里的变量 str,它是没有 __proto__ 属性的,但又可以读取到,是因为 JavaScript 对基础类型的进行了包装,叫基础包装类型。在代码执行的瞬间创建了一个临时对象 const str = new String('cobyte'),让这个字符串变量临时可以访问一些对象属性,但它本质并不是一个对象。同时具有此特性的类型还有 Number 和 Boolean。

其他的同理。

但如果我们使用 new 操作符进行创建的话:

const s = new String('Cobyte')
typeof s === 'object' // true
s.__proto__ === String.prototype // true
s instanceof String // true

所以当 String、Number、Boolean 通过 new 操作符创建的话,它们就是一个对象了。

但最新的 BigInt、Symbol 类型则不能通过 new 操作符进行创建变量。

还有一些类型是既不是 object 类型,也不拥有一些对象才有的方法的类型,比如 undefined 类型,我们用typeof 来看一下 undefined 的类型,输出为 undefined ,并且 undefined 也没有包装类型,所以 undefined 不是 object 类型。

还有一个特殊的类型:null, 虽然它的 typeof 的值是 object,但它不拥护上文说到那些方法:toString、valueOf,null 可以看作是一个空对象。

所以关于 “在JavaScript 中一切皆对象” 这个说法,虽不严谨,但也有助于我们理解 JavaScript 这门语言和理解原型与原型链。所有的对象都会按照原型链找到 Object.prototype,因此所有的对象都会继承来自 Object.prototype 的属性和方法。

原型与原型链的作用

原型

prototype 一般称为显式原型,__proto__一般称为隐式原型。每一个函数在创建之后,在默认情况下,会拥有一个名为 prototype 的属性,这个属性表示函数的原型对象。除此之外,每个 JavaScript 对象都有一个隐藏的原型属性——__proto__

function Fn() {}
const obj = new Fn()

通过上文我们可以知道对象 obj 的隐式原型指向构造函数 Fn 的原型对象。

即:obj.__proto__ === Fn.prototype

另外在 ECMAScript 5 中推荐构造函数的首字母为大写,这个是借鉴其他 OOP 语言的特性,主要是了区别其他普通函数。

原型链

当我们访问一个对象的属性时,JS 会先在这个对象定义的属性中进行查找,如果没有找到,就会沿着 __proto__ 这个隐式原型关联起来的链条向上一个对象查找,这个链条就是原型链

function Fn() {}
Fn.prototype.info = 'cobyte'
const obj = new Fn()
obj.age = 18
console.log(obj.age) // 18
console.log(obj.info) // 'cobyte'

obj 是 Fn 构造函数通过 new 操作符实例化出来的实例对象,obj.age 是定义在 obj 这个对象上的属性,而在获取 obj.info 的这个属性的时候,在 obj 这个对象上是没有定义的,所以它会沿着实例对象 obj 的 __proto__ 这个隐式原型指向的构造函数的原型对象上去查找,发现在 Fn.prototype 原型对象找到了属性 info,于是就返回这个属性的值,如果没有找到则继续沿着这个Fn.prototype 原型对象的 __proto__ 这个隐式原型指向的原型对象上继续查找,直到找到 Object.prototype.__proto__ 为止,因为 Object.prototype.__proto__ 已经为空了。

我们在上文的规则 6 中有说到:原型对象上有一个 constructor 属性指向构造函数本身。所以上述代码还有以下关系:

Fn.prototype.constructor === Fn // true

到这里我们可以简单总结一下构造函数、原型、和实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针(constructor 属性),而实例都包含一个指向原型对象的内部指针( __proto__ 属性)。

上述这种获取上一层对象属性和方法的过程称之为继承。

继承

JavaScript 中的继承是基于原型链的方式实现的;其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。简单来说就是让对象 A 拥有对象 B 的属性和方法。

通过重写隐式原型属性实现
const obj1 = {
    info: 'cobyte',
    run: function() {
        console.log('run')
    }
}
const obj2 = {
    age: 18
}
obj2.__proto__ = obj1
obj2.info // 'cobyte'
obj2.run() // 'run'
通过 Object.create 方法实现
const obj1 = {
    info: 'cobyte',
    run: function() {
        console.log('run')
    }
}
const obj2 = Object.create(obj1, { age: { value: 18 } })
obj2.info // 'cobyte'
obj2.run() // 'run'

Object.create() 方法创建一个拥有指定原型和若干个指定属性的对象。

语法 Object.create(proto, [ propertiesObject ])

其实通过 Object.create 方法实现继承的本质是还通过重写隐式原型属性实现的。

在这里我们还需要讲一个知识点。

区别属性和方法来源

我们可以通过对象上的 hasOwnProperty 方法来检测对象上的属性是否是自身定义的还是通过原型链继承而来的。

obj2.hasOwnProperty('info') // false
obj2.hasOwnProperty('age') // true
通过构造函数方式实现

在构造函数 Fn1 上定义了一些属性和方法,希望构造函数 Fn2 也能继承 Fn1 上的属性和方法。

function Fn1() {
    this.age = 18
}
Fn1.prototype.getAge = function() {
    return this.age
}

function Fn2() {}
// 让构造函数 Fn2 的原型对象等于构造函数 Fn1 的实例对象
Fn2.prototype = new Fn1()

const fn2 = new Fn2()
fn2.age // 18
fn2.getAge() // 18

以上代码定义了两个构造函数 Fn1 和 Fn2。构造函数 Fn1 在实例上定义了一个属性 age,在原型对象上定义了一个方法 getAge,然后让构造函数 Fn2 的原型对象等于构造函数 Fn1 的实例对象。这继承的本质就是重写原型对象,代之以一个新类型的实例。这样原来存在于 Fn1 实例中的所有属性和方法,现在也存在于 Fn2.prototype 中了。

new 操作符背后做了什么

  1. 创建一个新的空对象
  2. 让新的空对象的隐式原型(__proto__)指向构造函数的原型对象(prototype)
  3. 让新的空对象的作用域指向构造函数的作用域,也就是改变构造函数的 this 指向新的空对象
  4. 执行构造函数并返回结果,判断返回的结果如果是对象则返回,否则返回新创建的对象

从原型与原型链上来进行理解就是这样的:实例对象 fn2 的隐式原型 __proto__ 指向构造函数 Fn2 的 prototype 属性值,而 Fn2.prototype 的值并不是原来的默认的原型,而是给它换成了一个新的原型;这个新的原型就是构造函数 Fn1 的实例对象,这样新原型对象不仅拥有 Fn1 实例对象的全部属性和方法,而且 Fn1 实例的隐式原型指向 Fn1 的显式原型。最终 Fn1 的 prototype 属性值的隐式原型指向 Object.prototype,这样最终实现继承了 Object 上的属性和方法。通过上述解析一条完整的原型链结构就能理出来了。

用代码进行理解:

fn2.__proto__ === Fn2.prototype // true
Fn2.prototype.__proto__ === Fn1.prototype // true
Fn1.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true
instanceof 方法的理解

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

下面我们通过手写实现 instanceof 运算符来对 instanceof 运算符进行理解。

// 手写实现 instanceof 运算符
function myInstanceof (L, R) {
    // 基本数据类型以及 null 直接返回 false
    if (!['function', 'object'].includes(typeof L) || L === null) return false
    // 拿到参数的原型对象
    let proto = L.__proto__
    while (true) {
        // 查找到尽头,还没找到就返回 false
        if (proto === null) return false
        // 找到相同的原型对象就返回 true
        if (proto === R.prototype) return true
        proto = proto.__proto__
    }
}

那么我们通过手写的 instanceof 方法检验我们上面写的 Fn1 和 Fn2 的实例。

// 检测 Fn2.prototype 是否在实例对象 fn2 的原型链上
myInstanceof(fn2,Fn2) // true
// 检测 Fn1.prototype 是否在实例对象 fn2 的原型链上
myInstanceof(fn2,Fn1) // true
// 检测 Object.prototype 是否在实例对象 fn2 的原型链上
myInstanceof(fn2,Object) // true

那么我们再使用原生的 instanceof 方法进行校验。

// 检测 Fn2.prototype 是否在实例对象 fn2 的原型链上
fn2 instanceof Fn2 // true
// 检测 Fn1.prototype 是否在实例对象 fn2 的原型链上
fn2 instanceof Fn1 // true
// 检测 Object.prototype 是否在实例对象 fn2 的原型链上
fn2 instanceof Object // true

最终我们可以看到我们手写的 instanceof 方法的校验结果和原生的 instanceof 方法校验结果是一致的,也证明了我们的手写实现也正确的,我们的理解也是正确的。

最后我们再通过图文来进一步进行理解。

我们通过图文形式来看一下对象 fn2 的原型链:

永不过时的话题:原型与原型链的实战详解(过万字总结)

通过上面的图文,我们可以很清楚看到 Fn2.prototype、Fn1.prototype、Object.prototype 都在实例对象 fn2 的原型链上。

所以通过上述例子我们可以更清楚知道,在实例对象 fn2 上读取一个属性值的时候,它会现在 fn2 自身上查找,如果没找到,那么就会沿着原型链往上一层进行查找,相继就是 Fn2.prototype 上查找,没找到,就继续往 Fn1.prototype 上查找,最后到 Object.prototype 上查找,然后停止查找,因为已经到末端了。这个也是原型链的最直接查找。

构造函数进阶应用

我们在上文中可以看到我们有些属性是在构造函数内部进行创建的,有些是在构造函数的原型对象上创建的,那么这两者有什么区别呢?

function Fn1() {
    // 在构造函数内部创建
	this.info = 'cobyte'
}
// 在构造函数的原型对象上创建
Fn1.prototype.age = 18

在构造函数内部创建属性,我们主要是可以通过传递不同的参数创建不同的对象属性。

function Fn1(info) {
    // 在构造函数内部创建
	this.info = info
}
const fn1 = new Fn1('cobyte')
const fn2 = new Fn1('coboy')
fn1.info // 'cobyte'
fn2.info // 'coboy'
// 修改属性值
fn1.info = 'cobyte1'

fn1.info // 'cobyte1'
// 实例对象 fn2 上的属性并不会发生改变
fn2.info // 'coboy'

而如果是在构造函数的原型对象上创建属性,则会有什么效果呢。

function Fn1() {
    // 在构造函数内部创建
	this.info = 'cobyte'
}
// 在构造函数的原型对象上创建
Fn1.prototype.info = 'coboy'

const fn1 = new Fn1()
fn1.info // 'cobyte'

我们可以看到同时在构造函数内部和构造函数原型对象上创建相同的属性时,访问属性值时返回的是构造函数内部的属性值。这是因为定义在构造函数里的属性优先级较高,所有的实例对象可以快速访问使用构造函数中的属性。

从现实经验来看每个实例对象的属性值大多都是不一样的,所以一般创建属性值都放在构造函数内部,而构造函数原型对象上的属性则一般作为公共属性使用,一般是不变的。

而方法大多数情况下是固定的,因此不必每次实例化的时候都把所有的方法实例化重新创建一遍。

function Fn1() {
    // 在构造函数内部创建
	this.info = 'cobyte'
    this.getInfo = function() {
        return this.info
    }
}
const fn1 = new Fn1()
const fn2 = new Fn1()
fn1.info === fn2.info // true
fn1.getInfo === fn2.getInfo // false

我们可以看到两个实例对象的 getInfo 方法是不相等的,这是因为它们每次创建的时候都进行了实例创建的过程,我们上文说过函数本质是通过 Function 实例化而来的;实际上 fn1 和 fn2 中的方法 getInfo 是两个不同的 Function 实例对象。从逻辑角度讲,上述代码中的构造函数也可以这样写。

function Fn1() {
    // 在构造函数内部创建
	this.info = 'cobyte'
    this.getInfo = new Function('return this.info')
}

所以如果在构造函数内部创建方法,就会导致每个方法都要在每个实例上重新创建一遍,因此不同实例上的同名函数是不相等的,同时这样也会造成性能浪费。所以为了共享公共方法,一般把方法定义在原型对象上。

function Fn1() {
    // 在构造函数内部创建
	this.info = 'cobyte'
}
Fn1.prototype.getInfo = function() {
    return this.info
}

其实这里也已经解析了文章开头关于 ES6 的 class 类的原型对象上进行重新方法名会被覆盖的问题了。

ES6 中类方法

在 ES6 时代,我们可以使用 class 进行类的书写,但是,我们都知道 ES6 中的 class 其实就是 ES5 中的语法糖。

class Person {
    run() {
        console.log('跑步1')
    }
}
Person.prototype.run = function () {
    console.log('跑步2')                                                         
}

const p = new Person()

p.run() // '跑步2'

为了节约性能开销和共享公共方法,所以方法定义在了原型对象上,后续如果继续在原型对象上定义同样的方法名称则会覆盖原来的方法,原因也在于此。

我们可以看一下上述 Person 类被 babel 编译之后的代码。

// 检测 class 的调用方式,确保是通过 new 操作符进行调用
function _classCallCheck(instance, Constructor) { 
    if (!(instance instanceof Constructor)) { 
        throw new TypeError("Cannot call a class as a function"); 
    }
}
// 定义方法属性
function _defineProperties(target, props) { 
    for (var i = 0; i < props.length; i++) { 
        var descriptor = props[i];
        descriptor.enumerable = descriptor.enumerable || false; 
        descriptor.configurable = true; 
        if ("value" in descriptor) descriptor.writable = true; 
        Object.defineProperty(target, descriptor.key, descriptor); 
    } 
}
// 创建 class 对象类的工厂函数
function _createClass(Constructor, protoProps, staticProps) {
    // 从这里我们可以看出 ES6 的类方法是定义在 prototype 原型对象上的
    if (protoProps) _defineProperties(Constructor.prototype, protoProps); 
    if (staticProps) _defineProperties(Constructor, staticProps); 				      Object.defineProperty(Constructor, "prototype", { writable: false }); 
    return Constructor;
}
// 定义 Person 类型
var Person = /*#__PURE__*/function () {
  function Person() {
    // 检测 Person 作为一个类构造函数的调用方式,确保是通过 new 操作符进行调用
    _classCallCheck(this, Person);
  }

  _createClass(Person, [{
    key: "run",
    value: function run() {
      console.log('跑步1');
    }
  }]);

  return Person;
}();

通过观察通过 babel 编译之后的代码我们可以看到 ES6 的 class 的方法是定义在原型对象上的,另外 ES6 中的 class,虽然 typeof 检测的类型是 function,但并不能像普通函数那样进行执行,是因为限制了它的调用方式,必须是通过 new 操作符来调用。

原型与原型链在 Vue2 源码中的应用

Vue2 的构造函数

看过 Vue2 源码的同学应该都知道,Vue2 是一个典型的 ES5 那种通过构造函数去创建一个应用实例对象的写法。

function Vue(options) {
    // 限制构造函数 Vue 只能通过 new 操作符调用
    if(!this instanceof Vue){
        console.warn('Vue is a constructor and should be called with the `new` keyword')
    }
    this._init(options)
}
initMixin(Vue) 
stateMixin(Vue)
function initMixin(Vue) {
    // 往构造函数的原型对象上定义方法
    Vue.prototype._init = function(options) {
        const vm = this
        // ...
    }
}
function stateMixin (Vue){
  const dataDef = {}
  dataDef.get = function () { return this._data }
  const propsDef = {}
  propsDef.get = function () { return this._props }
  // 往构造函数的原型对象上定义属性
  Object.defineProperty(Vue.prototype, '$data', dataDef)
  // 往构造函数的原型对象上定义属性
  Object.defineProperty(Vue.prototype, '$props', propsDef)
  // 往构造函数的原型对象上定义方法
  Vue.prototype.$set = set
  // 往构造函数的原型对象上定义方法
  Vue.prototype.$delete = del
  // 往构造函数的原型对象上定义方法
  Vue.prototype.$watch = function (){
      // ...
  }
}

我们可以看到 Vue2 的构造函数是非常简单的,这个构造函数只是为了生成一个 Vue 的应用实例对象。在刚开始代码加载的时候,通过工具函数往构造函数 Vue 的原型对象上添加了很多方法和属性。那么通过上文我们知道在进行 new 操作符创建 Vue 应用实例对象的时候会经历以下步骤:

  1. 首先创建一个空对象,这个对象将会作为执行构造函数之后返回的对象实例。
  2. 使上面创建的空对象的原型(__proto__)指向构造函数的 prototype 属性。
  3. 将这个空对象赋值给构造函数内部的 this,并执行构造函数的逻辑代码。
  4. 根据构造函数执行逻辑,返回第一步创建的对象。

这样通过 new 操作符创建出来的实例对象中就可以访问使用这些原型对象上的方法了。比如:this.$data 原来是原型上的属性,实际获取到的是实例对象上的 this._datathis.$props 原来是原型上的属性实际获取到的是实例对象上的 this._props;还有其他的 this.$setthis.$deletethis.$watch 等都是原型对象上的属性和方法。

接下来我们去探讨本文开篇说到的那个问题。有时我们在做项目的时候某个方法或某些方法需要重复时候,这个时候我们可以将它挂载到构造函数 Vue 的原型上,然后就可以在组件内部直接 this. 方法进行使用。

首先在 main.js 写入以下代码:

import Vue from 'vue'
import util from './utils/util'
Vue.prototype.$util = util
const app = new Vue(App)
app.$mount('#app')

然后在页面(组件)就可以直接这样 this.$util 操作,你就可以使用你挂载的方法了。这其中是为什么呢?

为什么 Vue2 中的 this 能够直接获取到 data 和 methods ?

我们先把我们要实现的效果代码先展示出来。

Vue.prototype.$request = function(params) {
   return `进行了 http 请求,参数:${params}`
}
const app = new Vue({
    data() {
        return {
            params: 'request',
            data: null
        }
    },
    methods: {
        fetchData() {
            // 通过 this 可以获取到设置在 Vue 原型对象上的方法和 data 里面的属性
            this.data = this.$request(this.params)
        },
        getData() {
            console.log(this.data)
        }
    }
})
// 然后可以在创建的实例对象 app 上进行以下操作
console.log(app.params) // 'request'
// 进行模拟数据请求
app.fetchData() 
app.getData() // 进行了 http 请求,参数:request

上述代码基本还原了我们平时工作的使用场景,通过 this 可以获取到设置在 Vue 原型对象上的方法和 data 里面的属性。

我们直接贴上实现的代码进行分析。

function Vue(options) {
    // 限制构造函数 Vue 只能通过 new 操作符调用
    if(!this instanceof Vue){
        console.warn('Vue is a constructor and should be called with the `new` keyword')
    }
    this._init(options)
}
initMixin(Vue) 
function initMixin(Vue) {
    // 往构造函数的原型对象上定义方法
    Vue.prototype._init = function(options) {
        const vm = this
        vm.$options = options;
  	    const opts = vm.$options;
        if(opts.data){
            // 初始化 data
        	initData(vm);
        }
        if(opts.methods){
            // 初始化方法
        	initMethods(vm, opts.methods)
        }
    }
}

function initData(vm) {
  // 因为 data 是函数,所以执行获取返回结果,并赋值给实例上的 _data 属性
  const data = vm._data = vm.$options.data();
  const keys = Object.keys(data);
  var i = keys.length;
  while (i--) {
    var key = keys[i];
    // 将 data 中的属性都在实例对象上进行劫持
    proxy(vm, '_data', key);
  }
}

function proxy(target, sourceKey, key) {
    // 将 data 中的属性都在实例对象上进行劫持
    Object.defineProperty(target, key,{
        get() {
            // 如果劫持到,则返回 this._data[key] 上的值,而 this._data 是配置项里的 data 方法的执行返回结果
            return this[sourceKey][key]
        },
        set(val){
            this[sourceKey][key] = val
        }
    })
}

function initMethods(vm, methods){
  for (const key in methods) {
    // 先将 methods 上的方法中的 this 全部绑定为 Vue 中的实例对象,然后再设置到实例对象上,这样 methods 中的方法中的 this 就是 Vue 的实例对象,就可以获取 Vue 实例对象上的方法了。
    vm[key] = methods[key].bind(vm);
  } 
}

通过上面的代码,我们可以知道 Vue2 中的组件里面的 data 返回的值先被赋值给 Vue 实例对象的一个 _data 的属性上,这样做主要是为了隐藏 data 的数据,不要让用户可以随便修改到原始数据;然后再把 data 返回的数据的所有 key 都在 Vue 的实例对象上进行代理劫持(Object.defineProperty),当想要访问 data 中的某个属性时,就可以通过实例对象进行访问,而访问实例对象时,如果访问的属性正好是 data 中的属性,则通过Object.defineProperty 中的 getter 把对应的 _data[key] 中的属性值返回去。

而 Vue2 中的组件中 methods 里的方法;则先将 methods 上的方法中的 this 全部绑定为 Vue 中的实例对象,然后再设置到实例对象上,这样methods 中的方法中的 this 就是 Vue 的实例对象,就可以获取 Vue 实例对象上的方法了。

而通过上文我们知道当访问一个对象的属性,会先在自身上查找,如果没找到则沿着对象上的原型链进行查找,所以我们设置在构造函数 Vue 的原型对象上的方法自然可以被通过 new 操作符实例化出来的对象获取到了。

Vue2 数组响应式实现中运用到的原型与原型链

我们上面的一些自定义构造函数的方法都是通过原型对象来进行定义的,而原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建。

我们知道每个对象上都拥有一些共同的方法比如 toString()、valueOf() 等,toString()、valueOf() 是由 Object 上继承而来的。而数组对象则拥有一些专属的方法,比如:push()、pop()、shift() 等,而在字符串类型中则拥有 substring() 方法。所有原生引用类型(Object、Array、String 等等)都在其构造函数的原型上定义了方法。

通过原生对象原型,不仅可以取得所有默认方法的引用,而且也可以定义新的方法。也可以像修改自定义对象的原型一样修改原生对象的原型。而在 Vue2 中数组响应式原理的实现中则充分利用了这一个原型特性。

Vue2 中是通过对数组原型上的 7 个方法进行重写进行响应式监听的。原理就是使用拦截器覆盖 Array.prototype,之后再去使用 Array 原型上的方法的时候,其实使用的是拦截器提供的方法,在拦截器里面才真正使用原生 Array 原型上的方法去操作数组。

拦截器

// 拦截器其实就是一个和 Array.prototype 一样的对象。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(function (method) {
    // 缓存原始方法
    const original = arrayProto[method]
    Object.defineProperty(arrayMethods, method, {
        value: function mutator(...args) {
            // 最终还是使用原生的 Array 原型方法去操作数组
            return original.apply(this, args)
        },
        eumerable: false,
        writable: false,
        configurable: true
    })
})

所以通过拦截器之后,我们就可以追踪到数组的变化了,然后就可以在拦截器里面进行依赖收集和触发依赖了。接下来我们就使用拦截器覆盖那些进行了响应式处理的 Array 原型,数组也是一个对象。

Vue2 是在 Observer 类里面对对象的进行响应式处理。

class Observer {
    constructor(value) {
        this.value = value
        // 如果是数组则通过覆盖数组的原型方法进来拦截操作
        if(Array.isArray(value)) {
          value.__proto__ = arrayMethods 
        }
    }
    // ...
}

在这个地方 Vue2 会进行一些兼容性的处理,如果能使用 __proto__ 就覆盖原型,如果不能使用,则直接把那 7 个操作数组的方法直接挂载到需要被进行响应式处理的数组上,因为当访问一个对象的方法时,只有这个对象自身不存在这个方法,才会去它的原型上查找这个方法。

原型与原型链在 Vue3 源码中的应用

工厂模式创建对象

虽然 Object 构造函数或对象字面量都可以都可以用来创建单个对象,但这些方式有个明显的缺点,就是会生成大量的冗余代码。而工程师们创造了一种工厂模式来解决这个问题,工程师使用函数来封装以特定接口创建对象的细节。

比如 Vue3 源码中创建 Vue3 应用对象实例的方法就是一个工厂函数。

let uid = 0
function createApp(rootComponent, rootProps = null) {
    const app = {
        _uid: uid++,
        _component: rootComponent
        _container: null,
        _context: context,
        _instance: null,
        get config() {}
        set config() {}
	    use() {}
        mixin() {}
        mount() {}
        // ...
    }
	return app
}

还有创建组件实例对象的方法也是一个工厂函数。

let uid = 0
function createComponentInstance(vnode) {
    const type = vnode.type
    const instance = {
        uid: uid++,
        vnode,
        type,
        root: null,
        next: null,
        render: null,
        // ...
    }
    return instance
}

工厂函数就是能够根据接受的参数来构建一个包含所有必要信息的对象,可以无数次地调用这个函数,创建无数个对象。这种模式创建的对象,却存在一个缺点,就是没办法识别对象的类型。比如说上面创建组件实例对象:

const instance = createComponentInstance({})
// 无法通过 instanceof 来检查对象 instance 是什么类型

无法通过 instanceof 来检查对象 instance 是什么类型。所以在源码中使用了 TypeScript 重新定义了一个类型。

interface ComponentInternalInstance {
    uid: number
    // ...
}

同样上面的创建 Vue3 应用实例对象也使用 interface 进行定义了一个类型。

面向对象与原型

上一个小节我们介绍了在 Vue3 源码中使用工厂函数模式创建一个对象,而工厂函数内部本质是通过对象字面量的方式创建对象,而在前面的基础部分我们也介绍了可以通过构造函数来创建对象。随着 ES Next 标准的进化和新特性的添加,JavaScript 面向对象更加贴近其他的传统面向对象型语言,在 ES6 中我们可以直接通过 class 关键字来定义一个类,然后通过 new 操作符进行实例化创建一个对象。

在 Vue3 实现响应式源码部分 effect 底层的响应依赖对象就是通过一个 class 创建的类,然后通过 new 操作符实例化创建响应依赖对象的。

class ReactiveEffect {
    constructor(public fn){
        // ...
    }
    run() {
        
    }
    stop() {
        
    }
}
// 通过 new 操作符创建实例对象
const _effect = new ReactiveEffect(fn)

我们通过前文可以知道通过构造函数创建的实例对象有一个隐式原型指向构造函数的的原型对象。在 ES6 中的 class 其实就是 ES5 中的语法糖。所以在上述代码中依然存在以下关系:

// 实例对象的 __proto__ 指向构造函数的 prototype
_effect.__proto__ === ReactiveEffect.prototype // true
// 这个很好理解,_effect 是 ReactiveEffect 的实例对象
_effect instanceof ReactiveEffect // true

另外在 TypeScript 中 class 可以直接作为一个类型,而不用像上面的工厂函数创建对象那样要重新定义一个类型。

原型式继承

Vue3 的 Provide / Inject 的实现原理其实就是巧妙利用了原型和原型链来实现的,具体就是利用了原型式继承。接下来我们详细了解一下什么是原型式继承。

其实我们前面的篇幅已经介绍过了,就是通过 Object.create() 重写隐式原型属性实现继承。

// 父组件
const obj1 = {
    name: '父组件',
    ownName: '父组件专属',
    infos: [1,2],
    data: { txt: 'cobyte' }
}
// 子组件
const obj2 = Object.create(obj1)
obj2.name = '子组件'
obj2.ownName2 = '子组件专属'
// 孙组件
const obj3 = Object.create(obj2)
obj3.name = '孙组件'
obj2.ownName3 = '孙组件专属'

上面的代码模拟了从父组件提供了一组数据,然后后代组件进行继承,并且各个后代组件也可以写自己专属的属性。

// 优先读取自身的属性再读取原型上的属性
console.log(obj2.name) // 子组件
// 在子组件上可以读取父组件的属性
console.log(obj2.ownName) // 父组件专属
// 在子组件上读取自身的专有属性
console.log(obj2.ownName2) // 子组件专属

// 在孙组件上也是优先读取自身的属性
console.log(obj3.name) // 孙组件
// 在孙组件上读取父级组件的属性
console.log(obj3.ownName2) // 子组件专属
// 在孙组件上读取爷爷级组件的属性
console.log(obj3.ownName) // 父组件专属

这种继承方式在继承包含引用类型值的属性的时候会共享相应的引用类型的值,也就是说修改其中任何一个对象的引用类型的值,其他对象对应属性的引用类型的值都会改变。

// 孙组件上修改
obj3.data.txt = 'coboy'
// 在子组件上读取到的结果
console.log(obj3.data.txt) // coboy
// 在父组件上读取到的结果
console.log(obj1.data.txt) // coboy

// 在子组件上修改
obj2.infos.push(3)
// 在父组件上读取到的结果
console.log(obj1.infos) // [1, 2, 3]
// 在孙组件上读取到的结果
console.log(obj3.infos) // [1, 2, 3]

使用原型与原型链的方式实现经典的链式调用

Promise 的使用特点之一就是链式调用,这跟经典库 Jquery 是非常相似。

Jquery 的使用方式:

$('p').addClass('className')

这其中也是原型和原型链在Jquery源码中发挥了重要的作用。

Jquery 的模拟源码实现:

var Jquery = (function() {
    var $
    $ = function(selector,context) {
        var dom = []
        // 跟我们上文的例子同样通过重写原型
        dom.__proto__ = $.fn
        return dom
    }
    $.fn = {
        addClass: function() {
            // ...
        }
    }
})()
window.Jquery = Jquery
window.$ === undefind && (window.$ = Jquery)

通过上面的代码我们可以很清楚知道 Jquery 可以实现链式调用是因为在它返回的对象中进行了重写原型,使得返回的结果对象可以在其原型链上找到对应的方法。

Promise 的使用特点也是链式调用,我们也可以通过原型与原型链的方式实现这一功能。

// 构造函数
function Promise() {}
// 在构造函数的原型对象上添加 then 方法
Promise.prototype.then = function(fn) {
    return fn && typeof fn === 'function' && fn()
}

使用

new Promise().then(function() {
    console.log('then1')
    return new Promise()
}).then(function() {
    console.log('then2')
})
// then1
// then2

最终我们通过原型与原型链的方法实现了 Promise 的链式调用。同时通过前文我们可以知道上述代码中的两个 new Promise() 实例化的对象是两个不同的对象,但它们可以共享原型对象上的 then 方法。

总结

原型与原型链及对象与面向对象它们基本都是相连在一起的,说到原型,就很可能涉及到原型链,说到对象也很可能涉及到面向对象,然后就涉及到了继承,然后又回到原型与原型链的上面来。这相关的知识点是一个永远说不完的话题,更是一个永远不会过时的话题,作为一个前端开发者,我们想要进阶更高的曾经,则必须要掌握相关的知识点。