前端开发中 class 没死:从原型链到组合式函数的演进与选型

发布时间:2026/10/4 4:34:21
前端开发中 class 没死:从原型链到组合式函数的演进与选型
不用纠结那个标题我先直接说结论class 没有死也没有被“主流抛弃”它只是从“默认选项”变成了“可选项”。现在打开 GitHub 上那些新起的 TypeScript 项目函数式写法、组合式函数几乎是默认风格class 出现的频率确实低了很多。但这不代表 class 没用了更准确的说法是前端在工程层面把 class 用在了更合适的地方。我为什么会想聊这个话题因为最近我在一个群里看到有人把这个疑问抛出来底下讨论得很热闹。有人说“我工作两年没写过 class”有人说“公司老项目满是 class 根本不敢动”两边都不是同一份代码。这种分歧其实特别有代表性它背后是 JavaScript 生态从“模仿 Java 的面向对象”转向“组合优先”的真实过程。这篇就按我自己写代码和维护项目的经验把 class 的前世今生、适用场景、踩坑记录和选型思路一次说清楚。1. 为什么你会产生“class 没人用”的错觉1.1 新项目和老项目完全是两个世界如果你只看 2020 年以后创建的前端项目尤其是 React 应用你会发现 class 确实很少见。React 官方从 16.8 推出 Hooks 之后新文档、新教程、新示例几乎全部改成函数组件useStateuseEffectclass 组件被降级成“legacy”概念。更年轻的开发者一入行接触的就是函数组件写 class 的机会自然少。但这只是“新项目世界”。你随手打开任何一个 2016 到 2019 年之间启动的中大型后台项目里面到处是 class业务基类、工具类的静态方法、 axios 封装、状态管理仓库全是 class 写法。这些项目还要维护很长时间不可能推翻重写。所以“没人用”和“代码里全是 class”可以同时成立取决于你站在哪一代项目里看问题。1.2 前端写法的风向标变了从“抽象复用”到“就近复用”早些年 JavaScript 在服务端和前端都大量借鉴 Java/C# 的面向对象思想class 是组织代码的核心工具。那时候写页面逻辑习惯先把功能抽成一个基类比如BaseTable、BaseForm然后页面去继承它。这种设计解决了当时的一个大问题让相似页面少写重复代码。现在前端的主流思路变了很多。组件化框架普及之后一个页面就是组件的组合不同能力被拆成 Hooks 或组合式函数挂在哪个组件就用哪个组件的能力。这种“就近复用”方式比继承更直观而且不会因为多层继承把逻辑绕成迷宫。所以新代码里自然少了很多extends BaseXXX的写法class 出现频率随之下降。2. class 拆开看本质它只是原型链的语法糖2.1 没有 class 之前的 JavaScript 怎么写对象要理解 class 为什么被一部分人嫌弃、又被另一部分人依赖得先看它的底层机制。在 ES6 之前JavaScript 实现“类”靠的是构造函数加原型链。最常见的写法是这样function Animal(name) { this.name name; } Animal.prototype.sayName function () { console.log(this.name); };然后用new Animal(旺财)创建实例实例调用sayName的时候实际走的是Animal.prototype上的方法。所有实例共享同一个原型对象不会每个实例都复制一份方法这是原型链设计省内存的核心思路。ES6 的class本质上是将这个写法包装成了更像传统面向对象的样子class Animal { constructor(name) { this.name name; } sayName() { console.log(this.name); } }两种写法创建的实例行为几乎一致。class 的constructor对应构造函数类体里声明的方法对应Animal.prototype上的方法static关键字则对应构造函数自身的属性或方法。所以你在 class 里写的实例方法其实拿到的就是原型对象上的函数这决定了后续很多行为的理解方式。2.2 class 帮我们隐藏了什么又暴露了什么class 语法最大的贡献是让“构造函数原型方法”的写法更整齐也补上了new之外的严格模式约定。你在 class 内部直接定义方法不需要反复写类名.prototype.方法名 function代码块内方法都是不可枚举的且整个 class 块默认处于严格模式。但注意class 并没有改变 JavaScript 的对象模型。它既不是像 Java 那样编译期生成真正类型也没有引入传统的“类继承”机制。extends底层走的依然是原型链子类的super()调用的本质是“以当前实例为 this 调用父类构造函数”。很多看起来像 Java 的东西行为上还是 JavaScript 那套。有一个非常能说明问题的点typeof class {}返回的是function因为 class 本质上就是一个构造函数。理解这一点之后很多困惑会解开class 的方法定义在原型上所以实例方法可以被解构出来当一个普通函数用class 的字段声明在实例上所以每个实例独立拥有自己的状态。这些行为对写业务代码的人来说很重要后面讲坑的时候还会提到。2.3 class 和普通函数在“判断类型”上的关系class 既然本质是函数那判断对象类型最常用的办法就是检查它的原型链上是否存在某个类的原型。instanceof运算符就是干这个的class Animal {} const dog new Animal(); console.log(dog instanceof Animal); // true这个特性在实际业务里很有价值。比如你写一个缓存模块内部要区分哪些对象带过期时间、哪些是永久缓存你完全可以在对象的原型上挂一个标记类然后用instanceof判断。用纯对象加一个字符串字段也能实现但instanceof的好处是它是语言内置的、不会因为字段名冲突而误判。当然instanceof也有坑比如跨 iframe、跨包重复实例化的场景下同一个名字的两个类原型不相等instanceof会返回 false。我遇到过用 npm 装的包内部依赖了一个重复安装的同一个库然后实例判断失败这种问题排查起来很隐蔽。遇到这种情况一般会改用内部标记字段或者Symbol标识来判断而不是依赖instanceof。3. class 依然好用的几个硬核场景3.1 需要“状态和行为强绑定”的对象不是所有代码都能用纯函数优雅解决。当你需要表示一个拥有内部状态、同时提供一系列操作方法的实体时class 的写法最直白。典型的就是播放器封装、拖拽交互对象、WebSocket 连接管理器、扫码枪事件聚合器。我举一个实际例子。我之前封装过一个日志上报器它内部维护一个队列攒够 10 条或者超过 5 秒就上报。如果不用 class我会写成闭包工厂function createReporter(endpoint) { let queue []; let timer null; function push(item) { queue.push(item); if (queue.length 10) { flush(); } else if (!timer) { timer setTimeout(flush, 5000); } } function flush() { if (queue.length 0) return; send(endpoint, queue); queue []; clearTimeout(timer); timer null; } return { push, flush }; }用 class 写结果也差不多class Reporter { constructor(endpoint) { this.endpoint endpoint; this.queue []; this.timer null; } push(item) { this.queue.push(item); if (this.queue.length 10) { this.flush(); } else if (!this.timer) { this.timer setTimeout(() this.flush(), 5000); } } flush() { if (this.queue.length 0) return; send(this.endpoint, this.queue); this.queue []; clearTimeout(this.timer); this.timer null; } }两种写法功能完全一致。闭包版的好处是不需要担心this被外部拿走class 版的好处是调试的时候在 DevTools 里能看到清晰的类名和状态结构而且如果需要扩展能力比如加一个destroy方法或者记录上报时间结构上更容易扩展。这种场景我个人的体会是类更“像一个东西”闭包更“像一个函数集合”两者没有高下之分。3.2 自定义异常类这是 class 最值得推荐用的地方之一JavaScript 内置的Error类只是代表“出错了”但实际业务往往需要区分“网络错误”“参数校验错误”“业务规则冲突”。用 class 继承Error是最干净的做法class ValidationError extends Error { constructor(message, field) { super(message); this.name ValidationError; this.field field; } } class TimeoutError extends Error { constructor(message, timeoutMs) { super(message); this.name TimeoutError; this.timeoutMs timeoutMs; } }然后在上层捕获的时候用instanceof区分错误类型做不同处理。如果不写 class你也可以创建普通对象{ code: VALIDATION_ERROR, message: xx }但这样抛出来的不是一个 Error 实例堆栈信息会缺失调起调试工具也不方便。这里有一个关键点用class XxxError extends Error定义一个自定义错误在现代 JavaScript 里能正确继承内置类型的行为。如果你用的是 ES5 时代的function XxxError() {}方式模拟继承反而容易遇到instanceof失效的问题。所以 class 在“定义错误类型”上是少有的、比旧函数写法更稳的用法。3.3 框架或规范要求 class 的场景该用就用有一些场景属于“你不用 class 就融不进生态”。最典型的是类组件时代的 React想要使用shouldComponentUpdate、getSnapshotBeforeUpdate这类生命周期钩子就必须写 class 组件。还有 Angular 里的服务和组件依赖注入的设计高度依赖 class 的装饰器能力不用 class 根本跑不起来。再有一个常见例子是工具库的“插件基类”。许多开源库会要求你继承它的PluginBase类在子类里实现固定方法。这种“定制一个接口”的场景class 的表达方式最清晰。你说它是不是一定非用 class 不可不是如果库作者愿意他完全可以把插件定义成普通函数然后接受一个配置对象。但生态既然选择了 class作为使用者跟着规范走就好。我个人判断的原则很简单当你要实现的对象在生态里被期待表现为“可以被 new、可以判断类型、可以延续既有基类能力”那就用 class。当它只是临时拼逻辑、没有强状态连接就用函数。4. 为什么 class 会招人烦四个高频踩坑现场4.1 this 丢失是 first-class 的坑排第一没悬念class 实例方法里的this指向是动态的方法本身只是定义在原型上的函数。一旦你把这个方法单独拿出来用比如作为事件回调、传进 setTimeout、解构赋值给变量this就不再指向实例。最常见的晾晒现场是这样的class Counter { constructor() { this.count 0; } increment() { this.count; } } const counter new Counter(); const handler counter.increment; handler();调用handler()的时候方法内部的this指向的是undefined严格模式下直接报Cannot read properties of undefined (reading count)。换成函数组件里你自然不需要关心这个问题因为闭包变量根本不涉及this。要想避开这个坑有几种做法绑定版本的写法是在构造函数里手动this.increment this.increment.bind(this)或者用箭头函数字段比如increment () { this.count }或者每次传递的时候包一层箭头函数() counter.increment()。招都有但招越多说明这个语法糖越需要小心翼翼。这大概是很多人转向函数式写法的直接导火索。4.2 继承层级太深代码变得像俄罗斯套娃class 提供了extends但它没有限制你 extends 多深。刚接触面向对象的开发者容易沿袭后端思维搞一个BaseEntity下面分BaseUser、BaseOrder再往下分AdminUser、VipUser……前期看着结构清晰加需求到后面就变成噩梦。想改最底层基类的一个方法你根本不知道会影响多少层子类。我参与维护过一个老后台它的表格基类继承链有五六层每个页面继承最近一层然后重写几个方法。看起来复用度很高实际上每次加一列、改一个展示逻辑都要沿着继承链层层排查因为你不知道哪一层偷偷改了返回值。后来我们重构时把这些基类拆成了若干组合式函数每个页面按需引入反而少了大量隐性依赖。这里给一个非常朴素但实用的经验如果继承深度超过两层而且子类不满足“子类是父类的特殊化”这种关系那继承大概率用错了。class 不代表必须继承写 class 也可以只用组合只是很多人的惯性思维太强。4.3 私有字段的跨浏览器兼容性让人心累class 的私有属性有两种写法。老一点的习惯是约定用下划线_count但那只是约定外部照样能访问。新标准提供了#count的私有字段语法class Wallet { #balance 0; deposit(amount) { this.#balance amount; } balance() { return this.#balance; } }#语法在主流现代浏览器里已经支持得很好Node 环境更没什么问题。问题在于一些需要支持旧浏览器、但又不想做复杂编译映射的项目里#字段会直接导致语法错误。而使用WeakMap存储私有状态的方式写起来很绕大多数人嫌麻烦干脆就放弃了真正的私有性。所以你会看到很多项目里class 的“私有”字段依然靠下划线约定这有风险但大家都接受了。在我看来不像 Java 有语言级私有JavaScript 的私有一直是“解释性私有”。如果你真的在意封装可以自己写一个工厂函数把状态放进闭包那东西外部是碰不到的。class 在这个角度上没有占到便宜这也是不少人觉得 class 不够“纯粹面向对象”的原因。4.4 super 的顺序和 new 的硬性要求extends子类的构造函数里调用super()和访问this的先后顺序有严格限制。你不能在super()之前操作this否则直接报错。对不熟悉这个规则的人报错信息也不算友好。class Parent { constructor() { this.type parent; } } class Child extends Parent { constructor() { this.name child; // 报错 super(); } }另外class 构造函数只能通过new调用不能像普通函数那样直接调用。设计上这是为了规避一些旧函数把构造函数当普通函数调用引发的混乱但也会吓到从别的语言转过来的人他们觉得“这不就照搬 Java 吗为什么这么多限制”。5. 新代码里到底该不该写 class一份决策表格这个问题没有人能替你一刀切我给一个自己项目里实际在用的判断清单几乎每次写新的业务模块我都会过一遍这个清单已经帮我避掉很多后来才爆的雷。问自己三个问题第一这个对象是不是有明确的内部状态并且这些状态和方法天然绑定第二我是否需要利用instanceof对它做类型区分第三未来的使用方是否需要继承它或者重写它的方法如果三个回答里有两个是肯定的class 是合理的。如果主要动机只是“把相关函数打包成一个模块”那闭包或者普通对象就够了。决策条件推荐写法理由需要挂载多个状态并有一组操作这些状态的方法class 或闭包工厂class 可读性更强、便于调试需要对外暴露类型并配合instanceof判断class原生类型机制支持良好只是将若干纯函数按领域分组export 的函数模块简洁、可 tree-shaking需要跨组件共享一段逻辑且与组件生命周期相关组合式函数 / Hooks和框架贴合更好必须遵循特定库的插件基类规范class extends生态要求必须这么写除这些方向之外还有一个很实际的因素是团队约定。我们团队里历史的 class 代码不少但新代码默认函数优先。这个“默认”不是强制而是一种代码评审共识。如果谁想用 class需要在评审时说清楚为什么这个场景 class 更合适通常只要能说通我们不会拦。你会发现当你把“能不能用 class”变成“该不该用 class”问题就健康多了。我还想强调一点现代 JavaScript 引擎对 class 和函数闭包的性能差异已经打磨得非常小。如果你在网上看到有人说 class 慢、闭包快除非你在做一个性能极其敏感的热路径否则这个差异基本可以忽略。真正影响性能的是你实例化的次数和每次分配的对象大小和语法结构关系不大。6. 从旧代码到新代码的一种过渡思路6.1 不急着推翻重写先拆出“纯逻辑”如果你的老项目里有大量 class其中有很多继承结构很深的代码我劝你不要头脑发热去重构掉所有 class。一次全量重写几乎必然引入回归 bug尤其在没有充足测试覆盖的项目里风险极高。一个稳妥的过渡方法是先把 class 里那些不依赖实例状态的逻辑抽出来变成纯函数导出。比如表单校验、日期格式化、字段映射这些往往只是用this取一下字段再返回结果。这类方法改成普通函数后既容易被测试覆盖也让 class 变得轻薄。然后再看剩下必须有状态的核心对象想想它们是不是真的适合 class。我经历过类似的重构把一个三百行的UserManagerclass 拆成了一个 createUserManager 工厂函数加几个纯函数。最终外面调用方的代码几乎没变还是const manager createUserManager(config)然后manager.login()但里面的逻辑从this.xxx xxx变成显式局部变量排查问题时不用再到处找this指向谁了。6.2 让 class 和函数式写法共存不需要二选一这俩从来不是对立关系很多人把它们想成非黑即白。实际代码里最常见的其实是 hybrid外层是函数或类内部某些方法又是纯函数。你封装一个类用来维护状态它的某个私有方法内部调用了外部导出的纯函数做计算这种模式既不牺牲状态管理的清晰度又能保留函数式写法的可测试性。我提问开头提到的document.querySelector(video).style.rotate -90deg这种写法能一口气看懂的人多半不在乎是 class 还是函数他们更关心哪个方式改起来快。这个细节其实也说明了问题前端业务一旦落到具体的 DOM 操作、样式调整、事件响应上class 提供的“高大上抽象”经常不如图表里的一个函数回调来得直观。抽象层级不是越高越好够用最舒服。7. 关于 class 的常见问题快速查一遍我把这几年工作中被同事问到的、还有我自己踩过的一些问题整理成了一份速查表。有些问题看着基础但只要你接触 class 就可能遇到收藏了准没坏处。问题原因解决办法方法被当作回调执行时 this 变成了 undefined方法只是原型上的函数this 取决于调用方式绑定、箭头函数字段、包装回调子类构造函数里不能提前访问 thissuper 必须在 this 之前调用这是语言限制先调用 super()再访问 this用 class 定义自定义错误后 instance 判断失败继承链被原生构造函数破坏常见于编译目标太低使用 ES6 的原生 class extends Error并设置 name 属性私有字段#xxx在旧浏览器报语法错误该语法是 ES2022 特性老浏览器不支持降级编译或改用闭包弱私有class 内部方法调试时看到大段堆栈方法在原型链上DevTools 展开层级多利用断点定位源码或考虑函数式实现还有一个我特别想提醒的细节new出来的对象它的方法默认就是可枚举属性放在原型上的但字段声明可以直接写在 class 体里class Example { data []; constructor() { this.data.push(init); } }这种写法在写法上直观地声明了“这类对象会有什么数据”很多从 Vue 2 转过来的开发者会觉得特别熟悉因为 Vue 2 的 data 选项也是类似结构。配合 TypeScript 的可见性修饰符class 也能写出类似强类型风格的代码。实际上现在即便写 class很多项目也会加上 TypeScript用来弥补 JavaScript 原生缺少的接口能力和类型标注。8. 一个小技巧用 class 的地方学会“压缩继承放大组合”我最后分享一个非常实际的经验。如果你还是要用 class试着把“继承”的使用频率压到最低。class 可以做的很多事通过依赖注入或者组合都能做得更清晰。比如你需要两个类共享一段逻辑别急着抽一个父类。更稳的做法是抽一个函数或者在类里持有另一个对象的实例调用它来完成。这叫组合优先。它不是函数式写法的专利class 内部照样用组合。当你在 class 里这样写了保留 class 作为“对象形态”但不再让继承链蔓延既保住了 class 的可读性也避开了继承带来的强耦合。拿我之前处理过的一个任务队列举例一开始我写了一个TaskQueue类后来需要“带重试的队列”和“带限流的队列”。第一反应是各写一个子类继承TaskQueue。后来发现重试和限流都是“增强行为”把它们抽成两个可组合的中间件函数挂在 class 内部更好。外层使用者依然实例化TaskQueue只是传进来的配置里有retry和throttle选项。这样做下来三个类变成一个类加两个纯函数测试也简单了。这类重构做多了以后我对 class 的态度是它不是必须封印的危险品也不是荣耀加身的银弹。它是 JavaScript 提供的一种组织代码的方式好用与否取决于你是否把它的能力用在了它的能力边界之内。继承和this是它的双刃剑状态和方法绑定是它的舒适区超出边界就要小心了。