“现在写 JavaScript 的是不是已经没人在用 class 这个关键字了?”
这句话我是真在一个技术交流群里看到的,当时大家正聊新项目到底要不要用 class。群里七八个人发言,超过一半说“能不用就不用”,还有人直接评价 class 是历史包袱。其实这个观点近两三年我身边也越来越常见,尤其是 React Hooks 普及以后,打开一个新写的前端项目,业务代码里确实很难撞到class关键字。但问题就在这里:JavaScript 的 class 真的凉了吗?作为既在框架层写过大量 class、又在业务层长期写函数组件的开发者,我觉得这个问题值得认真拆一拆。它的答案不是“新潮写法取代旧写法”那么简单,背后是前端范式切换、状态管理偏好、TypeScript 普及程度,以及团队工程规范共同作用的结果。这篇文章我会尽量把两边的真实情况都摊开聊,适合正在纠结代码风格、准备给团队定规范,或者准备在技术分享里把这个问题讲明白的读者。
1. 为什么现在很多新项目里已经看不到 class 了
1.1 React 把 class 组件请出了业务层
2018 年底 React 16.8 带来了 Hooks,这是整个前端风格变化的分水岭。在此之前,如果组件里要保存 state,几乎只能靠 class 组件完成。以前一个计数器组件,大家写起来都是这样:
class Counter extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } handleClick = () => { this.setState({ count: this.state.count + 1 }); }; render() { return ( <button onClick={this.handleClick}> {this.state.count} </button> ); } }那时候函数组件只能做静态展示,一旦涉及状态、生命周期就得升级成 class。Hooks 出现以后,官方文档和绝大多数开源模板都转向函数组件,同样的需求变成:
function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}> {count} </button> ); }代码短了,状态逻辑还能抽成自定义 Hook 在多个组件间复用。于是,一个非常直观的结果出现了:在 React 业务组件层,class 几乎消失。如果你只写 React 界面,确实会产生“全世界都没人用 class”的错觉。这种体感会长期影响很多新入行的前端,导致大家默认 class 是旧东西。
1.2 Vue、TypeScript 和脚手架风格也在推着大家离开 class
类 React 的问题不只发生在 React。Vue 从 2 到 3,逻辑复用的主力方案从 mixins 变成 Composition API。现在写一个 Vue 组件,大多数人在<script setup>里用ref和reactive组织数据,并不需要专门定义 class:
<script setup> import { ref, onMounted, onUnmounted } from 'vue'; const count = ref(0); const timer = setInterval(() => count.value++, 1000); onUnmounted(() => clearInterval(timer)); </script>TypeScript 全面普及后,原来“想描述一个对象类型就得写类”的诉求也被 interface 接管。interface 只描述形状,不需要构造函数,不需要 new,写起来比 class 轻不少。再加上 create-vite 这一代脚手架生成的项目模板,默认全是函数组件、普通函数、interface 类型,class 在初始代码里几乎没有存在感。这种环境对新人塑造力很强:他从第一天开始写的代码里就没有 class,自然觉得 class 已死。
1.3 热搜里的 class 经常跨语言混杂,背后是对 JS 定位的错觉
我去搜“class”相关关键词的时候,发现结果里经常混着 Java、Python、C# 的内容,比如“python 中 class 函数的用法”“c# 不同的 class 可以组成数组吗”“class 文件 overrider 注解为什么会丢失”。这其实反映出一种普遍认知:class 是跨语言通用概念,学过 Java/C# 再来写 JS,理所当然要定义 class。但 JavaScript 的设计哲学里,“必须定义一个类才能创建对象”并不是约束。ES5 时代靠构造函数加 prototype 已经能实现类似能力,class 在 ES6 只是把它语法糖化。所以 class 在 JS 生态中从来不是唯一的对象组织方式,这是它容易被“边缘化”的语言根源。理解了这一点,再看“JS 里还在用 class 吗”这个问题,就不会把它理解成“JS 里的 class 是不是太落后”。
2. class 被嫌弃的三大真实痛点:不是矫情
2.1 this 绑定劝退了很多人
class 方法里的 this 是动态绑定的,一旦方法被单独取出来调用,this 就指向不对了。典型例子:
class Counter { constructor() { this.count = 0; } increment() { this.count++; } } const counter = new Counter(); setTimeout(counter.increment, 100); // this 丢失,counter.count 不会变这段代码里,setTimeout 接收的是increment这个函数引用,调用时没有上下文,所以 this 不再指向 counter 实例。解决的办法要么是绑定:
this.increment = this.increment.bind(this);要么写成箭头函数字段:
handleClick = () => this.setState({ count: this.state.count + 1 });问题在于,一个 class 里既有普通方法又有箭头函数字段,新人不清楚为什么有的能直接作回调,有的不行。闭包式写法天然捕获环境,不会出现这种问题。对一个刚从函数式风格进入 class 的人来说,this 机制是最大的劝退点之一。
2.2 继承层级写多了真的会烂尾
class 最顺手的联想是继承,但业务继承写深了就是灾难。我见过不少团队的文件结构长成这样:BaseService -> AbstractUserService -> UserServiceImpl -> SpecialUserServiceImpl。每层都以为自己是抽象合理,实际上改一个基类方法,所有子类都要重新过一遍,一个补丁可能把某个子类的隐式行为弄坏。JavaScript 的 class 继承不像 Java 有编译期保护,更没有抽象类和接口的强约束,只能靠人肉规范维护。于是大家很快学会“组合优于继承”,并把这四个字当成反对 class 的武器。其实这句话反对的是滥用继承,不是反对 class 本身。
2.3 在组件化场景里,class 确实技不如人
React 的 class 组件有两个深坑:逻辑复用困难,以及生命周期分散。订阅一个数据源,class 版要在componentDidMount里订阅,在componentWillUnmount里取消订阅,两段代码隔着几十行,很容易漏掉另一半。函数组件一个useEffect把两个阶段收在一起,读写上下文都更集中。逻辑复用更明显,class 组件时代要用 HOC 或 render props 在组件树外面绕来绕去;函数组件时代,自定义 Hook 一抽就走。这是 UI 层抛弃 class 最根本的原因之一,不是“为了酷”,是确实好用。
3. 但翻进框架源码和基础设施底层,class 从未缺席
3.1 框架引擎层和原生 API 绕不开 class
当你说“没人用 class”时,看的往往是业务层。但只要翻开源项目源码,你会发现 class 依然是主力。React 的内核里,负责管理根节点渲染的 ReactDOMRoot、调度队列里的各种辅助对象,以及组件更新过程中大量内部实例,都存在以构造器或 class 为载体的对象;Vue 3 的响应式虽然大量用 Proxy,但调度器任务、副作用对象、渲染上下文这些需要长期保存内部状态的地方,class 同样是常见选择;Angular 就更不用说了,service、component、pipe、guard 几乎都是 class 配合装饰器在写。原因之一在于现代 JS 引擎对基于构造函数的隐藏类优化已经非常成熟,频繁创建实例的底层代码里,这条路往往比自由对象更稳。
另一个绕不开的地方是 Web Component。标准 API 规定自定义元素必须继承 HTMLElement:
class MyCard extends HTMLElement { connectedCallback() { this.innerHTML = '<div class="card">自定义组件内容</div>'; } } customElements.define('my-card', MyCard);你想在浏览器原生组件体系里做扩展,就只能用 class。说“完全没人用 class”,至少在框架源码和原生能力这两个地方就不成立。
3.2 自定义错误和领域模型,用 class 能省一大把判断代码
Node.js 后端经常需要给调用方返回更明确的错误类型。比如批量同步用户时,我希望往上抛一个自解释的错误:
class UserSyncError extends Error { constructor(failedUsers, cause) { super(`同步用户失败,共 ${failedUsers.length} 个`); this.name = 'UserSyncError'; this.failedUsers = failedUsers; this.cause = cause; } } try { await syncUsers(); } catch (err) { if (err instanceof UserSyncError) { // 针对 failedUsers 做批量补偿 } else { // 其他错误走通用告警 } }class 加上instanceof可以让错误类型在传递过程中保持可识别。如果不用 class,用普通对象模拟错误,就得反复检查err.type === 'UserSyncError',还容易和原生错误混为一谈。领域建模也一样,一个聚合根要同时保存状态和提供变更方法,class 可以把不变量约束在类内部,调用方拿到的始终是一个行为可预期的对象。这类“身份与结构稳定”的对象,硬用函数式表达反而更绕。
3.3 SDK、客户端实例和连接池是 class 的传统主场
第三方基础设施设计也大量使用 class。TypeORM 的实体模型、Sequelize 的模型类、AWS SDK 里的客户端、Redis 或 RabbitMQ 的连接客户端,几乎都是 class 实例。这类场景的共同特征很明确:实例要保存连接配置、内部缓存、并发标记,同时对外暴露一组方法;实例和实例之间相互隔离,但结构完全一致;你可能还需要静态工厂方法控制实例创建数量。class 的实例语义、私有字段、静态方法在这一整套需求里几乎是量身定制。你可以把 class 理解为“有状态服务实例的容器”,当你的项目需要这类容器时,它依然是很好用的工具。
4. 真正需要判断的不是该不该用 class,而是用它承载什么
4.1 把 class 的使用场景分成三类,争论会立刻清晰
我自己的经验是,把“写 class 的意图”先分个类,很多争吵立刻就没有意义了。大体可以分为三类:
| 用途类型 | 核心特征 | 常见例子 | 推荐写法 |
|---|---|---|---|
| 有状态服务实例 | 有内部状态、生命周期、多个方法共享资源 | WebSocket 客户端、数据库连接池、流式任务队列 | class |
| 纯工具函数集 | 内部无状态、方法之间不共享数据 | formatDate、debounce、deepClone | 普通函数 |
| 数据传输对象 | 只承载字段、不含行为、经常和 JSON 互转 | 分页参数、用户 DTO、接口返回结构 | interface/普通对象 |
在这个分类下,“class 该不该用”就变成了“这个对象的本质是什么”。需要保存身份与状态的对象,class 合适;一堆静态方法的集合,普通函数更合适;只有字段没有行为的形状,interface 更好。
4.2 “把 class 当命名空间用”是最典型的误用
我经常在项目里看到这种 class:
class DateUtils { static format(date) { /* ... */ } static daysBetween(a, b) { /* ... */ } static isWeekend(date) { /* ... */ } }它和下面这段在语义上几乎等价:
export const DateUtils = { format(date) { /* ... */ }, daysBetween(a, b) { /* ... */ }, isWeekend(date) { /* ... */ } };后者甚至更直观,也更方便按需导入和 tree-shaking。类里全是静态方法、没有实例状态时,class 提供的封装能力完全用不上,只是徒增一个语法壳。这类误用积累多了,很容易让团队得出“class 又笨又没必要”的结论。所以当有人说“class 没用”的时候,另一个可能的意思是“这个 class 用得没用”。
4.3 TypeScript 时代,class 和 interface 各管一摊
TypeScript 普及后,定义 class 不再是表达类型的唯一选择。interface 可以描述一切对象形状,而且不需要运行时信息。但 interface 和 class 并不是对立关系:
interface IUser { name: string; age: number; } class User implements IUser { constructor(public name: string, public age: number) {} }当你要写策略模式、依赖注入容器、装饰器的时候,class 作为“可实例化的运行时值集合”是必要的,interface 在运行时并不存在,无法承载这些机制。反过来,如果只是描述接口返回结构,用 class 就得写构造函数,还要考虑JSON.parse出来的对象能否顺利变成类实例,反而全是负担。想清楚“我到底需要运行时值,还是只需要编译期形状”,class 和 interface 的选择就不难了。
5. 我在实战里的平衡法:能用函数就用函数,需要实例就交给 class
5.1 我的三层代码分工
我最近维护的 Node 服务和前端项目,基本形成了一套朴素分工。前端 React 页面层几乎不用 class,组件和 Hook 全部是函数,类型用 interface 描述;数据接入层用 class 做有状态的客户端,比如数据库访问器、第三方 API 封装、流式任务队列;错误处理层定义若干 Error 子类,配合instanceof做精细化异常分支。
这里放一个我用 class 写的调度器示例:
class SyncScheduler { #queue = []; #running = false; constructor({ concurrency = 3, logger } = {}) { this.concurrency = concurrency; this.logger = logger; } push(task) { this.#queue.push(task); this.#drain(); } async #drain() { if (this.#running) return; this.#running = true; while (this.#queue.length > 0) { const batch = this.#queue.splice(0, this.concurrency); await Promise.all(batch.map((task) => this.#run(task))); } this.#running = false; } async #run(task) { try { await task(); } catch (err) { this.logger?.error(err); } } }它保存了待执行队列、正在运行标记、并发数,还对外提供 push 方法。用闭包写也可以实现,但 class 把状态声明、方法边界和生命周期表达得更清楚,私有字段还防止外部误改内部队列。对我来说,这类场景 class 是加分的,不是减分的。
5.2 给团队定代码规范时的落地清单
代码风格之争最怕一句“我不管,我就是不想用 class”。落成规则才可执行。我建议团队按这个清单约束:
- 无状态的纯函数一律写成普通函数并单独导出,不要塞进工具类。
- 有状态且生命周期明确的服务实例,才允许定义 class。
- 禁止出现三层以上继承;需要共享逻辑时优先组合或用函数抽象。
- DTO 不使用 class,统一用 interface 加 plain object。
- 错误类型例外,鼓励用 Error 子类,方便 instanceof 识别。
这样的规则新人一看就懂,PR 评审也好讨论。争议从“我喜不喜欢 class”变成了“这里是不是需要实例状态”。目标明确后,团队风格自然统一。
5.3 一个真实重构案例:我不是为了用 class 而用 class
前阵子重构一个旧系统,核心 Parser 类有三层继承,我当时第一反应也是全部拆成函数。但真正评估后,发现其中几个对象需要共享内部缓存和不可变配置,还要提供统一的解析入口。如果用闭包把所有内部函数塞进一个作用域,代码更绕,反而不如 class。最后我们做了折中:三层继承压缩成两层,纯计算的部分抽成普通函数导出,保留有状态的部分作为 class。重构完整个文件减少了很多重复代码,也没有陷入“必须函数化”的教条。这个经历让我更相信,class 和函数式不是二选一,而是看哪个更符合对象语义。
6. class 还在进化:标准在推进,使用场景在收敛
6.1 私有字段、静态块和装饰器,正在补上 class 过去的短板
class 并没有停在 ES6 那个版本。私有字段#foo已经进了标准,static initialization block 也已经可用,装饰器提案在逐步趋稳。更早的坑比如命名冲突、无法保护内部状态、静态初始化需要绕道外部函数,现在都有了原生方案。举一个简单的 SessionStore:
class SessionStore { #token = null; static stores = new Map(); constructor(name) { this.name = name; } setToken(token) { this.#token = token; } static create(name) { if (!this.stores.has(name)) { this.stores.set(name, new SessionStore(name)); } return this.stores.get(name); } }私有字段让外部无法直接读取 token,静态 Map 控制全局实例数量,这套能力在过去要写很多辅助代码才能模拟。你可以不喜欢 class,但要说它在语言层面原地踏步,那明显不是事实。
6.2 我的判断:别再说“没人用 class”,而是“各自用对了场景”
回到开头那个问题。我的个人判断是:JavaScript 的 class 不会消失,但确实会从“通用代码风格”逐渐收敛成“特定场景的工具”。业务组件层、纯函数工具层,函数式和普通对象会更普及;框架底层、原生组件扩展、领域建模、基础设施封装这些需要“实例语义”的地方,class 还会一直在。两者不是新旧替代关系,更像是分工关系。
如果你问我有没有一个可以通用的判断句式,我通常会这样问:这个对象会被创建很多次吗?它有自己的内部状态吗?它需要在生命周期里做初始化和清理吗?如果三个里有两个回答是 yes,class 就是合理选项;如果只是承载计算和转换,交给函数就好。下次再看到“已经没人在用 class”这种说法,你可以先确认对方说的是业务 UI 层,还是整个 JavaScript 生态。有人愿意继续聊的话,我会建议他从“这个对象需不需要有身份和状态”出发,而不是从“class 是不是过时”出发。