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

JavaScript prototype特性的设计原则

2022-10-136.8k 阅读

JavaScript prototype 特性概述

在 JavaScript 中,prototype 是一个极为重要的特性,它构建了 JavaScript 独特的基于原型的继承机制。与传统基于类的面向对象语言不同,JavaScript 没有类的概念,而是通过原型对象来实现对象之间属性和方法的共享。

每个函数在创建时,JavaScript 引擎会自动为其添加一个 prototype 属性,这个属性指向一个对象,我们称之为原型对象。例如:

function Person() {}
console.log(Person.prototype);

上述代码中,定义了一个 Person 函数,通过 console.log(Person.prototype) 可以看到 Person 函数的原型对象。这个原型对象默认有一个 constructor 属性,它指向创建该原型对象的函数,即:

function Person() {}
console.log(Person.prototype.constructor === Person); // true

当使用 new 关键字调用构造函数创建实例时,实例对象会通过内部的 [[Prototype]] (在现代 JavaScript 中可以通过 __proto__ 属性访问,虽然 __proto__ 并非标准的用于原型操作的属性,但广泛可用)链接到构造函数的原型对象。比如:

function Person() {}
let person1 = new Person();
console.log(person1.__proto__ === Person.prototype); // true

设计原则之属性查找与共享

  1. 属性查找机制
    • 当访问一个对象的属性时,JavaScript 会首先在对象自身上查找该属性。如果没有找到,就会沿着 [[Prototype]] 链向上查找,直到找到该属性或者到达原型链的顶端(null)。例如:
function Animal() {
    this.species = 'animal';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};
function Dog() {
    this.name = 'Buddy';
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
let myDog = new Dog();
console.log(myDog.name); // 'Buddy',在自身找到
console.log(myDog.getSpecies()); // 'animal',在原型链上找到
- 在上述代码中,`myDog` 实例对象自身有 `name` 属性,所以直接返回 `'Buddy'`。而 `getSpecies` 方法并不在 `myDog` 自身,JavaScript 会沿着原型链在 `Animal.prototype` 上找到该方法并执行。

2. 属性共享 - 原型对象的设计使得多个对象实例可以共享属性和方法。这极大地节省了内存,因为相同的属性和方法不需要在每个实例上重复创建。以之前的 AnimalDog 为例,所有 Dog 实例共享 Animal.prototype 上的 getSpecies 方法。如果修改原型对象上的属性或方法,所有基于该原型的实例都会受到影响。例如:

function Circle() {
    this.radius = 5;
}
Circle.prototype.getArea = function() {
    return Math.PI * this.radius * this.radius;
};
let circle1 = new Circle();
let circle2 = new Circle();
console.log(circle1.getArea()); // 计算面积
Circle.prototype.getArea = function() {
    return Math.PI * this.radius ** 2;
};
console.log(circle2.getArea()); // 新的计算方式,circle2 也受影响
- 这里先定义了 `Circle` 构造函数及其原型上的 `getArea` 方法。创建 `circle1` 和 `circle2` 实例后,修改了 `Circle.prototype` 上的 `getArea` 方法,`circle2` 调用 `getArea` 时就会使用新的方法逻辑。

设计原则之动态性

  1. 原型的动态修改
    • JavaScript 的原型是动态的,这意味着可以在运行时修改原型对象。这种动态性提供了极大的灵活性。比如,我们可以在创建实例之后为原型对象添加新的属性或方法,新添加的内容对已创建的实例同样有效。
function Bird() {
    this.color = 'blue';
}
let bird1 = new Bird();
Bird.prototype.fly = function() {
    console.log('I can fly!');
};
bird1.fly(); // 'I can fly!'
- 在上述代码中,先创建了 `bird1` 实例,之后才为 `Bird.prototype` 添加了 `fly` 方法,`bird1` 依然可以调用这个新添加的方法。

2. 动态原型模式 - 动态原型模式是一种利用原型动态性的设计模式。它允许在构造函数内部根据条件动态地初始化原型。例如:

function Product(name, price) {
    this.name = name;
    this.price = price;
    if (typeof this.getDetails!== 'function') {
        Product.prototype.getDetails = function() {
            return `Name: ${this.name}, Price: ${this.price}`;
        };
    }
}
let product1 = new Product('Book', 10);
console.log(product1.getDetails());
- 在 `Product` 构造函数中,通过检查 `this.getDetails` 是否为函数来决定是否初始化原型上的 `getDetails` 方法。这样,只有在需要时才会为原型添加方法,避免了不必要的初始化。

设计原则之原型链的构建

  1. 显式构建原型链
    • 可以通过 Object.create 方法显式地构建原型链。Object.create 方法创建一个新对象,新对象的 [[Prototype]] 会被设置为指定的对象。例如:
let animalProto = {
    move: function() {
        console.log('Moving...');
    }
};
let dogProto = Object.create(animalProto);
dogProto.bark = function() {
    console.log('Woof!');
};
let myDog = Object.create(dogProto);
myDog.bark(); // 'Woof!'
myDog.move(); // 'Moving...'
- 这里首先创建了 `animalProto` 对象,然后通过 `Object.create` 以 `animalProto` 为原型创建了 `dogProto` 对象,最后又以 `dogProto` 为原型创建了 `myDog` 对象。这样就构建了一条原型链 `myDog -> dogProto -> animalProto`。

2. 构造函数与原型链 - 通常使用构造函数和原型对象来构建原型链。通过将一个构造函数的原型对象设置为另一个构造函数的实例,可以建立继承关系。例如:

function Shape() {
    this.color = 'black';
}
Shape.prototype.getColor = function() {
    return this.color;
};
function Rectangle() {
    this.width = 10;
    this.height = 5;
}
Rectangle.prototype = new Shape();
Rectangle.prototype.constructor = Rectangle;
Rectangle.prototype.getArea = function() {
    return this.width * this.height;
};
let rect = new Rectangle();
console.log(rect.getColor()); // 'black'
console.log(rect.getArea()); // 50
- 在这个例子中,`Rectangle` 构造函数的原型被设置为 `Shape` 构造函数的实例,从而 `Rectangle` 实例可以访问 `Shape` 原型上的 `getColor` 方法,同时 `Rectangle` 自身也有独特的 `getArea` 方法。

设计原则之兼容性与可维护性

  1. 兼容性考虑
    • 在使用 prototype 特性时,需要考虑不同 JavaScript 环境的兼容性。虽然现代浏览器对原型相关的特性支持良好,但在一些老旧环境中可能存在差异。例如,__proto__ 属性并非所有环境都支持,虽然它提供了一种便捷访问对象 [[Prototype]] 的方式。更好的做法是使用标准的 Object.getPrototypeOfObject.setPrototypeOf 方法来操作对象的原型。
function Car() {}
let car1 = new Car();
// 使用 __proto__(非标准,但广泛可用)
console.log(car1.__proto__ === Car.prototype);
// 使用标准方法
console.log(Object.getPrototypeOf(car1) === Car.prototype);
  1. 可维护性
    • 为了保证代码的可维护性,在操作原型时应遵循一定的规范。避免在多个地方随意修改原型对象,尽量将原型相关的操作集中在构造函数定义附近或者专门的原型初始化函数中。例如:
function Employee(name, salary) {
    this.name = name;
    this.salary = salary;
}
function initEmployeePrototype() {
    Employee.prototype.getDetails = function() {
        return `Name: ${this.name}, Salary: ${this.salary}`;
    };
    Employee.prototype.getSalaryAfterTax = function() {
        return this.salary * 0.8;
    };
}
initEmployeePrototype();
let emp1 = new Employee('John', 5000);
console.log(emp1.getDetails());
console.log(emp1.getSalaryAfterTax());
- 通过将原型方法的初始化放在 `initEmployeePrototype` 函数中,使得代码结构更清晰,易于维护和理解。

设计原则之性能考量

  1. 原型链长度与查找性能
    • 原型链的长度会影响属性查找的性能。当查找一个属性时,JavaScript 引擎需要沿着原型链逐个查找,原型链越长,查找所需的时间就越长。例如:
function A() {}
function B() {}
function C() {}
function D() {}
B.prototype = new A();
C.prototype = new B();
D.prototype = new C();
let d = new D();
// 查找一个属性,假设属性在 A.prototype 上
console.time('lookup');
d.someProperty;
console.timeEnd('lookup');
- 在这个例子中,如果 `someProperty` 在 `A.prototype` 上,查找该属性需要经过 `D -> C -> B -> A` 这样较长的原型链,相比短原型链,性能会有所下降。

2. 缓存与优化 - 为了优化性能,可以对经常访问的属性或方法进行缓存。例如,如果一个对象经常调用原型链上的某个方法,可以将该方法缓存到对象自身。

function MathUtils() {}
MathUtils.prototype.calculateSquare = function(num) {
    return num * num;
};
let utils = new MathUtils();
// 缓存方法
utils.calculateSquareCached = utils.calculateSquare;
// 后续调用缓存的方法
console.time('cachedCall');
utils.calculateSquareCached(5);
console.timeEnd('cachedCall');
- 这里将 `MathUtils.prototype` 上的 `calculateSquare` 方法缓存到 `utils` 对象自身,后续调用缓存的方法可以减少原型链查找的开销,提高性能。

设计原则之与其他特性的结合

  1. 与闭包结合
    • 原型与闭包可以很好地结合使用。闭包可以访问其外部函数作用域中的变量,通过在原型方法中使用闭包,可以实现一些独特的功能。例如:
function Counter() {
    let count = 0;
    this.getCount = function() {
        return count;
    };
    Counter.prototype.increment = function() {
        count++;
    };
}
let counter1 = new Counter();
counter1.increment();
console.log(counter1.getCount()); // 1
- 在 `Counter` 构造函数中,`count` 变量形成了一个闭包。`Counter.prototype` 上的 `increment` 方法可以访问并修改这个闭包中的 `count` 变量,同时通过 `getCount` 方法可以获取 `count` 的值。

2. 与 ES6 类结合 - ES6 引入了 class 关键字,它在语法上更接近传统的基于类的面向对象语言,但本质上还是基于原型的。class 只是对原型机制的一种语法糖。例如:

class Animal {
    constructor() {
        this.species = 'animal';
    }
    getSpecies() {
        return this.species;
    }
}
class Dog extends Animal {
    constructor(name) {
        super();
        this.name = name;
    }
    bark() {
        console.log('Woof!');
    }
}
let myDog = new Dog('Buddy');
console.log(myDog.getSpecies()); // 'animal'
myDog.bark(); // 'Woof!'
- 这里 `class Dog extends Animal` 实际上是通过原型链实现继承。`Dog` 的原型对象是 `Animal` 的实例,`Dog` 实例可以访问 `Animal` 原型上的 `getSpecies` 方法。

设计原则之错误处理

  1. 原型属性覆盖导致的错误
    • 在操作原型时,可能会因为意外覆盖原型属性而导致错误。例如,不小心在实例对象上定义了与原型属性同名的属性,会屏蔽原型上的属性。
function Person() {
    this.name = 'John';
}
Person.prototype.getName = function() {
    return this.name;
};
let person1 = new Person();
person1.getName = function() {
    return 'Overridden';
};
console.log(person1.getName()); // 'Overridden',原型方法被屏蔽
- 在这个例子中,`person1` 实例对象自身定义了 `getName` 方法,屏蔽了 `Person.prototype` 上的 `getName` 方法,这可能并非开发者本意。为避免这种情况,应仔细检查属性命名,特别是在给实例对象添加属性时。

2. 原型链断裂导致的错误 - 如果不小心破坏了原型链,会导致对象无法访问原型上的属性和方法。例如:

function Shape() {
    this.color = 'black';
}
Shape.prototype.getColor = function() {
    return this.color;
};
function Rectangle() {
    this.width = 10;
    this.height = 5;
}
Rectangle.prototype = {
    constructor: Rectangle,
    getArea: function() {
        return this.width * this.height;
    }
};
let rect = new Rectangle();
// rect.getColor(); // 报错,原型链被破坏,无法找到 getColor 方法
- 在上述代码中,`Rectangle.prototype` 被重新赋值为一个新对象,而没有通过 `Object.create` 等方式正确继承 `Shape.prototype`,导致原型链断裂,`rect` 实例无法访问 `Shape.prototype` 上的 `getColor` 方法。为避免原型链断裂,应使用正确的方式设置原型,如 `Rectangle.prototype = Object.create(Shape.prototype);` 并正确设置 `constructor` 属性。

通过深入理解这些关于 JavaScript prototype 特性的设计原则,开发者可以更好地利用原型机制,编写出更高效、更健壮、更易于维护的 JavaScript 代码。无论是小型项目还是大型复杂的应用,合理运用 prototype 特性都能带来显著的优势。同时,随着 JavaScript 语言的不断发展,对 prototype 的理解也有助于更好地掌握新的语言特性和编程模式。