Skip to content
数据获取与网络

数据获取与网络

移动端网络请求的 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 类页面的标配,但两者的默认行为凑在一起会出事故:

  1. FlatList 的 onEndReachedThreshold 默认 0.5——距列表底部还有半屏就触发,不是滚到底才触发;
  2. 触发条件满足期间(新页数据还没渲染进来、列表高度没变),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——滚出屏幕的行被回收,重新进入时带着一张"上一任"的旧图。表现为:快速滚动时图片串位一瞬间、或列表数据变化后图和数据对不上。修复思路两层:

  1. keyExtractor 给稳定唯一 id(绝不要用 index),保证行和数据严格绑定,重排时不会张冠李戴;
  2. 给图片行的 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"的隐形福利——缺的能力,全部要在应用层自己补。