1. 从历史演变看两种组件的本质差异——为什么会有“两套写法”
先说个我面试时的真实感受:问“类组件和函数组件的区别”,十个人里有八个能说出“一个用class,一个用function”“函数组件有hooks”,但再往下追问“为什么React要把Hooks引进来”“类组件到底哪里不好”,能讲透的很少。所以这篇我不打算只列对比表格,而是从根上把两条技术路线的差异讲清楚。
1.1 React组件从“类”到“函数”的演化动因
React在2013年开源时,组件主要写法就是React.createClass,2015年ES6普及之后,class语法成为主流。那个时候函数组件是真实存在的,但定位非常尴尬——它只能接收props渲染UI,不能持有state,不能访问生命周期方法,官方文档里管它叫“无状态组件”(Stateless Component)。你要是想在函数组件里做点有状态的事,唯一的出路是把它改写成类组件。
这个局面持续到2018年React 16.8发布Hooks才被彻底打破。Hooks让函数组件能够使用state、effect、context、ref这些原本只有类组件才有的能力。这里有个值得注意的细节:Hooks不是“补丁式”地给函数组件加几个API,而是React内部对组件渲染机制的一次重新梳理。理解了这个背景,你就能明白为什么现在社区普遍默认新代码用函数组件——那不是跟风,是因为函数组件本身就是React官方在“修完历史包袱”之后推崇的方向。
1.2 设计哲学的分叉:面向对象 vs 函数式
类组件的底层逻辑是面向对象:一个组件就是一个“类的实例”,状态挂在这个实例上(this.state),方法也挂在这个实例上(this.setState、生命周期方法),通过this串联一切。
函数组件的底层逻辑是函数式:组件就是一个纯函数,接收props返回UI,状态通过hooks挂在函数的作用域上,而不是挂在一个可变的实例对象上。每次渲染,函数重新执行一遍,生成新的作用域,闭包捕获当次渲染的props和state。
这个本质差异,几乎可以解释类组件和函数组件的所有其他区别。比如:
- 类组件有
this,所以有this绑定问题; - 类组件有实例,所以可以用
ref直接拿到组件实例; - 函数组件每次渲染重新执行函数体,所以有闭包陷阱(stale closure)问题;
- 函数组件没有实例,所以
React.memo的对比模式、forwardRef的转发模式,都是为了绕过“没有实例”这个特性而设计的。
后面所有章节都是围绕这几条主线展开的,先把这条主干记住。
2. 渲染执行机制:this绑定、实例化流程和闭包陷阱
这章是很多人面试翻车的重灾区,因为平时写业务代码不容易察觉,但底层差异全在这里。
2.1 类组件的this绑定问题为什么是“绕不开的坎”
类组件里写事件处理函数,经典问题:
class Counter extends React.Component { state = { count: 0 }; handleClick() { // 这里如果直接绑定给onClick,this是undefined this.setState({ count: this.state.count + 1 }); } render() { return <button onClick={this.handleClick}>+1</button>; } }上面这段代码点击按钮就会报错,因为handleClick是原型上的方法,事件触发时是“裸调用”,JavaScript的this指向规则决定它拿不到组件实例。解决办法有三种:
- 在constructor里
this.handleClick = this.handleClick.bind(this); - 在JSX里写箭头函数
onClick={() => this.handleClick()}; - 用Class Fields语法把方法定义成箭头函数属性(
handleClick = () => {})。
为什么会这样?核心是“一个类的实例方法本质是放在原型上的函数”,当它被单独取出来调用时,和实例的联系就断了。函数组件里根本没有this,所以压根不存在这个问题,事件处理函数可以直接定义成普通闭包函数,天然能访问当前渲染作用域里的props和state。
2.2 函数组件的闭包机制和“渲染快照”本质
函数组件每次渲染,函数体重新执行,这意味着每次渲染都有自己独立的props和state副本。经典的三秒延时陷阱:
function DelayMessage({ message }) { const [count, setCount] = useState(0); const showMessage = () => { setTimeout(() => { alert(message + ',count值为:' + count); }, 3000); }; return ( <div> <button onClick={() => setCount(count + 1)}>更新count</button> <button onClick={showMessage}>三秒后弹窗</button> </div> ); }如果点击“更新count”把count从0变成1,然后马上点击“三秒后弹窗”,3秒后弹窗里显示的count还是0。为什么?因为showMessage闭包捕获的是“点击那一刻”那一次渲染的count值,而那次渲染的count就是0。这就是所谓的“渲染快照”特性,不是bug,是函数组件的设计结果。
理解这个机制很重要,很多经典的Hooks坑(比如useEffect里用了过期的state)都源于此。解决办法通常是useRef保存最新值,或者把依赖写进useEffect的依赖数组里。
类组件没有这个问题,因为this.state指向的是同一个实例对象,任何时候读this.state拿到的都是当前最新的值。但反过来说,类组件也因此容易在异步回调里不小心读到“已经变化”的值,行为更不可预测。
2.3 实例化流程的差异:从new到直接调用
类组件渲染时,React会new一个类实例,然后调用实例的render方法;函数组件渲染时,React直接调用这个函数。类组件有实例这个中间层,意味着:
- 类组件的实例会常驻,
this可以跨渲染保持引用,实例变量(比如this.timerId)不参与渲染但能跨渲染访问; - 函数组件没有实例,想要类似的“跨渲染保持可变值”只能用
useRef。
useRef的底层实现其实就是React帮你创建了一个跨渲染持久化的可变对象,等价于类组件里的实例字段。这也是为什么“useRef可以绕过闭包陷阱”的根本原因——它不随渲染重建。
理解到这里,“类组件好还是函数组件好”这个问题的答案就很清晰了:不是能力上的差距(绝大多数场景Hooks都能替代),而是心智模型的差异。类组件的心智模型是“我一直是那个实例”,函数组件的心智模型是“我每次都重新活一遍”。
3. 状态管理与生命周期:核心API的能力差异
这章是业务代码里最常接触的部分,也是面试题的重灾区。我从状态更新机制和生命周期映射两个维度拆开讲。
3.1 setState与useState的根本差异
类组件的setState和函数组件的useState返回的setter,看起来都能更新状态,但底层行为有几点关键差异。
第一,合并策略不同。
setState是浅合并的。连续调用:
this.setState({ a: 1 }); this.setState({ b: 2 });两个对象会被合并,this.state同时拥有a和b。而useState直接“替换”——setCount(1)之后,count就是1,不是合并。所以如果state本身是个对象,要手动展开:
setUser(prev => ({ ...prev, name: 'aa' }));这个差异很容易导致从类组件迁移到函数组件时漏掉展开操作,把整个对象覆盖丢了。
第二,更新副作用不同。
setState可以在类组件里通过第二个参数拿到更新完成的回调:this.setState(newState, () => { console.log('更新完成') })。useState没有这个能力,想在更新后做点事只能用useEffect监听依赖项。这个差异会让刚从类组件转过来的同学很不适应——他们习惯在setState回调里读最新值。
useState的setter还接受函数式更新:setCount(prev => prev + 1)。这个函数式更新能拿到上一次的state值,在循环或多次更新场景下非常有用。函数式更新在React内部会形成一个更新队列,依次传入prev值执行,和setState传函数是一样的语义:
// 函数式更新,连续累加 setCount(prev => prev + 1); setCount(prev => prev + 1);第三,读取最新状态的时机不同。
类组件随时通过this.state读当前最新值;函数组件在事件处理函数、异步回调里,读到的state是“当前这次渲染的闭包值”,可能是旧的。这一点我在2.2节已经用例子演示过了,这里不再重复。总结成一句话就是:类组件的state是“活值”,函数组件的state是“快照值”,你要在函数组件里拿到活值,得靠useRef加同步逻辑。
3.2 生命周期方法 vs 各种Effect的映射关系
类组件的生命周期是一条固定的流水线:constructor → render → componentDidMount → shouldComponentUpdate → componentDidUpdate → componentWillUnmount。Hooks没有生命周期“方法”,而是用useEffect加依赖数组来表达“副作用发生时机”。
我画过一张对应关系表(只能文字表述),基本映射如下:
| 类组件生命周期 | 函数组件Hooks等价写法 | 说明 |
|---|---|---|
| constructor | useState(() => initialValue)或直接初始化 | 初始化state、绑定this的场景被Hooks覆盖 |
| componentDidMount | useEffect(() => {}, []) | 空依赖数组,只在首次渲染后执行 |
| componentDidUpdate | useEffect(() => {})(不传依赖)或传具体依赖 | 不传依赖每次渲染都执行,传了依赖只在依赖变化时执行 |
| componentWillUnmount | useEffect(() => { return () => {} }, []) | useEffect的清理函数 |
| getDerivedStateFromProps | 渲染期间计算派生状态(React 16.4+的setState在渲染期间调用会有警告,更推荐直接在render里计算) | 这是Hooks场景下最复杂的一环,后面细说 |
| shouldComponentUpdate | React.memo包裹组件 | memo做浅比较,控制是否重渲染 |
| componentDidCatch | 暂无Hooks等价方案 | 只能继续用类组件写Error Boundary |
| getSnapshotBeforeUpdate | 暂无Hooks等价方案 | 只能继续用类组件 |
这个表格基本上涵盖了日常开发的所有映射关系。但有两点必须特别强调,否则你会踩坑:
第一,useEffect的依赖数组语义和componentDidUpdate并不完全等价。
类组件的componentDidUpdate(prevProps, prevState)能拿到上一次的props和state,你可以手动比较“哪些变了才做什么事”。useEffect的依赖数组是“依赖变了就执行”,相当于React替你做了比较。但有个细节:componentDidUpdate在每次更新后都会执行(除非你手动加if判断),而useEffect传空数组时只在挂载后执行一次,传[a, b]时只有在a或b变化后执行。这个“默认不执行”和“默认执行”的差异,迁移时要格外小心。
第二,不要把所有副作用的活都丢给useEffect。
React 18的StrictMode会在开发模式下故意“双重执行”effect(挂载→卸载→重新挂载),就是为了暴露副作用没写对的问题。如果你在useEffect里直接改外部变量、发请求没有清理,StrictMode下就会看到重复请求。这不是bug,是在提醒你把副作用写得“可清理、可重放”。
3.3 getDerivedStateFromProps的Hooks替代:一个实操经验
这个生命周期函数在类组件里是出了名的难用(官方文档自己都列出了它的几个常见误用场景),Hooks时代没有直接对应物,我推荐的最佳实践是:
如果派生状态可以在渲染期间直接计算,就不要存state,直接算:
function List({ items, filter }) { const visibleItems = items.filter(item => item.name.includes(filter)); return <ul>{visibleItems.map(...)}</ul>; }这是最推荐的方式,避免同步state的麻烦。
如果是受控组件场景下的存储值跟随props变化,比如
props.value变了,input的内部文本要同步,我一般在渲染时比较上一个props,用useRef存prev值判断。React官方给的经典方案是用“key”强制重置整个组件:<Input key={userId} defaultValue={userName} />key一变,整个组件重新挂载,所有state重置。这比在getDerivedStateFromProps里做同步逻辑清爽得多。
如果确实需要在props变化时执行副作用,用
useEffect监听props变化:useEffect(() => { // props.userId变化后做的事 }, [props.userId]);
4. 上下文、转发、缓存机制与性能优化路径
类组件和函数组件在这几个进阶能力上的写法差异,同样根源于“有没有实例”这个核心区别。
4.1 context:static contextType 与 useContext
类组件使用Context的方式有两种:老式的是<Context.Consumer>包裹,新式的是static contextType = MyContext,然后通过this.context访问。限制非常明显——static contextType只能订阅一个Context,多个Context就得嵌套Consumer。
函数组件用useContext(Context),想订阅几个订阅几个:
const user = useContext(UserContext); const theme = useContext(ThemeContext);代码层级和可读性都好得多。这在复杂状态共享场景里优势很明显。
4.2 ref:类实例引用 与 forwardRef + useImperativeHandle
类组件可以直接给子组件传ref拿到子组件实例,然后调用子组件的实例方法:
// 类父组件 this.childRef.current.doSomething();函数组件默认没有实例,父组件传ref拿不到子组件的任何东西。如果你要在函数子组件里暴露方法给父组件,必须用forwardRef加useImperativeHandle:
const Child = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ doSomething() { console.log('子组件方法被父组件调用'); } })); return <div>子组件</div>; });这套机制相当于函数子组件“主动声明”自己暴露哪些API给父组件,而类组件因为本身有实例,父组件拿到实例就能调用它的所有公开方法。从封装性来看,useImperativeHandle的显式声明反而更安全,避免父组件随手调子组件内部方法的隐患。
4.3 性能优化:shouldComponentUpdate 与 React.memo、useMemo的取舍
类组件的性能优化路径是:手动实现shouldComponentUpdate做深比较,或者继承React.PureComponent做浅比较。函数组件的对应物是React.memo(对比props是否变化)和useMemo/useCallback(缓存计算结果、缓存函数引用)。
这里有个常见误区:很多人觉得“用了memo就万事大吉”,其实memo做的是浅比较,如果props里有对象、数组、函数,父组件每次渲染都会创建新的引用,浅比较会认为props变了,memo就失效了。所以memo要配合useCallback(缓存函数引用)和useMemo(缓存对象/计算结果)一起用。
不过说实话,业务代码里性能优化的投入产出比没那么高,真正遇到长列表渲染卡顿、复杂表单频繁重渲染时再上memo也不迟。过早优化反而会让代码可读性变差。
4.4 逻辑复用:HOC、Render Props 与 自定义Hooks
类组件时代做逻辑复用,主流方案是高阶组件(HOC)和Render Props。这两种方案都有明显的痛点:HOC层层嵌套形成“包装地狱”(wrapper hell),Render Props回调嵌套导致JSX缩进爆炸、可读性差。
函数组件时代的答案是自定义Hooks,把可复用的状态逻辑抽成一个普通函数:
function useWindowWidth() { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { const handler = () => setWidth(window.innerWidth); window.addEventListener('resize', handler); return () => window.removeEventListener('resize', handler); }, []); return width; }用的时候一行搞定:const width = useWindowWidth()。对比HOC的话,不需要额外组件层级,不需要考虑props命名冲突,也不存在包装地狱。这是函数组件在工程化上碾压类组件的核心原因之一。
5. 实战选型:哪些场景应该选类?哪些必须选函数?
下面的内容是我在实际项目和团队代码规范落地中的经验,不是理论推导。
5.1 主流项目里的选型原则(新老代码都适用)
新代码:默认函数组件+Hooks。原因很简单——官方文档、社区最佳实践、组件库的示例代码、面试题的默认前提,全部默认这个方向。以后接手的人看到新的类组件代码会疑惑你为什么不按规范来。除非有下面说的特殊情况,否则新写类组件就是给自己和团队添麻烦。
遗留类组件:不要为了“现代化”而重写。类组件在跑、bug少、可维护,就不要动它。重写函数组件不会让业务价值提升,反而容易引入回归问题。我在实际项目里见过因为强行迁移导致状态不同步的线上事故,所以这个原则一定要记住:能用就不动。
必须用类组件的两个硬性场景:
- Error Boundary(错误边界)。React官方至今没有提供Hooks版本的错误边界API,只能靠类组件的
componentDidCatch和getDerivedStateFromError实现。如果你要做全局错误兜底,这个组件只能写成类组件。 - 需要
getSnapshotBeforeUpdate的场景。比如读取DOM在更新前的滚动位置、焦点状态等信息,Hooks没有等价方案。
除了这两个,其他场景理论上一一都有替代方案。举个例子:如果你在类组件里用this.timerId保存定时器ID,函数组件里换成useRef存同样的东西即可。
5.2 从类组件迁移到函数组件的四个高频坑
很多团队做类组件到函数组件的渐进式迁移,我总结几个踩过的坑。
第一个坑:setState合并特性丢失。类组件里this.setState({ a: 1 })不会影响state里其他字段,但函数组件如果用useState存对象,setState({ a: 1 })会直接把整个对象替换掉。迁移时要把所有setState的对象字段在setter里展开一遍,这个漏改率很高。
第二个坑:异步回调里读到旧state。类组件在setTimeout、Promise.then回调里读this.state拿到的一定是最新值;函数组件读闭包里的state可能是旧值。迁移时如果代码里有这种异步读state的逻辑,需要改成用useRef同步最新值,或者把读取逻辑挪到useEffect里。
第三个坑:依赖数组漏写导致effect不执行或多次执行。类组件的componentDidMount只执行一次,但如果你把同样逻辑丢进useEffect不传依赖数组,它会每次渲染都执行;如果传了空数组,它只在挂载时执行一次。这个语义差异在迁移时很容易被忽略,尤其是那些依赖了props但没写进依赖数组的effect,会在props更新后拿着旧值执行,排查起来非常隐蔽。
第四个坑:StrictMode双重执行效应。开发模式下所有useEffect都会执行两次(挂载→卸载→再次挂载),如果你的effect里有未经清理的订阅、定时器、事件监听,会看到重复创建、重复请求。这和类组件时期的行为不一样,迁移时确保所有副作用都有清理函数。
6. 面试和代码评审中的关键考核点
最后这部分我换个视角,从面试官和代码评审者的角度聊聊,哪些点最值得讲出来,哪些点最容易被忽略。
6.1 最容易被追问的几个盲区
盲区一:为什么函数组件不能有条件地调用Hooks?
因为React依赖“调用顺序”来匹配state和effect。每次渲染,同一个组件里useState的调用顺序必须完全一致,React才能把第一次渲染的state和第二次渲染的state对应起来。如果你在if里写useState,条件不满足时少调用一次,后面的所有hooks对不上号,state就全乱了。这就是“Hooks的调用顺序必须稳定”这个规则的底层含义。
盲区二:为什么多个state更新在React里是“批处理”的?
React 18以前的批处理仅限于React事件处理函数内部,setTimeout、Promise回调里的setState是立即执行的;React 18开始所有场景都自动批处理。这在类组件和函数组件里都生效。理解批处理能回答“为什么我的count连续加三次只加了一次”这种问题——因为三次setCount被合并成一次渲染。要绕过就用函数式更新setCount(prev => prev + 1)。
盲区三:React.memo是做什么的?useMemo又是做什么的?
React.memo是组件级别的缓存—— props浅比较相同就跳过重渲染(类似PureComponent);useMemo是值级别的缓存——依赖不变就不重新计算;useCallback是函数引用级别的缓存——依赖不变就返回同一个函数引用。
这三个不是同一个东西。React.memo只影响组件渲染表现,useMemo和useCallback主要影响子组件重新渲染的次数和计算开销。很多人说“用memo优化性能”,实际要组合着用才有明显效果。
6.2 我常用的回答框架,供参考
如果你正在准备面试,我建议不要背答案,而是按这个框架来组织语言:
- 先说定义:类组件是基于ES6 Class的组件,函数组件是基于纯函数+Hooks的组件;
- 说底层:类组件有实例和this,函数组件没有实例,每次渲染重新执行函数体,靠闭包捕获props/state;
- 说核心差异(挑2-3个展开):this绑定问题、生命周期映射关系、状态更新机制、逻辑复用方式(HOC vs 自定义Hooks);
- 说结论和建议:新代码默认函数组件+Hooks,个别场景(Error Boundary)必须类组件。
这样回答既有深度又有框架,比零散地背“函数组件没有this”“函数组件性能更好”要有用得多。
6.3 代码评审时我会盯的几个点
做代码评审这三年,我见过太多混合写法踩坑的案例。如果你也是前端团队的reviewer,我建议重点关注以下几点:
- 事件处理函数有没有无谓的
useCallback包着?如果子组件没有memo,包了也白包,反而增加代码复杂度; - 自定义Hooks是否遵守了命名约定(以use开头)?内部是否违反了hooks调用规则?
- class组件里是否在
componentDidUpdate里做了不必要的setState?这可能导致无限循环; useEffect里是否依赖了外部变量但没写进依赖数组?这是最常见的bug来源,eslint-plugin-react-hooks的exhaustive-deps规则建议团队强制开启。
最后分享一个小经验:React学习别急着追新语法,先把类组件和函数组件的本质差异吃透,很多面试题和实际业务场景中的“诡异问题”,其实都能归结到这两者执行机制的不同。掌握了这个底层视角,React生态里其他概念——并发渲染、Transitions、Suspense——学起来会顺畅很多。