公司动态
深入理解JavaScript原型链与ES5面向对象编程核心机制
1. 项目概述为什么今天还要深挖ES5的面向对象如果你是一名JavaScript开发者尤其是从ES6时代才开始接触这门语言的可能会觉得“ES5的面向对象”这个话题有点过时了。毕竟现在谁还用function关键字来写类用prototype来挂方法呢class语法糖它不香吗直接写extends继承它不优雅吗这正是我想和你聊这个话题的原因。我见过太多开发者对class、extends、super这些语法如数家珍但一旦被问到“JavaScript的继承到底是怎么实现的”、“new操作符背后做了什么”、“instanceof的原理是什么”就立刻语塞。知其然而不知其所以然这就像盖房子只关心装修风格却不了解地基和承重结构。当你的应用遇到一些诡异的、与原型链相关的Bug时或者当你需要阅读、维护一些遗留的老代码库时这种“地基”知识的缺失会让你寸步难行。ES5的面向对象实现正是JavaScript这门语言面向对象特性的“地基”。它没有语法糖的包装直接暴露了最核心的机制构造函数、原型对象、原型链。理解这套机制不仅能让你彻底搞懂ES6的class到底在帮你做什么更能让你在遇到复杂对象关系、性能优化、甚至是框架源码阅读时拥有降维打击的能力。这不是在学“过时”的技术而是在修炼内功。今天我们就抛开class的糖衣回到那个“质朴”的ES5时代亲手用最原始的工具搭建起JavaScript面向对象的大厦。你会发现那些看似复杂的魔法其底层逻辑清晰而有力。2. 核心基石构造函数与new操作符的魔法在ES5中我们没有class关键字。创建一种“类型”的蓝图靠的是一个普通的函数我们称之为构造函数。2.1 构造函数的本质与约定构造函数本质上就是一个函数。但为了区分它和普通函数我们有一个不成文的约定构造函数的函数名首字母大写。比如Person、Animal、Car。这个约定非常重要它提醒你和其他开发者“嘿这个函数需要用new来调用它是用来创建对象的模具。”// 这是一个构造函数 function Person(name, age) { // 函数体内部this指向即将被创建的新对象 this.name name; this.age age; this.sayHello function() { console.log(Hello, I am this.name); }; }看到这里你可能会想这不就是在函数里给this添加属性吗没错但关键就在于this的指向而改变this指向的魔法就来自于new操作符。2.2new操作符的四步拆解当你写下var person1 new Person(Alice, 25);时JavaScript引擎在背后默默地为你做了四件事创建一个全新的空对象在内存中开辟一块新空间创建一个纯净的{}。将这个新对象的原型[[Prototype]]链接到构造函数的prototype属性所指向的对象。这是实现继承和共享方法的关键一步我们稍后会深入。此时新对象就可以访问构造函数原型上的属性和方法了。将构造函数内部的this绑定到这个新创建的对象上。于是在构造函数内部所有this.xxx yyy的操作都是在为这个新对象添加属性和方法。执行构造函数内部的代码为这个新对象添加属性。如果构造函数没有显式返回一个对象则自动返回这个新创建的对象。如果构造函数返回了一个非原始值对象、数组、函数等则new表达式的结果就是这个返回的值。我们可以用一段代码来模拟new操作符的行为这能让你看得更清楚function myNew(constructorFn, ...args) { // 1. 创建一个新对象并将其原型指向构造函数的prototype const obj Object.create(constructorFn.prototype); // 2. 执行构造函数并将this绑定到新对象上 const result constructorFn.apply(obj, args); // 3. 判断构造函数执行后返回的是否是一个对象是则返回该对象否则返回新创建的对象 return result instanceof Object ? result : obj; } // 使用我们自己的myNew var person2 myNew(Person, Bob, 30); console.log(person2.name); // Bob console.log(person2 instanceof Person); // true注意这里有一个非常常见的误区。在我们上面的Person构造函数中我将sayHello方法定义在了构造函数内部this.sayHello function() {...}。这意味着每new一个Person实例都会在内存中创建一个全新的sayHello函数。如果创建1000个Person对象就会有1000个功能完全相同的sayHello函数这无疑是巨大的内存浪费。这引出了我们下一个核心概念——原型。3. 灵魂所在原型对象与原型链机制为了解决上述方法重复创建的内存浪费问题JavaScript设计了原型Prototype。3.1 理解prototype和__proto__这是两个最容易混淆的概念务必分清prototype属性这是函数特别是构造函数独有的属性。它是一个对象我们称之为原型对象。这个对象的用途就是存放该构造函数创建的所有实例可以共享的属性和方法。__proto__属性这是每个对象包括函数对象都有的一个内部属性在ES5标准中更准确的描述是[[Prototype]]__proto__是多数浏览器提供的访问它的非标准方式现已标准化为Object.getPrototypeOf。它指向创建该对象的构造函数的prototype对象。它们的关系是实例对象的__proto__ 其构造函数的prototype。让我们改造之前的Person将方法放到原型上function Person(name, age) { // 实例属性每个对象独有一份 this.name name; this.age age; } // 将方法定义在构造函数的prototype对象上 Person.prototype.sayHello function() { console.log(Hello, I am this.name); }; Person.prototype.species Homo Sapiens; // 甚至可以定义共享的属性 var p1 new Person(Alice, 25); var p2 new Person(Bob, 30); console.log(p1.sayHello p2.sayHello); // true! 两个实例共享同一个方法 console.log(p1.__proto__ Person.prototype); // true console.log(Object.getPrototypeOf(p1) Person.prototype); // true (标准方法)现在无论创建多少个Person实例sayHello方法在内存中都只有一份存放在Person.prototype这个对象里。每个实例通过其__proto__链找到并调用它。这就是原型继承的核心优势共享方法节省内存。3.2 原型链属性查找的“向上委托”机制当你访问一个对象的属性比如p1.sayHello时JavaScript引擎会执行以下步骤首先在对象自身属性中查找即p1对象上直接定义的nameage。如果没找到就通过__proto__去它的原型对象即Person.prototype上查找。如果在Person.prototype上还没找到就继续通过Person.prototype.__proto__向上查找。这条由__proto__串联起来的链就叫做原型链。这个查找过程会一直持续到Object.prototype所有对象的顶层原型如果还没找到则返回undefined。console.log(p1.toString); // ƒ toString() { [native code] } // p1自身没有toStringPerson.prototype上也没有。 // 查找顺序p1 - p1.__proto__(Person.prototype) - Person.prototype.__proto__(Object.prototype) - 找到原型链是实现继承的基石。它描述了一种“委托”机制对象自己没有的能力可以委托给原型链上游的对象去完成。3.3 静态方法、实例方法与原型方法理解了原型我们就能清晰地区分这三种方法实例方法/属性定义在构造函数内部this上的每个实例独有一份。如this.name。原型方法/属性定义在Constructor.prototype上的所有实例共享一份。如Person.prototype.sayHello。静态方法/属性直接定义在构造函数本身上的只能通过构造函数访问实例不能访问。常用于工具函数或与实例无关的操作。function MathUtils() {} // 静态方法 MathUtils.add function(a, b) { return a b; }; // 原型方法假设我们要创建数学对象实例虽然这里不常见 MathUtils.prototype.square function(x) { return x * x; }; console.log(MathUtils.add(1, 2)); // 3 通过构造函数调用 // var util new MathUtils(); util.add(1,2) // 报错实例无法访问静态方法 console.log(new MathUtils().square(5)); // 25 实例可以访问原型方法4. 实现继承ES5时代的三种经典模式在ES5中实现继承需要我们手动操作原型链。这里有三种主流模式各有优劣。4.1 原型链继承最直接但也问题最多核心思想让子类的原型对象等于父类的一个实例。function Parent() { this.name parent; this.colors [red, blue]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child() {} // 关键步骤原型链继承 Child.prototype new Parent(); var c1 new Child(); c1.sayName(); // parent 继承成功 console.log(c1.colors); // [red, blue] var c2 new Child(); c1.colors.push(green); console.log(c2.colors); // [red, blue, green] 问题1引用类型的属性被所有实例共享 c1.name child1; console.log(c1.name); // child1 console.log(c2.name); // parent 基本类型没问题但这是直接在c1上添加了自身属性并非修改原型链 // 问题2无法向父类构造函数传参优点实现简单能继承父类实例和原型上的属性和方法。致命缺点所有子类实例共享父类实例的引用类型属性如数组、对象一个修改全部受影响。创建子类实例时无法向父类构造函数传递参数。4.2 构造函数继承经典继承核心思想在子类构造函数内部调用父类构造函数通过call或apply改变this指向。function Parent(name) { this.name name; this.colors [red, blue]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { // 关键步骤构造函数继承 Parent.call(this, name); // 相当于 this.Parent(name) this.age age; } var c1 new Child(Alice, 25); var c2 new Child(Bob, 30); c1.colors.push(green); console.log(c1.colors); // [red, blue, green] console.log(c2.colors); // [red, blue] 问题解决引用类型属性独立了 console.log(c1.name, c2.name); // Alice, Bob 可以传参了 c1.sayName(); // TypeError: c1.sayName is not a function优点解决了原型链继承中引用类型属性共享的问题。可以在子类构造函数中向父类构造函数传参。致命缺点无法继承父类原型上的方法sayName访问不到。方法都定义在构造函数里又回到了每个实例独有一份方法的内存浪费老路。4.3 组合继承伪经典继承—— ES5最常用的继承模式核心思想结合原型链继承和构造函数继承取其精华去其糟粕。使用构造函数继承来继承父类的实例属性解决引用共享和传参问题。使用原型链继承来继承父类原型上的方法实现方法共享。function Parent(name) { this.name name; this.colors [red, blue]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { // 1. 构造函数继承继承实例属性 Parent.call(this, name); // 第二次调用Parent this.age age; } // 2. 原型链继承继承原型方法 Child.prototype new Parent(); // 第一次调用Parent // 修正constructor指向否则Child.prototype.constructor会指向Parent Child.prototype.constructor Child; // 可以在子类原型上添加自己的方法 Child.prototype.sayAge function() { console.log(this.age); }; var c1 new Child(Alice, 25); var c2 new Child(Bob, 30); c1.colors.push(green); console.log(c1.colors); // [red, blue, green] console.log(c2.colors); // [red, blue] // 实例属性独立 c1.sayName(); // Alice // 可以调用父类原型方法 c1.sayAge(); // 25 // 可以调用子类原型方法 console.log(c1 instanceof Child); // true console.log(c1 instanceof Parent); // true优点融合了两种模式的优点是ES5中最常用的继承范式。既能保证实例属性的独立性又能实现原型方法的共享同时还能通过instanceof识别对象关系。缺点存在一个小小的效率问题父类构造函数被调用了两次Parent.call一次new Parent()一次。这导致子类原型Child.prototype上会有一份多余的、来自父类的实例属性虽然被子类实例自身的属性覆盖但确实存在。4.4 寄生组合式继承——公认的ES5继承最佳实践为了解决组合继承中父类构造函数被调用两次的瑕疵出现了寄生组合式继承。它被普遍认为是ES5中最理想的继承方式。 核心思想不通过new Parent()来创建子类原型而是直接获得父类原型的一个副本。function inheritPrototype(child, parent) { // 关键步骤1创建一个以父类原型为原型的新对象等同于Object.create(parent.prototype) var prototype Object.create(parent.prototype); // 关键步骤2增强对象修正constructor指向 prototype.constructor child; // 关键步骤3将新对象赋值给子类的原型 child.prototype prototype; } function Parent(name) { this.name name; this.colors [red, blue]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { // 构造函数继承只调用一次Parent Parent.call(this, name); this.age age; } // 使用寄生组合式继承替换原来的 Child.prototype new Parent() inheritPrototype(Child, Parent); // 添加子类原型方法 Child.prototype.sayAge function() { console.log(this.age); }; var c1 new Child(Alice, 25); c1.sayName(); // Alice c1.sayAge(); // 25 console.log(c1 instanceof Child); // true console.log(c1 instanceof Parent); // true优点只调用了一次父类构造函数Parent.call效率更高。原型链保持干净正确。能够正常使用instanceof和isPrototypeOf。这几乎是完美的ES5继承方案也是后来ES6的classextends语法在底层所采用的实现原理或类似原理。5. 高级特性与核心API解析掌握了继承我们再来看看ES5中几个与面向对象密切相关的核心API和特性。5.1instanceof操作符的工作原理我们经常用obj instanceof Constructor来判断对象是否属于某个构造函数的实例。它的原理就是检查构造函数的prototype属性是否出现在对象的原型链上。console.log(c1 instanceof Child); // true // 引擎检查Child.prototype 是否在 c1.__proto__ 这条链上是的。 console.log(c1 instanceof Parent); // true // 引擎检查Parent.prototype 是否在 c1.__proto__ 这条链上是的。 console.log(c1 instanceof Object); // true // 所有对象的原型链顶端都是Object.prototype我们可以自己实现一个简单的myInstanceoffunction myInstanceof(obj, constructorFn) { let proto Object.getPrototypeOf(obj); // 获取对象的原型 const prototype constructorFn.prototype; // 获取构造函数的原型对象 while (proto ! null) { if (proto prototype) { return true; } proto Object.getPrototypeOf(proto); // 沿着原型链向上查找 } return false; }5.2Object.create()以指定原型创建对象这是ES5中一个极其重要的方法。它创建一个新对象并使用现有的对象来作为新创建对象的原型。var parentProto { sayHello: function() { console.log(Hello from parent); } }; var child Object.create(parentProto); child.name Child; child.sayHello(); // Hello from parent console.log(Object.getPrototypeOf(child) parentProto); // trueObject.create(null)会创建一个没有任何原型[[Prototype]]为null的“纯净”对象常用作字典Map的替代以避免原型链属性的干扰。5.3constructor属性及其意义每个原型对象prototype都有一个constructor属性默认指向该原型关联的构造函数。function Person() {} console.log(Person.prototype.constructor Person); // true var p new Person(); console.log(p.constructor Person); // true (通过原型链找到的)当我们重写原型对象时如Child.prototype new Parent()会破坏这个默认的指向所以好的实践是手动修正它Child.prototype.constructor Child;。这并非必需但保持了对象关系的清晰性。5.4 属性描述符与对象控制ES5引入了Object.defineProperty()允许我们精确地定义或修改对象的属性。这不仅是Vue 2响应式系统的基石也是实现高级对象行为控制的关键。var obj {}; Object.defineProperty(obj, key, { value: static, writable: false, // 不可写 enumerable: false, // 不可枚举for...in, Object.keys看不到 configurable: false // 不可配置不能删除不能再次修改描述符 }); obj.key new; // 静默失败严格模式下报错 console.log(obj.key); // static通过get和set描述符我们可以创建访问器属性实现属性的计算和拦截。6. 常见问题、陷阱与最佳实践实录在实际开发中仅仅知道原理是不够的踩过坑才知道哪里路不平。下面是我总结的一些典型问题和实践建议。6.1 原型链操作中的经典陷阱陷阱一在重写原型后再为其添加方法function Person() {} var p1 new Person(); // 此时p1.__proto__指向旧的Person.prototype // 完全重写原型对象 Person.prototype { sayHello: function() { console.log(hello); } }; var p2 new Person(); // p2.__proto__指向新的原型对象 p2.sayHello(); // hello 正常 p1.sayHello(); // TypeError! p1的原型链还连着那个空的老原型对象。实操心得如果要扩展原型使用Person.prototype.sayHello ...。如果要彻底重写原型务必在创建任何实例之前完成或者清楚知道这会切断已创建实例与原型的联系。陷阱二循环引用导致的原型链死循环几乎不可能但需理解理论上如果A.prototype new B()且B.prototype new A()就会形成循环原型链。JavaScript引擎会抛出错误。实践中应避免这种设计。6.2 性能考量与内存优化将方法放在原型上这是铁律。除非方法必须访问构造函数内的私有变量这时可以考虑用闭包否则一律定义在prototype上。谨慎使用for...in循环for...in会遍历对象自身及其原型链上所有可枚举的属性。如果只想遍历自身属性一定要用hasOwnProperty进行过滤。for (var key in obj) { if (obj.hasOwnProperty(key)) { // 只处理对象自身的属性 } }缓存原型方法在需要频繁调用原型方法的循环内部可以先将方法引用缓存到局部变量避免每次都要查找原型链。var sayHello person.sayHello; // 假设sayHello在原型上 for (var i 0; i 10000; i) { sayHello.call(person); // 比 person.sayHello() 查找次数少一次 }6.3 如何安全地扩展内置对象原型扩展Object.prototype、Array.prototype等内置对象的原型是非常危险的行为因为它会影响所有该类型的对象可能会与未来JavaScript标准或其他库产生冲突。最佳实践是尽量避免。如果非要扩展务必使用Object.defineProperty并设置enumerable: false避免在for...in中被遍历到。方法名要非常独特比如加上库的前缀_myLib_。先检查该方法是否已存在。if (!Array.prototype._myLib_secureForEach) { Object.defineProperty(Array.prototype, _myLib_secureForEach, { value: function(callback) { /* ... */ }, writable: true, configurable: true, enumerable: false // 关键不可枚举 }); }6.4 面向对象设计建议优先使用组合而非继承这是现代软件工程的重要原则。如果“有一个”的关系能解决问题就不要用“是一个”的关系继承。继承会带来紧耦合。可以通过在类内部持有其他类的实例组合来实现功能复用这更灵活。构造函数用于定义差异化的实例属性原型用于定义共享的行为和方法。职责分离结构清晰。对于复杂的继承关系寄生组合式继承是你的首选。它概念清晰没有副作用是通往ES6class理解的最佳桥梁。7. 从ES5到ES6class语法糖的真相最后我们站在ES5的肩膀上看一眼ES6的class。你会恍然大悟原来它只是语法糖。// ES6 Class class Person { constructor(name) { this.name name; } sayHello() { console.log(Hello, ${this.name}); } static staticMethod() { console.log(I am static); } } // 上述代码几乎等价于下面的ES5代码 function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(Hello, this.name); }; Person.staticMethod function() { console.log(I am static); }; // 继承 class Student extends Person { constructor(name, grade) { super(name); // 相当于 Parent.call(this, name) this.grade grade; } } // 其底层实现本质上就是寄生组合式继承class并没有引入新的面向对象模型它只是让基于原型的语法更加清晰、更像传统面向对象语言。constructor就是构造函数类内部定义的方法就是原型方法static关键字定义的就是静态方法extends和super则封装了原型链搭建和父类构造函数调用的繁琐细节。所以当你深入理解了ES5的构造函数、原型和原型链之后再看ES6的class一切都会变得通透。你不会再对super的调用顺序感到迷惑也不会对class中的方法为什么不能枚举感到奇怪因为class中定义的方法默认就是不可枚举的这对应着Object.defineProperty的enumerable: false。回过头来看ES5的面向对象实现或许显得“粗糙”但正是这份粗糙让你能亲手触摸到JavaScript对象系统的每一块基石。理解了这些你不仅能写出更健壮的ES5兼容代码更能以俯视的角度从容地运用ES6及以后的所有新特性。这才是学习“旧”技术的真正价值——它让你在快速变化的技术浪潮中始终拥有那份深刻的理解力和定力。