尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JavaScript对象创建模式演进:从工厂函数到ES6 Class的完整指南

JavaScript对象创建模式演进:从工厂函数到ES6 Class的完整指南 1. 从“散装”到“封装”理解对象创建的原始需求刚接触 JavaScript 那会儿我处理数据的方式简单粗暴。比如要管理一个用户的信息我可能会这么干let userName 张三; let userAge 30; let userJob 工程师; let userName2 李四; let userAge2 25; let userJob2 设计师;这方法在数据量小的时候似乎还行但问题很快就暴露了。首先变量名满天飞userName1、userName2... 管理起来简直是噩梦。其次数据和操作是割裂的。我想打印用户信息得写一个函数然后把三个变量都传进去function printUserInfo(name, age, job) { console.log(姓名${name}年龄${age}职业${job}); } printUserInfo(userName, userAge, userJob);这带来了两个致命问题一是容易传错参数顺序把年龄和职业搞混二是当用户属性增加时函数签名也得跟着改牵一发而动全身。这种编程方式我们称之为“面向过程”或“基于散装变量的编程”其核心缺陷在于数据与行为的分离导致代码的聚合度和内聚性极差。于是对象Object的概念应运而生。在 JavaScript 中对象就是一组**属性Property和方法Method**的集合属性用来描述状态方法用来定义行为。把相关的变量和函数打包在一起这就是最朴素的“封装”思想。我们的目标就是找到一种优雅、高效且可维护的方式来创建这些对象。这不仅仅是语法问题更是设计思维的转变——从操作离散数据到管理和交互一个具有完整状态的实体。2. 创建对象的“石器时代”工厂模式与构造函数当我们意识到需要批量创建结构相似的对象时最直观的想法就是写一个“工厂函数”。比如创建一个用户function createUser(name, age, job) { let o new Object(); o.name name; o.age age; o.job job; o.sayName function() { console.log(this.name); }; return o; } let user1 createUser(张三, 30, 工程师); let user2 createUser(李四, 25, 设计师); user1.sayName(); // 输出张三工厂模式解决了批量创建的问题但存在一个关键缺陷我们无法识别对象的“类型”。user1和user2都是Object类型我们无法知道它们是由createUser函数创建的。在调试或使用instanceof操作符时这会造成困扰。于是JavaScript 提供了更接近传统面向对象语言的方案构造函数模式。function User(name, age, job) { this.name name; this.age age; this.job job; this.sayName function() { console.log(this.name); }; } let user1 new User(张三, 30, 工程师); let user2 new User(李四, 25, 设计师); console.log(user1 instanceof User); // true console.log(user1 instanceof Object); // true user1.sayName(); // 输出张三这里发生了四件事在内存中创建一个新对象。这个新对象内部的[[Prototype]]即__proto__特性被赋值为构造函数的prototype属性。构造函数内部的this被赋值为这个新对象即this指向新对象。执行构造函数内部的代码给新对象添加属性。如果构造函数返回非空对象则返回该对象否则返回刚创建的新对象。通过new操作符和首字母大写的函数名约定我们明确了对象的“类型”是User。这看起来完美了对吗但这里藏着一个性能陷阱。注意看我们在构造函数内部定义了sayName方法。这意味着每次调用new User()都会创建一个新的函数对象。user1.sayName和user2.sayName是两个不同的函数实例虽然功能一模一样。创建100个用户就会创建100个完全相同的函数这无疑是巨大的内存浪费。实操心得在早期项目中我曾在一个列表页里用构造函数模式创建了上百个列表项对象每个对象都带了几个方法。页面性能在低端手机上明显卡顿用内存分析工具一看成千上万的函数实例占了大部分内存。这是构造函数模式最容易被忽略的坑。3. 共享方法的进化原型模式与组合模式为了解决每个实例都重复创建方法的问题我们需要让方法被所有实例共享。这就引出了 JavaScript 中最核心的概念之一原型Prototype。每个函数都有一个prototype属性它是一个对象。当调用构造函数创建一个新实例后该实例的内部[[Prototype]]指针就会被赋值为构造函数的prototype对象。当我们访问一个对象的属性时如果对象本身没有引擎就会去它的原型对象上找。这个查找链就是原型链。原型模式的思想是将属性和方法分离。实例特有的属性放在构造函数内部所有实例需要共享的方法放在构造函数的原型上。function User(name, age, job) { // 实例属性 this.name name; this.age age; this.job job; } // 共享方法 - 添加到原型上 User.prototype.sayName function() { console.log(this.name); }; User.prototype.sayJob function() { console.log(我的职业是${this.job}); }; let user1 new User(张三, 30, 工程师); let user2 new User(李四, 25, 设计师); user1.sayName(); // 张三 user2.sayName(); // 李四 console.log(user1.sayName user2.sayName); // true证明是同一个函数现在无论创建多少个User实例sayName和sayJob方法在内存中都只存在一份所有实例通过原型链共享它们。这完美解决了内存浪费问题。但是纯原型模式也有其弊端。如果所有属性都放在原型上那么它们也会被所有实例共享这通常不是我们想要的。function User() {} User.prototype { constructor: User, name: 默认名, age: 0, job: 无, friends: [张三, 李四], sayName: function() { console.log(this.name); } }; let user1 new User(); let user2 new User(); user1.name 王五; // 在实例上创建同名属性遮蔽原型属性 console.log(user2.name); // 输出默认名没问题 user1.friends.push(赵六); // 修改引用类型的属性 console.log(user2.friends); // 输出[张三, 李四, 赵六]大问题可以看到对于基本值属性如name,age在实例上赋值会创建一个新属性从而“遮蔽”原型上的属性不影响其他实例。但对于引用值属性如数组friends修改操作是在共享的原型对象上进行的会导致所有实例的状态都被意外更改这通常是严重的 bug。因此在实践中我们几乎总是使用组合使用构造函数模式和原型模式这也是 ES5 时代最主流、认同度最高的自定义类型创建方式。// 构造函数模式定义实例属性 function User(name, age, job) { this.name name; this.age age; this.job job; this.friends [王五, 赵六]; // 引用值属性也放在实例上 } // 原型模式定义共享方法和共享属性 User.prototype { constructor: User, // 显式设置 constructor 指向 sayName: function() { console.log(this.name); }, species: 人类 // 所有用户共享的只读属性 }; let user1 new User(张三, 30, 工程师); let user2 new User(李四, 25, 设计师); user1.friends.push(钱七); console.log(user1.friends); // [王五, 赵六, 钱七] console.log(user2.friends); // [王五, 赵六]互不影响 console.log(user1.sayName user2.sayName); // true方法共享 console.log(user1.species); // 人类这种模式清晰地划分了实例成员和原型成员实例拥有各自独立的属性副本同时共享着方法在功能和内存上达到了最佳平衡。注意事项在重写整个prototype对象时如上例中用对象字面量赋值一定要记得显式地设置constructor属性指回构造函数。否则新对象的constructor属性会指向Object破坏原型链的完整性可能导致一些依赖constructor的库或代码出错。4. 封装与继承的探索从寄生构造函数到 ES6 Class组合模式虽然好但代码结构上构造函数和原型定义是分离的有些人觉得不够“封装”。于是出现了一些变体模式如动态原型模式它把原型方法的定义也封装在构造函数里更符合传统面向对象语言的视觉习惯。function User(name, age, job) { this.name name; this.age age; this.job job; this.friends [王五, 赵六]; // 避免每次 new 都重复执行原型赋值 if (typeof this.sayName ! function) { User.prototype.sayName function() { console.log(this.name); }; User.prototype.sayJob function() { console.log(this.job); }; } }if判断确保了原型方法只会在第一次调用构造函数时被添加。这种模式将一切封装在构造函数内但代价是破坏了原型对象的纯粹性并且不能使用对象字面量重写原型。另一个有趣的模式是寄生构造函数模式。它看起来像工厂模式但使用了new操作符。通常用于扩展一个已有类型而不直接修改其构造函数。function SpecialArray() { // 创建数组 let values new Array(); // 添加初始值 arguments 是传入的参数列表 values.push.apply(values, arguments); // 添加特殊方法 values.toPipedString function() { return this.join(|); }; // 返回这个新数组 return values; } let colors new SpecialArray(red, blue, green); console.log(colors.toPipedString()); // red|blue|green console.log(colors instanceof SpecialArray); // false这是一个大问题注意colors是Array的实例不是SpecialArray的实例。这种模式创建的对象与构造函数原型之间没有联系instanceof操作符会失效。因此除非有特殊需求比如为内置类型添加额外功能而不污染全局原型否则不建议使用。真正的革命来自于ES6 的 Class。class语法糖的出现极大地统一和简化了面向对象编程的写法。它底层仍然是基于原型和构造函数的但提供了一种更清晰、更易读的语法。class User { // 构造函数定义实例属性 constructor(name, age, job) { this.name name; this.age age; this.job job; this.friends [王五, 赵六]; } // 类方法自动添加到 User.prototype 上 sayName() { console.log(this.name); } sayJob() { console.log(我的职业是${this.job}); } // 静态方法属于类本身而不是实例 static describe() { console.log(这是一个“用户”类); } } let user1 new User(张三, 30, 工程师); let user2 new User(李四, 25, 设计师); user1.sayName(); // 张三 console.log(user1.sayName user2.sayName); // true User.describe(); // 这是一个“用户”类 console.log(typeof User); // function类本质还是函数 console.log(User User.prototype.constructor); // trueClass 语法清晰地定义了构造函数、实例方法、静态方法并且方法默认是不可枚举的Object.defineProperty的enumerable: false这比直接在原型上添加属性更规范。它没有引入新的面向对象模型只是让原有的原型模型更容易理解和书写。5. 对象创建方式的对比与选型指南面对这么多模式在实际项目中该如何选择我根据自己的经验整理了一个对比表格并给出选型建议。创建模式优点缺点适用场景对象字面量语法简单直观一目了然。无法批量创建代码重复。创建单例对象、配置对象、模块导出。工厂模式封装创建细节避免重复代码。无法识别对象类型instanceof无效。简单场景下需要批量创建且不关心对象类型时。构造函数模式能识别对象类型符合传统 OO 习惯。每个实例都创建独立的方法浪费内存。基本被淘汰仅用于理解原型概念的教学场景。原型模式方法共享极致节省内存。共享引用值属性会导致数据污染初始化参数不灵活。几乎不单独使用常与构造函数模式组合。组合模式构造函数原型实例属性独立方法共享内存与功能平衡。是 ES5 的黄金标准。代码结构略显分离构造函数和原型定义分开。ES5 环境下的主流选择需要精细控制内存和继承时。动态原型模式将所有定义封装在构造函数内代码组织更集中。不能使用对象字面量重写原型原型修改逻辑稍显晦涩。喜欢将所有类定义放在一个代码块中的开发者。寄生构造函数模式可以为已有类型如 Array创建增强版不污染原生原型。创建的对象与构造函数原型无关联instanceof失效。非常特殊的场景如创建具有额外方法的特殊数组或日期对象。ES6 Class语法简洁现代结构清晰支持继承、静态方法、访问器属性等。本质是语法糖某些底层操作如直接修改prototype需理解原型本质。现代项目的绝对首选只要环境支持ES6无脑用 Class。我的选型建议非常直接对于现代项目支持 ES6毫不犹豫地使用ES6 Class。它语法友好工具链支持完善如 TypeScript是社区标准。它解决了组合模式代码分离的问题提供了更完整的面向对象编程体验如继承通过extends关键字变得极其简单。对于需要支持老旧环境如 IE的 ES5 项目使用组合模式构造函数原型。这是经过时间检验的、最可靠和高效的模式。动态原型模式可以作为个人偏好的一种变体。对于简单的数据集合或配置直接使用对象字面量。不要为了面向对象而面向对象。工厂模式和寄生构造函数模式有非常特定的使用场景在普通业务开发中应尽量避免除非你非常清楚它们的局限性和你的特殊需求。踩坑实录我曾接手一个老项目里面混用了工厂模式和构造函数模式导致一些工具函数里对类型的判断instanceof完全失效不得不大量改用duck typing检查对象是否具有某些属性或方法来修复费时费力。统一代码规范在团队内明确一种主要的对象创建范式至关重要。6. 深入原理new操作符与原型链的内存模型理解了各种模式我们还得挖一挖底层这样才能在遇到诡异问题时心里有数。让我们画一下使用组合模式或 Class 时内存中发生了什么。假设我们有如下代码function User(name) { this.name name; } User.prototype.sayHi function() { console.log(this.name); }; let u1 new User(张三); let u2 new User(李四);其内存关系可以这样理解注意这是逻辑示意图并非实际内存布局函数User被创建它自动获得一个prototype属性指向一个空对象我们称之为User.prototype。我们在User.prototype上添加了sayHi方法。执行new User(张三)引擎创建一个新的普通对象我们叫它instance1。将instance1的内部[[Prototype]]可通过__proto__或Object.getPrototypeOf()访问链接到User.prototype。将this绑定到instance1执行构造函数instance1.name 张三。返回instance1赋值给变量u1。u2的创建过程类似生成instance2其[[Prototype]]也指向User.prototype。当我们调用u1.sayHi()时引擎首先在u1对象自身查找sayHi属性没找到。引擎沿着u1的[[Prototype]]链找到User.prototype对象。在User.prototype上找到了sayHi方法于是调用它。函数内部的this在调用时被绑定为u1所以能正确输出u1.name。这就是原型继承的核心对象通过内部指针关联到其原型对象形成一个链式结构原型链。属性查找会沿着这条链向上进行直到找到属性或到达链的末端null。new操作符就是这个机制的“启动器”。而instanceof操作符的工作原理就是检查右侧构造函数的prototype属性是否出现在左侧对象的原型链上。console.log(u1 instanceof User); // true // 因为User.prototype 在 u1.__proto__ 上 console.log(u1 instanceof Object); // true // 因为Object.prototype 在 u1.__proto__.__proto__ 上User.prototype 本身也是一个对象它的原型是 Object.prototype理解了这个模型你就能明白为什么直接修改prototype的指向会影响所有实例为什么在实例上添加同名属性会“遮蔽”原型属性以及为什么组合模式是合理的。7. 常见问题排查与性能优化实践在实际开发中围绕对象创建和原型我遇到过不少典型问题。这里列几个高频的并附上排查思路。问题一方法调用时报错 “xxx is not a function”场景你确信原型上定义了方法但调用实例方法时却报错。可能原因与排查原型被意外重写或覆盖检查是否在代码某处执行了Constructor.prototype {...}或Constructor.prototype null特别是使用对象字面量整体赋值时忘了设置constructor属性可能导致原型链断裂。实例创建时机不对如果在创建实例之后才修改构造函数的原型那么之前创建的实例仍然指向旧的原型对象。function User() {} let u1 new User(); // u1.__proto__ 指向旧的 User.prototype User.prototype.sayHi function() {}; // 修改原型 let u2 new User(); // u2.__proto__ 指向新的 User.prototype u1.sayHi(); // 可能报错取决于旧的 User.prototype 上是否有 sayHi u2.sayHi(); // 正常使用Object.create(null)创建了无原型对象这样创建的对象没有__proto__无法参与原型链查找。问题二修改原型上的引用值属性影响了所有实例场景如前面例子在原型上定义了数组或对象一个实例修改后所有实例都“看到”了变化。解决方案永远不要将需要独立状态的引用值属性放在原型上。它们应该被定义在构造函数内部作为实例属性。原型上只放方法或真正需要共享的、只读的常量。问题三在 Class 中定义箭头函数方法场景为了规避this绑定问题在类中使用了箭头函数定义方法。class Button { constructor() { this.clicked false; } handleClick () { // 箭头函数作为实例属性 this.clicked true; console.log(this.clicked); }; }分析这不是类方法而是实例属性每个实例都会创建自己的handleClick函数副本回到了构造函数模式的内存浪费问题。它虽然解决了this绑定但牺牲了内存。建议优先使用普通类方法。如果确实需要绑定this可以在构造函数中绑定或使用类属性箭头函数的语法但要清楚其内存代价。更好的方式是使用 React 等框架时利用其自身的事件处理机制。性能优化点方法共享这是最大的性能优化点。务必确保方法定义在原型或 Class 中而不是在构造函数或类属性中重复创建。属性枚举使用Object.defineProperty或 Class 语法定义的方法默认是不可枚举的enumerable: false。这在使用for...in循环时能避免遍历到方法性能稍好也更符合预期。直接在原型上通过赋值添加的属性是可枚举的。原型链不宜过深极端情况下过长的原型链查找会影响性能。但在实际业务中多级继承如A - B - C造成的性能差异微乎其微可读性和架构清晰度更重要。避免设计环形原型链那会导致死循环。对象创建是 JavaScript 面向对象的基石。从散装变量到工厂函数从内存浪费的构造函数到优雅共享的原型模式再到语法糖 Class每一步优化都旨在让代码更高效、更易维护、更符合工程实践。理解这个过程不仅是为了掌握几种写法更是为了透彻理解 JavaScript “基于原型”这一核心设计思想。当你再看到new、prototype、__proto__、class、extends这些关键字时脑海中能清晰地浮现出它们背后的内存关联与设计意图这才是真正吃透了这部分内容。在接下来的部分我们会基于这些创建好的对象深入探讨如何实现对象之间的继承那将是面向对象编程的另一块重要拼图。
返回列表