1. 先搞清楚:Class 到底是什么,为什么大家都在用
第一次接触 JavaScript 的时候,我脑子里对“面向对象”的印象一直停留在“用函数封装状态和行为”这个层面。后来 ES6 正式引入了 Class 语法,社区里争论过一阵子“JavaScript 到底需不需要 Class”。说实话,Class 不是一种全新的能力,它更像是一层语法糖,把 JavaScript 里原本基于原型链的写法包装成了更直观、更接近主流面向对象语言的形式。
很多人听到“语法糖”三个字,会觉得这东西不高级。但我的看法刚好相反:如果一种语法能让初学者快速上手、能让团队协作时减少理解偏差、能让代码意图更清晰,那它就有足够的存在价值。Class 解决的不是“能不能实现”的问题,而是“能不能一眼看懂”的问题。
举个例子,以前我们要创建一个“人”的对象,用函数构造器和原型链这样写:
function Person(name, age) { this.name = name; this.age = age; } Person.prototype.sayHello = function () { console.log('大家好,我是' + this.name + ',今年' + this.age + '岁'); }; const p1 = new Person('小明', 18); p1.sayHello();这段代码逻辑没问题,但对新手来说,“prototype”是什么、为什么要往 prototype 上挂方法、new 的时候到底发生了什么,每一个点都能劝退一批人。Class 写法则要直观得多:
class Person { constructor(name, age) { this.name = name; this.age = age; } sayHello() { console.log(`大家好,我是${this.name},今年${this.age}岁`); } } const p1 = new Person('小明', 18); p1.sayHello();两段代码做的事情一模一样,但后者对阅读者友好得多。我们可以专注于“这个类有哪些属性、哪些行为”,而不是被底层机制的细节绊住手脚。这篇文章适合刚接触 JavaScript 不久、对 Class 似懂非懂的读者,也适合那些写过一阵子 function 但一直没系统梳理过 Class 知识点的同学。我会从最基础的概念讲起,一步步拆到继承、私有字段、静态成员,再结合我在真实项目中踩过的坑,帮你把这块知识补完整。
2. 从“工厂模式”到 Class:理解对象创建方式的演变
2.1 为什么需要“模板”来创建对象
在日常开发里,我们经常会遇到这种情况:需要创建多个结构相似的对象。比如一个购物车里有好几个商品,每个商品都有名称、价格、数量,还需要能计算小计的方法。最笨的办法是写几个对象字面量:
const product1 = { name: '手机', price: 2999, count: 1, subtotal() { return this.price * this.count; }, }; const product2 = { name: '耳机', price: 499, count: 2, subtotal() { return this.price * this.count; }, };如果只有两个商品,这么写还能接受,但一旦商品数量增加到十个、五十个,代码就会变得非常臃肿,而且很容易出现“某个商品忘写 subtotal 方法”之类的低级错误。这个时候,我们需要的是一套可复用的“模板”,只要传不同的参数进去,就能得到不同内容、但结构完全一致的对象。
这个“模板”思维其实就是 Class 的核心思想。Class 定义的是对象的结构和行为框架,new 关键字负责按照框架创建出具体的实例。每个实例都有自己的属性值,但共享同一套行为定义,这样既节省了内存,也统一了逻辑。
2.2 函数构造器与 Class 的本质联系
很多 JavaScript 老手喜欢强调“Class 本质还是函数”,这句话是对的。我们可以在浏览器控制台里做个小实验:
class Person {} console.log(typeof Person); // function看到没,typeof 输出的是 function。Class 本质上就是一个构造函数,只是它的写法更规范、更严格。比如 Class 声明和函数声明有几个明显区别:
- Class 声明不会提升(no hoisting),在声明之前使用会报错
- Class 内部的代码默认运行在严格模式下
- Class 不可以直接被调用,必须用 new 来创建实例
这里我特别说一下第一点。函数声明有个特性叫“提升”,你可以在声明之前调用它:
sayHello(); function sayHello() { console.log('hello'); }但 Class 不行:
const p = new Person(); // ReferenceError: Cannot access 'Person' before initialization class Person { constructor() { this.name = '张三'; } }这个区别在实际开发中很重要。如果你习惯了函数的书写顺序,突然换用 Class 时很容易踩到“变量未初始化”的报错。我的经验是:把 Class 定义放在文件靠前的位置,或者单独抽成一个模块导出,避免依赖提升特性。
3. 手写一个 Class:核心结构逐块拆解
3.1 constructor:每次 new 都在这里初始化
constructor 是 Class 里最先要被理解的成员。它的作用是在创建实例时执行一些初始化逻辑。简单说,你每次调用 new Person(),constructor 里的代码就会自动执行一遍。
class Person { constructor(name, age) { console.log('constructor 被调用了,参数是:', name, age); this.name = name; this.age = age; } } const p1 = new Person('小明', 18); // constructor 被调用了,参数是: 小明 18 console.log(p1.name); // 小明 console.log(p1.age); // 18constructor 里最常见的操作,就是把传入的参数绑定到新创建的对象上。注意,这里用的是 this,它指向的就是新创建的那个实例。如果 constructor 不写,Class 会给一个默认的空构造器,等价于 constructor() {},对象仍然能被创建,只是没有初始化任何属性。
有一个细节值得注意:constructor 里可以 return 一个对象。如果返回的是对象类型,new 的结果就会变成那个对象;如果返回的是原始类型,那么 new 仍然返回 this 指向的实例。这个特性用的场景很少,但面试偶尔会问到,理解了“constructor 可以拦截 new 的结果”这一点就足够了。
class Foo { constructor() { return { custom: true }; } } const instance = new Foo(); console.log(instance.custom); // true console.log(instance instanceof Foo); // false3.2 实例属性与方法:哪些属于“每一个对象”
constructor 里通过 this.name = name 创建的属性,叫做实例属性。每个实例都有一份独立的副本。比如两个 Person 实例,p1.name 改掉不会影响 p2.name。
除了在 constructor 里定义属性,ES2022 开始也支持直接在类的顶层声明属性字段:
class Person { name = '张三'; // 直接写字段名 + 初始值 age = 0; constructor(name, age) { if (name) this.name = name; if (age) this.age = age; } } const p1 = new Person(); console.log(p1.name); // 张三 const p2 = new Person('李四', 25); console.log(p2.name); // 李四这种写法更直观,也更容易一眼看出类有哪些“状态”。而且顶层字段初始化发生在 constructor 执行之前,如果你想在 constructor 里覆盖字段值,是可以直接覆盖的。
类的方法则不同,它们定义在 prototype 上,被所有实例共享。这意味着不管创建多少个实例,方法只有一份拷贝,不会浪费内存。你可以在 console 里验证一下:
class Person { constructor(name) { this.name = name; } sayHello() { return `hello, ${this.name}`; } } const p1 = new Person('张三'); const p2 = new Person('李四'); console.log(p1.sayHello === p2.sayHello); // true这里的输出是 true,因为 p1 和 p2 的 sayHello 都是同一个函数引用。如果想给某个实例单独加方法,直接赋值就行,但除非有特殊需求,一般不建议这么做,因为会破坏实例之间的一致性。
3.3 原型链的一次直观映射:Class 方法的真实存放位置
理解了方法存在 prototype 上,我们再往底层走一步。用 Class 定义的类,其实和“构造函数 + 原型方法”的经典写法是对应的:
class Person { constructor(name) { this.name = name; } sayHello() {} } // 等价于 function Person(name) { this.name = name; } Person.prototype.sayHello = function () {};Object.getPrototypeOf(p1)拿到的原型对象,和Person.prototype是同一个对象。这也带来一个实际的用途:如果你要给一个类批量添加方法,可以直接改它的原型:
class Person {} Person.prototype.walk = function () { return `${this.name} 正在走路`; };这种方式在维护老代码时偶尔会遇到,理解 Class 和原型的映射关系,你在排查“为什么方法在实例上不存在”这类问题时就能快速反应过来。
4. 继承与 super:子类如何优雅地复用父类逻辑
4.1 extends:一个项目里最常用的复用方式
继承是面向对象里绕不开的话题。在 JavaScript 里,用 extends 关键字实现类与类之间的继承关系。父类的公共属性和方法,子类可以自动继承下来,不用再写一遍。
class Animal { constructor(name) { this.name = name; } eat() { return `${this.name} 正在吃东西`; } } class Dog extends Animal { bark() { return `${this.name} 正在汪汪叫`; } } const dog = new Dog('旺财'); console.log(dog.eat()); // 旺财 正在吃东西 console.log(dog.bark()); // 旺财 正在汪汪叫这里 Dog 没有写 constructor,创建实例的时候会默认调用父类的 constructor,把参数透传过去。如果你需要在子类里增加新的初始化逻辑,就必须写 constructor,并且必须在 this 被使用之前调用 super()。
4.2 super 的两个使用场景:调用父类构造函数与调用父类方法
super 有两种用法,一种是当函数调用,另一种是当对象访问属性。
当函数调用时,super() 指的是父类的构造函数。它只能在子类 constructor 中使用,而且必须在 this 出现之前调用:
class Dog extends Animal { constructor(name, breed) { super(name); // 调用 Animal 的 constructor,初始化 name this.breed = breed; } }如果子类写了 constructor,但没调 super(),使用 this 时会直接报错:
class Dog extends Animal { constructor(name, breed) { this.breed = breed; // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor super(name); } }另一种用法是 super.methodName(),在子类方法里显式调用父类的方法,这种模式在“扩展某个功能而不是重写整个方法”时非常实用:
class Dog extends Animal { eat() { const result = super.eat(); // 先执行父类逻辑 return `${result},并且还吃骨头`; } }4.3 继承链上的 this 指向与“父类拿不到子类属性”的迷思
有些初学者会有个困惑:子类实例调用父类方法时,父类方法里的 this 到底指向谁?答案很简单:无论方法定义在哪个类里,只要最终是某个实例调用的,this 就指向那个实例。
class Animal { whoAmI() { return this.name; } } class Dog extends Animal { constructor(name) { super(); this.name = name; } } const dog = new Dog('旺财'); console.log(dog.whoAmI()); // 旺财这里的 whoAmI 是父类的方法,但调用者是 dog 实例,所以 this.name 能取到子类初始化过的值。这个特性为“继承链上的多态”提供了基础:子类可以重写父类方法,然后在父类方法里通过 this 动态调用到子类的版本。
实际项目中,我很少设计特别深的继承链,因为层级一多,代码的可读性和可调试性都会下降。更多时候我用继承去抽象“具有相同基础能力”的模型,比如“所有动物都能吃”这类共性很强的场景。当你发现某两个类之间是“有一种”的关系时,优先考虑组合而不是继承,这是很多老开发总结出来的经验。
5. 静态成员、私有字段与 getter/setter:接触实用的进阶特性
5.1 static:属于类而不属于实例的方法和属性
有些情况下,某个行为并不依赖具体实例。比如创建一个“计算两个数的和”的工具方法,或者管理这个类一共创建了多少个实例。这种场景下,可以用 static 关键字定义静态成员。
class Person { static totalCount = 0; constructor(name) { this.name = name; Person.totalCount++; } } const p1 = new Person('张三'); const p2 = new Person('李四'); console.log(Person.totalCount); // 2 console.log(p1.totalCount); // undefined静态属性和静态方法挂在类本身,而不是挂在实例上。所以要用 类名.属性名 来访问。静态方法里如果想调用其他静态方法,直接用 this 是可行的,因为 this 指向类本身,但要注意别和实例方法的 this 搞混。
一个常见的应用是工具类封装。比如封装日期格式化、请求统一处理等,都可以用静态方法实现:
class DateUtils { static formatNow() { const now = new Date(); return now.toISOString(); } } console.log(DateUtils.formatNow());另外,静态属性也可以被继承。子类可以通过 父类名 或 this 访问到继承来的静态成员,这个特性在某些框架源码里很常见。
5.2 私有字段 #:真正的“外部不可访问”终于来了
JavaScript 的私有字段语法用 # 开头,它比约定俗成的下划线 _ 更严格,是真正的语言层面的保护。
class BankAccount { #balance = 0; constructor(initialBalance) { this.#balance = initialBalance; } deposit(amount) { if (amount <= 0) { throw new Error('存款金额必须大于0'); } this.#balance += amount; } getBalance() { return this.#balance; } } const account = new BankAccount(100); account.deposit(50); console.log(account.getBalance()); // 150 console.log(account.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class在类外部访问 #balance 会直接报语法错误,这种保护机制对维护大型项目非常有价值。以前用 _ 开头只是“约定”,大家靠自觉去遵守,现在有了 #,可以从语言层面阻止误操作。
私有字段有几个特性值得记住:它不能在类外部读取或赋值;它不会被Object.keys()之类的内置方法枚举;它也不能通过instance.#field在类外被动态访问。还有一点,私有字段只能在声明它的类内部通过 this.#xxx 访问,子类也不可以直接访问父类的私有字段与私有方法。如果子类确实需要用,可以在父类暴露一个受保护的方法来间接访问。
5.3 getter 和 setter:让属性拥有“副作用”能力
Class 里的 getter 和 setter 语法,可以让你像读取普通属性一样,去执行一段函数逻辑。典型的使用场景是在赋值或取值时做校验、格式化或者派发事件。
class User { #firstName = ''; #lastName = ''; constructor(firstName, lastName) { this.#firstName = firstName; this.#lastName = lastName; } get fullName() { return `${this.#firstName} ${this.#lastName}`; } set fullName(value) { const [firstName, lastName] = value.split(' '); if (!firstName || !lastName) { throw new Error('fullName 格式不正确,需要包含空格'); } this.#firstName = firstName; this.#lastName = lastName; } } const user = new User('张', '三'); console.log(user.fullName); // 张 三 user.fullName = '李 四'; console.log(user.firstName); // undefined注意,我在 User 里没有定义 firstName 属性,所以外部访问 user.firstName 是 undefined。如果希望它能被读取,需要在类里加上对应的 getter。getter 和 setter 配合私有字段使用,是一种很干净的数据保护方式。
从使用体验来看,用 getter/setter 封装内部状态,可以在“外部看起来像访问普通属性”的同时,内部做好各种防御性检查。这比暴露一个 setName/getName 方法更符合面向对象的设计直觉。
6. 高频报错与真实踩坑记录:把这些坑提前填平
6.1 忘记 new 直接调用 Class
Class 和普通函数不一样,它不能被直接调用。如果你在代码里写下Person(),浏览器会报 “Class constructor Person cannot be invoked without 'new'”。
这个报错很容易发生在“参数是一个函数,回调里直接传入类名”的场景。比如:
class Person {} ['a', 'b'].map(Person); // TypeError: Class constructor Person cannot be invoked without 'new'因为 map 的回调是直接调用传入的函数,并不会加 new 关键字。解决办法就是显式包一层:
['a', 'b'].map(item => new Person(item));6.2 this 指向丢失:方法提取后 undefined
Class 方法默认不会自动绑定 this。如果你把方法从实例上取出来单独调用,this 就会丢失。
class Counter { count = 0; increment() { this.count++; console.log(this.count); } } const counter = new Counter(); const fn = counter.increment; fn(); // TypeError: Cannot read properties of undefined (reading 'count')解决这个问题的常见方案有三种:绑定函数到实例属性、使用箭头函数定义方法、调用时用 bind/call/apply。
如果想让方法在作为回调时依然保持正确的 this,最推荐的方式是在 constructor 里绑定:
class Counter { count = 0; constructor() { this.increment = this.increment.bind(this); } increment() { this.count++; console.log(this.count); } }或者直接使用类字段和箭头函数:
class Counter { count = 0; increment = () => { this.count++; console.log(this.count); }; }箭头函数没有自己的 this,它会继承作用域链上最近的 this,也就是实例对象。这种方式在 React 类组件里曾经非常流行,现在虽然函数组件是主流,但理解这个机制仍然很重要。
6.3 子类里漏写 super() 导致初始化失败
前面提过,子类只要写了 constructor,就必须在第一行调用 super()。漏写常见的报错是“Must call super constructor in derived class before accessing 'this'”。
这个坑的隐蔽之处在于,有时候你可能不是直接访问 this,而是通过某个方法间接访问。比如:
class Animal { constructor() { this.init(); } init() { this.name = 'animal'; } } class Dog extends Animal { constructor() { super(); // 如果漏写,this.name 赋值时会报错 } }写代码的时候一定要形成条件反射:有 extends 就有 super()。我通常会先写好 super(),再写其他逻辑,避免后面忘了。
6.4 私有字段的跨类访问限制
私有字段 # 的访问权限限制很严格,子类无法直接访问父类的私有字段。我一开始以为“私有字段会不会像私有属性一样,继承时会被子类继承”?
不会。子类方法里访问 this.#parentField 会直接报语法错误。如果你确实需要子类操作父类的某个内部状态,可以提供受保护的方法。这个设计看似死板,其实对封装性有正向作用:父类的内部实现细节不会被随意篡改。
6.5 浏览器兼容性带来的意外报错
Class 的相关语法在主流浏览器里已经很成熟了,但如果你的项目需要跑在旧版浏览器上,比如某些企业内部系统的 WebView,私有字段 #、静态属性这些较新的特性可能会直接报语法错误。
遇到这种情况,有两个选择:一是使用 Babel 等编译工具把新语法转换成兼容版本;二是在项目里约定规范,尽量避免使用太新的特性。我的建议是,如果项目不是特别需要,私有字段可以先不用,保持代码最大兼容性;如果确实用了,必须确认构建链路的转译配置没问题。
7. 从面试题到真实项目:Class 该怎么用才不显得“过度设计”
7.1 面试常考的几个 Class 相关知识点
面试中关于 Class 的问题通常会集中在几个方向:
- Class 和 function 构造器有什么区别
- 继承的实现原理,以及 super 关键字执行顺序
- 私有字段与闭包实现私有变量的区别
- 把类方法作为回调时怎么解决 this 丢失
- Class 上的方法为什么不能用 Object.keys 枚举
回答这些问题,关键不在于背结论,而在于能否从原型链的角度解释清楚。比如“Class 方法为什么不能被 Object.keys 枚举?”是因为方法定义在 prototype 上,而 Object.keys 只获取可枚举的自身属性。这些点理解了,面试时就能从容应对。
7.2 什么时候应该用 Class,什么时候不应该
虽然我在前面用了大量篇幅讲 Class 的用法,但实际项目里我并不建议任何场景都无脑使用 Class。原因很简单:Class 适合承载一组“有状态、有约定、有复用关系”的业务模型,但并不适合当纯工具函数库。
如果你只是想把几个数学计算函数放一起,用对象字面量或者模块导出各个函数就够了:
export function add(a, b) { return a + b; } export function subtract(a, b) { return a - b; }如果有一组数据,需要同时维护属性和行为,并可能产生多个实例,Class 就是合适的工具。比如订单、用户、购物车这类领域模型,用 Class 可以把数据属性和业务操作紧密绑定在一起,职责划分更清晰。
团队协作时,Class 还有一个隐性好处:它提供了一种“结构约束”。大家看到 class 名称,就能推断出这个模块具备哪些属性和行为,代码组织更规范。对于大型项目而言,这种统一性带来的维护效率提升是很可观的。
7.3 一个综合小例子:用 Class 做一个简单的任务清单
为了把前面讲的特性串起来,我写一个简单但完整的示例,把 constructor、实例属性、私有字段、静态方法、继承和 getter 都融入进去:
class Task { static taskCount = 0; #status = 'pending'; constructor(title) { this.title = title; Task.taskCount++; } get status() { return this.#status; } set status(newStatus) { if (newStatus !== 'pending' && newStatus !== 'completed') { throw new Error('status 只能是 pending 或 completed'); } this.#status = newStatus; } complete() { this.status = 'completed'; } describe() { return `任务:${this.title},状态:${this.status}`; } } class UrgentTask extends Task { constructor(title, deadline) { super(title); this.deadline = deadline; } describe() { const base = super.describe(); return `${base},截止时间:${this.deadline}`; } } const task1 = new Task('写周报'); task1.complete(); console.log(task1.describe()); const task2 = new UrgentTask('修复线上 Bug', '2025-01-10 18:00'); console.log(task2.describe()); console.log(Task.taskCount); // 2这段代码里,Task 维护了标题和状态,状态通过私有字段加 getter/setter 控制读写校验。UrgentTask 继承了 Task,并在 describe 方法里用 super.describe() 复用了父类逻辑。静态属性 taskCount 追踪创建的任务数。
把这个例子跑一遍,你对 Class 的全局认识会清晰很多。
7.4 深入实践建议:自己动手写几个真实类
看再多示例,都不如自己动手写几遍。我建议你去实现这几个小功能,做完之后 Class 的基础就算打牢了:
- 写一个 Stack 类,支持 push、pop、peek、isEmpty 方法,内部用数组存储数据
- 写一个 EventEmitter 类,支持 on、off、emit 方法,用来做简单的事件发布订阅
- 写一个 Animal 类,派生 Cat 和 Dog 子类,重写各自的声音方法
每个小练习都会让你更熟悉 Class 的语法和设计思维。过程中遇到报错不用慌,对照这篇文章里的常见问题去排查,基本都能解决。
8. 写在最后:我的真实体会是……
接触 JavaScript Class 这两年多,我最大的体会是:语法只是表面,真正值钱的是面向对象的设计思维。Class 给了我们一种更自然的表达方式,但能不能设计出好用的类,仍然取决于你对业务场景的理解。
如果在实际项目中遇到“这个功能到底要不要抽成 Class”的纠结,我会先问自己三个问题:它有没有自己的状态?它需不需要被创建多个实例?它和现有代码之间有没有稳定的复用关系?如果有至少两个答案是肯定的,那用 Class 通常是合适的。如果只是单纯想封装几个函数,那就不要硬上 Class。
最后再分享一个小技巧:写 Class 的时候,尽量让 constructor 只做“接收参数 + 初始化属性”这两件事,把复杂的业务逻辑抽到方法里。这样类的结构会非常清晰,其他人读起来也轻松。你可以在自己的代码里刻意练习一下,坚持一段时间,会发现类设计越来越顺手。