1. 项目概述:从“对象”说起,理解JavaScript的基石
如果你写过JavaScript,那你一定用过对象。无论是从后端接口拿到的一个JSON数据,还是用document.getElementById获取的一个DOM元素,它们都是对象。但“面向对象”这四个字,在JavaScript里,总带着点神秘和混乱。新人常常被prototype、__proto__、constructor这些概念绕晕,而一些有经验的开发者,也可能只是停留在“会用class语法糖”的层面,一旦遇到继承的深坑或者需要手写一个精巧的工具函数时,底层的无力感就上来了。
这个内容,就是要彻底掀开JavaScript面向对象编程(OOP)的盖子。我们不只讲怎么用new关键字,更要回溯到没有class的年代,看看那些最原始、最本质的创建对象和实现继承的模式:工厂模式、构造函数模式、原型模式。理解了这些,你才能真正看懂那条著名的“原型链”到底是怎么连起来的,为什么一个数组能调用toString方法,以及instanceof运算符背后究竟在检查什么。这对于你深入理解JavaScript语言本身、应对复杂面试题、甚至是进行一些高级的JS逆向分析都至关重要。无论你是刚入门想夯实基础的新手,还是希望突破瓶颈的中级开发者,这次对“对象”的深度探索,都会让你对JavaScript有焕然一新的认识。
2. 核心模式演进:从“制造”到“描述”
JavaScript的面向对象之路,是一部从“模拟”到“本质”的演进史。它没有像Java、C++那样一开始就提供“类”的概念,而是提供了一套更为灵活的机制,让我们可以通过多种方式达到“对象复用”和“继承”的目的。理解这几种经典模式,不仅是学习历史,更是掌握JavaScript动态语言精髓的关键。
2.1 工厂模式:对象的“流水线”
在早期,如果我们想批量创建一系列结构相似的对象,最直观的想法就是写一个函数,这个函数像一个工厂车间,接收原材料(参数),然后生产并返回一个加工好的产品(对象)。
function createPerson(name, age) { var obj = new Object(); // 1. 原材料:创建一个新对象 obj.name = name; // 2. 加工:为对象添加属性 obj.age = age; obj.sayName = function() { console.log(this.name); }; return obj; // 3. 出厂:返回这个对象 } var person1 = createPerson('Alice', 25); var person2 = createPerson('Bob', 30); person1.sayName(); // 输出:Alice模式解析与核心问题: 工厂模式的逻辑非常清晰,封装了创建对象的细节。但它有两个显著的缺陷:
- 对象类型识别问题:
person1和person2都是Object的实例,我们无法区分它们是由createPerson工厂创建的,还是由其他函数创建的。console.log(person1 instanceof Object); // true,但我们更希望有person1 instanceof createPerson这样的判断,可惜做不到。 - 方法冗余:每次调用
createPerson,都会创建一个新的sayName函数。这意味着person1.sayName和person2.sayName是两个完全不同的函数对象,尽管它们的功能一模一样。这在创建成千上万个对象时,会造成巨大的内存浪费。
注意:工厂模式解决了“批量创建”的问题,但没有解决“对象标识”和“方法共享”的问题。它更像是基于对象的封装,而非真正意义上的面向对象。
2.2 构造函数模式:赋予对象“身份”
为了解决工厂模式的对象类型识别问题,JavaScript提供了构造函数模式。构造函数本质上就是一个普通函数,但按照约定,其首字母大写。调用方式也发生了关键变化:必须使用new操作符。
function Person(name, age) { // 1. 隐式操作:new 关键字会创建一个新对象,并将this绑定到这个新对象。 this.name = name; // 2. 执行构造函数代码:为新对象添加属性 this.age = age; this.sayName = function() { console.log(this.name); }; // 3. 隐式返回:构造函数执行完毕,自动返回这个新对象。 } var person1 = new Person('Alice', 25); var person2 = new Person('Bob', 30); console.log(person1 instanceof Person); // true! 问题一解决 console.log(person1 instanceof Object); // true console.log(person1.constructor === Person); // true,constructor属性指向构造函数new操作符背后的四步魔法:
- 创建:在内存中创建一个全新的空对象。
- 链接:将这个新对象的
[[Prototype]](即__proto__)内部属性,指向构造函数的prototype属性所指向的对象。这是原型链连接的起点。 - 绑定:将构造函数内部的
this绑定到这个新创建的对象上。 - 执行与返回:执行构造函数内部的代码(为
this添加属性)。如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象。
遗留问题: 构造函数模式完美解决了对象类型识别的问题(instanceof和constructor都可用),但工厂模式中的方法冗余问题依然存在。person1.sayName === person2.sayName的结果是false。每个实例仍然拥有自己独立的方法副本。
2.3 原型模式:共享的蓝图
我们终于来到了JavaScript面向对象的核心——原型模式。它的核心思想是:让所有对象实例共享它所包含的属性和方法。这个共享的容器,就是构造函数的prototype(原型)属性。
function Person() {} // 构造函数现在可以是空函数 // 将属性和方法直接添加到构造函数的原型对象上 Person.prototype.name = 'Default Name'; Person.prototype.age = 0; Person.prototype.sayName = function() { console.log(this.name); }; var person1 = new Person(); var person2 = new Person(); person1.name = 'Alice'; // 在实例上设置同名属性,会屏蔽原型上的属性 console.log(person1.name); // 'Alice',来自实例自身 console.log(person2.name); // 'Default Name',来自原型 console.log(person1.sayName === person2.sayName); // true! 方法实现了共享原型对象与实例的关系: 每个函数在创建时,都会自动获得一个prototype属性,这个属性是一个指针,指向一个对象,这个对象就是原型对象。原型对象默认只有一个属性constructor,指回构造函数。 当我们用new调用构造函数创建实例时,实例内部会包含一个[[Prototype]]指针(在浏览器中可通过__proto__访问),这个指针指向构造函数的原型对象。
属性访问的搜索机制: 当访问一个对象的属性(如person1.name)时,会发生以下搜索:
- 首先在对象实例本身查找是否有该属性。如果有,则返回,搜索停止。
- 如果没有,则沿着实例的
__proto__指针,到它的原型对象上查找。 - 如果原型对象上还没有,则继续沿着原型对象的
__proto__向上查找,直到找到属性或到达原型链的末端(null)。
这就是著名的原型链。person1.sayName之所以能被访问到,是因为在实例上没找到sayName,于是通过person1.__proto__找到了Person.prototype,并在那里找到了共享的sayName方法。
实操心得:在原型上定义方法是非常好的实践,它节约了内存。但通常不建议在原型上定义引用类型的属性(如数组、对象),因为一个实例修改了这个共享的引用类型,会影响到所有其他实例。对于需要个性化的属性,应该在构造函数内部用
this.xxx来定义。
3. 组合模式:扬长避短的实践
单纯的原型模式也有缺点:所有实例初始状态都一样(因为属性都定义在原型上)。我们更常见的需求是:实例拥有独立的属性,但共享方法。这催生了最广泛使用的组合模式:构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性。
// 组合构造函数模式和原型模式 function Person(name, age) { // 实例属性:每个实例独有一份 this.name = name; this.age = age; this.friends = ['Alice', 'Bob']; // 引用类型属性也放在这里,每个实例独立 } // 共享的方法和常量属性:定义在原型上 Person.prototype.sayName = function() { console.log(this.name); }; Person.prototype.species = 'Human'; // 共享的常量属性 var person1 = new Person('Jack', 20); var person2 = new Person('Lucy', 22); person1.friends.push('Tom'); console.log(person1.friends); // ['Alice', 'Bob', 'Tom'] console.log(person2.friends); // ['Alice', 'Bob'],互不影响 console.log(person1.sayName === person2.sayName); // true,共享方法这种模式是目前ECMAScript中使用最广泛、认同度最高的一种创建自定义类型的方法。它完美兼顾了实例的独立性和方法的共享性,代码也非常清晰。
4. 原型与原型链的深度剖析
理解了组合模式,我们已经能应对90%的日常开发。但要真正通透,必须把“原型链”这个核心机制吃透。
4.1 理解prototype、__proto__和constructor
这是最容易混淆的三个概念,它们的关系可以用一张图和一个等式来概括:
关系等式:实例.__proto__ === 其构造函数.prototype
具体解释:
prototype(显式原型):这是函数独有的属性。它是一个指针,指向一个对象,这个对象就是调用该构造函数创建的实例的原型对象。只有函数才有prototype(Function.prototype绑定的函数除外)。__proto__(隐式原型):这是每个对象都有的属性(null除外)。它指向创建该对象的构造函数的prototype。__proto__是对象查找属性的路径,构成了原型链。constructor(构造函数):这是原型对象(即xxx.prototype指向的对象)独有的属性。它指回创建当前原型对象的那个构造函数。
function Person() {} var p = new Person(); // 验证关系 console.log(p.__proto__ === Person.prototype); // true console.log(Person.prototype.constructor === Person); // true console.log(p.constructor === Person); // true (p自身没有constructor,沿原型链找到Person.prototype.constructor) // 函数的__proto__指向 Function.prototype console.log(Person.__proto__ === Function.prototype); // true // Function.prototype也是一个对象,它的__proto__指向 Object.prototype console.log(Function.prototype.__proto__ === Object.prototype); // true // Object.prototype是原型链的顶端,它的__proto__是null console.log(Object.prototype.__proto__); // null4.2 完整的原型链图示与搜索过程
让我们以一个具体的例子,描绘出完整的原型链:
function Person(name) { this.name = name; } Person.prototype.say = function() {}; var p = new Person('Tom'); // 当访问 p.toString() 时,JavaScript引擎如何查找? // 1. 检查 p 对象自身是否有 toString 属性?没有。 // 2. 通过 p.__proto__ 找到 Person.prototype,检查是否有 toString?没有。 // 3. 通过 Person.prototype.__proto__ 找到 Object.prototype,检查是否有 toString?有!调用它。这条查找路径p -> Person.prototype -> Object.prototype -> null就是p实例的原型链。几乎所有对象最终都会链到Object.prototype,因此所有对象都能调用toString、valueOf、hasOwnProperty等方法。
instanceof运算符的原理:A instanceof B的检查逻辑是:沿着A的__proto__链向上查找,看能否找到B.prototype。如果能找到,返回true;如果直到null都没找到,返回false。
console.log(p instanceof Person); // true (p.__proto__ === Person.prototype) console.log(p instanceof Object); // true (p.__proto__.__proto__ === Object.prototype) console.log(Person instanceof Function); // true (Person.__proto__ === Function.prototype)4.3 原型链继承的实现与缺陷
基于原型链,我们可以实现继承。子类型的原型对象等于父类型的一个实例。
// 父类 function SuperType() { this.property = true; this.colors = ['red', 'blue']; } SuperType.prototype.getSuperValue = function() { return this.property; }; // 子类 function SubType() { this.subproperty = false; } // 关键步骤:实现继承。让子类的原型指向父类的一个实例。 SubType.prototype = new SuperType(); // 原型链继承 // 修复 constructor 指向(可选但建议) SubType.prototype.constructor = SubType; // 为子类添加新方法(必须在继承之后) SubType.prototype.getSubValue = function() { return this.subproperty; }; var instance = new SubType(); console.log(instance.getSuperValue()); // true,成功调用父类方法原型链继承的致命缺陷:
- 引用类型属性共享问题:由于
SubType.prototype是SuperType的一个实例,父类构造函数中定义的引用类型属性(如colors数组)就变成了子类原型上的属性,会被所有子类实例共享。var instance1 = new SubType(); var instance2 = new SubType(); instance1.colors.push('green'); console.log(instance2.colors); // ['red', 'blue', 'green'],instance2也被影响了! - 无法向父类构造函数传参:在创建子类实例时,无法在不影响所有实例的情况下,向父类的构造函数传递参数。因为父类实例化(
new SuperType())发生在定义子类原型时,是早于子类实例创建的。
正因为这些缺陷,单纯的原型链继承在实践中很少直接使用。
5. 进阶继承模式与最佳实践
为了解决原型链继承的问题,开发者们探索出了多种组合方案。其中,借用构造函数继承和寄生组合式继承最为重要。
5.1 借用构造函数继承(经典继承)
思路:在子类构造函数内部,调用父类构造函数。这样每个子类实例都会拥有独立的父类属性副本。
function SuperType(name) { this.name = name; this.colors = ['red', 'blue']; } function SubType(name, age) { // 继承属性:在子类构造函数中“借用”父类构造函数 SuperType.call(this, name); // 关键!第二次调用父类构造函数 this.age = age; } var instance1 = new SubType('Jack', 20); var instance2 = new SubType('Lucy', 22); instance1.colors.push('green'); console.log(instance1.colors); // ['red', 'blue', 'green'] console.log(instance2.colors); // ['red', 'blue'],问题解决! console.log(instance1.name); // 'Jack'优点:
- 解决了原型链继承中“引用类型属性共享”和“无法传参”的问题。
- 可以在子类构造函数中向父类构造函数传递参数。
缺点:
- 方法都在构造函数中定义,无法实现函数复用(每个实例都有方法副本,内存浪费)。
- 父类原型中定义的方法,对子类不可见。
instance1无法调用SuperType.prototype上的方法。
5.2 组合继承(最常用继承模式)
组合继承结合了原型链继承(继承方法)和借用构造函数继承(继承属性),成为了JavaScript中最常用的继承模式。
function SuperType(name) { this.name = name; this.colors = ['red', 'blue']; } SuperType.prototype.sayName = function() { console.log(this.name); }; function SubType(name, age) { // 1. 继承属性(借用构造函数) SuperType.call(this, name); // 第二次调用SuperType this.age = age; } // 2. 继承方法(原型链) SubType.prototype = new SuperType(); // 第一次调用SuperType SubType.prototype.constructor = SubType; // 3. 定义子类自己的方法 SubType.prototype.sayAge = function() { console.log(this.age); }; var instance1 = new SubType('Jack', 20); instance1.colors.push('green'); console.log(instance1.colors); // ['red', 'blue', 'green'] instance1.sayName(); // 'Jack' instance1.sayAge(); // 20 var instance2 = new SubType('Lucy', 22); console.log(instance2.colors); // ['red', 'blue'] instance2.sayName(); // 'Lucy'组合继承的优缺点:
- 优点:融合了两种模式的优点,既保证了实例属性的独立性,又实现了方法的共享。是JavaScript中最常用的继承模式。
- 缺点:存在一个效率问题。父类构造函数被调用了两次:一次在创建子类原型时(
new SuperType()),一次在子类构造函数内部(SuperType.call(this))。这导致子类原型上(SubType.prototype)和子类实例上(instance1)存在两组相同的属性:一组来自原型,一组来自实例。实例上的属性会屏蔽原型上的同名属性,这虽然不影响功能,但造成了不必要的冗余。
5.3 寄生组合式继承(理想的继承范式)
寄生组合式继承被普遍认为是引用类型最理想的继承范式。它解决了组合继承调用两次父类构造函数的问题。其核心是:不必为了指定子类型的原型而调用父类的构造函数,我们所需要的无非就是父类原型的一个副本而已。
function inheritPrototype(subType, superType) { // 1. 创建父类原型的一个副本对象。Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。 var prototype = Object.create(superType.prototype); // 2. 为创建的副本添加constructor属性,弥补因重写原型而失去的默认constructor属性。 prototype.constructor = subType; // 3. 将新创建的对象(副本)赋值给子类型的原型。 subType.prototype = prototype; } // 使用示例 function SuperType(name) { this.name = name; this.colors = ['red', 'blue']; } SuperType.prototype.sayName = function() { console.log(this.name); }; function SubType(name, age) { // 只调用一次父类构造函数,继承实例属性 SuperType.call(this, name); this.age = age; } // 调用封装的函数实现原型继承,而不是 new SuperType() inheritPrototype(SubType, SuperType); // 添加子类自己的方法 SubType.prototype.sayAge = function() { console.log(this.age); }; var instance = new SubType('Tom', 25); instance.sayName(); // 'Tom' instance.sayAge(); // 25为什么这是最理想的?
- 高效率:只调用了一次
SuperType构造函数(在子类构造函数中),避免了在SubType.prototype上创建不必要的、多余的属性。 - 原型链保持不变:
instance能正常访问SuperType.prototype上的方法。 - 无属性冗余:子类实例的属性都在实例自身上,原型链干净清晰。
ES5的Object.create()方法规范化了这种模式。因此,在ES5及以后的环境中,实现继承的首选就是寄生组合式继承。
6. ES6 Class:语法糖与本质
ES6引入的class语法,让JavaScript的面向对象编程看起来更像传统语言。但必须清醒认识到:class只是一个语法糖,它的底层实现依然是我们上面讨论的原型和构造函数。
// ES6 Class 写法 class Person { constructor(name, age) { // 对应构造函数 this.name = name; this.age = age; } sayName() { // 对应原型上的方法 console.log(this.name); } static sayHello() { // 静态方法,对应构造函数自身的属性 console.log('Hello'); } } // 等同于下面的ES5代码 function Person(name, age) { this.name = name; this.age = age; } Person.prototype.sayName = function() { console.log(this.name); }; Person.sayHello = function() { console.log('Hello'); };class继承的底层:class的extends和super关键字,其内部实现就是寄生组合式继承。
class SubType extends SuperType { constructor(name, age) { super(name); // 相当于 SuperType.call(this, name) this.age = age; } }使用class的注意事项:
class中的方法都是定义在原型上的,且是不可枚举的(Object.keys()不会列出它们)。class必须使用new调用,不能像普通函数一样调用。class不存在变量提升。super在构造函数中必须在使用this之前调用。
实操心得:在现代开发中,应优先使用
class语法,它更简洁、易读,且不易出错。但在调试、理解底层机制或处理一些复杂继承关系时,脑子里必须有原型链的图景。Babel等工具将class转译成的ES5代码,就是最好的学习材料。
7. 常见问题与深度排查技巧
在实际开发和面试中,关于原型和原型链的坑点很多。这里记录一些典型问题和排查思路。
7.1 原型链污染与属性屏蔽
问题:当通过一个实例修改了原型上的引用类型属性时,会影响所有实例。
function Person() {} Person.prototype.friends = ['Alice']; var p1 = new Person(); var p2 = new Person(); p1.friends.push('Bob'); // 通过实例修改了原型上的数组 console.log(p2.friends); // ['Alice', 'Bob'],p2也受到了影响!排查与解决:
- 排查:使用
hasOwnProperty()方法判断属性是实例自身的还是原型上的。p1.hasOwnProperty('friends')返回false。 - 解决:对于需要独立化的引用类型属性,务必在构造函数中初始化,而不是放在原型上。如果确实需要修改原型数据,应通过
Person.prototype.friends直接操作,并清楚其全局影响。
7.2 重写原型对象导致的问题
问题:在已经创建了实例的情况下,完全重写构造函数的prototype对象,会导致已存在的实例与新的原型失去联系。
function Person() {} var friend = new Person(); // friend.__proto__ 指向最初的 Person.prototype // 完全重写原型对象 Person.prototype = { constructor: Person, sayName: function() { console.log('Hi'); } }; friend.sayName(); // TypeError: friend.sayName is not a function // friend的[[Prototype]]仍然指向旧的原型对象,而旧对象上没有sayName方法。排查与解决:
- 注意:重写
prototype会切断现有实例与构造函数之间的联系。如果需要添加新方法,应该使用Person.prototype.newMethod = ...的形式增量添加,而不是直接赋值一个新对象。
7.3instanceof与isPrototypeOf的差异
两者都用于检查原型链,但用法不同:
instanceof:运算符,检查构造函数的prototype属性是否出现在对象的原型链上。object instanceof ConstructorisPrototypeOf():方法,检查对象是否出现在另一个对象的原型链上。prototypeObj.isPrototypeOf(object)
console.log(friend instanceof Person); // false (重写原型后) console.log(Person.prototype.isPrototypeOf(friend)); // false // 创建一个新实例 var newFriend = new Person(); console.log(newFriend instanceof Person); // true console.log(Person.prototype.isPrototypeOf(newFriend)); // true7.4 性能考量与最佳实践
- 减少原型链查找深度:过长的原型链会影响属性查找的性能。设计时应尽量保持继承层次扁平。
- 使用
Object.create(null)创建纯字典对象:如果你需要一个完全没有原型(如toString,hasOwnProperty等方法)的纯键值对集合,可以使用Object.create(null)。这比{}更干净,且能避免原型上的属性被意外遍历或覆盖。 - 方法放在原型,数据放在实例:这是黄金法则。可变的状态数据定义在构造函数内,共享的行为方法定义在原型上。
- 谨慎使用
__proto__:虽然浏览器普遍支持__proto__,但它不是ES标准的一部分。在需要操作原型时,更推荐使用Object.getPrototypeOf()和Object.setPrototypeOf()(性能需注意)这些标准方法。
理解JavaScript的面向对象,就是理解它的“原型”本质。从工厂模式的粗糙封装,到构造函数的身份赋予,再到原型模式的共享蓝图,最后通过组合与寄生组合实现优雅的继承,这条演进路径揭示了JavaScript灵活而强大的对象机制。class语法让这一切变得更加优雅,但它的基石从未改变。当你下次使用new、extends或访问一个对象属性时,如果能清晰地看到背后那条连接着Object.prototype乃至null的隐式链条,那么你对这门语言的理解,就已经超越了大多数使用者。这不仅是应对面试的利器,更是编写健壮、高效、可维护JavaScript代码的底层支撑。