mcpField
A
api-client

api-client

github.com/profpowell开发653 次安装今天更新
代码仓库

关于 api-client

面向构建 fetch API 客户端的 JavaScript 开发者技能,涵盖明确的错误处理、重试、取消、缓存、请求去重和离线队列。

主要功能

  1. 01类型化 fetch 封装
  2. 02明确的错误处理
  3. 03指数退避重试
  4. 04AbortController 取消请求
  5. 05缓存与请求去重
  6. 06离线变更队列

适用场景

  1. 01构建浏览器 API 客户端
  2. 02重试暂时性 API 故障
  3. 03避免重复的进行中请求
  4. 04离线时排队等待提交变更

安装

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

$npx skills add https://github.com/profpowell/vanilla-breeze --skill api-client

安装后在 Claude Code 中输入/api-client确认已加载。

详细介绍

API 客户端技能

基于 fetch 的 API 通信模式,包含稳健的错误处理、重试逻辑和响应缓存。

理念

  1. 仅使用原生 fetch() - 不使用 axios 或其他库
  2. 错误应明确 - 不吞掉 Promise
  3. 请求应可取消 - 使用 AbortController
  4. 优雅地处理失败 - 妥善处理网络问题

基础 Fetch 封装

围绕 fetch 构建的类型化封装,提供一致的错误处理:

/**
 * @typedef {Object} ApiError
 * @property {number} status - HTTP status code
 * @property {string} statusText - HTTP status text
 * @property {Object} [body] - Parsed error response body
 * @property {string} message - Error message
 */

/**
 * Custom error class for API failures
 */
class ApiError extends Error {
  /**
   * @param {Response} response
   * @param {Object} [body]
   */
  constructor(response, body = null) {
    super(`API Error: ${response.status} ${response.statusText}`);
    this.name = 'ApiError';
    this.status = response.status;
    this.statusText = response.statusText;
    this.body = body;
  }
}

/**
 * Base fetch wrapper with consistent error handling
 * @template T
 * @param {string} endpoint
 * @param {RequestInit} [options={}]
 * @returns {Promise<T>}
 * @throws {ApiError}
 */
async function api(endpoint, options = {}) {
  const response = await fetch(endpoint, {
    headers: {
      'Content-Type': 'application/json',
      ...options.headers
    },
    ...options
  });

  if (!response.ok) {
    let body = null;
    try {
      body = await response.json();
    } catch {
      // Response may not be JSON
    }
    throw new ApiError(response, body);
  }

  // Handle empty responses (204 No Content)
  if (response.status === 204) {
    return /** @type {T} */ (null);
  }

  return response.json();
}
用法
// GET request
const user = await api('/api/users/123');

// POST request
const newUser = await api('/api/users', {
  method: 'POST',
  body: JSON.stringify({ name: 'Alice', email: '[email protected]' })
});

// With error handling
try {
  const data = await api('/api/protected');
} catch (error) {
  if (error instanceof ApiError) {
    if (error.status === 401) {
      // Redirect to login
    } else if (error.status === 404) {
      // Show not found
    }
  }
  throw error;
}

错误处理模式

类型化错误响应
/**
 * @typedef {'validation' | 'auth' | 'not_found' | 'server'} ErrorType
 */

/**
 * @typedef {Object} ValidationError
 * @property {'validation'} type
 * @property {Object<string, string[]>} errors - Field-level errors
 */

/**
 * Check error type and handle appropriately
 * @param {ApiError} error
 */
function handleApiError(error) {
  const body = error.body;

  if (error.status === 400 && body?.type === 'validation') {
    // Show field-level errors
    Object.entries(body.errors).forEach(([field, messages]) => {
      console.error(`${field}: ${messages.join(', ')}`);
    });
    return;
  }

  if (error.status === 401) {
    // Redirect to login
    window.location.href = '/login';
    return;
  }

  if (error.status === 403) {
    // Show permission denied
    showNotification('You do not have permission for this action');
    return;
  }

  if (error.status >= 500) {
    // Show generic server error
    showNotification('Something went wrong. Please try again later.');
    return;
  }

  // Unknown error - log and show generic message
  console.error('Unexpected API error:', error);
  showNotification('An unexpected error occurred');
}
错误边界集成
/**
 * Report error to monitoring service
 * @param {Error} error
 * @param {Object} [context]
 */
function reportError(error, context = {}) {
  // Use sendBeacon for reliability
  navigator.sendBeacon('/api/errors', JSON.stringify({
    message: error.message,
    stack: error.stack,
    status: error instanceof ApiError ? error.status : undefined,
    url: window.location.href,
    timestamp: Date.now(),
    ...context
  }));
}

指数退避重试

/**
 * @typedef {Object} RetryOptions
 * @property {number} [retries=3] - Maximum retry attempts
 * @property {number} [delay=1000] - Initial delay in ms
 * @property {(status: number) => boolean} [retryOn] - Function to determine if status should retry
 */

/**
 * Fetch with automatic retry on failure
 * @template T
 * @param {string} url
 * @param {RequestInit} [options={}]
 * @param {RetryOptions} [retryOptions={}]
 * @returns {Promise<T>}
 */
async function fetchWithRetry(url, options = {}, retryOptions = {}) {
  const {
    retries = 3,
    delay = 1000,
    retryOn = (status) => status >= 500 && status <= 504
  } = retryOptions;

  let lastError;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await api(url, options);
    } catch (error) {
      lastError = error;

      // Don't retry on client errors or if not a retryable status
      if (error instanceof ApiError && !retryOn(error.status)) {
        throw error;
      }

      // Don't retry if we've exhausted attempts
      if (attempt === retries) {
        throw error;
      }

      // Exponential backoff: 1s, 2s, 4s, 8s...
      const backoff = delay * Math.pow(2, attempt);
      await new Promise(resolve => setTimeout(resolve, backoff));
    }
  }

  throw lastError;
}
用法
// Retry up to 3 times with exponential backoff
const data = await fetchWithRetry('/api/unstable-endpoint', {}, {
  retries: 3,
  delay: 1000,
  retryOn: (status) => status === 503 || status === 429
});

请求取消

超时模式
/**
 * Fetch with automatic timeout
 * @template T
 * @param {string} url
 * @param {RequestInit & {timeout?: number}} [options={}]
 * @returns {Promise<T>}
 */
async function fetchWithTimeout(url, options = {}) {
  const { timeout = 10000, ...fetchOptions } = options;

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    return await api(url, {
      ...fetchOptions,
      signal: controller.signal
    });
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error(`Request timed out after ${timeout}ms`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}
组件清理
class DataComponent extends HTMLElement {
  /** @type {AbortController | null} */
  #controller = null;

  async connectedCallback() {
    this.#controller = new AbortController();

    try {
      const data = await api('/api/data', {
        signal: this.#controller.signal
      });
      this.render(data);
    } catch (error) {
      if (error.name === 'AbortError') {
        // Component was disconnected - ignore
        return;
      }
      this.renderError(error);
    }
  }

  disconnectedCallback() {
    // Cancel any in-flight requests
    this.#controller?.abort();
  }
}

响应缓存

带 TTL 的内存缓存
/**
 * @typedef {Object} CacheEntry
 * @property {*} data
 * @property {number} expires
 */

/**
 * Create a cached fetch function
 * @param {number} ttl - Time to live in milliseconds
 * @returns {<T>(url: string, options?: RequestInit) => Promise<T>}
 */
function createCachedFetch(ttl = 60000) {
  /** @type {Map<string, CacheEntry>} */
  const cache = new Map();

  return async function cachedFetch(url, options = {}) {
    // Only cache GET requests
    const method = options.method?.toUpperCase() || 'GET';
    if (method !== 'GET') {
      return api(url, options);
    }

    const cacheKey = url;
    const cached = cache.get(cacheKey);

    if (cached && cached.expires > Date.now()) {
      return cached.data;
    }

    const data = await api(url, options);

    cache.set(cacheKey, {
      data,
      expires: Date.now() + ttl
    });

    return data;
  };
}
缓存失效
/**
 * Create a cache manager with invalidation
 */
function createCacheManager(ttl = 60000) {
  const cache = new Map();

  return {
    async fetch(url, options = {}) {
      // Same caching logic as above
    },

    /**
     * Invalidate specific URL
     * @param {string} url
     */
    invalidate(url) {
      cache.delete(url);
    },

    /**
     * Invalidate URLs matching pattern
     * @param {RegExp} pattern
     */
    invalidatePattern(pattern) {
      for (const key of cache.keys()) {
        if (pattern.test(key)) {
          cache.delete(key);
        }
      }
    },

    /**
     * Clear entire cache
     */
    clear() {
      cache.clear();
    }
  };
}

请求去重

防止重复发送进行中的请求:

/**
 * Create a deduplicated fetch function
 * @returns {<T>(url: string, options?: RequestInit) => Promise<T>}
 */
function createDedupedFetch() {
  /** @type {Map<string, Promise<*>>} */
  const pending = new Map();

  return async function dedupedFetch(url, options = {}) {
    const method = options.method?.toUpperCase() || 'GET';

    // Only dedupe GET requests
    if (method !== 'GET') {
      return api(url, options);
    }

    const key = url;

    // Return existing promise if request is in-flight
    if (pending.has(key)) {
      return pending.get(key);
    }

    // Create new request
    const promise = api(url, options).finally(() => {
      pending.delete(key);
    });

    pending.set(key, promise);
    return promise;
  };
}
用法
const dedupedFetch = createDedupedFetch();

// These will only make ONE network request
const [user1, user2, user3] = await Promise.all([
  dedupedFetch('/api/users/123'),
  dedupedFetch('/api/users/123'),
  dedupedFetch('/api/users/123')
]);

离线队列

将失败的变更请求加入队列,以便在联网后重试:

/**
 * @typedef {Object} QueuedRequest
 * @property {string} url
 * @property {RequestInit} options
 * @property {number} timestamp
 */

/**
 * Create an offline-capable mutation queue
 */
function createOfflineQueue() {
  const STORAGE_KEY = 'offline-queue';

  /**
   * Load queue from storage
   * @returns {QueuedRequest[]}
   */
  function loadQueue() {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : [];
  }

  /**
   * Save queue to storage
   * @param {QueuedRequest[]} queue
   */
  function saveQueue(queue) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
  }

  /**
   * Add request to queue
   * @param {string} url
   * @param {RequestInit} options
   */
  function enqueue(url, options) {
    const queue = loadQueue();
    queue.push({ url, options, timestamp: Date.now() });
    saveQueue(queue);
  }

  /**
   * Process all queued requests
   */
  async function flush() {
    const queue = loadQueue();
    const failed = [];

    for (const request of queue) {
      try {
        await api(request.url, request.options);
      } catch (error) {
        // Keep failed requests for next attempt
        failed.push(request);
      }
    }

    saveQueue(failed);
  }

  // Auto-flush when coming online
  window.addEventListener('online', flush);

  return {
    /**
     * Execute request, queue if offline
     * @template T
     * @param {string} url
     * @param {RequestInit} options
     * @returns {Promise<T>}
     */
    async execute(url, options) {
      if (!navigator.onLine) {
        enqueue(url, options);
        throw new Error('Request queued - you are offline');
      }

      try {
        return await api(url, options);
      } catch (error) {
        // Queue network errors for retry
        if (error.name === 'TypeError') {
          enqueue(url, options);
          throw new Error('Request queued - network error');
        }
        throw error;
      }
    },

    flush,
    getQueue: loadQueue
  };
}

完整的 API 客户端

组合所有模式:

/**
 * Create a full-featured API client
 * @param {Object} config
 * @param {string} config.baseUrl
 * @param {number} [config.timeout=10000]
 * @param {number} [config.cacheTtl=60000]
 * @param {number} [config.retries=3]
 */
function createApiClient(config) {
  const { baseUrl, timeout = 10000, cacheTtl = 60000, retries = 3 } = config;

  const cache = new Map();
  const pending = new Map();

  /**
   * @template T
   * @param {string} endpoint
   * @param {RequestInit & {skipCache?: boolean}} [options={}]
   * @returns {Promise<T>}
   */
  async function request(endpoint, options = {}) {
    const url = `${baseUrl}${endpoint}`;
    const method = options.method?.toUpperCase() || 'GET';
    const { skipCache = false, ...fetchOptions } = options;

    // Check cache for GET requests
    if (method === 'GET' && !skipCache) {
      const cached = cache.get(url);
      if (cached && cached.expires > Date.now()) {
        return cached.data;
      }
    }

    // Dedupe GET requests
    if (method === 'GET' && pending.has(url)) {
      return pending.get(url);
    }

    // Set up abort controller for timeout
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    const promise = fetchWithRetry(url, {
      ...fetchOptions,
      signal: controller.signal
    }, { retries })
      .then(data => {
        // Cache GET responses
        if (method === 'GET') {
          cache.set(url, { data, expires: Date.now() + cacheTtl });
        }
        return data;
      })
      .finally(() => {
        clearTimeout(timeoutId);
        pending.delete(url);
      });

    if (method === 'GET') {
      pending.set(url, promise);
    }

    return promise;
  }

  return {
    get: (endpoint, options) => request(endpoint, options),
    post: (endpoint, body, options) => request(endpoint, {
      method: 'POST',
      body: JSON.stringify(body),
      ...options
    }),
    put: (endpoint, body, options) => request(endpoint, {
      method: 'PUT',
      body: JSON.stringify(body),
      ...options
    }),
    delete: (endpoint, options) => request(endpoint, {
      method: 'DELETE',
      ...options
    }),
    invalidateCache: (pattern) => {
      for (const key of cache.keys()) {
        if (key.includes(pattern)) {
          cache.delete(key);
        }
      }
    }
  };
}

API 客户端检查清单

实现 API 调用时:

  • 所有请求都有超时设置
  • 使用 AbortController 取消请求
  • 错误包含状态码和解析后的响应体
  • 仅对安全/幂等操作进行重试
  • 缓存键包含所有相关参数
  • 组件清理时会中止进行中的请求
  • 考虑为变更请求使用离线队列
  • 使用 JSDoc 类型定义请求/响应结构

相关技能

  • state-management - Web Components 的客户端状态管理模式
  • authentication - 使用 JWT、会话、OAuth... 实现安全认证
  • rest-api - 使用 HTTP 方法、状态码等编写 REST API 端点...
  • observability - 实现错误跟踪、性能监控,并使用...

评论

支持 Markdown