有人说JavaScript里"万物皆对象",但对象和对象之间怎么攀亲戚?今天我们就来扒一扒JS的"家族关系"------原型和原型链。看懂了它,你就理解了JS面向对象的核心,也能明白为什么一个数组能调用那么多方法。
前言
如果你第一次接触原型,可能会觉得它像个黑魔法:明明没在那个对象上定义方法,怎么就突然能用了?比如:
js
const arr = [1, 2, 3];
arr.push(4); // 哪里来的push?
这个push方法既不是我们手动加的,也不是数组本身自带的(其实数组本身也没有,不信你console.log(arr)看看)。它是从"祖先"那里继承来的。
今天我们就来扒一扒JavaScript这个家族的族谱,看看对象们是怎么"攀亲戚"的,以及怎么利用这门亲戚关系写出优雅的代码。
一、原型是个啥?
简单来说,原型就是一个普通的对象,它被别的对象当作"备用方案"。当你访问一个对象的属性或方法时,如果这个对象自己没有,JavaScript就会去它的原型上找。如果原型上也没有,就去原型的原型上找,直到找到或者到达尽头。
这个"备用方案"的链条,就是原型链。
1. 每个函数都有个prototype属性
在JavaScript里,每个函数都有一个prototype属性(箭头函数除外)。这个属性指向一个对象,当这个函数被用作构造函数(用new调用)时,创建出来的实例会继承这个prototype对象上的所有属性和方法。
js
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log(`你好,我是${this.name}`);
};
const zhangsan = new Person('张三');
zhangsan.sayHello(); // 你好,我是张三
这里sayHello不在zhangsan自己身上,但它在Person.prototype上,zhangsan通过原型链找到了它。
2. 每个对象都有个__proto__属性
每个对象(除了null)都有一个__proto__属性(非标准,但几乎所有浏览器都实现),它指向该对象的原型(即构造函数的prototype)。
js
console.log(zhangsan.__proto__ === Person.prototype); // true
这个__proto__就是连接实例和原型的"脐带"。
3. 构造函数也有自己的原型
构造函数本身也是对象,所以它也有__proto__。它指向Function.prototype,因为所有函数都是Function的实例。
js
console.log(Person.__proto__ === Function.prototype); // true
二、原型链:从孙子到老祖宗
我们来看一个完整的查找链:
js
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name}在吃东西`);
};
function Dog(name, breed) {
Animal.call(this, name);
this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
console.log('汪汪汪');
};
const wangcai = new Dog('旺财', '土狗');
wangcai.bark(); // 汪汪汪 (自己原型上的)
wangcai.eat(); // 旺财在吃东西 (从Animal原型继承来的)
wangcai.toString(); // [object Object] (从Object原型来的)
当调用wangcai.eat()时,查找过程是这样的:
- 先看
wangcai自己身上有没有eat方法 → 没有 - 去
wangcai.__proto__(也就是Dog.prototype)上找 → 没有 - 去
Dog.prototype.__proto__(也就是Animal.prototype)上找 → 找到了eat - 如果还没找到,继续往上到
Animal.prototype.__proto__(也就是Object.prototype) - 还没找到就去
Object.prototype.__proto__→ 这是null,链条结束,返回undefined
这个链条就是原型链 。它像一条家族血脉,从孙子到儿子到父亲到爷爷到祖宗,直到追溯到null。
三、原型链的终点:Object.prototype
所有普通对象的原型链终点都是Object.prototype。Object.prototype本身的原型是null。
js
console.log(Object.prototype.__proto__); // null
Object.prototype上定义了一些所有对象都有的方法,比如toString()、hasOwnProperty()、valueOf()等。这就是为什么你的数组、函数、正则都能用这些方法。
四、如何判断属性是自己的还是继承的?
有时候我们需要知道一个属性是对象自己拥有的,还是从原型链上继承来的。这时候可以用hasOwnProperty():
js
function Person(name) {
this.name = name;
}
Person.prototype.age = 18;
const p = new Person('张三');
console.log(p.hasOwnProperty('name')); // true,自己的
console.log(p.hasOwnProperty('age')); // false,继承的
console.log('age' in p); // true,不管自己的还是继承的,只要能访问到就返回true
hasOwnProperty只检查自身属性,in操作符会检查整个原型链。
五、修改原型的影响:千万别乱动
原型是共享的,所以如果你修改了原型,所有继承自它的实例都会受影响。
js
function Person() {}
const p1 = new Person();
const p2 = new Person();
Person.prototype.say = function() {
console.log('hello');
};
p1.say(); // hello
p2.say(); // hello,两个实例都有了
Person.prototype.say = function() {
console.log('world');
};
p1.say(); // world,瞬间都变了
这个特性有时候很有用(比如给内置类型添加方法),但也非常危险。尤其是在多人协作的项目里,随便修改原型可能导致难以追踪的bug。
注意 :千万不要修改内置对象的原型,比如Array.prototype、Object.prototype,除非你非常清楚自己在做什么。这会污染全局,导致不可预测的行为。
六、原型链实现继承:传统方式
在ES6的class出现之前,JS主要靠原型链实现继承。上面的Dog继承Animal就是经典写法:
js
// 1. 定义父类构造函数
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name}吃东西`);
};
// 2. 定义子类构造函数
function Dog(name, breed) {
Animal.call(this, name); // 继承属性
this.breed = breed;
}
// 3. 继承方法
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 4. 添加子类自己的方法
Dog.prototype.bark = function() {
console.log('汪汪汪');
};
这三步是经典组合寄生继承,ES6的class语法就是它的语法糖。
七、ES6 class:原型的"糖衣"
现在写继承用class简单多了:
js
class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log(`${this.name}吃东西`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // 调用父类构造函数
this.breed = breed;
}
bark() {
console.log('汪汪汪');
}
}
看着舒服多了吧?但其实它底层还是原型那一套,只是帮我们省去了手动操作prototype的麻烦。
八、常见坑点与最佳实践
1. 不要用__proto__
__proto__虽然能用,但不是标准(虽然现代浏览器都支持),而且性能也不太好。用Object.getPrototypeOf()和Object.setPrototypeOf()替代。
js
console.log(Object.getPrototypeOf(zhangsan) === Person.prototype); // true
2. 小心原型链上的属性被覆盖
如果子类实例定义了与原型同名的属性,会"遮蔽"原型上的属性。
js
function Person() {}
Person.prototype.name = '祖先';
const p = new Person();
p.name = '自己';
console.log(p.name); // '自己',原型的被遮住了
delete p.name;
console.log(p.name); // '祖先',删除自己的,又露出来了
3. 用Object.create()创建对象
Object.create(proto)可以创建一个新对象,它的原型直接指向proto。
js
const parent = { name: '父亲' };
const child = Object.create(parent);
child.age = 10;
console.log(child.name); // '父亲',从parent继承
这是创建原型关系最简单的方式。
4. 尽量用class,少手动操作原型
现代开发中,class语法足够应对绝大多数场景,代码更清晰,不容易出错。
九、总结:原型链就是JS的"家谱"
- 每个函数都有
prototype属性(指向原型对象) - 每个实例都有
__proto__属性(指向构造函数的prototype) - 访问属性时,先在自身找,找不到就沿着
__proto__往上找,直到null - 这个链条就是原型链
Object.prototype是链条的终点,上面定义了所有对象都有的方法- ES6的
class是原型的语法糖,写起来更清爽
理解了原型链,你就能理解JS的继承机制,也能更高效地利用这个"家族关系"来复用代码。明天我们将在此基础上,深入讲解继承的多种实现方式,从原型链继承到ES6 class,一次性帮你理清JS继承的所有姿势。
如果你觉得这篇文章讲得清楚明白,点个赞让更多人看到。有疑问欢迎评论区留言,我们明天见!