
看到这个标题我先愣了一下因为它问到了很多前端开发者心里都模糊过的一个地方class 到底还行不行我说句实话只要你的项目里还跑着 React 16 之前的类组件、或者任何一个用 TypeScript 写的后端服务甚至只是 node_modules 里某个老一点的库你天天都在和 class 打交道。只是到了业务代码这一层很多人确实不主动写 class 了转而用函数、hooks、对象字面量把逻辑拼起来。这个观察没有错但这跟“class 没用”是两码事。今天就把这个话题从头到尾拆一遍包括它的原理、新特性、到底该在哪些场景用、哪些场景果断别用以及我在实际项目里踩过的那些和 class 有关的坑。1. 为什么你会觉得“没人用 class”了1.1 框架层面的错觉React 和 Vue 的双重夹击先说 React。2014 到 2018 年那阵子class 组件就是 React 的绝对主流componentDidMount、componentDidUpdate、shouldComponentUpdate 这几个生命周期方法当年背得比自己的生日还熟。但 hooks 出来之后函数组件一下子把类组件在 UI 状态管理上的优势全消解了。你再也不需要为了一个局部状态去写 constructor、绑定 this、维护一整套生命周期一个 useState、一个 useEffect 就能搞定。于是新项目里用函数组件已经成了默认选择class 组件逐渐只出现在老项目维护和面试题里。Vue 这边也是类似的路数。Vue 2 时代还有不少人用 vue-class-component 写 Options API 的 class 风格组件到了 Vue 3 全面推 Composition API你打开任何一份新教程看到的都是 setup 语法糖和响应式 APIclass 风格基本被官方架空了。框架层面都不怎么给 class 站台了日常写业务的前端自然就产生了“现在没人写 class”的直觉。但这里要分清楚框架层的 API 选择不代表 JavaScript 语言层面的 class 就没用了。框架可以给你提供新的组织代码的方式却不能改变 JavaScript 本身的原型模型和面向对象能力。就像你住进了一套精装修的公寓不代表钢筋水泥和梁柱结构就没有意义了只是你在日常居住时不会再盯着它们看而已。1.2 社区话语权被函数式风格拿走了一大半另一个很重要的原因是JavaScript 社区过去十年的审美倾向是越来越“函数式”的。不管是 lodash/fp 的流行、RxJS 对响应式编程的普及还是 Redux 里 reducer 那种“纯函数处理状态”的模式都在训练开发者用函数组合、数据映射的思路来写代码。函数永远比 class 更容易测试因为没有内部状态传入什么就返回什么断点一打心智负担极低。再加上现代前端工程里 TypeScript 已经成了标配很多以前只有 class 才能表达清楚的“约束”现在用接口interface和联合类型就能完成。比如你要定义一个 API 返回的数据结构一个 interface 摆在那边就够了根本不需要为了类型约束去建一个类更不需要 new 出来一个实例。这种情况下class 在业务代码里的存在感确实被大幅压缩了。所以当你刷到那种“现在谁还在用 class”的帖子刷到一百条面试表演示用函数式重写类组件的视频你的认知就会被强化好像 class 已经是个历史遗留物了。但真实的生产环境里它依然承担着一大批基础设施和复杂业务模型的重任只是这些场景很少被写进炫技的博客里。2. class 真的过时了吗先看它的工作原理2.1 class 只是语法糖但糖也有糖的价值想判断一个东西是不是过时先看清楚它到底是什么。class 在 JavaScript 里的本质仍然是基于原型链的构造函数。你写 class Foo它底层做的事情和以前的 function Foo Foo.prototype.xxx 几乎是同一套机制。ES2015 引入 class 不是为了创造一种新的对象模型而是为了让构造函数写法更整洁尤其是让继承的写法变得可读。我用一个最直白的例子来讲。以前用函数写一个“人”的构造函数是这种画风function Person(name) { this.name name; } Person.prototype.say function () { console.log(你好我是 this.name); };而用 class 写则是这种画风class Person { constructor(name) { this.name name; } say() { console.log(你好我是 this.name); } }两者几乎等价但 class 版本把构造逻辑和原型方法放在同一个块里结构上清晰得多。这种“语法糖”不能因为它是糖就觉得廉价它降低了阅读成本和新人上手成本也给了引擎更明确的语义去优化。很多喊着“class 过时”的人其实本质上是想说“我不需要继承”这在一些场景下是对的但不能把“我不需要”和“这东西不存在价值”混为一谈。2.2 new、this、superclass 背后三条不能跳过的原理线要真正判断 class 适不适合你的项目你得先弄明白 new 到底发生了什么。很多人用 class 用了好几年new 一个实例出来之后就只会调方法了但机制不清不楚导致 this 一丢就抓瞎。new 一个 class 实例jsx内部大概做了这样几件事创建一个新对象把新对象的原型链指向构造函数的 prototype执行构造函数体内代码并把 this 绑定到新对象上最后返回这个新对象如果构造函数没显式返回对象的话。很多人遇到“this 变成 undefined”的问题就是因为忘了这种绑定只在 new 调用时才存在。当你把类的方法单独抽出来当一个回调函数传出去比如 setTimeout 或事件监听器里直接写 this.xxxthis 就会按调用位置重新判断跟 class 本身没关系是 JavaScript 的函数调用规则使然。再看 super。class 的子类构造函数里必须先调用 super() 才能访问 this这不是什么流程性的要求而是原型机制决定的。子类实例的构造依赖父类先完成实例字段的初始化父类的构造函数执行完了子类才能在它基础上继续添加自己的属性。不理解这一条你会在报错“Must call super constructor in derived class before accessing this”的时候一头雾水然后靠猜来调代码。还有一点容易被忽略class 里的方法定义默认就是不可枚举的而普通构造函数写在 prototype 上的方法是可枚举的。这意味着 for...in 遍历实例的时候class 方法不会冒出来。这个差异在很多老的浏览器兼容逻辑和序列化工具里会造成完全不同的行为。你没主动留意过的话迟早被这种隐坑绊一跤。3. class 的新能力私有字段和静态块的春天3.1 class fields 和真正意义的私有属性如果你记忆里的 class 还停留在“constructor 里写属性 prototype 上挂方法”这个阶段那你可能错过了 ES2022 前后的几个重要更新。现代 class 已经在语言层面支持类字段class fields、私有字段、静态初始化块这些能力它们让 class 的竞争力重新上了一个台阶。类字段的写法让你不用在 constructor 里逐个赋值了直接在类体里声明属性就行class Counter { count 0; step 1; increment() { this.count this.step; } }这个写法最直观的好处是一个类有哪些状态一眼扫过去就知道不用去构造函数里翻。更关键的是私有字段用井号开头定义比如 #count。这是 JavaScript 第一次在语言层面真正实现私有属性而不是靠约定俗成的下划线 _count 假装私有。class Counter { #count 0; increment() { this.#count; } getCount() { return this.#count; } }外面的代码直接访问 counter.#count 会在语法层面就报错这是编译器/引擎层面的硬约束比用 WeakMap 模拟私有变量或者靠闭包去藏状态来得干净得多。WeakMap 的方案虽然也行但可读性差而且每个属性都要单独建一个 WeakMap。闭包方案则是把状态锁死在构造函数里无论你想在原型方法里访问还是想让多个方法共享都麻烦。私有字段还有一个很容易被忽略的特性它不参与 Object.keys、for...in 这类枚举操作JSON.stringify 序列化它也拿不到。这个特性在状态同步和日志输出时特别好用比如你有个内部缓存不想被带到服务器端的日志里用 # 修饰就能天然挡住。不过它也意味着你没法拿到一份“包含私有字段的纯数据快照”要用 getter 或者 toJSON 手动导出来设计时得把这个账算清楚。3.2 static block 和装饰器class 的进阶姿势除了字段和私有属性现代 class 还支持静态初始化块。你是不是经常需要在 class 定义完成之后做一些一次性的静态数据准备以前的做法是在 class 外面手动写一行设置逻辑比如 Foo.ready prepare(Foo)。问题在于这段初始化代码和类的定义之间没有强绑定关系换文件、改顺序、tree-shaking 时很容易出岔子。静态初始化块就是来解决这个问题的它写在类体里面用 static 关键字加一个块class DataRepository { static cache new Map(); static { // 这块会在类定义完成后立即执行一次 // 可以在这里做一些需要依赖静态字段的准备工作 const ttl 60_000; this.cache.set(defaultTTL, ttl); } }这个块里的 this 指向类本身并且它能访问静态私有字段。换句话说你可以在类内部完成“类级别”的初始化逻辑不需要把它泄漏到外部作用域。对数据库连接池、配置加载、路由注册这类场景静态块用起来很顺手类定义里面就把事情说清楚了。装饰器更是 class 场景里的大杀器。虽然 JavaScript 标准的装饰器提案磨磨蹭蹭了很长时间但 TypeScript 里早就把装饰器和反射元数据的能力用得很透了。依赖注入框架里 Inject、Injectable 是 core 级别的能力NestJS 一套后端架构全建立在类装饰器的方法上。你去看任何一份 NestJS 的项目代码controller、service、module 到处都是 class只是你平时看见它们时可能没意识到自己正在用 class。这也是为什么很多前端会误以为 class 没用了因为你只看自家前端页面那层 UI 逻辑不看整个工程的服务器端或者业务基础设施层。4. 场景化决策什么时候用 class什么时候果断放弃4.1 class 适合承担领域模型和复杂状态结构的家伙我自己的判断标准很朴素当你需要维护一组“数据 状态关联行为”的时候class 依然是很顺手的工具。尤其是那种有内部状态、行为又依赖状态的对象你在函数式写法里会需要套一堆闭包或者传参用 class 反而正在点子上。举个例子我做过一个基于 canvas 的交互编辑器里面有很多图形元素每个元素都有自己的坐标、缩放、旋转角度、透明度、边框样式还有对自身的操作行为比如 drag、resize、rotate。这种需求如果用函数式去写你得把整个状态对象传来传去每个方法都要接收完整 context用 class 写每个图形实例自己带着 state行为直接挂在自己的原型上逻辑清晰测试也方便。再看接口对接层的场景。你在做数据模型时比如一个订单模型它不只是一堆字段它还需要有计算属性比如订单总价、折扣后价格、剩余支付时间。这种跟数据强绑定、可复用的行为放在 class 的 getter 和 method 里非常自然。类型上还能配合 TypeScript 做完善的字段约束。TypeScript 项目里还有一个特色场景值得关注利用 class 的工具类型。TypeScript 的 typeof、InstanceType、ConstructorParameters 这些类型操作很多都是围绕构造函数和实例设计的。你在类型体操里要想提取一个构造函数的返回类型或者从类里抠出一个字段的类型class 就比普通对象字面量好使。老实说现代 TS 项目里每三个类型定义就有一个在跟 class 打交道。4.2 直接别用 class 的场景纯工具函数和一次性流程反过来我见过很多痛苦的翻车现场就是不该用 class 的时候硬上了 class。接下来列几个我看到就会头疼的情况。纯工具函数集合。有人会把日期格式化、字符串截断、防抖节流这类工具收拢成一个 Tool 类然后到处 new。问题在于这种类没有任何状态方法也不依赖 this每次 new 出来是一件毫无意义的事情。挂着一堆静态方法都比一个实例好但更好的做法是直接导出一个个命名函数。这里用 class 的唯一作用是给代码加了个没必要的包装。一次性流程控制。比如你只在某个页面里用一个状态机这个状态机整个生命周期只有几十行代码你就把它写成一个 classconstructor 里初始化一堆字段再在页面上手动 new 一次。其实这种场景用闭包工厂函数或者一个 useState 加 useReducer 就能搞定。滥用 class 之后变量生命周期变长状态跟踪更困难代码拆分也更碎。还有最经典的一种乱象为了“面向对象设计”而拼继承。我见过一个项目里写了一个 BasePage然后搞了三个层级每层继承都在加代码第六层之后逻辑已经纠缠到不可维护。这种“继承灾难”进一步加深了人们对 class 的偏见。本质上不是 class 过度是继承被过度使用了class 只是承担了这个骂名。优先用组合、接口和普通对象来解耦当继承真正能带来一致性和复用价值时再动它。5. 在线项目里常见的问题和排查实录5.1 this 丢失和箭头函数所有用 class 写 UI 和事件的人迟早会撞上一次“this 不是实例”的诡异现场。最典型的场景是把一个实例方法直接当成回调传给 React 或者事件监听器比如 onClick{this.handleClick}。如果没有给 handleClick 绑定上下文它执行时 this 是 undefined然后你想访问 this.setState 或者 this.someField直接就炸了。解决方案归成三类构造函数里 bind 一份定义方法时用箭头函数字段调用时在外面包一层箭头函数。现在很多项目已经把箭头函数字段当默认选项了因为写法最简洁。但箭头函数字段也有个代价它定义在实例上而不是原型上相当于每个实例都持有一份自己的方法副本不像原型方法那样被所有实例共享。如果实例数量特别大内存上会有轻微上升。多数场景不用太在意但如果你在做一个超大量对象的列表渲染还是值得留意一下的。5.2 私有字段、继承和 typeof 之间的坑私有字段的几个大坑我列成一个速查表给你参考。第一私有字段不会被 Object.assign、展开运算符复制。你做一个浅拷贝想复制实例的全部字段拷贝出来的对象会丢 # 私有字段。第二私有字段做结构打印时总是不见踪影调试时容易误以为数据丢了。第三点尤其重要# 私有字段的访问链还受类边界限制。父类不能访问子类定义的私有字段子类也不能直接访问父类的私有字段这是有意设计但如果你本来想用 protected 语义就误伤了。因此 TypeScript 里如果要设计基类让子类读写某个内部状态用 protected 字段更合适而不是 #。typeof 的坑我顺便说一下。typeof 对 class 的结果是 function因为 class 本质上还是函数。你看到 typeof Foo function 不要惊奇但要注意 class 和普通函数有个很大区别class 不能被缺少 new 的调用直接调用 Foo() 会抛 TypeError普通函数不会。所以判断一个值是不是 class 不能用 typeof得借助 Function.toString 或者看它的原型描述符这个技巧在写通用库时很常用。5.3 super 和原型链的坑super 相关的报错可能是新手朝着 class 迈步时翻得最多的一堵墙。在派生类里构造函数用 this 之前必须先调 super()这是我前面讲过的东西。但还有一个实际中很容易踩的细节super 在方法里访问父类方法时它的 this 绑定仍然指向当前子类实例。这意味着子类如果覆盖了某个属性父类方法访问 this.xxx 时拿到的是子类的值。这不是 bug而是设计使然可不少人在排查时以为父类方法里应该用“父类的属性”结果数据对不上查了半天。还有一种常见场面是 esbuild 或者 babel 转译 class 之后低版本浏览器上 instanceof 的结果出现异常。因为转译出来的代码是普通函数加原型链模拟如果有 symlink 或者两个版本的包并存的复杂依赖树一个类的实例可能被另一个包的 instanceof 判断为 false。这类问题排查思路就是先确认是不是同一份模块实例不要被多副本库误导。Class 的 instanceof 本质上是在原型链上找 prototype一旦原型链上出现分叉结果自然偏离你的预期。6. 一点个人经验如果你问我遇到具体的项目时怎么拿主意我通常按一条简单的决策线走如果目标是封装状态并附带相关行为、而且要复用相同的结构我选 class如果只是把几段逻辑串起来跑一次流程、或者做一堆无状态的工具函数我用普通函数和对象就够了。这套决策法用了好几年在 React、Vue、Node 后端、小程序、命令行工具里都没出过大的偏差。真要聊趋势class 的可见热度的确不如 2016 到 2018 年那么高但它并没有走远。你看看 TypeScript 的配置里允许装饰器的项目数量看看 antd 的组件定义风格看看 axios 内部适配器那层用 class 做的封装你就知道它依然是整个 JavaScript 生态里一块承重墙。最后分享一个自己的小习惯当你犹豫某个东西该不该用类时先写一个纯函数版本如果你的函数版本里为了传状态累到想骂人再回头看 class。这种做法既能避免为了“范式正确”硬上 class也不会因为潮流风向而无脑放弃它。语言特性没有高下之分只有用得是地方还是用错了地方class 就是这种特性的一个典型样本。