<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Promise封装Fetch + 重试机制</title>
<link rel="stylesheet" href="./static/css/Promise封装Fetch.css">
</head>
<body>
<div class="container">
<h1>Promise 封装 Fetch + 重试机制</h1>
<p class="subtitle">自动重试 · 超时中断 · JSON处理 · 状态日志</p>
<div class="card">
<h3>测试请求</h3>
<div class="btn-group">
<button class="btn btn-success" onclick="testSuccess()">成功请求 (JSONPlaceholder)</button>
<button class="btn btn-danger" onclick="test404()">404 错误 (触发重试)</button>
<button class="btn btn-warning" onclick="testTimeout()">超时模拟 (延迟接口)</button>
<button class="btn btn-purple" onclick="testInvalidHost()">无效地址 (网络错误)</button>
</div>
</div>
<div class="card">
<h3>控制台输出</h3>
<div id="console">
<div class="log-line log-info">🚀 等待发起请求...</div>
</div>
</div>
</div>
<script>
// ==============================
// 核心: request 函数
// ==============================
/**
* 基于 Fetch 封装的请求函数,支持自动重试、超时中断
* @param {string} url - 请求地址
* @param {object} options - fetch 配置项 (method, body, headers 等)
* @param {number} retries - 剩余重试次数 (内部递归使用)
* @returns {Promise} - 返回解析后的 JSON 数据
*/
function request(url, options = {}, retries = 3) {
// ---- [步骤1] 打印请求前时间戳 ----
const startTime = Date.now();
const timeStr = new Date(startTime).toLocaleTimeString();
console.log(`%c[${timeStr}] ⏳ 发起请求: ${url}`, 'color: #00d4ff;');
logToScreen(`[${timeStr}] ⏳ 发起请求: ${url}`, 'log-info');
// ---- [步骤2] 合并默认 Headers,自动加 Content-Type ----
const defaultHeaders = { 'Content-Type': 'application/json' };
const mergedOptions = {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
};
// ---- [步骤3] 超时控制: 使用 AbortController ----
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
mergedOptions.signal = controller.signal;
// ---- [步骤4] 发起 fetch 请求 ----
return fetch(url, mergedOptions)
.then(response => {
clearTimeout(timeoutId); // 清除超时定时器
const elapsed = Date.now() - startTime;
const endTime = new Date().toLocaleTimeString();
console.log(`%c[${endTime}] ✅ 收到响应: ${response.status} (耗时 ${elapsed}ms)`, 'color: #2ecc71;');
logToScreen(`[${endTime}] ✅ 收到响应: ${response.status} (耗时 ${elapsed}ms)`, 'log-success');
// ---- [步骤5] 状态码非 2xx 时 reject ----
if (!response.ok) {
// 主动抛出错误,进入 catch 链触发重试
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.catch(error => {
clearTimeout(timeoutId);
const elapsed = Date.now() - startTime;
const endTime = new Date().toLocaleTimeString();
// 判断是否是超时错误
if (error.name === 'AbortError') {
console.log(`%c[${endTime}] ⏰ 请求超时! (耗时 ${elapsed}ms)`, 'color: #f39c12;');
logToScreen(`[${endTime}] ⏰ 请求超时! (耗时 ${elapsed}ms)`, 'log-retry');
error = new Error('请求超时(超过5秒)');
} else {
console.log(`%c[${endTime}] ❌ 请求失败: ${error.message}`, 'color: #e74c3c;');
logToScreen(`[${endTime}] ❌ 请求失败: ${error.message}`, 'log-error');
}
// ---- [步骤6] 重试逻辑: 剩余重试次数 > 0 则递归重试 ----
if (retries > 0) {
const remaining = retries - 1;
console.log(`%c🔄 1秒后重试... (剩余 ${retries} 次)`, 'color: #f39c12;');
logToScreen(`🔄 1秒后重试... (剩余 ${retries} 次)`, 'log-retry');
// 延迟 1 秒后递归调用
return new Promise(resolve => {
setTimeout(() => {
resolve(request(url, options, remaining));
}, 1000);
});
}
// 重试耗尽,最终 reject
console.log(`%c💀 已重试3次,全部失败!`, 'color: #e74c3c; font-weight: bold;');
logToScreen(`💀 已重试3次,全部失败!`, 'log-error');
throw error;
});
}
// ==============================
// 页面交互: 控制台输出
// ==============================
function logToScreen(message, className) {
const consoleDiv = document.getElementById('console');
const line = document.createElement('div');
line.className = `log-line ${className}`;
line.textContent = message;
consoleDiv.appendChild(line);
consoleDiv.scrollTop = consoleDiv.scrollHeight;
}
// ==============================
// async/await 调用示例
// ==============================
// 成功请求示例
async function testSuccess() {
try {
console.clear();
clearConsole();
logToScreen('--- 测试: 成功请求 ---', 'log-info');
// async/await 方式调用
const data = await request('https://jsonplaceholder.typicode.com/posts/1');
console.log('%c📦 返回数据:', 'color: #2ecc71; font-weight: bold;', data);
logToScreen(`📦 返回数据: ${JSON.stringify(data, null, 2)}`, 'log-result');
} catch (error) {
logToScreen(`最终失败: ${error.message}`, 'log-error');
}
}
// 404 错误示例 (触发重试)
async function test404() {
try {
console.clear();
clearConsole();
logToScreen('--- 测试: 404 错误 ---', 'log-info');
const data = await request('https://jsonplaceholder.typicode.com/posts/99999');
logToScreen(`📦 返回数据: ${JSON.stringify(data)}`, 'log-result');
} catch (error) {
logToScreen(`最终失败: ${error.message}`, 'log-error');
}
}
// 超时示例
async function testTimeout() {
try {
console.clear();
clearConsole();
logToScreen('--- 测试: 超时请求 (延迟接口) ---', 'log-info');
// 使用一个慢速接口来模拟超时
const data = await request('https://httpbin.org/delay/6');
logToScreen(`📦 返回数据: ${JSON.stringify(data)}`, 'log-result');
} catch (error) {
logToScreen(`最终失败: ${error.message}`, 'log-error');
}
}
// 无效地址示例 (网络错误,触发重试)
async function testInvalidHost() {
try {
console.clear();
clearConsole();
logToScreen('--- 测试: 无效地址 ---', 'log-info');
const data = await request('https://this-domain-does-not-exist-12345.com/api');
logToScreen(`📦 返回数据: ${JSON.stringify(data)}`, 'log-result');
} catch (error) {
logToScreen(`最终失败: ${error.message}`, 'log-error');
}
}
// POST 请求示例 (展示自定义 headers 合并)
async function testPost() {
try {
console.clear();
clearConsole();
logToScreen('--- 测试: POST 请求 ---', 'log-info');
const data = await request('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify({ title: 'foo', body: 'bar', userId: 1 }),
// Content-Type 会自动添加, 这里也可以覆盖
});
console.log('%c📦 POST 返回:', 'color: #2ecc71;', data);
logToScreen(`📦 POST 返回: ${JSON.stringify(data, null, 2)}`, 'log-result');
} catch (error) {
logToScreen(`最终失败: ${error.message}`, 'log-error');
}
}
function clearConsole() {
document.getElementById('console').innerHTML = '';
}
</script>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
padding: 40px 20px;
color: #e0e0e0;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 10px;
font-size: 28px;
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
text-align: center;
color: #8892b0;
margin-bottom: 30px;
font-size: 14px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 24px;
margin-bottom: 20px;
backdrop-filter: blur(10px);
}
.card h3 {
color: #00d4ff;
margin-bottom: 16px;
font-size: 16px;
}
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-success {
background: linear-gradient(135deg, #00b4d8, #0077b6);
color: #fff;
}
.btn-danger {
background: linear-gradient(135deg, #e63946, #c1121f);
color: #fff;
}
.btn-warning {
background: linear-gradient(135deg, #f4a261, #e76f51);
color: #fff;
}
.btn-purple {
background: linear-gradient(135deg, #7b2ff7, #5a189a);
color: #fff;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
#console {
background: #0a0a1a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 16px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.log-line {
padding: 4px 8px;
border-radius: 4px;
margin: 2px 0;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.log-info {
color: #00d4ff;
}
.log-success {
color: #2ecc71;
}
.log-error {
color: #e74c3c;
}
.log-retry {
color: #f39c12;
}
.log-time {
color: #8892b0;
}
.log-result {
background: rgba(46, 204, 113, 0.1);
border-left: 3px solid #2ecc71;
color: #a0ffc0;
}