news 2026/8/29 6:18:51

JavaScript对象创建模式:从构造器到工厂与单例的进阶实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaScript对象创建模式:从构造器到工厂与单例的进阶实践

1. 从“new Object()”到设计模式:为什么我们需要对象创建模式?

在JavaScript的世界里,对象是我们打交道最多的实体。从初学时的let obj = {}new Object(),到后来用构造函数function Person(name) { this.name = name; },我们似乎已经掌握了创建对象的所有方法。但随着项目规模扩大,代码复杂度提升,你会发现事情没那么简单。当你需要根据不同的配置创建一系列相似但又不完全相同的对象时,当你发现new关键字和构造函数让你的代码难以测试、难以扩展时,当你面对一个需要灵活组装、内部状态复杂的对象时,原始的创建方式就显得力不从心了。

这就是对象创建模式要解决的问题。它不是一个具体的语法,而是一套经过实践检验的、用于优化对象创建过程的设计思想和代码组织方式。简单来说,它关乎“如何更优雅、更灵活、更安全地把一个对象‘造’出来”。理解这些模式,意味着你从“会用JavaScript写对象”进阶到了“懂得如何设计对象的诞生过程”,这对于构建可维护、可复用的大型应用至关重要。今天,我们就来深入拆解JavaScript中几种核心的对象创建模式,从最基础的工厂模式,到经典的构造器模式、原型模式,再到更高级的组合模式,看看它们各自解决了什么问题,以及在实际编码中如何选择和运用。

2. 构造器模式:经典背后的隐患与“new”的魔法

构造器模式是我们最熟悉的对象创建方式。它看起来直白又强大:定义一个函数,用new关键字调用,一个新对象就诞生了。

function Car(model, year) { this.model = model; this.year = year; this.toString = function() { return `${this.model} (${this.year})`; }; } const myCar = new Car('Toyota', 2020); console.log(myCar.toString()); // Toyota (2020)

2.1 “new”到底做了什么?

很多开发者只是机械地使用new,却不清楚背后的四步魔法:

  1. 创建一个全新的空对象
  2. 将这个新对象的[[Prototype]](即__proto__)链接到构造函数的prototype对象。这是实现继承的关键。
  3. 将构造函数内部的this绑定到这个新创建的对象,并执行构造函数内部的代码(为这个新对象添加属性)。
  4. 如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象

理解这个过程,就能明白为什么在构造函数里直接给this赋值会生效,也就能避免一些常见的错误,比如忘记写new导致this指向全局对象(在非严格模式下)。

2.2 构造器模式的明显缺陷

尽管经典,但上面的例子暴露了构造器模式一个严重的性能问题:方法重复创建。例子中,toString方法是定义在构造函数内部的,这意味着每次调用new Car(),都会创建一个全新的函数对象赋值给this.toString。如果有1000个Car实例,就会有1000个功能完全相同的toString函数在内存中,这无疑是巨大的浪费。

const car1 = new Car('A', 2020); const car2 = new Car('B', 2021); console.log(car1.toString === car2.toString); // false!两个不同的函数对象

这个缺陷是推动我们寻找更好模式的主要动力之一。我们需要一种方式,让所有实例共享相同的方法,而不是各自拥有一份拷贝。

3. 原型模式:共享的力量与属性屏蔽的陷阱

为了解决构造器模式的方法重复问题,JavaScript 的原型链机制提供了完美的解决方案,由此引出了原型模式。其核心思想是:将属性和方法定义在构造函数的prototype对象上,这样所有实例都能共享这些属性和方法

3.1 原型模式的基本实现

function Car(model, year) { // 实例属性,每个对象独有 this.model = model; this.year = year; } // 共享方法,定义在原型上 Car.prototype.toString = function() { return `${this.model} (${this.year})`; }; Car.prototype.drive = function() { console.log(`${this.model} is driving.`); }; const myCar1 = new Car('Toyota', 2020); const myCar2 = new Car('Honda', 2021); console.log(myCar1.toString === myCar2.toString); // true!共享同一个函数 myCar1.drive(); // Toyota is driving.

现在,无论创建多少个Car实例,toStringdrive方法在内存中都只有一份。实例通过内部的[[Prototype]]链(可通过__proto__Object.getPrototypeOf()访问)查找到这些方法。这是JavaScript实现继承和共享行为的基石。

3.2 深入理解原型链查找与属性屏蔽

原型模式并非没有坑,最大的坑在于“属性屏蔽”。看下面的例子:

function Car() {} Car.prototype.wheels = 4; const myCar = new Car(); console.log(myCar.wheels); // 4,从原型上找到 myCar.wheels = 6; // 在实例自身添加同名属性 console.log(myCar.wheels); // 6,访问的是自身属性 console.log(Object.getPrototypeOf(myCar).wheels); // 4,原型上的值没变 delete myCar.wheels; // 删除自身属性 console.log(myCar.wheels); // 4,屏蔽解除,再次从原型找到

这个过程揭示了原型链的属性访问规则:

  1. 当试图访问一个对象的属性时,引擎首先在对象自身查找。
  2. 如果没找到,则沿着对象的[[Prototype]]链向上查找。
  3. 如果最终在原型链上找到,则返回该值。
  4. 如果给对象赋值一个原型链上也存在的属性名,不会修改原型上的属性,而是在对象自身创建或修改这个属性,从而“屏蔽”了原型上的属性。

> 注意:这是一个非常关键且容易出错的概念。在大型项目中,如果不清楚这个规则,可能会意外地修改了某个实例的属性,却困惑于为什么其他实例的行为没有改变。始终记住:直接通过实例赋值,影响的是该实例自身;要修改原型上的属性,必须通过Constructor.prototype.property来操作。

3.3 原型模式的局限性

原型模式完美解决了方法的共享问题,但它通常与构造器模式结合使用(如上例),因为纯原型模式在处理需要独立初始化的实例属性时很不方便。纯原型模式意味着所有属性都在原型上定义,这会导致所有实例共享相同的属性值,这显然不是我们想要的:

function BadCar() {} BadCar.prototype.model = 'Default'; // 所有实例共享! BadCar.prototype.year = 2000; const car1 = new BadCar(); const car2 = new BadCar(); car1.model = 'Toyota'; // 这会在car1自身上创建model属性,但car2.model还是'Default',逻辑混乱。

因此,在实践中,组合使用构造器模式和原型模式成为了最主流、最推荐的模式:构造器用于定义实例属性,原型用于定义共享方法和常量属性。

4. 动态原型模式:更优雅的封装

组合构造器与原型模式虽然好用,但有一个小瑕疵:代码的组织是分离的。构造函数的定义在一处,原型方法的添加在另一处。这破坏了代码的封装性和可读性,尤其是当类的方法很多时,你需要滚动屏幕在构造函数和原型赋值代码块之间来回跳转。

动态原型模式提供了一种更优雅的封装方式:将原型方法的定义也封装在构造函数内部,并且通过判断确保只初始化一次。

function Car(model, year) { // 实例属性 this.model = model; this.year = year; // 方法:动态添加到原型(仅第一次调用构造函数时执行) if (typeof Car.prototype.drive !== 'function') { Car.prototype.drive = function() { console.log(`${this.model} is driving.`); }; Car.prototype.toString = function() { return `${this.model} (${this.year})`; }; // 可以继续添加其他方法... } } const car1 = new Car('Toyota', 2020); const car2 = new Car('Honda', 2021); console.log(car1.drive === car2.drive); // true,共享方法 car1.drive(); // Toyota is driving.

这里的技巧在于if判断。当第一次调用new Car()时,Car.prototype.driveundefined,条件为真,执行花括号内的代码,将方法添加到原型上。之后再次调用new Car(),原型上已经存在drive方法,条件为假,跳过初始化,避免了重复赋值。

> 提示:动态原型模式是我个人非常偏爱的一种风格。它让一个“类”的所有定义(属性初始化、方法声明)都集中在一个函数块内,代码结构更紧凑,维护起来更方便。你只需要查看构造函数内部,就能对这个类的全貌有一个清晰的了解。这对于阅读代码的人来说非常友好。

5. 寄生构造模式与稳妥构造模式:特殊场景的解决方案

除了上述主流模式,还有两种在特定场景下有用的模式。

5.1 寄生构造模式

这种模式的基本思想是:创建一个函数,这个函数封装了创建对象的代码(类似于工厂函数),但最后使用new关键字来调用,并返回这个新创建的对象。它通常用于扩展一个已有类型,但又不想直接修改其原型。

一个经典的例子是,你想创建一个具有额外方法的特殊数组:

function SpecialArray() { // 创建数组对象 const values = new Array(); // 添加初始值(arguments是传入的参数) values.push.apply(values, arguments); // 添加自定义方法 values.toPipedString = function() { return this.join('|'); }; // 返回这个加工过的对象 return values; } const colors = new SpecialArray('red', 'blue', 'green'); console.log(colors.toPipedString()); // "red|blue|green" console.log(colors instanceof SpecialArray); // false console.log(colors instanceof Array); // true

注意,colors并不是SpecialArray的实例,而是Array的实例。因为new操作符在遇到构造函数返回一个对象时,会直接返回这个对象,而不是默认创建的那个this。寄生构造模式让你能利用new的语法,但返回一个完全不同的、经过“寄生”增强的对象。

5.2 稳妥构造模式

稳妥构造模式由道格拉斯·克罗克福德提出,主要目的是创建没有公共属性,且其方法不引用this的“稳妥对象”。这种对象非常适合在一些安全环境(如防止数据被篡改)或某些框架中使用。

function Person(name, age, job) { // 创建要返回的对象 const o = new Object(); // 可以在这里定义私有变量和函数 const secretCode = '123456'; // 定义公共方法(特权方法),这些方法可以访问私有变量 o.sayName = function() { // 注意:这里不直接暴露name,而是通过闭包访问 console.log(name); // 访问的是构造函数作用域中的name参数 }; o.getSecret = function() { return secretCode; }; // 返回对象 return o; } const friend = Person('Nicholas', 29, 'Software Engineer'); friend.sayName(); // 'Nicholas' console.log(friend.name); // undefined,无法直接访问 console.log(friend.getSecret()); // '123456'

稳妥构造模式的特点:

  1. 不使用new操作符调用构造函数。
  2. 不引用this
  3. 在方法中通过闭包访问传入的原始数据。

这样创建的对象,其内部状态对于外部是完全隐藏的,只能通过暴露的特定方法(sayName,getSecret)来交互。这提供了很好的封装性和安全性。当然,它的缺点也很明显:每个方法都是独立的函数,无法在实例间共享,会占用更多内存。因此,它通常只在有特定安全或封装需求时使用。

6. 工厂模式与抽象工厂:解耦创建逻辑

当我们把视线从“如何定义一个对象的模板”转移到“如何管理对象的创建过程”时,工厂模式就登场了。它的核心目标是将对象的创建逻辑封装起来,调用者无需关心具体的创建细节

6.1 简单工厂模式

这就像一个“对象创建中心”。你告诉工厂你需要什么类型的产品,工厂负责把产品造出来给你。

// 产品类型 function Car(options) { this.model = options.model; this.year = options.year; } function Truck(options) { this.capacity = options.capacity; } // 工厂 function VehicleFactory() {} VehicleFactory.prototype.createVehicle = function(options) { switch(options.vehicleType) { case 'car': return new Car(options); case 'truck': return new Truck(options); default: throw new Error('Unsupported vehicle type'); } }; // 使用工厂 const factory = new VehicleFactory(); const myCar = factory.createVehicle({ vehicleType: 'car', model: 'Toyota', year: 2020 }); const myTruck = factory.createVehicle({ vehicleType: 'truck', capacity: '5吨' });

好处:调用者(客户端代码)只需要知道VehicleFactorycreateVehicle方法,完全不用关心CarTruck的具体构造函数是什么、如何初始化。如果未来要新增一个Motorcycle类型,只需要修改工厂内部的switch语句,客户端代码几乎不用动。这符合“开闭原则”(对扩展开放,对修改关闭)。

6.2 抽象工厂模式

简单工厂在类型不多时很好用,但当产品家族变得庞大(比如有不同品牌的汽车、不同品牌的卡车)时,switch会变得臃肿。抽象工厂模式提供了一个更结构化的解决方案:它为创建一系列相关或依赖的对象提供一个接口,而无需指定它们具体的类

假设我们有两个UI主题:亮色(Light)和暗色(Dark)。每个主题都包含按钮(Button)和对话框(Dialog)。

// 抽象产品类:定义产品接口 class Button { render() { throw new Error('You have to implement the method render!'); } } class Dialog { render() { throw new Error('You have to implement the method render!'); } } // 具体产品:Light主题 class LightButton extends Button { render() { console.log('Rendering a light-themed button.'); } } class LightDialog extends Dialog { render() { console.log('Rendering a light-themed dialog.'); } } // 具体产品:Dark主题 class DarkButton extends Button { render() { console.log('Rendering a dark-themed button.'); } } class DarkDialog extends Dialog { render() { console.log('Rendering a dark-themed dialog.'); } } // 抽象工厂:定义创建产品家族的接口 class UIFactory { createButton() { throw new Error('You have to implement the method createButton!'); } createDialog() { throw new Error('You have to implement the method createDialog!'); } } // 具体工厂:创建Light主题产品家族 class LightUIFactory extends UIFactory { createButton() { return new LightButton(); } createDialog() { return new LightDialog(); } } // 具体工厂:创建Dark主题产品家族 class DarkUIFactory extends UIFactory { createButton() { return new DarkButton(); } createDialog() { return new DarkDialog(); } } // 客户端代码 function application(factory) { const button = factory.createButton(); const dialog = factory.createDialog(); button.render(); dialog.render(); } // 根据配置使用不同的工厂 const config = { theme: 'dark' }; let factory; if (config.theme === 'light') { factory = new LightUIFactory(); } else { factory = new DarkUIFactory(); } application(factory); // 输出: Rendering a dark-themed button. / Rendering a dark-themed dialog.

抽象工厂的价值:客户端代码(application函数)只依赖于抽象的UIFactoryButtonDialog。它完全不知道具体创建的是LightButton还是DarkButton。切换整个产品家族(主题)变得异常简单,只需要更换一个具体的工厂实例即可。这极大地降低了系统各部分之间的耦合度,使得替换一整套UI组件变得轻而易举。

> 实操心得:工厂模式(特别是抽象工厂)在大型前端框架和复杂业务系统中非常常见。例如,一个图表库可能有一个ChartFactory,根据传入的type: 'line''bar'返回不同的图表实例。而抽象工厂则常用于设计系统(Design System)中,确保同一套设计语言下的所有组件(按钮、输入框、卡片)风格一致。当你发现代码中有很多new关键字,并且这些new分散在各处,逻辑复杂时,就该考虑引入工厂模式来集中管理创建逻辑了。

7. 建造者模式:分步构建复杂对象

有些对象非常复杂,拥有众多部件和复杂的装配过程。用一个庞大的构造函数,接收几十个参数来初始化,不仅难以阅读,而且容易出错(参数顺序错了怎么办?)。建造者模式就是为了解决这个问题而生:它将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示

想象一下你要创建一个HttpRequest对象,它需要配置URL、方法、头部、超时时间、请求体等等。

糟糕的做法(巨型构造函数):

const request = new HttpRequest('https://api.example.com', 'POST', { 'Content-Type': 'application/json' }, 5000, JSON.stringify({ data: 'test' })); // 哪个参数对应什么?完全记不住。

建造者模式的做法:

class HttpRequest { constructor() { this.url = ''; this.method = 'GET'; this.headers = {}; this.timeout = 0; this.body = null; } } class HttpRequestBuilder { constructor() { this.request = new HttpRequest(); } setUrl(url) { this.request.url = url; return this; // 关键:返回this,支持链式调用 } setMethod(method) { this.request.method = method; return this; } setHeader(key, value) { this.request.headers[key] = value; return this; } setTimeout(timeout) { this.request.timeout = timeout; return this; } setBody(body) { this.request.body = body; return this; } build() { // 这里可以进行最终的校验 if (!this.request.url) { throw new Error('URL is required.'); } return this.request; } } // 使用建造者 const request = new HttpRequestBuilder() .setUrl('https://api.example.com/data') .setMethod('POST') .setHeader('Content-Type', 'application/json') .setTimeout(5000) .setBody(JSON.stringify({ data: 'test' })) .build(); // 最终构建出对象 console.log(request);

建造者模式的优势:

  1. 良好的封装性:建造过程被封装在Builder类中,客户端无需知道对象内部的组成细节。
  2. 易于扩展:要增加新的构建步骤或参数,只需要在Builder中添加新的方法即可,符合开闭原则。
  3. 更好的控制构建过程:可以在build()方法中进行最终校验,确保构建出的对象是有效的。
  4. 链式调用,代码清晰:通过返回this实现链式调用,代码读起来就像在描述构建步骤,非常直观。

> 注意事项:建造者模式适用于创建那些包含多个组成部分、且构建顺序可能灵活变化的复杂对象。对于简单的对象,使用建造者模式反而会引入不必要的复杂度。在JavaScript中,我们也可以利用“参数对象”来简化构造函数,这可以看作是一种轻量级的建造者模式变体。例如new HttpRequest({ url: '...', method: 'POST', headers: {...} })。但当构建逻辑非常复杂,需要分步、有条件地构建时,完整的建造者模式依然是更好的选择。

8. 单例模式:确保全局唯一实例

单例模式可能是最广为人知的设计模式之一。它的意图是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。在前端开发中,单例模式的应用场景非常多,例如全局状态管理(如Redux Store)、模态框(Modal)管理器、浏览器环境下的全局对象(如windowdocument本身就是单例)、日志记录器、配置管理器等。

8.1 传统的单例实现(基于类)

在ES6之前,我们通常使用闭包或者立即执行函数来实现单例。

// 使用闭包和立即执行函数 const Singleton = (function() { let instance; // 闭包内保存唯一实例 function createInstance() { // 这里是实际的构造函数逻辑 const object = new Object('I am the instance'); return object; } return { getInstance: function() { if (!instance) { instance = createInstance(); } return instance; } }; })(); // 使用 const instance1 = Singleton.getInstance(); const instance2 = Singleton.getInstance(); console.log(instance1 === instance2); // true

8.2 ES6+ 时代的单例实现

有了ES6的类和模块系统,实现单例变得更加简洁。一个非常巧妙且常用的方法是利用ES6模块的天然单例特性。

// Logger.js (一个模块文件) class Logger { constructor() { this.logs = []; if (Logger.instance) { return Logger.instance; // 如果已存在实例,直接返回 } Logger.instance = this; // 否则,将当前实例赋值给静态属性 return this; } log(message) { this.logs.push(message); console.log(`LOG: ${message}`); } printLogCount() { console.log(`Number of logs: ${this.logs.length}`); } } // 关键:立即创建并导出一个实例 const instance = new Logger(); Object.freeze(instance); // 可选:冻结实例,防止被修改 export default instance;
// app.js (使用方) import logger from './Logger.js'; logger.log('First message'); logger.log('Second message'); logger.printLogCount(); // Number of logs: 2 // 无论你在多少个文件中 import './Logger.js',你得到的都是同一个 logger 实例。

8.3 单例模式的陷阱与思考

单例模式虽然好用,但需要谨慎使用,因为它本质上是一个全局变量。过度使用单例会导致一些问题:

  • 隐藏的耦合:单例使代码的依赖关系变得不透明,一个模块可能默默地依赖了一个全局单例,这不利于单元测试(因为难以模拟)和代码理解。
  • 不利于测试:由于单例状态是全局共享的,一个测试用例对它的修改可能会影响另一个测试用例,导致测试结果不可预测。通常需要额外的resetmock机制。
  • 违反单一职责原则:单例类同时承担了“保证唯一性”和“业务逻辑”两种职责。

> 经验之谈:在现代前端开发中,对于真正的全局唯一服务(如与后端API通信的客户端、应用配置),使用单例模式是合理的。但对于那些只是“在当前上下文中需要唯一”的对象,考虑使用依赖注入(Dependency Injection)容器来管理生命周期,这能提供更好的灵活性和可测试性。例如,在React中,你可以使用Context API来提供一个“单例”似的服务给组件树,而不是创建一个真正的全局模块单例。这限定了服务的作用范围,更加可控。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/29 6:17:28

AI替代人力后怎么办:从机器人税到人机协作系统设计

比尔盖茨又谈 AI 了。这次不是在聊模型参数、算力规模或又一家独角兽,而是在长文里正面讨论:当 AI 大规模替代人力之后,社会该怎么分配收益,人还剩下哪些不可替代的岗位。他提到的两个概念——机器人税和人类专属岗位,…

作者头像 李华
网站建设 2026/8/29 6:17:03

拓扑排序与动态规划:解决有向无环图路径计数问题

1. 项目概述与问题引入最近在刷算法题,特别是图论相关的题目时,遇到了一个挺有意思的经典问题——P4017 最大食物链计数。这题本质上是一个拓扑排序的应用,但它的背景设定在生态学上,让枯燥的算法瞬间有了画面感。题目描述了一个生…

作者头像 李华
网站建设 2026/8/29 6:13:37

C/C++工程师能力评估全解析:从语言功底到系统思维

干了十来年C/C,面试过上百号人,也帮几个团队搭过完整的C/C工程师能力评估体系。说实话,大多数团队在评估这件事上是拍脑袋的:拉几张八股文题,手写一个链表反转,再问问虚函数和智能指针,就结束了…

作者头像 李华
网站建设 2026/8/29 6:12:41

商务人形机器人:服务业的数字转型(上)

智慧酒店生态一、商务人形机器人在服务业数字转型中的应用商务人形机器人正逐步从概念走向现实,开始在酒店、物流等领域承担具体工作,这标志着服务业数字化转型进入新阶段。(一)核心应用场景与价值商务人形机器人核心应用场景与价…

作者头像 李华
网站建设 2026/8/29 6:11:32

Python零基础学习路线:从语法入门到自动化实战的完整指南

如果你正在网上搜索“Python 零基础教程”,大概率会看到类似这样的标题:全100集、绝对最好、逼自己七天学完、从入门到精通、全程干货无废话。这些标题确实很抓人,因为它精准击中了一个普遍焦虑:Python 资料太多,我不知…

作者头像 李华
网站建设 2026/8/29 6:10:11

单片机毕设选题推荐:基于 STM32 单片机的消防多参量感知联动报警装置开发 基于 STM32 的火灾隐患多传感器监测与自动控制平台设计(012605)

博主介绍:✌️码农一枚 ,专注于大学生项目实战开发、讲解和毕业🚢文撰写修改等。全栈领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机,Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华