JS中的原型晦涩难懂?看这篇文章就够了

409 阅读18分钟

JS中的原型是个什么概念?它是用来做什么的?它存在的意义是什么?今天,就让你的疑惑一次性全部解答。我们来好好的聊聊JS中的原型

1. 构造函数

在聊原型之前,我们得先来聊聊构造函数

在JS中,有一种特殊的函数,构造函数。它其实与普通函数没有什么区别,当我们自己写了一个函数用new去调用它的时候,它就变成了构造函数。

JS中的内置的构造函数有这几种:

1. String()
2. Number()
3. Boolean()
4. Object()
5. Array()
6. Function()
7. date()

它是怎么使用的呢?

let str = new String();

我们用new去调用这个函数,而不用我们自己去创造,JS自带的一种函数。

它与我们直接用引号创建一个字符串没有任何区别。

let s = '';

我们一般推荐第二种使用方法,简洁。

其实,当你写下let s = ''这段代码时,v8引擎就会转换成let str = new String()这样去执行。所以我们说两种方式没有任何区别。

我们把第一种使用方法拿到v8引擎中看看会有什么输出结果。

image.png

我们发现输出结果是一个大括号包裹着的,说明是一个对象。

这是因为用new调用构造函数的得到的是一个实例对象。那这玩意究竟是字符串还是对象呢?我们统一叫字符串对象。其它几个构造函数同理。new一个数组的构造函数一定会得到一个数组对象,new一个数字的构造函数一定会得到一个数字对象等等。

我们用let s = ''得到的叫字符串字面量。其它同理。

那既然我们说两种创建方式没有任何区别,那为什么构造函数要存在呢?

我们回到上面在v8引擎时的输出结果,我们发现在字符串对象前面有一个可以展开的三角形。里面就是官方打造构造函数的一个重大意义。我们点开看看。

image.png

我们发现这个对象有一个属性,length。我们转念一想,字符串确实有这么一个属性。那么这个属性哪来的呢?我们明明只new了一个函数得到了一个对象,但我们并没有往对象里写这个属性啊。这个问题我们先按下不表,在length的下面不还有东西吗。

这个[[Prototype]]是个什么东西呢?你不要看它这么奇怪,它其实就是这个对象的一个属性,和length一样。我们展开看看。

image.png

你会发现,这里面有一堆东西。这其实就是字符串能使用的全部方法。它们全部存放在这个[[Prototype]]里。你只要创建了一个字符串,你就能使用里面的方法。

我们创建一个函数能更好的理解。

image.png

这个函数有prototype这么一个属性。我们说过用字面量创建就相当于用new构造函数创建,v8引擎就是这样执行的。所以我们创建的这个函数也有prototype这么一个属性。我们展开看看。

image.png

我们发现展开一看又有[[Prototype]]这么一个东西。里面就是函数能使用的一些方法。其实prototype这个单词翻译过来就叫原型

2. 原型

2.1 原型的概念

见识完这些,我们通过一个例子来理解一下这个原型

function Person() {
    this.name = 'Tom';
    this.age = 18;
}
let p = new Person()

我们创建了一个函数Person(),然后用new去调用它,就相当于把它当作构造函数来使用了。

而这个函数Person()一定有这么一个属性prototype,就叫原型

我们今天,就是要搞明白函数的这个属性是个什么东西。

首先,我们发现这个属性值是一个对象类型,它输出的是一对大括号。

image.png

所以,原型也是一个对象。

那么对于原型,它其实就是一个函数被定义出来天生就具有的一个属性

2.2 原型存在的意义

所以为什么函数要有这么一个属性呢?

我们说过,函数的这个属性是一个对象,里面存放的是函数能使用的各种方法。那我们往对象上再添加一个值。

Person.prototype.say = 'hello'
function Person() {
    this.name = 'Tom';
    this.age = 18;
}
let p = new Person()

console.log(p);

我们输出p看看结果,发现还是这两个值。

image.png

那我们访问p.say能访问到吗?我们输出p.say看看。

image.png

我们发现能访问到。但我们明明在函数Person中没有定义say这个属性,为什么能访问到呢?

从结论上来看,如果我们往函数的这个属性上添加值,实例对象是能够访问的到的。虽然我们输出实例对象看不见这个值。但我们能访问得到。

所以我们回过头一看,我们当时定义了一个字符串str,展开它的prototype发现有那么多方法。即使这些方法不是我们创建的,我们也能使用。

所以,原型存在的意义就呼之欲出了。在JS中,有那么几种数据结构,JS官方打造它们出来,总得让它们有点方法可用,那这些方法就写在原型里了。而当我们自己也想写点方法时,也是往原型上写。

所以打造构造函数的原因我们也能知道了。当我们new了一个构造函数时,就能生成一个实例对象,因为函数上有原型这个属性,实例对象上就能拥有原型这个属性。

函数原型存在的意义:因为实例对象能访问到函数原型上的属性,所以原型存在的意义就是为了让某一种数据结构拥有更多的方法可用。

2.3 原型的作用

现在我们知道了,JS官方打造原型的意义就是为了写一些方法放在原型里面供某一种数据结构去使用。

那既然如此,官方为什么要让我们能看见原型这么个东西呢?你就自己慢慢往里面写方法就行了,为什么让我们也能看见呢?

那就说明原型对我们程序员来说一定有什么妙用。

例如:

function Car(color, owner) {
    this.name = 'su7'
    this.height = 1400
    this.lang = 5000
    this.color = color
    this.owner = owner
}

let car1 = new Car('red', '阿炜')
let car2 = new Car('green', '小朱')

我们写了这么一个构造函数,然后去调用它。我们调用了它两次。是不是构造函数Car里面的this.name = 'su7'、this.height = 140、this.lang = 5000重复执行了。明明这几行代码创建的是固定值,不用我们自己去定义。

此时,我们就能把这几行代码写到函数的原型上去。

Car.prototype.name = 'su7'
Car.prototype.height = 1400
Car.prototype.lang = 5000

function Car(color, owner) {
    this.color = color
    this.owner = owner
}

let car1 = new Car('red', '阿炜')
let car2 = new Car('green', '小朱')

这样是不是相当于这个函数本来就有这么几个属性,当我们去调用这个构造函数时,我们只要定义color和owner就行了,上面那三行代码只执行了一次,优化了代码结构。当我们调用函数次数很多时,效果就更明显,减少了语句的重复执行。

原型的作用:可以将一些固定的属性提取到原型上,减少重复的代码执行。

2.4 原型的特性

我们现在知道了JS官方打造原型的意义就是为了让我们有方法可用,也知道了我们能对原型上的属性进行操作。

那我们以字符串举例,当我们自己写的方法与原型里本来就有的方法重名了怎么办,它是会去执行我们自己打造的方法还是去执行官方打造的方法呢?

我们来试一下。

 let str = ' hello '  // new String('hello')

 console.log(str);
 console.log(str.trim());

我们用字面量创建了一个str,但我们已经知道了。当v8引擎看到这行代码时,它还是会执行成new String()去使用,为的就是生成一个实例对象,让字符串上有原型这么个属性。

然后我们调用一下字符串原型里的trim这个方法。它的作用就是去除字符串前后的空格。

我们输出看看结果。

image.png

我们发现字符串前后的空格确实被去除掉了。

那此时如果我们往原型里也写一个方法,名字和trim一样,此时v8引擎会执行哪个方法呢?

String.prototype.trim = function () {
    return 'mytrim'
}

let str = ' hello ';  // new String('hello')

console.log(str);
console.log(str.trim());

我们往原型里写了一个同名方法,作用是返回‘mytrim’。我们来看看输出结果。

image.png

我们发现,输出结果是‘mytrim’。说明执行的是我们自己写的方法。我们可以去更改原型中官方打造好的方法。

我们再来看一个例子。

let str = 'hello'  // new String('hello')

str.length = 10

console.log(str.length);

我们知道,字符串中的length可以去返回字符串的长度。我们定义一个字符串为‘hello’,长度为5。我们人为的去修改str.length的值为10。能修改成功吗?我们看看输出结果。

屏幕截图 2024-11-19 104740.png

答案是不能。输出结果还是5。

所以实例对象是不能修改原型上的值的。那不能修改,能删除吗?我们再来试一下。

Car.prototype.run = function () {
    console.log('running');
}
function Car() {
    this.name = 'su7'
}

let car = new Car()
car.run = 'run'
console.log(car.run);

我们定义了一个构造函数Car,往他的原型上添加了一个方法run,用来输出‘running’。然后new构造函数创建一个实例对象car,去调用我们自己写的这个方法,输出应该是‘running’。那么我们要是在实例对象上修改这个方法值为‘run’,能修改成功吗?按照我们上面说的应该是改不动才对。我们来看一下结果。

image.png

我们发现输出结果进竟然是‘run’,那这不是改成功了吗?和上面说的不一样啊。

真的改动了吗?我们输出一下car来看看。

image.png

我们发现,在实例对象上多了一个属性run值为‘run’。这是不是叫我们人为的往实例对象上添加了这么一个属性啊,往一个对象上添加一个属性是不是就是这样添加的。

所以,这行代码,并没有去修改它原型上的‘run’,而是往对象上显示增加了一个属性run。

那这样不冲突吗?不冲突,因为函数原型上的属性是实例对象隐式拥有的属性,我们直接输出实例对象是看不见的 ,能看见的只是实例对象显示拥有的属性,比如‘name’,我们后面添加的‘run’。所以这样并不冲突。

所以,当一个实例对象隐式显示拥有了一个‘run’,那么它去访问run,是去访问显示拥有的run。

那能删除吗?我们来试一下。

Car.prototype.run = function () {
    console.log('running');
}
function Car() {
    this.name = 'su7'
}

let car = new Car()
delete car.run 
car.run();

我们用关键字delete去删除属性run,输出结果为:

image.png

结果表示,不仅不能修改,也不能删除。我们再输出实例对象car看看,删除的是car显示拥有的属性run。

屏幕截图 2024-11-19 130038.png

所以,原型的特性:1.实例对象无法修改(增删改)函数原型上的属性,只能查原型上的属性值。2.可以去更改原型中官方打造好的方法。

3. 对象原型

关于函数上的原型我们先聊到这里。我们得再来介绍一下对象属性,之后会把这两个关联起来。

这个世界上除了函数身上有原型,对象身上也有原型。我们可以到v8引擎上看一下。

image.png

我们发现,确实有这么个属性prototype。函数上的原型叫prototype,其实对象上的原型叫__proto__,只是谷歌浏览器特殊,它没有这么叫。

我们发现,对象原型上也有这么多属性和方法可以用,那它是哪来的呢?我们知道,函数原型上的属性和方法是JS官方自己打造供我们去使用的,那对象原型上的是怎么来的呢?

此时,关联来了。我们调用一下对象构造函数Object上的原型来看看。

image.png

我们发现,对象原型上的属性和方法和函数原型上的一模一样。

我们说过,我们new一个构造函数创建的实例对象能够去访问函数原型上的方法与属性,那么这是为什么呢?我们知道,当我们创建了一个对象,我们去访问一个属性,如果这个对象显示拥有这个属性,它就直接去对象里面找,如果这个属性不在对象里显示拥有,那它就会去对象原型中找。

所以对象原型的一个重要特性:V8在查找对象上的一个属性时,如果该属性不是对象显示拥有的,那么v8就会去对象的对象原型上查找。

而为了让实例对象能够去访问函数原型上的方法与属性,所以JS又打造了一个对象原型,而这个对象原型就是完完全全继承于函数原型上的,所以对象原型上的属性和方法和函数原型上的一模一样。

对象原型的第二个重要特性:对象的隐式原型(对象原型)会被赋值成创建该对象的构造函数的显示原型(函数原型)。

4. new 的原理

那么对象上的隐式原型是什么时候被赋值为构造函数的显示原型的呢?是创建对象的那一刻。

那对象是怎么被创建的呢?我们说过,不管你用哪种方法去创建对象,它都会被v8引擎执行成用new去调用构造函数创建。

所以关键在于这个关键字new身上,是new去干了赋值这个操作。

那么new执行的原理是什么呢?其实new执行的原理很简单。

function Car() {
    this.name = 'su7'
    this.height = 1400
}

let car = new Car()

我们创建了一个构造函数Car,然后用new去调用它去得到一个实例对象car,说明构造函数在被new执行的时候返回了一个对象。

其实,在构造函数被new调用时,它偷偷的在构造函数里生成了一个叫this的对象。

function Car() {
    // var this = {}
    this.name = 'su7'
    this.height = 1400
}

let car = new Car()

然后正常去执行下面两行代码,this.name = 'su7'、this.height = 1400,这两行代码是不是就相当于往this对象中添加了两个属性name和height。

function Car() {
    // var this = {
    //     name: 'su7',
    //     height: 1400
    }
    this.name = 'su7'
    this.height = 1400   
}

let car = new Car()

接下来就让对象this的隐式原型赋值为函数的显示原型,然后返回这个对象。所以car就被赋值为了这个this对象。

Car.prototype.run = 'running'
function Car() {
    // var this = {
    //     name: 'su7',
    //     height: 1400
    // }
    // this.__proto__ = Car.prototype  // {run: 'running'}
    this.name = 'su7'
    this.height = 1400

    // return this
}
let car = new Car()

这就是new的整个运行原理。

1. 创建一个this对象

2. 让构造函数中的逻辑正常执行,这就相当于往this对象上添加属性

3. 让this对象上的__proto__= 构造函数的prototype

4. return this对象

所以当时我们说创建的字符串str有length这个属性,但我们并没有往str中添加这个属性,这其实就是new干的。

而当我们创建一种数据类型时,我们说过在v8引擎运行时,都会执行成用new去调用对应的构造函数创建出来的。所以,当我们创建一个数组时,数组是不是也被new创建成了一个对象;创建一个字符串时,字符串也被创建成了一个对象。所以我们能说:万物皆对象。

所以构造函数创建出来的意义,就是为了用new去调用它时,把自身的显示原型(对象原型)赋值给对应的数据类型的隐式原型(对象原型)。

我们还得搞懂一点,当我们创建了一个数组arr,它的隐式原型是不是等于构造函数Array的显示原型。而我们说过,原型也是一个对象,所以构造函数Array的显示原型自己是不是也有一个隐式原型,而因为原型是对象,所以构造函数的隐式原型又等于对象的显示原型。所以有下面这种关系。

let arr = []

arr.__proto__ === Array.prototype
Array.prototype.__proto__ === Object.prototype // {}

是不是有点绕。其实,你只要记住,对象一定有隐式原型,函数一定有显示原型。对象的隐式原型一定来自于创建它的构造函数的显示原型。 所以说万物皆对象。

5. 所有对象都有原型吗?

所有的对象都有原型吗?其实有一种特殊的对象没有原型。我们在v8引擎上看一下。

函数Object有一种特殊的方法create,这个方法可以创建一个对象,而创建的这个对象的隐式原型等于给方法create传进来的参数的显示属性。方法create要求我们传一个对象参数。例如

image.png

我们发现,对象b的隐式原型是a。所以当我们给create传一个‘null’时。

image.png

我们发现,c里面什么也没有,自然也没有隐式原型。

所以不是所有的对象都有原型,上面这种特殊方法创建出来的对象就没有原型。