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时有一个细节值得注意:如果你的类不需要初始化逻辑,完全可以省略constructor,JavaScript 会自动生成一个空的默认构造函数。但在继承场景下,子类如果不写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对象,子类才能在这个对象基础上继续添加属性。如果不调用super,this就没有被初始化,直接使用就会抛错。
第二,在子类的静态方法里,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 存在暂时性死区 | 把类的使用移到声明之后 |
| 对象整体赋值后页面不更新 | 替换响应式对象未触发视图更新 | 改用属性赋值,或使用框架更新 API |
| class 内部报严格模式语法错误 | 类体内部默认开启严格模式 | 检查代码里是否有非严格模式才能使用的写法 |
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,这样维护起来才省心。
每次接手别人的代码,看到一个类能够不打开源码就先猜到它大概有哪些方法和属性,这个类就是合格的。如果你写的类也能做到这样,那说明你对类和对象的理解已经到位了。