尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ES6类与对象:从底层原理到继承机制的实战指南

ES6类与对象:从底层原理到继承机制的实战指南 ES6 中的类和对象是 JavaScript 开发者绕不开的一块硬骨头。我记得刚从前端切到中后台项目那会儿对着prototype、constructor、new这一套组合拳是真的头疼直到把 class 语法背后发生的那些事彻底搞清楚才敢说自己真的会写对象了。这篇文章我会从 ES6 类的底层逻辑讲起把核心特性、继承机制、实战用法和踩坑记录一次性说透适合所有想把 JS 面向对象基本功打扎实的同学尤其是面试前需要快速梳理知识点的朋友。1. 类不是新概念而是语法糖的集大成者1.1 ES5 时代我们是怎么模拟“类”的很多人会把 ES6 的 class 当成一门全新的东西其实它只是把 ES5 里那套“构造函数 原型方法”的写法用一种更接近 Java、C 等传统语言的语法包装起来了而已。在 ES6 之前我们想模拟一个“类”通常是这样写的// ES5 的“类”构造函数 原型方法 function Player(name, level) { this.name name; this.level level; } // 方法挂在 prototype 上供实例共享 Player.prototype.gainExp function (exp) { this.level exp; console.log(${this.name} 升级了当前等级 ${this.level}); }; Player.prototype.describe function () { return ${this.name}等级 ${this.level}; };这段代码在功能上没问题但有几个让人不舒服的地方。一是写法太散了先是构造函数然后又是一堆Player.prototype.xxx function定义的方法越多重复的Player.prototype前缀就越多可读性很差二是对新手非常不友好很多人一开始根本理解不了“为什么要挂在 prototype 上而不是直接写在构造函数里”。我记得自己刚学那会儿就闹过笑话把所有方法都写在构造函数里面结果每个实例都拷贝一份函数内存开销肉眼可见地涨。后来才明白挂在原型上的方法是被所有实例共享的这背后是 JavaScript 原型链的机制。1.2 class 语法到底做了什么ES6 的 class 语法把上面的代码改写成这样class Player { constructor(name, level) { this.name name; this.level level; } gainExp(exp) { this.level exp; console.log(${this.name} 升级了当前等级 ${this.level}); } describe() { return ${this.name}等级 ${this.level}; } }看起来舒服多了对不对但关键问题是class 到底做了什么它创造了新的对象模型吗答案是否定的。typeof Player的结果依然是function它本质上仍然是函数只是这个函数被强制要求必须通过new来调用不能像普通函数那样直接执行。class 语法和普通构造函数相比还有一些很重要的底层差异类声明不会被提升存在暂时性死区必须先声明再使用类内部默认就是严格模式不需要手动写use strict类的方法是不可枚举的这一点和 ES5 里直接挂在 prototype 上的方法不同类本身不能被当作普通函数调用必须配合new。理解这些差异对后面排查各种“莫名其妙”的问题非常有帮助。比如面试题经常问“为什么 class 不能提升”本质是因为 class 要遵循块级作用域和暂时性死区规则这是对它运行语义的一种保护避免你在声明之前就去用它。注意日常开发中不必纠结于“class 是不是语法糖”这种口水仗但必须清楚它底层仍然是基于原型链的。只有理解了这点才能在遇到性能问题和继承问题时不慌。2. 类与对象的核心特性拆解2.1 constructor、实例属性和实例方法class 里的constructor方法是类的默认初始化函数相当于其他语言里的构造函数。当你执行new Player(小明, 1)时constructor会被自动调用你传入的参数会在这里被绑定到实例上。这里要区分清楚两个概念实例属性和实例方法。实例属性是每个实例各自独有的。比如this.name、this.level每个 Player 实例的name和level互不干扰。实例方法则是定义在原型上的共享方法所有实例通过原型链访问同一个函数而不是每个实例拷贝一份。const p1 new Player(小明, 1); const p2 new Player(小红, 10); console.log(p1.gainExp p2.gainExp); // true方法共享 console.log(p1.name p2.name); // false属性各自独立这个设计思路很像是把“设计图”和“实体”分开。类就是设计图描述对象有什么属性和行为实例就是按照设计图造出来的实体数据各自维护方法统一共享。这样做的好处是内存占用小函数逻辑改动也只需要动一处。在写constructor时有一个细节值得注意如果你的类不需要初始化逻辑完全可以省略constructorJavaScript 会自动生成一个空的默认构造函数。但在继承场景下子类如果不写constructor需要清楚默认构造行为不是继承父类逻辑它有自己的一套规则后面讲继承时我会展开。2.2 静态方法与静态属性类方法不一定都跟实例有关。有些方法就是一个纯粹的独立功能比如校验数据、格式转换、打日志甚至创建特定配置的实例。这时候可以用static关键字声明静态方法。class User { constructor(name, age) { this.name name; this.age age; } // 实例方法 isAdult() { return this.age 18; } // 静态方法 static createGuest() { return new User(guest, 0); } static validateAge(age) { if (typeof age ! number || age 0 || age 150) { throw new TypeError(年龄参数不合法); } return true; } }静态方法挂在类本身而不是原型上所以调用方式也不同const guest User.createGuest(); User.validateAge(25);注意在静态方法内部this指向的是类本身这里是 User而不是实例。所以你不能在静态方法里直接访问this.name这种实例属性。反过来实例方法内部也不能直接用类名调用静态方法必须写成User.validateAge(...)或通过构造函数引用去访问。静态属性比如User.maxAge 150在 ES6 类中其实一直没有正式纳入标准语法直到 ES2022 才支持static字段声明。在此之前大家都用“在类外部赋值”的方式模拟比如User.maxAge 150;这种写法依然有效只是不如类内部的静态字段声明直观。新语法允许你直接在类体里写class User { static maxAge 150; }2.3 字段声明与私有字段ES2022 还引入了真正的类字段声明语法让属性定义变得更清晰class Counter { count 0; // 公共字段 #step 1; // 私有字段以 # 开头 static version 1.0; // 静态字段 increment() { this.count this.#step; } getStep() { return this.#step; } }私有字段是 JavaScript 长期以来最让人头疼的缺失能力之一。过去我们只能用约定俗成的“下划线开头”表示私有但那只是一种君子协定外部照样能访问。现在用#声明的字段是真正语言级别的私有变量外部任何方式都无法读取除非类内部暴露了访问方法。私有字段最常见的用途是保护内部状态。比如一个计数器的步长参数外部不应该随意篡改用#step声明后即便别人拿到实例对象也只能通过getStep()这类方法去读取没法直接赋值有效防止了状态被搞乱。还需要注意的是私有字段的语法约束#不是装饰器它是字段名的一部分。你不能在外部用obj.#step或者obj[#step]访问它也不存在“先声明后使用”的灵活性。私有字段必须在类内部被引用否则会直接报语法错误。3. 继承extends 与 super 的底层逻辑3.1 原型链视角看继承ES6 里实现继承用extends关键字它的核心是把子类的原型链和父类关联起来。从底层来看做了两件事class Animal { constructor(name) { this.name name; } move() { return ${this.name} 在移动; } } class Dog extends Animal { constructor(name) { super(name); // 调用父类构造函数 } bark() { return 汪汪; } }这条继承关系的底层结构是这样的Dog.prototype的原型指向Animal.prototype所以 Dog 实例可以调用 Animal 原型上的move()Dog.__proto__指向Animal这是为了让静态方法也能被继承Dog 实例本身通过new Dog(...)创建它的原型链是实例 → Dog.prototype → Animal.prototype → Object.prototype。画成图可能更直观但你只需要记住一句话继承的意义不在于复制父类的代码而在于复用原型链上的能力。子类自己没定义的方法顺着原型链往上找就能找到。这种设计和 ES5 时代手动实现的继承最大的区别是ES6 的继承建立在语言标准之上行为一致且稳定。而在 ES5 里很多人会用“借用构造函数 原型替换”的方式模拟继承稍不注意就会把父类实例上的属性变成共享状态导致各种诡异的 bug。3.2 super 关键字的三重身份super是 ES6 继承当中最容易被误解的关键字因为它在不同场景下指向完全不同的东西。第一在子类的constructor里super(...)作为函数调用表示“调用父类的构造函数”而且必须在使用this之前调用否则会报错class Dog extends Animal { constructor(name, breed) { super(name); // 先初始化父类部分 this.breed breed; // 再初始化子类自己的属性 } }为什么必须先调用super因为子类实例的创建依赖父类构造流程先完成。JavaScript 引擎需要先通过父类构造函数生成this对象子类才能在这个对象基础上继续添加属性。如果不调用superthis就没有被初始化直接使用就会抛错。第二在子类的静态方法里super指向父类构造函数本身。可以借此调用父类的静态方法class Parent { static who() { return parent; } } class Child extends Parent { static who() { return super.who() child; } } console.log(Child.who()); // parent child第三在子类的普通实例方法里super指向父类的原型对象即Parent.prototype。所以你可以通过super.method()调用父类原型上的方法用于在重写方法的同时保留父类逻辑class Dog extends Animal { move() { return ${super.move()}用四条腿跑; } }三种场景对应三个不同的目标这是super最核心的考点也是面试里经常出现的题目。建议你亲自敲一遍代码确认一下每个场景下super具体指向什么比死记结论强得多。3.3 继承的注意事项继承不是“子类自动拥有父类一切”这么简单有几个很容易踩的坑需要单独说明。第一个是派生类的默认constructor。如果子类没有声明constructor会自动生成一个constructor(...args) { super(...args); }也就是说父类收什么参数子类默认就会把参数原样传给父类。这在多层继承时特别容易让人困惑尤其是你给子类传了参数却忘了检查父类的构造函数是否接收这些参数。第二个是重写方法时不要破坏父类约定。比如父类方法返回一个对象子类重写时最好也保持类似的返回结构否则调用方会莫名其妙。最好的做法是除非确实需要完全不一样的逻辑否则尽量在子类方法第一行调用super.method()以“增强”而不是“替换”父类行为。第三个是new.target的用途。类是唯一能直接判断“当前是通过哪个构造函数创建实例”的地方这在模拟抽象类时很有用。比如你定义一个Shape基类但不希望有人直接实例化它就可在构造函数里检查class Shape { constructor() { if (new.target Shape) { throw new TypeError(Shape 是抽象类不能直接实例化); } } }这种用法本质上是一种约定式约束虽然不如 Java 等语言的抽象类那么硬性但足以在团队协作时防止误用。4. 类的实战应用场景与设计思路4.1 从哪里开始用类三个典型场景很多人学了类语法到了实际项目里反而不知道该不该用。我个人经验是类适合用在三个典型场景里。第一个是领域模型封装。也就是说你有一个明确的业务实体比如用户、订单、商品这些实体有自己的属性也有一组固定的行为。用类可以把这些建模成一个整体代码可读性和复用性都更强。class Order { constructor(orderId, items) { this.orderId orderId; this.items items; // [{ sku, price, count }] this.status pending; } get totalAmount() { return this.items.reduce((sum, item) sum item.price * item.count, 0); } pay() { if (this.status ! pending) { throw new Error(订单状态不允许支付); } this.status paid; } }第二个是工具类或服务类。比如封装 HTTP 请求、封装 localStorage、封装日志上报这些模块本身没有任何具体数据但提供一组相关的操作。用类是顺理成章的而且方便扩展配置和共享状态。class StorageService { static set(key, value) { localStorage.setItem(key, JSON.stringify(value)); } static get(key) { const raw localStorage.getItem(key); return raw ? JSON.parse(raw) : null; } static remove(key) { localStorage.removeItem(key); } }第三种是基类与多态。一个系统里有多个形态相似但细节不同的组件可以抽象出一个基类让子类各自实现差异部分。这种模式在前面继承章节已经演示过了适用于表单控件、页面容器、各类插件等。4.2 手写一个事件总线类我建议每个前端开发者都至少手写一遍事件总线它几乎用到了类的所有核心特性实例属性、实例方法、数组操作、闭包技巧而且能让你体会到“类封装逻辑”为什么好用。class EventBus { constructor() { this.events new Map(); } on(event, listener) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(listener); } once(event, listener) { const wrapper (...args) { this.off(event, wrapper); listener(...args); }; wrapper.origin listener; this.on(event, wrapper); } off(event, listener) { const listeners this.events.get(event); if (!listeners) return; const index listeners.findIndex( (item) item listener || item.origin listener ); if (index -1) { listeners.splice(index, 1); } } emit(event, ...args) { const listeners this.events.get(event); if (!listeners || listeners.length 0) return; // 拷贝一份再执行防止回调中修改原数组导致循环异常 [...listeners].forEach((listener) { listener(...args); }); } clear(event) { if (event) { this.events.delete(event); } else { this.events.clear(); } } }使用方式也很直观const bus new EventBus(); bus.on(login, (user) { console.log(${user.name} 已登录); }); bus.once(init, () { console.log(init 只会触发一次); }); bus.emit(login, { name: zhang }); bus.emit(login, { name: li }); bus.emit(init); bus.emit(init); // 不会再次输出这个类里面踩过不少坑。比如emit里为什么要先拷贝数组再遍历因为如果某个监听器内部调用了off去移除自身直接遍历原数组会导致索引错乱。比如once包装的函数为什么要把原 listener 保存在origin属性上因为这样你传同一个原始函数给off时能正确找到包装函数把它移除掉。这些细节都是真实项目中会遇到的问题写一遍比背十遍有用。4.3 类实例与深拷贝热词里经常出现“es6深拷贝”很多人在做状态管理、跨页面传参时需要对类实例做深拷贝。但这里有个容易掉进去的深坑JSON 深拷贝会完全丢失原型和方法。const user new User(张三, 25); const clone JSON.parse(JSON.stringify(user)); console.log(clone instanceof User); // false console.log(clone.isAdult()); // TypeError: clone.isAdult is not a function原因很简单JSON.stringify只会序列化对象自身可枚举的属性类实例的原型链和方法都丢失了。即便你改用structuredClone它做的也只是结构化克隆仍然不会保留原型链。所以类实例的正确深拷贝姿势是给类设计一个“序列化 / 反序列化”的对称方法class User { constructor(name, age) { this.name name; this.age age; } isAdult() { return this.age 18; } toJSON() { return { name: this.name, age: this.age }; } static fromJSON(json) { return new User(json.name, json.age); } } const user new User(张三, 25); const clone User.fromJSON(JSON.parse(JSON.stringify(user))); console.log(clone instanceof User); // true console.log(clone.isAdult()); // true这么做的好处是可控、安全而且不依赖任何第三方库。唯一需要注意的是类属性一旦变多toJSON和fromJSON需要同步维护建议写单元测试来保证一致性。5. 类的“坑”与排查技巧实录5.1 this 指向第一大坑类方法里this的指向问题是实际开发中遇到最多的问题。最大的坑在于当你把方法从实例上解构出来单独使用时方法内部的this就丢了。class Counter { count 0; increment() { this.count; console.log(this.count); } } const counter new Counter(); const { increment } counter; increment(); // TypeError: Cannot read properties of undefined (reading count)为什么会报错因为increment在类原型上定义时并没有依赖任何“闭包变量”来保存实例信息。它只是一个普通函数函数内部this的值取决于调用方式。当你解构后直接调用它是以普通函数的方式执行的按严格模式规则this就是undefined。解决方案有三种。第一种是调用时手动绑定increment.call(counter);第二种是构造时绑定class Counter { count 0; constructor() { this.increment this.increment.bind(this); } increment() { this.count; } }第三种是直接把方法定义为箭头函数字段class Counter { count 0; increment () { this.count; }; }箭头函数字段之所以能规避问题是因为它在实例创建时被初始化成闭包捕获了当前的this不会被后续调用方式改变。这种写法在 React 类组件的回调事件中特别常见。但它也有缺点每个实例都会创建一份独立函数内存上不如原型方法共享划算所以到底是绑定还是箭头函数字段要看你更在意写法的简洁还是实例的内存占用。5.2 暂时性死区与类表达式类的“不提升”特性也坑过不少新手。ES5 里函数声明会提升你可以在声明之前调用它但类声明不会提升提前使用会直接报ReferenceError。// 这样会报错 const p new Player(小明, 1); class Player { constructor(name) { this.name name; } }这背后的逻辑是类声明具备暂时性死区TDZ特性在代码执行到声明语句之前类绑定是不可访问的。这也提醒我们写代码时要有习惯类的使用位置永远放在声明之后。另外值得一提的是类表达式。类表达式和函数表达式类似可以让类拥有一个不污染作用域的名字const AnyName class InnerName { getClassName() { return InnerName.name; } }; const instance new AnyName(); console.log(instance.getClassName()); // InnerName在需要动态创建不同配置的类或者实现简单的依赖注入时类表达式非常有用。5.3 类实例与响应式框架的小陷阱热词里有“vue对象赋值页面不变”这其实也跟类和对象的使用习惯有关。当把一个类实例赋值给 Vue 或 React 等框架的响应式数据时容易出现两种问题。第一种是直接整体替换对象导致视图不更新。比如this.form this.formFactory.create();在很多框架里直接替换一个响应式对象与修改对象内部某个属性触发的更新机制不一样。解决方式通常是先清空再赋值或者调用框架提供的批量更新 API。第二种是给类实例动态新增自定义属性但这些属性没有响应式绑定。类的字段在初始化时固定了结构新增的字段对框架来说是一个“陌生属性”不会自动进入响应式系统。如果你确实需要把类实例放到响应式数据里最好的做法不是塞一个完整实例而是把实例的核心数据映射成普通对象配合类的方法工厂来还原实例// 保存时只存数据 const plain { name: this.user.name, age: this.user.age }; // 使用时再还原成实例 this.user User.fromJSON(plain);这样既享受了类的封装又避免了响应式系统的各种怪问题。5.4 常见问题速查表问题现象根本原因解决办法方法解构后调用报this is undefined类的普通方法没有闭包绑定 this使用bind、箭头函数字段或调用处手动绑定子类构造函数里用 this 报错派生类必须先调用 super 才能使用 this在构造函数第一行调用super(...)类实例 JSON 深拷贝后方法丢失JSON 序列化只保留自身可枚举属性给类实现toJSON和fromJSON类声明前的代码访问类报引用错误class 存在暂时性死区把类的使用移到声明之后对象整体赋值后页面不更新替换响应式对象未触发视图更新改用属性赋值或使用框架更新 APIclass 内部报严格模式语法错误类体内部默认开启严格模式检查代码里是否有非严格模式才能使用的写法6. 拓展实践与个人经验6.1 用 new.target 模拟抽象类前面简单提过new.target的用法这里再深入一点。JavaScript 没有正式的abstract关键字但我们完全可以用运行时检查模拟出“抽象类不能被实例化”的约定class Animal { constructor() { if (new.target Animal) { throw new TypeError(Animal 是抽象类不能直接实例化); } } makeSound() { throw new Error(子类必须实现 makeSound 方法); } } class Dog extends Animal { makeSound() { return 汪汪; } } // const animal new Animal(); // 报错 const dog new Dog(); console.log(dog.makeSound());这里还配合了一个“接口约定”基类Animal的makeSound方法故意抛出异常逼着子类去实现。这种方式虽然比不上 Java 编译期的强制约束但在团队协作中能起到“文档即约束”的作用别人看到基类的注释和抛错提示就会明白必须重写该方法。6.2 对象创建方式怎么选实际写代码时创建对象的方式不止 class 一条路。字面量、工厂函数、类、Object.create各有用处。我的选型标准是这样的如果只是临时携带一组数据比如接口返回结果、配置文件项直接用对象字面量干净利落如果对象创建逻辑复杂或需要多个步骤初始化用工厂函数方便做各种默认值合并如果对象有明确的行为方法并且在多个地方被复用用类最合适如果你需要定制原型链比如从一个已有对象直接派生新对象用Object.create。举个例子在写配置解析的时候工厂函数比类更灵活function createConfig(options {}) { return { apiBase: options.apiBase || /api, timeout: options.timeout || 5000, retry: options.retry ?? 2, }; }类更强调“封装 行为”工厂函数更强调“组装 返回新对象”。不要觉得用了 class 就高级代码最终追求的是清晰、好维护。6.3 我在真实项目中的最佳实践最后聊聊我在项目里积累的几条实际经验不算什么金科玉律但都是踩过坑之后总结出来的。第一条不要为了类而类。如果一个类只有一个方法或者只是包了一层工具函数那它就是过度设计拆成普通函数反而更好维护。类的价值在于组织有一定复杂度、有内部状态的逻辑而不是满足“面向对象”的表面仪式感。第二条继承层级控制在两层以内。多层继承会让你陷入“父父类改了影响所有孙类”的困境排查成本非常高。如果发现继承链超过三层优先考虑用组合替代继承把共同逻辑抽成独立的工具函数或混入。第三条类方法尽量做到“无状态化”。方法内部尽可能只依赖参数和this上的属性不要依赖模块级别的全局变量。这样方法才容易被测试也容易被复用不至于换一个上下文就崩溃。第四条类的命名要体现职责。我见过太多Utils、Manager、Handler这种泛泛的类名一眼看不出它到底管什么。尽量用业务上能感知的概念来命名比如OrderService、UserSession、StorageService这样维护起来才省心。每次接手别人的代码看到一个类能够不打开源码就先猜到它大概有哪些方法和属性这个类就是合格的。如果你写的类也能做到这样那说明你对类和对象的理解已经到位了。
返回列表