前端人保命指南:搞定异步回调地狱,7天从Promises到async-await丝滑进阶

前端人保命指南:搞定异步回调地狱,7天从Promises到async-await丝滑进阶


别整那些虚的,先聊聊咱们为啥总被异步搞崩心态

说实话,我干了这么多年前端,最崩溃的时刻往往不是CSS布局崩了,也不是浏览器兼容性问题——是明明代码逻辑看着都对,控制台却疯狂输出undefined,或者接口数据死活拿不到手

那种无力感,就像你明明点了外卖,骑手显示"已送达",但门口啥也没有。你盯着代码,代码也盯着你,双方在沉默中互相怀疑人生。

那些年我们写过的"回调地狱",代码缩进比楼梯还长

还记得jQuery时代吗?那时候我们写异步大概是这样的:

// 经典回调地狱,缩进地狱级难度
$.ajax({
    url: '/api/getUser',
    success: function(user) {
        $.ajax({
            url: '/api/getOrders',
            data: { userId: user.id },
            success: function(orders) {
                $.ajax({
                    url: '/api/getOrderDetails',
                    data: { orderId: orders[0].id },
                    success: function(details) {
                        $.ajax({
                            url: '/api/getPaymentInfo',
                            data: { detailId: details.id },
                            success: function(payment) {
                                // 终于拿到了!但是代码已经在屏幕外面了...
                                console.log('支付信息:', payment);
                            },
                            error: function(err) {
                                console.error('获取支付信息失败:', err);
                            }
                        });
                    },
                    error: function(err) {
                        console.error('获取订单详情失败:', err);
                    }
                });
            },
            error: function(err) {
                console.error('获取订单列表失败:', err);
            }
        });
    },
    error: function(err) {
        console.error('获取用户信息失败:', err);
    }
});

看到没?这就是传说中的**“回调地狱”(Callback Hell)**。代码往右缩进得越来越深,最后你得横向滚动才能看到完整的代码。更恶心的是,错误处理得在每个层级都写一遍,漏一个就等着线上报错吧。

我当时写这种代码的时候,心里就一个念头:这玩意儿要是能像写同步代码那样从上到下写该多好。可惜那时候年少无知,不知道后面会有Promise来救我狗命。

为什么明明逻辑对了,接口数据就是拿不到,控制台全是undefined

这个问题坑过多少前端新人?我举个最经典的例子:

// 错误的示范:以为这样写能拿到数据
function getUserData() {
    let result;
    fetch('/api/user')
        .then(response => response.json())
        .then(data => {
            result = data;  // 数据确实赋值了
            console.log('内部:', result);  // 这里能打印出来
        });
    return result;  // 但是这里返回的是undefined!
}

const user = getUserData();
console.log('外部:', user);  // undefined,心态崩了

问题出在哪? fetch是异步的,当你return result的时候,网络请求可能还没完成,result还是初始的undefined。这就好比你让朋友去楼下买奶茶,他刚出门你就问他"奶茶呢",他肯定一脸懵逼。

正确的思维方式应该是:异步操作的结果,只能在异步的上下文里处理。你不能用同步的思维去"等待"一个异步结果,除非你用await

同步思维撞上异步世界的墙,脑子容易短路的那些瞬间

咱们写代码的时候,大脑默认是同步模式。比如:

// 同步思维:先A后B再C,天经地义
const a = doA();
const b = doB(a);
const c = doC(b);
console.log(c);

但异步世界不讲这个规矩。当你改成:

// 异步现实:谁先完事说不准
const a = await doA();  // 可能等2秒
const b = await doB(a); // 又等2秒
const c = await doC(b); // 再等2秒
// 总共等了6秒,用户骂娘了

这时候你就得琢磨:这三个操作有没有依赖关系?能不能并行? 同步思维会让你习惯性地一个接一个写,但异步世界里,并行往往才是正解

用户界面卡死、白屏、转圈圈,背锅侠永远是前端

最惨的是什么?是后端接口200ms就返回了,但你的页面白屏3秒。产品经理跑过来:"怎么加载这么慢?"你一查,发现是串行请求太多,或者某个异步操作阻塞了主线程

还有那种"转圈圈"转个没完的,用户以为页面死了,疯狂点击刷新。实际上可能是某个Promise一直没resolve,或者catch没写导致错误被吞了

前端作为用户直接接触的那一层,所有性能问题最后都会变成前端的问题。接口慢是后端的事,但页面卡死就是你没处理好异步加载策略。所以搞懂异步,真的是保命技能


扒一扒异步这玩意儿到底是个啥妖魔鬼怪

要搞定异步,得先明白JavaScript是单线程的。它一次只能干一件事,但又要处理网络请求、定时器、用户点击这些"耗时操作",怎么办?**事件循环(Event Loop)**就是它的解决方案。

事件循环(Event Loop)其实就是个无情的排队机器

想象一下,JavaScript引擎是个勤劳的打工人,手里有两个清单:

  1. 执行栈(Call Stack):现在正在干的活
  2. 任务队列(Task Queue):等着干的活

代码从上到下执行,遇到同步代码直接干,遇到异步代码(比如setTimeoutfetch)就扔给浏览器/Node.js的Web API去处理,然后继续往下走。等异步操作完了,回调函数会被塞进任务队列,等着事件循环来取。

console.log('1');  // 同步,直接执行

setTimeout(() => {
    console.log('2');  // 异步,扔进宏任务队列
}, 0);

Promise.resolve().then(() => {
    console.log('3');  // 异步,扔进微任务队列
});

console.log('4');  // 同步,直接执行

// 输出顺序:1 -> 4 -> 3 -> 2

为啥是1、4、3、2? 因为同步代码先执行完(1、4),然后事件循环检查微任务队列,发现有个Promise的then,执行(3),最后检查宏任务队列,执行setTimeout(2)。

这个顺序死记硬背没用,得理解微任务优先级高于宏任务这个规则。

宏任务和微任务那点爱恨情仇,谁插队谁先跑

**宏任务(Macrotask)**包括:

  • setTimeoutsetInterval
  • setImmediate(Node.js)
  • I/O操作
  • UI rendering

**微任务(Microtask)**包括:

  • Promise.then/catch/finally
  • MutationObserver
  • queueMicrotask

关键规则:每次事件循环,先清空所有微任务,再执行一个宏任务。也就是说,微任务能插队,宏任务得老老实实等着。

// 极端案例,看看你能不能猜对输出
setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);

Promise.resolve().then(() => {
    console.log('promise 1');
    Promise.resolve().then(() => console.log('promise 2'));
});

console.log('sync');

// 输出:sync -> promise 1 -> promise 2 -> timeout 1 -> timeout 2

看到没?两个setTimeout都是宏任务,但promise链是微任务,微任务里还能继续塞微任务,所以promise 2比timeout 1还早执行。这个机制要是搞不清,写复杂异步逻辑的时候时序能把你绕死。

Promise状态机:从Pending到Fulfilled或Rejected的内心戏

Promise其实是个状态机,三种状态:

  • Pending:进行中,还没结果
  • Fulfilled:成功,有了值(value)
  • Rejected:失败,有了原因(reason)

状态一旦改变就不可逆。Pending可以变成Fulfilled或Rejected,但Fulfilled和Rejected之间不能互相转换。

const promise = new Promise((resolve, reject) => {
    console.log('Promise executor 立即执行');  // 这是同步代码!
    
    setTimeout(() => {
        if (Math.random() > 0.5) {
            resolve('成功了!');
        } else {
            reject('失败了!');
        }
    }, 1000);
});

console.log('这行会在 Promise executor 之后打印');

// Promise的executor函数是同步执行的!
// 但then/catch里的回调是异步的

很多人误以为Promise全是异步的,其实executor是同步执行的。只有then/catch/finally里的回调才是异步的,被扔进微任务队列。

async/await披着同步外衣的异步狼,别被它骗了

async/await是ES2017引入的语法糖,本质上还是Promise。它只是让异步代码看起来像同步代码,但执行逻辑依然是异步的。

async function foo() {
    console.log('A');
    await Promise.resolve();  // 这里会暂停,但把后面的代码扔进微任务
    console.log('B');  // 这行变成异步了!
}

foo();
console.log('C');

// 输出:A -> C -> B

关键点await关键字会"暂停"async函数的执行,但它后面的代码实际上被转换成了Promise的then回调,所以变成了异步。这就是为什么上面输出是A、C、B而不是A、B、C。

这个特性有时候会导致意外的时序问题,特别是当你以为await能阻塞整个程序的时候——它只阻塞async函数内部,不阻塞外部代码


Promise这波操作到底香在哪,又坑在哪

Promise的出现确实拯救了回调地狱,但也不是银弹,它有自己的甜蜜点和陷阱

链式调用让代码终于能读得像人话了

Promise最大的贡献就是链式调用(Chaining),让异步流程能从上到下读:

// 用Promise重构之前的回调地狱
fetch('/api/user')
    .then(response => {
        if (!response.ok) throw new Error('网络错误');
        return response.json();
    })
    .then(user => {
        console.log('拿到用户:', user);
        return fetch(`/api/orders?userId=${user.id}`);
    })
    .then(response => response.json())
    .then(orders => {
        console.log('拿到订单:', orders);
        return fetch(`/api/order-details?orderId=${orders[0].id}`);
    })
    .then(response => response.json())
    .then(details => {
        console.log('拿到详情:', details);
        // 继续处理...
    })
    .catch(error => {
        // 一个catch捕获链上所有错误!
        console.error('某个环节出错了:', error);
    });

看着舒服多了对吧?错误处理集中在一个catch里,不用每层都写。而且每个then返回新的Promise,可以一直链下去。

但链式调用也有坑:忘记return

// 错误示范:then里没return,链断了
fetch('/api/user')
    .then(response => {
        response.json();  // 没return!
    })
    .then(data => {
        // 这里的data是undefined,因为上一个then没返回Promise
        console.log(data);
    });

// 正确写法
fetch('/api/user')
    .then(response => {
        return response.json();  // 必须return
    })
    .then(data => {
        console.log(data);  // 正常拿到数据
    });

箭头函数隐式返回可以省点代码:

fetch('/api/user')
    .then(response => response.json())  // 隐式return
    .then(data => console.log(data));

.then().catch() 处理错误的优雅姿势 vs 漏写catch的火葬场

Promise的错误处理看起来优雅,但漏写catch就是灾难

// 火葬场现场:没catch,错误被吞了,程序静默失败
fetch('/api/data')
    .then(response => response.json())
    .then(data => processData(data));
    // 如果processData抛错,或者fetch失败,这里没catch
    // 你会在控制台看到"UnhandledPromiseRejection",然后程序继续跑
    // 但你的数据没了,页面可能白屏,你还不知道为啥

// 优雅姿势:每个链都要有个catch
fetch('/api/data')
    .then(response => response.json())
    .then(data => processData(data))
    .catch(error => {
        console.error('处理失败:', error);
        showErrorUI(error);  // 给用户看错误提示
    });

更隐蔽的坑:catch本身也会返回Promise,如果你catch后还想继续链式调用,得注意:

fetch('/api/user')
    .then(response => response.json())
    .catch(error => {
        console.error('获取用户失败:', error);
        return { name: '匿名用户' };  // catch后返回默认值,链继续
    })
    .then(user => {
        // 即使前面出错了,这里也能拿到匿名用户对象
        console.log('当前用户:', user);
    });

并发处理神器 Promise.all 和 Promise.race,谁先完事谁赢

有时候你需要同时发起多个请求,等所有结果回来再处理,这时候Promise.all是神器:

// 并行获取用户、订单、配置,比串行快3倍
async function loadDashboardData() {
    try {
        const [user, orders, config] = await Promise.all([
            fetch('/api/user').then(r => r.json()),
            fetch('/api/orders').then(r => r.json()),
            fetch('/api/config').then(r => r.json())
        ]);
        
        console.log('全部加载完成:', { user, orders, config });
        renderDashboard(user, orders, config);
    } catch (error) {
        // 任何一个失败都会进这里
        console.error('至少一个接口挂了:', error);
    }
}

Promise.all的坑一个失败,全部失败。如果某个接口挂了,整个Promise.all直接reject,你拿不到其他成功的结果。

如果你需要不管成功失败,都要等所有请求完成,用Promise.allSettled(ES2020):

const results = await Promise.allSettled([
    fetch('/api/user'),
    fetch('/api/orders'),  // 假设这个会失败
    fetch('/api/config')
]);

// results是数组,每个元素有status: 'fulfilled' | 'rejected'
results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
        console.log(`请求${index}成功:`, result.value);
    } else {
        console.error(`请求${index}失败:`, result.reason);
    }
});

Promise.race则是另一个极端:谁先完成(无论成功失败)就返回谁

// 超时控制:5秒内没返回就报错
const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), 5000);
});

try {
    const data = await Promise.race([
        fetch('/api/slow-endpoint'),
        timeoutPromise
    ]);
    console.log('在5秒内拿到了数据:', data);
} catch (error) {
    console.error('要么接口挂了,要么超时了:', error);
}

内存泄漏警告:忘了回收的Promise就像没关的水龙头

Promise虽然好用,但创建了就无法取消(除非用AbortController,后面讲)。如果你在组件里创建了大量Promise,但组件卸载了,Promise还在跑,就可能内存泄漏状态更新到已卸载组件

// React组件里的经典内存泄漏
useEffect(() => {
    fetch('/api/data')
        .then(response => response.json())
        .then(data => {
            // 如果组件已经卸载,这里setState会报错
            setData(data);
        });
}, []);

// 正确做法:用AbortController取消请求
useEffect(() => {
    const controller = new AbortController();
    
    fetch('/api/data', { signal: controller.signal })
        .then(response => response.json())
        .then(data => {
            setData(data);
        })
        .catch(err => {
            if (err.name === 'AbortError') {
                console.log('请求被取消,正常');
            } else {
                console.error('其他错误:', err);
            }
        });
    
    // 清理函数:组件卸载时取消请求
    return () => {
        controller.abort();
    };
}, []);

AbortController是现代浏览器提供的取消异步操作的标准方式,axios也支持。记住:任何可能在组件卸载后完成的异步操作,都要考虑取消机制


async/await:让异步代码看起来像同步的魔法

如果说Promise是回调地狱的救生艇,那async/await就是豪华游轮。它让异步代码的可读性提升了一个维度。

别再.then()到底了,try/catch 才是成年人的错误处理方式

async/await最大的好处是可以用try/catch处理异步错误,就像写同步代码一样自然:

// Promise风格的错误处理
fetchUser()
    .then(user => fetchOrders(user.id))
    .then(orders => processOrders(orders))
    .catch(error => handleError(error));

// async/await风格,是不是顺眼多了?
async function loadUserData() {
    try {
        const user = await fetchUser();
        const orders = await fetchOrders(user.id);
        const processed = processOrders(orders);
        return processed;
    } catch (error) {
        handleError(error);
        // 可以选择重新抛出,或者返回默认值
        throw error;
    }
}

try/catch能捕获await的reject,也能捕获同步抛错,一网打尽。而且你可以用多个catch块处理不同类型的错误:

async function robustFetch() {
    try {
        const data = await fetchData();
        return data;
    } catch (error) {
        if (error.name === 'NetworkError') {
            // 网络问题,重试或提示用户检查网络
            return await retryFetch();
        } else if (error.status === 401) {
            // 未授权,跳转登录
            redirectToLogin();
            return null;
        } else {
            // 其他错误,上报日志
            reportError(error);
            throw error;
        }
    }
}

await 后面必须跟Promise?不然就是脱裤子放屁

很多人以为await后面只能跟Promise,其实await后面可以跟任何值

// await 普通值,相当于Promise.resolve(值)
async function test() {
    const a = await 42;  // 等同于 await Promise.resolve(42)
    console.log(a);  // 42
    
    const b = await 'hello';  // 也能await字符串
    console.log(b);  // 'hello'
    
    const c = await null;  // 甚至null
    console.log(c);  // null
}

// 那await有啥用?主要是等Promise,但也能等thenable对象
const thenable = {
    then(resolve, reject) {
        setTimeout(() => resolve('我是thenable'), 100);
    }
};

async function testThenable() {
    const result = await thenable;  // 也能await!
    console.log(result);  // '我是thenable'
}

实际意义:有时候你不知道一个值是不是Promise,直接await它,是Promise就等,不是Promise就当同步值用,代码更健壮。

串行执行还是并行执行?别把性能跑丢了还不知道

这是async/await最常见的性能陷阱:习惯性await,导致串行执行

// 性能杀手:串行执行,总耗时 = time1 + time2 + time3
async function slowWay() {
    const user = await fetchUser();      // 假设1秒
    const orders = await fetchOrders();  // 假设1秒
    const config = await fetchConfig();  // 假设1秒
    // 总共3秒!用户等哭了
    return { user, orders, config };
}

// 正确姿势:并行执行,总耗时 = max(time1, time2, time3)
async function fastWay() {
    // 先同时发起所有请求
    const userPromise = fetchUser();
    const ordersPromise = fetchOrders();
    const configPromise = fetchConfig();
    
    // 再一起await
    const user = await userPromise;
    const orders = await ordersPromise;
    const config = await configPromise;
    // 总共1秒左右!
    return { user, orders, config };
}

// 更简洁的写法:Promise.all
async function fastWay2() {
    const [user, orders, config] = await Promise.all([
        fetchUser(),
        fetchOrders(),
        fetchConfig()
    ]);
    return { user, orders, config };
}

原则:如果多个异步操作没有依赖关系,一定要并行!别一个一个await。

但如果有依赖关系,串行是必须的:

// 必须先拿用户ID,才能查订单,必须串行
async function dependentWay() {
    const user = await fetchUser();  // 必须先完成
    const orders = await fetchOrders(user.id);  // 依赖user.id
    return orders;
}

top-level await 在模块里的新玩法,直接爽翻天

以前await只能在async函数里用,ES2022引入了top-level await,模块顶层也能直接await了:

// config.js 模块
// 以前得这么写,很丑
let config;
async function init() {
    config = await fetch('/api/config').then(r => r.json());
}
init();

export { config };  // 导出的时候可能还没初始化完!

// 现在可以直接在模块顶层await
const response = await fetch('/api/config');
export const config = await response.json();

// 其他模块导入时,会自动等待config加载完成
// import { config } from './config.js';  // 保证config已就绪

应用场景

  • 模块初始化需要异步数据(配置、用户权限等)
  • 动态导入模块(await import('./heavy-module.js')
  • 条件导出(根据环境导出不同实现)
// 根据环境变量选择不同实现
const implementation = process.env.NODE_ENV === 'development'
    ? await import('./mock-api.js')
    : await import('./real-api.js');

export const { fetchData } = implementation;

注意:top-level await会阻塞模块加载,用多了可能影响启动性能,别滥用。


实际干活时这些场景你肯定躲不掉

理论知识再多,不如看看真实业务里怎么玩。这几个场景,你100%会遇到。

封装axios请求拦截器,统一处理Token过期和全局报错

实际项目里不会裸用fetch,都会封装axios。拦截器是处理异步流程的绝佳案例:

// utils/request.js
import axios from 'axios';
import { message } from 'antd';  // 假设用antd
import { useUserStore } from '@/stores/user';  // 假设用pinia/zustand

const request = axios.create({
    baseURL: import.meta.env.VITE_API_BASE_URL,
    timeout: 10000,
    headers: {
        'Content-Type': 'application/json'
    }
});

// 请求拦截器:加token、加loading等
request.interceptors.request.use(
    (config) => {
        const token = localStorage.getItem('token');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        
        // 可以在这里加全局loading
        if (config.showLoading !== false) {
            window.showGlobalLoading?.();
        }
        
        return config;
    },
    (error) => {
        return Promise.reject(error);
    }
);

// 响应拦截器:统一错误处理、token过期等
request.interceptors.response.use(
    (response) => {
        // 关闭loading
        if (response.config.showLoading !== false) {
            window.hideGlobalLoading?.();
        }
        
        // 业务逻辑错误(后端返回200但data里code不为0)
        const { code, data, msg } = response.data;
        if (code !== 0) {
            message.error(msg || '操作失败');
            return Promise.reject(new Error(msg));
        }
        
        return data;  // 直接返回业务数据,省得每次.data
    },
    async (error) => {
        // 关闭loading
        window.hideGlobalLoading?.();
        
        const { response, config } = error;
        
        // 401未授权,token过期
        if (response?.status === 401) {
            // 清除旧token
            localStorage.removeItem('token');
            
            // 如果配置了自动刷新token,这里可以处理
            if (config.retryCount && config.retryCount > 0) {
                try {
                    const newToken = await refreshToken();  // 调用刷新接口
                    localStorage.setItem('token', newToken);
                    config.headers.Authorization = `Bearer ${newToken}`;
                    config.retryCount--;
                    return request(config);  // 重试原请求
                } catch (refreshError) {
                    // 刷新也失败了,跳转登录
                    redirectToLogin();
                    return Promise.reject(refreshError);
                }
            }
            
            redirectToLogin();
            return Promise.reject(new Error('登录已过期,请重新登录'));
        }
        
        // 其他HTTP错误
        const errorMsg = response?.data?.msg || 
                        response?.statusText || 
                        '网络错误,请稍后重试';
        message.error(errorMsg);
        
        return Promise.reject(error);
    }
);

// 辅助函数:刷新token
async function refreshToken() {
    const refreshToken = localStorage.getItem('refreshToken');
    const { data } = await axios.post('/api/refresh-token', { refreshToken });
    return data.token;
}

function redirectToLogin() {
    window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}

export default request;

使用的时候就很爽了:

import request from '@/utils/request';

// 自动带token、自动处理错误、自动显示loading
async function getUserList() {
    try {
        const users = await request.get('/api/users', {
            params: { page: 1, size: 10 },
            showLoading: true  // 控制是否显示全局loading
        });
        return users;
    } catch (error) {
        // 错误已经被拦截器处理过了(提示了用户)
        // 这里可以选择性处理,或者干脆不catch
        console.log('获取失败,但用户已经看到提示了');
        return [];
    }
}

多个接口依赖,先拿用户ID再查详情,怎么串联最丝滑

业务里经常有这种瀑布流依赖:A接口返回id,B接口用id查详情,C接口用详情里的某个字段再查其他数据。

// 场景:查看订单详情,需要串联多个接口
async function loadOrderDetail(orderId) {
    try {
        // 1. 先拿订单基础信息
        const order = await request.get(`/api/orders/${orderId}`);
        
        // 2. 用订单里的userId查用户信息
        const [user, products, logistics] = await Promise.all([
            request.get(`/api/users/${order.userId}`),
            // 3. 用订单里的productIds批量查商品(可能多个)
            request.post('/api/products/batch', { 
                ids: order.productIds 
            }),
            // 4. 如果有物流单号,查物流
            order.trackingNumber 
                ? request.get(`/api/logistics/${order.trackingNumber}`)
                : Promise.resolve(null)  // 没有物流单号就返回null
        ]);
        
        // 5. 组装完整数据
        return {
            ...order,
            userName: user.name,
            userAvatar: user.avatar,
            products: products.map(p => ({
                name: p.name,
                price: p.price,
                image: p.mainImage
            })),
            logistics: logistics?.routes || []
        };
    } catch (error) {
        console.error('加载订单详情失败:', error);
        throw error;
    }
}

技巧

  • 没有依赖关系的并行(Promise.all)
  • 有依赖关系的串行(await)
  • 条件请求用三元运算符 + Promise.resolve(null)占位

上传大文件分片并发控制,别让浏览器直接卡死

上传大文件要分片,但并发数不能太高,不然浏览器卡死。需要实现并发池

class FileUploader {
    constructor(file, options = {}) {
        this.file = file;
        this.chunkSize = options.chunkSize || 1024 * 1024; // 默认1MB一片
        this.concurrency = options.concurrency || 3; // 同时上传3片
        this.chunks = this.createChunks();
        this.uploadedChunks = new Set(); // 记录已上传的分片
    }
    
    // 将文件切成多块
    createChunks() {
        const chunks = [];
        let start = 0;
        while (start < this.file.size) {
            const end = Math.min(start + this.chunkSize, this.file.size);
            chunks.push(this.file.slice(start, end));
            start = end;
        }
        return chunks;
    }
    
    // 上传单个分片
    async uploadChunk(chunk, index, retryCount = 3) {
        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('index', index);
        formData.append('total', this.chunks.length);
        formData.append('filename', this.file.name);
        
        for (let i = 0; i < retryCount; i++) {
            try {
                const result = await request.post('/api/upload/chunk', formData, {
                    headers: { 'Content-Type': 'multipart/form-data' }
                });
                this.uploadedChunks.add(index);
                return result;
            } catch (error) {
                if (i === retryCount - 1) throw error;
                console.warn(`分片${index}${i+1}次重试...`);
                await this.delay(1000 * (i + 1)); // 指数退避
            }
        }
    }
    
    // 并发控制核心:用Promise.race实现池化
    async upload() {
        const pool = new Set(); // 正在执行的上传任务
        const results = [];
        
        for (let i = 0; i < this.chunks.length; i++) {
            // 如果该分片已上传(断点续传),跳过
            if (this.uploadedChunks.has(i)) {
                console.log(`分片${i}已存在,跳过`);
                continue;
            }
            
            const promise = this.uploadChunk(this.chunks[i], i).then(result => {
                pool.delete(promise); // 完成后从池子移除
                return result;
            });
            
            pool.add(promise);
            results.push(promise);
            
            // 池子满了就等一个完成再继续
            if (pool.size >= this.concurrency) {
                await Promise.race(pool);
            }
        }
        
        // 等待所有剩余任务完成
        await Promise.all(results);
        
        // 通知后端合并分片
        return this.mergeChunks();
    }
    
    async mergeChunks() {
        return request.post('/api/upload/merge', {
            filename: this.file.name,
            total: this.chunks.length
        });
    }
    
    delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}

// 使用
const uploader = new FileUploader(largeFile, { concurrency: 3 });
try {
    const result = await uploader.upload();
    console.log('上传成功:', result);
} catch (error) {
    console.error('上传失败:', error);
    // 可以保存进度,下次断点续传
}

关键点

  • Promise.race(pool)实现并发控制:池子满了就等最快的完成
  • 每个分片独立重试,失败不影响其他分片
  • 支持断点续传:刷新页面后跳过已上传分片

防抖节流配合异步请求,搜索框别再每敲一个字都调接口

搜索框输入时防抖是必须的,但异步请求的时序问题容易被忽略:

// 错误的防抖:只防抖了函数调用,没处理响应时序
function debounce(fn, delay) {
    let timer;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
    };
}

// 问题:用户输入"abc",停300ms触发请求
// 但请求A(搜"abc")耗时2秒,请求B(搜"abcd")耗时200ms
// 结果B先回来,A后回来,页面显示"abc"的结果,用户看到的是"abcd"的搜索词
const search = debounce(async (keyword) => {
    const results = await fetchSearchResults(keyword);
    renderResults(results); // 可能渲染错的结果!
}, 300);

// 正确的防抖:加上请求取消机制
function createAbortableDebounce(fn, delay) {
    let timer;
    let currentController = null;
    
    return async function(...args) {
        clearTimeout(timer);
        
        // 取消之前的请求
        if (currentController) {
            currentController.abort();
        }
        
        currentController = new AbortController();
        const signal = currentController.signal;
        
        timer = setTimeout(async () => {
            try {
                await fn.apply(this, [...args, signal]);
            } catch (error) {
                if (error.name !== 'AbortError') {
                    throw error;
                }
            }
        }, delay);
    };
}

// 使用
const search = createAbortableDebounce(async (keyword, signal) => {
    try {
        const response = await fetch(`/api/search?q=${keyword}`, { signal });
        const results = await response.json();
        
        // 只有没被取消的请求才渲染
        if (!signal.aborted) {
            renderResults(results);
        }
    } catch (error) {
        if (error.name === 'AbortError') {
            console.log('请求被取消,正常');
        } else {
            showError(error);
        }
    }
}, 300);

// React Hook版本
function useDebouncedSearch() {
    const [results, setResults] = useState([]);
    const abortControllerRef = useRef(null);
    
    const debouncedSearch = useMemo(
        () => debounce(async (keyword) => {
            // 取消上次请求
            abortControllerRef.current?.abort();
            abortControllerRef.current = new AbortController();
            
            try {
                const response = await fetch(
                    `/api/search?q=${keyword}`, 
                    { signal: abortControllerRef.current.signal }
                );
                const data = await response.json();
                setResults(data);
            } catch (error) {
                if (error.name !== 'AbortError') {
                    console.error('搜索失败:', error);
                }
            }
        }, 300),
        []
    );
    
    // 组件卸载时取消请求
    useEffect(() => {
        return () => abortControllerRef.current?.abort();
    }, []);
    
    return { results, search: debouncedSearch };
}

核心逻辑:每次新输入都取消之前的请求,保证只有最后一次输入的结果被渲染。这比单纯防抖更安全,防止竞态条件(Race Condition)


代码跑飞了?别慌,按这个路子排查能救半条命

异步代码出问题往往静默失败,或者时序错乱,排查起来比同步代码麻烦得多。这几个调试技巧,关键时刻能救命。

控制台爆红 UnhandledPromiseRejection,赶紧去找漏网的catch

Chrome控制台看到这种红字,说明有Promise rejected了但没被catch:

Uncaught (in promise) Error: 请求失败

排查步骤

  1. 点击错误堆栈,找到源头
  2. 检查链式调用是否每个分支都有catch
  3. 特别注意async函数里没await的Promise
// 容易被忽略的坑:async函数里创建了Promise但没await
async function dangerous() {
    // 这个Promise如果reject了,外部try/catch捕获不到!
    somePromise().then(data => {
        // 处理数据
    });
    // 因为没有await,这里直接返回了,Promise在后台跑
}

// 正确做法:要么await,要么加catch
async function safe() {
    await somePromise().then(data => {
        // 处理
    });
    
    // 或者如果不关心结果
    somePromise().catch(err => console.error(err));
}

全局兜底(不推荐作为常规手段,但开发时可用):

window.addEventListener('unhandledrejection', event => {
    console.error('未处理的Promise拒绝:', event.reason);
    // 可以上报监控
    reportError(event.reason);
    event.preventDefault(); // 防止控制台报错
});

时序错乱导致数据覆盖,console.log都救不了你的时候咋办

最隐蔽的bug:先发的请求后回来,覆盖了后发请求的结果。比如搜索"react"然后快速改成"vue",结果页面显示"react"的数据。

排查神器:给每个请求加ID

let requestId = 0;

async function fetchData(keyword) {
    const currentId = ++requestId;
    console.log(`发起请求 #${currentId}: ${keyword}`);
    
    const response = await fetch(`/api/search?q=${keyword}`);
    const data = await response.json();
    
    // 检查这个响应是否还对应当前最新的请求
    if (currentId !== requestId) {
        console.warn(`请求 #${currentId} 已过期,丢弃结果`);
        return null; // 丢弃过期结果
    }
    
    return data;
}

或者用前面提到的AbortController直接取消旧请求,更干净。

用 Chrome DevTools 的 Async 堆栈追踪,揪出那个隐藏的异步调用

普通断点调试异步代码很痛苦,因为调用栈断了——异步回调执行时,已经看不到是谁发起的。

解决方案

  1. 打开DevTools,Sources面板
  2. 右侧勾选Async选项(在Call Stack旁边)
  3. 打断点,现在能看到完整的异步调用链了!

或者代码里手动加debugger

async function complexFlow() {
    const step1 = await fetchStep1();
    debugger; // 在这里暂停,Async栈能看到完整流程
    const step2 = await fetchStep2(step1);
    return step2;
}

Performance面板也能看事件循环和异步任务耗时,优化性能时有用。

模拟弱网环境,看看你的 Loading 状态是不是真的生效了

很多bug只在慢网络下出现,本地开发很难复现。

Chrome DevTools Network面板

  • 下拉选择Slow 3GFast 3G
  • 或者自定义:Add… -> 设置下载速度500kb/s,延迟300ms

测试重点

  • Loading状态是否及时显示
  • 快速切换页面是否会取消请求
  • 超时处理是否生效
  • 重试机制是否正常工作
// 可以在代码里手动延迟,模拟慢接口
const mockSlowApi = (data, delay = 2000) => {
    return new Promise(resolve => {
        setTimeout(() => resolve(data), delay);
    });
};

// 使用
const user = await mockSlowApi({ name: '张三' }, 3000); // 强制等3秒

老鸟私藏的几个骚操作,让代码逼格瞬间拉满

掌握了基础,来看看一些进阶玩法,面试或者重构老代码时能派上用场。

手写一个简易版 Promise,搞懂原理比背API强一百倍

理解Promise最好的方式就是手写一个,虽然生产环境不会用,但面试常考:

class MyPromise {
    constructor(executor) {
        this.state = 'pending'; // pending, fulfilled, rejected
        this.value = undefined;
        this.reason = undefined;
        this.onFulfilledCallbacks = [];
        this.onRejectedCallbacks = [];
        
        const resolve = (value) => {
            if (this.state === 'pending') {
                this.state = 'fulfilled';
                this.value = value;
                this.onFulfilledCallbacks.forEach(fn => fn());
            }
        };
        
        const reject = (reason) => {
            if (this.state === 'pending') {
                this.state = 'rejected';
                this.reason = reason;
                this.onRejectedCallbacks.forEach(fn => fn());
            }
        };
        
        try {
            executor(resolve, reject);
        } catch (error) {
            reject(error);
        }
    }
    
    then(onFulfilled, onRejected) {
        // 处理默认值,实现值传递
        onFulfilled = typeof onFulfilled === 'function' 
            ? onFulfilled 
            : value => value;
        onRejected = typeof onRejected === 'function'
            ? onRejected
            : reason => { throw reason };
            
        const promise2 = new MyPromise((resolve, reject) => {
            if (this.state === 'fulfilled') {
                setTimeout(() => {
                    try {
                        const x = onFulfilled(this.value);
                        this.resolvePromise(promise2, x, resolve, reject);
                    } catch (error) {
                        reject(error);
                    }
                }, 0);
            } else if (this.state === 'rejected') {
                setTimeout(() => {
                    try {
                        const x = onRejected(this.reason);
                        this.resolvePromise(promise2, x, resolve, reject);
                    } catch (error) {
                        reject(error);
                    }
                }, 0);
            } else {
                // pending状态,先存起来
                this.onFulfilledCallbacks.push(() => {
                    setTimeout(() => {
                        try {
                            const x = onFulfilled(this.value);
                            this.resolvePromise(promise2, x, resolve, reject);
                        } catch (error) {
                            reject(error);
                        }
                    }, 0);
                });
                
                this.onRejectedCallbacks.push(() => {
                    setTimeout(() => {
                        try {
                            const x = onRejected(this.reason);
                            this.resolvePromise(promise2, x, resolve, reject);
                        } catch (error) {
                            reject(error);
                        }
                    }, 0);
                });
            }
        });
        
        return promise2;
    }
    
    // 处理then返回值的复杂逻辑(Promise A+规范核心)
    resolvePromise(promise2, x, resolve, reject) {
        if (promise2 === x) {
            reject(new TypeError('Chaining cycle detected'));
            return;
        }
        
        if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
            let called = false;
            try {
                const then = x.then;
                if (typeof then === 'function') {
                    // x是thenable,递归解析
                    then.call(x, 
                        y => {
                            if (called) return;
                            called = true;
                            this.resolvePromise(promise2, y, resolve, reject);
                        },
                        r => {
                            if (called) return;
                            called = true;
                            reject(r);
                        }
                    );
                } else {
                    resolve(x);
                }
            } catch (error) {
                if (called) return;
                reject(error);
            }
        } else {
            resolve(x);
        }
    }
    
    catch(onRejected) {
        return this.then(null, onRejected);
    }
    
    static resolve(value) {
        return new MyPromise(resolve => resolve(value));
    }
    
    static reject(reason) {
        return new MyPromise((_, reject) => reject(reason));
    }
    
    static all(promises) {
        return new MyPromise((resolve, reject) => {
            if (!Array.isArray(promises)) {
                reject(new TypeError('Argument must be an array'));
                return;
            }
            
            const results = new Array(promises.length);
            let completedCount = 0;
            
            if (promises.length === 0) {
                resolve(results);
                return;
            }
            
            promises.forEach((promise, index) => {
                MyPromise.resolve(promise).then(
                    value => {
                        results[index] = value;
                        completedCount++;
                        if (completedCount === promises.length) {
                            resolve(results);
                        }
                    },
                    reason => reject(reason)
                );
            });
        });
    }
}

// 测试一下
const p = new MyPromise((resolve, reject) => {
    setTimeout(() => resolve('Hello'), 1000);
});

p.then(msg => {
    console.log(msg); // Hello
    return msg + ' World';
}).then(msg => {
    console.log(msg); // Hello World
});

关键理解

  • then必须返回新Promise,实现链式调用
  • 状态改变后不可变
  • 异步执行回调(用setTimeout模拟微任务)

用 async/await 重构老旧的回调代码,同事看了都得喊666

维护老项目时,经常遇到回调地狱。用promisify技巧可以快速改造:

const fs = require('fs');
const util = require('util');

// Node.js内置的promisify,把回调风格转为Promise
const readFile = util.promisify(fs.readFile);

// 现在可以用async/await了
async function processFile() {
    try {
        const data = await readFile('./file.txt', 'utf8');
        console.log(data);
    } catch (error) {
        console.error('读取失败:', error);
    }
}

// 如果没有util.promisify,自己手写一个
function promisify(fn) {
    return function(...args) {
        return new Promise((resolve, reject) => {
            fn.call(this, ...args, (error, result) => {
                if (error) reject(error);
                else resolve(result);
            });
        });
    };
}

// 改造jQuery的$.ajax(假设还在用)
const requestAsync = promisify($.ajax);

async function modernFetch() {
    const data = await requestAsync({
        url: '/api/data',
        method: 'GET'
    });
    return data;
}

渐进式重构:不用一次性改完,可以边改边用,回调和Promise混用完全OK。

结合生成器函数(Generator)玩点更高级的流程控制

async/await本质上就是Generator + Promise的语法糖。理解Generator能让你实现更灵活的异步流程控制

// 用Generator实现可中断的异步流程
function* fetchGenerator() {
    console.log('开始获取用户...');
    const user = yield fetchUser();  // yield暂停,等待异步结果
    console.log('获取到用户:', user);
    
    console.log('开始获取订单...');
    const orders = yield fetchOrders(user.id);
    console.log('获取到订单:', orders);
    
    return { user, orders };
}

// 自动执行Generator的runner函数
function runGenerator(generatorFn) {
    const iterator = generatorFn();
    
    function handle(result) {
        if (result.done) return Promise.resolve(result.value);
        
        return Promise.resolve(result.value)
            .then(data => handle(iterator.next(data)))
            .catch(error => handle(iterator.throw(error)));
    }
    
    return handle(iterator.next());
}

// 使用
runGenerator(fetchGenerator)
    .then(result => console.log('完成:', result))
    .catch(error => console.error('出错:', error));

// 更高级的:可取消的异步任务
function createCancellableTask(generatorFn) {
    let cancelled = false;
    let currentPromise = null;
    
    const iterator = generatorFn();
    
    const cancel = () => {
        cancelled = true;
        // 可以在这里abort fetch等
    };
    
    function run() {
        function step(prevResult) {
            if (cancelled) {
                return Promise.resolve({ cancelled: true });
            }
            
            const { value, done } = iterator.next(prevResult);
            
            if (done) return Promise.resolve(value);
            
            currentPromise = Promise.resolve(value);
            return currentPromise.then(
                result => step(result),
                error => {
                    if (!cancelled) throw error;
                }
            );
        }
        
        return step();
    }
    
    return { run, cancel };
}

// 使用:可以中途取消的长任务
const task = createCancellableTask(function* () {
    const step1 = yield delay(1000);
    console.log('步骤1完成');
    const step2 = yield delay(1000);
    console.log('步骤2完成');
    return '全部完成';
});

const promise = task.run();
setTimeout(() => task.cancel(), 1500); // 1.5秒后取消,步骤2不会执行

应用场景

  • 可取消的异步序列(比如路由切换时取消未完成的加载)
  • 复杂的异步状态机
  • 需要"步进"调试的异步流程

封装通用的异步重试机制,网络抖动也不怕请求挂掉

生产环境网络不稳定,自动重试是必备功能:

/**
 * 带重试和退避策略的请求封装
 * @param {Function} fn - 要执行的异步函数
 * @param {Object} options - 配置项
 */
async function withRetry(fn, options = {}) {
    const {
        maxRetries = 3,           // 最大重试次数
        retryDelay = 1000,        // 基础延迟(毫秒)
        backoffMultiplier = 2,    // 退避倍数(指数退避)
        retryCondition = (error) => true, // 判断是否应该重试
        onRetry = (error, attempt) => {}   // 每次重试的回调
    } = options;
    
    let lastError;
    
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            return await fn(); // 尝试执行
        } catch (error) {
            lastError = error;
            
            // 最后一次尝试也失败了,或者不符合重试条件
            if (attempt === maxRetries || !retryCondition(error)) {
                throw error;
            }
            
            // 计算退避时间:基础延迟 * (倍数 ^ 尝试次数)
            // 加上随机抖动,防止雪崩
            const delay = retryDelay * Math.pow(backoffMultiplier, attempt) 
                         + Math.random() * 1000;
            
            onRetry(error, attempt + 1);
            console.warn(`${attempt + 1}次尝试失败,${delay}ms后重试...`);
            
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
    
    throw lastError; // 理论上不会到这里,为了保险
}

// 使用示例
async function fetchWithRetry() {
    return withRetry(
        () => fetch('/api/unstable-endpoint').then(r => r.json()),
        {
            maxRetries: 3,
            retryDelay: 1000,
            retryCondition: (error) => {
                // 只有网络错误和5xx错误才重试,4xx不重试(客户端错误)
                return !error.status || error.status >= 500;
            },
            onRetry: (error, attempt) => {
                showToast(`请求失败,正在进行第${attempt}次重试...`);
            }
        }
    );
}

// 更高级:带断路器模式的封装(防止雪崩)
class CircuitBreaker {
    constructor(fn, options = {}) {
        this.fn = fn;
        this.failureThreshold = options.failureThreshold || 5;
        this.resetTimeout = options.resetTimeout || 60000;
        this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
        this.failureCount = 0;
        this.lastFailureTime = null;
    }
    
    async execute(...args) {
        if (this.state === 'OPEN') {
            if (Date.now() - this.lastFailureTime > this.resetTimeout) {
                this.state = 'HALF_OPEN';
            } else {
                throw new Error('Circuit breaker is OPEN');
            }
        }
        
        try {
            const result = await this.fn(...args);
            this.onSuccess();
            return result;
        } catch (error) {
            this.onFailure();
            throw error;
        }
    }
    
    onSuccess() {
        this.failureCount = 0;
        this.state = 'CLOSED';
    }
    
    onFailure() {
        this.failureCount++;
        this.lastFailureTime = Date.now();
        
        if (this.failureCount >= this.failureThreshold) {
            this.state = 'OPEN';
            console.error('Circuit breaker opened!');
        }
    }
}

// 使用断路器保护关键接口
const protectedFetch = new CircuitBreaker(
    () => fetch('/api/critical-endpoint'),
    { failureThreshold: 3, resetTimeout: 30000 }
);

// 连续失败3次后,直接抛错,不再请求,保护后端服务

断路器模式(Circuit Breaker)在微服务架构里很常见,前端也可以用,防止失败请求雪崩


最后唠两句,别把异步当洪水猛兽

写到这儿,该讲的都讲了。但还有几句话想唠叨唠叨。

哪怕是大佬也会写出竞态条件,心态要稳

我见过工作十年的架构师,照样在快速输入的场景下栽跟头,写出竞态条件的bug。异步编程的坑,跟资历没关系,跟细心程度有关系

所以当你又遇到"为什么数据不对"的问题时,别慌,先检查时序。打印一下请求发起时间、响应时间、渲染时间,往往一目了然。

多写多错多调试,哪有什么天生就会的异步大师

我刚开始学Promise的时候,then里忘记return,catch没写全,async/await串行执行慢成狗,这些坑一个没落全都踩过。

唯一的捷径就是多写。写错了就调试,看事件循环,看调用栈,看网络时序。踩的坑多了,自然就长记性了。

下次再遇到回调地狱,直接甩出这篇大纲里的招数怼回去

现在你有武器库了:

  • 回调地狱?上Promise链式调用
  • 错误处理乱?try/catch安排上
  • 性能慢?检查串行/并行
  • 时序错乱?AbortController取消旧请求
  • 网络不稳定?重试机制+断路器

别再让异步搞崩心态了。它不是妖魔鬼怪,只是个需要排队办事的打工人,理解它的规则,就能使唤得动它。

好了,去写代码吧,记得多写注释,多写catch,多检查并行,保你异步不翻车。

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐