本文深入解析JavaScript异步编程核心面试题,涵盖并发与并行的本质区别、回调函数的缺陷及解决思路、Generator的控制权特性、Promise的三种状态与链式调用,以及Async/Await的语法糖原理。通过对比各方案优缺点,帮助开发者理解异步执行机制,掌握解决回调地狱的最佳实践。
上一节我们梳理了ES6语法的基础知识点。本章将聚焦于JavaScript中至关重要的异步编程领域。由于该部分既是技术难点也是面试高频考点,我们将分三个章节系统讲解其核心概念与难点。
并发(concurrency)与并行(parallelism)的概念辨析
面试题:如何区分并发与并行?
异步(asynchronous)与并发/并行并非同一概念,但中文译名相似常导致混淆。实际上,它们在英文语境中代表完全不同的技术含义。
并发属于宏观概念。指在一段时间内,系统通过任务切换机制交替执行任务A和任务B,从而在宏观上呈现出同时处理多个任务的状态。
并行属于微观概念。指系统拥有多个CPU核心,能够真正同时执行任务A和任务B。只有当多个任务在物理时间上同时被处理时,才称为并行。
回调函数(callback)的应用与局限
面试题:什么是回调函数?它存在哪些缺点?如何避免回调地狱?
回调函数是JavaScript中最常用的异步处理模式,典型调用方式如下:
ajax(url,()=>{
//处理逻辑
})然而,回调函数存在一个显著缺陷:当多个异步请求存在依赖关系时,极易形成深层嵌套,导致代码难以阅读,即所谓的“回调地狱”。示例如下:
ajax(url,()=>{
ajax(url,()=>{})
})有人可能认为将函数拆分定义即可解决可读性问题,但这种方式并未触及根本。
function firstAjax(){
ajax(url1,()=>{
secondAjax()
})
}
function second(){
ajax(url2,()=>{
})
}
ajax(url,()=>{
firstAjax()
})即使将函数分离,代码结构依然松散,且并未解决嵌套带来的维护难题。
回调地狱的根本问题在于:
- 嵌套函数之间存在强耦合性,任何一处修改都可能引发连锁反应,导致牵一发而动全身。
- 随着嵌套层级增加,错误处理变得极其困难,难以定位具体出错位置。
此外,回调函数还有其他局限,例如无法使用标准的try-catch块捕获异步错误,且不能直接通过return语句返回值。
Generator函数的高级用法
面试题:如何理解Generator函数?
Generator是ES6中较难掌握的概念之一,其核心特性在于能够手动控制函数的执行流程。本节不赘述基础定义,重点解析其易混淆的特性。
function *foo(){
let y = 2*(yield(x+1))
let z = yield(y/3)
return (x+y+z)
}
let it = foo(5)
console.log(it.next())
console.log(it.next(12))
console.log(it.next(13))若对Generator返回值的计算结果感到困惑,以下将逐行代码解析其执行逻辑。
- 首先,Generator函数与普通函数的调用机制不同,它不会立即执行,而是返回一个迭代器对象。
- 第一次调用next()时,传入的参数会被忽略,函数暂停在yield (x + 1)处,因此返回值为5 + 1 = 6。
- 第二次调用next()时,传入的参数将作为上一个yield表达式的返回值。若不传参,yield默认返回undefined。此时let y = 2 * 12,因此第二个yield的值为2 * 12 / 3 = 8。
- 第三次调用next()时,传入的参数会赋值给z,此时z = 13, x = 5, y = 24,最终相加结果为42。
Generator函数在实际开发中较少直接使用,因其逻辑较为绕口,通常配合co库使用。不过,我们可以利用Generator函数重构之前的回调地狱示例,代码如下:
function *fetch() {
yield ajax(url, () => {})
yield ajax(url1, () => {})
yield ajax(url2, () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()Promise对象的状态管理
Promise意为“承诺”,表示一个异步操作最终会有一个确定的结果。它包含三种状态:
- 等待中(pending)
- 已完成(resolved)
- 已拒绝(rejected)
Promise的状态一旦从“等待中”转变为“已完成”或“已拒绝”,状态便不可逆,无法再次更改。
new Promise((resolve, reject) => {
resolve('success')
// 无效
reject('reject')
})在构造Promise时,传入的构造函数内部的代码会立即执行。
new Promise((resolve, reject) => {
console.log('new Promise')
resolve('success')
})
console.log('finifsh')
// new Promise -> finifshPromise实现了链式调用,每次调用then()后返回的都是一个新的Promise实例,这是由状态不可变性决定的。若在then中return一个值,该值会被自动包装为Promise.resolve()。
Promise.resolve(1)
.then(res => {
console.log(res) // => 1
return 2 // 包装成 Promise.resolve(2)
})
.then(res => {
console.log(res) // => 2
})Promise同样能有效解决回调地狱问题,重构后的示例代码如下:
ajax(url)
.then(res => {
console.log(res)
return ajax(url1)
}).then(res => {
console.log(res)
return ajax(url2)
}).then(res => console.log(res))尽管Promise优势明显,但也存在局限:例如无法取消正在进行的Promise,且错误必须通过回调函数(catch或then的第二个参数)进行捕获。
Async与Await语法详解
面试题:Async和Await的特点、优缺点及await的实现原理是什么?
任何被async关键字修饰的函数,在调用时都会返回一个Promise对象。
async function test() {
return "1"
}
console.log(test()) // -> Promise {<resolved>: "1"}Async函数的返回值会被Promise.resolve()包裹,这与then中处理返回值的逻辑一致。此外,await关键字必须与async函数配合使用。
async function test() {
let value = await sleep()
}Async和Await被视为异步编程的终极解决方案。相比原生Promise,其优势在于简化了then链的调用,使代码逻辑更清晰。虽然避免了冗长的then链,但也存在缺点:await将异步代码转化为同步执行风格,若多个无依赖的异步任务串行使用await,会导致性能下降。
async function test() {
// 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
// 如果有依赖性的话,其实就是解决回调地狱的例子了
await fetch(url)
await fetch(url1)
await fetch(url2)
}以下是一个使用await的示例:
let a = 0
let b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1针对上述代码的执行顺序,可能存在疑惑,具体原因解析如下:
- 首先执行变量b的赋值,在await 10之前,变量a的值仍为0。因为await内部实现了Generator机制,会保存当前的执行上下文(堆栈),此时a = 0的状态被保留。
- 由于await是异步操作,若其后的表达式不返回Promise,则会被包装为Promise.resolve(返回值),随后继续执行函数外部的同步代码。
- 当外部同步代码执行完毕后,异步任务恢复,取出之前保存的a = 0的值进行计算,此时a = 0 + 10。
上述解释中提到await内部实现了Generator,实际上await就是Generator与Promise的语法糖,且内部自动实现了Generator的执行逻辑。熟悉co库的开发者可以自行实现类似的语法糖。
常用定时器机制解析
面试题:setTimeout、setInterval和requestAnimationFrame各有什么特点?
异步编程离不开定时器,常见的有setTimeout、setInterval和requestAnimationFrame。首先讨论最常用的setTimeout,许多人误以为设定多少毫秒就会精确在多少毫秒后执行,这是一个误区。
JavaScript是单线程执行的,若前序代码执行耗时较长,会导致setTimeout无法按预期时间触发。当然,我们可以通过代码优化来修正setTimeout,使其执行时间相对准确。
let period = 60 * 1000 * 60 * 2
let startTime = new Date().getTime()
let count = 0
let end = new Date().getTime() + period
let interval = 1000
let currentInterval = interval
function loop() {
count++
// 代码执行所消耗的时间
let offset = new Date().getTime() - (startTime + count * interval);
let diff = end - new Date().getTime()
let h = Math.floor(diff / (60 * 1000 * 60))
let hdiff = diff % (60 * 1000 * 60)
let m = Math.floor(hdiff / (60 * 1000))
let mdiff = hdiff % (60 * 1000)
let s = mdiff / (1000)
let sCeil = Math.ceil(s)
let sFloor = Math.floor(s)
// 得到下一次循环所消耗的时间
currentInterval = interval - offset
console.log('时:'+h, '分:'+m, '毫秒:'+s, '秒向上取整:'+sCeil, '代码执行时间:'+offset, '下次循环间隔'+currentInterval)
setTimeout(loop, currentInterval)
}
setTimeout(loop, currentInterval)接下来看setInterval,其作用与setTimeout类似,区别在于它会每隔指定时间重复执行回调函数。
通常不建议使用setInterval。首先,它同样受单线程阻塞影响,无法保证在预期时间执行。其次,它存在任务累积问题,见以下伪代码示例:
function demo() {
setInterval(function(){
console.log(2)
},1000)
sleep(2000)
}
demo()在浏览器环境中,若定时器执行过程中出现耗时操作,多个回调函数会在耗时操作结束后同时触发执行,从而引发性能问题。
若有循环定时器的需求,推荐使用requestAnimationFrame来实现。
function setInterval(callback, interval) {
let timer
const now = Date.now
let startTime = now()
let endTime = startTime
const loop = () => {
timer = window.requestAnimationFrame(loop)
endTime = now()
if (endTime - startTime >= interval) {
startTime = endTime = now()
callback(timer)
}
}
timer = window.requestAnimationFrame(loop)
return timer
}
let a = 0
setInterval(timer => {
console.log(1)
a++
if (a === 3) cancelAnimationFrame(timer)
}, 1000)requestAnimationFrame自带函数节流功能,在屏幕刷新率正常(约16.6毫秒)的情况下保证每帧只执行一次。其延时效果精确,无其他定时器的不准问题,也可用于实现setTimeout的功能。
以上为JavaScript异步编程常见面试题的详细解析,更多相关技术资料请持续关注本站后续文章。