面向对象 原型和原型链

一、原型对象

  1. 定义:所有构造函数在初始化时,会自动生成一个特殊的实例化对象,构造函数的prototype属性指向该对象,这个对象就是原型对象(prototype 对象)。

  2. 核心特点

    • 实例化对象通过__proto__属性指向构造函数的原型对象
    • 实例访问属性 / 方法时,先查自身,再通过__proto__查原型对象
    • 原型对象的主要作用是实现继承和共享方法
  3. 原型对象默认属性

    • constructor:指向构造函数本身
    • __proto__:指向其父级构造函数的原型对象
function Student(name) {
  this.name = name;
}

// 原型对象
console.log(Student.prototype); 
// constructor指向构造函数
console.log(Student.prototype.constructor === Student); // true
// 实例对象的__proto__指向原型对象
const s = new Student('Tom');
console.log(s.__proto__ === Student.prototype); // true

二、原型链

  1. 定义:由一系列__proto__属性串联起来的原型对象链称为原型链。

  2. 查找机制

    • 访问对象属性 / 方法时,先查自身
    • 若无,则通过__proto__查原型对象
    • 若无,则继续查原型的原型,直至Object.prototype
    • Object.prototype.__proto__null,查找终止
  3. 示意图

    plaintext

    实例对象 -> 构造函数.prototype -> 父构造函数.prototype -> ... -> Object.prototype -> null
    

三、典型面试题解析

数组去重方法添加

Array.prototype.unique = function() {
  const res = [];
  for (let i = 0; i < this.length; i++) {
    if (!res.includes(this[i])) {
      res.push(this[i]);
    }
  }
  return res;
};

const arr = [1, 2, 2, 3, 3, 3];
console.log(arr.unique()); // [1, 2, 3]

异步循环输出问题

for(var i = 0; i < 10; i++){
  setTimeout(function(){
    console.log(i, 'inner'); // 10个10 'inner'
  }, 0);
}
console.log(i, 'outer'); // 10 'outer'

// 原因:var声明的i是函数作用域,循环结束后i=10,定时器回调访问的是同一个i

this 指向问题

var x = 1234;
function test(){
  var x = 4567;
  console.log(this.x);  
}
test(); // 1234(this指向window)
var o = {x:5678, m:test};
o.m(); // 5678(this指向o)

函数与原型方法优先级

function Foo() {
  getName = function () { console.log(1); };
  return this;
}
Foo.getName = function () { console.log(2); };
Foo.prototype.getName = function () { console.log(3); };
var getName = function () { console.log(4); };
function getName() { console.log(5); }

Foo.getName(); // 2(调用静态方法)
getName(); // 4(函数表达式覆盖函数声明)
Foo().getName(); // 1(全局getName被重写)
new Foo().getName(); // 3(调用原型方法)

原型链查找案例

function A(){};
function B(a){ this.a = a};
function C(a){ if(a){this.a = a;}}
A.prototype.a = 1;
B.prototype.a = 1;
C.prototype.a = 1;

console.log(new A().a); // 1(来自原型)
console.log(new B().a); // undefined(实例无a属性)
console.log(new C(2).a); // 2(实例有a属性)

静态方法与实例方法

function Cat(name){
  this.name = name;
}
Cat.say = function(){ // 静态方法
  console.log(this.name1);
};
Cat.name1 = "kmf";

const cat1 = new Cat('cat1');
cat1.say(); // 报错(实例无法访问静态方法)
Cat.say(); // "kmf"(静态方法中this指向构造函数)

面向对象 - 继承

一、继承基础:call () 与 apply ()

JS 继承的底层依赖 call() 和 apply() 方法,二者的核心作用是改变函数内 this 的指向,从而让子类构造函数能 “借用” 父类的构造逻辑,实现构造属性的继承。

1. 语法对比

方法 语法 特点
call() 父类构造函数.call(thisObj, arg1, arg2, ...) 参数需逐个传入
apply() 父类构造函数.apply(thisObj, [arg1, arg2, ...]) 参数需以数组形式传入

2. 核心作用

在子类构造函数中调用 父类.call(this, 参数),可将父类构造函数的 this 指向子类实例,使子类实例能继承父类的构造属性 / 方法。

二、7 种常见继承方式

根据继承的对象(构造属性 / 原型属性)和实现逻辑,JS 继承可分为 7 种核心方式,各有优缺点,需根据场景选择。

1. 构造继承(借助 call/apply)

实现逻辑

仅通过 call() 或 apply() 借用父类构造函数,让子类继承父类的构造属性 / 方法,不涉及原型属性的继承。

代码示例
// 父类:Person(含构造属性和构造方法)
function Person(name, sex, age) {
  this.name = name; // 构造属性
  this.sex = sex;
  this.age = age;
  this.play = function() { // 构造方法
    console.log(`${this.name}在玩`);
  };
}
// 父类原型属性(构造继承无法继承)
Person.prototype.eat = function() {
  console.log(`${this.name}在吃饭`);
};

// 子类:Student(继承Person的构造属性,新增学号)
function Student(name, sex, age, stuID) {
  // 核心:通过apply改变this指向,继承Person的构造属性/方法
  Person.apply(this, [name, sex, age]); 
  this.stuID = stuID; // 子类新增构造属性
  this.study = function() { // 子类新增构造方法
    console.log(`${this.name}在学习`);
  };
}

// 测试
const stu1 = new Student('Jack', '男', 18, '1001');
console.log(stu1.name); // Jack(继承自Person的构造属性)
stu1.play(); // Jack在玩(继承自Person的构造方法)
stu1.eat(); // 报错!构造继承无法继承原型方法
特点
  • 优点:实现简单,能传参,子类实例的构造属性是独立的(无共享问题)。
  • 缺点:仅能继承父类的构造内容,无法继承父类的原型属性 / 方法(如上述 eat());子类每个实例会重复创建父类的构造方法(如 play()),浪费内存。

2. 原型继承

实现逻辑

通过重写子类的原型对象,让子类的 prototype 指向父类的实例,从而使子类继承父类的原型属性 / 方法(父类实例会携带父类的构造属性和原型属性)。

代码示例
// 父类:Person(同上)
function Person(name, sex, age) {
  this.name = name;
  this.sex = sex;
  this.age = age;
}
Person.prototype.eat = function() {
  console.log(`${this.name}在吃饭`);
};

// 子类:Student(原型继承)
function Student() {}
// 核心:子类原型指向父类实例,继承父类的原型属性/方法
Student.prototype = new Person('Jack', '男', 18); 

// 测试
const stu1 = new Student();
console.log(stu1.name); // Jack(继承自父类实例的构造属性)
stu1.eat(); // Jack在吃饭(继承自父类的原型方法)
特点
  • 优点:能继承父类的原型属性 / 方法,实现方法复用(所有子类实例共享原型方法)。
  • 缺点
    1. 子类无法新增构造属性(需在原型上添加,不灵活);
    2. 父类的构造属性会被所有子类实例共享(如上述 stu1.name 是固定的 Jack,无法传参修改);
    3. 无法实现多继承。

3. 实例继承

实现逻辑

在子类构造函数内部直接实例化父类对象,并为其添加子类特有的属性 / 方法,最后返回该父类实例。本质是 “包装父类实例”,而非真正的子类。

代码示例
// 父类:Person(同上)
function Person(name, sex, age) {
  this.name = name;
  this.sex = sex;
  this.age = age;
}
Person.prototype.eat = function() {
  console.log(`${this.name}在吃饭`);
};

// 子类:Student(实例继承)
function Student(name, sex, age) {
  // 1. 实例化父类对象
  const per = new Person(name, sex, age);
  // 2. 为父类实例添加子类特有属性/方法
  per.stuID = '1001';
  per.study = function() {
    console.log(`${this.name}在学习`);
  };
  // 3. 返回父类实例(此时Student的实例本质是Person实例)
  return per;
}

// 测试(调用方式不限制new,有无new结果一致)
const stu1 = new Student('Rose', '女', 17);
const stu2 = Student('Tom', '男', 19);
console.log(stu1 instanceof Student); // false(本质是Person实例)
console.log(stu1 instanceof Person); // true
stu1.eat(); // Rose在吃饭(继承父类原型方法)
stu1.study(); // Rose在学习(子类新增方法)
特点
  • 优点:调用灵活(可加 new 也可不加),能继承父类的构造和原型属性。
  • 缺点
    1. 子类实例本质是父类实例(instanceof 检测不成立);
    2. 无法给子类添加独立的原型属性 / 方法;
    3. 无法实现多继承。

4. 拷贝继承

实现逻辑

通过循环遍历父类实例的属性 / 方法,将其逐个拷贝到子类的原型对象上,从而实现继承。支持多继承(拷贝多个父类)。

代码示例
// 父类:Person(同上)
function Person(name, sex, age) {
  this.name = name;
  this.sex = sex;
  this.age = age;
}
Person.prototype.eat = function() {
  console.log(`${this.name}在吃饭`);
};

// 子类:Student(拷贝继承)
function Student(name, sex, age, stuID) {
  // 1. 实例化父类
  const per = new Person(name, sex, age);
  // 2. 循环拷贝父类的所有属性/方法到子类原型
  for (const key in per) {
    Student.prototype[key] = per[key];
  }
  // 3. 子类新增属性/方法
  this.stuID = stuID;
  Student.prototype.study = function() {
    console.log(`${this.name}在学习`);
  };
}

// 测试
const stu1 = new Student('Jack', '男', 18, '1001');
console.log(stu1.name); // Jack(拷贝自父类构造属性)
stu1.eat(); // Jack在吃饭(拷贝自父类原型方法)
stu1.study(); // Jack在学习(子类新增方法)
特点
  • 优点:支持多继承(可拷贝多个父类的属性);能继承父类的构造和原型属性。
  • 缺点
    1. 效率低(需遍历所有属性,属性多时性能差);
    2. 内存占用高(拷贝会生成重复属性 / 方法);
    3. 无法获取父类中不可枚举的方法(如 Object.prototype 上的隐藏方法)。

5. 组合继承(构造 + 原型)

实现逻辑

结合构造继承call/apply 继承构造属性)和原型继承(子类原型指向父类实例),同时继承父类的构造属性和原型属性,是最常用的继承方式之一。

代码示例
// 父类:Person(同上)
function Person(name, sex, age) {
  this.name = name;
  this.sex = sex;
  this.age = age;
}
Person.prototype.eat = function() {
  console.log(`${this.name}在吃饭`);
};

// 子类:Student(组合继承)
function Student(name, sex, age, stuID) {
  // 1. 构造继承:继承父类构造属性(传参灵活)
  Person.call(this, name, sex, age); 
  this.stuID = stuID; // 子类新增构造属性
}
// 2. 原型继承:继承父类原型属性/方法
Student.prototype = new Person(); 
// 修复构造器指向(关键!否则Student.prototype.constructor指向Person)
Student.prototype.constructor = Student; 
// 子类新增原型方法
Student.prototype.study = function() {
  console.log(`${this.name}在学习`);
};

// 测试
const stu1 = new Student('Jack', '男', 18, '1001');
console.log(stu1.name); // Jack(构造继承)
console.log(stu1.stuID); // 1001(子类新增)
stu1.eat(); // Jack在吃饭(原型继承)
stu1.study(); // Jack在学习(子类原型方法)
console.log(stu1.constructor); // Student(构造器已修复)
特点
  • 优点
    1. 兼顾构造继承(传参灵活、实例属性独立)和原型继承(方法复用);
    2. 能继承父类的构造和原型属性,支持子类新增属性 / 方法。
  • 缺点:父类构造函数被调用两次(一次在 Person.call(this),一次在 new Person()),造成内存浪费(父类构造属性会在子类实例和子类原型中各存一份)。

6. 寄生继承

实现逻辑

基于 “寄生” 思想:创建一个中间空类(作为 “寄生载体”),让中间类的原型指向父类的原型,再让子类的原型指向中间类的实例,从而避免父类构造函数被重复调用(解决组合继承的缺点)。

代码示例
// 父类:Person(同上)
function Person(name, sex, age) {
  this.name = name;
  this.sex = sex;
  this.age = age;
}
Person.prototype.eat = function() {
  console.log(`${this.name}在吃饭`);
};

// 子类:Student(寄生继承)
function Student(name, sex, age, stuID) {
  // 1. 构造继承:仅调用一次父类构造
  Person.call(this, name, sex, age); 
  this.stuID = stuID;
}

// 2. 寄生逻辑:通过中间类实现原型继承(避免父类构造重复调用)
(function() {
  // 中间空类(无自身构造逻辑,仅作为原型桥梁)
  const Super = function() {}; 
  // 中间类的原型指向父类原型(继承父类原型属性)
  Super.prototype = Person.prototype; 
  // 子类原型指向中间类实例(避免调用父类构造)
  Student.prototype = new Super(); 
  // 修复构造器指向
  Student.prototype.constructor = Student; 
})();

// 子类新增原型方法
Student.prototype.study = function() {
  console.log(`${this.name}在学习`);
};

// 测试
const stu1 = new Student('Jack', '男', 18, '1001');
console.log(stu1.name); // Jack(构造继承)
stu1.eat(); // Jack在吃饭(原型继承,父类构造仅调用一次)
特点
  • 优点:解决组合继承中父类构造函数被调用两次的问题,内存更高效;同时继承构造和原型属性。
  • 缺点:实现逻辑复杂(需借助自调用函数和中间类);仍无法实现多继承。

7. ES6 class 继承(语法糖)

ES6 引入 class 关键字和 extends 语法,本质是原型继承的语法糖,简化了继承代码的编写,逻辑与寄生组合继承一致。

代码示例
// 父类:Person(class语法)
class Person {
  // 构造方法(对应ES5的构造函数)
  constructor(name, sex, age) {
    this.name = name;
    this.sex = sex;
    this.age = age;
  }
  // 原型方法(自动挂载到Person.prototype)
  eat() {
    console.log(`${this.name}在吃饭`);
  }
}

// 子类:Student(extends继承)
class Student extends Person {
  // 子类构造方法(必须调用super(),对应ES5的Person.call(this))
  constructor(name, sex, age, stuID) {
    super(name, sex, age); // 调用父类构造,继承构造属性
    this.stuID = stuID; // 子类新增属性
  }
  // 子类原型方法
  study() {
    console.log(`${this.name}在学习`);
  }
}

// 测试
const stu1 = new Student('Jack', '男', 18, '1001');
console.log(stu1.name); // Jack(继承父类构造属性)
stu1.eat(); // Jack在吃饭(继承父类原型方法)
stu1.study(); // Jack在学习(子类原型方法)
特点
  • 优点:语法简洁直观,与传统面向对象语言(如 Java)一致;底层自动解决父类构造重复调用问题,无需手动修复构造器。
  • 缺点:本质仍是原型继承,无法摆脱原型链的底层逻辑(如 instanceof 检测仍基于原型)。

更多推荐