数据获取与网络
移动端网络请求的 API 表面看起来和浏览器一样(还是 fetch、AbortController、WebSocket 那套),但底层完全是另一回事:请求由原生网络栈代发,没有浏览器同源策略,没有内置超时,没有可靠持久的 HTTP 磁盘缓存,cookie 行为对 JS 半透明。本章按"先搞清运行环境差异,再写请求层,最后接 TanStack Query"的顺序展开。
基线:React Native 0.88(新架构已默认)/ Expo SDK 58 / TypeScript。fetch、AbortController、XMLHttpRequest 是 RN 运行时长期稳定的能力,可以放心依赖;axios 等库在 RN 下走 XHR 通道也能正常工作。
RN 网络栈全景
请求到底是谁发出去的
JS 里写的 fetch 只是一层壳。调用后请求会交给原生侧:iOS 走 NSURLSession,Android 走 OkHttp(这是 React Native 公开的架构事实)。理解这一点能解释很多"浏览器里正常、RN 里诡异"的行为:
- 超时没有原生支持。
fetch的RequestInit里根本没有timeout选项(浏览器也没有),而原生网络栈的默认超时又长又受平台配置影响,JS 侧改不了。生产代码必须自己做超时,标准姿势是AbortController + setTimeout,见下文封装。 - 没有 HTTP 持久磁盘缓存。浏览器里刷新页面静态资源命中缓存的行为,在 Hermes 环境下不存在——Hermes 的
fetch没有内置的跨会话 HTTP 缓存。需要缓存,要么靠服务端Cache-Control加系统行为(原生栈在某些情况下会尊重响应头,但不要当成契约),要么自己做落盘:最常见的组合是 TanStack Query + MMKV/AsyncStorage 持久化(见下文与08-存储与文件.md)。 - cookie 需要显式管理。原生网络栈各自维护 cookie 存储,但 JS 层的
fetch拿不到Set-Cookie响应头,也无法可靠地控制域与路径。结论很直接:认证用Authorization头 + token,别把会话状态寄托在 cookie 上。确实要对接只认 cookie 的第三方接口,就上专门的 cookie 管理库单独处理。
为什么没有 CORS
RN 不是浏览器,没有同源策略这回事:原生网络栈想请求哪个域就请求哪个域,服务端返回 403 也是业务层的拒绝,跟跨域无关。所以你会遇到一种排查陷阱:
注意:用 Expo 跑 Web 预览(react-native-web)时,同一套代码会突然报 CORS 错误——那不是原生网络出了问题,只是浏览器接管了请求。验证原生行为请以真机/模拟器为准;反过来,本地能绕过 CORS 的"歪招"(比如给 Web 配代理)也别带进原生发布。
fetch 基础与请求层封装
超时 + 取消:一个完整可跑的 client.ts
移动端请求层要解决四件事:统一 baseURL、注入 token、超时兜底、错误归一化(把 HTTP 错误、网络错误、超时、主动取消收敛成一种类型,让上层 UI 好判断)。
// src/api/client.ts —— 统一请求层
const BASE_URL = 'https://api.example.com'
const DEFAULT_TIMEOUT_MS = 10_000
export class ApiError extends Error {
constructor(
public readonly status: number, // HTTP 状态码;0 表示没拿到响应(网络/超时/取消)
public readonly code: 'http' | 'network' | 'timeout' | 'abort' | string,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
export interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown
timeoutMs?: number
withAuth?: boolean
}
let accessToken: string | null = null
export function setAccessToken(token: string | null) {
accessToken = token
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { body, timeoutMs = DEFAULT_TIMEOUT_MS, withAuth = true, headers, signal, ...rest } = options
// 超时与外部取消共用一个 controller:谁先触发谁说了算
const controller = new AbortController()
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
controller.abort()
}, timeoutMs)
if (signal) {
if (signal.aborted) controller.abort()
else signal.addEventListener('abort', () => controller.abort(), { once: true })
}
try {
const res = await fetch(`${BASE_URL}${path}`, {
...rest,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(withAuth && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
})
// Response 的 body 是一次性流,.json() 调第二次直接抛错。
// 先读成文本,再解析,方便非 2xx 时把服务端错误信息带出来。
const text = await res.text()
const json: Record<string, unknown> | null = text ? JSON.parse(text) : null
if (!res.ok) {
throw new ApiError(res.status, String(json?.code ?? 'http'), String(json?.message ?? res.statusText))
}
return json as T
} catch (err) {
if (timedOut) throw new ApiError(0, 'timeout', `请求超时(${timeoutMs}ms)`)
if (err instanceof Error && err.name === 'AbortError') throw new ApiError(0, 'abort', '请求被取消')
if (err instanceof ApiError) throw err
throw new ApiError(0, 'network', err instanceof Error ? err.message : '未知网络错误')
} finally {
clearTimeout(timer)
}
}
export const api = {
get: <T>(path: string, options?: RequestOptions) => request<T>(path, { ...options, method: 'GET' }),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, body, method: 'POST' }),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, body, method: 'PUT' }),
del: <T>(path: string, options?: RequestOptions) => request<T>(path, { ...options, method: 'DELETE' }),
}关于超时实现再多说一句:规范里的 AbortSignal.timeout(ms) 在 Hermes 的支持情况随版本变化,为兼容性起见手写 setTimeout + controller.abort() 更稳,代价只是多两行代码。
组件卸载时取消请求
请求层不带取消等于漏请求。裸 useEffect 场景的标准收尾:
// src/screens/ArticleScreen.tsx
function useArticle(id: string) {
const [article, setArticle] = useState<Article | null>(null)
useEffect(() => {
const controller = new AbortController()
api
.get<Article>(`/articles/${id}`, { signal: controller.signal })
.then(setArticle)
.catch((err: unknown) => {
// 卸载导致的取消不是错误,静默处理
if (err instanceof ApiError && err.code === 'abort') return
if (err instanceof ApiError && err.code === 'network') Toast.show('网络不可用,请检查连接')
})
return () => controller.abort() // 组件卸载/依赖变化即取消
}, [id])
return article
}实际项目里这类"发请求 + 缓存 + 取消 + 重试"的活儿应该整体交给 TanStack Query(下一节),手写 useEffect 版本只在极简场景或库管不到的边角出现。
与 TanStack Query 组合:只讲移动端三个点
Query 本身的 API(useQuery 选项、queryKey 设计、失效策略)不是本章内容,见 ../tanstack-query/02-useQuery详解.md 和 ../tanstack-query/04-查询键与缓存策略.md。移动端接 Query 需要额外做三件事,做完之后桌面端的用法几乎原样平移。
一、离线暂停:onlineManager
Query 默认通过 window 的 online/offline 事件加 navigator.onLine 判断网络状态,这些在 RN 运行时里要么不存在要么不可靠。不接线的后果是:断网时 Query 照常重试、请求以网络错误告终、UI 满屏报错。用 @react-native-community/netinfo 把它接上:
// src/query/queryClient.ts
import { QueryClient, focusManager, onlineManager } from '@tanstack/react-query'
import NetInfo from '@react-native-community/netinfo'
import { AppState } from 'react-native'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 2,
},
},
})
// 断网时 Query 自动暂停所有请求,恢复后再发
onlineManager.setEventListener((setOnline) => {
const unsubscribe = NetInfo.addEventListener((state) => {
setOnline(state.isConnected ?? false)
})
return unsubscribe
})接线之后有个立刻能吃到的红利:重试策略变得有意义了——请求在离线期间被 Query 压住,等网络恢复一次性补发,而不是对着不存在的网络烧完重试次数。
二、焦点重取:把 AppState 映射成 window focus
refetchOnWindowFocus 依赖 document 的 visibilitychange 事件,RN 里没有 document,所以这个默认行为在原生端静默失效——浏览器端"切标签页回来自动刷新"的体验在 App 里不会自动出现。手工把 AppState 映射给 focusManager:
// src/query/queryClient.ts(续)
focusManager.setEventListener((handleFocus) => {
const sub = AppState.addEventListener('change', (status) => {
handleFocus(status === 'active') // 回到前台视作窗口获得焦点
})
return () => sub.remove()
})这样"用户切回 App 自动刷新列表"的行为就和桌面端对齐了。注意 iOS 上 App 进后台后 JS 线程随时可能被挂起,background 状态下不要指望任何定时任务;active 判定也不覆盖"App 还在前台但用户停留在别的页面"这种 Web 意义上的失焦——移动端语义本来就是粗粒度的,够用。
三、分页:InfiniteQuery 接 FlatList,onEndReached 的提前触发坑
useInfiniteQuery 配 FlatList 是 Feed 类页面的标配,但两者的默认行为凑在一起会出事故:
FlatList的onEndReachedThreshold默认0.5——距列表底部还有半屏就触发,不是滚到底才触发;- 触发条件满足期间(新页数据还没渲染进来、列表高度没变),
onEndReached可能被连续回调多次。
两个因素叠加:一次滑动连发三页请求,页游标相同、结果重复或互相覆盖。防御写法是把守卫做满:
// src/screens/FeedScreen.tsx
interface FeedPage {
items: Post[]
nextCursor: string | null
}
function fetchFeed(cursor: string | null): Promise<FeedPage> {
return api.get<FeedPage>(cursor ? `/feed?cursor=${cursor}` : '/feed')
}
function FeedScreen() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isRefetching } =
useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const items = useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data])
const onEndReached = useCallback(() => {
// 关键守卫:hasNextPage 之外必须挡住 isFetchingNextPage。
// 用 isRefetching 守卫下拉刷新,用 isFetchingNextPage 守卫分页,两者别混用。
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostRow post={item} />}
onEndReached={onEndReached}
onEndReachedThreshold={0.3} // 比默认 0.5 更晚触发,预取时机依然富余
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
)
}注意:分页请求的
queryFn务必保持幂等。如果fetchNextPage因为守卫缺失被重复调用,最坏情况也应该是"同一页拉两次、getNextPageParam收敛到同一游标",而不是数据错位。游标分页天然满足这一点,offset 分页(page=2&size=20)在数据被插入时不满足——服务端返回错位数据时优先怀疑这个。
缓存落盘(顺带一提)
前面说过 Hermes 的 fetch 不给你做磁盘缓存,Query 的内存缓存杀掉 App 就没了。冷启动"先展示上次数据再静默刷新"的体验,靠 @tanstack/query-async-storage-persister 配 AsyncStorage,或者包一个 MMKV 适配器(createJSONStorage 风格的 StateStorage 接口即可,写法见 08-存储与文件.md)来做。bust 提示:持久化整个 Query 缓存会让冷启动 IO 变重,用 shouldDehydrateQuery 只挑值得留的 key。
列表增量刷新
下拉刷新直接绑 Query 的 refetch 与 isRefetching,别自己再管 loading 状态:
<FlatList
data={items}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#3b82f6" // iOS 下拉圈颜色;Android 用 colors 属性
/>
}
/>refetch 会重新执行 queryFn 并替换第一页(InfiniteQuery 的 refetch 语义就是重刷游标起点);如果列表数据是"服务端增量下发"的,用 useQuery 拉增量 + queryClient.setQueryData 合并比整表 refetch 省流量。
后台静默刷新的时机选择:
// 回到前台时标记数据过期,由 focusManager 驱动的 refetchOnWindowFocus 处理
useEffect(() => {
const sub = AppState.addEventListener('change', (status) => {
if (status === 'active') {
queryClient.invalidateQueries({ queryKey: ['feed'] })
}
})
return () => sub.remove()
}, [])注意:如果已经接好了
focusManager(上文第二节),回到前台本来就会触发refetchOnWindowFocus,这里再invalidateQueries会造成同一时刻双刷。二选一:要么依赖 focus 驱动(被动、温和),要么手动 invalidate 并把refetchOnWindowFocus关掉(主动、可精确到 key)。
真正意义的"后台静默刷新"(App 在后台时拉数据)在 iOS 上基本不用考虑——JS 线程已挂起;Android 的 Headless JS 是另一套话题。移动端现实的做法是把刷新时机让给:回前台、深链返回、推送到达(收到消息推送时 invalidateQueries 对应 key),这三类事件驱动。
WebSocket
RN 内置了全局 WebSocket 构造器,用法与浏览器一致,无需额外依赖。移动端做长连接(聊天、协作、行情)要自己补上浏览器帮开发者兜底的两件事:心跳和重连。
// src/net/socket.ts —— 心跳 + 指数退避重连的最小实现
export class SocketClient {
private ws: WebSocket | null = null
private retries = 0
private heartbeat: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private closedByUser = false
constructor(
private readonly url: string,
private readonly onMessage: (event: MessageEvent) => void,
) {}
connect(): void {
this.closedByUser = false
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.retries = 0
// RN 的 WebSocket 不暴露协议层 ping 帧,心跳只能做成应用层 JSON 消息,
// 服务端按约定的 pong 回包判定连接还活着
this.heartbeat = setInterval(() => this.send({ type: 'ping' }), 25_000)
}
this.ws.onmessage = (event) => {
const payload = this.safeParse(event.data)
if (payload?.type !== 'pong') this.onMessage(event) // pong 只用于保活,不上抛
}
this.ws.onclose = () => {
this.clearTimers()
if (!this.closedByUser) this.scheduleReconnect()
}
this.ws.onerror = () => {
/* onerror 之后必然跟 onclose,重连逻辑统一在 onclose 里做 */
}
}
send(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
}
}
disconnect(): void {
this.closedByUser = true
this.clearTimers()
this.ws?.close()
}
private scheduleReconnect(): void {
const delay = Math.min(1_000 * 2 ** this.retries, 30_000)
this.retries += 1
this.reconnectTimer = setTimeout(() => this.connect(), delay)
}
private clearTimers(): void {
if (this.heartbeat) clearInterval(this.heartbeat)
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.heartbeat = null
this.reconnectTimer = null
}
private safeParse(raw: string): { type?: string } | null {
try {
return JSON.parse(raw)
} catch {
return null
}
}
}注意:上面的重连在 App 进后台后意义有限——JS 挂起时心跳发不出去,服务端早就把你踢了;回到前台大概率要走"主动重建连接 + 拉一次增量补漏"的流程,而不是指望旧连接还活着。长连接保活的现实方案是:
AppState变active时检查readyState,不是 OPEN 就直接connect()重建。
选型提醒:能用"下拉刷新 + 短轮询"解决的实时性需求,就别上长连接——每条常驻 WebSocket 都意味着持续的电量和基带唤醒成本,弱网下还会放大重连风暴。消息类、协作编辑类才值得长连接。
上传与下载
FormData 上传:RN 特有的"对象形态"文件
浏览器里上传文件是 <input type="file"> 拿到 File 对象塞进 FormData;RN 里没有 File 这个 DOM 类型,约定是用 { uri, name, type } 形状的对象冒充文件字段,原生网络栈(iOS/Android 各自的 multipart 编码器)认这个结构:
// src/api/upload.ts
export async function uploadPhoto(photoUri: string, caption: string, signal?: AbortSignal) {
const form = new FormData()
form.append('caption', caption)
// RN 特有约定:值传 { uri, name, type } 对象而不是 Blob/File。
// TS 官方类型没有覆盖这种写法,这里需要一次断言。
form.append('file', {
uri: photoUri, // 相册选择器返回的 file:// 开头 URI,见 06-表单与交互.md
name: 'photo.jpg',
type: 'image/jpeg',
} as unknown as string)
// 上传走 multipart,不能再设 Content-Type: application/json,
// 交给浏览器/RN 自动生成 boundary
return request<{ id: string }>('/photos', {
method: 'POST',
body: form as BodyInit,
signal,
headers: { 'Content-Type': undefined as unknown as string }, // 覆盖默认 JSON 头,让它回落到 multipart
timeoutMs: 60_000, // 上传超时给足
})
}三个容易翻车的点:字段名 uri/name/type 拼错(原生栈认不出就当普通字段序列化);uri 必须是本地文件 URI(远程 URL 会被当普通字符串);大文件上传的超时和重试要单独放宽,别复用 10 秒的 API 默认值。
下载:为什么 fetch 落盘力不从心
fetch 下载文件的问题在于:整个响应体先驻留在 JS 侧内存里(res.blob() / res.arrayBuffer()),大文件直接撞内存上限;也没有可靠的进度回调。两条出路:
- 优先用 expo-file-system 的下载能力:原生侧直接落盘,支持进度,文件类型和目录语义见
08-存储与文件.md; - 服务端配合分块:文件大且要断点续传时,服务端支持
Range请求头,客户端按区间请求、每段追加写入本地文件——这套只能自己实现,RN 的fetch不提供流式读取。
小文件(比如下载一份 JSON 配置)用 fetch 读文本再写盘完全没问题,别为杀鸡用牛刀。
图片网络
列表图片是移动端网络流量的大头,而且它的坑不在请求层,在渲染层。
缓存现实:<Image source={{ uri }}> 的缓存行为由原生栈决定,跨会话不保证命中,Hermes 侧也没有你熟悉的服务端缓存全量生效。所以列表图片要么接受反复下载,要么用带显式缓存策略的图片库(Expo 的 expo-image 支持 memory-disk(内存+磁盘组合)这类显式缓存策略,且提供淡入过渡——见 07-动画与手势.md 提及的视觉细节)。
预加载:Image.prefetch(url) 是 RN 长期稳定的 API,返回 Promise,把图片拉进原生侧缓存:
// 列表快滚到某项之前,提前拉下一屏的封面图
useEffect(() => {
const next = items.slice(items.length - 10, items.length)
const handles = next.map((item) => Image.prefetch(item.coverUrl))
return () => {
handles.forEach((h) => h.catch(() => undefined)) // 预加载失败静默,别炸渲染
}
}, [items])注意:预加载不保证成功、不保证持久(缓存满会被系统回收)、也不保证命中时机,只当"尽力而为的预热",UI 逻辑绝不能依赖"prefetch 完一定能秒出图"。
列表复用错位:FlatList 池化 cell——滚出屏幕的行被回收,重新进入时带着一张"上一任"的旧图。表现为:快速滚动时图片串位一瞬间、或列表数据变化后图和数据对不上。修复思路两层:
keyExtractor给稳定唯一 id(绝不要用 index),保证行和数据严格绑定,重排时不会张冠李戴;- 给图片行的
key拼上 uri(key={item.coverUrl}),强制图片视图随数据重建,牺牲一点复用换正确性;池化闪烁严重的长列表直接换expo-image,它的transition能盖掉旧帧闪现。
安全
强制 HTTPS。Android 9 起平台默认禁止明文 HTTP(当前模板 targetSdk 36,同样受此约束),iOS 的 ATS 默认也要求 TLS。开发时连本地 http:// 调试服务,Android 需要在 android/app/src/main/res/xml/network_security_config.xml 里对特定域名放行明文并在 manifest 声明引用,iOS 在 Info.plist 的 NSAppTransportSecurity 下开例外——这类配置只留给开发域名,别整个 release 配置图省事全放开。
证书校验与固定。默认校验由原生栈完成(系统信任链),别为了"证书报错"关闭校验(fetch 层面没有开关,但别引入绕过 Trust Manager 的原生配置)。生产 App 对抗中间人可以在原生层做证书固定(certificate pinning),但要清醒:换证就是发布事故现场——固定了叶子证书,续期后旧版本 App 集体断网。真要做就固定公钥/CA 且预留轮换与远程开关,成本不低,多数业务 App 用"强 TLS + 服务端校验 token"已经够。
token 存哪:永远不要放 AsyncStorage(明文磁盘存储)。访问令牌放 Keychain/Keystore,经 expo-secure-store,具体限制与用法见 08-存储与文件.md。
坑清单
- Android 模拟器里
localhost不通:模拟器的127.0.0.1是它自己,宿主机要写10.0.2.2。iOS 模拟器可以直接localhost。真机连开发机用局域网 IP(http://192.168.x.x:8081),注意 Android 还得放行明文。Expo 的 tunnel/LAN 模式免掉这套手工配置(SDK 58 还在改进开发服务器经代理与隧道的链路、提速 CLI 隧道),连调试 URL 都不用拼。 - Metro 端口被占:默认 8081,多项目并行时常撞车。
npx expo start --port <端口>换端口,或清掉旧进程;改端口后原生侧要能跟上(Expo 会自动处理,裸 RN 手动配RCT_METRO_PORT)。 - Response body 是一次性的:
res.json()调第二次抛 “body already consumed”。拦截器里要复用响应就先res.text()读出来缓存,再分发解析。 - AbortError 和业务错误混在一起:取消(卸载、超时联动)在 JS 里以
name === 'AbortError'的错误呈现,别把它统计进错误率、别弹 Toast。上文client.ts里code: 'abort'的单独分支就是为这个。 - 超时没有原生支持:忘了包
AbortController的请求会挂着到原生栈自己超时(可能几十秒起步),用户视角就是"卡死"。请求层封装时把超时做成默认注入,而不是指望每个调用点自觉传。 - Hermes 没有跨会话 HTTP 缓存:接口响应体大又"冷启动必拉"的场景,别等浏览器式缓存救你,主动落盘(Query persister 或自己写 MMKV)。
- CORS 报错只在 Web 端出现:见第一节,别在原生调试时追着它修。
- cookie 对 JS 不透明:依赖 cookie 会话的服务对接第三方 SDK 是常态,自家 API 一律 header token。
onEndReached默认半屏就触发:重复分页的根源在守卫不在回调,见 InfiniteQuery 一节。- 上传忘改 Content-Type:请求层默认注入的
application/json会毁掉 multipart 请求,上传通道要么绕过默认头,要么显式覆盖。
实战建议
移动端网络层的工作量应该集中在三件事:一个带超时/取消/token 注入/错误归一化的请求层(本章的 client.ts 直接改造可用);一次 TanStack Query 的事件源接线(onlineManager + focusManager,十五分钟的活儿换来离线自动暂停和回前台自动刷新);一套图片与分页列表的防坑约定(稳定 key、分页守卫、预加载只当预热)。其余的"浏览器差异"里,值得长期记住的只有一条:RN 里网络的一切底层语义归原生栈,JS 层没有任何"系统帮你缓存、帮你超时、帮你管 cookie"的隐形福利——缺的能力,全部要在应用层自己补。