这两年带前端团队招人、做技术评审,我反复被问到一个问题:为什么现在写React、Vue,大家几乎不怎么写Class了?别说新项目,就是看开源社区,类组件的占比也在肉眼可见地萎缩。我自己的技术栈从早期jQuery时代的面向对象封装,到React的class组件,再到现在函数组件加Hooks一路走过来,说实话这个变化不是某个框架拍脑袋决定的,而是前端开发模型一次底层逻辑的切换。这篇就聊聊我观察到的趋势,以及藏在“类用得少了”这个现象背后的几个真正原因。
如果你正在学前端,或者处于“类的组件写法还能不能学”的纠结里,这篇文章应该适合你。我会把Class在前端框架里遇到的痛点、函数组件加Hooks为什么能补上这些短板、以及类到底是不是真的“凉了”,一次性讲透。
1. 这波趋势的本质:从“对象加生命周期”到“函数加状态同步”
1.1 类组件当年是怎么成为主流的
在React 16.8之前,类组件基本是复杂组件的唯一选择。函数组件只能做纯展示,一旦涉及到state、生命周期,你就必须写一个class。Vue 2那边虽然官方推荐对象字面量写法,但在TypeScript加持下,很多人也会用vue-class-component,本质上就是把Vue组件包装成class。那个年代的前端团队,面试题里必然有“React生命周期顺序”“setState是同步还是异步”“this指向怎么处理”这些经典问题,整个心智模型都是围绕“组件是一个对象,对象有诞生、更新、销毁的过程”来建立的。
我自己刚转React时,也写过大量class组件。最典型的一个业务场景是:列表页进入时拉数据、搜索条件变化时重新拉、离开页面时清掉定时器。我需要在componentDidMount里发请求,在componentDidUpdate里对比搜索条件做二次请求,在componentWillUnmount里清理,逻辑一旦多了,组件代码就像撒了一地的拼图。这还不是最难受的,最难受的是多个组件共用一套数据请求逻辑时,得用高阶组件(HOC)包裹一层,或者用render props。嵌套层数一多,你自己都分不清props是从哪一层传进来的。
1.2 函数组件加Hooks为什么能接管局面
React Hooks出现以后,函数组件突然获得了完整的表达能力。useState解决状态,useEffect解决副作用,useContext解决跨层级共享,useMemo和useCallback解决性能优化。最关键的还不是API数量变多了,而是组合方式变了:同一类逻辑可以从生命周期里“抠”出来,聚合成一个自定义Hook。
我经常用一个生活化的类比来解释这件事:类组件像一个大工具箱,所有工具都放在一个盒子里,你找一把螺丝刀得翻半天;Hooks像一堆独立的小抽屉,每个抽屉只放一类工具,要用哪个直接抽哪个,还能把几个抽屉组合成一套新工具。这个解释带过很多刚入门的新人,基本一听就懂。
Vue 3的Composition API走的也是同一条路。Options API里data、computed、watch、methods被严格划分,一个功能的代码要散落在至少四个区块里;Composition API直接用setup函数把相关的数据、计算属性、监听器写在一起。所以别单纯说“React抛弃了类”,这是整个前端框架层面的趋势,只是React和Vue 3各自以不同的语法形态完成了同一个方向上的转变。
1.3 注意:类的减少不等于面向对象思想的消失
这里需要郑重区分一件事:JavaScript里的class,和面向对象编程(OOP)思想并不是一码事。ES6的class本质是语法糖,底层还是基于原型链。前端框架减少使用class,不代表OOP被封杀了,反而在业务模型、设计模式、服务端Node.js代码里,class依然很常见。我后面会单独拿一节约来说这个问题,因为你如果心里把这个概念混了,很容易得出“前端不需要类和对象了”的片面结论。
2. 拆解Class在前端框架里的具体痛点:每个坑我都踩过
2.1 this指向问题:日常开发里最浪费时间的Bug来源
类方法默认不绑定this。这是我在团队里讲解最多、也最哭笑不得的问题。
class Counter extends React.Component { handleClick() { this.setState({ count: this.state.count + 1 }); } render() { return <button onClick={this.handleClick}>点击</button>; } }这段代码看似没有问题,但用户一点按钮,浏览器就报错:Cannot read properties of undefined (reading 'setState')。原因很简单:事件处理函数里的this不再是组件实例。解决办法不外乎三种:在constructor里bind,写箭头函数类属性,或者在JSX里写箭头函数。三种方式各有坑:bind让代码啰嗦;箭头函数类属性依赖编译插件;JSX里写箭头函数则每次渲染都会创建新函数,可能破坏子组件的浅比较优化。
这类问题在函数组件里直接消失了,因为函数组件根本没有this。useState返回的setter是稳定的引用,直接调用即可。不需要bind,不需要担心this丢失。单就这一点,就能省下新人至少两天的排错时间。
2.2 生命周期方法把相关逻辑拆得七零八落
类组件里,数据的获取、更新、清理分散在不同的生命周期方法中。复用逻辑更是痛苦:组件A的componentDidMount里有一段埋点逻辑,组件B也想用,你没法直接抽出去,只能做成HOC或者render props。
我印象很深的一个项目:一个数据报表页面,包含筛选、图表渲染、定时刷新、WebSocket推送。所有逻辑散落在componentDidMount、componentDidUpdate、componentWillUnmount里。后来加需求“筛选条件变化时重新初始化图表”,我需要同时改三个地方,漏改一个就出bug。最后重构成了函数组件,只用了两个自定义Hook就整理干净了。
这个痛点不是React独有。Vue 2的Options API同样存在:data负责状态,methods负责方法,watch负责监听,created和mounted负责初始化,一个相对完整的功能被强制拆开。Composition API的出现,本质上就是把“按生命周期组织代码”改成“按业务逻辑组织代码”。
2.3 逻辑复用:HOC vs 自定义Hook
类组件的逻辑复用一直是个老大难问题。mixin在React社区不流行,推荐方案是HOC和render props。HOC的本质是一个函数,接收组件并返回新的组件:
function withFetch(WrappedComponent, url) { return class extends React.Component { componentDidMount() { fetch(url).then(res => res.json()).then(data => { this.setState({ data }); }); } render() { return <WrappedComponent {...this.props} data={this.state.data} />; } }; }看起来很优雅,但一旦多个HOC嵌套,你很难定位props是从哪个HOC来的。你自己调试时打开DevTools,看到的组件树是withFetch(withLoading(withErrorBoundary(MyComponent))),一层套一层,心智负担很重。
如果用自定义Hook,逻辑就是直接的函数调用:
function useFetch(url) { const [data, setData] = useState(null); useEffect(() => { fetch(url).then(res => res.json()).then(setData); }, [url]); return data; }然后在组件里就是一行调用。哪个Hook负责哪段逻辑一目了然,依赖关系也清晰。这种组合模式相比HOC的嵌套,无论是可读性还是可维护性都高出不少。
2.4 函数更利于静态分析和打包优化
还有一个容易被忽视的技术原因:class的静态分析难度比普通函数大。比如某个class定义了一堆方法,其中有些方法可能根本没被调用,但打包工具难以安全地删除它们,因为class的实例方法是挂在原型上的,工具无法静态确定这些方法是否在未来被动态调用。相比之下,普通函数配合tree-shaking,凡是模块里没有被引用的导出,分析工具可以放心去除。
这个差异在大型项目里会被放大。前端框架本身为了性能和体积,也会尽量避开class这种天然不利于优化的结构。框架设计者选择函数式方向,不只是一个审美偏好,背后有实实在在的工程考量。
3. 实操对比:同一个计数器,两条技术路线差在哪
3.1 用Class实现:带加载状态和数据请求的列表
为了不空谈,我给一个具体例子。假设你要实现一个用户列表组件:进入页面加载数据、显示loading、请求失败提示错误、点击某个用户跳转详情。用类组件写,大概长这样:
class UserList extends React.Component { constructor(props) { super(props); this.state = { users: [], loading: false, error: null }; } componentDidMount() { this.fetchUsers(); } componentDidUpdate(prevProps) { if (prevProps.query !== this.props.query) { this.fetchUsers(); } } componentWillUnmount() { this.mounted = false; // 防止异步回调在卸载后setState } fetchUsers() { this.setState({ loading: true, error: null }); fetch(`/api/users?query=${this.props.query}`) .then(res => { if (!res.ok) throw new Error('请求失败'); return res.json(); }) .then(users => { if (this.mounted !== false) { this.setState({ users, loading: false }); } }) .catch(err => { if (this.mounted !== false) { this.setState({ error: err.message, loading: false }); } }); } render() { const { users, loading, error } = this.state; if (loading) return <div>加载中</div>; if (error) return <div>出错了:{error}</div>; return ( <ul> {users.map(user => ( <li key={user.id} onClick={() => this.props.onSelect(user.id)}> {user.name} </li> ))} </ul> ); } }这段代码里有个细节:componentWillUnmount里设置this.mounted = false,这个写法在当年的解决方案中非常常见,用来防止setState发生在已卸载的组件上。但它本身就是一个很别扭的补丁,它说明类组件在“异步操作生命周期管理”这件事上给开发者留下了额外的家务活。
3.2 用函数加Hooks实现:一样的场景,明显更薄的代码
同样的功能,用Hooks重写:
function UserList({ query, onSelect }) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { let ignore = false; setLoading(true); setError(null); fetch(`/api/users?query=${query}`) .then(res => { if (!res.ok) throw new Error('请求失败'); return res.json(); }) .then(data => { if (!ignore) { setUsers(data); setLoading(false); } }) .catch(err => { if (!ignore) { setError(err.message); setLoading(false); } }); return () => { ignore = true; }; }, [query]); if (loading) return <div>加载中</div>; if (error) return <div>出错了:{error}</div>; return ( <ul> {users.map(user => ( <li key={user.id} onClick={() => onSelect(user.id)}> {user.name} </li> ))} </ul> ); }你仔细对比就能发现:类组件里componentDidMount、componentDidUpdate、componentWillUnmount做的三件事情,在函数组件里被合并成一个useEffect。useEffect的依赖数组[query]自动完成了“query变化时才重新请求”的判断,清理函数替代了componentWillUnmount来防止异步setState。少写了很多防御性代码,逻辑还是聚合的一整块。
3.3 为什么团队最终选择整体迁移
我朋友的公司有一个中型后台管理系统,代码量大概三十万行,早期全部是类组件。他们做了一次渐进式迁移:新功能一律用函数组件加Hooks,老组件在需求变更时顺手重构。半年后统计,新代码占比超过了八成,团队的bug率明显下降,尤其是原来隔三差五出现的this相关报错基本绝迹了。
他们做这个决定时考虑的不只是语法好丑这类问题,我总结了三个实际动因:
- 协作成本降低。类组件里代码分布在不同生命周期,新人接手时要不停上下滚动找逻辑块;函数组件里一个功能的数据、监听、副作用全在一起,代码评审时能沿着“函数执行流”往下看,而不是沿着生命周期脑补执行流程。
- 复用效率提升。公司内部沉淀了一批自定义Hook:useAuth、useTable、useDownload,新页面拼装页面像搭积木,比之前引HOC再包一层清晰太多。
- 招人容易了。现在前端市场上,新人对函数组件和Hooks的掌握程度普遍高于class组件的各种奇技淫巧,团队的培养成本明显下降。
4. 类并没有消失:那些仍在重度使用class的地方
4.1 框架内部实现依然大量依赖类
你可能觉得“类都被框架抛弃了”,但真实情况是,框架内核里class到处都是。React的Fiber架构中,每个组件节点内部的数据结构就是一个Fiber对象;Vue 3的响应式系统里有ReactiveEffect类;Vue 3的组件实例、渲染上下文也都用class封装。框架的对外API越来越函数式,但对内实现依旧用class来组织数据结构和算法。
这一点很重要:函数编程适合描述UI界面和组件逻辑,但底层基础设施、类库设计、复杂状态管理,class依然是主力。可以这么说:类在“框架内部”没有变少,变少的是在“业务组件层”的使用。
4.2 TypeScript加持下,class依然是领域建模的好工具
如果做后端Node.js或者TypeScript全栈,class依然是很好用的建模工具。比如定义一个User实体,既有数据字段又有行为方法,用class非常自然:
class User { constructor( public id: number, public name: string, private passwordHash: string ) {} isPasswordValid(plain: string): boolean { return hash(plain) === this.passwordHash; } toSafeJSON() { return { id: this.id, name: this.name }; } }这种模型描述业务的表达能力,是单纯接口加工具箱函数不好替代的。这跟用类写React组件完全是两码事。类的问题不在类本身,而在于“让类去描述UI组件的生命周期”这件事并不合适。UI是状态映射成视图,用函数天然合适;业务实体是稳定的数据结构带方法,用类天然合适。
4.3 前端领域面向对象设计模式并没有过时
设计模式中很多经典模式,比如策略模式、观察者模式、状态模式,在前端底层库中依然用得很多。状态管理库Pinia、Redux Toolkit的Store设计,内部还是用了很多面向对象的思想,只不过对外暴露的是函数式API。你要看懂这类库的源码,还是得会读class。
我自己带新人的经验是:如果完全不懂OOP,去看前端工程化工具链的源码会非常吃力。所以面试时我依然会问面向对象基础,但考核点从“React里的class生命周期”转到了“解释原型链、继承、组合和设计模式的基本原则”上。一个只会在React组件里写class的人,和真正理解面向对象的人,对框架源码的理解深度会有本质差距。
5. 常见问题与实操心得:前端人关于类的十大疑问
5.1 类组件会被彻底删除吗
至少从React官方态度看,短期内不会。类组件仍然被支持,社区生态量太大,直接remove不现实。但React文档已经开始把函数组件当作默认推荐,新教程以Hooks为主。Vue 3同样保留Options API,不过官方推荐Composition API。所以别担心你以前写的类组件会突然跑不了,但要清醒一点:新项目、新团队,默认函数式路线是更稳妥的选择。
5.2 老项目里的类组件要不要立刻重构
我的建议是:不搞“一刀切”式的重构。老的类组件如果运行稳定、测试覆盖足够、没有频繁变更需求,就别动它。重构有一个天然风险——你可能把一个稳定模块改成了新bug。正确的做法是:在给老组件提需求、改bug时,顺手将这个小模块改成函数组件,既验证了回归,又完成了迁移,风险可控。我曾经在重构一个数据大屏时,一次性迁移了二十多个类组件,结果引入了一个因为useEffect依赖遗漏导致的轮询bug,在线上跑了一个星期才发现。自那以后,我就坚持“小步快跑”的原则。
5.3 JavaScript的class和Java的class是不是一回事
这是新人最常混淆的点。JavaScript的class是语法糖,底层是原型链,没有真正的“类”概念;Java的class是编译期的类型模板,对象由类实例化而来。前端框架不用JavaScript的class,针对的是这种“原型式语法糖”在大型UI组件场景下不顺手;而Java这类强类型语言里,class依然是中流砥柱。两者不能混为一谈。
5.4 面试还被问类组件怎么办
放心,面试官问类组件,考察的不再是“你还会写类组件吗”,而是背后的三个底层认知:是否理解组件从实例化到销毁的完整生命周期;是否理解this在不同调用场景下的指向规则;是否能从设计模式角度分析HOC和Hook的优劣。这几个问题背后对应的是你对JavaScript语言本质的理解,跟你写不写class没有关系。
5.5 我用class写Vue 3组件可以吗
可以,但没必要。Vue 3提供了defineComponent配合setup函数和TSX的写法,社区里还有vue-class-component插件可以用class风格。不过既然Composition API已经成为主流推荐,再坚持class风格意味着要额外维护装饰器配置,生态支持和文档资料都会少很多。很多情况下你会发现自己一边用class装饰器,一边又要在setup函数里写业务,混着用更难受。
6. 实战避坑:想真正用好函数式组件,这几个念头必须扭转
6.1 别再用“生命周期”的脑回路去理解useEffect
不少从类组件转过来的同事,第一反应是把useEffect当componentDidMount来用:往里面塞一堆逻辑,依赖数组传个空数组,完事。结果一旦业务需要依赖某个props,就只能改成非空依赖,然后莫名其妙踩了闭包陷阱。我建议的思考方式是:不要把useEffect当成生命周期钩子,而是当成“当依赖变化时同步执行的副作用调度器”。组件模板渲染完,浏览器更新真实DOM后,useEffect才会执行,时机天然延后,跟mounted的语义并不完全相同。想通这一点,很多诡异问题就自然而然解释了。
6.2 不要为了消除class而硬造自定义Hook
新同事经常走向另一个极端:把所有逻辑都封装成自定义Hook,组件里只剩一行useXXX,美其名曰“彻底函数式”。其实这是把之前HOC嵌套的问题换了个马甲,过度抽象同样会增加理解成本。一个自定义Hook如果只在同一个组件里被调用一次,且没有与其他Hook复用的需求,我倾向于先让它留在组件函数内部,等出现第二个使用场景再抽出来。过早抽象是比过度封装更隐蔽的坏味道。
6.3 类仍然适合做基础设施和复杂状态模型
如果你在开发一个复杂的前端业务库,比如一个富文本编辑器、一个图形编辑器、一个音视频剪辑工具,内部会有大量复杂的对象状态和操作,类的封装往往是比一坨函数更清晰的选择。比如编辑器的文档模型、选区模型、操作历史栈,天然是“状态加行为”的集合,用class管理内部状态和方法的私有性,比把一堆工具函数塞进模块里更容易维护。
我自己做过一个画布标注工具,核心模块就是用class写的:画布引擎类负责节点管理、缩放、拖拽;命令类负责撤销重做;每个图形元素是一个独立的类实例。这个场景下,状态机加命令模式用面向对象实践顺畅得多。后来给这个工具加了个React外壳,外壳用函数组件加Hooks,内部引擎还是class。两者结合得非常自然。所以说到底,函数式与类不是仇人,而是不同场景下的互相补充。
6.4 写TypeScript时,如何优雅地保留类的优势
既然团队越来越TypeScript优先,聊类的使用就绕不开TS。我认为最佳实践是:UI层用函数组件加Hooks,类型用interface或type定义props;业务模型层可以放心用class,配合private和readonly做访问控制;框架边界层少用类继承,多写纯工具函数。TypeScript的interface描述数据结构的能力很强,配合泛型,很多原本需要class的场景用interface加工具函数已经足够了。只有在需要封装私有状态、或者做动态分发时,class才更有优势。
7. 未来趋势:前后端框架里的类不会被消灭,但会进一步退到它该待的位置
结合这几年的生态演进,我认为接下来类在前端领域的发展方向是:组件层继续函数化,类作为基础设施继续默默支撑。React Server Components、Vue Vapor、Svelte这些新方向,都在加强编译时优化和更精细的响应式更新,而这些底层机制用class组织更顺手。另一方面,AI辅助编程普及后,不管是class还是函数,写代码的颗粒度都在加快,开发者把更多精力花在“组合”而不是“定义”上,这也会让偏向函数式组合的范式更受欢迎。
我从职业生涯里切身体会到:一门技术能长期存在,一定有它不可替代的价值;一门技术被替换,也不是因为它不好,而是出现了更匹配场景的替代品。类的退让,从本质上是前端把“UI描述”和“业务逻辑”的边界划分得更清晰了。UI是函数式的好土壤,业务实体是面向对象的好土壤。认清这一点,你就不会焦虑“类是不是没用了”,而是可以在具体场景里选择最适合的工具。
最后分享一个我们团队现在写React的统一规范:组件一律函数式,状态管理一律用Hook,跨组件复用一律自研Hook,只有底层库、工具链、领域模型才允许使用class。这套规则执行了两年,代码质量明显稳定,新老成员交接效率也高了很多。如果你正在为团队是否迁移到函数式而犹豫,可以从一条新的页面组件约定开始,慢慢让团队尝到甜头,再逐步推广。