ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JavaScript对象创建模式:从原型到Class的性能与设计实战

JavaScript对象创建模式:从原型到Class的性能与设计实战 1. 从“蜜汁上帝视角”看对象创建我们到底在造什么每次看到“JavaScript 创建对象的模式”这个标题我都能想象到新手朋友们的表情不就是new Object()或者{}吗还能玩出花来但当你真正深入项目尤其是维护一个超过三年的老代码库时你就会发现对象创建方式的选择远不止“创建一个东西”那么简单。它直接关系到代码的可读性、可维护性、内存效率甚至是团队协作的顺畅度。所谓的“蜜汁上帝视角”在我看来就是跳出“能用就行”的思维从一个更高维度去审视我们为什么需要这些模式它们各自解决了什么问题又带来了哪些新的“坑”JavaScript 的对象系统因其基于原型的独特设计显得既灵活又“诡异”。它不像 Java 或 C# 那样有严格的“类”蓝图这给了我们极大的自由但也让代码的组织方式五花八门。从最原始的Object构造函数到如今 ES6 的class语法糖中间涌现出的各种模式其实是一部为了解决特定时期、特定问题而不断演化的“编年史”。理解这些模式不是为了炫技或在面试时背诵八股文而是为了在遇到具体场景时能立刻选出最合适、最“优雅”或者说最不容易给自己和同事挖坑的那把工具。今天我们就抛开教科书式的定义罗列以一个踩过无数坑的“过来人”视角重新梳理这些模式。我们会重点关注在什么场景下该用哪种模式每种模式看似简单的背后隐藏着哪些性能陷阱或设计缺陷更重要的是我们会把class这个“新贵”和传统模式放在一起对比看看它到底是不是“终极解决方案”。无论你是刚刚接触对象概念的新手还是已经用过class却对prototype一知半解的中级开发者相信这篇“内功心法”都能帮你打通任督二脉。2. 基石与起点直接量、Object构造函数与工厂模式在讨论任何“模式”之前我们必须回到最根本的创建方式。这就像学武功要先扎马步理解这些基础才能看清后续模式演进的动机。2.1 对象字面量最直观的“快速创建”这是 99% 的开发者入门时学会的第一招也是日常编码中使用频率最高的方式。const person { name: 小明, age: 25, sayHello() { console.log(你好我是${this.name}); } };为什么它如此流行因为它极度简洁、直观。你需要什么属性直接往里写就行无需任何前置声明或模板。在需要创建一次性、结构简单的配置对象、数据载体或模块导出时它是无可争议的最佳选择。“蜜汁”视角下的深层思考内存与性能每次执行字面量引擎都会创建一个全新的对象。如果这段代码在循环或高频函数中被调用会创建大量短暂存在的对象可能触发垃圾回收影响性能。对于需要大量创建相同结构对象的场景这不是最优解。类型标识所有通过字面量创建的对象其constructor属性都指向Object。这意味着person.constructor Object为true。如果你需要区分“人”对象和“狗”对象字面量无法在语言层面提供这种类型信息当然你可以手动加一个type: person属性但这很原始。方法冗余注意sayHello方法。每个person对象都会拥有自己独立的sayHello函数副本。如果创建一千个person对象就会有一千个功能完全相同的sayHello函数存在于内存中这是极大的浪费。这是字面量模式在需要定义方法时的一个致命缺点。实操心得对象字面量是你的“瑞士军刀”适合轻量级、临时性的任务。但一旦你的对象需要方法或者需要批量创建请立刻考虑其他模式。判断标准是这个对象的结构是否会被反复使用如果是字面量就该退场了。2.2 Object构造函数几乎被遗忘的“上古语法”const person new Object(); person.name 小明; person.age 25; person.sayHello function() { console.log(你好我是${this.name}); };它和字面量有区别吗在功能上几乎没有。最终得到的对象一模一样。但在底层new Object()是一个构造函数调用而字面量{}是语法糖。在现代 JavaScript 引擎的优化下性能差异微乎其微可以忽略不计。为什么现在没人用了纯粹是因为冗余和丑陋。它比字面量写法更长且没有任何额外好处。在 ES5 之后Object.create()的出现更是让它失去了存在的必要。你可以把它当作 JavaScript 历史的一部分了解即可在实际编码中请永远使用字面量{}。2.3 工厂模式封装创建细节的第一次尝试当我们发现需要批量创建结构类似的对象且字面量会导致方法冗余时工厂模式便应运而生。它的核心思想是用一个函数来封装对象的创建过程。function createPerson(name, age) { const obj new Object(); // 或使用 {} obj.name name; obj.age age; obj.sayHello function() { console.log(你好我是${this.name}); }; return obj; } const person1 createPerson(小明, 25); const person2 createPerson(小红, 23);工厂模式解决了什么问题代码复用创建逻辑被封装在一处修改起来方便。解耦调用者无需关心对象内部是如何构建的只需传入参数即可。“蜜汁”视角下的致命缺陷工厂模式并没有解决我们之前提到的方法冗余和类型识别这两个核心问题。方法冗余依旧person1.sayHello person2.sayHello的结果是false。每个对象仍然有自己的函数副本内存浪费问题依旧存在。类型识别模糊person1和person2的构造函数依然是Object。我们无法通过instanceof等操作符来判断它是不是一个“人”类型的对象。工厂模式只是一个代码组织模式而非 JavaScript 对象系统的继承模式。它让创建过程更整洁但没有触及对象之间共享行为、建立类型关系的本质。因此它通常被视为一种过渡方案当你的对象需要方法时它的缺点就会立刻暴露。踩坑实录我曾接手一个老项目里面大量使用工厂函数创建带有复杂方法的对象。在页面表格中渲染几百行数据时内存占用飙升页面滚动开始卡顿。定位后发现正是每个对象都携带了数个完整的函数副本。将其重构为使用原型的模式后内存占用立减 90%性能问题迎刃而解。这个坑让我深刻认识到选择对象创建模式首先需要考虑的是内存效率。3. 走向专业构造函数模式与原型模式为了克服工厂模式的缺陷JavaScript 社区探索出了结合构造函数与原型的方法。这构成了 ES5 时代面向对象编程的基石。3.1 构造函数模式赋予对象“类型”构造函数本质上就是一个普通函数但通常约定以大写字母开头并通过new操作符来调用。function Person(name, age) { this.name name; this.age age; this.sayHello function() { console.log(你好我是${this.name}); }; } const person1 new Person(小明, 25); const person2 new Person(小红, 23); console.log(person1 instanceof Person); // true console.log(person1.constructor Person); // true划时代的进步类型识别使用new调用构造函数时引擎会做四件事创建一个新的空对象。将这个新对象的内部[[Prototype]]即__proto__链接到构造函数的prototype对象。将构造函数内部的this绑定到这个新对象。执行构造函数内部的代码为this添加属性。如果构造函数没有显式返回一个对象则自动返回这个新对象。关键在第 2 步。这使得person1.__proto__ Person.prototype从而让instanceof检查成为可能。我们终于有了区分“人”对象和“狗”对象的内置机制但是老问题阴魂不散方法冗余仔细看sayHello方法仍然是在构造函数内部定义的。这意味着person1.sayHello person2.sayHello依然是false。每一个new Person()调用都会在内存中创建一个全新的sayHello函数。构造函数模式解决了类型问题但没解决内存效率问题。3.2 原型模式共享行为的终极答案原型Prototype是 JavaScript 实现继承和共享属性的核心机制。每个函数都有一个prototype属性指向一个对象。所有由该构造函数创建的对象实例都可以访问其prototype对象上的属性和方法。function Person(name, age) { this.name name; this.age age; } // 将方法定义在构造函数的原型上 Person.prototype.sayHello function() { console.log(你好我是${this.name}); }; const person1 new Person(小明, 25); const person2 new Person(小红, 23); console.log(person1.sayHello person2.sayHello); // true方法共享了工作原理与内存优势当我们调用person1.sayHello()时引擎首先在person1对象自身查找sayHello属性。没找到于是沿着__proto__链找到Person.prototype对象并在那里找到了sayHello方法。person2的查找过程完全相同。因此无论创建多少个 Person 实例sayHello方法在内存中只存在一份被所有实例共享。这完美解决了内存浪费问题。“蜜汁”视角下的原型陷阱与最佳实践原型非常强大但也非常容易用错。动态性原型上的属性/方法是动态查找的。即使在对象创建之后你修改了Person.prototype所有已存在的实例也能立即“看到”这个变化因为查找是实时的。这既是优点也是缺点需要谨慎使用。Person.prototype.sayBye function() { console.log(再见); }; person1.sayBye(); // 可以调用即使 person1 是在添加方法前创建的重写原型对象这是一个经典大坑。function Person() {} const person1 new Person(); Person.prototype { sayHello: function() { console.log(Hello); } }; const person2 new Person(); console.log(person1.sayHello); // undefined console.log(person2.sayHello); // functionperson1的__proto__指向的是最初的Person.prototype对象。当你用一个新的对象完全替换Person.prototype时person1的链接不会更新它依然指向旧的原型对象。而person2是在替换后创建的它的__proto__指向新的原型对象。这会导致程序出现难以调试的不一致行为。最佳实践是永远不要直接替换prototype对象而是逐个修改其属性。共享引用类型属性这是原型模式最容易踩的坑。function Person() {} Person.prototype.friends [小红, 小刚]; // 在原型上定义一个数组 const person1 new Person(); const person2 new Person(); person1.friends.push(小李); console.log(person2.friends); // [小红, 小刚, 小李]因为friends数组存在于原型上person1和person2访问的是同一个数组。修改其中一个会影响到所有实例。对于需要独立拥有的引用类型属性如数组、对象必须在构造函数内部初始化而不是放在原型上。核心心法构造函数模式负责定义实例属性每个对象独有的如name,age原型模式负责定义共享方法和只读的共享属性。二者结合构成了 ES5 时代最经典、最可靠的创建对象模式也被称为“组合使用构造函数模式和原型模式”。4. 进化与融合从寄生构造到ES6的Class在组合模式成为主流前后社区还探索过一些其他模式它们各有其特定的应用场景和思想价值。而 ES6 的class语法则可以看作是对组合模式的一种标准化和语法美化。4.1 寄生构造函数模式一个特殊的“工厂”这种模式看起来像构造函数用new调用但内部实现更像工厂函数。function SpecialArray(...items) { const array new Array(); // 创建一个基础对象 array.push(...items); // 添加特殊方法 array.toPipedString function() { return this.join(|); }; return array; // 返回这个加工后的对象 } 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它的特点与用途它返回的对象与构造函数 (SpecialArray) 的原型没有关系。instanceof会失效。它主要用于扩展一个已有的内置类型如 Array、Date但又不想直接修改其原型的场景。你可以创建一个具有额外功能的特殊对象同时保留原类型的全部特性。慎用由于破坏了instanceof的语义且创建的对象与构造函数脱钩这种模式容易造成混淆除非在非常特定的场景如创建不可直接实例化的“工具对象”否则不建议使用。4.2 稳妥构造函数模式追求安全性的极致在一些强调安全性的环境如防止数据被篡改的库或者旧式浏览器中这种模式曾被使用。function Person(name, age) { const o new Object(); // 创建新对象 // 可以在这里定义私有变量和函数 const privateSecret secret; // 定义公有方法通过闭包访问私有数据 o.sayName function() { console.log(name); // 直接使用参数而非 this.name console.log(privateSecret); }; return o; // 返回这个对象 } const friend Person(小明, 25); friend.sayName(); // 输出 小明, secret console.log(friend.name); // undefined console.log(friend.privateSecret); // undefined核心思想不引用this。不使用new操作符调用。实例方法通过闭包访问传入的原始数据而不将其作为对象的属性暴露出去。这样创建的对象极其安全外界无法直接访问其数据只能通过定义的公有方法来交互。现代替代方案在 ES6 中我们可以使用WeakMap或Symbol来模拟私有属性或者直接使用模块作用域这比稳妥构造函数模式更清晰、更强大。4.3 ES6 Class语法糖但不仅仅是糖ES6 引入了class关键字提供了一种更接近传统面向对象语言的语法来创建对象和处理继承。class Person { constructor(name, age) { // 实例属性对应构造函数模式 this.name name; this.age age; } // 类方法对应原型模式 sayHello() { console.log(你好我是${this.name}); } // 静态方法存在于类本身而非实例 static describe() { console.log(这是一个“人”类); } } const person1 new Person(小明, 25); person1.sayHello(); // 你好我是小明 Person.describe(); // 这是一个“人”类 console.log(person1 instanceof Person); // true console.log(typeof Person); // function“蜜汁”深度剖析Class 的本质它确实是语法糖class声明的Person本质上仍然是一个函数typeof Person function。constructor就是原来的构造函数类中定义的方法会自动添加到Person.prototype上。instanceof机制完全一样。Babel 等工具可以将class代码转译成 ES5 的组合模式。它带来了什么更清晰的意图语法上明确区分了构造函数(constructor)、实例方法、静态方法代码结构一目了然。内置的严格模式类声明和类表达式中的代码默认在严格模式下执行。更好的继承语法extends和super关键字让继承的实现比 ES5 的Object.create和修改原型链要简洁、安全得多。访问器属性可以使用get和set关键字优雅地定义。一些“坑”被填平类方法不可枚举Object.keys(Person.prototype)拿不到sayHello这更符合预期。它没改变什么原型链机制没变对象之间依然是基于原型的继承。“类”是“特殊函数”的本质没变。引用类型属性的共享问题依然需要注意虽然定义方式变了但原理相同。Class vs 传统组合模式如何选择对于新项目和个人学习无脑选择class。它是现代 JavaScript 的标准写法语法更优工具链支持更好可读性更强。如果你需要支持非常古老的浏览器如 IE10 及以下且无法使用转译工具那么只能使用 ES5 组合模式。理解class背后的原型机制至关重要。这能帮助你在调试时看懂__proto__链理解super的工作原理避免一些深层次的错误。一个常见的误解有人认为class让 JavaScript 变成了“真正的”基于类的语言。这是错误的。class没有引入新的面向对象模型它只是现有原型模型的一个更友好、更强大的语法界面。理解这一点你就能看透所有class相关问题的本质。5. 模式选择实战指南与性能迷思理论说了这么多到底该怎么选我们结合具体场景和性能考量来做一个实战决策。5.1 场景化决策树当你需要创建一个对象时可以遵循以下决策流程对象是简单的、一次性的数据集合吗是- 使用对象字面量{}。例如函数配置参数、API 响应数据格式化、模块导出。否- 进入下一步。这个对象结构会被多次使用并且需要定义方法吗否只需要数据 - 可以考虑一个返回字面量的工厂函数用于统一创建逻辑。例如创建一系列只有数据的 DTO数据传输对象。是- 进入下一步。你需要明确的类型检查和继承关系吗你的运行环境支持 ES6 吗是且支持 ES6- 使用class。这是现代 Web 开发、Node.js 服务的标准答案。是但不支持 ES6- 使用ES5 组合模式构造函数 原型。否你只是想要一个具有特定功能的对象不关心它的“类型”是什么 - 可以考虑稳妥构造函数模式或工厂模式但务必清楚其局限性。你需要扩展一个内置类型如 Array而不污染其原型吗是- 考虑寄生构造函数模式。但请三思通常有更好的设计如组合优于继承使用工具函数处理数组。5.2 性能迷思与真相关于对象创建的性能社区有很多传言。我们基于 V8 引擎的现代优化来澄清一下操作性能考量现代引擎下的真相字面量{}vsnew Object()new调用有开销差异可忽略。引擎对字面量有高度优化new Object()也被优化。永远选择字面量为了代码简洁。在构造函数内定义方法 vs 在原型上定义每次new都创建新函数天壤之别。原型定义是常量级的内存占用构造函数内定义是O(n)的内存占用。对于方法必须放在原型或 class 中。classvs 传统组合模式class是语法糖有转换开销在支持 ES6 的环境中无显著差异。class声明在解析阶段就被转换为内部表示运行时的对象创建和查找机制完全相同。选择class无需担心性能损失。动态添加原型属性修改原型影响查找效率微乎其微。现代引擎的隐藏类Hidden Class和 ICInline Cache机制非常智能能很好地处理原型变化。但为了代码清晰和可预测性仍建议在定义类时就规划好原型属性。真正的性能杀手往往不是选择哪种模式而是错误地使用模式在循环中创建大量携带独立函数副本的对象错误使用构造函数内定义方法。深度嵌套的原型链导致属性查找过深。频繁地动态增删对象或原型上的属性导致引擎的优化策略失效隐藏类转换。5.3 从“对象创建”到“对象思维”的跃迁掌握了这些模式最终目的不是为了记住它们而是为了培养一种“对象思维”。当你设计一个模块或一个组件时你应该思考职责边界这个对象应该拥有什么数据实例属性应该提供什么行为方法哪些行为是相似的可以共享放到原型/class里关系设计对象之间是“有一个”组合的关系还是“是一个”继承的关系在 JavaScript 中优先使用组合而非继承这是软件工程的通用原则在 JS 中尤其重要因为原型链继承很脆弱。class的extends虽然好用但滥用会导致僵化的层级结构。状态管理对象的状态属性应该是私有的还是公有的如何通过方法公有接口来控制状态的修改而不是直接暴露属性这引出了封装的概念在现代 JavaScript 中我们可以利用闭包、WeakMap、Symbol或 ES2022 的#私有字段来实现。例如设计一个简单的TodoItem组件错误思维我直接用一个字面量{id: 1, text: ..., completed: false}然后在外面写一堆函数来操作它。对象思维TodoItem是一个具有明确状态和行为的实体。class TodoItem { #id; // 私有字段ES2022 #text; #completed; constructor(text) { this.#id generateId(); this.#text text; this.#completed false; } // 公有接口控制状态修改 complete() { this.#completed true; } updateText(newText) { if (newText.trim()) { this.#text newText; } } // 提供状态的只读访问 get info() { return { id: this.#id, text: this.#text, completed: this.#completed }; } }这样设计TodoItem的状态被很好地封装起来外部只能通过定义好的方法来交互避免了状态被随意修改带来的 bug。这就是从“创建对象”升华到了“设计对象”。回过头看“蜜汁上帝视角”其实就是要求我们超越具体的语法去理解每种模式背后的设计意图和适用场景。没有一种模式是银弹class也不是终点。真正的功力在于你能在纷繁的需求和约束下熟练地运用这些知识创造出清晰、健壮、高效的代码结构。对象创建是起点良好的设计才是通往可维护软件的道路。
返回列表