带你玩转JavaScript中的Callback Function、Promise、async/await

前言

本文章源自《JavaScript知识小册》专栏,感兴趣的话还请关注点赞收藏.

上一篇文章:《JavaScript令人心烦的this

异步

JavaScript是单线程语言,代表着在同一时间内只能处理一件事情。Java等支持多线程的语言天然不一样,多线程编程会有并发、锁、线程池等概念和知识,而JS单线程反而简单许多,而也正是因为这个单线程特性,JS非常依赖异步回调,比如网络请求的时候,不能老老实实在原地等着网络请求响应,而是发起网络请求后马上跑去处理别的事,等到网络请求响应了之后再回过头来处理响应。[像极了那为生活不停奔波劳碌的你我]

Tips: 浏览器或node.js环境都支持新建JS进程,比如Web Worker。但也只是多进程,并没有改变JavaScript单线程的本质。

异步场景

JavaScript中异步的应用场景主要有以下

  1. 网络请求
  2. 定时任务 setTimeoutsetInterval
  3. 图片加载 [可以获取img标签,并设置onload属性为某个函数,那么等到img标签加载完图片后,这个回调会被自动调用]

callback

最简单的异步,通过回调函数的方式来实现

javascript 复制代码
setTimeout(() => {console.log(123)}, 10)
console.log(456)

这里用setTimeout提交了一个输出123的函数,在10毫秒后就会执行。JS在执行代码的时候会经历如下步骤

  1. 执行setTimeout提交任务,不会原地傻傻等待,而是立马跑去处理下一行代码
  2. 输出456
  3. 10毫秒过后,回调输出123

callback hell

异步是基于回调函数的方式来实现,但如果是遇到强调执行顺序的多重异步的情况下,则会产生回调地狱。比如像下面的同时发起多个网络请求,上一个请求的响应作为本次发起请求的参数这样,多层回调函数嵌套很容易把人绕晕

javascript 复制代码
function post(url, data, callback) {
    // 伪代码,发起网络请求
    const res = http(url, data)
    callback(res)
}

post('https://xxxx/login', {phone: '135xxxxxx'}, (res) => {
    let {userId} = res
    post('https://xxxx/userInfo', {userId}, (res) => {
        let {userName, shopCartList} = res
        post('https://xxxx/order', {userId, userName, shopCartList}, (res) => {
            alert('购买成功')
        })
    })
})

Promise

Promise是解决Callback hell的一种方式,可以让开发从回调函数的层层嵌套中解放出来,变成是一种链式调用的开发方式,即原先像剥洋葱似的得一层层剥,而现在则像是管道排水似的,一节节管道连成一条线就可以排水了。

上边的回调地狱换成使用Promise的方式则为如下形式

javascript 复制代码
function post(url, data) {
    return new Promise((resolve, reject) => {
        try {
            // 伪代码,发起网络请求
            const res = http(url, data)
            // 成功之后调用resolve()并将请求响应res作为结果返回,触发then()
            resolve(res)
        } catch (e) {
            console.log(e)
            // 失败调用reject()并将错误e作为参数传递,触发catch()
            reject(e)
        }
    })
}

post('https://xxxx/login', {phone: '135xxxxxx'})
    .then((res) => {
        let {userId} = res
        return post('https://xxxx/userInfo', {userId})
    })
    .then((res) => {
        let {userName, shopCardList} = res
        return post('https://xxx/order', {userId, userName, shopCardList})
    }).then(res => {
    alert('下单成功')
}).catch(e => {
    alert(`购买出错 ${e}`)
})

可以看到代码没有了之前的一层层嵌套,而是在返回的Promise对象后面不断地then()处理下一步业务,catch()处理发生的异常,形成了一条线过的链式调用

resolve返回

Promise调用resolve时传入的值为普通对象或基本类型时,那么then里收到的参数是原模原样的值

javascript 复制代码
function http(){
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(100), 10) // 
    })
}

http().then((res) => console.log(res))

输出100

同时还可以往resolve里传入Promise

scss 复制代码
function http() {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(
            // 传递给resolve的依然是个Promise,最后返回的依然是resolve(100),如果这里只是resolve(new Promise(...)),没有再继续调用resolve(...),那么then(res)中的res不存在值
            new Promise((resolve, reject) => {
                resolve(100)
            })
        ), 10)
    })
}

http().then((res) => console.log(res))

输出100

then返回

then函数中的返回值跟上边resolve差不多

javascript 复制代码
http().then((res) => {
    console.log(`res is ${res}`)
    return 1 // 返回普通的值
}).then((res) => {
    console.log(`res is ${res}`) // 输出1
    return new Promise((resolve, reject) => { // 返回Promise
        setTimeout(() => resolve(2), 10)
    })
}).then((res) => {
    console.log(`res is ${res}`) // 输出2,也就是resolve(2)的结果
    return // 无返回
}).then((res) => {
    console.log(`res is ${res}`) // 输出undefined
})

依次输出 res is 100 res is 1 res is 2 res is undefined

三种状态

Promise存在三种状态,分别是pending resolved rejected

最初始的状态是pending

javascript 复制代码
const p = new Promise((resolve, reject) => {
})

console.log(p)

输出

调用resolve方法可以使Promise对象转变为pending状态

javascript 复制代码
const p = new Promise((resolve, reject) => {
    resolve(1)
})

console.log(p)

输出

调用reject方法使Promise对象转变为rejected状态

javascript 复制代码
const p = new Promise((resolve, reject) => {
    reject()
})

console.log(p)

输出

需要注意的是,Promise状态转变只会有一次,且不可逆,只能从pending转为resolved或者转为rejected,而不会是从pending转为resolved,再转为rejected

状态和回调之间的关系

Promise不同状态和后续回调函数的关系如下

  1. pending状态下,不会触发thencatch回调
  2. resolved状态,才会触发后续的then回调
  3. rejected状态,才会触发后续的catch回调,若没有catch回调,则会直接抛出错误

then catch连接

使用Promisethencatch各种连接使用也是个比较有意思的点

最基本的thencatch组合

javascript 复制代码
Promise.resolve().then(() => console.log(1))
    .catch(() => console.log(-1))
    .then(() => console.log(2))

输出1 2catch回调没有被触发,没有输出-1,这是因为在catch前的then回调函数执行时没有产生错误

稍作改动,在第一个then回调函数中抛出自定义异常

javascript 复制代码
Promise.resolve().then(() => {
    console.log(1)
    throw new Error("custom err")
})
    .catch((e) => {
        console.log(-1)
        console.log(e) // 输出Error: custom err
    })
    .then(() => console.log(2))

输出1 -1 Error: custom err 2

那么如果在catch里再次抛出异常的话

javascript 复制代码
Promise.resolve().then(() => {
    console.log(1)
    throw new Error("custom err")
})
    .catch((e) => {
        console.log(-1)
        throw e
    })
    .then(() => console.log(2))
    .catch((e) => {
        console.log(3)
        console.log(e)
    })

输出1 -1 3 Error: custom err,因为第一个catch中再次抛异常了,所以紧跟着的then不会被执行,反而是第二个catch中的代码被执行了

从这里几个示例其实可以总结出这么一个概念,调用Promise.resolve()我们可以获取到执行结果,并在后紧跟着调用then进行下一步业务处理,或者catch进行异常处理。然后在then方法之后还可以继续跟着then或者catch,然后还可以无限地继续跟着thencatch。那么可以理解为,每个then或者catch返回的还是一个Promise对象

可能有点太抽象,换代码说话

javascript 复制代码
Promise.resolve().then(() => {
    console.log(1)
}).then(() => console.log(2))

其实等同于

javascript 复制代码
Promise.resolve().then(() => {
    console.log(1)
    return Promise.resolve()
}).then(() => {
    console.log(2)
    return Promise.resolve()
})

接着

javascript 复制代码
Promise.resolve().then(() => {
    console.log(1)
    throw new Error('custom err')
}).then(() => {
    console.log(2)
}).catch((e) => {
    console.log(-1)
    console.log(e)
})

其实等同于

javascript 复制代码
Promise.resolve().then(() => {
    console.log(1)
    return Promise.reject('custom err')
}).then(() => {
    console.log(2)
}).catch((e) => {
    console.log(-1)
    console.log(e)
})

给出一长串Promise try catch连接,然后考察哪些代码会执行,哪些代码不会执行。这种在面试的时候很常见,但只要深入掌握Promise的话也不难。

只要记住,

  1. 若上一个then执行代码没抛出异常,那么这个then就会执行。
  2. 若上一个then执行过程中抛出异常了,这个then不会被执行,而是执行catch,如果没catch,那么就会抛出Uncaught (in promise) xxx
  3. catch中继续抛出异常,那么下一个catch会被触发。(如果有下一个catch的话)
  4. catch中没有抛出异常,那么是这个catch后紧跟的then被触发,而下一个catch不会被触发

async/await

基于Callback的异步回调,因为回调地狱的存在,所以有了Promise来优化,但Promise也是基于回调函数,虽然链式调用then catch让回调处理平铺开来,但还是要传递一个个回调函数,而async await则在于它让开发者在编写异步处理的时候,就好像在写同步代码一样,忘记异步回调这回事,当然async await也是基于Promise的,只不过是语法糖而已。重点在于能让我们像写"同步"代码一样处理异步逻辑,但本质上"它不是真正的同步",因为JavaScript是单线程的,所以异步回调永远都存在

asyncawait是配对使用的,不能单独使用。async 算是 function的一个修饰符,表示这个方法内的代码会涉及到异步,然后await则用在被async修饰的function中,用在方法体中调用的异步或同步方法 前,表示等待该方法执行完毕,再执行下一步操作

javascript 复制代码
function http() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(100)
        }, 500)
    })
}

async function doHttp() {
    console.log('start')
    const res = await http()
    console.log(`res is ${res}`)
}

doHttp()

先是输出start,然后等了一下输出res is 100

可以看到不再像之前那样写上then catch回调才能对Promise返回做处理,整个过程就好像是在写console.log(1); console.log(2); console.log(3)似的,整体代码都是从上往下一行行执行,即便存在异步也是如此

async/await 和Promise的关系

async await 并不是来彻底取代Promise的,相反它其实依赖于Promsie

首先用async修饰的函数,返回的是Promise对象

javascript 复制代码
async function print(){}
console.log(print())

async修饰的print方法,即便里面没有任何代码,但执行print方法后,输出的是处于resolved状态的Promise对象

即便在print中手动return 100

javascript 复制代码
async function print(){
    return 100
}
console.log(print())

输出也还是个状态为resolvedPromise对象

而且再看如下代码,print()后面一样可以紧跟then catch

javascript 复制代码
async function print() {
    return 100  // 等同于 return Promise.resolve(100)
}

print().then((res) => console.log(res)) // 输出100

await相当于等待Promise执行完成,也就相当于then,具体看如下代码

javascript 复制代码
async function print() {
    return 100
}

// 定义匿名函数并立即调用
(async function(){
    const val = await print() // 调用一个返回Promise对象的方法,并等到Promise状态变为resolved才会执行后续代码,当然这里并不会乖乖地等,因为JavaScript是单线程的,在Promise还没有变为resolved前,JavaScript会跑去执行别的任务
    console.log(val)
})()  // 输出100

当执行到await print()的时候,会等待print()执行完成,且返回的Promise状态为resolved时,const val = await print()的变量定义及赋值操作 和 后续代码才会继续执行,所以说await 相当于Promise.resolve(100).then((res) => {const val = res; console.log(res)})

如果不加await,你会发现不等Promise完成,JavaScript就立即执行完所有代码,并且此时val不是明确的100,而是一个Promise对象

javascript 复制代码
async function print() {
    return 100
}

(async function(){
    const val = print() // 取消await
    console.log(val)
})()

输出

还有一点是await后面不一定非要是Promise对象,当然如果不是Promise对象,也没有使用await的必要

javascript 复制代码
(async function () {
    const val = await 100  // 可以变相当做是await Promise.resolve(100) 
    console.log(val) // 输出100
})()

try/catch

使用Promise时紧跟catch来进行异常捕获,async/await同样也是使用catch进行异常捕获,不过方式就要稍微变一下

javascript 复制代码
async function print() {
    throw new Error("err:!00")
    return 100
}

(async function () {
    try {
        const val = await print() // await 相当于Promise.then,而因为print()抛出异常,所以是返回状态为rejected的Promise对象,因此const val = ... 这句代码都不会执行完,后续代码更不会执行
        console.log(val)
    } catch (e) {
        console.log(e) // 使用try{...}catch(e){...} 来进行异常捕获
    }
})()

Event Loop

Event Loop事件循环,它其实就是JavaScript异步回调的原理,也就是有了这么一套机制才保证了JavaScript在遇到网络请求等异步操作时,不会傻傻原地等待[低性能],而是跑去执行别的任务,晚点再回过头来处理异步任务的返回[高性能]

首先是最简单的同步代码执行

arduino 复制代码
console.log(1)
console.log(2)
console.log(3)

在浏览器环境中代码执行过程大致如下,JavaScript执行代码过程是一行行执行的,当JavaScript遇到console.log(1)这行代码时,会将其推入到Call Stack中进行执行,执行完毕则浏览器控制台相应输出1,当console.log(1)执行完毕后,调用栈将会被清空,接着是第2行代码入栈,执行,输出,出栈,第3行...以此类推

当遇到setTimeout,setInterval这种异步任务的时候,就没有这么简单了

javascript 复制代码
console.log(1)
setTimeout(() => console.log('callback'), 20)
console.log(2)
console.log(3)

同步代码执行过程如上,不再解释。在执行到setTimeout()的时候,的确也是把setTimeout()放入到调用栈中执行,但是因为setTimeout的回调需要20毫秒后才触发,所以在执行完setTimeout这行代码的同时,会将回调当做一个晚点触发的任务放到另一个待处理队列中JavaScript在20毫秒这段期间内,会继续执行console.log(2) console.log(3),直到20毫秒结束,setTimeout的回调需要被触发了,JavaScript才会真正执行回调函数,浏览器控制台输出callback

Tips: 因为setTimeout,setInterval,DOM事件监听是浏览器环境才有的,所以Web APIS可以理解为是另一个调用栈,是存放浏览器定义的相关API的执行任务

不过到这里其实也还是简略版的异步同步调用机制,接下来便是引出Event Loop

其实在20毫秒后需要触发setTimeout回调时,会将该回调当做一个任务添加到Callback Queue[回调队列]中等待执行,当JavaScript执行完所有同步代码之后,会看看Callback Queue是否存在待处理任务,存在的话,把任务取出来放入到Call Stack中执行,最后浏览器输出callback而这个过程则是依赖于EventLoop这个机制[重点:Event Loop不是指某个对象,某个新API,某个语法糖,而是一套机制]

也就是说在执行同步异步代码整个过程中会经历如下步骤

  1. 执行同步代码,放入Call Stack,执行完成结束
  2. 执行异步代码,放入Call Stack,因为是异步,所以延迟执行,定时执行,网络请求之类的回调,会将其放入另一个队列中 [Promise这种不是说也是放入WebAPIs这个队列,可以理解为异步任务回调会被存放到某个地方,等待后续调用,要明白这个概念,而不是纠结具体放到哪里去]
  3. 异步任务回调需要被触发时,会将其作为一个任务放到Callback Queue中进行排队等待
  4. [Event Loop]机制:当JavaScript执行完所有同步代码后,会查看Callback Queue是否存在待处理任务,有的话则会将其取出来放入Call Stack中进行执行

Tips:Event Loop这种机制,其实跟网络请求的IO多路复用类似,若是熟悉Java网络编程的话,会知道Netty中有个类叫做NioEventLoop,这两个前端后端Event Loop 机制其实都是一样的

宏任务/微任务

宏任务macroTask 微任务microTask是对Callback Queue中的任务的一个类型区分。

  1. 宏任务包括: setTimeoutsetIntervalAjaxDOM事件
  2. 微任务包括:Promise async/await

Tips: 微任务执行的时机要比宏任务更早。

javascript 复制代码
setTimeout(() => console.log(123), 20) // 宏任务
console.log(789) // 同步代码
Promise.resolve().then(() => console.log(456)) // 微任务

以上代码中,微任务Promise要早于宏任务setTimeout执行,但是因为两者都涉及异步回调,所以输出为

789
456
123

微任务先于宏任务执行理由

首先进一步引申出整个事情的全貌,JavaScript是单线程的,在浏览器环境中不能只执行JavaScript代码,DOM渲染也依赖于JavaScript线程,所以在EventLoop触发前,还存在DOM渲染,也就是在Call StackCallback Queue之间还存在DOM渲染需要处理

大体流程如下:

  1. 执行完所有同步代码,清空Call Stack
  2. 如果需要重新渲染DOM的话,渲染DOM
  3. 查看Callback Queue是否存在任务,存在则执行
  4. 再次执行Call Stack中的入栈任务
  5. 如此往复...无尽循环...

在引入了DOM渲染时机之后,这里可以进一步细分宏任务微任务间执行循序的区别,那就是微任务是在DOM渲染前触发,宏任务是在DOM渲染后触发

而具体原理则还得引出另一个队列Micro Task Queue[微任务对列]

setTimeout setInterval等这种宏任务,其实是浏览器提供的API,所以这些异步任务会被放入WebAPIs中等待,等到时机成熟后会将任务放入Callback Queue中,等待EventLoop机制触发,然后将入队任务取出并执行。

Promise不是浏览器规范提供的,跟浏览器内核也没啥关系,所以Promise任务不会被放入WebAPIs队列中,更不会被放入Callback Queue,而是单独有一个Micro Callback Queue存放,而执行Micro Callback Queue中任务的时机,正好在DOM渲染前,所以这也是为什么微任务执行永远早于宏任务

Tips: 微任务是ES6语法规定的,宏任务是浏览器规定的,两者算是不同体系,所以两种类型的任务会放在不同的队列

相关推荐
Fighting_p2 分钟前
【记录】列表自动滚动轮播功能实现
前端·javascript·vue.js
前端Hardy3 分钟前
HTML&CSS:超炫丝滑的卡片水波纹效果
前端·javascript·css·3d·html
技术思考者7 分钟前
HTML速查
前端·css·html
缺少动力的火车7 分钟前
Java前端基础—HTML
java·前端·html
Domain-zhuo20 分钟前
Git和SVN有什么区别?
前端·javascript·vue.js·git·svn·webpack·node.js
雪球不会消失了25 分钟前
SpringMVC中的拦截器
java·开发语言·前端
李云龙I36 分钟前
解锁高效布局:Tab组件最佳实践指南
前端
m0_7482370540 分钟前
Monorepo pnpm 模式管理多个 web 项目
大数据·前端·elasticsearch
JinSoooo43 分钟前
pnpm monorepo 联调方案
前端·pnpm·monorepo
m0_748244961 小时前
【AI系统】LLVM 前端和优化层
前端·状态模式