javascript面试系列(一)——原型与原型链

52 阅读8分钟

在JavaScript的知识体系中,原型与原型链是贯穿始终的核心机制,也是前端面试中区分基础是否扎实的“分水岭”。不同于传统面向对象语言的“类”继承,JavaScript通过原型实现了对象间的属性和方法共享,这种独特的设计思想不仅支撑着数组、函数等内置对象的运作,更藏着诸多易混淆的面试考点。今天这篇文章,我们就从面试视角出发,把原型、原型链的本质、核心用法以及高频考题一次性讲透。

一、为什么需要原型?—— 从对象复用的痛点说起

在理解原型之前,我们先思考一个问题:如何高效创建多个具有相同属性和方法的对象?比如创建一批“学生”对象,每个学生都有“姓名”“年龄”属性和“学习”方法。最直接的方式是使用对象字面量或构造函数:

// 方式1:对象字面量(重复代码过多)
const student1 = {
  name: '张三',
  age: 18,
  study() {
    console.log(`${this.name}正在学习`);
  }
};
const student2 = {
  name: '李四',
  age: 19,
  study() { // 方法重复定义,浪费内存
    console.log(`${this.name}正在学习`);
  }
};

// 方式2:构造函数(方法仍重复创建)
function Student(name, age) {
  this.name = name;
  this.age = age;
  this.study = function() { // 每个实例都会创建一个新的study函数
    console.log(`${this.name}正在学习`);
  };
}
const student3 = new Student('王五', 20);
const student4 = new Student('赵六', 21);

这两种方式都存在明显缺陷:方法重复创建。每个学生对象的“study”方法逻辑完全相同,却被重复定义在每个实例中,导致内存浪费。如果创建成千上万的学生对象,这种浪费会非常严重。

为了解决“属性/方法复用”的问题,JavaScript引入了“原型(prototype)”机制:将所有实例共享的属性和方法存放在一个公共对象中,每个实例都能访问这个公共对象的内容,从而实现复用。这个公共对象就是原型。

二、原型核心解析:三个关键对象与关联属性

原型体系的核心围绕三个关键对象展开:构造函数实例对象原型对象。它们之间通过两个特殊属性建立关联:prototype(构造函数的属性)和__proto__(实例对象的属性,ES6后标准化为Object.getPrototypeOf())。

1. 构造函数与prototype属性

在JavaScript中,任何函数都可以作为构造函数(通过new关键字调用)。每个构造函数都有一个内置的prototype属性,该属性指向一个原型对象。这个原型对象就是所有通过该构造函数创建的实例的“公共仓库”。

// 构造函数
function Student(name, age) {
  this.name = name; // 实例私有属性
  this.age = age;  // 实例私有属性
}

// 向构造函数的prototype添加共享方法
Student.prototype.study = function() {
  console.log(`${this.name}正在学习`);
};

// 向构造函数的prototype添加共享属性
Student.prototype.school = 'XX大学';

// 创建实例
const student1 = new Student('张三', 18);
const student2 = new Student('李四', 19);

// 实例访问共享方法和属性
student1.study(); // 张三正在学习
student2.study(); // 李四正在学习
console.log(student1.school); // XX大学
console.log(student2.school); // XX大学

// 验证两个实例的study方法是同一个
console.log(student1.study === student2.study); // true

通过上述代码可以发现:将study方法和school属性添加到Student.prototype后,所有Student实例都能访问到这些内容,且方法只被创建一次,实现了复用。

面试高频考点:构造函数的prototype属性的作用?答案是:用于存储该构造函数所有实例的共享属性和方法,实现属性/方法的复用,减少内存开销。

2. 实例对象与__proto__属性

当通过构造函数创建实例时,实例对象会自动生成一个内置属性__proto__(隐式原型),这个属性指向构造函数的prototype所指向的原型对象。正是通过__proto__,实例才能“找到”原型对象中的共享内容。

// 验证实例的__proto__指向构造函数的prototype
console.log(student1.__proto__ === Student.prototype); // true
console.log(student2.__proto__ === Student.prototype); // true

// ES6标准方法:获取实例的原型对象
console.log(Object.getPrototypeOf(student1) === Student.prototype); // true

需要注意的是,__proto__是实例的属性,而prototype是构造函数的属性,二者指向同一个原型对象,这是实例与原型对象建立关联的关键。

3. 原型对象与constructor属性

原型对象并非“孤立”的,它自身也有一个内置属性constructor(构造器),这个属性指向对应的构造函数。这就形成了一个闭环:构造函数 → 原型对象 → 构造函数。

// 验证原型对象的constructor指向构造函数
console.log(Student.prototype.constructor === Student); // true

// 实例可以通过__proto__访问到constructor
console.log(student1.__proto__.constructor === Student); // true
// 实例也可以直接访问constructor(通过原型链查找)
console.log(student1.constructor === Student); // true

这个闭环的作用非常重要:当我们不知道实例的构造函数时,可以通过instance.constructor快速获取,也可以用于修复被覆盖的原型对象的constructor属性(后续会讲)。

三、原型链:对象属性查找的“追溯机制”

原型对象本身也是一个对象,它同样有自己的__proto__属性,指向它的原型对象。这样一层一层追溯下去,就形成了一条链式结构,这就是原型链。原型链的尽头是Object.prototype,它的__proto__属性指向null,表示“没有更上层的原型了”。

1. 原型链的结构与属性查找规则

我们以Student实例为例,画出完整的原型链结构:

student1 → Student.prototypeObject.prototypenull
// 解释:
// student1的__proto__ → Student.prototype
// Student.prototype的__proto__ → Object.prototype
// Object.prototype的__proto__ → null

当我们访问一个对象的属性或方法时,JavaScript会遵循以下查找规则:

  1. 首先在对象自身上查找,如果找到则直接使用;
  2. 如果自身没有,则通过__proto__查找其原型对象
  3. 如果原型对象上也没有,则继续通过原型对象的__proto__向上追溯,直到Object.prototype
  4. 如果在Object.prototype上仍未找到,则返回undefined(如果是方法则报错“XXX is not a function”)。
// 实例自身有name属性
console.log(student1.name); // 张三(自身查找)

// 实例自身没有study方法,查找原型对象Student.prototype
student1.study(); // 张三正在学习(原型对象查找)

// 实例自身和Student.prototype都没有toString方法,查找Object.prototype
console.log(student1.toString()); // [object Object](Object.prototype查找)

// 查找不存在的属性,追溯到null后返回undefined
console.log(student1.gender); // undefined

2. 原型链的核心作用:实现继承

原型链最核心的作用是实现JavaScript的“继承”特性。通过改变原型对象的指向,让一个构造函数的原型指向另一个构造函数的实例,就能实现属性和方法的继承。

// 父构造函数:Person
function Person(name) {
  this.name = name;
  this.eat = function() {
    console.log(`${this.name}正在吃饭`);
  };
}
// 父构造函数的原型方法
Person.prototype.sleep = function() {
  console.log(`${this.name}正在睡觉`);
};

// 子构造函数:Student
function Student(name, age) {
  // 继承父构造函数的私有属性
  Person.call(this, name); // 改变Person的this指向为Student实例
  this.age = age;
}

// 核心:让Student的原型指向Person的实例,实现原型链继承
Student.prototype = new Person();
// 修复constructor指向(因为Student.prototype被覆盖后,constructor指向了Person)
Student.prototype.constructor = Student;

// 子构造函数的原型方法
Student.prototype.study = function() {
  console.log(`${this.name}正在学习`);
};

// 创建子实例
const student = new Student('张三', 18);

// 访问自身属性
console.log(student.age); // 18

// 访问继承自Person的私有方法
student.eat(); // 张三正在吃饭

// 访问继承自Person.prototype的方法
student.sleep(); // 张三正在睡觉

// 访问自身原型方法
student.study(); // 张三正在学习

// 验证原型链:student → Person实例 → Person.prototype → Object.prototype → null
console.log(student.__proto__ === Student.prototype); // true
console.log(Student.prototype.__proto__ === Person.prototype); // true

面试必考点:原型链继承时为什么要修复constructor?因为将Student.prototype = new Person()后,Student的原型对象变成了Person的实例,其constructor指向Person,这会导致通过student.constructor获取的构造函数不准确,修复后才能保证原型链的闭环正确。

四、常见原型相关API与实战技巧

除了上述核心概念,JavaScript还提供了多个操作原型的API,这些API在开发和面试中都高频出现,必须熟练掌握。

1. Object.getPrototypeOf() 与 Object.setPrototypeOf()

ES6标准化的原型操作方法,用于获取和设置对象的原型,替代了非标准的__proto__

const obj = {};
const protoObj = { a: 1 };

// 设置obj的原型为protoObj
Object.setPrototypeOf(obj, protoObj);

// 获取obj的原型
console.log(Object.getPrototypeOf(obj) === protoObj); // true
console.log(obj.a); // 1(通过原型链查找)

注意:Object.setPrototypeOf()会直接修改对象的原型,可能影响性能,开发中应谨慎使用,优先通过构造函数的prototype定义原型。

2. Object.create(proto[, propertiesObject])

创建一个新对象,其原型指向指定的proto对象,第二个参数可选,用于定义新对象的自身属性。这是实现“原型式继承”的常用方法。

// 原型对象
const animalProto = {
  eat() {
    console.log(`${this.name}正在吃东西`);
  }
};

// 创建新对象,原型为animalProto
const cat = Object.create(animalProto, {
  name: {
    value: '小花',
    writable: true // 允许修改name属性
  }
});

cat.eat(); // 小花正在吃东西
console.log(Object.getPrototypeOf(cat) === animalProto); // true

3. hasOwnProperty() 与 in 运算符

这两个方法常用于判断属性是否属于对象自身(而非原型链上的属性),是原型链查找的重要辅助工具。

  • hasOwnProperty() :仅判断属性是否为对象自身的属性,不包括原型链上的属性;
  • in 运算符:判断属性是否存在于对象或其原型链上。
function Student(name) {
  this.name = name;
}
Student.prototype.school = 'XX大学';

const student = new Student('张三');

// hasOwnProperty:仅判断自身属性
console.log(student.hasOwnProperty('name')); // true(自身属性)
console.log(student.hasOwnProperty('school')); // false(原型链属性)

// in 运算符:判断自身或原型链属性
console.log('name' in student); // true
console.log('school' in student); // true
console.log('toString' in student); // true(Object.prototype上的属性)

面试考点:如何遍历对象自身的所有属性?答案:结合for...in循环和hasOwnProperty(),过滤掉原型链上的属性。

for (const key in student) {
  if (student.hasOwnProperty(key)) {
    console.log(key, student[key]); // 仅输出name: 张三
  }
}

五、面试高频题

原型与原型链的面试题往往围绕“属性查找”“原型链结构”“继承实现”展开,以下是高频真题解析。

真题1:原型链属性查找与修改

题目:以下代码的输出结果是什么?

function Foo() {
  this.a = 1;
}
Foo.prototype.a = 2;
Foo.prototype.b = 3;

const f1 = new Foo();
const f2 = new Foo();

console.log(f1.a); // ?
console.log(f1.b); // ?

f1.a = 4;
console.log(f1.a); // ?
console.log(f2.a); // ?

delete f1.a;
console.log(f1.a); // ?
delete Foo.prototype.a;
console.log(f1.a); // ?

答案:1 → 3 → 4 → 1 → 2 → undefined

解析:

  1. f1.a:f1自身有a属性(值为1),优先取自身,输出1;
  2. f1.b:f1自身无b属性,原型链查找Foo.prototype的b(值为3),输出3;
  3. f1.a = 4:给f1自身赋值a=4,覆盖原型链属性,输出4;
  4. f2.a:f2自身a=1,与f1独立,输出1;
  5. delete f1.a:删除f1自身的a属性,再次查找时取原型链的a=2,输出2;
  6. delete Foo.prototype.a:删除原型的a属性,原型链上无a属性,输出undefined。

真题2:判断实例与构造函数的关系

题目:instanceof运算符的作用是什么?以下代码输出结果是什么?

function Person() {}
function Student() {}
Student.prototype = new Person();

const s = new Student();

console.log(s instanceof Student); // ?
console.log(s instanceof Person); // ?
console.log(s instanceof Object); // ?
console.log(Student instanceof Function); // ?

答案:true → true → true → true

解析:

  • instanceof的作用:判断一个实例是否属于某个构造函数,本质是判断“实例的原型链上是否存在该构造函数的prototype属性指向的原型对象”;
  • s instanceof Student:s的原型链包含Student.prototype,true;
  • s instanceof Person:s的原型链包含Person.prototype,true;
  • s instanceof Object:s的原型链包含Object.prototype,true;
  • Student instanceof Function:Student是函数,函数的原型链指向Function.prototype,true。

真题3:手写原型链继承(经典继承)

题目:实现一个Teacher构造函数,继承自Person构造函数,要求:

  1. Person有name、age属性和eat方法;
  2. Teacher有subject属性和teach方法;
  3. Teacher实例能访问自身、Teacher原型、Person原型、Object原型的属性和方法。
// 父构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
}
// 父原型方法
Person.prototype.eat = function() {
  console.log(`${this.name}正在吃饭`);
};

// 子构造函数
function Teacher(name, age, subject) {
  // 继承父构造函数的私有属性
  Person.call(this, name, age);
  // 子构造函数私有属性
  this.subject = subject;
}

// 原型链继承:子原型指向父实例
Teacher.prototype = new Person();
// 修复constructor指向
Teacher.prototype.constructor = Teacher;
// 子原型方法
Teacher.prototype.teach = function() {
  console.log(`${this.name}正在教${this.subject}`);
};

// 测试
const teacher = new Teacher('李老师', 30, '数学');
console.log(teacher.name); // 李老师(继承自Person)
console.log(teacher.subject); // 数学(自身属性)
teacher.eat(); // 李老师正在吃饭(继承自Person.prototype)
teacher.teach(); // 李老师正在教数学(自身原型方法)
console.log(teacher.toString()); // [object Object](继承自Object.prototype)
console.log(teacher instanceof Teacher); // true
console.log(teacher instanceof Person); // true

六、总结

原型与原型链是JavaScript的“基石”,面试考察的核心是对“属性复用”“链状查找”“继承实现”的理解。最后梳理核心考点:

  1. 三个核心关联:构造函数的prototype指向原型对象,实例的__proto__指向原型对象,原型对象的constructor指向构造函数;
  2. 原型链的结构:实例 → 构造函数.prototype → Object.prototype → null,属性查找遵循“自身→原型→上层原型→null”的规则;
  3. 继承的核心:通过“子构造函数.prototype = new 父构造函数()”改变原型指向,结合Person.call(this)继承私有属性,同时修复constructor
  4. 关键API:Object.getPrototypeOf()Object.create()hasOwnProperty()instanceof的用法与区别;
  5. 常见误区:实例修改属性不会影响原型,删除实例属性后会回归原型链查找,原型链继承时需修复构造器指向。