1. 项目概述:为什么今天还要深挖ES5的面向对象?
如果你是一名JavaScript开发者,尤其是从ES6时代才开始接触这门语言的,可能会觉得“ES5的面向对象”这个话题有点过时了。毕竟,现在谁还用function关键字来写类,用prototype来挂方法呢?class语法糖它不香吗?直接写extends继承它不优雅吗?
这正是我想和你聊这个话题的原因。我见过太多开发者,对class、extends、super这些语法如数家珍,但一旦被问到“JavaScript的继承到底是怎么实现的?”、“new操作符背后做了什么?”、“instanceof的原理是什么?”,就立刻语塞。知其然,而不知其所以然,这就像盖房子只关心装修风格,却不了解地基和承重结构。当你的应用遇到一些诡异的、与原型链相关的Bug时,或者当你需要阅读、维护一些遗留的老代码库时,这种“地基”知识的缺失会让你寸步难行。
ES5的面向对象实现,正是JavaScript这门语言面向对象特性的“地基”。它没有语法糖的包装,直接暴露了最核心的机制:构造函数、原型对象、原型链。理解这套机制,不仅能让你彻底搞懂ES6的class到底在帮你做什么,更能让你在遇到复杂对象关系、性能优化、甚至是框架源码阅读时,拥有降维打击的能力。这不是在学“过时”的技术,而是在修炼内功。今天,我们就抛开class的糖衣,回到那个“质朴”的ES5时代,亲手用最原始的工具,搭建起JavaScript面向对象的大厦。你会发现,那些看似复杂的魔法,其底层逻辑清晰而有力。
2. 核心基石:构造函数与new操作符的魔法
在ES5中,我们没有class关键字。创建一种“类型”的蓝图,靠的是一个普通的函数,我们称之为构造函数。
2.1 构造函数的本质与约定
构造函数本质上就是一个函数。但为了区分它和普通函数,我们有一个不成文的约定:构造函数的函数名首字母大写。比如Person、Animal、Car。这个约定非常重要,它提醒你和其他开发者:“嘿,这个函数需要用new来调用,它是用来创建对象的模具。”
// 这是一个构造函数 function Person(name, age) { // 函数体内部,this指向即将被创建的新对象 this.name = name; this.age = age; this.sayHello = function() { console.log('Hello, I am ' + this.name); }; }看到这里,你可能会想,这不就是在函数里给this添加属性吗?没错,但关键就在于this的指向,而改变this指向的魔法,就来自于new操作符。
2.2new操作符的四步拆解
当你写下var person1 = new Person('Alice', 25);时,JavaScript引擎在背后默默地为你做了四件事:
- 创建一个全新的空对象:在内存中开辟一块新空间,创建一个纯净的
{}。 - 将这个新对象的原型(
[[Prototype]]),链接到构造函数的prototype属性所指向的对象。这是实现继承和共享方法的关键一步,我们稍后会深入。此时,新对象就可以访问构造函数原型上的属性和方法了。 - 将构造函数内部的
this绑定到这个新创建的对象上。于是,在构造函数内部,所有this.xxx = yyy的操作,都是在为这个新对象添加属性和方法。 - 执行构造函数内部的代码(为这个新对象添加属性)。
- 如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象。如果构造函数返回了一个非原始值(对象、数组、函数等),则
new表达式的结果就是这个返回的值。
我们可以用一段代码来模拟new操作符的行为,这能让你看得更清楚:
function myNew(constructorFn, ...args) { // 1. 创建一个新对象,并将其原型指向构造函数的prototype const obj = Object.create(constructorFn.prototype); // 2. 执行构造函数,并将this绑定到新对象上 const result = constructorFn.apply(obj, args); // 3. 判断构造函数执行后返回的是否是一个对象,是则返回该对象,否则返回新创建的对象 return result instanceof Object ? result : obj; } // 使用我们自己的myNew var person2 = myNew(Person, 'Bob', 30); console.log(person2.name); // 'Bob' console.log(person2 instanceof Person); // true注意:这里有一个非常常见的误区。在我们上面的
Person构造函数中,我将sayHello方法定义在了构造函数内部(this.sayHello = function() {...})。这意味着,每new一个Person实例,都会在内存中创建一个全新的sayHello函数。如果创建1000个Person对象,就会有1000个功能完全相同的sayHello函数,这无疑是巨大的内存浪费。这引出了我们下一个核心概念——原型。
3. 灵魂所在:原型对象与原型链机制
为了解决上述方法重复创建的内存浪费问题,JavaScript设计了原型(Prototype)。
3.1 理解prototype和__proto__
这是两个最容易混淆的概念,务必分清:
prototype属性:这是函数(特别是构造函数)独有的属性。它是一个对象,我们称之为原型对象。这个对象的用途,就是存放该构造函数创建的所有实例可以共享的属性和方法。__proto__属性:这是每个对象(包括函数对象)都有的一个内部属性(在ES5标准中,更准确的描述是[[Prototype]],__proto__是多数浏览器提供的访问它的非标准方式,现已标准化为Object.getPrototypeOf)。它指向创建该对象的构造函数的prototype对象。
它们的关系是:实例对象的__proto__=== 其构造函数的prototype。
让我们改造之前的Person,将方法放到原型上:
function Person(name, age) { // 实例属性,每个对象独有一份 this.name = name; this.age = age; } // 将方法定义在构造函数的prototype对象上 Person.prototype.sayHello = function() { console.log('Hello, I am ' + this.name); }; Person.prototype.species = 'Homo Sapiens'; // 甚至可以定义共享的属性 var p1 = new Person('Alice', 25); var p2 = new Person('Bob', 30); console.log(p1.sayHello === p2.sayHello); // true! 两个实例共享同一个方法 console.log(p1.__proto__ === Person.prototype); // true console.log(Object.getPrototypeOf(p1) === Person.prototype); // true (标准方法)现在,无论创建多少个Person实例,sayHello方法在内存中都只有一份,存放在Person.prototype这个对象里。每个实例通过其__proto__链找到并调用它。这就是原型继承的核心优势:共享方法,节省内存。
3.2 原型链:属性查找的“向上委托”机制
当你访问一个对象的属性(比如p1.sayHello)时,JavaScript引擎会执行以下步骤:
- 首先在对象自身属性中查找(即
p1对象上直接定义的name,age)。 - 如果没找到,就通过
__proto__去它的原型对象(即Person.prototype)上查找。 - 如果在
Person.prototype上还没找到,就继续通过Person.prototype.__proto__向上查找。 - 这条由
__proto__串联起来的链,就叫做原型链。 - 这个查找过程会一直持续到
Object.prototype(所有对象的顶层原型),如果还没找到,则返回undefined。
console.log(p1.toString); // ƒ toString() { [native code] } // p1自身没有toString,Person.prototype上也没有。 // 查找顺序:p1 -> p1.__proto__(Person.prototype) -> Person.prototype.__proto__(Object.prototype) -> 找到!原型链是实现继承的基石。它描述了一种“委托”机制:对象自己没有的能力,可以委托给原型链上游的对象去完成。
3.3 静态方法、实例方法与原型方法
理解了原型,我们就能清晰地区分这三种方法:
- 实例方法/属性:定义在构造函数内部
this上的,每个实例独有一份。如this.name。 - 原型方法/属性:定义在
Constructor.prototype上的,所有实例共享一份。如Person.prototype.sayHello。 - 静态方法/属性:直接定义在构造函数本身上的,只能通过构造函数访问,实例不能访问。常用于工具函数或与实例无关的操作。
function MathUtils() {} // 静态方法 MathUtils.add = function(a, b) { return a + b; }; // 原型方法(假设我们要创建数学对象实例,虽然这里不常见) MathUtils.prototype.square = function(x) { return x * x; }; console.log(MathUtils.add(1, 2)); // 3, 通过构造函数调用 // var util = new MathUtils(); util.add(1,2) // 报错,实例无法访问静态方法 console.log(new MathUtils().square(5)); // 25, 实例可以访问原型方法4. 实现继承:ES5时代的三种经典模式
在ES5中,实现继承需要我们手动操作原型链。这里有三种主流模式,各有优劣。
4.1 原型链继承:最直接但也问题最多
核心思想:让子类的原型对象等于父类的一个实例。
function Parent() { this.name = 'parent'; this.colors = ['red', 'blue']; } Parent.prototype.sayName = function() { console.log(this.name); }; function Child() {} // 关键步骤:原型链继承 Child.prototype = new Parent(); var c1 = new Child(); c1.sayName(); // 'parent', 继承成功 console.log(c1.colors); // ['red', 'blue'] var c2 = new Child(); c1.colors.push('green'); console.log(c2.colors); // ['red', 'blue', 'green'] 问题1:引用类型的属性被所有实例共享! c1.name = 'child1'; console.log(c1.name); // 'child1' console.log(c2.name); // 'parent', 基本类型没问题,但这是直接在c1上添加了自身属性,并非修改原型链 // 问题2:无法向父类构造函数传参优点:实现简单,能继承父类实例和原型上的属性和方法。致命缺点:
- 所有子类实例共享父类实例的引用类型属性(如数组、对象),一个修改,全部受影响。
- 创建子类实例时,无法向父类构造函数传递参数。
4.2 构造函数继承(经典继承)
核心思想:在子类构造函数内部,调用父类构造函数(通过call或apply改变this指向)。
function Parent(name) { this.name = name; this.colors = ['red', 'blue']; } Parent.prototype.sayName = function() { console.log(this.name); }; function Child(name, age) { // 关键步骤:构造函数继承 Parent.call(this, name); // 相当于 this.Parent(name) this.age = age; } var c1 = new Child('Alice', 25); var c2 = new Child('Bob', 30); c1.colors.push('green'); console.log(c1.colors); // ['red', 'blue', 'green'] console.log(c2.colors); // ['red', 'blue'] 问题解决!引用类型属性独立了 console.log(c1.name, c2.name); // 'Alice', 'Bob' 可以传参了! c1.sayName(); // TypeError: c1.sayName is not a function优点:
- 解决了原型链继承中,引用类型属性共享的问题。
- 可以在子类构造函数中向父类构造函数传参。致命缺点:无法继承父类原型上的方法(
sayName访问不到)。方法都定义在构造函数里,又回到了每个实例独有一份方法的内存浪费老路。
4.3 组合继承(伪经典继承)—— ES5最常用的继承模式
核心思想:结合原型链继承和构造函数继承,取其精华,去其糟粕。
- 使用构造函数继承来继承父类的实例属性(解决引用共享和传参问题)。
- 使用原型链继承来继承父类原型上的方法(实现方法共享)。
function Parent(name) { this.name = name; this.colors = ['red', 'blue']; } Parent.prototype.sayName = function() { console.log(this.name); }; function Child(name, age) { // 1. 构造函数继承(继承实例属性) Parent.call(this, name); // 第二次调用Parent this.age = age; } // 2. 原型链继承(继承原型方法) Child.prototype = new Parent(); // 第一次调用Parent // 修正constructor指向,否则Child.prototype.constructor会指向Parent Child.prototype.constructor = Child; // 可以在子类原型上添加自己的方法 Child.prototype.sayAge = function() { console.log(this.age); }; var c1 = new Child('Alice', 25); var c2 = new Child('Bob', 30); c1.colors.push('green'); console.log(c1.colors); // ['red', 'blue', 'green'] console.log(c2.colors); // ['red', 'blue'] // 实例属性独立 c1.sayName(); // 'Alice' // 可以调用父类原型方法 c1.sayAge(); // 25 // 可以调用子类原型方法 console.log(c1 instanceof Child); // true console.log(c1 instanceof Parent); // true优点:融合了两种模式的优点,是ES5中最常用的继承范式。既能保证实例属性的独立性,又能实现原型方法的共享,同时还能通过instanceof识别对象关系。缺点:存在一个小小的效率问题:父类构造函数被调用了两次(Parent.call一次,new Parent()一次)。这导致子类原型(Child.prototype)上会有一份多余的、来自父类的实例属性(虽然被子类实例自身的属性覆盖,但确实存在)。
4.4 寄生组合式继承——公认的ES5继承最佳实践
为了解决组合继承中父类构造函数被调用两次的瑕疵,出现了寄生组合式继承。它被普遍认为是ES5中最理想的继承方式。 核心思想:不通过new Parent()来创建子类原型,而是直接获得父类原型的一个副本。
function inheritPrototype(child, parent) { // 关键步骤1:创建一个以父类原型为原型的新对象(等同于Object.create(parent.prototype)) var prototype = Object.create(parent.prototype); // 关键步骤2:增强对象,修正constructor指向 prototype.constructor = child; // 关键步骤3:将新对象赋值给子类的原型 child.prototype = prototype; } function Parent(name) { this.name = name; this.colors = ['red', 'blue']; } Parent.prototype.sayName = function() { console.log(this.name); }; function Child(name, age) { // 构造函数继承(只调用一次Parent) Parent.call(this, name); this.age = age; } // 使用寄生组合式继承替换原来的 Child.prototype = new Parent() inheritPrototype(Child, Parent); // 添加子类原型方法 Child.prototype.sayAge = function() { console.log(this.age); }; var c1 = new Child('Alice', 25); c1.sayName(); // 'Alice' c1.sayAge(); // 25 console.log(c1 instanceof Child); // true console.log(c1 instanceof Parent); // true优点:
- 只调用了一次父类构造函数(
Parent.call),效率更高。 - 原型链保持干净正确。
- 能够正常使用
instanceof和isPrototypeOf。
这几乎是完美的ES5继承方案,也是后来ES6的classextends语法在底层所采用的实现原理(或类似原理)。
5. 高级特性与核心API解析
掌握了继承,我们再来看看ES5中几个与面向对象密切相关的核心API和特性。
5.1instanceof操作符的工作原理
我们经常用obj instanceof Constructor来判断对象是否属于某个构造函数的实例。它的原理就是检查构造函数的prototype属性是否出现在对象的原型链上。
console.log(c1 instanceof Child); // true // 引擎检查:Child.prototype 是否在 c1.__proto__ 这条链上?是的。 console.log(c1 instanceof Parent); // true // 引擎检查:Parent.prototype 是否在 c1.__proto__ 这条链上?是的。 console.log(c1 instanceof Object); // true // 所有对象的原型链顶端都是Object.prototype我们可以自己实现一个简单的myInstanceof:
function myInstanceof(obj, constructorFn) { let proto = Object.getPrototypeOf(obj); // 获取对象的原型 const prototype = constructorFn.prototype; // 获取构造函数的原型对象 while (proto !== null) { if (proto === prototype) { return true; } proto = Object.getPrototypeOf(proto); // 沿着原型链向上查找 } return false; }5.2Object.create():以指定原型创建对象
这是ES5中一个极其重要的方法。它创建一个新对象,并使用现有的对象来作为新创建对象的原型。
var parentProto = { sayHello: function() { console.log('Hello from parent'); } }; var child = Object.create(parentProto); child.name = 'Child'; child.sayHello(); // 'Hello from parent' console.log(Object.getPrototypeOf(child) === parentProto); // trueObject.create(null)会创建一个没有任何原型([[Prototype]]为null)的“纯净”对象,常用作字典(Map的替代)以避免原型链属性的干扰。
5.3constructor属性及其意义
每个原型对象(prototype)都有一个constructor属性,默认指向该原型关联的构造函数。
function Person() {} console.log(Person.prototype.constructor === Person); // true var p = new Person(); console.log(p.constructor === Person); // true (通过原型链找到的)当我们重写原型对象时(如Child.prototype = new Parent()),会破坏这个默认的指向,所以好的实践是手动修正它:Child.prototype.constructor = Child;。这并非必需,但保持了对象关系的清晰性。
5.4 属性描述符与对象控制
ES5引入了Object.defineProperty(),允许我们精确地定义或修改对象的属性。这不仅是Vue 2响应式系统的基石,也是实现高级对象行为控制的关键。
var obj = {}; Object.defineProperty(obj, 'key', { value: 'static', writable: false, // 不可写 enumerable: false, // 不可枚举(for...in, Object.keys看不到) configurable: false // 不可配置(不能删除,不能再次修改描述符) }); obj.key = 'new'; // 静默失败(严格模式下报错) console.log(obj.key); // 'static'通过get和set描述符,我们可以创建访问器属性,实现属性的计算和拦截。
6. 常见问题、陷阱与最佳实践实录
在实际开发中,仅仅知道原理是不够的,踩过坑才知道哪里路不平。下面是我总结的一些典型问题和实践建议。
6.1 原型链操作中的经典陷阱
陷阱一:在重写原型后,再为其添加方法
function Person() {} var p1 = new Person(); // 此时p1.__proto__指向旧的Person.prototype // 完全重写原型对象 Person.prototype = { sayHello: function() { console.log('hello'); } }; var p2 = new Person(); // p2.__proto__指向新的原型对象 p2.sayHello(); // 'hello', 正常 p1.sayHello(); // TypeError! p1的原型链还连着那个空的老原型对象。实操心得:如果要扩展原型,使用
Person.prototype.sayHello = ...。如果要彻底重写原型,务必在创建任何实例之前完成,或者清楚知道这会切断已创建实例与原型的联系。
陷阱二:循环引用导致的原型链死循环(几乎不可能,但需理解)理论上,如果A.prototype = new B()且B.prototype = new A(),就会形成循环原型链。JavaScript引擎会抛出错误。实践中应避免这种设计。
6.2 性能考量与内存优化
- 将方法放在原型上:这是铁律。除非方法必须访问构造函数内的私有变量(这时可以考虑用闭包),否则一律定义在
prototype上。 - 谨慎使用
for...in循环:for...in会遍历对象自身及其原型链上所有可枚举的属性。如果只想遍历自身属性,一定要用hasOwnProperty进行过滤。for (var key in obj) { if (obj.hasOwnProperty(key)) { // 只处理对象自身的属性 } } - 缓存原型方法:在需要频繁调用原型方法的循环内部,可以先将方法引用缓存到局部变量,避免每次都要查找原型链。
var sayHello = person.sayHello; // 假设sayHello在原型上 for (var i = 0; i < 10000; i++) { sayHello.call(person); // 比 person.sayHello() 查找次数少一次 }
6.3 如何安全地扩展内置对象原型?
扩展Object.prototype、Array.prototype等内置对象的原型是非常危险的行为,因为它会影响所有该类型的对象,可能会与未来JavaScript标准或其他库产生冲突。最佳实践是:尽量避免。如果非要扩展,务必:
- 使用
Object.defineProperty并设置enumerable: false,避免在for...in中被遍历到。 - 方法名要非常独特,比如加上库的前缀
_myLib_。 - 先检查该方法是否已存在。
if (!Array.prototype._myLib_secureForEach) { Object.defineProperty(Array.prototype, '_myLib_secureForEach', { value: function(callback) { /* ... */ }, writable: true, configurable: true, enumerable: false // 关键!不可枚举 }); }6.4 面向对象设计建议
- 优先使用组合而非继承:这是现代软件工程的重要原则。如果“有一个”的关系能解决问题,就不要用“是一个”的关系(继承)。继承会带来紧耦合。可以通过在类内部持有其他类的实例(组合)来实现功能复用,这更灵活。
- 构造函数用于定义差异化的实例属性,原型用于定义共享的行为和方法。职责分离,结构清晰。
- 对于复杂的继承关系,寄生组合式继承是你的首选。它概念清晰,没有副作用,是通往ES6
class理解的最佳桥梁。
7. 从ES5到ES6:class语法糖的真相
最后,我们站在ES5的肩膀上,看一眼ES6的class。你会恍然大悟,原来它只是语法糖。
// ES6 Class class Person { constructor(name) { this.name = name; } sayHello() { console.log(`Hello, ${this.name}`); } static staticMethod() { console.log('I am static'); } } // 上述代码几乎等价于下面的ES5代码 function Person(name) { this.name = name; } Person.prototype.sayHello = function() { console.log('Hello, ' + this.name); }; Person.staticMethod = function() { console.log('I am static'); }; // 继承 class Student extends Person { constructor(name, grade) { super(name); // 相当于 Parent.call(this, name) this.grade = grade; } } // 其底层实现,本质上就是寄生组合式继承!class并没有引入新的面向对象模型,它只是让基于原型的语法更加清晰、更像传统面向对象语言。constructor就是构造函数,类内部定义的方法就是原型方法,static关键字定义的就是静态方法,extends和super则封装了原型链搭建和父类构造函数调用的繁琐细节。
所以,当你深入理解了ES5的构造函数、原型和原型链之后,再看ES6的class,一切都会变得通透。你不会再对super的调用顺序感到迷惑,也不会对class中的方法为什么不能枚举感到奇怪(因为class中定义的方法默认就是不可枚举的,这对应着Object.defineProperty的enumerable: false)。
回过头来看,ES5的面向对象实现或许显得“粗糙”,但正是这份粗糙,让你能亲手触摸到JavaScript对象系统的每一块基石。理解了这些,你不仅能写出更健壮的ES5兼容代码,更能以俯视的角度,从容地运用ES6及以后的所有新特性。这才是学习“旧”技术的真正价值——它让你在快速变化的技术浪潮中,始终拥有那份深刻的理解力和定力。