Day-29-API请求fetch

Day 29: API请求fetch

🎯 学习目标

  • 理解什么是API以及HTTP协议基础
  • 掌握fetch API的基本用法
  • 学会处理GET、POST等常见请求
  • 了解异步数据获取和错误处理
  • 实现真实的天气查询应用

💡 核心概念

什么是API?

API(Application Programming Interface,应用程序编程接口)是不同软件系统之间通信的桥梁。在Web开发中,我们最常用的是Web API,它通过HTTP协议传输数据。

现实类比

  • API就像餐厅的服务员
  • 你(客户端)告诉服务员想要什么菜(请求)
  • 服务员去厨房(服务器)取菜
  • 最后把菜端给你(响应)

HTTP协议基础

HTTP请求方法

GET    - 获取数据(安全、幂等)
POST   - 创建新资源
PUT    - 更新整个资源
PATCH  - 部分更新资源
DELETE - 删除资源

HTTP状态码

200 - 成功 ✅
201 - 已创建 ✅
400 - 错误请求 ❌
401 - 未授权 ❌
403 - 禁止访问 ❌
404 - 未找到 ❌
500 - 服务器错误 ❌

Fetch API是什么?

Fetch API是现代浏览器提供的网络请求接口,它基于Promise设计,比传统的XMLHttpRequest更简洁、更强大。

基本语法

fetch(url, options)
  .then(response => response.json())  // 解析JSON
  .then(data => console.log(data))    // 处理数据
  .catch(error => console.error(error)); // 处理错误

📝 Fetch API详解

1. GET请求 – 获取数据

基本示例

// 获取用户信息
fetch('https://jsonplaceholder.typicode.com/users/1')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  })
  .then(user => {
    console.log('用户数据:', user);
    displayUserInfo(user);
  })
  .catch(error => {
    console.error('请求失败:', error);
  });

带参数的GET请求

// 使用URLSearchParams构建查询字符串
const params = new URLSearchParams({
  userId: 1,
  status: 'active'
});

fetch(`https://api.example.com/users?${params}`)
  .then(response => response.json())
  .then(data => console.log(data));

// 或者手动拼接
fetch('https://api.example.com/users?userId=1&status=active')
  .then(response => response.json())
  .then(data => console.log(data));

2. POST请求 – 创建数据

发送JSON数据

fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'foo',
    body: 'bar',
    userId: 1,
  }),
})
  .then(response => response.json())
  .then(data => {
    console.log('成功创建:', data);
    // 服务器返回的新创建的资源
    // { id: 101, title: 'foo', body: 'bar', userId: 1 }
  });

发送表单数据

const formData = new FormData();
formData.append('username', 'john');
formData.append('email', 'john@example.com');

fetch('https://api.example.com/users', {
  method: 'POST',
  body: formData, // 自动设置Content-Type为multipart/form-data
})
  .then(response => response.json())
  .then(data => console.log(data));

3. PUT请求 – 更新数据

fetch('https://jsonplaceholder.typicode.com/posts/1', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    id: 1,
    title: 'updated title',
    body: 'updated body',
    userId: 1,
  }),
})
  .then(response => response.json())
  .then(data => console.log('更新成功:', data));

4. DELETE请求 – 删除数据

fetch('https://jsonplaceholder.typicode.com/posts/1', {
  method: 'DELETE',
})
  .then(response => {
    if (response.ok) {
      console.log('删除成功');
    }
  });

5. 常用Request选项

fetch('https://api.example.com/data', {
  // HTTP方法
  method: 'GET', // 或 'POST', 'PUT', 'DELETE' 等

  // 请求头
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer YOUR_TOKEN',
    'Accept': 'application/json',
  },

  // 请求体
  body: JSON.stringify({ key: 'value' }),

  // 请求模式
  mode: 'cors', // 'cors', 'no-cors', 'same-origin'

  // 凭证模式
  credentials: 'include', // 'include', 'same-origin', 'omit'

  // 缓存模式
  cache: 'default', // 'default', 'no-cache', 'reload', 'force-cache'

  // 重定向模式
  redirect: 'follow', // 'follow', 'error', 'manual'

  // 引用者
  referrer: 'no-referrer', // 'client', 'no-referrer'

  // 优先级
  priority: 'high', // 'high', 'low', 'auto'
});

🎮 实战示例:天气查询应用

完整HTML结构

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>天气查询应用</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 20px;
    }

    .container {
      background: white;
      border-radius: 20px;
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
      padding: 40px;
      max-width: 500px;
      width: 100%;
    }

    h1 {
      text-align: center;
      color: #333;
      margin-bottom: 30px;
      font-size: 2em;
    }

    .search-box {
      display: flex;
      gap: 10px;
      margin-bottom: 30px;
    }

    #cityInput {
      flex: 1;
      padding: 15px;
      border: 2px solid #e0e0e0;
      border-radius: 10px;
      font-size: 16px;
      transition: border-color 0.3s;
    }

    #cityInput:focus {
      outline: none;
      border-color: #667eea;
    }

    #searchBtn {
      padding: 15px 30px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      border: none;
      border-radius: 10px;
      font-size: 16px;
      font-weight: bold;
      cursor: pointer;
      transition: transform 0.2s;
    }

    #searchBtn:hover {
      transform: scale(1.05);
    }

    #searchBtn:active {
      transform: scale(0.95);
    }

    .weather-info {
      display: none;
      text-align: center;
    }

    .weather-info.active {
      display: block;
    }

    .weather-icon {
      font-size: 80px;
      margin-bottom: 20px;
    }

    .temperature {
      font-size: 48px;
      font-weight: bold;
      color: #333;
      margin-bottom: 10px;
    }

    .description {
      font-size: 20px;
      color: #666;
      margin-bottom: 20px;
      text-transform: capitalize;
    }

    .details {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      gap: 15px;
      margin-top: 20px;
    }

    .detail-item {
      background: #f5f5f5;
      padding: 15px;
      border-radius: 10px;
    }

    .detail-label {
      font-size: 12px;
      color: #999;
      margin-bottom: 5px;
    }

    .detail-value {
      font-size: 18px;
      font-weight: bold;
      color: #333;
    }

    .loading {
      text-align: center;
      padding: 20px;
      font-size: 18px;
      color: #666;
    }

    .error {
      background: #ff4444;
      color: white;
      padding: 15px;
      border-radius: 10px;
      text-align: center;
      margin-top: 20px;
    }

    .history {
      margin-top: 30px;
      padding-top: 20px;
      border-top: 1px solid #e0e0e0;
    }

    .history-title {
      font-size: 16px;
      font-weight: bold;
      color: #333;
      margin-bottom: 15px;
    }

    .history-list {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
    }

    .history-item {
      background: #f0f0f0;
      padding: 8px 15px;
      border-radius: 20px;
      font-size: 14px;
      cursor: pointer;
      transition: background 0.2s;
    }

    .history-item:hover {
      background: #e0e0e0;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>🌤️ 天气查询</h1>

    <div class="search-box">
      <input
        type="text"
        id="cityInput"
        placeholder="输入城市名称..."
        autocomplete="off"
      >
      <button id="searchBtn">查询</button>
    </div>

    <div id="loading" class="loading" style="display: none;">
      加载中...
    </div>

    <div id="error" class="error" style="display: none;"></div>

    <div id="weatherInfo" class="weather-info">
      <div class="weather-icon" id="weatherIcon">☀️</div>
      <div class="temperature" id="temperature">--°C</div>
      <div class="description" id="description">--</div>

      <div class="details">
        <div class="detail-item">
          <div class="detail-label">湿度</div>
          <div class="detail-value" id="humidity">--%</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">风速</div>
          <div class="detail-value" id="windSpeed">-- m/s</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">体感温度</div>
          <div class="detail-value" id="feelsLike">--°C</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">气压</div>
          <div class="detail-value" id="pressure">-- hPa</div>
        </div>
      </div>
    </div>

    <div class="history">
      <div class="history-title">📍 搜索历史</div>
      <div class="history-list" id="historyList">
        <span style="color: #999; font-size: 14px;">暂无历史记录</span>
      </div>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>

完整JavaScript逻辑

// ===== 应用状态 =====
let searchHistory = [];

// ===== DOM元素 =====
const cityInput = document.getElementById('cityInput');
const searchBtn = document.getElementById('searchBtn');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const weatherInfo = document.getElementById('weatherInfo');
const historyList = document.getElementById('historyList');

// ===== 天气图标映射 =====
const weatherIcons = {
  '01d': '☀️', // 晴天
  '01n': '🌙',
  '02d': '⛅', // 多云
  '02n': '☁️',
  '03d': '☁️', // 阴天
  '03n': '☁️',
  '04d': '☁️',
  '04n': '☁️',
  '09d': '🌧️', // 小雨
  '09n': '🌧️',
  '10d': '🌦️', // 雨天
  '10n': '🌧️',
  '11d': '⛈️', // 雷阵雨
  '11n': '⛈️',
  '13d': '❄️', // 雪
  '13n': '❄️',
  '50d': '🌫️', // 雾
  '50n': '🌫️',
};

// ===== API配置 =====
const API_KEY = 'YOUR_OPENWEATHERMAP_API_KEY'; // 需要替换为真实API密钥
const API_BASE = 'https://api.openweathermap.org/data/2.5/weather';

// ===== 获取天气数据 =====
async function getWeather(city) {
  // 显示加载状态
  loading.style.display = 'block';
  error.style.display = 'none';
  weatherInfo.classList.remove('active');

  try {
    // 发送API请求
    const response = await fetch(
      `${API_BASE}?q=${encodeURIComponent(city)}&units=metric&lang=zh_cn&appid=${API_KEY}`
    );

    // 检查响应状态
    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('未找到该城市,请检查城市名称');
      } else if (response.status === 401) {
        throw new Error('API密钥无效,请检查配置');
      } else {
        throw new Error(`请求失败: ${response.status}`);
      }
    }

    // 解析JSON数据
    const data = await response.json();

    // 显示天气信息
    displayWeather(data);

    // 添加到历史记录
    addToHistory(city);

  } catch (err) {
    // 显示错误信息
    showError(err.message);
  } finally {
    // 隐藏加载状态
    loading.style.display = 'none';
  }
}

// ===== 显示天气信息 =====
function displayWeather(data) {
  const { main, weather, wind, name } = data;

  // 更新天气图标
  const iconCode = weather[0].icon;
  document.getElementById('weatherIcon').textContent =
    weatherIcons[iconCode] || '🌡️';

  // 更新温度
  document.getElementById('temperature').textContent =
    `${Math.round(main.temp)}°C`;

  // 更新天气描述
  document.getElementById('description').textContent =
    weather[0].description;

  // 更新详细信息
  document.getElementById('humidity').textContent = `${main.humidity}%`;
  document.getElementById('windSpeed').textContent = `${wind.speed} m/s`;
  document.getElementById('feelsLike').textContent =
    `${Math.round(main.feels_like)}°C`;
  document.getElementById('pressure').textContent = `${main.pressure} hPa`;

  // 显示天气信息
  weatherInfo.classList.add('active');
}

// ===== 显示错误信息 =====
function showError(message) {
  error.textContent = message;
  error.style.display = 'block';
}

// ===== 添加到历史记录 =====
function addToHistory(city) {
  // 移除重复项
  searchHistory = searchHistory.filter(item => item !== city);

  // 添加到开头
  searchHistory.unshift(city);

  // 限制历史记录数量
  if (searchHistory.length > 10) {
    searchHistory = searchHistory.slice(0, 10);
  }

  // 更新显示
  updateHistoryDisplay();

  // 保存到本地存储
  localStorage.setItem('weatherHistory', JSON.stringify(searchHistory));
}

// ===== 更新历史记录显示 =====
function updateHistoryDisplay() {
  if (searchHistory.length === 0) {
    historyList.innerHTML =
      '<span style="color: #999; font-size: 14px;">暂无历史记录</span>';
    return;
  }

  historyList.innerHTML = searchHistory
    .map(city => `<div class="history-item" onclick="searchCity('${city}')">${city}</div>`)
    .join('');
}

// ===== 从历史记录搜索 =====
function searchCity(city) {
  cityInput.value = city;
  getWeather(city);
}

// ===== 加载历史记录 =====
function loadHistory() {
  const saved = localStorage.getItem('weatherHistory');
  if (saved) {
    searchHistory = JSON.parse(saved);
    updateHistoryDisplay();
  }
}

// ===== 事件监听 =====
searchBtn.addEventListener('click', () => {
  const city = cityInput.value.trim();
  if (city) {
    getWeather(city);
  } else {
    showError('请输入城市名称');
  }
});

// 回车键搜索
cityInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') {
    searchBtn.click();
  }
});

// 页面加载时恢复历史记录
loadHistory();

// 聚焦输入框
cityInput.focus();

如何获取API密钥

  1. 访问 https://openweathermap.org/api
  2. 注册账号(免费)
  3. 在API keys页面获取密钥
  4. 将代码中的YOUR_OPENWEATHERMAP_API_KEY替换为你的密钥

⚠️ 重要注意事项

1. CORS(跨域资源共享)

什么是CORS?
浏览器的安全策略,限制网页向不同域的服务器发送请求。

常见CORS错误

Access to fetch at 'https://api.example.com' from origin 'https://yoursite.com'
has been blocked by CORS policy

解决方案

  • 服务器端设置正确的CORS头
  • 使用代理服务器
  • 开发环境可以使用CORS插件(仅用于测试)

2. 错误处理最佳实践

async function fetchWithErrorHandling(url, options = {}) {
  try {
    const response = await fetch(url, options);

    // 检查HTTP状态码
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new Error(
        errorData.message || `HTTP ${response.status}: ${response.statusText}`
      );
    }

    // 检查Content-Type
    const contentType = response.headers.get('content-type');
    if (!contentType || !contentType.includes('application/json')) {
      throw new Error('响应不是JSON格式');
    }

    return await response.json();

  } catch (error) {
    // 网络错误或JSON解析错误
    if (error instanceof TypeError) {
      throw new Error('网络连接失败');
    }
    throw error;
  }
}

// 使用示例
fetchWithErrorHandling('https://api.example.com/data')
  .then(data => console.log(data))
  .catch(error => console.error('错误:', error.message));

3. 请求超时处理

function fetchWithTimeout(url, options = {}, timeout = 5000) {
  return Promise.race([
    fetch(url, options),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('请求超时')), timeout)
    ),
  ]);
}

// 使用示例
fetchWithTimeout('https://api.example.com/data', {}, 3000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('错误:', error.message));

4. 取消请求(AbortController)

const controller = new AbortController();

// 5秒后自动取消
setTimeout(() => controller.abort(), 5000);

fetch('https://api.example.com/data', {
  signal: controller.signal
})
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('请求已取消');
    } else {
      console.error('其他错误:', error);
    }
  });

// 手动取消
// controller.abort();

5. API密钥安全

⚠️ 永远不要在前端代码中硬编码敏感的API密钥!

为什么?

  • 前端代码对所有人可见
  • 任何人都可以查看源代码窃取密钥

正确做法

  • 使用代理服务器转发请求
  • 服务器端保存密钥
  • 前端只请求自己的服务器

示例

// ❌ 错误做法
const API_KEY = 'sk-1234567890abcdef';
fetch('https://api.example.com/data?key=' + API_KEY);

// ✅ 正确做法
fetch('/api/weather?city=Beijing');
// 服务器端转发请求,添加密钥

6. 节流和防抖

节流(Throttle):限制函数执行频率

function throttle(func, delay) {
  let lastCall = 0;
  return function(...args) {
    const now = new Date().getTime();
    if (now - lastCall < delay) return;
    lastCall = now;
    return func.apply(this, args);
  };
}

// 使用示例:限制搜索频率
const throttledSearch = throttle((city) => {
  getWeather(city);
}, 1000); // 最少间隔1秒

防抖(Debounce):延迟执行,只在最后一次触发后执行

function debounce(func, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// 使用示例:输入停止后才搜索
const debouncedSearch = debounce((city) => {
  if (city) getWeather(city);
}, 500); // 停止输入500ms后执行

✍️ 练习任务

基础练习

  1. 练习fetch请求

    • 使用JSONPlaceholder API练习GET请求
    • 尝试获取用户列表、文章列表
    • 显示获取的数据
  2. 错误处理练习

    • 故意输入错误的URL
    • 实现完整的错误处理逻辑
    • 显示友好的错误提示

进阶练习

  1. 实现节流和防抖

    • 为搜索框添加防抖功能
    • 优化用户体验,减少API请求
  2. 添加加载状态

    • 在请求期间显示加载动画
    • 禁用搜索按钮防止重复点击

实战练习

  1. 扩展天气应用

    • 添加未来5天天气预报
    • 显示天气图表(温度变化趋势)
    • 支持多城市对比
  2. 创建其他API应用

    • GitHub用户搜索
    • 电影信息查询(TMDB API)
    • 新闻阅读器

🎓 今日挑战

挑战任务:增强版天气应用

需求

  1. 支持用户当前位置自动定位
  2. 添加多城市管理(保存偏好城市)
  3. 实现天气预警功能
  4. 添加天气分享功能(生成分享链接)

提示

  • 使用Geolocation API获取当前位置
  • 使用localStorage保存城市列表
  • 查阅天气API文档获取预警数据

💡 常见问题 FAQ

Q1: fetch和axios有什么区别?

Fetch API:

  • 原生浏览器API,无需安装
  • 基于Promise设计
  • 需要手动处理某些细节(如超时、取消)

Axios:

  • 第三方库,需要引入
  • 自动转换JSON数据
  • 内置请求/响应拦截器
  • 更好的错误处理
  • 支持请求超时配置

选择建议

  • 小项目:使用fetch(轻量)
  • 大项目:使用axios(功能完善)

Q2: 如何处理文件上传?

const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];

const formData = new FormData();
formData.append('file', file);
formData.append('description', 'My file');

fetch('https://api.example.com/upload', {
  method: 'POST',
  body: formData,
})
  .then(response => response.json())
  .then(data => console.log('上传成功:', data));

Q3: 如何设置请求超时?

参见上面的"请求超时处理"章节,使用Promise.race()实现。

Q4: 如何并行发送多个请求?

// Promise.all - 所有请求都成功
Promise.all([
  fetch('https://api.example.com/users'),
  fetch('https://api.example.com/posts'),
  fetch('https://api.example.com/comments'),
])
  .then(responses => Promise.all(responses.map(r => r.json())))
  .then(([users, posts, comments]) => {
    console.log('用户:', users);
    console.log('文章:', posts);
    console.log('评论:', comments);
  });

// Promise.allSettled - 等待所有请求完成(不管成功或失败)
Promise.allSettled([
  fetch('https://api.example.com/users'),
  fetch('https://api.example.com/posts'),
])
  .then(results => {
    results.forEach((result, i) => {
      if (result.status === 'fulfilled') {
        console.log(`请求${i + 1}成功:`, result.value);
      } else {
        console.error(`请求${i + 1}失败:`, result.reason);
      }
    });
  });

📚 拓展阅读

推荐资源

  1. MDN – Fetch API:
    https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API

  2. HTTP协议详解:
    https://developer.mozilla.org/zh-CN/docs/Web/HTTP

  3. RESTful API设计指南:
    https://restfulapi.net/

  4. 免费API资源:

    • JSONPlaceholder: https://jsonplaceholder.typicode.com/
    • OpenWeatherMap: https://openweathermap.org/api
    • GitHub API: https://docs.github.com/en/rest

下一步学习

  • Day 30: 项目6-天气查询应用(完整实现)
  • async/await深入
  • WebSocket实时通信
  • GraphQL API

下一步学习: Day 30: 项目6-天气查询应用