本文总结了JavaScript中处理异步操作的四种主要方式:回调函数、发布/订阅模式、Promise以及Async/Await。文章通过具体代码示例,分析了回调地狱的问题,并对比了各方法在解耦、链式调用及语义化方面的优劣,旨在帮助开发者在ES5兼容环境下选择最合适的异步处理方案。
本文通过具体实例,详细梳理了JavaScript中处理异步操作的几种主流方案,供读者参考学习。
引言
JavaScript异步操作是一个经典话题,网络上相关讨论众多。本文的写作动机源于实际开发需求:在构建一套复杂插件时,需处理多种异步场景。出于对代码体积和兼容性的严格限制,项目不允许引入任何Polyfill甚至Babel,因此必须基于ES5标准进行开发。鉴于回调方式不利于代码解耦,笔者探索了其他处理路径。在解决具体问题的过程中,也引发了对JS异步处理方法的系统性思考:究竟有哪些可行的技术方案?
一、回调函数
著名的“回调地狱”(callback hell)即源于此方法。尽管存在缺陷,回调函数仍是处理JS异步操作最基础且常用的手段。以下通过一个简单示例进行说明:
首先定义三个函数:
function fn1 () {
console.log('Function 1')
}
function fn2 () {
setTimeout(() => {
console.log('Function 2')
}, 500)
}
function fn3 () {
console.log('Function 3')
}
其中fn2可视为一个延迟500毫秒执行的异步函数。若需依次执行fn1、fn2和fn3,并确保fn3在最后执行,可将其作为fn2的回调函数传入:
function fn2 (f) {
setTimeout(() => {
console.log('Function 2')
f()
}, 500)
}
fn2(fn3)
如上所示,fn2与fn3紧密耦合。若涉及多个类似函数,极易出现fn1(fn2(fn3(fn4(...))))式的嵌套结构。回调地狱带来的维护难题无需多言,相信读者均有切身感受。
二、事件发布/订阅
发布/订阅模式是众多设计模式之一,且能在ES5环境下优雅地处理异步操作。所谓发布/订阅,以上一节为例,fn1、fn2和fn3均可视为事件发布者,执行它们即发布事件。此时,可通过一个事件订阅者批量订阅并处理这些事件,包括控制其执行顺序。基于上一节示例,增加一个消息订阅者方法(为简洁起见,代码采用ES6写法):
class AsyncFunArr {
constructor (...arr) {
this.funcArr = [...arr]
}
next () {
const fn = this.funcArr.shift()
if (typeof fn === 'function') fn()
}
run () {
this.next()
}
}
const asyncFunArr = new AsyncFunArr(fn1, fn2, fn3)
随后在fn1、fn2和fn3内部调用其next()方法:
function fn1 () {
console.log('Function 1')
asyncFunArr.next()
}
function fn2 () {
setTimeout(() => {
console.log('Function 2')
asyncFunArr.next()
}, 500)
}
function fn3 () {
console.log('Function 3')
asyncFunArr.next()
}
// output =>
// Function 1
// Function 2
// Function 3
可见,函数的执行顺序与传入AsyncFunArr的参数顺序一致。AsyncFunArr在内部维护一个数组,每次调用next()方法时,按顺序取出数组中保存的一个对象并执行。这是笔者在实际工作中较为常用的方法。
三、Promise
使用Promise无需额外编写消息订阅者函数,只需异步函数返回一个Promise对象即可。示例如下:
function fn1 () {
console.log('Function 1')
}
function fn2 () {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Function 2')
resolve()
}, 500)
})
}
function fn3 () {
console.log('Function 3')
}
同样定义三个函数,其中fn2是一个返回Promise的异步函数。若需按顺序执行,可采用以下方式:
fn1()
fn2().then(() => { fn3() })
// output =>
// Function 1
// Function 2
// Function 3
与回调相比,Promise有两个显著优势:一是fn2与fn3得以解耦;二是将函数嵌套转化为链式调用,无论从语义还是写法上,都对开发者更加友好。
四、generator
如果说Promise能将回调转化为链式调用,那么Generator方法则能消除大量Promise特有的特征方法,例如大量的then()调用。
function fn1 () {
console.log('Function 1')
}
function fn2 () {
setTimeout(() => {
console.log('Function 2')
af.next()
}, 500)
}
function fn3 () {
console.log('Function 3')
}
function* asyncFunArr (...fn) {
fn[0]()
yield fn[1]()
fn[2]()
}
const af = asyncFunArr(fn1, fn2, fn3)
af.next()
// output =>
// Function 1
// Function 2
// Function 3
在此示例中,Generator函数asyncFunArr()接收一个待执行函数列表fn,异步函数通过yield执行。在异步函数内部,通过af.next()激活Generator函数的下一步操作。
粗略看来,这与发布/订阅模式相似,均是在异步函数内部主动调用方法,通知订阅者执行下一步。然而,这种方式仍不够优雅:若涉及多个异步函数,Generator函数需相应改写;且在语义化表达上,略显不够直观。
五、优雅的async/await
使用最新版本的Node.js已原生支持async/await语法,通过Polyfill也可在旧版浏览器中运行。为何称async/await为最优雅的方案?请看代码:
function fn1 () {
console.log('Function 1')
}
function fn2 () {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Function 2')
resolve()
}, 500)
})
}
function fn3 () {
console.log('Function 3')
}
async function asyncFunArr () {
fn1()
await fn2()
fn3()
}
asyncFunArr()
// output =>
// Function 1
// Function 2
// Function 3
是否发现,定义异步函数fn2时的内容与前文使用Promise时完全一致?再看执行函数asyncFunArr(),其执行方式与使用Generator时也非常类似。
所有异步操作均返回Promise,若需顺序执行,只需await相应函数即可。这种方式在语义化方面极为友好,且代码维护简单——只需返回Promise并await它,无需像Generator那样手动维护内部yield的执行逻辑。
六、尾声
作为归纳总结,本文涉及的诸多知识点源自他人经验,并融入了笔者个人的理解与体会。不求科普大众,但求作为个人技术积累。欢迎读者提出修正意见,共同学习进步!
感兴趣的朋友可以使用在线HTML/CSS/JavaScript代码运行工具:http://tools.jb51.net/code/HtmlJsRun测试上述代码运行效果。
更多关于JavaScript相关内容可查看本站专题:《JavaScript操作DOM技巧总结》、《JavaScript页面元素操作技巧总结》、《JavaScript事件相关操作与技巧大全》、《JavaScript查找算法技巧总结》、《JavaScript数据结构与算法技巧总结》、《JavaScript遍历算法与技巧总结》及《JavaScript错误与调试技巧总结》
希望本文所述对大家JavaScript程序设计有所帮助。