一、为什么需要原型?

先看一个简单的问题:如果我们要创建多个同类型的对象(比如多辆汽车),如何避免重复定义相同的属性和方法?

// 不使用原型的写法
function Car(color, name) {
  this.color = color;
  this.name = name;
  // 每个实例都会单独拥有一份run方法(浪费内存)
  this.run = function() {
    console.log(this.name + "在行驶");
  };
}

const car1 = new Car("红色", "宝马");
const car2 = new Car("蓝色", "奔驰");

// 虽然功能一样,但它们是不同的函数
console.log(car1.run === car2.run); // false

上面的代码中,car1 和 car2 都有 run 方法,但它们是两个独立的函数(内存中存了两份)。如果创建 1000 个实例,就会有 1000 个重复的 run 方法 —— 这显然不合理。

原型(prototype)就是为了解决这个问题而生的:把公共的属性和方法放到原型上,所有实例共享这一份资源,节省内存。

二、什么是原型?

每个函数(包括构造函数)都有一个 prototype 属性,这个属性指向一个对象,我们称之为 "原型对象",原型初始为空对象。

function Car() {}

// 构造函数的prototype属性指向原型对象
console.log(Car.prototype); // { constructor: Car, ... }

原型对象有一个特殊的 constructor 属性(隐藏属性),指向它对应的构造函数。

console.log(Car.prototype.constructor === Car); // true

当我们通过 new 关键字创建实例时,实例会自动关联到构造函数的原型对象上,这个关联通过实例的 __proto__ 属性体现(注意:__proto__ 是实例的属性,prototype 是构造函数的属性)。

function Car() {}
const car1 = new Car();

// 实例的__proto__指向构造函数的prototype
console.log(car1.__proto__ === Car.prototype); // true

简单理解:构造函数通过 prototype 连接原型对象,实例通过 __proto__ 连接原型对象。

三、原型链:属性查找的规则

当我们访问一个对象的属性时,JS 会先在对象自身查找:

如果找到,直接返回;

如果没找到,就通过 __proto__ 去原型对象上找;

如果原型对象上也没有,就去原型对象的原型上找(即 __proto__.__proto__);

以此类推,直到找到 null(原型链的顶端),如果还没找到就返回 undefined

这种链式查找的机制,就是原型链

// 1. 定义构造函数
function Car(color, name) {
  // 实例自身属性
  this.color = color;
  this.name = name;
}

// 2. 往原型上添加公共属性和方法
Car.prototype.age = 3; // 所有汽车的默认年龄
Car.prototype.run = function() {
  console.log(this.name + "在行驶");
};

// 3. 创建实例
const car1 = new Car("红色", "宝马");

// 访问实例自身属性
console.log(car1.name); // "宝马"(自身有,直接返回)

// 访问原型上的属性
console.log(car1.age); // 3(自身没有,去原型找)

// 访问原型上的方法
car1.run(); // "宝马在行驶"

// 访问不存在的属性(沿原型链查到顶端null)
console.log(car1.price); // undefined

四、原型的实际应用:继承

原型链最常见的用途是实现 "继承"—— 让一个对象拥有另一个对象的属性和方法。

// 父类:交通工具
function Vehicle() {
  this.wheels = 4; // 默认4个轮子
}

// 父类原型上的方法
Vehicle.prototype.move = function() {
  console.log("移动中...");
};

// 子类:汽车(继承自交通工具)
function Car(name) {
  this.name = name;
}

// 核心:让Car的原型指向Vehicle的实例,实现继承
Car.prototype = new Vehicle();
// 修复constructor指向(否则会指向Vehicle)
Car.prototype.constructor = Car;

// 子类自己的方法
Car.prototype.horn = function() {
  console.log(this.name + "鸣笛:嘀嘀!");
};

// 创建汽车实例
const car1 = new Car("宝马");

// 继承自父类的属性
console.log(car1.wheels); // 4

// 继承自父类原型的方法
car1.move(); // "移动中..."

// 子类自己的方法
car1.horn(); // "宝马鸣笛:嘀嘀!"

五、class 语法中的原型

ES6 引入的 class 语法其实是原型的语法糖,它让原型的使用更直观,但底层逻辑还是原型链。

class Car {
  // 构造函数:对应传统构造函数的内部代码
  constructor(color, name) {
    this.color = color;
    this.name = name;
  }

  // 实例方法:会被添加到Car.prototype上
  run() {
    console.log(this.name + "在行驶");
  }

  // 静态方法:不会添加到原型,只能通过Car调用
  static info() {
    console.log("这是汽车类");
  }
}

// 等价于往Car.prototype上添加属性
Car.prototype.age = 3;

const car1 = new Car("红色", "宝马");

console.log(car1.age); // 3(原型上的属性)
car1.run(); // "宝马在行驶"(原型上的方法)
Car.info(); // "这是汽车类"(静态方法)

因为 class 语法创建的方法有一个特殊性质:它们是不可枚举的(non-enumerable)。因此

// 1. 直接打印只能看到可枚举属性
console.log(Car.prototype);  // { age: 3 }

// 2. 使用 Object.getOwnPropertyDescriptor 查看属性描述符
console.log(Object.getOwnPropertyDescriptor(Car.prototype, 'run'));
// {
//   value: [Function: run],
//   writable: true,
//   enumerable: false,  // 注意这里是 false
//   configurable: true
// }

// 3. 使用 Object.getOwnPropertyNames 可以看到所有属性(包括不可枚举的)
console.log(Object.getOwnPropertyNames(Car.prototype));
// [ 'constructor', 'run', 'age' ]

六、常见误区

1.prototype和__proto__

prototype 是构造函数的属性,指向原型对象;__proto__ 是实例的属性,指向原型对象;二者的关系只是都指向原型对象,二者均可以修改原型对象,也可以修改二者的指向其他对象,不会影响另一个的指向。

function Car() {}

const car1 = new Car();

// 替换整个原型对象
Car.prototype = {
    age: 3
};

console.log(car1.age); // undefined,因为 car1.__proto__ 还指向旧的原型对象
console.log(Car.prototype);//{ age: 3 },因为Car.prototype已经指向新的对象
console.log(car1.__proto__);//{},因为car1.__proto__ 还指向旧的原型对象
car1.__proto__.age = 5;
console.log(car1.age); // 5,因为 car1.__proto__.age = 5;修改的是旧原型对象的属性
console.log(Car.prototype);//3,因为Car.prototype已经指向新的对象
console.log(car1.__proto__);//{ age: 5 },因为car1.__proto__ 还指向旧的原型对象

2.constructor

constructor 只是原型对象上的一个普通属性,不会影响实例对象的属性。真正决定原型继承的是 __proto__ 指针。

function Car() {}
const car1 = new Car();

// 改变 constructor 不会影响原型链
car1.constructor = Array;
console.log(car1.__proto__ === Car.prototype); // true,原型链关系不变
function Car() {}
function Person() {}
const car1 = new Car();

// 这样才能改变继承关系
car1.__proto__ = Person.prototype;

3.包装类和原型的关系:

当Number,String,Boolean类型在使用方法时,会自动进行临时包装为对象。原型链结构为:

num/str/bool -> Number/String/Boolean.prototype -> Object.prototype -> null。

let num = 123;
num.toString(); // JavaScript 自动装箱为 Number 对象,let num = new Number(123);

let str = "abc";
str.charAt(0); // JavaScript 自动装箱为 String 对象,let str = new String("abc");

let bool = true;
bool.valueOf(); // JavaScript 自动装箱为 Boolean 对象,let bool = new Boolean(true);

例如,当num.toString();这行代码执行完毕,num又会变成Number类型。因此,我们可以通过包装类将num/str/bool永久变成对象。此时,num/str/bool可以有属性或方法。

另外注意:Number/String/Boolean.prototype里会重新定义toString(),不会直接使用Object.prototype里面的toString(),因此Number,String,Boolean类型在使用toString()时输出结果会和Object.prototype.toString()的不一样。因此,我们可以通过覆盖的方式去覆盖掉原型链中的方法或属性。

七、总结

原型和原型链是 JavaScript 面向对象的核心机制:

原型(prototype)是构造函数的属性,用于存储实例共享的属性和方法;

原型链是通过 __proto__ 连接的链式结构,决定了属性的查找规则;

class 语法简化了原型的使用,但底层依然依赖原型链。

(本文章为学习而作,如有错误感谢指正)

更多推荐