深入理解JavaScript中的原型

210 阅读9分钟

前言:在 JavaScript 中,原型(Prototype) 是一种重要的机制,它是对象继承的基础。通过原型,JavaScript 实现了对象之间的属性共享和方法复用。以下是原型机制的详细讲解:

一、 前置知识

1.构造函数

构造函数其实和JavaScript中的其他函数没有什么本质的区别,它只是一种基础的面向对象的编程方式,与new关键字配合使用用于创建和初始化对象实例。通过构造函数,我们可以方便地创建具有相似结构和行为的多个对象。为了方便理解后面的内容,我们先简单了解下JS的构造函数。

1.1 构造函数的特点

1.命名约定

构造函数的名称通常首字母是大写的,以区分普通函数。例如:Student, Car, Teacher

2.与new关键字配合使用

构造函数的主要用途是通过new关键字创建实例对象。比如这里我们就创建了一个字符串对象。

let str = new String()

3.JavaScript内置的构造函数

其实JavaScript中内置了一些构造函数,下面是一些常见的构造函数

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

这些构造函数也都一样是通过new关键字来使用

let n = new Number()

这和我们直接创建一个字面量是一样的效果

let num = 0

不过为了简洁建议尽量使用字面量创建常用类型(如数组、对象、字符串等)。

二、原型(Prototype)

在谈原型之前我们先来在浏览器中执行下面代码

image.png

在这里我们new了一个数字对象,我们发现输入框出现了一个黑色的小三角形,我们来点开看一看

image.png

我们发现点开黑色三角形后出现了一个[[Prototype]],并且在它下面有很多方法。这究竟是怎么个事呢?其实Prototype就是原型的意思,在这里原型提供了很多方法供我们来使用。大家看到这可能会感到一头雾水,没关系,我们下面来详细聊一聊原型到底是何方神圣。

1.原型的定义

原型 (prototype) 是一个函数被定义出来天生就具有的属性,可以提供共享的属性和方法供实例对象使用,原型本质上也是一个对象。我们来看一个例子

image.png

我们定义了一个构造函数,并且这个函数可以直接访问prototype这个属性,我们可以看到这个属性值是一个对象类型,它输出了一对花括号{}

2.原型存在的意义

我们先来看一段代码

image.png 我们创建了一个构造函数,然后用这个构造函数new了一个对象,最后输出了这个对象。我们可以看到构造函数定义的属性都输出了,但刚刚我们不是说原型是一个函数被定义出来的就具有属性,为什么这里没有呢?原型存在的意义又是什么呢?

我们前面提到过原型本质上是一个对象,里面存放着函数各种各样能够使用的方法,既然是一个对象,那我们是不是可以往这个对象上添加方法呢?我们来上代码试一下

Person.prototype.say = 'Hello'
function Person() {
    this.name = 'John'
    this.age = 20
}
let p = new Person()
console.log(p); 

我们创建了一个构造函数并new了一个对象,并且输出了这个对象。输出结果中并没有我们定义的say属性

image.png

我们再直接输出这个属性试试

console.log(p.say);

我们看到成功输出了结果Hello image.png

从上面的例子我们可以得出一个结论,那就是虽然我们不能看到原型里的属性,但是我们可以直接调用原型中定义的各种方法,或者自己往原型里面加属性供我们使用。我们在最开始定义了一个数字对象,我们自己并没有往原型里面加属性,但里面却有很多方法供我们使用,这些方法是谁写的呢?显然,这是JavaScript这门语言的开发者提前帮我们写好的,可以方便我们使用。

所以原型存在的意义就是利用实例对象能访问到函数原型上的属性这个特点,让某一种数据机构拥有更多的方法可用

3.原型的妙用

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

我们来看一个场景:雷总的小米汽车工厂可以让用户定制他们喜欢的颜色,并且可以在轮毂上刻车主的名字,非常的炫酷。于是我们定义了一个构造函数

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

我们知道雷总是一个非常注重效率的人,他指出小米汽车工厂现在生产的汽车名字、高度、长度都是一样的,我们现在写的代码存在重复定义的问题,效率不高。命令你一天之内改进一下,为了保住自己的饭碗,你灵机一动想到了在JavaScript中原型可以解决这个问题,于是你把代码改进了一下。

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

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

我们可以看到我们利用原型把车的名字、高度、长度等参数加到原型的属性上了,并且我们也可以访问到这些参数。这样用户只需要提供他们喜欢的颜色和名字了。太好了,你的工作保住了!

4.原型的特性

实例对象无法修改函数原型上的属性

我们已经知道了,JavaScript官方打造原型就是为了提供一些方法让我们使用,并且我们也可以自己往原型里面去添加自己打造的方法。那如果我们恰巧和原型里面原有的方法重名了怎么办?它会执行我们自己的方法还是执行官方的方法呢?我们上代码来探究一下:

let str = '    hello' // new String('hello')
console.log(str);
console.log(str.trim());

image.png

我们定义了一个字符串,并且调用了String自带的trim去除字符串前后的空格。

我们现在自己写一个trim方法然后执行,看看会输出什么

String.prototype.trim = function () {
    return 'mytrim'
}
let str = '    hello' // new String('hello')
console.log(str);
console.log(str.trim());

image.png 我们看到最终输出了我们自己定义的方法,所以我们可以知道我们可以去重写原型中官方打造好的方法

我们再来看一个例子

let str = 'hello' // new String('hello')
str.length = 10
console.log(str.length);

image.png

我们知道,字符串的length方法可以返回字符串的长度,我们定义了一个长度为5的字符串,并尝试去修改它的长度。最后的输出结果仍然是5。

这样来看实例对象是不能修改原型上的值的,既然修改不行,那我们删除行不行呢?我们再来试一下


function Car() {
    this.name = 'su7'

}
Car.prototype.run = function () {
    console.log('Running');

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

image.png 我们定义了一个构造函数Car,并且往它的原型添加了一个方法run,这个方法会输出Running。然后我们用new构造函数创建一个实例对象car,并尝试用delete删除run方法。但最终结果还是输出了Running。由此可见,实例对象也不能删除原型上的值。

三、 对象原型

我们前面提到每个函数都由原型,但其实对象也有原型。为了区分它们,我们把函数的原型称之为显式原型,对象的原型我们叫隐式原型__proto__。 我们以String()构造函数为例子,来探究下显式原型prototype和隐式原型__proto__它们之间有什么关系。

image.png

image.png

我们再来看看Object对象的原型

image.png

我们不难发现String构造函数的隐式原型竟然和Object的原型长得一模一样!其实所有普通对象都从 Object.prototype 继承。v8在查找对象上的一个属性时,如果该属性不是对象还显示拥有的,那么v8就会去对象原型上查找

new的原理

我们在了解完显式原型(prototype)和隐式原型(__proto__)后便能知道new的工作原理了

function Car() {
    // var this = {
    // name:'su7',
    // height:1400
    //  }
    // this.__proto__ = Car.prototype
    this.name = 'su7'
    this.height = 1400
    // return this
}
let car = new Car()

我们利用new关键字创建一个实例对象时,首先是创建了一个this对象,然后我们让构造函数中的逻辑正常执行,也就是往this对象上添加了属性。然后我们this对象的隐式原型__proto__会被赋值成该对象的构造函数的显式原型,最后return一个this对象。

我们总结一下:

  1. 创建一个this对象
  2. 让构造函数中的逻辑正常执行(相当于 往this对象上添加了属性)
  3. this 对象的 __proto__ = 构造函数的 prototype
  4. return this 对象

原型链

前面我们提到对象的隐式原型会被赋值成创建该对象的构造函数的显式原型,每个对象都有一个内部属性 [[Prototype]](可以通过 Object.getPrototypeOf(obj) 获取),它指向该对象的原型。通过这种方式,多个对象可以相互连接,形成一条链,称为 原型链

// 常见的原型链结构
对象实例 -> 构造函数的 prototype -> Object.prototype -> null

我们来看一段代码

function Person(name) {
    this.name = name;
}

Person.prototype.sayHello = function () {
    console.log(`Hello, I am ${this.name}`);
};

const alice = new Person('Alice');

// 查找过程
alice.sayHello(); // 找到 alice.__proto__.sayHello
alice.toString(); // 找到 alice.__proto__.__proto__.toString

在上面的代码中,我们定义了构造函数Person,并且往这个构造函数的显式原型prototype中加了一个方法sayHello,然后创建了一个实例对象alice。并执行了sayHello()toString()方法。查找顺序如下

  • 首先在 alice 对象自身查找 sayHello 方法;

  • 找不到时,查找 alice.__proto__(即 Person.prototype);

  • 如果还找不到,则继续查找 Person.prototype.__proto__(即 Object.prototype);

  • 如果仍然找不到,则返回 undefined

原型链中的顶层:Object.prototype

原型链并不是没有终点的,所有对象的原型链最终都会指向 Object.prototype,Object.prototype 是 JavaScript 中所有普通对象的终点,其原型为 null

const obj = {};
console.log(Object.getPrototypeOf(obj)); // 输出: Object.prototype
console.log(Object.getPrototypeOf(Object.prototype)); // 输出: null

四、所有的对象都有原型吗?

Object.create(proto) 方法

在解答这个问题之前我们先来了解下Object.create(proto) 方法,Object.create(proto) 方法创建一个新对象,并将其 [[Prototype]] 设置为指定的 proto

const animal = {
    eat() {
        console.log('Eating...');
    },
};

const dog = Object.create(animal);
dog.bark = function () {
    console.log('Woof!');
};

dog.eat(); // 输出: Eating... (从 animal 继承)
dog.bark(); // 输出: Woof! (自身方法)
console.log(Object.getPrototypeOf(dog) === animal); // 输出: true

在上面的这段代码中我们就利用Object.create(proto) 方法为dog设置animal为它的隐式原型。

当我们执行下面这个代码时会发生什么呢

let c = Object.create(null)

image.png

因为我们传进来的'null'什么也没有,所以这个对象没有隐式原型。

所以我们可以得出结论,并不是所有对象都有隐式原型!