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

JavaScript prototype特性的底层机制

2022-06-245.4k 阅读

JavaScript prototype特性的底层机制

一、JavaScript中的对象与原型

在JavaScript中,对象是一种复合数据类型,它可以包含零个或多个键值对。每个对象都有一个与之关联的原型对象。原型对象本身也是一个对象,它为其关联的对象提供了属性和方法的共享机制。

(一)创建对象与原型关联

  1. 字面量方式创建对象 通过字面量方式创建的对象,其原型指向 Object.prototype。例如:
let obj = {name: 'John'};
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true

这里,Object.getPrototypeOf 方法用于获取对象的原型。从代码可以看出,通过字面量创建的 obj 对象,其原型就是 Object.prototype

  1. 构造函数方式创建对象 当使用构造函数创建对象时,新创建的对象的原型会指向构造函数的 prototype 属性。比如:
function Person(name) {
    this.name = name;
}
let person = new Person('Jane');
console.log(Object.getPrototypeOf(person) === Person.prototype); // true

在上述代码中,Person 是一个构造函数,使用 new 关键字创建的 person 对象,其原型指向 Person.prototype

二、Prototype 链的形成

原型链是JavaScript实现继承和属性查找的重要机制。当访问一个对象的属性或方法时,如果该对象本身没有这个属性或方法,JavaScript会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的顶端(null)。

(一)原型链示例

function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};

function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

let myDog = new Dog('Buddy', 'Golden Retriever');

// 查找 speak 方法
myDog.speak(); // Buddy makes a sound.

在这个例子中,myDog 对象首先在自身查找 speak 方法,没有找到。然后沿着原型链向上,在 Dog.prototype 中没有找到,继续向上在 Animal.prototype 中找到了 speak 方法并执行。

(二)原型链的顶端

原型链的顶端是 null。当在原型链上查找属性或方法,一直到顶端都没有找到时,返回 undefined。例如:

let emptyObj = {};
console.log(emptyObj.someNonexistentMethod); // undefined

这里 emptyObj 对象沿着原型链查找 someNonexistentMethod,一直到 Object.prototype 的原型(null)都未找到,所以返回 undefined

三、Prototype特性的底层存储与实现

在JavaScript引擎内部,对象的原型关系是通过内部属性 [[Prototype]] 来实现的。这个内部属性在JavaScript代码中不能直接访问,但可以通过 Object.getPrototypeOfObject.setPrototypeOf 方法间接操作。

(一)[[Prototype]] 的存储

不同的JavaScript引擎可能有不同的方式来存储 [[Prototype]]。在V8引擎中,对象在内存中以一种称为“隐藏类(Hidden Class)”的数据结构来表示。每个对象都有一个指向其隐藏类的指针,隐藏类记录了对象的布局信息,包括属性的偏移量等。而 [[Prototype]] 则是对象结构的一部分。

(二)属性查找与 [[Prototype]]

当进行属性查找时,JavaScript引擎首先在对象自身的属性列表中查找。如果没有找到,就会通过 [[Prototype]] 指针找到原型对象,然后在原型对象的属性列表中查找,依此类推沿着原型链进行查找。例如:

function Parent() {
    this.parentProp = 'parent value';
}
function Child() {
    this.childProp = 'child value';
}
Child.prototype = Object.create(Parent.prototype);

let childObj = new Child();
// 查找 parentProp 属性
console.log(childObj.parentProp); // parent value

在这个例子中,childObj 对象自身没有 parentProp 属性,引擎通过 [[Prototype]] 找到 Child.prototype,再通过 Child.prototype[[Prototype]] 找到 Parent.prototype,从而找到了 parentProp 属性。

四、Prototype与构造函数的关系

构造函数的 prototype 属性是一个对象,这个对象将成为通过该构造函数创建的所有对象的原型。

(一)构造函数的 prototype 属性

function Circle(radius) {
    this.radius = radius;
}
Circle.prototype.getArea = function() {
    return Math.PI * this.radius * this.radius;
};

let circle = new Circle(5);
console.log(circle.getArea()); // 78.53981633974483

在上述代码中,Circle 构造函数的 prototype 属性上定义了 getArea 方法。通过 Circle 构造函数创建的 circle 对象可以访问到这个方法,因为 circle 的原型指向 Circle.prototype

(二)constructor 属性

在每个构造函数的 prototype 对象上,都有一个 constructor 属性,它指向该构造函数本身。例如:

function Square(side) {
    this.side = side;
}
console.log(Square.prototype.constructor === Square); // true

这个 constructor 属性在需要知道对象是由哪个构造函数创建时很有用。但需要注意的是,当手动修改原型对象时,可能需要手动修正 constructor 属性。例如:

function Rectangle(width, height) {
    this.width = width;
    this.height = height;
}
Rectangle.prototype.getArea = function() {
    return this.width * this.height;
};

function Square2(side) {
    Rectangle.call(this, side, side);
}
Square2.prototype = Object.create(Rectangle.prototype);
// 修正 constructor 属性
Square2.prototype.constructor = Square2;

let square = new Square2(5);
console.log(square.constructor === Square2); // true

如果不修正 constructor 属性,square.constructor 将指向 Rectangle,这可能会导致一些混淆。

五、Prototype在继承中的应用

JavaScript通过原型链实现继承,这使得代码可以复用已有对象的属性和方法。

(一)原型式继承

let personPrototype = {
    name: 'Default Name',
    greet: function() {
        console.log('Hello, I\'m'+ this.name);
    }
};

let person1 = Object.create(personPrototype);
person1.name = 'John';
person1.greet(); // Hello, I'm John

在这个例子中,person1 通过 Object.create 方法以 personPrototype 为原型创建。person1 可以访问 personPrototypegreet 方法,并且有自己的 name 属性。

(二)经典继承模式(构造函数 + 原型链)

function Shape(color) {
    this.color = color;
}
Shape.prototype.getColor = function() {
    return this.color;
};

function Rectangle(width, height, color) {
    Shape.call(this, color);
    this.width = width;
    this.height = height;
}
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;
Rectangle.prototype.getArea = function() {
    return this.width * this.height;
};

let rect = new Rectangle(5, 10,'red');
console.log(rect.getColor()); // red
console.log(rect.getArea()); // 50

这里 Rectangle 继承自 Shape,通过构造函数 Shape.call(this, color) 继承了 Shape 的属性,通过原型链 Rectangle.prototype = Object.create(Shape.prototype) 继承了 Shape 的方法。

六、Prototype的动态性

JavaScript中对象的原型是动态的,可以在运行时进行修改。

(一)修改现有对象的原型

function Vehicle() {
    this.wheels = 4;
}
Vehicle.prototype.move = function() {
    console.log('Moving with'+ this.wheels +'wheels.');
};

function Bicycle() {
    this.wheels = 2;
}
let bike = new Bicycle();
// 动态修改 bike 的原型
Object.setPrototypeOf(bike, Vehicle.prototype);
bike.move(); // Moving with 2 wheels.

在上述代码中,bike 原本是 Bicycle 的实例,通过 Object.setPrototypeOf 方法将其原型修改为 Vehicle.prototype,从而可以调用 Vehicle.prototype 上的 move 方法。

(二)对原型的修改影响所有相关对象

当对原型对象进行修改时,所有以该原型为基础的对象都会受到影响。例如:

function Animal2() {}
Animal2.prototype.speak = function() {
    console.log('I\'m an animal.');
};

let cat = new Animal2();
let dog = new Animal2();

Animal2.prototype.speak = function() {
    console.log('I\'m a different animal.');
};

cat.speak(); // I'm a different animal.
dog.speak(); // I'm a different animal.

这里对 Animal2.prototypespeak 方法进行了修改,catdog 作为以 Animal2.prototype 为原型的对象,都受到了影响。

七、Prototype与性能

原型链的查找机制虽然强大,但也可能会带来性能问题,尤其是在原型链较长或者频繁进行属性查找时。

(一)原型链长度与查找性能

原型链越长,属性查找所需的时间就越长。因为每次查找都需要沿着原型链一级一级向上查找。例如:

function A() {}
function B() {}
function C() {}
function D() {}

B.prototype = Object.create(A.prototype);
C.prototype = Object.create(B.prototype);
D.prototype = Object.create(C.prototype);

let d = new D();
// 查找一个属性,假设在 A.prototype 上定义
console.time('lookup');
d.someProp;
console.timeEnd('lookup');

在这个例子中,如果 someProp 定义在 A.prototype 上,从 d 对象查找该属性需要经过较长的原型链,性能相对较差。

(二)避免不必要的原型链查找

为了提高性能,可以尽量避免在原型链上进行深层次的查找。一种方法是在对象自身定义常用的属性和方法,而不是依赖原型链。例如:

function User(name) {
    this.name = name;
    // 直接在对象自身定义方法,避免原型链查找
    this.greet = function() {
        console.log('Hello, I\'m'+ this.name);
    };
}

let user = new User('Alice');
user.greet();

这样,调用 user.greet() 时就不需要进行原型链查找,提高了性能。

八、Prototype相关的常见问题与误解

在使用JavaScript的 prototype 特性时,有一些常见的问题和误解需要注意。

(一)this 在原型方法中的指向

在原型方法中,this 指向调用该方法的对象。例如:

function Counter() {
    this.count = 0;
}
Counter.prototype.increment = function() {
    this.count++;
    console.log(this.count);
};

let counter = new Counter();
counter.increment(); // 1

这里 increment 方法中的 this 指向 counter 对象,所以可以正确地修改 count 属性。但如果不小心改变了 this 的指向,就可能导致错误。例如:

function Logger() {
    this.message = 'Default message';
}
Logger.prototype.log = function() {
    console.log(this.message);
};

let logger = new Logger();
let logFunction = logger.log;
// 这里 logFunction 中的 this 不再指向 logger
logFunction(); // undefined

在这个例子中,将 logger.log 赋值给 logFunction 后,logFunction 中的 this 不再指向 logger,而是指向全局对象(在严格模式下为 undefined),导致 this.messageundefined

(二)原型对象的共享性

由于原型对象是共享的,对原型对象属性的修改可能会影响到所有相关对象。例如:

function Person2() {}
Person2.prototype.hobbies = [];

let personA = new Person2();
let personB = new Person2();

personA.hobbies.push('reading');
console.log(personB.hobbies); // ['reading']

这里 personApersonB 共享 Person2.prototype.hobbies 数组,所以 personAhobbies 的修改也影响到了 personB。如果不希望这种共享,可以在构造函数中初始化属性,而不是在原型上。例如:

function Person3() {
    this.hobbies = [];
}

let personC = new Person3();
let personD = new Person3();

personC.hobbies.push('swimming');
console.log(personD.hobbies); // []

这样每个对象都有自己独立的 hobbies 数组。

九、Prototype与ES6 Classes

ES6引入了 class 语法,它在语法上更简洁,并且看起来更像传统的面向对象编程语言。但实际上,class 只是基于原型的继承的语法糖。

(一)Class 语法中的原型

class Shape2 {
    constructor(color) {
        this.color = color;
    }
    getColor() {
        return this.color;
    }
}

class Rectangle2 extends Shape2 {
    constructor(width, height, color) {
        super(color);
        this.width = width;
        this.height = height;
    }
    getArea() {
        return this.width * this.height;
    }
}

let rect2 = new Rectangle2(4, 6, 'blue');
console.log(rect2.getColor()); // blue
console.log(rect2.getArea()); // 24

在这个例子中,Rectangle2 继承自 Shape2。虽然使用了 class 语法,但内部仍然是通过原型链实现继承。Rectangle2.prototype 仍然是一个对象,并且其 [[Prototype]] 指向 Shape2.prototype

(二)Class 与传统原型继承的对比

class 语法更符合人类的思维习惯,代码更易读。但从底层实现来看,和传统的原型继承没有本质区别。例如,传统的原型继承方式:

function Shape3(color) {
    this.color = color;
}
Shape3.prototype.getColor = function() {
    return this.color;
};

function Rectangle3(width, height, color) {
    Shape3.call(this, color);
    this.width = width;
    this.height = height;
}
Rectangle3.prototype = Object.create(Shape3.prototype);
Rectangle3.prototype.constructor = Rectangle3;
Rectangle3.prototype.getArea = function() {
    return this.width * this.height;
};

let rect3 = new Rectangle3(3, 5, 'green');
console.log(rect3.getColor()); // green
console.log(rect3.getArea()); // 15

对比这两种方式,可以发现它们实现的功能是一样的,只是 class 语法更加简洁明了。

十、Prototype特性在实际项目中的应用场景

在实际的JavaScript项目中,prototype 特性有很多应用场景。

(一)代码复用

通过原型链继承,可以复用已有对象的属性和方法。例如,在一个游戏开发项目中,可能有一个 GameObject 基类,包含一些通用的属性和方法,如位置、大小、渲染方法等。然后不同的具体游戏对象,如 PlayerEnemy 等可以继承自 GameObject,复用这些属性和方法,减少代码冗余。

class GameObject {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }
    render() {
        console.log('Rendering game object at (' + this.x + ','+ this.y + ')');
    }
}

class Player extends GameObject {
    constructor(x, y, width, height, name) {
        super(x, y, width, height);
        this.name = name;
    }
    move() {
        console.log(this.name +'is moving.');
    }
}

let player = new Player(10, 10, 50, 50, 'John');
player.render(); // Rendering game object at (10, 10)
player.move(); // John is moving.

(二)插件开发

在开发JavaScript插件时,可以利用原型特性来提供通用的功能。例如,开发一个DOM操作插件,可能有一个基础的 DOMPlugin 类,定义一些基本的DOM操作方法,如选择元素、添加样式等。然后不同的具体插件功能可以继承自 DOMPlugin,扩展其功能。

class DOMPlugin {
    constructor() {}
    selectElement(selector) {
        return document.querySelectorAll(selector);
    }
    addClass(element, className) {
        element.classList.add(className);
    }
}

class ImagePlugin extends DOMPlugin {
    constructor() {
        super();
    }
    loadImage(src) {
        let img = new Image();
        img.src = src;
        return img;
    }
}

let imagePlugin = new ImagePlugin();
let images = imagePlugin.selectElement('img');
let newImage = imagePlugin.loadImage('example.jpg');

通过深入理解JavaScript prototype 特性的底层机制,可以更好地编写高效、可维护的JavaScript代码,在各种项目中灵活运用这一特性来实现代码复用、继承等功能。