如果你用React做过带接口请求的页面,大概率见过这个场面:页面先白屏,loading转圈,数据一回来整个页面“弹”出来;运气差一点,直接给你一个红色报错——Cannot read property 'map' of undefined。这个现象背后就是React异步数据渲染问题,也是React面试里常问、但实战里依然翻车率很高的核心难点。
这篇东西的定位,不是把文档复读一遍,而是把我实际开发中处理“异步数据+React渲染”这个组合时踩过的坑、总结出的规律和最终沉淀下来的解决方案,从头到尾梳理清楚。适合刚接触React、被接口渲染搞到头晕的新人,也适合已经写过不少页面、但想系统理清竞态、卸载、缓存这些工程问题的朋友。
1. 为什么数据已经回来了,页面还是一动不动:React渲染时序拆解
很多新手遇到的问题是:接口明明返回了,数据也存到变量里了,但页面就是没变化。要理解这个问题,得先明白React的渲染函数到底是怎么执行的。
1.1 一个再常见不过的白屏现场
先看一段代码:
function UserProfile({ userId }) { let user = null; fetch(`/api/user/${userId}`) .then(res => res.json()) .then(data => { user = data; }); return <div>{user.name}</div>; }这段代码什么效果?一打开页面,直接报错:Cannot read property 'name' of null。就算你不报错,把user.name换成{user?.name},页面也永远是空的,请求再多遍,界面纹丝不动。
原因在于:fetch回调执行的时候,组件函数早就执行完并返回了。你修改的user变量只是个普通局部变量,React根本不知道它变了,自然不会重新渲染。这个案例基本解释了异步数据渲染的本质矛盾:请求是异步的,渲染是同步的,两者之间必须靠一套机制来连接。
1.2 渲染函数是同步的,异步数据可不会自己喊“我到了”
React的渲染函数(组件函数本身)是一个同步的纯函数。所谓纯函数,就是你给它一份props和state,它返回一份固定的UI描述;数据变了,它重新执行,返回新的UI描述。React对比前后两份UI描述的差异,再更新真实DOM。
异步数据的问题是:它到达的时间点不可控。请求发出去之后,组件已经开始渲染了;1秒后数据回来,组件函数已经“下班”了。如果没有人通知React“数据到了、你该重新渲染了”,页面就会一直停留在初始状态。
所以,要让异步数据驱动页面更新,就必须把数据放进React能感知的状态容器里。这个容器就是useState或者useReducer。组件函数里读到的任何变量,只要是state,React就会帮你监听它的变化,一变就触发重新渲染。
1.3 setState之后,React到底做了什么
这里稍微深入一点。调用setState之后,React并不是立刻同步更新DOM,而是把本次更新放进一个队列里,在事件处理结束后统一批量处理(React 18并发特性下尤其明显)。这个机制叫“批处理”(batching)。
const [count, setCount] = useState(0); function handleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1); }这个函数执行完,count变成1而不是3,因为三次setCount拿到的是同一个count。异步数据场景里,批处理带来的体感是:哪怕你的接口一次返回了一大批数据,页面也只会重新渲染一次,不会闪烁好几下。这是好事,但如果你在同一个回调里面既改A状态又改B状态,然后又依赖A去计算C,就要小心拿到的还是旧值。
const [list, setList] = useState([]); const [total, setTotal] = useState(0); function handleData(res) { setList(res.items); // 这里读到的 total 还是旧值 setTotal(total + res.items.length); }要解决,要么用函数式更新:setTotal(prev => prev + res.items.length),要么等到下次渲染再计算派生值。这是很多人写异步请求时会忽略的细节。
2. useEffect + setState:异步请求的“标准姿势”与三个翻车写法
理解了渲染时序,接下来就是实操。React官方推荐的异步数据请求姿势是:把请求放进useEffect副作用里,拿到数据再setState。
2.1 useEffect是处理副作用的正确位置
组件函数体必须保持纯净,请求接口、操作DOM、订阅事件都属于副作用,所以React把它们规整到useEffect里。useEffect的第二个参数是依赖数组,数组里的依赖项一旦变化,React就先执行上一次effect的清理函数,再重新执行effect。
function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { let cancelled = false; setLoading(true); fetch(`/api/user/${userId}`) .then(res => res.json()) .then(data => { if (!cancelled) { setUser(data); setLoading(false); } }); return () => { cancelled = true; }; }, [userId]); if (loading) return <div>加载中...</div>; return <div>{user?.name}</div>; }userId变化时,effect会重新执行,并且旧effect的清理函数先执行,把cancelled置为true,这样哪怕旧请求晚于新请求返回,也不会覆盖新数据。这是处理竞态的基础写法。
2.2 新手最容易踩的三种错误写法
第一种:在组件函数体里直接发请求。开头已经演示了,这不只不会更新数据,而且组件每次渲染都会发一次请求,直接打爆接口。
第二种:effect里发请求,但依赖数组是空的[],然后又在effect里读取某个prop或者state。这种情况React会直接警告exhaustive-deps,但你如果不当回事,拿到的就是闭包捕获的旧值。比如:
useEffect(() => { fetch(`/api/list?page=${page}`).then(...); }, []); // page 变了不会重新请求第三种:effect里调用了setState,触发重新渲染,然后重新渲染又触发effect,形成死循环:
function Test() { const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); // 每次渲染都在加,React 会死循环 }, [count]); }经典解法是去掉[count]依赖,或者改为按下标更新,确保effect执行不会必然导致依赖项变化。
2.3 找不到规律?直接封装一个useAsync
手写了十几个useEffect之后,我最大的体会是:useEffect的可复用性太低了。每个页面都在写loading、error、data三个状态,都在写cancelled标记,代码高度重复。后来干脆封装了一个useAsynchook,一次搞定。
import { useState, useEffect, useCallback } from 'react'; function useAsync(asyncFn, deps = []) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const run = useCallback(() => { let isCancelled = false; setLoading(true); setError(null); asyncFn() .then(res => { if (!isCancelled) setData(res); }) .catch(err => { if (!isCancelled) setError(err); }) .finally(() => { if (!isCancelled) setLoading(false); }); return () => { isCancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); useEffect(() => { const cancel = run(); return cancel; }, [run]); return { data, loading, error, run }; }调用方式很简单:
const { data: user, loading } = useAsync( () => fetch(`/api/user/${userId}`).then(res => res.json()), [userId] );已经处理了卸载后的state更新、依赖变化时的自动重新请求、loading状态切换。项目里如果用了这个hook,起码能删掉三分之一重复代码。
3. 竞态条件:搜索框、分页和列表场景里最隐蔽的数据错乱
比白屏更折磨人的是:页面渲染了,数据也对,但展示的内容跟用户的输入对不上。这是异步数据渲染里最典型的竞态条件问题。
3.1 词条请求交错,结果列表对不上输入框
假设你在做一个搜索框,用户一边输入一边请求接口:
const [keyword, setKeyword] = useState(''); const [results, setResults] = useState([]); useEffect(() => { fetch(`/api/search?q=${keyword}`) .then(res => res.json()) .then(data => setResults(data.items)); }, [keyword]);用户先输入“react”,发出了请求A;接着补成“react hook”,发出请求B。如果网络状况不稳定,请求A可能2秒后才返回,而请求B只用200毫秒。于是界面上input框里是“react hook”,列表内容却是请求A返回的旧结果。你以为是自己代码写错了,其实就是竞态:“发出请求的顺序”和“返回结果的顺序”并不一致。
3.2 请求竞态的三种处理方案
第一种是忽略过期结果。用ref记录当前请求的编号,请求发出前先自增,返回后校验编号是否还是自己:
const requestIdRef = useRef(0); useEffect(() => { const id = ++requestIdRef.current; fetch(`/api/search?q=${keyword}`) .then(res => res.json()) .then(data => { if (id === requestIdRef.current) { setResults(data.items); } }); }, [keyword]);这种方法实现简单、兼容性好,缺点是过期的请求本身还在执行,白白消耗带宽。
第二种是AbortController主动取消,能真正终止请求:
useEffect(() => { const controller = new AbortController(); fetch(`/api/search?q=${keyword}`, { signal: controller.signal }) .then(res => res.json()) .then(data => setResults(data.items)) .catch(err => { // 中断请求的AbortError,不用处理 if (err.name !== 'AbortError') { console.error(err); } }); return () => controller.abort(); }, [keyword]);第三种是结合useRef与清理函数,本质上和第一种一样,但写进effect清理更符合React的思维习惯。实际上2.1节里的cancelled标记也可以处理跨组件间的竞态,但它解决不了同一组件多次请求的竞争——这时候方案一更稳妥。
3.3 用AbortController终止其实没那么复杂
很多人一听AbortController就觉得是新东西不想用。其实它就是一个普通浏览器API,支持面很广,和fetch天然整合。
注意:如果你项目里用的不是fetch,而是axios,也有现成的
CancelToken或新版AbortController信号传递。请求库一般都会支持,建议优先用AbortController,因为标准统一,兼容性更好。
实测下来,AbortController在搜索场景里的体验是最好的:输入框内容一变化,上一个请求立刻被中断,不会出现“一次性发出七八个请求,最后渲染结果看运气”的情况。配合loading状态,还能让用户明确感知到每次输入都在触发新的搜索。
4. Suspense + 数据请求:把“等待”交给React
如果说useEffect+setState是手动挡,那Suspense就是自动挡。它的核心思路是:组件不再自己管理loading状态,而是让React在数据还没准备好时自动展示fallback,数据ready后自动切换内容。
4.1 Suspense的适用边界
很多资料里Suspense只被拿来配合React.lazy做代码分割,这算是它的一个典型应用,但远不是全部。React 18之后,Suspense的定位变成了“声明式加载等待边界”,数据请求也可以挂起。前提是你得有一个能“抛出Promise”的数据消费方式。
简单理解:普通请求是return data,Suspense模式是“数据没到位就throw new Promise(...)”,React捕获到这个Promise,就去渲染边界上的fallback,等Promise resolve之后从原来挂起的地方继续渲染。
4.2 手写一个wrapPromise
实际项目里如果还不想引入useQuery这类重型库,可以自己实现一个简单的promise缓存包装:
function wrapPromise(promise) { let status = 'pending'; let result; let suspender = promise.then( (value) => { status = 'success'; result = value; }, (error) => { status = 'error'; result = error; } ); return { read() { if (status === 'pending') { throw suspender; } if (status === 'error') { throw result; } return result; }, }; }在组件里直接用read()读取数据:
function UserProfile({ resource }) { const user = resource.read(); return <div>{user.name}</div>; } function Page({ userId }) { const resource = wrapPromise( fetch(`/api/user/${userId}`).then(res => res.json()) ); return ( <Suspense fallback={<div>数据加载中...</div>}> <UserProfile resource={resource} /> </Suspense> ); }这里有个关键点:wrapPromise必须在组件外部或者被缓存起来,否则每次渲染都创建一个新Promise,状态永远是pending。实际使用中通常配合一个缓存Map,以请求key为维度缓存对应的wrapPromise结果。
4.3 use() 钩子和并发模式下的异步渲染
React 18.3试验性的use()钩子进一步简化了这件事。你可以直接在组件里读取一个Promise:
function UserProfile() { const user = use(fetchUser()); return <div>{user.name}</div>; }use()还不是稳定API,生产项目用起来要谨慎。但你可以从中理解Suspense数据请求的核心哲学:把异步数据渲染从命令式(我手动控制loading)转换成声明式(我只需要告诉React数据要什么,React自己处理等待和重试)。这个心智模型的升级,价值远高于某一两个API本身。
5. 高频异步场景的工程化解法:分页、防抖搜索、串行依赖
业务项目里有一些复现率极高的异步渲染组合拳,单独拆开来都不难,合在一起就容易出问题。这里挑三个典型场景说一下工程化的处理方式。
5.1 列表分页:缓存上一页数据而不是清空重来
很多人写分页列表的初版是这样的:
const [list, setList] = useState([]); const [page, setPage] = useState(1); useEffect(() => { fetch(`/api/list?page=${page}`) .then(res => res.json()) .then(data => setList(data.items)); }, [page]);问题在于每次翻页,原来渲染好的第一页数据直接清空了,页面瞬间白屏或者跳到loading。体验很差。
正确的做法是把list和loading分离开,切换到新页时保留旧数据,直到新数据完全到达再替换:
const [state, setState] = useState({ list: [], page: 1, loading: false, }); useEffect(() => { setState(prev => ({ ...prev, loading: true })); fetch(`/api/list?page=${page}`) .then(res => res.json()) .then(data => { setState(prev => ({ ...prev, list: data.items, // 注意这里是替换不是覆盖 loading: false, })); }); }, [page]);再配合scroll到页面底部的自动加载,以及“正在加载第N页”的小菊花,体感会好很多。如果是无限滚动场景,往往需要合并而不是替换列表数据,这时候setState里就不能直接赋值,而是prev.list.concat(data.items)。
5.2 搜索防抖与竞态的合体改造
搜索框除了要处理竞态,还要处理防抖。防抖的本质是:用户停止输入一段时间后再发请求,避免每个字符都触发一次请求。两者通常一起实现:
function useDebounce(value, delay = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } // 使用 const debouncedKeyword = useDebounce(keyword, 300); const requestIdRef = useRef(0); useEffect(() => { const id = ++requestIdRef.current; if (!debouncedKeyword) { setResults([]); return; } fetch(`/api/search?q=${debouncedKeyword}`) .then(res => res.json()) .then(data => { if (id === requestIdRef.current) { setResults(data.items); } }); }, [debouncedKeyword]);这里防抖解决的“请求频率”问题,请求编号解决的是“返回顺序”问题,两个问题互相独立,但必须同时处理,否则防抖只是减少竞态发生的概率,并不能消除竞态。
5.3 串行依赖请求与并行请求的取舍
业务里经常遇到:A接口返回的数据是B接口的入参。比如先登录拿userId,再拿userId去查订单列表。直接把两者串起来写,会有一个loading状态被多次赋值的问题。
const [userId, setUserId] = useState(null); const [orders, setOrders] = useState([]); // 第一步请求 useEffect(() => { fetch('/api/login') .then(res => res.json()) .then(data => setUserId(data.userId)); }, []); // 第二步请求 useEffect(() => { if (!userId) return; fetch(`/api/orders?userId=${userId}`) .then(res => res.json()) .then(data => setOrders(data.items)); }, [userId]);这种写法的优点是清晰,缺点是你得处理两个loading状态,而且如果两个步骤都失败,错误处理比较分散。如果两个接口之间没有依赖关系,一定要用Promise.all并行,而不是两个effect里各自串行:
const [data, setData] = useState(null); useEffect(() => { Promise.all([ fetch('/api/baseInfo').then(res => res.json()), fetch('/api/config').then(res => res.json()), ]).then(([baseInfo, config]) => { setData({ baseInfo, config }); }); }, []);这样首屏耗时是慢的那个接口的耗时,而不是两个接口耗时相加。一个常见的性能优化点。
6. 卸载、错误、Key变更:异步数据渲染的边界问题处置
异步数据渲染的难点不只在“数据来了怎么渲染”,还在于各种边界条件:组件已经卸载、接口报错、列表key变化导致状态残留。这些坑往往在开发环境测不出来,上线后才爆。
6.1 组件卸载后setState:内存泄漏与清理标记
React 18之前,组件卸载后调用setState会有一个警告:Can't perform a React state update on an unmounted component。虽然React 18去掉这个警告,不意味着你可以随便写——回调里setState一个已卸载组件的状态,本身是无效操作,该执行的请求结果也白等了。
最稳妥的做法是:在清理阶段做一个标记,请求回到来后先判断是否需要更新:
useEffect(() => { let isMounted = true; fetch('/api/detail') .then(res => res.json()) .then(data => { if (isMounted) { setDetail(data); } }); return () => { isMounted = false; }; }, []);我一般习惯用AbortController代替手动isMounted标记,因为前者还能把请求真正中断掉,减少无谓的网络流量。两者选一个就行,不用叠加。
6.2 请求失败不能只靠console.error
很多初版代码里,接口报错只是打印到控制台,页面停留在loading状态或者空白状态。用户在线上环境根本看不到错误信息,只感觉“这个页面卡死了”。
正确做法是给组件增加错误边界,或者至少在页面State里保存error信息,渲染一个错误提示和重试按钮:
const [error, setError] = useState(null); if (error) { return ( <div> <p>数据加载失败:{error.message}</p> <button onClick={retry}>重试</button> </div> ); }如果错误是不可恢复的,比如网络断线,应该通过ErrorBoundary捕获,跳转或者展示全局错误页。特别提醒:不要忘记处理请求失败后loading状态的重置,否则用户永远卡在loading页面。
6.3 避坑清单汇总
按照这些年处理React异步数据渲染的经验,我整理了一份常用自查清单,每次写相关代码之前过一遍,能省很多排查时间。
| 问题 | 表现 | 根因 | 解决方案 |
|---|---|---|---|
| 页面白屏 | 数据返回但界面为空 | 普通变量未进state | 用useState管理请求结果 |
| Cannot read property | 对null调属性 | 初始值未设置 | 给state设置合理的初始值 |
| 无限请求 | 接口被疯狂调用 | effect依赖项在effect中变化 | 检查依赖数组,拆分副作用 |
| 数据错乱 | 列表和输入框对不上 | 请求竞态 | 请求id标记或AbortController |
| loading闪烁 | 反复出现加载中 | 每次查询都全量替换state | 保留旧数据,按需替换 |
| 卸载报错 | 切页后控制台警告 | 销毁后未清理回调 | cleanup函数加isMounted标记 |
| 接口失败白屏 | 错误不可见 | 未处理error状态 | 错误边界 + 重试按钮 |
| 列表key残留 | 搜索后旧数据残留 | key没有绑定到结果ID | 列表项key用唯一业务ID |
这个清单本身没有多少高深的原理,但每一条都是从真实故障里沉淀出来的。与其在代码审查时一遍一遍讲,不如直接挂一份这样的自查清单在团队文档里,效率高很多。
最后说一个我做异步渲染重构时的小体会:真正稳定的异步渲染方案,从来不是某个神奇的库或者某个API,而是你对自己代码里每个状态的生命周期都心里有数。数据什么时候进来、组件什么时候销毁、用户又在什么时候改变了自己的意图,这三条线理清楚了,异步渲染这个问题的答案,其实就藏在这几条线的交汇处。