Promise封装Fetch + 重试机制(HTML+JS)

上图!!!

HTML源码

html 复制代码
<!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>

CSS源码

css 复制代码
* {
    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;
}
相关推荐
Elastic 中国社区官方博客1 小时前
使用重新设计的 AutoOps 更快地进行 Elasticsearch 问题排查
大数据·运维·前端·人工智能·elasticsearch·搜索引擎·全文检索
无巧不成书02181 小时前
Vue+Vue-CLI全平台零基础搭建教程
前端·javascript·vue.js·vue·前端开发·vue-cli·前端环境搭建
何时梦醒1 小时前
⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信
前端·人工智能·react.js
bonechips1 小时前
React 组件通信:一套 TodoList 讲清 props 与回调
前端·react.js
渣波1 小时前
别把 TodoList 写成面条代码!一文吃透 React 组件通信与状态驱动的核心内功
前端
2601_964702891 小时前
Claude Opus 5 API 开发实战:对话、文本生成与结构化输出
java·服务器·前端
cellurw1 小时前
20260724 六组件编译收尾与全局构建验证
前端