ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JavaScript对象创建三剑客:工厂、构造函数与原型模式深度解析

JavaScript对象创建三剑客:工厂、构造函数与原型模式深度解析 1. 从“new”一个对象说起我们到底在做什么每次在代码里写下new Date()或者new Array()的时候你有没有那么一瞬间停下来想过这个new关键字背后到底发生了多少事对于很多刚入行的开发者来说这可能只是一个“创建对象”的固定语法用就完了。但当你开始设计自己的类、构建复杂的应用架构或者面试时被问到“不用new怎么创建对象”时才会意识到对象创建这件事远不止一个关键字那么简单。今天我们不聊那些高深莫测的设计模式全家桶就聚焦在 JavaScript 这个灵活到有些“诡异”的语言里最基础、最核心的三种对象创建模式工厂模式、构造函数模式和原型模式。这听起来像是教科书里的老古董但我可以负责任地告诉你理解它们之间的细微差别和适用场景是写出优雅、高效且易于维护的 JavaScript 代码的基石。无论是你手写的工具类还是你正在使用的某个著名框架的底层都能看到它们的影子。简单来说这三种模式都在解决同一个核心问题如何更优雅、更可控地批量生产具有相同特征的对象。但它们的实现思路、内存开销和适用场景截然不同。接下来我会带你像解构一个精密仪器一样拆开这三种模式看看它们内部的齿轮是如何咬合的以及在实际项目中我为什么会选择 A 而不是 B。2. 工厂模式像点餐一样创建对象让我们从一个最直观的场景开始。假设你正在开发一个员工管理系统需要创建很多Employee对象每个员工都有姓名、工号和部门属性。最朴素的做法是什么直接字面量创建const emp1 { name: 张三, id: E001, department: 研发部 }; const emp2 { name: 李四, id: E002, department: 市场部 }; // ... 重复 N 次代码重复率高且创建逻辑散落在各处如果后期需要给每个员工对象都增加一个默认的joinDate入职日期属性你就得修改每一个创建对象的地方。这时候工厂模式就派上用场了。2.1 工厂模式的实现一个封装好的“车间”工厂模式的本质是定义一个函数工厂这个函数负责接收参数在内部组装好对象然后返回给调用者。调用者无需关心对象内部是如何构建的。function createEmployee(name, id, department) { // 可以在这里做一些统一的处理比如参数校验、默认值设置 if (!name || !id) { throw new Error(员工姓名和工号为必填项); } const joinDate new Date(); // 统一的默认属性 // 组装并返回对象 return { name, id, department, joinDate, introduce() { console.log(大家好我是${this.name}工号${this.id}隶属于${this.department}。); } }; } // 使用工厂函数创建对象 const emp1 createEmployee(张三, E001, 研发部); const emp2 createEmployee(李四, E002, 市场部); emp1.introduce(); // 输出大家好我是张三工号E001隶属于研发部。 console.log(emp1.joinDate); // 输出当前的日期时间为什么这样设计工厂模式的核心优势在于封装和控制。封装创建细节将对象的构建逻辑集中在一处。未来如果需要修改对象的内部结构比如增加一个email属性或者改变introduce方法的实现你只需要修改createEmployee这一个函数。实现创建逻辑的复用任何需要创建员工的地方都调用同一个工厂函数保证了对象创建行为的一致性。可以实现更复杂的创建逻辑工厂内部可以根据不同的参数返回不同类型的对象这接近于“抽象工厂”或“工厂方法”模式的思想。例如根据department参数返回具有不同权限方法的对象。2.2 工厂模式的“阿喀琉斯之踵”对象标识问题工厂模式很好用但它有一个 JavaScript 语言特性带来的、无法忽视的缺点无法通过instanceof来识别对象类型。console.log(emp1 instanceof Object); // true这没问题所有对象都是Object的实例 // 但是我们无法判断 emp1 是不是一个“Employee”类型 console.log(emp1 instanceof createEmployee); // falseinstanceof操作符的工作原理是检查对象的原型链上是否存在某个构造函数的prototype属性。工厂函数返回的是一个全新的普通对象它的原型是Object.prototype与createEmployee函数本身没有任何原型链上的关联。因此emp1 instanceof createEmployee永远为false。这在某些需要严格类型检查的场景下会带来麻烦。为了解决这个问题并引入更符合传统面向对象编程习惯的语法构造函数模式登场了。3. 构造函数模式赋予对象“血统”构造函数模式是 JavaScript 早期模拟“类”的主要方式。它利用了函数的一个特性当使用new操作符调用一个函数时这个函数就成为了一个构造函数其内部的this会绑定到一个新创建的对象上。3.1 构造函数的经典写法我们把上面的工厂函数改造成构造函数function Employee(name, id, department) { // 构造函数内部的 this 指向新创建的对象实例 this.name name; this.id id; this.department department; this.joinDate new Date(); this.introduce function() { console.log(大家好我是${this.name}工号${this.id}隶属于${this.department}。); }; } // 使用 new 操作符调用 const emp1 new Employee(张三, E001, 研发部); const emp2 new Employee(李四, E002, 市场部); emp1.introduce(); // 功能正常看起来和工厂模式区别不大关键差异在于调用方式new和内部实现使用this。这个差异带来了一个巨大的好处console.log(emp1 instanceof Employee); // true! console.log(emp1 instanceof Object); // true现在我们可以用instanceof来清晰地判断emp1是Employee的实例了。这为代码的类型检查和逻辑判断提供了极大的便利也让代码在组织形式上更接近 Java、C 等经典 OOP 语言降低了理解成本。3.2 深入new操作符四步魔法理解构造函数必须理解new到底做了什么。当我们执行new Employee(...)时引擎在幕后默默完成了以下四步创建一个全新的空对象。将这个新对象的[[Prototype]]即__proto__链接到构造函数的prototype对象上。这是实现instanceof判断的关键一步。将构造函数内部的this绑定到这个新创建的对象。执行构造函数内部的代码为this添加属性。如果构造函数没有显式返回一个对象则自动返回这个新创建的对象。第二步是精髓。它建立了emp1.__proto__ Employee.prototype这个链接。当你访问emp1 instanceof Employee时引擎就是沿着emp1的原型链向上查找看能否找到Employee.prototype。3.3 构造函数模式的性能陷阱方法的重复创建构造函数模式解决了类型识别问题但却引入了一个新的、更严重的问题内存浪费。仔细看上面的代码introduce方法是在构造函数内部定义的。这意味着每一次调用new Employee()都会创建一个全新的introduce函数对象并将其赋值给新实例的introduce属性。console.log(emp1.introduce emp2.introduce); // falseemp1.introduce和emp2.introduce是两个功能完全相同、但占用不同内存空间的函数。创建10个员工实例就会在内存中存在10个一模一样的introduce函数。对于只有几个方法的小对象来说这可能无关紧要但对于拥有几十个方法、需要创建成千上万个实例的系统比如游戏中的粒子系统、UI组件库这种内存浪费是灾难性的。我们创建对象是希望实例共享相同的行为方法而不是各自持有一份行为的副本。这就需要请出今天的主角之一也是 JavaScript 面向对象编程的灵魂——原型模式。4. 原型模式共享的智慧原型模式的核心思想是我们创建的每一个函数都有一个prototype原型属性这个属性是一个指针指向一个对象。而这个对象包含了可以由该函数创建的所有实例共享的属性和方法。4.1 理解原型对象Prototype让我们重新审视Employee构造函数。实际上当我们定义function Employee() {...}时JavaScript 引擎已经自动为Employee创建了一个对应的原型对象Employee.prototype。最初这个原型对象只有一个属性constructor指回Employee函数本身。我们可以把需要共享的方法添加到这个原型对象上function Employee(name, id, department) { // 实例属性每个实例独有的数据 this.name name; this.id id; this.department department; this.joinDate new Date(); } // 原型方法所有实例共享的行为 Employee.prototype.introduce function() { console.log(大家好我是${this.name}工号${this.id}隶属于${this.department}。); }; Employee.prototype.getWorkYears function() { const now new Date(); return now.getFullYear() - this.joinDate.getFullYear(); }; const emp1 new Employee(张三, E001, 研发部); const emp2 new Employee(李四, E002, 市场部); emp1.introduce(); emp2.introduce(); console.log(emp1.introduce emp2.introduce); // true! 关键变化现在emp1和emp2的introduce方法指向了同一个函数对象即Employee.prototype.introduce。内存得到了极大的节省。4.2 原型链与属性查找机制这是 JavaScript 中最精妙也最容易让人困惑的部分之一。当我们访问emp1.introduce()时引擎是如何找到这个方法的引擎首先检查emp1对象自身是否有introduce属性。发现没有。接着引擎沿着emp1的[[Prototype]]通过__proto__访问即emp1.__proto__向上查找。而emp1.__proto__正是在new操作时被指向了Employee.prototype。在Employee.prototype这个对象上引擎找到了introduce方法于是调用它。这条查找路径emp1 - emp1.__proto__ (Employee.prototype) - ... - Object.prototype - null就是所谓的原型链。instanceof操作符也是利用这条链进行工作的。4.3 组合使用构造函数与原型模式最佳实践纯粹的构造函数模式方法在内部定义浪费内存。纯粹的原型模式所有属性方法都放在prototype上也有问题所有实例会共享相同的属性值这对于像name、id这样的实例特有数据来说是错误的。// 错误示范纯粹的原型模式 function Employee() {} Employee.prototype.name ; Employee.prototype.id ; Employee.prototype.department ; Employee.prototype.introduce function() { ... }; const emp1 new Employee(); emp1.name 张三; const emp2 new Employee(); emp2.name 李四; console.log(emp1.name); // 张三 console.log(emp2.name); // 李四 // 看起来没问题但如果属性是引用类型比如一个数组 hobbies Employee.prototype.hobbies []; emp1.hobbies.push(篮球); console.log(emp2.hobbies); // [篮球] emp2的hobbies也被修改了因为它们共享同一个数组引用。因此在实践中被广泛认可为最佳实践的是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性。这些属性是每个实例独有的在调用构造函数时通过this赋值。原型模式用于定义共享的方法和常量属性。这些是所有实例共有的节省内存且便于维护。这就是我们上面Employee的最终写法也是 ES5 时代最经典、最通用的对象创建模式。// 组合模式标准写法 function Employee(name, id, department) { // 实例属性独有 this.name name; this.id id; this.department department; this.joinDate new Date(); this.hobbies []; // 引用类型也放在这里每个实例独立 } // 原型方法共享 Employee.prototype.introduce function() { console.log(大家好我是${this.name}工号${this.id}隶属于${this.department}。); }; Employee.prototype.getWorkYears function() { ... }; // 甚至可以定义共享的“类属性/常量” Employee.prototype.company 某科技有限公司;5. 模式对比与实战场景选择理解了三种模式的原理我们来做一次清晰的对比并看看在什么情况下该用谁。特性工厂模式构造函数模式原型模式 (组合使用)创建语法普通函数调用new操作符调用new操作符调用对象类型识别不支持 (instanceof无效)支持 (instanceof有效)支持 (instanceof有效)方法/属性定义位置返回的对象内部构造函数内部 (this.xxx)实例属性在构造函数共享方法在prototype方法内存占用每个对象独立一份每个对象独立一份(内存浪费)所有对象共享一份 (高效)共享属性问题无 (每个对象独立)无 (每个对象独立)需注意引用类型属性若放在prototype上会共享代码封装性高创建逻辑完全封装中创建逻辑暴露在构造函数中中同构造函数模式适用场景1. 创建过程复杂需要封装2. 不需要严格的类型检查3. 创建不同系列但接口一致的对象已不推荐单独使用因其存在内存浪费的根本缺陷绝大多数场景的推荐做法兼顾了类型识别和内存效率实战选择指南当你需要创建大量实例且这些实例有相同的行为方法时毫不犹豫地选择“组合构造函数与原型模式”。这是 JavaScript 面向对象编程的基石直到 ES6 的class语法出现其底层原理依然如此。你的工具类、数据模型、业务实体如 User, Product, Order都应该采用这种模式。当对象的创建逻辑非常复杂或者你需要根据不同的条件返回不同的对象类型并且类型检查不那么重要时考虑使用工厂模式。例如一个创建 UI 对话框的工厂根据传入的type(‘alert’, ‘confirm’, ‘prompt’) 返回不同的对话框对象但这些对象都对外提供show(),hide()等统一接口。jQuery 的$()函数就是一个经典的工厂函数它根据传入的参数返回不同的 jQuery 对象。单独使用构造函数模式在构造函数内定义方法的情况在现代开发中应该避免。除非你非常确定这个类只会被实例化极少次数并且你不想碰prototype。但在大多数情况下这都是一个次优选择。6. 从ES5到ES6class语法糖的真相ES6 引入了class关键字让 JavaScript 的面向对象编程看起来更“正统”了。但重要的是要明白class在 JavaScript 中主要是一种语法糖它的底层实现依然是我们上面讨论的构造函数和原型模式。// ES6 class 写法 class Employee { constructor(name, id, department) { this.name name; this.id id; this.department department; this.joinDate new Date(); } introduce() { console.log(大家好我是${this.name}工号${this.id}隶属于${this.department}。); } getWorkYears() { ... } } const emp1 new Employee(张三, E001, 研发部); console.log(typeof Employee); // function类本质还是函数 console.log(emp1 instanceof Employee); // true console.log(emp1.introduce Employee.prototype.introduce); // true方法定义在原型上可以看到class中的constructor就是原来的构造函数而类中定义的方法会自动成为原型方法。class语法更清晰、更易于理解并且提供了extends继承、super调用父类、static静态方法等更强大的功能但其核心的对象创建、继承和属性查找机制仍然是基于原型的。理解工厂、构造函数和原型模式是理解class语法乃至整个 JavaScript 对象系统的前提。当你遇到class相关的疑难杂症时回溯到原型链的层面去思考往往能豁然开朗。7. 一个真实的踩坑案例修改内置原型的灾难理解了原型的力量也必须要警惕它的滥用。最经典的陷阱就是修改内置对象的原型。我曾经在维护一个古老的项目时看到过这样的代码// 为了“方便”给所有数组添加一个 sum 方法 Array.prototype.sum function() { return this.reduce((acc, cur) acc cur, 0); }; const arr [1, 2, 3]; console.log(arr.sum()); // 6看起来很美为什么这是一个灾难破坏封装性Array.prototype是 JavaScript 语言标准的一部分。随意修改它等于在全局范围内改变了所有数组对象的行为。你的代码和第三方库的代码共享同一个全局环境你的修改可能会影响库的正常运行反之亦然。命名冲突如果未来 ECMAScript 标准决定为数组增加一个官方的sum方法但其行为与你的实现不同你的代码将出现难以调试的兼容性问题。历史上因为Array.prototype.contains与 MooTools 库冲突导致标准委员会不得不将方法名改为includes。for...in循环的噩梦for...in循环会枚举对象及其原型链上所有可枚举的属性。如果你给Array.prototype添加了可枚举属性那么遍历数组时也会出现这个属性。Array.prototype.customMethod function() {}; const arr [a, b, c]; for (let key in arr) { console.log(key); // 会输出0, 1, 2, customMethod }正确的做法是什么如果需要扩展功能应该创建自己的工具类或工具函数。或者在现代项目中使用 ES6 的class继承来创建自定义的集合类。// 正确做法工具函数 function sumArray(arr) { return arr.reduce((acc, cur) acc cur, 0); } // 或自定义类 class MyArray extends Array { sum() { return this.reduce((acc, cur) acc cur, 0); } } const myArr new MyArray(1, 2, 3); console.log(myArr.sum()); // 6这个坑让我深刻意识到强大的特性往往伴随着巨大的责任。原型系统赋予了 JavaScript 极高的灵活性但滥用这份灵活性会给项目埋下深水炸弹。8. 模式演进与框架中的身影这三种基础模式的影响深远它们不仅是语言特性更成为了框架和库设计的底层逻辑。工厂模式的现代应用React 的createElement函数、Vue 3 的h函数创建虚拟DOM节点本质上都是工厂函数。它们接收描述信息返回一个结构化的对象虚拟节点而使用者无需关心这个对象内部的具体构造算法。Redux 中的createStore也是一个典型的工厂函数。构造函数与原型模式的集大成者如前所述ES6 的class是其语法体现。几乎所有现代前端框架React Class 组件、Vue 2 的选项式 API中的组件定义都依赖于这套体系。当你定义一个class Component extends React.Component时你就在使用构造函数和原型链来实现组件的继承和方法共享。“PageFactory”的启示你提到的 Selenium 中的PageFactory是工厂模式在测试领域一个很好的应用。它通过注解如FindBy和反射机制将 Web 页面元素如输入框、按钮的定位逻辑封装起来自动初始化成页面对象Page Object中的属性。测试人员只需通过简单的工厂方法PageFactory.initElements(driver, this)就能获得一个可操作的对象无需手动编写冗长的driver.findElement(...)代码。这完美体现了工厂模式“封装复杂创建逻辑”的核心价值。理解这些基础模式就像掌握了编程世界里的“材料力学”。你知道了砖对象可以用不同的方式模式来烧制和组装。当你再去学习或使用任何高级框架、库时你就能一眼看穿它们在设计上的取舍与精妙之处而不是停留在死记硬背 API 的层面。下次当你再写下new或者看到一个工厂函数时希望你能会心一笑清楚地知道脚下这片土地的构造。
返回列表