初学 JavaScript 之原型与原型链:从困惑到清晰
一、为什么需要原型?
先看一个简单的问题:如果我们要创建多个同类型的对象(比如多辆汽车),如何避免重复定义相同的属性和方法?
// 不使用原型的写法
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 语法简化了原型的使用,但底层依然依赖原型链。
(本文章为学习而作,如有错误感谢指正)
更多推荐
所有评论(0)