资讯动态

从类组件到函数组件:React Hooks全面升级实战指南

发布时间:2026/10/14 16:23:56 来源:尧图企业网站定制
1. 为什么会有“React 2”这种叫法先搞清楚版本演进逻辑做前端的人应该都遇到过这种对话有次团队里来了个实习生开会时突然问“老大我们项目什么时候升到 React 2我看隔壁组都在讨论 React 2 了”。我当时就笑了因为 React 官方压根没有一个叫“React 2”的版本但这并不代表这个问题没有意义。恰恰相反“React 2”这个说法在社区里流传很广它并不是指某个具体的 v2.0.0 发布而是指 React 开发范式从“类组件 生命周期”全面转向“函数组件 Hooks”的这一次大换代我习惯把它称为 React 的第二代写法。为什么明明没有 2.0大家却觉得存在一个“React 2”这里面有个非常典型的软件版本认知差异。很多框架喜欢在大版本号上做文章比如 Vue 有 Vue 2 和 Vue 3Angular 甚至直接从 2 开始重新设计。但 React 选择的路径是持续发布大版本号从 16 到 17 再到 18却在 16.8 那次更新里塞进了一个足以改变整个开发方式的东西——Hooks。从 16.8 开始你写 React 组件的主要方式就变了虽然版本号只是小数点后一位但对开发者的心智冲击相当于一次“2.0”。1.1 很多人等一个“React 2”但等来的却是 Hooks我记得 2019 年前后React 社区里最热闹的话题就是 Hooks。当时大家还在纠结要不要把类组件改成函数组件很多人第一反应是“这不就是换皮吗我 class 写得好好的setState 也熟练为什么要迁”但当你真的用上 useState、useEffect 之后会发现这不是语法层面的替换而是整个组件的组织方式被重构了。类组件时代一个组件的代码分散在 componentDidMount、componentDidUpdate、componentWillUnmount 这几个生命周期里。如果一个组件既要在挂载时拉数据又要在更新时根据某个 props 变化重新拉数据还要在卸载时清理事件监听那你很可能需要在多个生命周期里反复写相似逻辑。更麻烦的是不相关的逻辑会被塞进同一个生命周期函数里代码很快就变成一团乱麻。Hooks 的做法是把你关心的逻辑按功能提取出来比如一个数据请求 Hook、一个窗口尺寸监听 Hook每个 Hook 内部自己管理自己的副作用组件主体反而干净了很多。所以从这个角度看“React 2”更像是一种开发心智的升级而不是简单的小版本号变化。如果你还在用 class 组件写新代码那么你实际上还在“React 1.x 时代”当你切换到 Hooks 并且习惯用自定义 Hook 组织业务逻辑时你已经进入“React 2 时代”了。1.2 React 从 v16 到 v18 的关键节点顺着这个思路可以把 React 16 之后的变化看作一个渐进式的大版本演进。真正影响日常开发的几个关键节点我整理成一张表版本核心特性对开发者意味着什么React 16Fiber 架构重写底层调度底层更稳为后面 concurrent 能力打底React 16.8Hooks 正式发布类组件不再是唯一选择函数组件全面上位React 17无新功能专注过渡事件系统重构逐步为未来版本铺路React 18Concurrent 特性、useTransition、useDeferredValue让渲染可以被打断提升交互响应感React 18 的 Concurrent 是另一大变化但它没有改变 Hooks 的核心写法更多是在底层让你“可以用”一些新 API 来优化体验。真正改变绝大多数业务代码的还是 16.8 的 Hooks。换言之如果你从 React 16.8 之后才开始写 React那你直接学的就是“第二代写法”。如果你是从 class 组件一路走过来的老手那你需要经历一次非常痛苦但值得的思维转换。2. React 2 的本质从“面向生命周期”到“面向状态与副作用”如果说 React 2 时代有一个最核心的思想转变那就是不再以“组件在什么时间点做什么事”为出发点而是以“这个状态变化了我该同步什么副作用”为出发点。这个转变听起来很简单实际写起来差距巨大。2.1 类组件时代的心智模型在类组件里你首先想到的是“我的组件会经历什么阶段”。一个典型的类组件长这样class FriendStatus extends React.Component { constructor(props) { super(props); this.state { online: false }; } componentDidMount() { this.subscribeToFriendStatus(); } componentDidUpdate(prevProps) { if (prevProps.friendId ! this.props.friendId) { this.unsubscribeFromFriendStatus(); this.subscribeToFriendStatus(); } } componentWillUnmount() { this.unsubscribeFromFriendStatus(); } subscribeToFriendStatus() { // 模拟订阅 this.setState({ online: ChatAPI.isOnline(this.props.friendId) }); } unsubscribeFromFriendStatus() { // 模拟取消订阅 } render() { return div{this.state.online ? 在线 : 离线}/div; } }这段代码里订阅逻辑被拆到了三个生命周期里。如果以后再加一个“当 friendId 改变时发送埋点”的需求你又得往 componentDidUpdate 里塞一段埋点代码。日积月累生命周期函数会变得特别长而且你很难一眼看出这个组件到底在做什么。类组件的另一个问题是 this 绑定和 this.setState 的异步坑很多新手会在 setState 后立刻读 state结果拿到的还是旧值这需要额外理解。类组件不是不能用而是它把“状态变化的后果”这个核心逻辑切碎了。你关心的是 friendId 这个数据却不得不在三个地方维护它带来的影响。这种心智模型非常容易出错尤其是组件复杂度上来之后。2.2 Hooks 如何改变组件写法换成 Hooks 之后同一个 FriendStatus 可以写成这样function FriendStatus({ friendId }) { const [online, setOnline] useState(false); useEffect(() { console.log(开始订阅好友 ${friendId} 的状态); setOnline(ChatAPI.isOnline(friendId)); return () { console.log(取消订阅好友 ${friendId} 的状态); }; }, [friendId]); return div{online ? 在线 : 离线}/div; }没有生命周期函数没有 this没有复杂的绑定关系。useEffect 的写法非常直观你给我一个副作用函数我会在依赖项变化时自动执行如果这个副作用需要清理你就返回一个清理函数。[friendId]这个依赖数组表达的是“只有 friendId 变了我才会重新执行这个 effect”。完美契合了“数据驱动副作用”的心智模型。这里有个非常关键的对比类组件里“订阅”和“取消订阅”是分开写在两个生命周期里的而 Hooks 里它们是成对出现的写在同一个 useEffect 中。这个设计极大地提升了代码的聚合性。你改订阅逻辑时只需要看一个地方不用担心 componentDidMount 里改了、componentWillUnmount 里忘了改的情况。当然Hooks 也不是没有坑。useEffect 的依赖数组如果写错会导致数据请求疯狂重复执行或者拿到过期数据。这些坑我在后面专门写一节。但瑕不掩瑜Hooks 让 React 组件的代码组织方式变得更接近“关注点分离”的原始理想。3. 迁移到“React 2”实操把老类组件逐步改造成 Hooks 风格很多老项目不是说不升就不升的技术债总有要还的一天。如果你接手了一个类组件遍地的项目又不想一次性推翻重来我建议你用“渐进式迁移”的方案。先挑选一个逻辑比较独立的组件做改造跑通之后形成规范再批量推进。3.1 改造前的状态盘点动手之前先把这个类组件里所有的 state 列个清单。比如下面这个经典的搜索组件它包含keyword用户输入的关键词results搜索结果列表loading是否正在加载error是否有错误类组件里这些状态可能都塞在 this.state 里同时还有两个 methodfetchSearchResults和handleChange。改造的第一步是把状态拆成多个 useState把异步请求逻辑拆成一个自定义 Hook。我通常会用一张简单的表格来规划类组件成员函数组件对应方案this.state.keyworduseState()this.state.resultsuseState([])this.state.loadinguseState(false)this.state.erroruseState(null)componentDidMount / componentDidUpdate 中的请求useEffect 自定义 Hook事件处理方法普通函数或 useCallback 包装这一步的价值在于你能提前看清哪些状态之间是有关联的。比如 loading 和 results 其实是一组请求状态可以合并成一个useAsync之类的自定义 Hook。但一开始不用过度设计先一对一映射跑通再说。3.2 落地步骤从 render 到 return从 setState 到 useState我以一个搜索组件为例展示关键迁移过程。改造前的类组件骨架class SearchBox extends React.Component { state { keyword: , results: [], loading: false, error: null, }; componentDidMount() { // 初始化拉取一次 this.fetchResults(); } async fetchResults(keyword) { this.setState({ loading: true }); try { const data await searchApi(keyword); this.setState({ results: data, loading: false, error: null }); } catch (err) { this.setState({ loading: false, error: err }); } } handleChange (e) { this.setState({ keyword: e.target.value }); }; render() { // 渲染逻辑... } }改造后的函数组件function SearchBox() { const [keyword, setKeyword] useState(); const [results, setResults] useState([]); const [loading, setLoading] useState(false); const [error, setError] useState(null); async function fetchResults(searchTerm) { setLoading(true); try { const data await searchApi(searchTerm); setResults(data); setError(null); } catch (err) { setError(err); } finally { setLoading(false); } } useEffect(() { fetchResults(); }, []); function handleChange(e) { const value e.target.value; setKeyword(value); // 这里先不自动请求实际项目通常配合防抖 } return ( div input value{keyword} onChange{handleChange} / {loading p加载中.../p} {error p出错了{error.message}/p} ul {results.map((item) ( li key{item.id}{item.title}/li ))} /ul /div ); }看到没有核心变化就是this.setState({ loading: true })变成了setLoading(true)this.state.keyword变成了keyword。看起来只是语法糖但关键是你在写的时候会不自觉地开始思考“哪些状态应该合并成自定义 Hook”这才是迁移的隐藏收益。如果你想把请求逻辑抽得更干净可以包装成一个useSearch自定义 Hookfunction useSearch(initialKeyword ) { const [keyword, setKeyword] useState(initialKeyword); const [state, setState] useState({ results: [], loading: false, error: null, }); useEffect(() { let ignore false; async function runSearch() { setState((s) ({ ...s, loading: true })); try { const data await searchApi(keyword); if (!ignore) { setState({ results: data, loading: false, error: null }); } } catch (err) { if (!ignore) { setState({ results: [], loading: false, error: err }); } } } runSearch(); return () { ignore true; }; }, [keyword]); return { ...state, keyword, setKeyword }; }这里我特意加了ignore这个变量。这个细节非常重要当依赖项 keyword 变化时上一次请求还没有返回新的 effect 已经开始执行了。如果没有ignore标记旧请求返回后仍然会执行 setState导致界面显示与当前输入不匹配的搜索结果。这个属于典型的“竞态条件”问题很多新手第一次遇到都会一脸懵。3.3 改造时最容易踩的坑生命周期对应关系把 class 组件迁移到函数组件最危险的并不是语法而是生命周期对应关系。很多人想当然地认为 componentDidMount 对应 useEffect(..., [])componentDidUpdate 对应 useEffect 不传依赖数组componentWillUnmount 对应 return 清理函数。但要注意useEffect 在一次渲染中可能同时承担 mount 和 update 两个角色。比如你原来的代码是componentDidUpdate(prevProps) { if (prevProps.id ! this.props.id) { this.loadData(this.props.id); } }在函数组件里不能写成useEffect(() { loadData(id); });因为这样写每次渲染都会触发即使 id 没变也会重复请求。正确做法是加上依赖数组useEffect(() { loadData(id); }, [id]);这个 effect 在 mount 时执行一次之后每次 id 变化再执行。和 componentDidMount componentDidUpdate 的组合行为基本等价。但还有个细微差别componentDidUpdate 不会在 mount 时执行而 useEffect 会在 mount 时执行。如果你需要“仅在更新时执行”可以借助 useRef 做一个跳过标记不过这属于比较少见的需求。另一个容易踩的坑是useEffect 里用了某个变量但忘了加进依赖数组。这会导致 effect 执行时拿到的是旧值也就是大家常说的“闭包陷阱”。我在下一节专门展开。4. 常见问题与排查技巧实录这一节是我最想写的因为踩过的坑比看过的文档多得多。Hooks 用起来简单但真要排查起来很多问题藏得很深。4.1 useEffect 依赖数组引发的重复执行问题有一次线上反馈说某个页面一直在发请求把后端压垮了。我打开代码一看某位同事写的 effect 是这么写的useEffect(() { fetchData(); });没有第二个参数。这意味着每次渲染后都会执行只要组件有过任何状态更新就会再次发请求。虽然页面看起来“正常”因为请求结果每次都一样但网络请求量直接翻倍再翻倍。排查思路很简单如果你发现某个副作用执行次数异常先看 useEffect 有没有传依赖数组再检查依赖数组里的每一项是不是稳定引用。比如依赖了[props.user]但父组件每次渲染都创建一个新对象传给子组件那么子组件的 effect 每次都会执行即使数据内容完全一样。我自己的经验是排查这类问题时用浏览器开发者工具的 Network 面板看请求频率再配合在 effect 第一行加 console.log能看到触发时机。如果确认依赖项没问题那就是引用不稳定需要在父组件里用 useMemo 或者 useCallback 缓存。4.2 闭包陷阱与过期状态闭包陷阱是我见过 React 新手最容易困惑的问题。看这段代码function Counter() { const [count, setCount] useState(0); useEffect(() { const timer setInterval(() { console.log(count); setCount(count 1); }, 1000); return () clearInterval(timer); }, []); }dead 这段代码会怎样每隔一秒 setCount(count 1)但这里的 count 永远是最初的 0所以 count 永远变成 1。因为 useEffect 的依赖数组是空数组effect 只在 mount 时执行一次闭包里捕获的 count 就是首次渲染时的值之后每次 setCount 都是基于同一个旧值计算。解决方案有很多种。最简单的是把依赖数组加上[count]这样每次 count 变化都重新创建定时器。但定时器会被反复清掉重建体验不太好。更优雅的是用 setCount 的函数式更新useEffect(() { const timer setInterval(() { setCount((c) c 1); }, 1000); return () clearInterval(timer); }, []);setCount 接收一个函数这个函数接收当前最新状态作为参数返回新状态。这样闭包里就不需要依赖 count 了完美规避闭包陷阱。我在实际项目中凡是遇到需要定时器、事件监听、或者根据旧状态计算新状态的场景都会优先考虑函数式更新。这是个非常实用的小技巧。4.3 自定义 Hook 封装数据请求的避坑指南把数据请求封装成自定义 Hook 是 React 2 时代的标配操作但封装过程有很多细节。我见过最离谱的封装是把请求函数定义在 Hook 外面这没问题但有人把请求参数作为 useEffect 的依赖项结果对象或函数类型作为依赖项导致无限循环。比如async function fetchData(params) { // ... } function useRemoteData(params) { const [data, setData] useState(null); useEffect(() { fetchData(params); }, [params]); // 如果 params 每次都是新对象这里会无限触发 }解决办法是只依赖 params 中的基本类型字段或者使用 useMemo 把 params 缓存为稳定引用。更进一步可以使用一个useDebouncedEffect或者自定义一个“有限次数”的 effect。如果是搜索类的场景我还会额外加防抖逻辑。下面是一个比较稳的封装模板直接拿去用function useAsync(fn, deps) { const [state, setState] useState({ data: null, loading: true, error: null }); useEffect(() { let active true; setState({ data: null, loading: true, error: null }); fn().then( (data) { if (active) setState({ data, loading: false, error: null }); }, (error) { if (active) setState({ data: null, loading: false, error }); } ); return () { active false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return state; }注意这里的active变量它是用来防止组件卸载后继续 setState 导致的内存泄漏警告。虽然 React 之后取消了这个警告但竞态问题依然存在所以这个标记我还是保留着。另外如果你的 fn 每次渲染都是一个新引用就不能把它放进依赖数组否则必然死循环。一个变通做法是把 fn 放在 useEffect 内部定义或者用 useCallback 包一层。5. 我对“React 2”的理解与踩坑总结回到最初的话题React 2 不是官方版本号但它确实代表了 React 开发方式的一次代际跃迁。我认为判断一个前端开发者是否已经进入 React 2 时代不取决于你用的是不是最新版 React 18而取决于你是否已经形成了“用 Hook 组织逻辑”的思维习惯。我自己的体会是从类组件迁到函数组件的头两周会非常痛苦。你会觉得一切都失控了不知道 effect 什么时候执行不知道依赖数组该怎么写甚至不知道一个状态应该放组件里还是抽成自定义 Hook。但坚持一个月之后再回头看旧代码你会明显感觉到类组件的笨重和割裂感。这里有一个很实用的判断标准当你拿到一个需求时第一反应是“需要几个 state、要不要抽一个自定义 Hook”而不是“我的组件挂载时要干嘛、更新时要干嘛”说明你已经彻底进入 React 2 的心智模式了。最后分享一个小技巧如果你正在做大型项目的老代码迁移不要试图一次性把所有 class 组件都改掉。我当时的做法是先在工具函数或纯 UI 组件上试试水然后把最频繁变动的业务组件优先迁移因为它们才是真正让你技术债利息变高的部分。每改完一个组件顺手把相关的测试补上至少保证关键链路跑通。这样三个月下来你的老项目就会在一个比较稳定的节奏里慢慢蜕变成“React 2”的模样。技术债不会消失但可以被逐步消化而且这个过程本身就是你作为前端开发者成长的修炼之路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑