JS 异步:从 “卡成 PPT” 到 “丝滑连招”,这篇让你秒懂!

1,210 阅读11分钟

前言

你有没有过这种体验:打开一个网页,点击按钮后页面直接 “僵住”,转圈圈转了半天才有反应?这背后,其实是 JS “单线程” 的小脾气在作祟。那什么是线程呢?进程又是什么?

进程:cpu 接受一个指令,到加载完上下文环境所需要的时间。

线程:cpu 执行指令所需的时间。

咱们先把专业词翻译成人话:进程就是你打开游戏时,电脑给它分配 “房间” 的时间;线程则是游戏里角色 “放技能” 的执行时间。而 JS 这货,默认只开一个 “技能槽”(单线程)—— 这是当年设计师为了省你家老电脑的性能才这么搞的。感兴趣的可以去了解一下 JS 的历史,10天就创造出来了,当时的电脑不像现在可以同时打开多个软件只能打开一个。所以 js 默认是单线程运行的

但问题来了:如果 JS 遇到 “加载 100 张图片” 这种慢活儿,总不能让页面卡成 PPT 吧?于是它想了个招:把耗时的活儿 “挂起”,先干快的(同步代码),等慢活儿干完了再回来处理。这就是 “异步” 的核心逻辑。

一、异步初体验:“先斩后奏” 的setTimeout

先看一个例子:

let a = 1;
setTimeout(() => {
    a = 2;
}, 1000)
console.log(a);

大声告诉我a的值是多少?没错答案是1。那问题来了,为什么不是2呢?v8不是从上往下执行吗,那为啥不是2?这就是我们要讲的异步了。

image.png

这里 JS 就像个 “急性子”:看到setTimeout是个 1 秒后才做的事儿,直接把它丢到 “待办箱”,V8 主线程优先执行同步代码,异步任务(如 setTimeout 回调)会被丢进宏任务队列排队。先执行 let a=1 和 console.log(a)(输出 1),同步代码跑完后,主线程才会在 1 秒后执行队列里的回调,修改 a=2

核心: 同步先执行,异步排队等主线程空闲。

二、回调地狱:嵌套到让你 “眼花缭乱”

为了处理异步,最早的办法是 “回调函数”—— 让慢活儿干完了自己喊一声。但如果要做 “连环任务”,就会变成这样:

let a = 1;
function foo() {
    // 1秒后执行:改a为2,再喊 bar来干活
    setTimeout(() => {
        a = 2;
        console.log('foo',a); // 1秒后输出foo 2
        bar();
    }, 1000)
}
function bar() {
    // 再等2秒:改a为3,再喊 baz来干活
    setTimeout(() => {
        a = 3;
        console.log('bar',a); // 3秒后输出bar 3
        baz();
    }, 2000)
}
function baz(){
    console.log(a); // 3秒后输出3
}
console.log(a); // 先输出1
foo();

是不是看着贼乱了,解析一下:

1. 先执行同步代码:

  • 定义 a = 1,再定义 foo/bar/baz 三个函数(函数定义不执行代码,只是 “存着”);
  • 直接执行 console.log(a),此时 a 还是初始值 1,所以先输出 1
  • 调用 foo() 函数,进入 foo 内部。

2. 遇到异步任务,挂起后继续执行同步(此处同步已无其他代码,等待异步):

  • foo 里的 setTimeout 是异步任务(1 秒后执行),JS 不等待,直接把它丢进 “异步任务队列”,然后 foo 函数执行完毕;
  • 1 秒后,setTimeout 的回调触发:把 a 改成 2,输出 foo 2,接着调用 bar()

3. 链式触发下一个异步任务:

  • bar 里的 setTimeout 也是异步任务(2 秒后执行),再次被丢进任务队列;
  • 2 秒后(从 bar 调用开始算),回调触发:把 a 改成 3,输出 bar 3,接着调用 baz()

4. 最后执行同步回调:

  • baz 里是同步代码,直接输出当前 a 的值 3。

输出结果与分析一致(代码可以自己运行一下,因为照片看不出延迟~):

image.png

简单来说就是把下一个要调用的强制塞到上一个的函数体里面,这样程序就必须按你的顺序来执行。但是这种方法你看,foo里套barbar里套baz—— 如果任务再多几层,代码就会变成 “金字塔形”,缩进能把人看瞎!这就是前端老鸟闻之色变的 “回调地狱”。不推荐,不推荐,不推荐!

三、Promise:给异步穿件 “体面的外套”

那一直嵌套也不是个办法,为了拯救被嵌套折磨的程序员,ES6 推出了大名鼎鼎的 Promise—— 把异步任务包装成 “状态机”,让代码从 “嵌套” 变成 “链式调用”,瞬间清爽!

咱就拿我们年轻人最喜欢的旅游举例:

// 第一步:订高铁票(3秒后完成,结果是“购票成功”)
function train() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
        console.log('查询车次成功');
        resolve('购票成功');
        }, 3000)
    });
}
// 第二步:订酒店(2秒后完成,结果是“预定酒店成功”)
function hotel() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
        console.log('筛选酒店成功');
        resolve('预定酒店成功');
        }, 2000)
    })
}
// 第三步:出发旅行(1秒后完成)
function travel() {
    setTimeout(() => {
        console.log('收拾行李,快乐出发!');
    }, 1000)
}
// 订高铁票→订酒店→出发旅行
train()
    .then(() => {
        return hotel(); // 高铁票订好后,才执行订酒店
    })
    .then(() => {
        travel(); // 酒店订好后,才执行出发
    })
    .catch((err) => {
        console.log('旅行计划泡汤:', err); // 只要中间有失败,就会跳到这里
})

这就是Promise 处理 “依赖型异步任务” 的典型案例—— 核心是模拟 “订高铁票→订酒店→出发旅行” 的流程:三步必须按顺序来,前一步失败,后续步骤直接跳过,统一走错误处理。我们一起来看看是怎么个事:

一、先认清楚:每个函数的作用(异步任务的包装)

1. train():订高铁票(第一步异步任务)

  • 返回 Promise 对象:Promise 就像一个 “任务跟踪器”,专门记录异步任务的 “成功 / 失败” 状态

  • 内部用 setTimeout 模拟 “3 秒查询 + 购票” 的耗时操作(比如真实场景中调用 12306 接口)

    • 3 秒后先打印 查询车次成功(表示 “查询” 这个前置动作完成)
    • 调用 resolve('购票成功'):告诉 Promise“这个任务成了!结果是‘购票成功’”,并把结果传递给下一个步骤

2. hotel():订酒店(第二步异步任务)

  • 和 train() 结构完全一致,只是 “耗时更短、任务不同”:

    • 模拟 “2 秒筛选 + 预订酒店” 的耗时操作(比如调用携程 / 飞猪接口)
    • 2 秒后打印 筛选酒店成功(前置动作完成)
    • 调用 resolve('预定酒店成功'):标记 “订酒店成功”,传递结果给下一个步骤

3. travel():出发旅行(第三步任务)

  • 注意:它是普通异步函数(没有返回 Promise),只负责 “执行最终动作”
  • 用 setTimeout 模拟 “1 秒收拾行李” 的准备时间,最后打印 收拾行李,快乐出发!
  • 只有前两步(订票、订酒店)都成功,它才会被执行

先看都成功的输出结果:

image.png

二、核心逻辑:链式调用的 “执行流程”(一步步看清楚)

train()
    .then(() => {
        return hotel(); // 高铁票订好后,才执行订酒店
    })
    .then(() => {
        travel(); // 酒店订好后,才执行出发
    })
    .catch((err) => {
        console.log('旅行计划泡汤:', err); // 只要中间有失败,就会跳到这里
})

Promise 链式调用的核心规则是  “成功则串行执行,全程无失败则不触发 catch” ,咱们模拟实际执行过程:

1. 代码开始执行,先调用 train():启动 “3 秒查询 + 购票” 的异步任务(主线程不等待,继续往下走,但这里没有其他同步代码,所以等异步结果)

2. 3 秒后,train() 执行完毕:

  • 打印 查询车次成功
  • 触发 resolve('购票成功'),标记 “第一步成功”

3. 第一步成功后,自动执行第一个 .then()

  • 这个 .then() 里返回 hotel(),启动 “2 秒筛选 + 订酒店” 的异步任务(因为返回了 Promise,所以下一个 .then() 会等这个 Promise 完成)

4. 又过了 2 秒,hotel() 执行完毕:

  • 打印 筛选酒店成功

  • 触发 resolve('预定酒店成功'),标记 “第二步成功” 5. 第二步成功后,自动执行第二个 .then()

  • 调用 travel(),启动 “1 秒收拾行李” 的异步任务

6. 再等 1 秒,travel() 执行完毕:

  • 打印 收拾行李,快乐出发!

7. 全程没有任何 reject(失败),所以最后的 .catch() 完全不会执行

如果有有一步有错误:

function train() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
        console.log('查询车次成功');
        reject('票已售罄');  // 改为 '票已售罄'
        }, 3000)
    });
}

输出结果:

image.png

这样就打印出了计划泡汤,票已售罄

三、关键知识点:为什么这么写比回调函数好?

1. 强制保证 “依赖顺序”

比如 “必须订完票才能订酒店,必须订完酒店才能出发”—— 如果不用 Promise,直接写三个 setTimeout,会因为异步任务 “谁先完成不一定”,导致 “还没订票就订酒店” 的逻辑错误。而 .then() 链式调用,天然保证 “前一步完成,后一步才执行”。

2. 代码简洁,无嵌套(告别回调地狱)

如果用传统回调函数实现同样的逻辑,会变成 “嵌套金字塔”,可读性极差:

// 回调地狱写法(不推荐)
train((trainRes) => {
    console.log('查询车次成功');
    if (trainRes === '购票成功') {
        hotel((hotelRes) => {
        console.log('筛选酒店成功');
        if (hotelRes === '预定酒店成功') {
            travel();
        }
        });
    }
}, 
(err) => {
    console.log('旅行计划泡汤:', err);
});

对比之下,Promise 的链式调用是 “平铺式” 的,一眼就能看明白流程是 “train→hotel→travel”。

四、小拓展:如果想拿到每个步骤的 “成功结果” 怎么办?

当前代码的 .then() 里没有接收 resolve 传递的结果(比如 “购票成功”“预定酒店成功”),如果想在后续步骤中使用这些结果,只需给 .then() 加参数即可:

train()
.then((trainResult) => {
    console.log('第一步结果:', trainResult); // 输出“第一步结果:购票成功”
    return hotel();
})
.then((hotelResult) => {
    console.log('第二步结果:', hotelResult); // 输出“第二步结果:预定酒店成功”
    travel();
})
.catch((err) => {
    console.log('旅行计划泡汤:', err);
});

这样就能在每个 .then() 里拿到上一步的成功结果,灵活用于后续逻辑。

Promise的妙处在于:

  • resolve表示 “任务成了”,reject表示 “任务黄了”;
  • .then()接下一个任务,用.catch()抓所有错误;
  • 彻底告别嵌套,代码像 “流水线” 一样清晰!

这也是 Promise 比传统回调函数更受欢迎的核心原因 —— 让异步流程 “看得见、管得住”~

四、手写 Promise:揭开它的 “神秘面纱” (面试手写题!)

最后咱们来扒一扒Promise的底层逻辑,Promise大概就长这样:

class MyPromise {
    constructor(fn) {
        // 初始化状态:pending(等待)、fulfilled(成功)、rejected(失败)
        this.status = 'pending';
        // 成功时的值
        this.value = undefined;
        // 失败时的原因
        this.reason = undefined;
        // 成功回调的队列(可能多个.then)
        this.onFulfilledCallbacks = [];
        // 失败回调的队列
        this.onRejectedCallbacks = [];

        // 成功函数:把状态改成fulfilled,执行所有成功回调
        const resolve = (value) => {
            if (this.status === 'pending') {
                this.status = 'fulfilled';
                this.value = value;
                // 依次执行.then里的回调
                this.onFulfilledCallbacks.forEach(cb => cb(this.value));
            }
        }
        // 失败函数:把状态改成rejected,执行所有失败回调
        const reject = (reason) => {
            if (this.status === 'pending') {
                this.status = 'rejected';
                this.reason = reason;
                // 依次执行.catch里的回调
                this.onRejectedCallbacks.forEach(cb => cb(this.reason));
            }
        }
        // 执行传入的异步函数
        try {
            fn(resolve, reject);
        } catch (err) {
            reject(err);
        }
    }
    // .then方法:注册成功/失败的回调
    then(onFulfilled, onRejected) {
        // 如果没传成功回调,默认透传value
        onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
        // 如果没传失败回调,默认透传reason
        onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason };
        // 返回新的Promise,实现链式调用
        return new MyPromise((resolve, reject) => {
        if (this.status === 'fulfilled') {
            // 成功状态:直接执行回调
            setTimeout(() => {
            try {
                const result = onFulfilled(this.value);
                // 回调的返回值传给下一个.then
                resolve(result);
            } catch (err) {
                reject(err);
            }
            }, 0);
        }
        if (this.status === 'rejected') {
            // 失败状态:直接执行回调
            setTimeout(() => {
            try {
                const result = onRejected(this.reason);
                resolve(result);
            } catch (err) {
                reject(err);
            }
            }, 0);
        }
        if (this.status === 'pending') {
            // 等待状态:把回调存到队列里
            this.onFulfilledCallbacks.push(() => {
            setTimeout(() => {
                try {
                    const result = onFulfilled(this.value);
                    resolve(result);
                } catch (err) {
                    reject(err);
                }
            }, 0);
            });
            this.onRejectedCallbacks.push(() => {
            setTimeout(() => {
                try {
                    const result = onRejected(this.reason);
                    resolve(result);
                } catch (err) {
                    reject(err);
                }
            }, 0);
            });
        }
        });
    }
    // .catch方法:就是.then的失败回调版
    catch(onRejected) {
        return this.then(null, onRejected);
    }
}

当然,一个简化版的Promise最核心的就是这样:

class Promise{
    constructor(fn){
        function resolve(){

        }
        function reject(){

        }
        fn(resolve,reject);
    }
}

简单说,Promise就是通过 “状态管理 + 回调队列”,把异步任务变得可控、可链式调用 —— 这就是它能解决回调地狱的关键!

结语:异步是 JS 的 “超能力”

其实从 “卡成 PPT” 的单线程,到 “丝滑连招” 的异步处理,JS 的异步逻辑就像生活里的 “多线程摸鱼”—— 你煮着泡面(异步),同时刷着手机(同步),泡面好了手机也没停,效率直接拉满。

回调Promise 这些工具,就是帮你把 “摸鱼流程” 理得更顺的秘籍:回调是 “喊一嗓子”,Promise 是 “排好队的流水线”。掌握了它们,你就能从 “被异步折磨” 变成 “把异步玩明白”

下次再遇到页面卡顿、代码嵌套成山,不妨问问自己:

这活儿,能不能让 Promise 帮我管管?