
1. 项目概述从“能用”到“会写”的JavaScript面向对象进阶如果你已经对JavaScript中的对象、构造函数和new关键字有了初步了解能写出一个简单的“类”那么恭喜你你已经跨过了面向对象编程OOP的第一道门槛。但随之而来的往往是更深的困惑为什么我的方法在每个实例里都存了一份感觉有点浪费内存prototype到底是个啥为什么到处都能看到它别人代码里的Object.create、call、apply又是干什么用的这个“面向对象-2”要探讨的正是这些在第一课之后必然浮现的“深水区”问题。它关乎的不再是语法而是理解JavaScript这门语言实现面向对象的独特机制——原型继承。掌握它你才能从“模仿着写”进化到“理解着写”从而设计出更高效、更优雅的代码结构真正驾驭诸如模块封装、插件开发、框架设计等中级前端任务。2. 核心机制深度解析彻底搞懂原型链要真正用好JavaScript的面向对象绝不能绕过“原型”这个概念。它不是一种可选的编程模式而是语言构建对象体系的基石。2.1 构造函数、原型对象与实例的三位一体当我们写下function Person(name) { this.name name; }时事情远不止创建了一个函数那么简单。这个Person函数作为一个构造函数自动获得了一个名为prototype的属性。这个prototype是一个对象我们称之为原型对象。关键点来了当我们通过new Person(‘Alice’)创建一个实例alice时这个实例内部会有一个隐藏的链接[[Prototype]]在大多数浏览器中可通过__proto__属性访问这个链接指向了构造函数的prototype对象。这就构成了一个三角关系构造函数Person 负责初始化实例的独有属性如name。原型对象Person.prototype 是所有通过Person创建的实例共享的一个对象。实例alice 拥有自己的属性name并通过[[Prototype]]链接到共享的原型对象。function Person(name) { // 构造函数初始化实例自身属性 this.name name; } // 在原型对象上添加方法实现共享 Person.prototype.sayHello function() { console.log(Hello, Im ${this.name}); }; const alice new Person(Alice); const bob new Person(Bob); alice.sayHello(); // Hello, Im Alice bob.sayHello(); // Hello, Im Bob // 验证共享关系 console.log(alice.sayHello bob.sayHello); // true是同一个函数 console.log(Object.getPrototypeOf(alice) Person.prototype); // true注意__proto__是一个非标准但被广泛实现的属性用于访问对象的原型。在正式代码中建议使用Object.getPrototypeOf(obj)来获取使用Object.setPrototypeOf(obj, prototype)来设置性能敏感处慎用。2.2 原型链与属性查找机制原型链是JavaScript实现继承的核心。当一个对象访问某个属性或方法时例如alice.sayHello引擎会执行以下查找首先在对象自身属性中查找即alice自己有没有sayHello属性。如果没找到则沿着它的[[Prototype]]链接向上查找即到Person.prototype对象中查找。如果Person.prototype中还没有就继续沿着Person.prototype的[[Prototype]]向上找通常是Object.prototype。直到找到该属性或到达链条尽头null为止。如果最终没找到则返回undefined。这条由[[Prototype]]链接串起来的路径就是原型链。它就像一条“委托链”对象把自身没有的能力“委托”给原型链上层的对象去实现。// 继续上面的例子 console.log(alice.hasOwnProperty(name)); // truename是自身属性 console.log(alice.hasOwnProperty(sayHello)); // falsesayHello来自原型 // 原型链演示alice - Person.prototype - Object.prototype - null alice.toString(); // 来自 Object.prototype2.3 几种核心原型操作方法的辨析理解了原理我们再来看看操作原型的几种方法它们常常让人混淆。1.Object.create(proto) 以指定对象为原型创建一个新对象。这是最直接、最纯净的创建具有特定原型对象的方式。const personPrototype { sayHello() { console.log(Hello, Im ${this.name}); } }; const alice Object.create(personPrototype); alice.name Alice; // 自身属性后续添加 alice.sayHello(); // Hello, Im Alice console.log(Object.getPrototypeOf(alice) personPrototype); // true实操心得Object.create(null)可以创建一个完全没有原型即[[Prototype]]为null的“纯净字典”对象非常适合用来做键值对映射因为它不会有从Object.prototype继承来的toString、hasOwnProperty等方法可以避免属性名冲突。2.Constructor.prototype 构造函数的原型属性。这是定义“类”共享方法的推荐位置。通过new创建实例时实例的[[Prototype]]会自动指向这个对象。3.Object.setPrototypeOf(obj, proto) 设置一个现有对象的原型。它可以动态改变对象的原型链但性能开销极大因为会破坏引擎的优化。除非万不得已否则应避免在性能关键的代码中使用。// 不推荐在频繁执行的代码中使用 const obj { a: 1 }; const newProto { b: 2 }; Object.setPrototypeOf(obj, newProto); console.log(obj.b); // 24.obj instanceof Constructor 检测对象是否在构造函数的原型链上。它的原理是检查Constructor.prototype是否存在于obj的原型链上。console.log(alice instanceof Person); // true console.log(alice instanceof Object); // true因为Object.prototype在原型链上注意事项instanceof并非百分百可靠。如果Constructor.prototype被重写或者使用Object.setPrototypeOf修改了原型链判断结果可能会出乎意料。在框架或库的开发中有时需要更精确的类型判断。3. 实现继承的演进从组合继承到ES6 Class理解了原型链我们就可以探讨如何在JavaScript中实现继承了。这是一个不断演进、优化的过程。3.1 经典模式组合继承最常用组合继承结合了构造函数继承继承实例属性和原型链继承继承原型方法是ES6之前最主流的继承模式。// 父类 function Animal(name) { this.name name; this.colors [black, white]; } Animal.prototype.sayName function() { console.log(My name is ${this.name}); }; // 子类 function Dog(name, age) { // 1. 构造函数继承调用父类构造初始化父类实例属性到子类实例 Animal.call(this, name); // 关键第二次调用父类构造函数 this.age age; } // 2. 原型链继承建立子类原型与父类原型的联系 Dog.prototype new Animal(); // 关键第一次调用父类构造函数 // 修正constructor指针指向子类自身 Dog.prototype.constructor Dog; // 子类新增方法 Dog.prototype.bark function() { console.log(Woof!); }; const dog1 new Dog(Buddy, 2); dog1.colors.push(brown); const dog2 new Dog(Lucy, 3); console.log(dog1.name); // Buddy (自身属性) dog1.sayName(); // My name is Buddy (通过原型链继承的方法) console.log(dog1.colors); // [black, white, brown] console.log(dog2.colors); // [black, white] (引用类型属性独立解决了问题)优点实例属性独立原型方法共享引用类型属性互不干扰。缺点调用了两次父类构造函数new Animal()和Animal.call导致子类原型Dog.prototype上多了一套不必要的父类实例属性name,colors虽然通过实例属性屏蔽了但确实存在。3.2 优化模式寄生组合式继承理想方案寄生组合式继承解决了组合继承调用两次构造函数的问题被普遍认为是引用类型最理想的继承范式。function inheritPrototype(child, parent) { // 创建一个以父类原型为原型的新对象作为子类的原型 const prototype Object.create(parent.prototype); // 修正新对象的constructor属性 prototype.constructor child; // 将新对象赋值给子类的原型 child.prototype prototype; } function Animal(name) { this.name name; this.colors [black, white]; } Animal.prototype.sayName function() { console.log(My name is ${this.name}); }; function Dog(name, age) { // 只调用一次父类构造函数继承实例属性 Animal.call(this, name); this.age age; } // 关键步骤使用寄生式继承来继承父类原型 inheritPrototype(Dog, Animal); Dog.prototype.bark function() { console.log(Woof!); }; const dog new Dog(Buddy, 2); console.log(dog instanceof Dog); // true console.log(dog instanceof Animal); // true dog.sayName(); // My name is Buddy核心改进Dog.prototype不再通过new Animal()创建而是通过Object.create(Animal.prototype)直接得到一个以父类原型为原型的纯净对象。这样子类原型上就不会再有父类的实例属性只保留了方法的链接。Animal构造函数仅在子类实例化时通过call调用一次效率最高。3.3 现代语法ES6 Class 与 extendsES6的class和extends语法是纯粹的语法糖它的底层实现本质上就是寄生组合式继承但写法上清晰直观得多。class Animal { constructor(name) { this.name name; this.colors [black, white]; } sayName() { console.log(My name is ${this.name}); } } class Dog extends Animal { // 使用 extends 继承 constructor(name, age) { super(name); // 必须首先调用 super()相当于之前的 Animal.call(this, name) this.age age; } bark() { console.log(Woof!); } } const dog new Dog(Buddy, 2); dog.sayName(); // My name is Buddy console.log(dog instanceof Animal); // true优点语法简洁意图明确。内置了super关键字用于调用父类的构造函数、静态方法和原型方法。更符合其他面向对象语言开发者的习惯。注意事项class中的方法默认是不可枚举的enumerable: false而以前在prototype上直接添加的方法是可枚举的。class声明不会提升存在暂时性死区而函数声明会提升。必须使用new调用不能像普通函数一样调用。super在子类构造函数中必须在使用this之前调用。实操心得在现代前端开发中除非有明确的兼容性要求如需要支持非常古老的浏览器否则应毫不犹豫地使用ES6class语法。它的可读性和可维护性远胜于传统的原型写法。Babel等工具可以帮你将其转换为兼容性更好的ES5代码。4. 高级主题与设计模式应用掌握了继承的基本实现后我们可以看看如何利用这些机制来解决更复杂的设计问题。4.1 混入模式组合优于继承继承是一种“是一个is-a”的关系如Dog是一个Animal。但很多时候我们需要的是“有一个has-a”或“能做什么can-do”的能力组合。这时“混入”模式就非常有用。它通过将多个对象的属性/方法混合到一个对象中来实现功能的复用。// 定义一些可复用的“能力”对象 const CanSwim { swim() { console.log(${this.name} is swimming.); } }; const CanFly { fly() { console.log(${this.name} is flying.); } }; // 混入辅助函数 function mixin(target, ...sources) { Object.assign(target, ...sources); return target; } // 创建一个“类”并混入能力 class Duck { constructor(name) { this.name name; } quack() { console.log(Quack!); } } // 将能力混入到 Duck 的原型中 mixin(Duck.prototype, CanSwim, CanFly); const donald new Duck(Donald); donald.quack(); // Quack! donald.swim(); // Donald is swimming. donald.fly(); // Donald is flying.优点避免了多重继承的复杂性和“钻石问题”更灵活地组合功能。缺点如果混入的多个对象有同名方法后者会覆盖前者Object.assign的特性。需要谨慎设计。4.2 静态方法与属性静态方法和属性属于类本身而不是类的实例。它们通常用于实现工具函数或存储类级别的常量。class MyClass { // 静态属性 (ES2022) static staticProperty I am static; // 静态方法 static staticMethod() { console.log(Static method called); console.log(Accessing static property: ${this.staticProperty}); // 这里的this指向类本身 } // 实例方法 instanceMethod() { console.log(Instance method called); } } // 通过类名访问 MyClass.staticMethod(); // Static method called console.log(MyClass.staticProperty); // I am static // 不能通过实例访问 const obj new MyClass(); // obj.staticMethod(); // TypeError在ES5中的实现静态方法直接作为构造函数的属性。function MyClass() {} MyClass.staticMethod function() { console.log(Static from ES5); };4.3 私有字段与方法长期以来JavaScript缺乏真正的私有成员。常见的做法是用下划线_开头约定为“私有”但这只是约定外部依然可以访问。ES2022正式引入了使用#符号定义的私有字段。class Counter { #count 0; // 私有字段 increment() { this.#count; console.log(this.#count); } getCount() { return this.#count; } } const counter new Counter(); counter.increment(); // 1 counter.increment(); // 2 console.log(counter.getCount()); // 2 // console.log(counter.#count); // SyntaxError: Private field #count must be declared in an enclosing class注意事项私有字段必须在类体中声明如#count 0。每个类的私有字段都是该类实例独有的子类无法访问父类的私有字段。在ES2022之前可以通过WeakMap或闭包来模拟私有变量但语法上不如#简洁直观。5. 面向对象实践中的常见陷阱与最佳实践理论最终要服务于实践。在实际编码中一些细节处理不当就会导致难以排查的Bug。5.1 原型链操作中的“坑”1. 动态修改原型的影响原型对象是共享的在运行时修改它会影响所有已存在和未来创建的实例。function Person(name) { this.name name; } const alice new Person(Alice); const bob new Person(Bob); Person.prototype.sayHi function() { console.log(Hi, ${this.name}); }; alice.sayHi(); // Hi, Alice (可以访问因为原型链查找是动态的) bob.sayHi(); // Hi, Bob // 但是重写整个原型对象会切断已有实例与旧原型的联系 const charlie new Person(Charlie); Person.prototype { sayBye() { console.log(Bye, ${this.name}); } }; alice.sayHi(); // Hi, Alice (仍然链接到旧原型) // alice.sayBye(); // TypeError: alice.sayBye is not a function const david new Person(David); // 使用新原型创建 // david.sayHi(); // TypeError: david.sayHi is not a function david.sayBye(); // Bye, David最佳实践尽量避免完全重写构造函数的prototype属性。如果需要添加大量方法可以逐个添加或者使用Object.assign(Person.prototype, { ...新方法 })来合并但要注意方法名冲突。2. 循环引用与内存泄漏虽然不常见但如果粗心地让原型链形成循环例如A.prototype new B(); B.prototype new A();会导致属性查找进入死循环在某些情况下可能引发问题。现代JavaScript引擎通常能检测并处理但应主动避免。3.for...in循环与可枚举性for...in循环会遍历对象自身及其原型链上所有可枚举的属性。这常常会把原型上的方法也遍历出来。function Person(name) { this.name name; } Person.prototype.sayHello function() {}; const alice new Person(Alice); for (let key in alice) { console.log(key); // 会输出 name 和 sayHello }解决方法使用Object.hasOwnProperty(key)进行过滤。for (let key in alice) { if (alice.hasOwnProperty(key)) { console.log(key); // 只输出 name } }或者在定义原型方法时将其属性描述符enumerable设为falseES6class的方法默认就是不可枚举的。Object.defineProperty(Person.prototype, sayHello, { value: function() {}, enumerable: false, // 关键 writable: true, configurable: true });5.2 构造函数与Class的使用规范1. 构造函数忘记使用new如果像普通函数一样调用构造函数this在非严格模式下会指向全局对象如window造成全局污染和难以预料的错误。function Person(name) { this.name name; } const p Person(Alice); // 糟糕没有new console.log(window.name); // Alice污染了全局 console.log(p); // undefined防御性写法在构造函数内部判断this是否为当前构造函数的实例。function Person(name) { if (!(this instanceof Person)) { return new Person(name); // 如果没通过new调用自动补上 } this.name name; }对于ES6class直接调用会抛出错误这是一种改进。2. 方法中this的丢失将对象的方法提取出来单独调用时this的指向会发生变化。class Logger { log(message) { console.log([${this.name}] ${message}); } } const logger new Logger(); logger.name App; const logFunc logger.log; logFunc(test); // TypeError: Cannot read property name of undefined解决方法使用箭头函数箭头函数不绑定自己的this会捕获其所在上下文的this值或在调用时使用bind、call、apply绑定this。// 方法1在构造函数中绑定this class Logger { constructor() { this.log this.log.bind(this); } log(message) { /* ... */ } } // 方法2使用箭头函数类字段 (ES2022) class Logger { log (message) { console.log([${this.name}] ${message}); } } // 方法3调用时绑定 logFunc.call(logger, test);5.3 面向对象设计原则的思考虽然JavaScript的面向对象有其独特性但一些通用的设计原则依然适用单一职责原则一个类应该只有一个引起它变化的原因。避免创建“上帝类”。开放-封闭原则对扩展开放对修改封闭。优先通过继承或混入来扩展功能而不是直接修改已有的类。里氏替换原则子类对象应该能够替换其父类对象并且程序的行为不会改变。这要求子类不要重写父类已定义的非抽象方法或者重写时要保证行为兼容。接口隔离原则客户端不应该依赖它不需要的接口。在JavaScript中可以理解为不要强迫一个类实现它用不到的方法可以通过更细粒度的混入对象来解决。依赖倒置原则高层模块不应该依赖低层模块二者都应该依赖其抽象。在JavaScript中可以多依赖接口用JSDoc或TypeScript描述或稳定的抽象类而非具体实现。将这些原则与JavaScript的原型、闭包、函数式特性结合才能设计出健壮、可维护的应用程序架构。例如利用高阶函数和闭包来实现策略模式、观察者模式会比生硬地套用类继承更加“JavaScript”。