
1. 从历史演变看两种组件的本质差异——为什么会有“两套写法”先说个我面试时的真实感受问“类组件和函数组件的区别”十个人里有八个能说出“一个用class一个用function”“函数组件有hooks”但再往下追问“为什么React要把Hooks引进来”“类组件到底哪里不好”能讲透的很少。所以这篇我不打算只列对比表格而是从根上把两条技术路线的差异讲清楚。1.1 React组件从“类”到“函数”的演化动因React在2013年开源时组件主要写法就是React.createClass2015年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() { // 这里如果直接绑定给onClickthis是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等价写法说明constructoruseState(() initialValue)或直接初始化初始化state、绑定this的场景被Hooks覆盖componentDidMountuseEffect(() {}, [])空依赖数组只在首次渲染后执行componentDidUpdateuseEffect(() {})不传依赖或传具体依赖不传依赖每次渲染都执行传了依赖只在依赖变化时执行componentWillUnmountuseEffect(() { return () {} }, [])useEffect的清理函数getDerivedStateFromProps渲染期间计算派生状态React 16.4的setState在渲染期间调用会有警告更推荐直接在render里计算这是Hooks场景下最复杂的一环后面细说shouldComponentUpdateReact.memo包裹组件memo做浅比较控制是否重渲染componentDidCatch暂无Hooks等价方案只能继续用类组件写Error BoundarygetSnapshotBeforeUpdate暂无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 contextstatic 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加useImperativeHandleconst 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 hellRender 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浅比较相同就跳过重渲染类似PureComponentuseMemo是值级别的缓存——依赖不变就不重新计算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——学起来会顺畅很多。