ARTICLE DETAIL

资讯详情

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

JavaScript面向对象编程:从工厂模式到原型链的演进与实践

JavaScript面向对象编程:从工厂模式到原型链的演进与实践 1. 从“捏小人”到“造工厂”为什么我们需要面向对象如果你写过一段时间的JavaScript尤其是处理过稍微复杂一点的页面逻辑比如一个动态表单验证、一个可拖拽的组件或者一个购物车你大概率会经历过这样的时刻变量满天飞函数互相调用改一个地方牵一发而动全身代码像一团乱麻自己写的代码过两周再看都像在看天书。这时候你需要的可能不仅仅是更多的if...else而是一种组织代码的“思想”。面向对象编程OOP就是这种思想。你可以把它想象成乐高积木。面向过程编程像是用一堆散装的积木块一块一块去拼虽然最终也能拼出个样子但结构松散容易散架。而面向对象编程则是先把积木块组合成一个个标准的、功能明确的“模块”比如车轮、窗户、门再用这些模块去搭建更复杂的结构。每个模块内部如何实现你无需时刻关心你只需要知道这个模块是“门”它有“打开”和“关闭”的方法。在JavaScript中实现OOP有几种经典的模式它们就像是不同年代、不同工艺的“造人”或“造物”方法。从最原始的“手工捏制”工厂模式到有了标准模具的“批量铸造”构造函数模式再到追求极致效率和共享的“基因克隆”原型模式最后理解整个家族的“血脉传承”原型链。理解这几种模式的演进不仅能让你写出更清晰、更易维护的代码更是深入理解JavaScript语言核心——原型机制——的必经之路。无论你是想解决实际开发中的代码组织难题还是为了应对那些总爱问“原型链”的面试题这篇文章都会带你从“为什么需要”开始一步步拆解这几种模式的原理、实现、优缺点以及它们之间的演进关系。2. 工厂模式手工作坊式的对象创建当我们最初想要批量创建一些具有相同属性结构的对象时最直观的想法就是写一个函数这个函数就像一个“工厂”你给它原料参数它给你吐出一个加工好的产品对象。2.1 基本实现与原理工厂模式的核心是一个函数这个函数内部会创建一个新对象为其添加属性和方法然后返回这个对象。这样我们就不用每次都重复写let obj {}; obj.xx xx;这样的代码了。举个例子假设我们要创建一系列“用户”对象每个用户都有名字、年龄和自我介绍的方法。function createUser(name, age) { // 1. 原料准备创建一个空对象 let user {}; // 2. 加工装配为对象添加属性 user.name name; user.age age; // 3. 添加功能为对象添加方法 user.sayHi function() { console.log(大家好我叫${this.name}今年${this.age}岁。); }; // 4. 出厂返回加工好的对象 return user; } // 使用工厂 let user1 createUser(张三, 25); let user2 createUser(李四, 30); user1.sayHi(); // 输出大家好我叫张三今年25岁。 user2.sayHi(); // 输出大家好我叫李四今年30岁。 console.log(user1.sayHi user2.sayHi); // 输出false从上面的代码和最后的console.log可以看出工厂模式工作得很好user1和user2都拥有了各自的数据和功能。但是这里暴露了工厂模式最大的一个问题方法无法共享。user1.sayHi和user2.sayHi是两个完全不同的函数虽然它们功能一模一样。想象一下如果我们要创建一万个用户对象就会在内存中存在一万个一模一样的sayHi函数这无疑是巨大的资源浪费。2.2 优缺点与适用场景优点封装了创建过程将创建对象的代码集中在一个地方代码复用性提高。解决了创建多个相似对象的问题避免了重复代码。结构简单直观对于初学者来说非常容易理解和上手。缺点对象类型识别问题instanceof 无效所有通过工厂函数创建的对象其构造函数都指向Object我们无法区分一个对象是“用户”还是“商品”。console.log(user1 instanceof createUser); // false方法无法共享内存效率低如上所述每个对象都拥有自己独立的方法副本。适用场景工厂模式适合创建一些简单的、一次性的、不需要复杂类型识别和方法共享的对象。在早期JavaScript或一些简单的脚本中很常见。但在现代开发中对于需要明确类型和注重性能的场景它已经很少作为首选方案了。注意虽然工厂模式有缺陷但“工厂”这个思想非常重要。在后端开发、设计模式中“抽象工厂”、“工厂方法”等模式都是其高级形态用于解耦对象的创建和使用。在JavaScript中我们通常用更高级的模式来替代基础的工厂函数。3. 构造函数模式引入“蓝图”的概念为了解决工厂模式的对象类型识别问题JavaScript提供了构造函数。构造函数本质就是一个普通函数但通常约定其首字母大写如User。通过new操作符来调用它它会做几件特殊的事情。3.1 new操作符到底做了什么当你使用new User(...)时背后发生了以下四件事创建一个全新的空对象。将这个新对象的[[Prototype]]即__proto__链接到构造函数的prototype对象。这是实现继承和共享的关键一步我们稍后会详细讲。将构造函数内部的this绑定到这个新创建的对象。因此在构造函数内部this.name name就是在给这个新对象添加属性。如果构造函数没有显式返回一个对象则自动返回这个新创建的对象。让我们用构造函数重写上面的例子function User(name, age) { // 这里的 this 指向 new 创建的新对象 this.name name; this.age age; this.sayHi function() { console.log(大家好我叫${this.name}今年${this.age}岁。); }; // 不需要 return new 操作符会帮我们返回 this } // 使用 new 操作符调用构造函数 let user1 new User(王五, 28); let user2 new User(赵六, 35); user1.sayHi(); // 输出大家好我叫王五今年28岁。 console.log(user1 instanceof User); // 输出true问题解决了 console.log(user1.sayHi user2.sayHi); // 输出false内存问题依旧存在现在user1 instanceof User返回了true我们成功解决了对象类型识别的问题user1和user2现在可以被明确识别为User类型的实例。3.2 深入理解this的绑定在构造函数中this的指向是动态的完全由调用方式决定。当使用new User()调用时this指向新创建的空对象。如果直接调用User()没有new在非严格模式下this会指向全局对象浏览器中是window这会导致属性被添加到全局造成污染和难以追踪的bug。严格模式下this为undefined直接报错。function User(name) { this.name name; } // 错误调用 let u User(全局泄露); // 没有 new console.log(window.name); // 输出“全局泄露”属性泄露到了全局 console.log(u); // 输出undefined因为函数没有返回值 // 正确调用 let u2 new User(正确实例); console.log(u2.name); // 输出“正确实例”实操心得为了避免忘记写new导致的错误一些库或框架会采用“安全构造函数”模式。即在构造函数内部判断this是否是当前构造函数的实例如果不是则自动用new重新调用一次。但这在现代开发中已不常见更推荐使用ES6的class语法它强制要求用new调用。3.3 构造函数模式的遗留问题虽然解决了类型识别但构造函数模式依然没有解决方法共享的问题。每个实例仍然有自己的sayHi方法副本。这违背了面向对象中“方法应该是所有实例共享的行为”这一原则。为了解决这个问题我们需要请出JavaScript面向对象的灵魂——原型。4. 原型模式共享的终极方案原型Prototype是JavaScript中实现继承和属性共享的基石。每个函数在创建时都会自动获得一个prototype属性这个属性是一个对象我们称之为“原型对象”。而通过这个构造函数new出来的所有实例其内部都有一个隐藏的[[Prototype]]属性在大多数浏览器中可以通过__proto__访问这个属性指向了构造函数的prototype对象。4.1 原型对象与实例的关系当我们试图访问一个对象的属性比如user1.sayHi时JavaScript引擎会执行以下搜索首先在对象自身的属性中查找即user1自己有没有sayHi。如果没找到就通过__proto__去它的原型对象即User.prototype上查找。如果原型对象上还没找到就继续沿着__proto__向上查找原型链直到找到或者到达终点null。利用这个机制我们可以把需要共享的方法定义在构造函数的prototype上。function User(name, age) { // 属性定义在实例自身上每个实例独有 this.name name; this.age age; } // 方法定义在原型上所有实例共享 User.prototype.sayHi function() { console.log(大家好我叫${this.name}今年${this.age}岁。); }; // 还可以在原型上定义其他共享属性通常用于常量或默认值 User.prototype.species 人类; let user1 new User(孙七, 40); let user2 new User(周八, 22); user1.sayHi(); // 输出大家好我叫孙七今年40岁。 user2.sayHi(); // 输出大家好我叫周八今年22岁。 console.log(user1.sayHi user2.sayHi); // 输出true方法实现了共享 console.log(user1.species); // 输出“人类” console.log(user2.species); // 输出“人类” // 实例自身没有 sayHi 属性但在原型上找到了 console.log(user1.hasOwnProperty(sayHi)); // false console.log(user1.hasOwnProperty(name)); // true现在sayHi方法只存在于内存中的一个地方User.prototype对象上所有User的实例都通过原型链共享这一个方法。这完美解决了内存浪费的问题。同时实例访问这个方法时方法内部的this仍然正确指向调用它的那个实例对象user1或user2这是由JavaScript的函数调用规则决定的。4.2 原型模式的动态性原型对象是“活”的。即使在创建了实例之后我们修改构造函数的prototype已有的实例也能访问到新的修改因为它们通过__proto__引用的就是那个对象。let user3 new User(吴九, 50); // 给原型新增一个方法 User.prototype.sayBye function() { console.log(${this.name}说再见); }; // 早已创建的实例 user3 也能调用新方法 user3.sayBye(); // 输出“吴九说再见”但是这里有一个极其重要的坑如果你重写了整个prototype对象而不是修改它那么之前创建的实例的__proto__仍然指向旧的原型对象与新原型断开了联系。function User() {} let user4 new User(); User.prototype { constructor: User, // 最好手动修正constructor指向 sayHello: function() { console.log(Hello); } }; // user4 无法访问新原型上的方法 // user4.sayHello(); // TypeError: user4.sayHello is not a function // 新创建的实例可以 let user5 new User(); user5.sayHello(); // 输出“Hello”踩坑实录在实际项目中应尽量避免直接重写整个prototype对象。如果必须这么做比如为了实现某种复杂的继承一定要确保在重写之前没有创建任何实例或者清楚地知道这会导致新旧实例的原型链不一致。更安全的做法是使用Object.assign(User.prototype, { ... })来合并新属性或者使用ES6的class语法中的extends。4.3 constructor属性你可能注意到在上面的重写例子中我们手动设置了constructor: User。每个原型对象prototype默认都有一个constructor属性指向其关联的构造函数。这个属性主要用于标识对象的类型来源。function User() {} console.log(User.prototype.constructor User); // true let user6 new User(); console.log(user6.constructor User); // true // user6自身没有constructor是从原型链上找到的 User.prototype.constructor当我们重写prototype时新的对象默认没有constructor属性所以最好手动补上以保持这个约定的连续性。虽然这个属性不常用但一些库或代码可能会依赖它。5. 组合模式构造函数与原型模式的结合在实践中我们几乎不会单独使用构造函数模式或原型模式而是将它们结合起来形成一种“组合模式”这被广泛认为是创建自定义类型的最有效方式。模式思想构造函数模式用于定义实例属性。这些属性是每个实例独有的比如名字、年龄、ID等。原型模式用于定义共享的方法和常量。所有实例共享同一份函数引用节省内存。// 组合构造函数模式和原型模式 function Person(name, age, job) { // 实例属性独有 this.name name; this.age age; this.job job; this.friends [小明, 小红]; // 引用类型属性也是独有 } // 共享的方法和属性 Person.prototype { constructor: Person, // 显式设置constructor避免重写导致丢失 sayName: function() { console.log(this.name); }, species: 人类 }; let person1 new Person(Alice, 29, Engineer); let person2 new Person(Bob, 27, Designer); person1.friends.push(小刚); console.log(person1.friends); // [小明, 小红, 小刚] console.log(person2.friends); // [小明, 小红]互不影响 console.log(person1.sayName person2.sayName); // true方法共享这种模式完美融合了两种模式的优点每个实例拥有自己独立的属性特别是引用类型属性如数组friends修改时不会影响其他实例。所有实例共享方法最大程度节省了内存。实例可以通过constructor属性识别类型。这也是ES6的class语法糖在底层所采用的主要实现方式。class中的constructor方法对应这里的构造函数在class内部定义的方法默认就是挂在原型上的。6. 原型链JavaScript的继承机制理解了单个对象的原型后我们就可以把多个原型对象串联起来形成一条链这就是原型链。它是JavaScript实现继承的核心机制。6.1 什么是原型链简单说原型链就是多个对象通过__proto__属性连接起来的一条链。当访问一个对象的属性时JavaScript引擎会沿着这条链向上查找直到找到属性或到达链的尽头null。让我们通过实现继承来构建一条原型链// 父类超类型 function SuperType() { this.property true; this.colors [red, blue, green]; } SuperType.prototype.getSuperValue function() { return this.property; }; // 子类子类型 function SubType() { this.subproperty false; } // 关键步骤实现继承。让子类的原型对象成为父类的一个实例。 SubType.prototype new SuperType(); // 原型链就此建立 // 在子类原型上添加新方法必须在继承语句之后 SubType.prototype.getSubValue function() { return this.subproperty; }; // 测试 let instance new SubType(); console.log(instance.getSuperValue()); // 输出true现在instance、SubType.prototype、SuperType.prototype之间就形成了一条原型链instance.__proto__-SubType.prototype(即new SuperType()这个对象)SubType.prototype.__proto__-SuperType.prototypeSuperType.prototype.__proto__-Object.prototypeObject.prototype.__proto__-null当调用instance.getSuperValue()时查找过程是instance自身没有 - 找SubType.prototype即那个SuperType实例自身也没有 - 找SuperType.prototype找到了调用成功。6.2 原型链继承的问题原型链继承虽然概念简单但存在两个显著问题原型中包含的引用类型值会被所有实例共享。这通常不是我们想要的。let instance1 new SubType(); instance1.colors.push(black); console.log(instance1.colors); // [red, blue, green, black] let instance2 new SubType(); console.log(instance2.colors); // [red, blue, green, black]instance1的修改影响了instance2这是因为colors属性存在于SubType.prototype即那个SuperType实例上它是所有SubType实例共享的。在创建子类实例时无法向父类的构造函数传递参数。因为继承是通过SubType.prototype new SuperType();实现的这次“父类实例化”发生在定义子类时而不是创建子类实例时。这导致所有子类实例共享的父类属性状态是固定的。6.3 更优的继承方式组合继承为了解决原型链继承的问题社区最常用的是一种叫做“组合继承”Combination Inheritance的模式它结合了构造函数继承和原型链继承。思路借用构造函数Constructor Stealing在子类构造函数内部调用父类构造函数利用call或apply方法将父类的实例属性“复制”到子类实例上。这解决了引用类型共享和传参问题。原型链设置子类原型为父类的一个实例实现方法的共享和继承。function SuperType(name) { this.name name; this.colors [red, blue, green]; } SuperType.prototype.sayName function() { console.log(this.name); }; function SubType(name, age) { // 1. 借用构造函数继承属性独有 SuperType.call(this, name); // 第二次调用 SuperType this.age age; } // 2. 原型链继承方法共享 SubType.prototype new SuperType(); // 第一次调用 SuperType SubType.prototype.constructor SubType; // 修正constructor指向 SubType.prototype.sayAge function() { console.log(this.age); }; let instance1 new SubType(Nicholas, 29); instance1.colors.push(black); console.log(instance1.colors); // [red, blue, green, black] instance1.sayName(); // Nicholas instance1.sayAge(); // 29 let instance2 new SubType(Greg, 27); console.log(instance2.colors); // [red, blue, green]互不影响 instance2.sayName(); // Greg instance2.sayAge(); // 27组合继承是JavaScript中最常用的继承模式它保留了原型链继承方法共享的优点又通过构造函数让每个实例拥有独立的属性。但它也有一个小缺点父类构造函数被调用了两次代码中已标注这导致子类原型SubType.prototype上会有一份多余的父类实例属性。不过在实践中这点性能开销和内存占用通常可以接受。6.4 原型链的终点与内置对象所有普通的对象原型链的终点都是Object.prototype而Object.prototype.__proto__是null。这意味着JavaScript中几乎所有对象都继承了Object.prototype上的方法比如toString()、valueOf()、hasOwnProperty()等。function MyFunc() {} let obj new MyFunc(); let arr []; let str hello; // 沿着原型链向上追溯 console.log(obj.__proto__ MyFunc.prototype); // true console.log(obj.__proto__.__proto__ Object.prototype); // true console.log(obj.__proto__.__proto__.__proto__); // null console.log(arr.__proto__ Array.prototype); // true console.log(arr.__proto__.__proto__ Object.prototype); // true console.log(str.__proto__ String.prototype); // true (临时包装对象) console.log(str.__proto__.__proto__ Object.prototype); // true理解原型链你就能明白为什么一个数组可以直接调用push、map方法来自Array.prototype一个函数可以直接调用call、apply方法来自Function.prototype以及为什么我们可以给所有对象添加自定义方法虽然不推荐污染内置原型。7. 现代实践从原型到ClassES6引入的class语法本质上只是上述原型和构造函数模式的语法糖它让面向对象的写法更清晰、更像传统面向对象语言如Java。但底层机制依然是原型。// ES6 Class 语法 class Person { constructor(name, age) { // 对应构造函数 this.name name; this.age age; } // 类中定义的方法默认就在原型上 sayHi() { console.log(Hello, Im ${this.name}); } // 静态方法属于类本身不属于实例 static describe() { console.log(这是一个Person类); } } // 继承 class Student extends Person { constructor(name, age, grade) { super(name, age); // 相当于借用父类构造函数 this.grade grade; } study() { console.log(${this.name} is studying.); } } let stu new Student(Alice, 20, A); stu.sayHi(); // 继承自Person原型 stu.study(); // 自己的方法 Student.describe(); // 调用静态方法class语法并没有引入新的继承模型它只是让基于原型的继承写起来更容易、意图更明确。extends关键字背后实现的继承逻辑比我们手动写的组合继承更优化例如解决了父类构造函数被调用两次的问题。在Babel等工具的转译下class代码最终会被转换为使用原型和构造函数的ES5代码。理解工厂模式、构造函数模式、原型模式以及它们的组合和演进是掌握JavaScript面向对象编程的基石。这不仅能让你在面试中游刃有余更重要的是它能让你在设计和组织复杂前端应用代码时有更清晰的思路和更扎实的工具。从“手工作坊”到“标准模具”再到“基因共享”每一次模式的演进都是为了解决实际开发中的痛点。当你下次再面对一团乱麻的业务代码时不妨思考一下是否可以通过合理的对象抽象和原型继承将它们梳理成一个个职责清晰、易于维护的“乐高模块”。
返回列表