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

JavaScript原型链中的属性查找机制

2022-01-286.6k 阅读

JavaScript 原型链基础回顾

在深入探讨属性查找机制之前,让我们先来回顾一下 JavaScript 原型链的基础知识。JavaScript 是一种基于原型的语言,这意味着对象可以从其他对象继承属性和方法。每个对象都有一个内部属性 [[Prototype]],它指向该对象的原型对象。

通过 Object.getPrototypeOf() 方法或者在现代 JavaScript 中使用 __proto__(注意 __proto__ 是非标准属性,主要用于调试和学习),我们可以访问对象的原型。例如:

let obj = {};
let proto = Object.getPrototypeOf(obj);
console.log(proto === Object.prototype); // true

在这里,我们创建了一个空对象 obj,然后通过 Object.getPrototypeOf() 获取它的原型。在 JavaScript 中,普通对象的原型默认是 Object.prototype

函数对象则有一个特殊的属性 prototype。当我们使用构造函数创建对象时,新创建的对象的 [[Prototype]] 会指向构造函数的 prototype 属性。例如:

function Person(name) {
    this.name = name;
}
let person = new Person('John');
let personProto = Object.getPrototypeOf(person);
console.log(personProto === Person.prototype); // true

这里,Person 是一个构造函数,通过 new 关键字创建的 person 对象的原型指向 Person.prototype

原型链的形成

原型链是由一系列原型对象连接而成的。当我们访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript 会沿着原型链向上查找,直到找到该属性或者到达原型链的顶端(null)。

例如,我们继续上面 Person 的例子:

Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};
person.sayHello(); // Hello, I'm John

这里,person 对象本身并没有 sayHello 方法,但是由于它的原型 Person.prototype 有这个方法,所以 person.sayHello() 能够正常调用。

再看一个更复杂的原型链示例:

function Animal() {}
Animal.prototype.move = function() {
    console.log('I can move');
};

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

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

let puppy = new Puppy('Buddy', 2);
puppy.move(); // I can move

在这个例子中,Puppy 继承自 DogDog 继承自 Animal。当我们调用 puppy.move() 时,puppy 对象本身没有 move 方法,所以会沿着原型链向上查找,先找到 Dog.prototype,再找到 Animal.prototype,最终找到 move 方法并执行。

属性查找机制的本质

基本查找流程

当我们尝试访问对象的一个属性时,JavaScript 首先会在对象自身的属性中查找。如果找到了,就直接返回该属性的值。例如:

let obj = {
    property: 'value'
};
console.log(obj.property); // value

这里,obj 对象自身有 property 属性,所以直接返回其值。

如果对象自身没有该属性,JavaScript 会沿着原型链向上查找。它会检查对象的 [[Prototype]] 指向的原型对象是否有该属性。如果原型对象有该属性,就返回该属性的值。如果原型对象也没有,就继续向上查找,直到到达原型链的顶端(null)。如果到 null 都没有找到,就返回 undefined。例如:

let obj1 = {};
let obj2 = Object.create(obj1);
obj1.property = 'value';
console.log(obj2.property); // value

在这个例子中,obj2 对象自身没有 property 属性,但是它的原型 obj1 有,所以能找到并返回值。

遮蔽效应

当对象自身的属性和原型对象的属性同名时,对象自身的属性会遮蔽原型对象的属性。也就是说,JavaScript 会优先返回对象自身的属性值。例如:

let proto = {
    property: 'proto value'
};
let obj = Object.create(proto);
obj.property = 'obj value';
console.log(obj.property); // obj value

这里,obj 对象自身的 property 属性遮蔽了原型 protoproperty 属性,所以返回 obj 对象自身的属性值。

继承属性的可枚举性

在原型链查找属性时,可枚举性也会影响属性的查找结果。通过 Object.defineProperty() 定义的属性,默认情况下是不可枚举的。例如:

let proto = {};
Object.defineProperty(proto, 'hiddenProperty', {
    value: 'hidden',
    enumerable: false
});
let obj = Object.create(proto);
for (let key in obj) {
    console.log(key); // 这里不会输出 hiddenProperty
}

在这个例子中,hiddenProperty 虽然存在于原型 proto 上,但是由于它不可枚举,所以在 for...in 循环中不会被枚举出来。

函数调用与原型链属性查找

当函数作为对象的方法被调用时,this 的值会绑定到调用该方法的对象。在这种情况下,属性查找机制同样会起作用。

例如:

function greet() {
    console.log(`Hello, ${this.name}`);
}
let person = {
    name: 'Alice',
    greet: greet
};
person.greet(); // Hello, Alice

这里,greet 函数被赋值给 person 对象的 greet 属性。当 person.greet() 被调用时,this 绑定到 person 对象,name 属性是在 person 对象自身查找的。

再看一个涉及原型链的例子:

function Animal() {}
Animal.prototype.speak = function() {
    console.log('I am an animal');
};

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

Dog.prototype.bark = function() {
    console.log(`${this.name} barks`);
};

let myDog = new Dog('Max');
myDog.bark(); // Max barks
myDog.speak(); // I am an animal

在这个例子中,myDog 调用 bark 方法时,bark 方法存在于 Dog.prototype 上,this 绑定到 myDogname 属性在 myDog 对象自身查找。而调用 speak 方法时,speak 方法存在于 Animal.prototype 上,this 同样绑定到 myDogthis.name 依然是在 myDog 对象自身查找。

原型链属性查找与性能

原型链的属性查找机制虽然强大,但也可能带来性能问题。因为每次属性查找都可能需要沿着原型链向上遍历,这在原型链较长或者频繁查找属性的情况下会影响性能。

例如,考虑以下代码:

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

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

let e = new E();
// 查找一个在 A.prototype 上的属性,需要遍历较长的原型链
console.log(e.someProperty); 

在这个例子中,如果 someProperty 定义在 A.prototype 上,从 e 对象查找该属性需要遍历 E.prototype -> D.prototype -> C.prototype -> B.prototype -> A.prototype,这会消耗一定的时间。

为了优化性能,可以尽量减少原型链的长度,避免在原型链上频繁查找属性。例如,可以将一些常用的属性直接定义在对象自身,而不是依赖原型链查找。

原型链属性查找的特殊情况

hasOwnProperty 方法

hasOwnProperty 方法用于判断对象自身是否有某个属性,而不会查找原型链。例如:

let proto = {
    property: 'proto value'
};
let obj = Object.create(proto);
console.log(obj.hasOwnProperty('property')); // false
obj.property = 'obj value';
console.log(obj.hasOwnProperty('property')); // true

通过 hasOwnProperty,我们可以准确判断属性是在对象自身还是在原型链上。

in 操作符

in 操作符用于检查对象或者其原型链上是否存在某个属性。例如:

let proto = {
    property: 'proto value'
};
let obj = Object.create(proto);
console.log('property' in obj); // true

这里,虽然 obj 对象自身没有 property 属性,但是由于其原型 proto 有,所以 in 操作符返回 true

Object.keysObject.getOwnPropertyNames

Object.keys 方法返回对象自身可枚举属性的键组成的数组,不会包含原型链上的属性。例如:

let proto = {
    hiddenProperty: 'hidden'
};
let obj = Object.create(proto);
obj.property = 'value';
console.log(Object.keys(obj)); // ['property']

Object.getOwnPropertyNames 方法返回对象自身所有属性(包括不可枚举属性)的键组成的数组,同样不会包含原型链上的属性。例如:

let obj = {};
Object.defineProperty(obj, 'hiddenProperty', {
    value: 'hidden',
    enumerable: false
});
console.log(Object.getOwnPropertyNames(obj)); // ['hiddenProperty']

这些方法在处理对象属性时,与原型链属性查找有着明确的界限,帮助开发者更精确地操作对象自身的属性。

原型链属性查找在实际开发中的应用

代码复用

原型链属性查找使得代码复用变得非常方便。通过继承,我们可以在多个对象之间共享属性和方法。例如,在开发一个游戏中,可能有多个角色类,它们都继承自一个基础的 Character 类,共享诸如 moveattack 等方法。

function Character() {
    this.health = 100;
}
Character.prototype.move = function() {
    console.log('Character is moving');
};

function Warrior() {
    Character.call(this);
    this.weapon = 'Sword';
}
Warrior.prototype = Object.create(Character.prototype);
Warrior.prototype.constructor = Warrior;

function Mage() {
    Character.call(this);
    this.spell = 'Fireball';
}
Mage.prototype = Object.create(Character.prototype);
Mage.prototype.constructor = Mage;

let warrior = new Warrior();
let mage = new Mage();
warrior.move(); // Character is moving
mage.move(); // Character is moving

这里,WarriorMage 类都继承自 Character 类,复用了 move 方法,减少了代码冗余。

插件和库的开发

在开发插件和库时,原型链属性查找也经常被用到。例如,开发一个 DOM 操作库,可能会定义一个基础的 DOMElement 类,其他特定的元素类如 ButtonInput 等继承自它,共享一些通用的操作方法,如 addClassremoveClass 等。

function DOMElement(tagName) {
    this.element = document.createElement(tagName);
}
DOMElement.prototype.addClass = function(className) {
    this.element.classList.add(className);
};
DOMElement.prototype.removeClass = function(className) {
    this.element.classList.remove(className);
};

function Button() {
    DOMElement.call(this, 'button');
}
Button.prototype = Object.create(DOMElement.prototype);
Button.prototype.constructor = Button;

function Input() {
    DOMElement.call(this, 'input');
}
Input.prototype = Object.create(DOMElement.prototype);
Input.prototype.constructor = Input;

let button = new Button();
let input = new Input();
button.addClass('active');
input.addClass('form-control');

通过这种方式,不同类型的 DOM 元素可以复用通用的操作方法,方便了库的开发和维护。

原型链属性查找的陷阱与注意事项

意外的属性覆盖

由于原型链属性查找机制,很容易出现意外的属性覆盖情况。例如:

let proto = {
    property: 'proto value'
};
let obj = Object.create(proto);
// 不小心在 obj 上定义了同名属性,覆盖了原型上的属性
obj.property = 'obj value'; 

在实际开发中,特别是在多人协作的项目中,要注意避免这种意外的属性覆盖,尽量使用更具描述性的属性名,减少冲突的可能性。

原型链修改的影响

动态修改原型链可能会带来意想不到的结果。例如:

function Person(name) {
    this.name = name;
}
let person = new Person('John');
Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};
person.sayHello(); // Hello, I'm John

// 动态修改原型链
Person.prototype = {
    constructor: Person,
    sayGoodbye: function() {
        console.log(`Goodbye, I'm ${this.name}`);
    }
};
// person 对象依然持有旧的原型,无法访问新的 sayGoodbye 方法
// person.sayGoodbye(); // TypeError: person.sayGoodbye is not a function 

在开发中,要谨慎修改原型链,特别是在对象已经创建之后。如果确实需要修改,要确保所有相关的对象都能正确地更新。

循环引用与原型链

在构建原型链时,要避免出现循环引用。例如:

let obj1 = {};
let obj2 = {};
obj1.__proto__ = obj2;
obj2.__proto__ = obj1;
// 这种循环引用会导致属性查找陷入无限循环
// console.log(obj1.someProperty); // 浏览器可能会崩溃或者抛出栈溢出错误 

要确保原型链的构建是合理的,避免出现循环引用的情况。

原型链属性查找与 ES6 类

ES6 引入了 class 语法糖,使得 JavaScript 的面向对象编程更加直观。然而,class 本质上还是基于原型链的。

例如:

class Animal {
    constructor() {
        this.health = 100;
    }
    move() {
        console.log('I can move');
    }
}

class Dog extends Animal {
    constructor(name) {
        super();
        this.name = name;
    }
    bark() {
        console.log(`${this.name} barks`);
    }
}

let myDog = new Dog('Max');
myDog.move(); // I can move
myDog.bark(); // Max barks

这里,Dog 类继承自 Animal 类,myDog 对象在查找 move 方法时,会沿着原型链找到 Animal.prototype.move,查找 bark 方法时,会在 Dog.prototype 上找到。class 语法只是对原型链操作的一种封装,属性查找机制依然遵循原型链的规则。

总结

JavaScript 的原型链属性查找机制是其核心特性之一,它为对象的属性和方法查找提供了一种灵活且强大的方式。通过理解原型链的形成、属性查找的基本流程、特殊情况以及在实际开发中的应用和注意事项,开发者能够更好地利用 JavaScript 的面向对象特性,编写出高效、可维护的代码。在使用原型链属性查找时,要充分考虑性能、避免意外的属性覆盖和原型链修改带来的问题,确保代码的稳定性和可靠性。无论是在小型项目还是大型框架的开发中,对原型链属性查找机制的深入理解都将有助于开发者解决各种复杂的问题,提升开发效率。