JavaScript 中的面向对象编程:从基础到继承
JavaScript(简称 JS)作为一种灵活的脚本语言,常被描述为"基于对象"(Object-based)的语言,而不是严格意义上的"面向对象"(Object-Oriented Programming,OOP)语言。这是因为 JS 的核心机制依赖于原型(prototype)系统,而非传统的类(class)继承模型。尽管如此,JS 提供了强大的工具来实现 OOP 的核心概念,如封装、继承和多态。早在 ES6 之前,开发者就通过构造函数和原型链来模拟 OOP;ES6 引入的 class 关键字则让代码更接近传统 OOP 的语法,但底层仍基于原型。
对象创建的基础:字面量和实例化
在 JS 中,几乎一切都是对象------甚至基本数据类型(如字符串和数字)都有对应的包装类(如 String 和 Number)。最简单的对象创建方式是使用对象字面量(object literal),这是一种直接定义对象属性的语法。
例如:
javascript
var Cat = {
name: "",
color: ""
};
var cat1 = {};
cat1.name = '加菲猫';
cat1.color = "橘色";
var cat2 = {};
cat2.name = '黑猫警长';
cat2.color = "黑色";
这里,Cat 作为一个模板对象,但每个实例(如 cat1 和 cat2)都需要手动赋值属性。这虽然简单,但会导致代码重复,尤其当需要创建多个相似对象时。扩展来说,在实际项目中,这种方法适合快速原型开发,但不利于大规模代码维护,因为缺乏封装和复用机制。
为了解决这个问题,JS 使用构造函数(Constructor)来封装实例化过程。构造函数是一个普通函数,但通过 new 关键字调用时,会自动创建一个空对象,并将 this 指向该对象。
示例:
javascript
function Cat(name, color) {
console.log(this); // 输出一个空对象
this.name = name;
this.color = color;
}
// 作为构造函数调用
const cat1 = new Cat('加菲猫', '橘色');
console.log(cat1); // { name: '加菲猫', color: '橘色' }
// 实例间共享构造函数
const cat2 = new Cat('黑猫警长', '黑色');
console.log(cat1.constructor === cat2.constructor); // true
console.log(cat1 instanceof Cat); // true
扩展解释:new 操作符的内部过程包括:
- 创建一个空对象
{}。 - 将该对象的
__proto__指向构造函数的prototype。 - 将
this绑定到该对象,并执行构造函数代码。 - 返回该对象(除非构造函数显式返回其他值)。
如果不使用 new,函数中的 this 会指向全局对象(如浏览器中的 window),这可能导致意外行为。这强调了构造函数在 JS OOP 中的核心作用:它封装了对象初始化逻辑,确保每个实例都有独立的属性。
原型模式:共享属性和方法
在构造函数中直接定义方法或共享属性(如 type 或 eat)会导致每个实例都复制一份相同的代码,这浪费内存。原型模式通过 prototype 属性解决这个问题:将不变的属性和方法放置在构造函数的原型对象上,所有实例通过原型链(prototype chain)共享它们。
示例:
javascript
function Cat(name, color) {
this.name = name;
this.color = color;
}
Cat.prototype.type = '猫科动物';
Cat.prototype.eat = function() {
console.log('喜欢jerry');
};
var cat1 = new Cat('tom', '黑色');
var cat2 = new Cat('咖啡猫', '橘色');
console.log(cat1.type, cat2.type); // '猫科动物' '猫科动物'
cat1.type = '铲屎官的主人'; // 只影响 cat1 的自身属性
console.log(cat1.type, cat2.type); // '铲屎官的主人' '猫科动物'
这里,type 和 eat 被所有实例共享。如果在实例上修改(如 cat1.type),它会创建一个自身属性(own property),遮蔽原型上的值,但不会影响其他实例。
扩展来说,原型链是 JS 继承的基础:每个对象都有一个 __proto__ 属性,指向其构造函数的 prototype。属性查找时,先检查自身属性,然后顺着原型链向上查找,直到 Object.prototype 或 null。这提高了效率,尤其在大型应用中(如 React 组件库),可以避免重复定义通用方法。
要检查属性来源:
hasOwnProperty(prop):检查是否为自身属性。in操作符:检查属性是否存在于自身或原型链。for...in循环:遍历自身和原型链上的可枚举属性。
示例:
javascript
console.log(cat1.hasOwnProperty('type')); // false (来自原型)
console.log(cat1.hasOwnProperty('name')); // true
console.log("type" in cat1); // true
ES6 Class:语法糖的便利
ES6 引入 class 关键字,让 JS 的 OOP 代码更像 Java 或 C++,但底层仍是原型机制。
示例:
javascript
class Cat {
constructor(name, color) {
this.name = name;
this.color = color;
}
eat() {
console.log("eat jerry");
}
}
const cat1 = new Cat('tom', 'black');
cat1.eat(); // "eat jerry"
console.log(cat1.__proto__); // Cat 的 prototype
扩展:class 是构造函数的语法糖。方法定义在 prototype 上,属性在构造函数中初始化。这简化了代码,但开发者仍需理解原型链,以避免常见错误如修改共享状态。
继承:通过构造函数绑定和原型链
继承允许子类复用父类的属性和方法。JS 通过两种方式实现:
- 构造函数绑定 :使用
apply或call将父构造函数的this绑定到子实例。 - 原型继承 :将子类的
prototype设置为父类的实例。
示例(构造函数绑定):
javascript
function Animal() {
this.species = '动物';
}
function Cat(name, color) {
Animal.apply(this); // 绑定 Animal 的 this 到 Cat 实例
this.name = name;
this.color = color;
}
const cat = new Cat("加菲猫", "橘色");
console.log(cat.species); // '动物'
这继承了属性,但未继承方法。
完整继承示例(结合原型):
javascript
function Animal() {
this.species = '动物';
}
Animal.prototype.sayHi = function() {
console.log('lll');
};
function Cat(name, color) {
Animal.apply(this);
this.name = name;
this.color = color;
}
Cat.prototype = new Animal(); // 原型继承
const cat = new Cat('加菲猫', '橘色');
cat.sayHi(); // 'lll'
扩展:这种"组合继承"是最常见的模式。它避免了经典继承的缺点(如多次调用父构造函数)。在现代 JS 中,ES6 的 extends 和 super 进一步简化:
javascript
class Animal {
constructor() {
this.species = '动物';
}
sayHi() {
console.log('哪哪哪啦');
}
}
class Cat extends Animal {
constructor(name, color) {
super(); // 调用父构造函数
this.name = name;
this.color = color;
}
}
这在实际开发中(如构建组件继承树)非常实用。
JS OOP 的实用价值
JS 的 OOP 机制虽不同于传统语言,但其灵活性使其在前端开发中大放异彩。通过构造函数、原型和继承,我们可以创建高效、可维护的代码库。例如,在 Vue 或 React 项目中,这些概念常用于组件复用和状态管理。理解原型链还能帮助调试继承相关的问题,如属性遮蔽。