mcpField
E
expo-data-fetching

expo-data-fetching

github.com/expo开发3.5k28.4k 次安装今天更新
代码仓库

关于 expo-data-fetching

面向 Expo 与 React Native 开发者的网络开发技能,涵盖请求、数据获取、缓存、离线处理和认证。提供 fetch、React Query 与 Expo Router 加载器等实践指导。

主要功能

  1. 01指导 fetch 请求与错误处理
  2. 02涵盖 React Query 和 SWR
  3. 03设计加载与空状态
  4. 04管理认证令牌与刷新
  5. 05配置离线支持和请求取消
  6. 06使用环境变量配置 API

适用场景

  1. 01为应用接入 API
  2. 02为数据请求添加缓存
  3. 03排查网络请求故障
  4. 04实现离线场景与令牌管理

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/expo/skills --skill expo-data-fetching

安装后在 Claude Code 中输入/expo-data-fetching确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
references/
expo-router-loaders.md
offline-and-cancellation.md

详细介绍

Expo 网络

任何网络相关工作都必须使用此技能,包括 API 请求、数据获取、缓存或网络调试。

参考资料

根据需要查阅以下资源:

references/
  expo-router-loaders.md        Route-level data loading with Expo Router loaders (web, SDK 55+)
  offline-and-cancellation.md   NetInfo network status, offline-first React Query, AbortController

使用场景

在以下情况下使用此技能:

  • 实现 API 请求
  • 设置数据获取(React Query、SWR)
  • 使用 Expo Router 数据加载器(useLoaderData,Web SDK 55+)
  • 调试网络故障
  • 实现缓存策略
  • 处理离线场景
  • 身份验证/令牌管理
  • 配置 API URL 和环境变量

偏好

  • 避免使用 axios,优先使用 expo/fetch

每个屏幕都有四种状态

为加载数据的屏幕设计加载中、错误、空和内容状态。这些状态可以同时存在:刷新出错时,缓存内容仍应显示。

  • 加载中 ≠ 空。 空状态表示已完成加载,但结果为零项,而不是缺少数据。在检查列表长度之前,应先处理初始加载、失败和状态恢复。在 TanStack Query v5 中,isLoading 表示首次获取正在进行;已禁用或因离线而暂停的查询可能没有数据,但并不处于加载中。此时应显示前置条件或离线状态。
  • 空状态需要经过设计,而不是显示空白列表。 在 FlatList/FlashList 上使用 ListEmptyComponent:说明列表为空的原因,并提供相关的下一步操作。“还没有项目”可以提供创建操作;“没有结果”则应提供更改或清除搜索条件/筛选条件的操作。
  • 重新获取数据时保留过期内容。 即使刷新失败,也要渲染缓存的 data,并显示非阻塞错误和重试选项。首次获取时使用 isLoading 显示加载指示器,后台活动使用 isFetching;如果已知布局且初次加载较慢,优先使用骨架屏;用户主动刷新时使用 RefreshControl。
  • 等待状态恢复完成。 如果初始界面或重定向取决于持久化状态(身份验证令牌、引导标记),那么在该状态加载完成之前,根布局应不渲染任何内容,或显示启动画面。根据尚未恢复的状态做出判断,会导致每次冷启动都闪现错误界面,也会错误处理在状态恢复完成前到达的深层链接。

保存时应保留用户的工作。 变更提交期间,禁用重复提交。失败时,保留草稿、显示行内错误,并允许用户重试;只有成功后才清除或关闭草稿。如果采用乐观更新,失败时应恢复之前的值,或将编辑标记为未同步。请通过保存失败后重试的方式进行验证。

常见问题与解决方案

1. 基本的 Fetch 用法

简单的 GET 请求:

const fetchUser = async (userId: string) => {
  const response = await fetch(`https://api.example.com/users/${userId}`);

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  return response.json();
};

带请求体的 POST 请求:

const createUser = async (userData: UserData) => {
  const response = await fetch("https://api.example.com/users", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(userData),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message);
  }

  return response.json();
};

2. React Query(TanStack Query)

设置:

// app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      retry: 2,
    },
  },
});

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack />
    </QueryClientProvider>
  );
}

获取数据:

import { useQuery } from "@tanstack/react-query";

function UserProfile({ userId }: { userId: string }) {
  const { data, fetchStatus, error, refetch } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });

  if (data === undefined) {
    if (error) return <ErrorState message={error.message} onRetry={() => refetch()} />;
    if (fetchStatus === "paused") return <OfflineState />;
    return <Loading />;
  }

  return (
    <>
      {error && <InlineError message="Could not refresh. Showing saved data." onRetry={() => refetch()} />}
      {data === null ? <EmptyState message="User not found" /> : <Profile user={data} />}
    </>
  );
}

变更操作:

import { useMutation, useQueryClient } from "@tanstack/react-query";

function CreateUserForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  const handleSubmit = (data: UserData) => {
    if (mutation.isPending) return;
    mutation.mutate(data);
  };

  // Form keeps its draft on error and disables Submit while isLoading.
  return <Form onSubmit={handleSubmit} isLoading={mutation.isPending} error={mutation.error?.message} />;
}

3. 错误处理

全面的错误处理:

class ApiError extends Error {
  constructor(message: string, public status: number, public code?: string) {
    super(message);
    this.name = "ApiError";
  }
}

const fetchWithErrorHandling = async (url: string, options?: RequestInit) => {
  try {
    const response = await fetch(url, options);

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new ApiError(
        error.message || "Request failed",
        response.status,
        error.code
      );
    }

    return response.json();
  } catch (error) {
    if (error instanceof ApiError) {
      throw error;
    }
    // Network error (no internet, timeout, etc.)
    throw new ApiError("Network error", 0, "NETWORK_ERROR");
  }
};

重试逻辑:

const fetchWithRetry = async (
  url: string,
  options?: RequestInit,
  retries = 3
) => {
  for (let i = 0; i < retries; i++) {
    try {
      return await fetchWithErrorHandling(url, options);
    } catch (error) {
      if (i === retries - 1) throw error;
      // Exponential backoff
      await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
    }
  }
};

4. 身份验证

令牌管理:

import * as SecureStore from "expo-secure-store";

const TOKEN_KEY = "auth_token";

export const auth = {
  getToken: () => SecureStore.getItemAsync(TOKEN_KEY),
  setToken: (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token),
  removeToken: () => SecureStore.deleteItemAsync(TOKEN_KEY),
};

// Authenticated fetch wrapper
const authFetch = async (url: string, options: RequestInit = {}) => {
  const token = await auth.getToken();

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: token ? `Bearer ${token}` : "",
    },
  });
};

令牌刷新:

let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;

const getValidToken = async (): Promise<string> => {
  const token = await auth.getToken();

  if (!token || isTokenExpired(token)) {
    if (!isRefreshing) {
      isRefreshing = true;
      refreshPromise = refreshToken().finally(() => {
        isRefreshing = false;
        refreshPromise = null;
      });
    }
    return refreshPromise!;
  }

  return token;
};

5. 离线支持

使用 NetInfo 检测网络状态并设置离线优先的 React Query:请参阅 ./references/offline-and-cancellation.md。


6. 环境变量

使用环境变量配置 API:

Expo 支持使用 EXPO_PUBLIC_ 前缀的环境变量。这些变量会在构建时内联,并可在 JavaScript 代码中使用。

// .env
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_API_VERSION=v1

// Usage in code
const API_URL = process.env.EXPO_PUBLIC_API_URL;

const fetchUsers = async () => {
  const response = await fetch(`${API_URL}/users`);
  return response.json();
};

特定环境的配置:

// .env.development
EXPO_PUBLIC_API_URL=http://localhost:3000

// .env.production
EXPO_PUBLIC_API_URL=https://api.production.com

使用环境配置创建 API 客户端:

// api/client.ts
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;

if (!BASE_URL) {
  throw new Error("EXPO_PUBLIC_API_URL is not defined");
}

export const apiClient = {
  get: async <T,>(path: string): Promise<T> => {
    const response = await fetch(`${BASE_URL}${path}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  },

  post: async <T,>(path: string, body: unknown): Promise<T> => {
    const response = await fetch(`${BASE_URL}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  },
};

重要说明:

  • 只有带有 EXPO_PUBLIC_ 前缀的变量才会暴露给客户端包
  • 切勿将机密信息(具有写入权限的 API 密钥、数据库密码)放入 EXPO_PUBLIC_ 变量中,因为这些信息在构建后的应用中可见
  • 环境变量在构建时内联,而不是在运行时
  • 修改 .env 文件后,重新启动开发服务器
  • 对于 API 路由中的服务端机密信息,请使用不带 EXPO_PUBLIC_ 前缀的变量

TypeScript 支持:

// types/env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      EXPO_PUBLIC_API_URL: string;
      EXPO_PUBLIC_API_VERSION?: string;
    }
  }
}

export {};

7. 请求取消

组件卸载时使用 AbortController(React Query 会自动取消):请参阅 ./references/offline-and-cancellation.md。


决策树

User asks about networking
  |-- Route-level data loading (web, SDK 55+)?
  |   \-- Expo Router loaders — see references/expo-router-loaders.md
  |
  |-- Basic fetch?
  |   \-- Use fetch API with error handling
  |
  |-- Need caching/state management?
  |   |-- Complex app -> React Query (TanStack Query)
  |   \-- Simpler needs -> SWR or custom hooks
  |
  |-- Authentication?
  |   |-- Token storage -> expo-secure-store
  |   \-- Token refresh -> Implement refresh flow
  |
  |-- Error handling?
  |   |-- Network errors -> Check connectivity first
  |   |-- HTTP errors -> Parse response, throw typed errors
  |   \-- Retries -> Exponential backoff
  |
  |-- Offline support?
  |   |-- Check status -> NetInfo
  |   \-- Queue requests -> React Query persistence
  |
  |-- Environment/API config?
  |   |-- Client-side URLs -> EXPO_PUBLIC_ prefix in .env
  |   |-- Server secrets -> Non-prefixed env vars (API routes only)
  |   \-- Multiple environments -> .env.development, .env.production
  |
  \-- Performance?
      |-- Caching -> React Query with staleTime
      |-- Deduplication -> React Query handles this
      \-- Cancellation -> AbortController or React Query

常见错误

错误:未处理错误

const data = await fetch(url).then((r) => r.json());

正确:检查响应状态

const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();

错误:将令牌存储在 AsyncStorage 中

await AsyncStorage.setItem("token", token); // Not secure!

正确:使用 SecureStore 存储敏感数据

await SecureStore.setItemAsync("token", token);

调用示例

用户:“如何在 React Native 中发起 API 调用?” -> 使用 fetch,并添加错误处理

用户:“我应该使用 React Query 还是 SWR?” -> 复杂应用使用 React Query,简单需求使用 SWR

用户:“我的应用需要支持离线使用” -> 使用 NetInfo 获取状态,使用 React Query 持久化缓存

用户:“如何处理身份验证令牌?” -> 将令牌存储在 expo-secure-store 中,并实现刷新流程

用户:“API 调用速度很慢” -> 检查缓存策略,使用 React Query 的 staleTime 用户:“如何为开发和生产环境配置不同的 API URL?” -> 使用 EXPO_PUBLIC_ 环境变量,并配置 .env.development 和 .env.production 文件 用户:“API 密钥应该放在哪里?” -> 客户端可安全使用的密钥:放在 .env 中,并使用 EXPO_PUBLIC_ 前缀。机密密钥:仅在 API 路由中使用无前缀的环境变量

用户:“如何在 Expo Router 中为页面加载数据?” -> 有关路由级加载器(Web、SDK 55+),请参阅 references/expo-router-loaders.md。对于原生应用,请使用 React Query 或 fetch。

提交反馈

如果你在此技能中发现错误、误导性信息或过时信息,请报告问题,以便 Expo 改进:

npx --yes @expo/agent-cli@latest feedback --category skills --subject "expo-data-fetching" "<actionable feedback>"

只有在有具体且可执行的反馈时才提交。请尽可能提供相关背景信息。 如果 Agent 反复无法完成 Expo 任务,或用户不得不介入,请使用 --category evals 和相关 Expo 软件包或命令作为 --subject,简要报告任务、预期结果以及出错之处。

评论

支持 Markdown