行业资讯

深入理解JavaScript原型链与ES5面向对象编程核心原理

发布时间:2026/8/23 10:40:00
深入理解JavaScript原型链与ES5面向对象编程核心原理 1. 为什么今天还要谈ES5的面向对象如果你是一名JavaScript开发者尤其是从ES6时代才开始入行的朋友可能会觉得“ES5面向对象”这个话题有点过时了。毕竟现在写个类直接用class关键字多香啊语法清晰接近其他主流语言谁还愿意去折腾那些prototype和构造函数呢这个想法很自然但也是我今天想写这篇长文的原因——理解ES5的面向对象实现不是让你回去写旧代码而是让你真正看懂JavaScript这门语言的“里子”。我见过不少开发者能用class写得飞起但一旦遇到继承链上的方法调用出错、this指向莫名奇妙改变、或者想实现一些class语法糖覆盖不了的灵活模式时就立刻抓瞎。问题的根源往往在于他们只记住了“形”而不理解“神”。class在ES6中只是一个语法糖它的底层机制依然是ES5那套基于原型的继承模型。Vue 2的源码、许多历史悠久的库比如早期的jQuery插件架构、甚至一些面试中深挖原理的问题都绕不开对原型链的透彻理解。所以这篇文章不是一份怀旧的考古报告而是一张通往JavaScript语言核心的地图。我们会彻底拆解ES5中实现面向对象的三大核心支柱构造函数、原型链、以及继承的几种经典模式。我会用大量的代码示例和类比让你不仅知道怎么写更明白为什么这样写以及在实际开发中可能踩到的坑。当你真正吃透了这些再看ES6的class甚至看TypeScript的装饰器都会有豁然开朗的感觉。你会发现你驾驭的不再是一门语言的API而是其设计哲学。2. 基石构造函数与new运算符的魔法在ES5中我们没有class关键字。创建一种“类型”的蓝图靠的是构造函数。function Person(name, age) { this.name name; this.age age; this.sayHello function() { console.log(Hello, I am this.name); }; }看它就是一个普通的函数。但当我们用new关键字去调用它时魔法就发生了const alice new Person(Alice, 25); const bob new Person(Bob, 30); alice.sayHello(); // 输出Hello, I am Alice bob.sayHello(); // 输出Hello, I am Bobnew运算符到底干了什么这个过程是理解后续所有内容的基础我们可以拆解为四个步骤创建一个全新的空对象。将这个新对象的内部[[Prototype]]也就是__proto__链接到构造函数的prototype对象。这是原型链连接的起点。将构造函数内部的this绑定到这个新创建的对象。执行构造函数内部的代码为这个新对象添加属性。如果构造函数没有显式返回一个对象则自动返回这个新创建的对象。注意很多人会混淆prototype和__proto__。简单来说prototype是函数构造函数的一个属性它是一个对象。而__proto__是每个对象实例都有的一个内部属性ES6规范中称为[[Prototype]]指向创建它的构造函数的prototype对象。即alice.__proto__ Person.prototype。现在让我们审视上面Person构造函数的一个严重性能问题sayHello方法被定义在了构造函数内部。这意味着每次new Person()都会在内存中创建一个全新的函数对象赋值给this.sayHello。创建100个Person实例就会在内存中有100个功能完全相同的sayHello函数这无疑是巨大的浪费。这就是为什么我们需要引入原型Prototype。3. 灵魂原型链与共享属性的艺术为了解决每个实例都重复创建相同方法的问题JavaScript设计了原型继承。每个构造函数都有一个prototype属性指向一个对象。当我们通过new创建实例时实例的__proto__会指向这个prototype对象。核心规则当试图访问一个对象的属性时JavaScript引擎首先会在对象自身查找。如果没找到就会沿着该对象的__proto__链向上查找直到找到该属性或到达原型链的尽头null。让我们优化Personfunction Person(name, age) { // 实例属性各自独立 this.name name; this.age age; } // 方法定义在原型上所有实例共享 Person.prototype.sayHello function() { console.log(Hello, I am this.name); }; const alice new Person(Alice, 25); const bob new Person(Bob, 30); alice.sayHello(); // Hello, I am Alice bob.sayHello(); // Hello, I am Bob console.log(alice.sayHello bob.sayHello); // true! 是同一个函数现在sayHello方法只存在于Person.prototype这一个地方。alice和bob自身都没有sayHello方法但当调用alice.sayHello()时JS引擎在alice上找不到就通过alice.__proto__即Person.prototype找到了它并执行。方法内的this仍然正确指向调用它的对象alice。这就是ES5面向对象的核心魅力数据实例属性私有行为方法共享。我们可以画一个简单的示意图来加深理解alice { name: Alice, age: 25 } __proto__ -- Person.prototype { sayHello: function() { ... }, constructor: Person } __proto__ -- Object.prototype __proto__ -- null bob { name: Bob, age: 30 } __proto__ -- (同一个) Person.prototype实操心得在调试复杂原型链问题时console.log配合Object.getPrototypeOf()标准方法优于__proto__和hasOwnProperty()是你的利器。obj.hasOwnProperty(key)可以精确判断属性是直接在obj上还是从原型链继承来的。4. 实现继承的经典模式从“简单粗暴”到“趋于完美”理解了原型链我们就可以尝试让一个类型继承另一个类型。假设我们有一个Student类型要继承Person。4.1 模式一原型链继承直接继承这是最直观的想法让子类的原型等于父类的一个实例。function Student(grade) { this.grade grade; } // 关键步骤原型链继承 Student.prototype new Person(); // 不传参数创建一个“空”的Person实例作为原型 Student.prototype.study function() { console.log(this.name is studying in grade this.grade); }; const stu new Student(6); stu.study(); // 期望输出undefined is studying in grade 6问题立刻暴露了无法向父类构造函数传参我们在创建Student.prototype时调用了new Person()此时根本无法确定未来Student实例的name和age。导致所有Student实例共享的“父类实例”的属性name,age都是undefined。引用类型属性的共享灾难如果父类实例属性中有数组或对象它们将成为子类原型上的属性被所有子类实例共享修改一个会影响所有。这通常不是我们想要的。function Parent() { this.colors [red, blue]; } function Child() {} Child.prototype new Parent(); const c1 new Child(); const c2 new Child(); c1.colors.push(green); console.log(c2.colors); // [red, blue, green]c2被影响了结论单纯的“原型链继承”基本不具备实用性但它揭示了继承的本质——原型链的链接。4.2 模式二构造函数继承经典继承为了解决属性共享和传参问题我们可以在子类构造函数内部“借用”父类构造函数。function Student(name, age, grade) { // 在子类实例的上下文中调用父类构造函数相当于“复制”了父类的实例属性到子类实例上 Person.call(this, name, age); this.grade grade; } const stu new Student(Alice, 25, 6); console.log(stu.name); // Alice 成功 console.log(stu.age); // 25 成功Person.call(this, ...)这一行是精髓。它在新创建的Student实例this的上下文中执行Person函数从而将name和age属性直接添加到stu对象上。这解决了实例属性私有化和传参的问题。但是新的问题来了stu.sayHello(); // TypeError: stu.sayHello is not a function因为Student.prototype目前是一个空对象Object.prototype上面根本没有sayHello方法。我们只继承了父类的实例属性但没有继承父类原型上的方法。4.3 模式三组合继承伪经典继承既然“原型链继承”能拿到方法“构造函数继承”能拿到独立的实例属性那我们把两者结合起来不就行了这就是组合继承也是ES5时代最常用的继承模式。function Student(name, age, grade) { // 1. 构造函数继承继承实例属性 Person.call(this, name, age); this.grade grade; } // 2. 原型链继承继承原型方法 Student.prototype new Person(); // 第一次调用父类构造函数 // 修正constructor指向 Student.prototype.constructor Student; // 3. 添加子类特有方法 Student.prototype.study function() { console.log(this.name is studying in grade this.grade); }; const stu new Student(Bob, 12, 6); // 第二次调用父类构造函数 stu.sayHello(); // Hello, I am Bob (方法继承成功) stu.study(); // Bob is studying in grade 6 console.log(stu instanceof Student); // true console.log(stu instanceof Person); // true组合继承融合了两种模式的优点既保证了实例属性的独立性又实现了原型方法的共享。它看起来几乎完美。然而它有一个细微的瑕疵父类构造函数被调用了两次。第一次在Student.prototype new Person();这次调用是为了建立原型链它会在Student.prototype上创建一组name和age属性虽然值是undefined。第二次在new Student(...)时内部的Person.call(this, ...)这次调用是在子类实例上创建真正的name和age属性。这就导致子类实例stu上有一组name和age同时它的原型Student.prototype上也有一组多余的name和age属性来自第一次调用。虽然实例自身的属性会屏蔽原型上的同名属性访问stu.name没问题但这确实造成了原型对象上不必要的属性残留。4.4 模式四寄生组合式继承终极方案为了解决组合继承中父类构造函数被调用两次的问题社区推出了公认的“终极”ES5继承方案——寄生组合式继承。这也是许多库和ES6classextends在底层采用的原理。它的核心思想是我们不需要为了继承方法而调用父类构造函数生成一个实例作为原型我们只需要一个干净的对象这个对象的原型指向父类的原型即可。function inheritPrototype(subType, superType) { // 关键步骤1创建一个以父类原型为原型的新对象 // Object.create() 是ES5方法它创建一个新对象并将其[[Prototype]]指向传入的参数 const prototype Object.create(superType.prototype); // 关键步骤2修正新对象的constructor属性 prototype.constructor subType; // 关键步骤3将新对象赋值给子类的原型 subType.prototype prototype; } function Student(name, age, grade) { // 构造函数继承仅此一次调用父类构造函数 Person.call(this, name, age); this.grade grade; } // 使用寄生组合式继承替换原来的 Student.prototype new Person() inheritPrototype(Student, Person); // 添加子类方法 Student.prototype.study function() { console.log(this.name is studying in grade this.grade); }; const stu new Student(Charlie, 14, 8); stu.sayHello(); // Hello, I am Charlie stu.study(); // Charlie is studying in grade 8让我们拆解inheritPrototype函数Object.create(superType.prototype)创建了一个空对象prototype并且prototype.__proto__ superType.prototype。这个对象拥有了通往父类原型链的通道但它本身没有父类构造函数的实例属性没有那些name,age。将这个干净对象的constructor指回子类subType。用这个干净对象替换子类原来的原型。这样我们只调用了一次父类构造函数在子类构造函数里就同时实现了实例属性的继承和原型方法的继承并且原型链干净、高效。这是最理想的ES5继承实现。5. 静态属性/方法与instanceof的机制面向对象中除了实例属性和方法还有属于类本身的静态属性和方法。在ES5中这非常简单直接将其添加到构造函数上即可。function Person(name) { this.name name; } Person.prototype.sayHello function() {}; // 静态属性与方法 Person.species Homo sapiens; Person.createAnonymous function() { return new Person(Anonymous); }; const p1 new Person(Alice); console.log(p1.species); // undefined实例访问不到静态成员 console.log(Person.species); // Homo sapiens const anon Person.createAnonymous(); // 通过静态工厂方法创建实例instanceof运算符的原理也基于原型链。obj instanceof Constructor的检查逻辑是沿着obj的__proto__链向上查找如果找到了Constructor.prototype则返回true否则返回false。console.log(stu instanceof Student); // true, stu.__proto__ Student.prototype console.log(stu instanceof Person); // true, stu.__proto__.__proto__ Person.prototype console.log(stu instanceof Object); // true 原型链顶端理解这一点你就能明白为什么修改了Student.prototype的指向后需要手动修正constructor属性——虽然不影响instanceof的判断它只认prototype对象但保持constructor的正确性是一种良好的约定让实例能知道自己是哪个构造函数创建的。6. 从ES5到ES6class语法糖的“翻译”当我们掌握了ES5面向对象的所有细节再回头看ES6的class一切就都透明了。class本质上就是一个语法糖它的绝大部分功能都可以用ES5的代码等价描述。// ES6 Class class Person { constructor(name, age) { this.name name; this.age age; } sayHello() { console.log(Hello, I am ${this.name}); } static species Homo sapiens; } class Student extends Person { constructor(name, age, grade) { super(name, age); this.grade grade; } study() { console.log(${this.name} is studying.); } }这段ES6代码几乎完全等价于我们之前用寄生组合式继承写出的ES5代码。constructor就是构造函数类内部定义的方法会自动添加到原型上static关键字定义的成员就是构造函数的属性extends和super则完美对应了寄生组合式继承和构造函数继承的调用。那么为什么还要学ES5这套“复杂”的东西呢调试与理解底层错误当你的class代码报出关于this、原型或super的晦涩错误时如果你理解底层原理就能快速定位。阅读遗留代码与经典库大量的现存项目、库的源码是基于ES5的。实现更灵活的模式class语法有一定限制。如果你想实现混入Mixin、动态修改原型链等高级模式直接操作原型和构造函数会更加灵活和强大。面试与深度考察这是区分初级和中级以上JS工程师的经典话题。7. 实战中的坑与高级技巧最后分享几个我在实际项目中踩过的坑和总结的技巧。坑1在原型方法中谨慎使用箭头函数function Person(name) { this.name name; } Person.prototype.sayHello () { console.log(Hello, I am this.name); // 这里的this指向定义时的上下文通常是window/global而不是实例 }; const p new Person(Alice); p.sayHello(); // 输出Hello, I am undefined (或全局的name)原型方法需要动态this务必使用普通函数。箭头函数会绑定词法作用域的this。技巧1安全的“类”属性初始化对于需要在所有实例间共享的配置或常量可以放在原型上。但如果它是一个引用类型如数组、对象务必注意共享问题。一个模式是将其在构造函数中初始化但引用原型上的默认值。function Widget(config) { // 合并实例配置与原型默认配置避免直接修改原型上的共享对象 this.settings Object.assign({}, Widget.defaultSettings, config); } Widget.defaultSettings { color: blue, size: medium };技巧2实现简单的Mixin混入ES5的原型机制可以很方便地实现混入给一个类批量添加功能。const CanEatMixin { eat(food) { console.log(${this.name} is eating ${food}.); this.energy 10; } }; const CanSleepMixin { sleep() { console.log(${this.name} is sleeping.); this.energy 50; } }; function Animal(name) { this.name name; this.energy 100; } // 混入 Object.assign(Animal.prototype, CanEatMixin, CanSleepMixin); const dog new Animal(Dog); dog.eat(bone); dog.sleep();坑2直接修改内置对象的原型是危险的Array.prototype.myCustomMethod ...这种做法被称为“猴子补丁”虽然强大但极易引发难以追踪的命名冲突和兼容性问题在现代开发中应极力避免。如果确实需要扩展功能考虑使用函数包装或组合的方式。吃透ES5的面向对象就像是掌握了JavaScript的“内功”。它可能不会直接体现在你每天的class语法中但它会让你在遇到任何与对象、继承、this相关的复杂问题时心里有底手中有术。这份理解是成为高级JavaScript开发者的必经之路。