MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

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); // 输出: 动物

在上述代码中,myDogDog 的实例,Dog 的原型是 Animal 的实例。当我们访问 myDog.species 时,myDog 本身没有 species 属性,于是沿着原型链找到 Dog.prototype(也就是 Animal 的实例),从而找到了 species 属性。

原型链的构建过程

  1. 函数对象的原型
    • 每个函数在创建时,都会自动拥有一个 prototype 属性,这个属性指向一个对象,称为函数的原型对象。例如:
function Person() {}
console.log(Person.prototype);
  • 这个原型对象默认有一个 constructor 属性,它指向函数本身。
function Person() {}
console.log(Person.prototype.constructor === Person); // 输出: true
  1. 实例对象的原型
    • 当使用 new 关键字调用一个函数(构造函数)时,会创建一个新的实例对象。这个实例对象的 [[Prototype]] 指向构造函数的 prototype。例如:
function Car() {}
let myCar = new Car();
console.log(Object.getPrototypeOf(myCar) === Car.prototype); // 输出: true
  1. 原型链的形成
    • 假设我们有一个继承关系,比如 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.prototypeCar 的实例,所以 Truck.prototype[[Prototype]] 指向 Car.prototype,最终形成了一条原型链:myTruck -> Truck.prototype -> Car.prototype -> Object.prototype -> null

原型链在属性和方法查找中的作用

  1. 属性查找
    • 当访问对象的属性时,首先在对象自身查找。如果找不到,就沿着原型链向上查找。例如:
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 属性。
  1. 方法查找
    • 方法也是属性,同样遵循原型链查找规则。例如:
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 方法。

原型链的特性与注意事项

  1. 共享性
    • 原型链上的属性和方法是共享的。例如,所有 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
  1. 可修改性
    • 虽然原型链上的属性和方法是共享的,但如果在实例上直接修改某个属性,不会影响原型链上的同名属性。例如:
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 属性。
  1. 原型链顶端
    • 原型链的顶端是 Object.prototype,它包含了一些通用的方法,如 toString()hasOwnProperty() 等。如果在原型链上一直找不到属性或方法,就会到达 Object.prototype。如果 Object.prototype 也没有该属性或方法,就会返回 undefined。例如:
let obj = {};
console.log(obj.toString()); // 输出: [object Object]
  • 这里 obj 自身没有 toString 方法,通过原型链在 Object.prototype 中找到了 toString 方法。

原型链的优化策略

  1. 合理设计原型链结构
    • 在设计继承关系时,要避免过深的原型链。过深的原型链会导致属性和方法查找变慢。例如,尽量减少不必要的中间层次。
    • 假设我们有一个复杂的继承结构:A -> B -> C -> D -> E。如果 E 的实例需要查找一个属性,可能需要经过多次查找才能找到。可以考虑简化这个结构,比如将一些通用的属性和方法提升到更合适的层次。
  2. 避免在原型链上频繁查找属性
    • 如果一个对象需要频繁访问某个属性,并且这个属性在原型链上层次较深,可以考虑将该属性直接定义在对象自身。例如:
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 属性,提高了访问效率。
  1. 使用 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() 直接基于给定的原型创建对象,避免了构造函数可能带来的一些不必要的开销。
  1. 缓存原型链上的属性和方法
    • 如果在代码中多次访问原型链上的某个属性或方法,可以将其缓存起来。例如:
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 方法时,不需要每次都沿着原型链查找,提高了执行效率。

原型链与性能分析

  1. 属性查找性能
    • 原型链的长度对属性查找性能有显著影响。较长的原型链意味着更多的查找步骤,从而增加查找时间。例如,通过性能测试可以验证这一点:
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 方法(在原型链上)的时间。如果原型链更长,这个时间会明显增加。
  1. 内存占用
    • 原型链的共享特性虽然节省内存,但如果原型链上的对象包含大量数据,也可能导致内存占用过高。例如,如果 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();
  • 虽然 dog1dog2 共享 Animal.prototype.bigArray,节省了内存空间,但这个大数组仍然占用了较多内存。在这种情况下,需要考虑是否有必要将这个大数组放在原型链上,或者对其进行更合理的处理,比如按需加载。
  1. 优化对性能的影响
    • 应用前面提到的优化策略,可以显著提升性能。例如,使用 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 特性的结合

  1. ES6 类与原型链
    • ES6 引入了 class 关键字,它提供了更简洁的面向对象编程语法。但实际上,class 仍然基于原型链。例如:
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 继承自 AnimalmyDog 的原型链结构与传统方式创建的继承结构类似:myDog -> Dog.prototype -> Animal.prototype -> Object.prototype -> nullclass 语法只是在原型链的基础上提供了更直观的代码结构。
  1. 箭头函数与原型链
    • 箭头函数没有自己的 thisargumentssupernew.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

原型链在实际项目中的应用案例

  1. 代码复用与模块化
    • 在大型项目中,通过原型链实现代码复用和模块化。例如,在一个游戏开发项目中,可能有一个 GameObject 基类,其他如 CharacterItem 等类继承自 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 继承了 GameObjectmove 方法,实现了代码复用,同时添加了自己的 speak 方法,符合模块化开发的思想。
  1. 插件系统
    • 一些 JavaScript 插件库利用原型链来实现插件的扩展。例如,一个 DOM 操作库可能有一个基础的 DOMElement 类,插件可以通过继承这个类来添加新的功能。
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 动画功能,实现了插件式的扩展。

原型链相关的常见错误与调试方法

  1. 原型链污染
    • 原型链污染是一种安全漏洞,攻击者可以通过修改原型链上的属性,影响整个应用程序。例如:
// 恶意代码
Object.prototype.newProp = '恶意属性';

function MyClass() {}

let myObj = new MyClass();
console.log(myObj.newProp); // 输出: 恶意属性
  • 这里恶意代码在 Object.prototype 上添加了 newProp 属性,导致所有对象都可以访问这个属性。为了防止原型链污染,要避免随意修改 Object.prototype 等全局原型对象。
  1. 错误的原型链继承
    • 在实现继承时,可能会出现错误的原型链设置。例如,忘记设置 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);
  • 通过打印原型链,可以清楚地看到继承关系是否正确,从而找出错误。
  1. 属性覆盖问题
    • 当在实例上直接定义与原型链上同名的属性时,可能会导致意外的行为。例如:
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 开发者必须掌握的核心知识之一。