一、原型和原型链的基本概念
在JavaScript中,每个对象都有一个原型对象(prototype)。原型对象就是一个普通的对象,在创建新对象时,可以将该对象作为新对象的原型。原型对象可以包含共享的属性和方法,这些属性和方法可以被新对象继承和访问。对象之间通过原型链(prototype chain)互相关联,形成了一个原型的链条。
当访问对象的属性或方法时,JavaScript会首先在对象本身查找,如果找不到,就会沿着原型链向上查找,直到找到对应的属性或方法,或者到达原型链的顶层(Object.prototype)。
下面是使用数组创建原型和原型链的示例:
在JavaScript中,数组是内置的对象。它的构造函数是Array,所有的数组对象都是通过Array构造函数创建的。所以,我们可以将Array构造函数看作是数组对象的原型。
在创建一个数组时,例如let arr = [1,2,3]
,实际上是通过Array构造函数创建了一个新的数组对象arr,并将原型链与Array.prototype关联起来。也就是说,arr对象的原型是Array.prototype。
如图:
根据上面的解释和图例展示,可知,arr.proto 和 Array.prototype是相等的
let arr = [1, 2, 3]
console.log(arr.__proto__ == Array.prototype) // true
可能这里有的小伙伴还有些不太理解,那我换个方式说明一下;
创建数组的方式还可以是这样,
使用Array()
构造函数创建数组:
let arr = new Array(1, 2, 3); // 包含多个元素的数组
此时的这一句代码和图例的对应关系如下:
- Array --Array构造函数
- arr – arr对象
- arr_proto__和Array.protoType是同一个对象 – Array的原型对象
那么这是最基本的构造函数、实例、和原型对象之间的关系,那么原型链又是如何产生的呢?
一起来思考一个问题,arr对象是通过Array构造函数new出来的。那么Array构造函数又是从哪里来的呢,它会不会也是通过一个构造函数new出来的呢?
答案是肯定的,那么我们如何获得Array构造函数的构造函数呢?
我们可以看看arr对象是如何知道Array是他的构造函数的
console.log(arr.__proto__.constructor.name) // Array
这段代码的含义是获取了数组对象 arr 的原型对象(也就是 Array.prototype 对象),并访问其 constructor 属性的 name 属性。
arr.__proto__
获取了 arr 的原型对象。constructor
是原型对象的一个属性,它指向创建该对象的构造函数,对于数组来说,它指向 Array 构造函数。name
是函数对象的一个属性,表示函数的名字。
因此,该代码的含义是获取 arr 的原型对象的构造函数的名字,对于数组对象来说,该名字应为 “Array”。
所以我们可以用同样的方式知道Array的原型对象的构造函数的名字
console.log(Array.__proto__.constructor.name) // Function
如图,控制台打印出来的是Function.
那么我们接着上面的图例扩展,如下:
在以同样的方式来找function原型对象的构造函数是谁
console.log(Function.prototype.__proto__.constructor.name) // Object
再以同样的方式来找Object原型对象的构造函数是谁
console.log(Object.prototype.__proto__.constructor.name)
这次控制台报错了,如下:
说不能从null上获取属性constructor
从这个报错信息可得Object.prototype.__proto__是null
console.log(Object.prototype.__proto__) // null
这就证实了开头我们介绍的原型链的顶层是(Object.prototype)
最后我们再来看下Array原型对象的构造函数是谁
console.log(Array.prototype.__proto__.constructor.name) // Object
打印出来是Object,Array原型对象的构造函数是Object
也就是说
Array.prototype.proto == Object.prototype
最终就形成了如下闭环
再回到开头,let arr = [1,2,3]
对于给定的arr对象,它的原型链如下:
arr -> Array.prototype -> Object.prototype -> null
也就是说,arr继承自Array.prototype,Array.prototype继承自Object.prototype,而Object.prototype的原型为null。
二、继承
1. 原型链继承
通过上面arr对象的原型链可以知道:
在JavaScript中,每个对象都有一个内部属性[[Prototype]],它指向其继承的原型对象。原型对象也可以拥有自己的原型,通过这种方式形成了原型链。当我们访问一个对象的属性或方法时,JavaScript引擎会先在对象本身查找,如果找不到则继续在其原型对象上查找,直到找到目标属性或方法,或者到达原型链的末尾。
通过原型和原型链的这种机制,我们可以在一个对象中共享属性和方法,这其实就是原型链的继承。
比如此时的arr
可以共享Array.prototype
上的方法,比如push()
方法,它向数组末尾添加一个或多个元素,并返回新的长度:
let arr = [1, 2, 3];
arr.push(4);
console.log(arr); // 输出:[1,2,3,4]
在原型链中,使用 Object.prototype
作为所有对象的原型。所以,数组对象 arr
可以共享 Object.prototype
上的toString方法,结果会返回数组转为字符串后的形式。
let arr = [1, 2, 3];
// 通过原型链访问共享方法
console.log(arr.toString()); // "1,2,3"
原型链继承的优点
是简单易用,可以继承父类的属性和方法,并且可以向上查找原型链上的属性和方法。但它也存在一些问题
,比如所有子类实例共享父类的属性和方法,无法向父类的构造函数传递参数,同时如果某个子类实例修改了继承的属性,会影响到其他子类实例。
2. 父类构造函数的实例设为子类原型对象实现继承
在JavaScript中,可以通过将父类构造函数的实例设置为子类构造函数的原型来实现继承。这种方式称为原型继承或者借用构造函数。
以下是实现继承的步骤:
- 创建父类构造函数,定义父类的属性和方法。
- 创建子类构造函数,并在构造函数中调用父类构造函数,使用.call()方法绑定当前子类的this到父类构造函数上,确保子类可以继承父类的属性。
- 创建父类的实例,并将该实例赋值给子类构造函数的prototype。这样,子类的prototype就会指向父类的实例,从而实现继承父类的方法。
- 在子类构造函数的原型中添加子类特有的属性和方法。
下面是一个例子来说明这个过程:
// 创建父类构造函数
function Animal(name) {
this.name = name;
}
// 父类方法
Animal.prototype.sayName = function() {
console.log('My name is ' + this.name);
}
// 创建子类构造函数
function Dog(name, breed) {
Animal.call(this, name); // 调用父类构造函数,绑定当前子类的this到父类构造函数上
this.breed = breed; // 子类特有的属性
}
// 将父类的实例赋值给子类构造函数的原型
//Dog.prototype = Object.create(Animal.prototype);
// 父类构造函数的实例
let animal = new Animal()
// 设为子类的原型对象
Dog.prototype = animal
// 修复constructor指针
Dog.prototype.constructor = Dog
// 子类特有的方法
Dog.prototype.bark = function() {
console.log('Woof!');
}
// 创建子类实例
var myDog = new Dog('Max', 'Labrador');
// 调用继承的父类方法
myDog.sayName(); // 输出 'My name is Max'
// 调用子类特有的方法
myDog.bark(); // 输出 'Woof!'
在上面的示例中,Animal是父类构造函数,Dog是子类构造函数,Dog通过调用Animal构造函数实现继承父类的属性。然后将Animal的实例赋值给Dog的原型,使得Dog可以继承父类的方法。最后可以通过创建Dog的实例来调用继承的父类方法和子类特有的方法。
但是这样的继承存在一个问题,继承过来的实例属性,如果是引用类型,会被多个子类的实例共享
这意味着所有的子类实例对于该属性的修改都会影响到其他子类实例。这是因为引用类型的属性存储在堆内存中,并且多个实例共用同一个引用地址。
以下是一个例子来具体说明这个问题:
function Person(name) {
this.name = name;
this.hobbies = ['reading', 'swimming'];
}
Person.prototype.sayHello = function() {
console.log('Hello, my name is ' + this.name);
};
function Student(name, grade) {
this.grade = grade;
}
Student.prototype = new Person();
Student.prototype.constructor = Student
var student1 = new Student('Alice', 5);
var student2 = new Student('Bob', 6);
// 修改student1的hobbies
student1.hobbies.push('playing basketball');
console.log(student1.hobbies); // ['reading', 'swimming', 'playing basketball']
console.log(student2.hobbies); // ['reading', 'swimming', 'playing basketball']
在上面的例子中,我们定义了一个Person
构造函数,它有一个name
属性和一个hobbies
数组属性。然后,我们又定义了一个Student
构造函数,它通过调用Person
构造函数来继承name
属性,并添加了一个grade
属性。
接着,我们将Student
的原型对象设为一个Person
的实例,从而实现了继承。
最后,我们创建了两个Student
实例student1
和student2
。然后,我们修改了student1
的hobbies
,添加了一个新的爱好playing basketball
。结果发现,student2
的hobbies
数组也被修改了,它也包含了playing basketball
。
这是因为hobbies
是一个数组,是引用类型属性。当student1
修改hobbies
时,它实际上是修改了父类构造函数的实例中的hobbies
数组,而这个实例也被student2
共享。所以,student2
的hobbies
也会被修改。
为了解决这个问题,可以使用其他的继承方式,比如原型继承、组合继承、寄生组合继承等,这些方式避免了引用类型属性被共享的问题。
3. 寄生组合继承
1. call和apply用法介绍
在JavaScript中,call
和apply
是两个用于调用函数的方法。
call
方法的语法是:function.call(thisArg, arg1, arg2, ...ARGUMENTS)
。它接收一个参数列表,并将每个参数传递给函数。第一个参数thisArg
是可选的,用于指定函数中的this
值。如果不传递thisArg
,默认为全局对象(在浏览器中是window
对象)。call
方法会立即调用函数。
例如,考虑下面的例子:
function greet(name) {
console.log(`Hello, ${name}! My name is ${this.name}.`);
}
const person = {
name: 'Alice'
};
greet.call(person, 'Bob');
在这个例子中,call
方法将person
对象作为第一个参数传递给了greet
函数。函数执行后,this.name
将会是person
对象的name
属性。输出将会是Hello, Bob! My name is Alice.
。
apply
方法的语法是:function.apply(thisArg, [argsArray])
。它与call
方法类似,不同之处在于它接收一个包含多个参数的数组作为参数列表。apply
方法也会立即调用函数。
例如,考虑下面的例子:
function add(a, b) {
return a + b;
}
const numbers = [3, 4];
console.log(add.apply(null, numbers));
在这个例子中,apply
方法将numbers
数组作为第二个参数传递给了add
函数。函数执行后,a
和b
将分别为3
和4
,并返回它们的和7
。
总结一下,call
和apply
方法都用于调用函数,并且允许绑定函数中的this
值。它们的主要区别在于传递参数的方式不同:call
方法接收参数列表,而apply
方法接收参数数组。
2. 完美继承(寄生组合)
在JavaScript中,我们可以使用call
和apply
方法来实现构造函数之间的继承。这种方式也称为借用构造函数或伪经典继承。
假设有两个构造函数Parent
和Child
,我们想要让Child
继承Parent
的属性和方法。
首先,创建Parent
构造函数:
function Parent(name) {
this.name = name;
}
Parent.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
}
然后,创建Child
构造函数,我们可以使用call
或apply
方法来继承Parent
的属性和方法。
function Child(name, age) {
// 继承属性
Parent.call(this, name); // 组合式继承
this.age = age;
}
// 继承方法
Child.prototype = Object.create(Parent.prototype); // 寄生继承
Child.prototype.sayAge = function() {
console.log(`I am ${this.age} years old`);
}
在上述例子中,通过 Parent.call(this, name);
使用了call
方法,在Child
构造函数中调用了Parent
构造函数,并将this
关键字指向Child
对象,这样Child
对象就拥有了Parent
构造函数的属性。
通过 Child.prototype = Object.create(Parent.prototype);
,我们创建了一个空对象作为Child
的原型,并将Parent
的原型作为新对象的原型,这样Child
对象就能够访问到Parent
原型上的方法了。
这一步其实也就是所谓的寄生继承,可以拆解为:
function Temp(){} // 临时构造函数
Temp.prototype = Parent.prototype
let childPrototype = new Temp()
Child.prototype = childPrototype
childPrototype.constructor = Child
最后,我们可以创建Child
对象并调用其方法:
let child = new Child('Alice', 20);
child.sayHello(); // 输出:Hello, my name is Alice
child.sayAge(); // 输出:I am 20 years old
通过使用call
和apply
方法组合继承继承属性,寄生继承继承方法,我们成功实现了Child
构造函数继承了Parent
构造函数的属性和方法。