JavaScript原型链的工作原理与优化
2022-03-025.3k 阅读
JavaScript 原型链的基本概念
在 JavaScript 中,每个对象都有一个 [[Prototype]]
内部属性(在现代 JavaScript 中可以通过 Object.getPrototypeOf()
方法或 __proto__
属性访问),这个属性指向另一个对象,而这个被指向的对象就是原型对象。原型对象本身也可能有自己的原型对象,这样就形成了一条链状结构,称为原型链。
当我们访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript 引擎就会沿着原型链向上查找,直到找到该属性或者到达原型链的顶端(null
)。例如:
function Animal() {
this.species = '动物';
}
function Dog() {
this.name = '小狗';
}
Dog.prototype = new Animal();
let myDog = new Dog();
console.log(myDog.species); // 输出: 动物
在上述代码中,myDog
是 Dog
的实例,Dog
的原型是 Animal
的实例。当我们访问 myDog.species
时,myDog
本身没有 species
属性,于是沿着原型链找到 Dog.prototype
(也就是 Animal
的实例),从而找到了 species
属性。
原型链的构建过程
- 函数对象的原型
- 每个函数在创建时,都会自动拥有一个
prototype
属性,这个属性指向一个对象,称为函数的原型对象。例如:
- 每个函数在创建时,都会自动拥有一个
function Person() {}
console.log(Person.prototype);
- 这个原型对象默认有一个
constructor
属性,它指向函数本身。
function Person() {}
console.log(Person.prototype.constructor === Person); // 输出: true
- 实例对象的原型
- 当使用
new
关键字调用一个函数(构造函数)时,会创建一个新的实例对象。这个实例对象的[[Prototype]]
指向构造函数的prototype
。例如:
- 当使用
function Car() {}
let myCar = new Car();
console.log(Object.getPrototypeOf(myCar) === Car.prototype); // 输出: true
- 原型链的形成
- 假设我们有一个继承关系,比如
Truck
继承自Car
。
- 假设我们有一个继承关系,比如
function Car() {
this.wheels = 4;
}
function Truck() {
this.loadCapacity = '10 吨';
}
Truck.prototype = new Car();
let myTruck = new Truck();
- 在这个例子中,
myTruck
的[[Prototype]]
指向Truck.prototype
,而Truck.prototype
是Car
的实例,所以Truck.prototype
的[[Prototype]]
指向Car.prototype
,最终形成了一条原型链:myTruck -> Truck.prototype -> Car.prototype -> Object.prototype -> null
。
原型链在属性和方法查找中的作用
- 属性查找
- 当访问对象的属性时,首先在对象自身查找。如果找不到,就沿着原型链向上查找。例如:
function Shape() {
this.color = '红色';
}
function Circle() {
this.radius = 5;
}
Circle.prototype = new Shape();
let myCircle = new Circle();
console.log(myCircle.color); // 输出: 红色
- 这里
myCircle
自身没有color
属性,但是沿着原型链在Circle.prototype
(也就是Shape
的实例)中找到了color
属性。
- 方法查找
- 方法也是属性,同样遵循原型链查找规则。例如:
function Animal() {}
Animal.prototype.speak = function() {
console.log('我是一只动物');
};
function Dog() {}
Dog.prototype = new Animal();
let myDog = new Dog();
myDog.speak(); // 输出: 我是一只动物
myDog
自身没有speak
方法,但是通过原型链在Dog.prototype
(进而在Animal.prototype
)中找到了speak
方法。
原型链的特性与注意事项
- 共享性
- 原型链上的属性和方法是共享的。例如,所有
Dog
的实例都会共享Animal.prototype
上的speak
方法。这在节省内存方面非常有优势,因为相同的方法不需要在每个实例中重复创建。
- 原型链上的属性和方法是共享的。例如,所有
function Animal() {}
Animal.prototype.speak = function() {
console.log('我是一只动物');
};
function Dog() {}
Dog.prototype = new Animal();
let dog1 = new Dog();
let dog2 = new Dog();
console.log(dog1.speak === dog2.speak); // 输出: true
- 可修改性
- 虽然原型链上的属性和方法是共享的,但如果在实例上直接修改某个属性,不会影响原型链上的同名属性。例如:
function Animal() {
this.species = '动物';
}
function Dog() {}
Dog.prototype = new Animal();
let myDog = new Dog();
myDog.species = '小狗';
console.log(myDog.species); // 输出: 小狗
console.log(Dog.prototype.species); // 输出: 动物
- 这里在
myDog
实例上修改了species
属性,只是在myDog
自身创建了一个新的species
属性,并没有改变Dog.prototype
上的species
属性。
- 原型链顶端
- 原型链的顶端是
Object.prototype
,它包含了一些通用的方法,如toString()
、hasOwnProperty()
等。如果在原型链上一直找不到属性或方法,就会到达Object.prototype
。如果Object.prototype
也没有该属性或方法,就会返回undefined
。例如:
- 原型链的顶端是
let obj = {};
console.log(obj.toString()); // 输出: [object Object]
- 这里
obj
自身没有toString
方法,通过原型链在Object.prototype
中找到了toString
方法。
原型链的优化策略
- 合理设计原型链结构
- 在设计继承关系时,要避免过深的原型链。过深的原型链会导致属性和方法查找变慢。例如,尽量减少不必要的中间层次。
- 假设我们有一个复杂的继承结构:
A -> B -> C -> D -> E
。如果E
的实例需要查找一个属性,可能需要经过多次查找才能找到。可以考虑简化这个结构,比如将一些通用的属性和方法提升到更合适的层次。
- 避免在原型链上频繁查找属性
- 如果一个对象需要频繁访问某个属性,并且这个属性在原型链上层次较深,可以考虑将该属性直接定义在对象自身。例如:
function Animal() {
this.species = '动物';
}
function Dog() {
this.name = '小狗';
}
Dog.prototype = new Animal();
let myDog = new Dog();
// 频繁访问 species 属性,可直接在 Dog 构造函数中定义
Dog.prototype.getSpecies = function() {
return this.species;
};
// 优化后
function Dog() {
this.name = '小狗';
this.species = '动物';
}
Dog.prototype.getSpecies = function() {
return this.species;
};
- 这样在调用
myDog.getSpecies()
时,就不需要沿着原型链查找species
属性,提高了访问效率。
- 使用
Object.create()
优化原型链创建Object.create()
方法可以更灵活地创建具有指定原型的对象,并且性能上可能更优。例如:
let animalProto = {
species: '动物',
speak: function() {
console.log('我是一只动物');
}
};
let myAnimal = Object.create(animalProto);
console.log(myAnimal.species); // 输出: 动物
myAnimal.speak(); // 输出: 我是一只动物
- 与使用构造函数和
new
关键字创建对象相比,Object.create()
直接基于给定的原型创建对象,避免了构造函数可能带来的一些不必要的开销。
- 缓存原型链上的属性和方法
- 如果在代码中多次访问原型链上的某个属性或方法,可以将其缓存起来。例如:
function Animal() {}
Animal.prototype.speak = function() {
console.log('我是一只动物');
};
function Dog() {}
Dog.prototype = new Animal();
let myDog = new Dog();
// 缓存 speak 方法
let speakMethod = myDog.speak;
// 多次调用缓存的方法
for (let i = 0; i < 1000; i++) {
speakMethod();
}
- 这样在循环中多次调用
speak
方法时,不需要每次都沿着原型链查找,提高了执行效率。
原型链与性能分析
- 属性查找性能
- 原型链的长度对属性查找性能有显著影响。较长的原型链意味着更多的查找步骤,从而增加查找时间。例如,通过性能测试可以验证这一点:
function Base() {}
function A() {}
A.prototype = new Base();
function B() {}
B.prototype = new A();
function C() {}
C.prototype = new B();
function D() {}
D.prototype = new C();
let myD = new D();
console.time('查找属性');
for (let i = 0; i < 10000; i++) {
myD.toString();
}
console.timeEnd('查找属性');
- 这里通过
console.time()
和console.timeEnd()
测量查找toString
方法(在原型链上)的时间。如果原型链更长,这个时间会明显增加。
- 内存占用
- 原型链的共享特性虽然节省内存,但如果原型链上的对象包含大量数据,也可能导致内存占用过高。例如,如果
Animal.prototype
包含一个非常大的数组:
- 原型链的共享特性虽然节省内存,但如果原型链上的对象包含大量数据,也可能导致内存占用过高。例如,如果
function Animal() {}
Animal.prototype.bigArray = new Array(1000000).fill(1);
function Dog() {}
Dog.prototype = new Animal();
let dog1 = new Dog();
let dog2 = new Dog();
- 虽然
dog1
和dog2
共享Animal.prototype.bigArray
,节省了内存空间,但这个大数组仍然占用了较多内存。在这种情况下,需要考虑是否有必要将这个大数组放在原型链上,或者对其进行更合理的处理,比如按需加载。
- 优化对性能的影响
- 应用前面提到的优化策略,可以显著提升性能。例如,使用
Object.create()
创建对象可能比传统的构造函数方式更快。通过性能测试可以验证:
- 应用前面提到的优化策略,可以显著提升性能。例如,使用
// 使用构造函数方式创建对象
console.time('构造函数方式');
function Person() {
this.name = '张三';
}
for (let i = 0; i < 10000; i++) {
let person = new Person();
}
console.timeEnd('构造函数方式');
// 使用 Object.create() 方式创建对象
console.time('Object.create() 方式');
let personProto = {
name: '张三'
};
for (let i = 0; i < 10000; i++) {
let person = Object.create(personProto);
}
console.timeEnd('Object.create() 方式');
- 通常情况下,
Object.create()
方式在创建简单对象时性能更好,因为它避免了构造函数的一些初始化开销。
原型链与现代 JavaScript 特性的结合
- ES6 类与原型链
- ES6 引入了
class
关键字,它提供了更简洁的面向对象编程语法。但实际上,class
仍然基于原型链。例如:
- ES6 引入了
class Animal {
constructor() {
this.species = '动物';
}
speak() {
console.log('我是一只动物');
}
}
class Dog extends Animal {
constructor() {
super();
this.name = '小狗';
}
}
let myDog = new Dog();
console.log(myDog.species); // 输出: 动物
myDog.speak(); // 输出: 我是一只动物
- 这里
Dog
继承自Animal
,myDog
的原型链结构与传统方式创建的继承结构类似:myDog -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
。class
语法只是在原型链的基础上提供了更直观的代码结构。
- 箭头函数与原型链
- 箭头函数没有自己的
this
、arguments
、super
和new.target
,也没有prototype
属性。这意味着箭头函数不能作为构造函数,也不会影响原型链的构建。例如:
- 箭头函数没有自己的
let func = () => {};
console.log(func.prototype); // 输出: undefined
- 当在对象方法中使用箭头函数时,需要注意它不会绑定到对象的
this
,而是继承外层作用域的this
。例如:
class Person {
constructor() {
this.name = '张三';
this.getSelf = () => {
return this;
};
}
}
let person = new Person();
console.log(person.getSelf() === person); // 输出: true
- 这里
getSelf
方法中的箭头函数this
指向person
实例,因为它继承了外层constructor
函数的this
。
原型链在实际项目中的应用案例
- 代码复用与模块化
- 在大型项目中,通过原型链实现代码复用和模块化。例如,在一个游戏开发项目中,可能有一个
GameObject
基类,其他如Character
、Item
等类继承自GameObject
。
- 在大型项目中,通过原型链实现代码复用和模块化。例如,在一个游戏开发项目中,可能有一个
class GameObject {
constructor(x, y) {
this.x = x;
this.y = y;
}
move(dx, dy) {
this.x += dx;
this.y += dy;
}
}
class Character extends GameObject {
constructor(x, y, name) {
super(x, y);
this.name = name;
}
speak() {
console.log(`我是 ${this.name}`);
}
}
let player = new Character(10, 10, '玩家');
player.move(5, 5);
player.speak();
Character
继承了GameObject
的move
方法,实现了代码复用,同时添加了自己的speak
方法,符合模块化开发的思想。
- 插件系统
- 一些 JavaScript 插件库利用原型链来实现插件的扩展。例如,一个 DOM 操作库可能有一个基础的
DOMElement
类,插件可以通过继承这个类来添加新的功能。
- 一些 JavaScript 插件库利用原型链来实现插件的扩展。例如,一个 DOM 操作库可能有一个基础的
class DOMElement {
constructor(selector) {
this.element = document.querySelector(selector);
}
show() {
this.element.style.display = 'block';
}
hide() {
this.element.style.display = 'none';
}
}
class DOMElementWithAnimation extends DOMElement {
constructor(selector) {
super(selector);
}
fadeIn() {
this.element.style.opacity = 0;
let interval = setInterval(() => {
let opacity = parseFloat(this.element.style.opacity);
if (opacity >= 1) {
clearInterval(interval);
this.element.style.opacity = 1;
} else {
this.element.style.opacity = opacity + 0.1;
}
}, 100);
}
}
let myElement = new DOMElementWithAnimation('#my - div');
myElement.fadeIn();
DOMElementWithAnimation
继承自DOMElement
,在保留原有功能的基础上添加了fadeIn
动画功能,实现了插件式的扩展。
原型链相关的常见错误与调试方法
- 原型链污染
- 原型链污染是一种安全漏洞,攻击者可以通过修改原型链上的属性,影响整个应用程序。例如:
// 恶意代码
Object.prototype.newProp = '恶意属性';
function MyClass() {}
let myObj = new MyClass();
console.log(myObj.newProp); // 输出: 恶意属性
- 这里恶意代码在
Object.prototype
上添加了newProp
属性,导致所有对象都可以访问这个属性。为了防止原型链污染,要避免随意修改Object.prototype
等全局原型对象。
- 错误的原型链继承
- 在实现继承时,可能会出现错误的原型链设置。例如,忘记设置
prototype
或者设置错误:
- 在实现继承时,可能会出现错误的原型链设置。例如,忘记设置
function Animal() {
this.species = '动物';
}
function Dog() {
this.name = '小狗';
}
// 错误设置,没有正确继承
Dog.prototype = Animal;
let myDog = new Dog();
console.log(myDog.species); // 输出: undefined
- 这里应该是
Dog.prototype = new Animal();
才是正确的继承设置。调试这种错误可以通过打印原型链来检查,例如:
function printPrototypeChain(obj) {
let proto = obj;
while (proto!== null) {
console.log(proto);
proto = Object.getPrototypeOf(proto);
}
}
let myDog = new Dog();
printPrototypeChain(myDog);
- 通过打印原型链,可以清楚地看到继承关系是否正确,从而找出错误。
- 属性覆盖问题
- 当在实例上直接定义与原型链上同名的属性时,可能会导致意外的行为。例如:
function Animal() {
this.species = '动物';
}
function Dog() {
this.species = '小狗';
}
Dog.prototype = new Animal();
let myDog = new Dog();
console.log(myDog.species); // 输出: 小狗
console.log(Dog.prototype.species); // 输出: 动物
- 如果不小心在
Dog
构造函数中定义了与Animal.prototype.species
同名的属性,可能会误解myDog.species
的来源。调试时可以通过hasOwnProperty()
方法来判断属性是在实例上还是在原型链上:
console.log(myDog.hasOwnProperty('species')); // 输出: true
- 这表明
myDog.species
是在myDog
实例上定义的,而不是从原型链继承的。
通过深入理解 JavaScript 原型链的工作原理,并合理应用优化策略,我们可以编写出更高效、更健壮的 JavaScript 代码,同时避免常见的错误和安全问题。在实际项目中,原型链在代码复用、模块化和插件开发等方面都发挥着重要作用,是 JavaScript 开发者必须掌握的核心知识之一。