
1. 从“new Object()”说起为什么我们需要五种创建方式在JavaScript的世界里对象是构建一切的基石。无论是前端页面的DOM操作还是后端的Node.js服务都离不开对象的创建与操作。很多刚入门的开发者可能只知道一两种创建对象的方式比如最直观的new Object()或者字面量{}。但当你深入项目面对不同的场景——比如需要复用模板、创建大量相似实例、或者实现私有属性时——你会发现单一的方式往往力不从心甚至可能引入性能或设计上的问题。这就是为什么理解JS中对象创建的五种核心方式至关重要。这五种方式并非简单的语法差异它们背后对应着不同的设计模式、内存管理策略和应用场景。掌握它们意味着你能在合适的场景选择最合适的工具写出更高效、更健壮、更易维护的代码。今天我们就来彻底拆解这五种方式工厂模式、构造函数模式、原型模式、组合使用构造函数和原型模式以及ES6的类语法。我会结合我多年踩坑的经验告诉你每种方式的“为什么”和“怎么选”而不仅仅是“是什么”。2. 工厂模式快速批量的“简易车间”当你需要创建一系列结构相似但数据不同的对象时第一个跳入脑海的可能是写一个函数来封装创建过程。这就是工厂模式的核心思想——像一个简易车间接收原料参数返回产品对象。2.1 基本实现与典型代码工厂模式不涉及new操作符和特定的构造函数。它就是一个普通的函数在函数内部手动创建一个新对象为其添加属性和方法最后返回这个对象。function createPerson(name, age) { // 1. 手动创建一个新对象 const obj new Object(); // 2. 为对象添加属性 obj.name name; obj.age age; // 3. 为对象添加方法 obj.sayName function() { console.log(this.name); }; // 4. 返回这个对象 return obj; } // 使用工厂函数 const person1 createPerson(张三, 25); const person2 createPerson(李四, 30); person1.sayName(); // 输出张三2.2 核心优势与适用场景工厂模式最大的优点是简单直观隔离了创建细节。调用者无需关心对象内部是如何构建的只需传入参数即可获得一个完整的对象。这在创建一些配置对象、数据模型或者需要一定初始化逻辑的简单对象时非常有用。例如在前端项目中我们经常需要根据API返回的数据创建视图模型function createUserViewModel(apiData) { return { id: apiData.userId, displayName: ${apiData.firstName} ${apiData.lastName}, avatarUrl: apiData.profileImage || /default-avatar.png, isActive: apiData.lastLoginTime Date.now() - 7 * 24 * 60 * 60 * 1000 }; }2.3 无法回避的“身份”困境然而工厂模式有一个致命的缺陷所有创建出来的对象都是“孤儿”它们之间没有内在的联系。console.log(person1 instanceof createPerson); // false console.log(person1.constructor); // [Function: Object]person1并不是createPerson的实例它的构造函数是顶层的Object。这意味着无法进行类型识别你无法用instanceof操作符来判断一个对象是否由某个特定的工厂函数创建。这在需要做类型检查或依赖注入的场景下是个大问题。方法重复创建内存浪费注意看上面的代码每个对象都有自己的sayName方法。创建100个对象就会在内存中有100个功能完全相同的函数副本这无疑是巨大的浪费。注意虽然现代JS引擎有优化但对于复杂的对象方法这种浪费是实实在在的。我曾在一个需要渲染大量列表项的项目中初期使用了类似工厂模式的方法导致页面内存占用飙升滚动卡顿排查了很久才发现是这个原因。因此工厂模式适用于创建少量、一次性、无需类型识别和方法复用的简单对象。对于需要创建大量实例或需要构建清晰继承关系的场景我们需要更强大的工具。3. 构造函数模式赋予对象“姓氏”为了解决工厂模式“身份不明”的问题JavaScript提供了构造函数模式。通过new操作符调用一个普通函数这个函数就扮演了构造器的角色为新对象打上“家族烙印”。3.1new操作符背后的四步魔法当你写下const obj new MyFunction()时引擎在幕后默默做了四件事创建一个全新的空对象。将这个新对象的内部[[Prototype]]即__proto__链接到构造函数的prototype属性指向的对象。这是实现继承的基石。将构造函数内部的this绑定到这个新创建的对象。执行构造函数内部的代码为this添加属性。如果构造函数没有显式返回一个对象则自动返回这个新创建的对象。function Person(name, age) { // 此处的 this 指向 new 创建的新对象 this.name name; this.age age; this.sayName function() { console.log(this.name); }; // 没有 return 语句默认返回 this } const person1 new Person(王五, 28); const person2 new Person(赵六, 35); console.log(person1 instanceof Person); // true解决了身份问题 console.log(person1.constructor Person); // true现在person1可以明确地被识别为Person的实例这为代码的组织和调试带来了极大的便利。3.2 方法定义的内存陷阱与变通方案构造函数模式解决了“身份”问题但它没有解决“方法复用”的问题。上面代码中sayName方法仍然是在每个实例中被重新创建。person1.sayName person2.sayName的结果是false。为了解决这个问题一个常见的变通方案是将方法定义转移到构造函数外部function sayName() { console.log(this.name); } function Person(name, age) { this.name name; this.age age; this.sayName sayName; // 引用外部同一个函数 } const p1 new Person(小明, 10); const p2 new Person(小红, 12); console.log(p1.sayName p2.sayName); // true内存优化了这样做确实实现了方法的共享但带来了新的问题污染了全局命名空间。如果有很多构造函数每个都有若干方法全局作用域下就会充斥着大量函数极易引发命名冲突且代码组织混乱不符合高内聚的原则。我们需要一种机制既能将方法共享给所有实例又能将这些方法优雅地组织在构造函数名下。这就是原型模式登场的时候。4. 原型模式共享的“家族宝藏”在JavaScript中每个函数都有一个prototype原型属性它是一个对象。当通过new调用这个函数创建实例时该实例的内部[[Prototype]]会指向这个原型对象。原型模式的核心思想是将所有实例需要共享的属性和方法直接定义在构造函数的原型对象上。4.1 理解原型链与属性查找机制function Person() {} // 空构造函数 // 在原型上添加共享属性和方法 Person.prototype.name 默认姓名; Person.prototype.age 0; Person.prototype.friends [张三, 李四]; Person.prototype.sayName function() { console.log(this.name); }; const person1 new Person(); const person2 new Person(); person1.sayName(); // 输出默认姓名 console.log(person1.sayName person2.sayName); // true方法完美共享当我们访问person1.sayName时引擎首先在person1实例自身查找sayName属性没找到。接着它会沿着person1的[[Prototype]]即Person.prototype向上查找在那里找到了于是调用它。这条查找路径就是原型链。4.2 共享引用类型属性带来的“惊天大坑”原型模式在共享方法上表现完美但在共享引用类型的属性时却可能引发灾难性的后果。person1.name 王小二; // 在实例上添加同名属性遮蔽原型属性 console.log(person1.name); // 王小二 (来自实例) console.log(person2.name); // 默认姓名 (来自原型) // 问题来了修改共享的引用类型属性 person1.friends.push(王五); console.log(person1.friends); // [张三, 李四, 王五] console.log(person2.friends); // [张三, 李四, 王五]person2的也被改了friends是一个数组存在于Person.prototype上。person1和person2访问的是同一个数组。通过person1修改这个数组person2看到的也会同步变化。这几乎从来都不是我们想要的效果我们希望每个实例有自己独立的friends列表。踩坑实录早期做一个多人协作的TODO List原型时我使用了原型模式来存储每个用户的待办事项数组。结果一个用户添加的任务神奇地出现在了所有用户的列表里造成了严重的逻辑错误。排查了半天才意识到是原型上的引用类型属性在作祟。因此纯原型模式适用于方法共享但属性尤其是引用类型属性必须独立的场景。这引出了最经典、最常用的组合模式。5. 组合使用构造函数和原型模式经典的王道组合这是ES6类语法出现之前使用最广泛、认可度最高的创建自定义类型的方式。它完美融合了构造函数模式和原型模式的优点同时规避了它们的缺点。5.1 模式结构与最佳实践思路非常简单清晰使用构造函数模式来定义实例属性。这些属性通常是每个实例独有的基本值或引用值。使用原型模式来定义方法和需要共享的属性。// 1. 构造函数定义实例属性 function Person(name, age, friends) { this.name name; // 实例自有 this.age age; // 实例自有 this.friends friends || []; // 实例自有默认空数组互不干扰 } // 2. 原型定义共享方法 Person.prototype.sayName function() { console.log(this.name); }; Person.prototype.addFriend function(friendName) { this.friends.push(friendName); // 操作的是实例自身的 friends }; // 使用 const p1 new Person(小明, 10, [小刚]); const p2 new Person(小红, 12, [小芳]); p1.addFriend(小强); console.log(p1.friends); // [小刚, 小强] console.log(p2.friends); // [小芳]互不影响 console.log(p1.sayName p2.sayName); // true方法共享这种组合方式实例属性在构造函数中初始化确保了数据的独立性共享方法存放在原型上确保了内存的高效利用。它同时满足了类型识别 (instanceof)、内存效率和数据封装的需求。5.2 动态原型模式更优雅的封装变体经典组合模式有一个小小的“美学”问题构造函数的定义和原型方法的定义在代码上是分离的。为了让代码封装得更紧密我们可以使用“动态原型模式”。function Person(name, age) { // 实例属性 this.name name; this.age age; this.friends []; // 方法仅在第一次调用构造函数时添加到原型上 if (typeof this.sayName ! function) { Person.prototype.sayName function() { console.log(this.name); }; Person.prototype.addFriend function(friendName) { this.friends.push(friendName); }; // ... 可以继续添加其他共享方法 } }if判断确保了原型方法只被初始化一次。无论创建多少个实例原型赋值代码只会在第一个实例创建时执行。这让整个“类”的定义都封装在了构造函数内部代码组织更整洁。不过在实际项目中经典组合模式因其极致的简单和清晰仍然是最主流的选择。6. ES6类语法更现代的“语法糖”ES6引入的class关键字并没有引入新的面向对象继承模型它只是上述组合模式构造函数原型的语法糖但让代码的书写和阅读都更加清晰、更接近传统面向对象语言。6.1 类语法基本结构与原理对应让我们用class重写上面的Personclass Person { // 构造函数对应原来的构造函数函数体 constructor(name, age) { this.name name; // 实例属性 this.age age; this.friends []; } // 类方法会自动添加到 Person.prototype 上 sayName() { console.log(this.name); } addFriend(friendName) { this.friends.push(friendName); } // 静态方法属于类本身而不是实例 static describe() { return 这是一个Person类; } } // 使用完全一样 const p1 new Person(小明, 10); p1.sayName(); // 小明 console.log(p1.sayName Person.prototype.sayName); // true // 调用静态方法 console.log(Person.describe()); // 这是一个Person类可以看到constructor方法就是原来的构造函数。在类块中定义的方法默认就是原型方法。使用static关键字可以定义静态方法这是以前需要直接挂在构造函数上如Person.describe function(){}才能实现的功能现在语法更统一。6.2 类语法的优势与细节陷阱优势语法简洁直观不再需要手动操作prototype继承语法extends也极其简单。更好的内置检查必须使用new调用否则报错。而传统的构造函数如果忘了newthis会指向全局对象严格模式下为undefined导致难以追踪的错误。支持继承的super关键字调用父类构造函数和方法更规范。清晰的静态成员定义。需要注意的细节类定义不会被提升。虽然函数声明会提升但class不会。你不能在定义类之前使用它。类体内的代码默认在严格模式下执行。所有方法都是不可枚举的。而在传统原型模式中我们添加到prototype上的方法是可枚举的可以通过for...in遍历到。这通常更符合预期。类没有真正的“私有属性”ES2022引入了#前缀的私有字段但属于较新特性。在构造函数中定义的属性仍然是公开的。6.3 如何选择类语法 vs 经典组合模式在现代前端开发中React, Vue 3等class语法已经成为绝对的主流因为它更清晰、更现代并且被框架和工具链良好支持。我个人的建议是在新项目中毫不犹豫地使用class语法。它解决了旧模式的大部分痛点并且是语言标准的发展方向。然而理解其背后的原型机制经典组合模式依然无比重要调试需要当你的代码在原型链上出现问题时如果你只懂class语法调试将非常困难。你必须能看懂__proto__和prototype。理解旧代码大量的遗留库和项目仍然使用传统的模式。应对特殊场景极少数情况下你可能需要直接操作原型对象来实现一些高级技巧比如猴子补丁这时对原型的深刻理解是必不可少的。7. 五种方式对比与实战选型指南让我们通过一个表格从核心特征、优缺点和典型场景来快速回顾这五种方式创建方式核心特征优点缺点适用场景工厂模式函数返回新对象无new简单封装创建过程1. 对象无法类型识别 (instanceof)2. 方法无法共享内存浪费创建少量、简单、无需类型检查的配置对象或数据模型构造函数模式使用new调用函数this指向新对象1. 解决了类型识别问题2. 实例属性独立1. 方法仍未共享内存浪费2. 全局定义方法污染命名空间需要明确类型但实例数量极少或方法复杂度极低的场景现已很少单独使用原型模式将共享成员定义在构造函数的prototype上1. 方法完美共享内存高效2. 类型识别正常1. 所有实例共享原型上的引用类型属性导致数据污染2. 无法在创建时为实例初始化独立的属性值几乎不单独使用主要用于方法共享部分的实现组合模式实例属性在构造函数中定义共享方法在原型上定义1. 实例属性独立2. 方法共享内存高效3. 类型识别正常4. 是ES6类的底层原理代码组织上构造函数和原型定义略显分离可用动态原型模式优化ES6之前创建自定义类型的标准、主流方式理解它才能深入理解JS对象系统ES6类class语法糖对应组合模式1. 语法简洁现代封装性好2. 内置严格模式和new检查3. 继承语法 (extends) 清晰4. 是未来标准1. 类定义不提升2. 需要理解其背后的原型原理才能深度调试现代JS开发的默认选择适用于所有需要创建自定义类型、构建复杂应用的场景实战选型心法如果你在写现代项目ES6直接使用class。这是最安全、最主流、最面向未来的选择。如果你需要维护或理解旧代码库必须熟练掌握组合模式因为它是class的基石也是旧代码的常态。当你需要创建一个纯粹的数据容器且不关心它的“类型”时可以考虑简单的工厂函数或直接使用对象字面量{}。永远避免单独使用纯构造函数模式或纯原型模式它们各自的问题在组合模式/类语法中都已得到解决。最后无论选择哪种方式关键是要理解其背后的内存模型和设计取舍。JavaScript的对象系统灵活而强大这种灵活性既是其魅力所在也要求开发者必须具备更扎实的理解才能写出高效可靠的代码。在我多年的开发经历中见过太多因为错误使用原型导致的数据共享bug也见过因为不理解new和this绑定而引发的诡异错误。希望这次对五种创建方式的深度拆解能帮你建立起清晰的知识图谱在下次创建对象时能自信地做出最适合当前场景的选择。