ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入理解JavaScript原型链:从工厂模式到ES6 Class的演进与最佳实践

2026/8/27 21:36:16 拓冰建站 浏览量
深入理解JavaScript原型链:从工厂模式到ES6 Class的演进与最佳实践 1. 项目概述从“对象”说起理解JavaScript的基石如果你写过JavaScript那你一定用过对象。无论是从后端接口拿到的一个JSON数据还是用document.getElementById获取的一个DOM元素它们都是对象。但“面向对象”这四个字在JavaScript里总带着点神秘和混乱。新人常常被prototype、__proto__、constructor这些概念绕晕而一些有经验的开发者也可能只是停留在“会用class语法糖”的层面一旦遇到继承的深坑或者需要手写一个精巧的工具函数时底层的无力感就上来了。这个内容就是要彻底掀开JavaScript面向对象编程OOP的盖子。我们不只讲怎么用new关键字更要回溯到没有class的年代看看那些最原始、最本质的创建对象和实现继承的模式工厂模式、构造函数模式、原型模式。理解了这些你才能真正看懂那条著名的“原型链”到底是怎么连起来的为什么一个数组能调用toString方法以及instanceof运算符背后究竟在检查什么。这对于你深入理解JavaScript语言本身、应对复杂面试题、甚至是进行一些高级的JS逆向分析都至关重要。无论你是刚入门想夯实基础的新手还是希望突破瓶颈的中级开发者这次对“对象”的深度探索都会让你对JavaScript有焕然一新的认识。2. 核心模式演进从“制造”到“描述”JavaScript的面向对象之路是一部从“模拟”到“本质”的演进史。它没有像Java、C那样一开始就提供“类”的概念而是提供了一套更为灵活的机制让我们可以通过多种方式达到“对象复用”和“继承”的目的。理解这几种经典模式不仅是学习历史更是掌握JavaScript动态语言精髓的关键。2.1 工厂模式对象的“流水线”在早期如果我们想批量创建一系列结构相似的对象最直观的想法就是写一个函数这个函数像一个工厂车间接收原材料参数然后生产并返回一个加工好的产品对象。function createPerson(name, age) { var obj new Object(); // 1. 原材料创建一个新对象 obj.name name; // 2. 加工为对象添加属性 obj.age age; obj.sayName function() { console.log(this.name); }; return obj; // 3. 出厂返回这个对象 } var person1 createPerson(Alice, 25); var person2 createPerson(Bob, 30); person1.sayName(); // 输出Alice模式解析与核心问题 工厂模式的逻辑非常清晰封装了创建对象的细节。但它有两个显著的缺陷对象类型识别问题person1和person2都是Object的实例我们无法区分它们是由createPerson工厂创建的还是由其他函数创建的。console.log(person1 instanceof Object); // true但我们更希望有person1 instanceof createPerson这样的判断可惜做不到。方法冗余每次调用createPerson都会创建一个新的sayName函数。这意味着person1.sayName和person2.sayName是两个完全不同的函数对象尽管它们的功能一模一样。这在创建成千上万个对象时会造成巨大的内存浪费。注意工厂模式解决了“批量创建”的问题但没有解决“对象标识”和“方法共享”的问题。它更像是基于对象的封装而非真正意义上的面向对象。2.2 构造函数模式赋予对象“身份”为了解决工厂模式的对象类型识别问题JavaScript提供了构造函数模式。构造函数本质上就是一个普通函数但按照约定其首字母大写。调用方式也发生了关键变化必须使用new操作符。function Person(name, age) { // 1. 隐式操作new 关键字会创建一个新对象并将this绑定到这个新对象。 this.name name; // 2. 执行构造函数代码为新对象添加属性 this.age age; this.sayName function() { console.log(this.name); }; // 3. 隐式返回构造函数执行完毕自动返回这个新对象。 } var person1 new Person(Alice, 25); var person2 new Person(Bob, 30); console.log(person1 instanceof Person); // true! 问题一解决 console.log(person1 instanceof Object); // true console.log(person1.constructor Person); // trueconstructor属性指向构造函数new操作符背后的四步魔法创建在内存中创建一个全新的空对象。链接将这个新对象的[[Prototype]]即__proto__内部属性指向构造函数的prototype属性所指向的对象。这是原型链连接的起点。绑定将构造函数内部的this绑定到这个新创建的对象上。执行与返回执行构造函数内部的代码为this添加属性。如果构造函数没有显式返回一个对象则自动返回这个新创建的对象。遗留问题 构造函数模式完美解决了对象类型识别的问题instanceof和constructor都可用但工厂模式中的方法冗余问题依然存在。person1.sayName person2.sayName的结果是false。每个实例仍然拥有自己独立的方法副本。2.3 原型模式共享的蓝图我们终于来到了JavaScript面向对象的核心——原型模式。它的核心思想是让所有对象实例共享它所包含的属性和方法。这个共享的容器就是构造函数的prototype原型属性。function Person() {} // 构造函数现在可以是空函数 // 将属性和方法直接添加到构造函数的原型对象上 Person.prototype.name Default Name; Person.prototype.age 0; Person.prototype.sayName function() { console.log(this.name); }; var person1 new Person(); var person2 new Person(); person1.name Alice; // 在实例上设置同名属性会屏蔽原型上的属性 console.log(person1.name); // Alice来自实例自身 console.log(person2.name); // Default Name来自原型 console.log(person1.sayName person2.sayName); // true! 方法实现了共享原型对象与实例的关系 每个函数在创建时都会自动获得一个prototype属性这个属性是一个指针指向一个对象这个对象就是原型对象。原型对象默认只有一个属性constructor指回构造函数。 当我们用new调用构造函数创建实例时实例内部会包含一个[[Prototype]]指针在浏览器中可通过__proto__访问这个指针指向构造函数的原型对象。属性访问的搜索机制 当访问一个对象的属性如person1.name时会发生以下搜索首先在对象实例本身查找是否有该属性。如果有则返回搜索停止。如果没有则沿着实例的__proto__指针到它的原型对象上查找。如果原型对象上还没有则继续沿着原型对象的__proto__向上查找直到找到属性或到达原型链的末端null。这就是著名的原型链。person1.sayName之所以能被访问到是因为在实例上没找到sayName于是通过person1.__proto__找到了Person.prototype并在那里找到了共享的sayName方法。实操心得在原型上定义方法是非常好的实践它节约了内存。但通常不建议在原型上定义引用类型的属性如数组、对象因为一个实例修改了这个共享的引用类型会影响到所有其他实例。对于需要个性化的属性应该在构造函数内部用this.xxx来定义。3. 组合模式扬长避短的实践单纯的原型模式也有缺点所有实例初始状态都一样因为属性都定义在原型上。我们更常见的需求是实例拥有独立的属性但共享方法。这催生了最广泛使用的组合模式构造函数模式用于定义实例属性原型模式用于定义方法和共享的属性。// 组合构造函数模式和原型模式 function Person(name, age) { // 实例属性每个实例独有一份 this.name name; this.age age; this.friends [Alice, Bob]; // 引用类型属性也放在这里每个实例独立 } // 共享的方法和常量属性定义在原型上 Person.prototype.sayName function() { console.log(this.name); }; Person.prototype.species Human; // 共享的常量属性 var person1 new Person(Jack, 20); var person2 new Person(Lucy, 22); person1.friends.push(Tom); console.log(person1.friends); // [Alice, Bob, Tom] console.log(person2.friends); // [Alice, Bob]互不影响 console.log(person1.sayName person2.sayName); // true共享方法这种模式是目前ECMAScript中使用最广泛、认同度最高的一种创建自定义类型的方法。它完美兼顾了实例的独立性和方法的共享性代码也非常清晰。4. 原型与原型链的深度剖析理解了组合模式我们已经能应对90%的日常开发。但要真正通透必须把“原型链”这个核心机制吃透。4.1 理解prototype、__proto__和constructor这是最容易混淆的三个概念它们的关系可以用一张图和一个等式来概括关系等式实例.__proto__ 其构造函数.prototype具体解释prototype显式原型这是函数独有的属性。它是一个指针指向一个对象这个对象就是调用该构造函数创建的实例的原型对象。只有函数才有prototypeFunction.prototype绑定的函数除外。__proto__隐式原型这是每个对象都有的属性null除外。它指向创建该对象的构造函数的prototype。__proto__是对象查找属性的路径构成了原型链。constructor构造函数这是原型对象即xxx.prototype指向的对象独有的属性。它指回创建当前原型对象的那个构造函数。function Person() {} var p new Person(); // 验证关系 console.log(p.__proto__ Person.prototype); // true console.log(Person.prototype.constructor Person); // true console.log(p.constructor Person); // true (p自身没有constructor沿原型链找到Person.prototype.constructor) // 函数的__proto__指向 Function.prototype console.log(Person.__proto__ Function.prototype); // true // Function.prototype也是一个对象它的__proto__指向 Object.prototype console.log(Function.prototype.__proto__ Object.prototype); // true // Object.prototype是原型链的顶端它的__proto__是null console.log(Object.prototype.__proto__); // null4.2 完整的原型链图示与搜索过程让我们以一个具体的例子描绘出完整的原型链function Person(name) { this.name name; } Person.prototype.say function() {}; var p new Person(Tom); // 当访问 p.toString() 时JavaScript引擎如何查找 // 1. 检查 p 对象自身是否有 toString 属性没有。 // 2. 通过 p.__proto__ 找到 Person.prototype检查是否有 toString没有。 // 3. 通过 Person.prototype.__proto__ 找到 Object.prototype检查是否有 toString有调用它。这条查找路径p - Person.prototype - Object.prototype - null就是p实例的原型链。几乎所有对象最终都会链到Object.prototype因此所有对象都能调用toString、valueOf、hasOwnProperty等方法。instanceof运算符的原理A instanceof B的检查逻辑是沿着A的__proto__链向上查找看能否找到B.prototype。如果能找到返回true如果直到null都没找到返回false。console.log(p instanceof Person); // true (p.__proto__ Person.prototype) console.log(p instanceof Object); // true (p.__proto__.__proto__ Object.prototype) console.log(Person instanceof Function); // true (Person.__proto__ Function.prototype)4.3 原型链继承的实现与缺陷基于原型链我们可以实现继承。子类型的原型对象等于父类型的一个实例。// 父类 function SuperType() { this.property true; this.colors [red, blue]; } SuperType.prototype.getSuperValue function() { return this.property; }; // 子类 function SubType() { this.subproperty false; } // 关键步骤实现继承。让子类的原型指向父类的一个实例。 SubType.prototype new SuperType(); // 原型链继承 // 修复 constructor 指向可选但建议 SubType.prototype.constructor SubType; // 为子类添加新方法必须在继承之后 SubType.prototype.getSubValue function() { return this.subproperty; }; var instance new SubType(); console.log(instance.getSuperValue()); // true成功调用父类方法原型链继承的致命缺陷引用类型属性共享问题由于SubType.prototype是SuperType的一个实例父类构造函数中定义的引用类型属性如colors数组就变成了子类原型上的属性会被所有子类实例共享。var instance1 new SubType(); var instance2 new SubType(); instance1.colors.push(green); console.log(instance2.colors); // [red, blue, green]instance2也被影响了无法向父类构造函数传参在创建子类实例时无法在不影响所有实例的情况下向父类的构造函数传递参数。因为父类实例化new SuperType()发生在定义子类原型时是早于子类实例创建的。正因为这些缺陷单纯的原型链继承在实践中很少直接使用。5. 进阶继承模式与最佳实践为了解决原型链继承的问题开发者们探索出了多种组合方案。其中借用构造函数继承和寄生组合式继承最为重要。5.1 借用构造函数继承经典继承思路在子类构造函数内部调用父类构造函数。这样每个子类实例都会拥有独立的父类属性副本。function SuperType(name) { this.name name; this.colors [red, blue]; } function SubType(name, age) { // 继承属性在子类构造函数中“借用”父类构造函数 SuperType.call(this, name); // 关键第二次调用父类构造函数 this.age age; } var instance1 new SubType(Jack, 20); var instance2 new SubType(Lucy, 22); instance1.colors.push(green); console.log(instance1.colors); // [red, blue, green] console.log(instance2.colors); // [red, blue]问题解决 console.log(instance1.name); // Jack优点解决了原型链继承中“引用类型属性共享”和“无法传参”的问题。可以在子类构造函数中向父类构造函数传递参数。缺点方法都在构造函数中定义无法实现函数复用每个实例都有方法副本内存浪费。父类原型中定义的方法对子类不可见。instance1无法调用SuperType.prototype上的方法。5.2 组合继承最常用继承模式组合继承结合了原型链继承继承方法和借用构造函数继承继承属性成为了JavaScript中最常用的继承模式。function SuperType(name) { this.name name; this.colors [red, blue]; } 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; // 3. 定义子类自己的方法 SubType.prototype.sayAge function() { console.log(this.age); }; var instance1 new SubType(Jack, 20); instance1.colors.push(green); console.log(instance1.colors); // [red, blue, green] instance1.sayName(); // Jack instance1.sayAge(); // 20 var instance2 new SubType(Lucy, 22); console.log(instance2.colors); // [red, blue] instance2.sayName(); // Lucy组合继承的优缺点优点融合了两种模式的优点既保证了实例属性的独立性又实现了方法的共享。是JavaScript中最常用的继承模式。缺点存在一个效率问题。父类构造函数被调用了两次一次在创建子类原型时new SuperType()一次在子类构造函数内部SuperType.call(this)。这导致子类原型上SubType.prototype和子类实例上instance1存在两组相同的属性一组来自原型一组来自实例。实例上的属性会屏蔽原型上的同名属性这虽然不影响功能但造成了不必要的冗余。5.3 寄生组合式继承理想的继承范式寄生组合式继承被普遍认为是引用类型最理想的继承范式。它解决了组合继承调用两次父类构造函数的问题。其核心是不必为了指定子类型的原型而调用父类的构造函数我们所需要的无非就是父类原型的一个副本而已。function inheritPrototype(subType, superType) { // 1. 创建父类原型的一个副本对象。Object.create()方法创建一个新对象使用现有的对象来提供新创建的对象的__proto__。 var prototype Object.create(superType.prototype); // 2. 为创建的副本添加constructor属性弥补因重写原型而失去的默认constructor属性。 prototype.constructor subType; // 3. 将新创建的对象副本赋值给子类型的原型。 subType.prototype prototype; } // 使用示例 function SuperType(name) { this.name name; this.colors [red, blue]; } SuperType.prototype.sayName function() { console.log(this.name); }; function SubType(name, age) { // 只调用一次父类构造函数继承实例属性 SuperType.call(this, name); this.age age; } // 调用封装的函数实现原型继承而不是 new SuperType() inheritPrototype(SubType, SuperType); // 添加子类自己的方法 SubType.prototype.sayAge function() { console.log(this.age); }; var instance new SubType(Tom, 25); instance.sayName(); // Tom instance.sayAge(); // 25为什么这是最理想的高效率只调用了一次SuperType构造函数在子类构造函数中避免了在SubType.prototype上创建不必要的、多余的属性。原型链保持不变instance能正常访问SuperType.prototype上的方法。无属性冗余子类实例的属性都在实例自身上原型链干净清晰。ES5的Object.create()方法规范化了这种模式。因此在ES5及以后的环境中实现继承的首选就是寄生组合式继承。6. ES6 Class语法糖与本质ES6引入的class语法让JavaScript的面向对象编程看起来更像传统语言。但必须清醒认识到class只是一个语法糖它的底层实现依然是我们上面讨论的原型和构造函数。// ES6 Class 写法 class Person { constructor(name, age) { // 对应构造函数 this.name name; this.age age; } sayName() { // 对应原型上的方法 console.log(this.name); } static sayHello() { // 静态方法对应构造函数自身的属性 console.log(Hello); } } // 等同于下面的ES5代码 function Person(name, age) { this.name name; this.age age; } Person.prototype.sayName function() { console.log(this.name); }; Person.sayHello function() { console.log(Hello); };class继承的底层class的extends和super关键字其内部实现就是寄生组合式继承。class SubType extends SuperType { constructor(name, age) { super(name); // 相当于 SuperType.call(this, name) this.age age; } }使用class的注意事项class中的方法都是定义在原型上的且是不可枚举的Object.keys()不会列出它们。class必须使用new调用不能像普通函数一样调用。class不存在变量提升。super在构造函数中必须在使用this之前调用。实操心得在现代开发中应优先使用class语法它更简洁、易读且不易出错。但在调试、理解底层机制或处理一些复杂继承关系时脑子里必须有原型链的图景。Babel等工具将class转译成的ES5代码就是最好的学习材料。7. 常见问题与深度排查技巧在实际开发和面试中关于原型和原型链的坑点很多。这里记录一些典型问题和排查思路。7.1 原型链污染与属性屏蔽问题当通过一个实例修改了原型上的引用类型属性时会影响所有实例。function Person() {} Person.prototype.friends [Alice]; var p1 new Person(); var p2 new Person(); p1.friends.push(Bob); // 通过实例修改了原型上的数组 console.log(p2.friends); // [Alice, Bob]p2也受到了影响排查与解决排查使用hasOwnProperty()方法判断属性是实例自身的还是原型上的。p1.hasOwnProperty(friends)返回false。解决对于需要独立化的引用类型属性务必在构造函数中初始化而不是放在原型上。如果确实需要修改原型数据应通过Person.prototype.friends直接操作并清楚其全局影响。7.2 重写原型对象导致的问题问题在已经创建了实例的情况下完全重写构造函数的prototype对象会导致已存在的实例与新的原型失去联系。function Person() {} var friend new Person(); // friend.__proto__ 指向最初的 Person.prototype // 完全重写原型对象 Person.prototype { constructor: Person, sayName: function() { console.log(Hi); } }; friend.sayName(); // TypeError: friend.sayName is not a function // friend的[[Prototype]]仍然指向旧的原型对象而旧对象上没有sayName方法。排查与解决注意重写prototype会切断现有实例与构造函数之间的联系。如果需要添加新方法应该使用Person.prototype.newMethod ...的形式增量添加而不是直接赋值一个新对象。7.3instanceof与isPrototypeOf的差异两者都用于检查原型链但用法不同instanceof运算符检查构造函数的prototype属性是否出现在对象的原型链上。object instanceof ConstructorisPrototypeOf()方法检查对象是否出现在另一个对象的原型链上。prototypeObj.isPrototypeOf(object)console.log(friend instanceof Person); // false (重写原型后) console.log(Person.prototype.isPrototypeOf(friend)); // false // 创建一个新实例 var newFriend new Person(); console.log(newFriend instanceof Person); // true console.log(Person.prototype.isPrototypeOf(newFriend)); // true7.4 性能考量与最佳实践减少原型链查找深度过长的原型链会影响属性查找的性能。设计时应尽量保持继承层次扁平。使用Object.create(null)创建纯字典对象如果你需要一个完全没有原型如toString,hasOwnProperty等方法的纯键值对集合可以使用Object.create(null)。这比{}更干净且能避免原型上的属性被意外遍历或覆盖。方法放在原型数据放在实例这是黄金法则。可变的状态数据定义在构造函数内共享的行为方法定义在原型上。谨慎使用__proto__虽然浏览器普遍支持__proto__但它不是ES标准的一部分。在需要操作原型时更推荐使用Object.getPrototypeOf()和Object.setPrototypeOf()性能需注意这些标准方法。理解JavaScript的面向对象就是理解它的“原型”本质。从工厂模式的粗糙封装到构造函数的身份赋予再到原型模式的共享蓝图最后通过组合与寄生组合实现优雅的继承这条演进路径揭示了JavaScript灵活而强大的对象机制。class语法让这一切变得更加优雅但它的基石从未改变。当你下次使用new、extends或访问一个对象属性时如果能清晰地看到背后那条连接着Object.prototype乃至null的隐式链条那么你对这门语言的理解就已经超越了大多数使用者。这不仅是应对面试的利器更是编写健壮、高效、可维护JavaScript代码的底层支撑。