异步的由来
JavaScript 是单线程语言,浏览器只分配了一个主线程执行任务,意味着如果有多个任务,则必须按照顺序执行,前一个任务执行完成之后才能继续下一个任务。
这个模式比较清晰,但是当任务耗时较长的时候,比如网络请求,定时器和事件监听等,这个时候后续任务继续等待,效率比较低。我们常见的页面无响应,有时候就是因为任务耗时长或者无限循环等造成的。那现在是怎么解决这个问题呢。。。。
首先维护了一个“任务队列”。JavaScript 虽然是单线程的,但运行的宿主环境(浏览器)是多线程的,浏览器为这些耗时任务开辟了另外的线程,主要包括 http 请求线程,浏览器定时触发器,浏览器事件触发线程。这些线程主要把任务回调,放在任务队列里,等待主线程执行。
简单介绍如下图:
这样就实现了 JavaScript 的单线程异步,任务被分为同步任务和异步任务两种:
同步任务:排队执行的任务,后一个任务等待前一个任务结束。
异步任务:放入任务队列的任务,未来才会触发执行的事件。
异步执行机制
异步任务分为宏任务和微任务。
宏任务(macroTask)
宏任务,其实就是标准机制下的常规任务,即”任务队列中“等待被主线程执行的事件,是由浏览器宿主发起的任务,例如:
- script (可以理解为外层主程序同步代码)。
- setTimeout,setInterval,requestAnimationFrame。
- I/O。
- 渲染事件(解析 DOM,布局,绘制等)。
- 用户交互事件(鼠标点击,页面滚动,放大缩小等)。
宏任务会被放在宏任务队列里,先进先出的原则,两个宏任务中间可能会被插入其他系统任务,间隔时间不定,效率较低 。
微任务(microTask)
由于宏任务间隔不定,时间颗粒大,对于实时性要求比较高的场景就需要更精确地控制,需要把任务插入到当前宏任务执行,从而产生了微任务的概念。
微任务是 JavaScript 引擎发起的,是需要异步执行的函数。例如:
- Promise:ES6 的异步编程,Promise 的各种 Api 会产生微任务,下面异步实现会做详细介绍。
- MutationObserver(浏览器):监视 DOM 树更改,DOM 节点的变化是微任务。
在执行 JavaScript 脚本,创建全局执行上下文的时候,JavaScript 引擎就会创建一个微任务队列,在执行当前宏任务时,产生的微任务都会保存到微任务队列里。在宏任务主函数执行结束之后,宏任务结束之前,清空微任务队列。
微任务和宏任务是绑定的,每个宏任务都会创建自己的微任务:
事件循环(Event loop)
主线程运行 JavaScript 代码时,会生成个执行栈(先进后出),管理主线程上函数调用关系的数据结构。
当执行栈中的所有同步任务执行完毕,系统就会不断的从"任务队列"中读取事件,这个过程是循环不断的,称为 Event Loop(事件循环)。
事件循环机制调度宏任务和微任务,机制如下:
- 执行一个宏任务(第一次是最外层同步代码),执行过程中如果遇到微任务会加入微任务队列;
- 代码执行完成后,查看是否有微任务,如果有的执行第 3 步,没有则执行第 4 步;
- 依次执行所有微任务,在执行微任务的过程中产生的新的微任务也会被事件循环处理,直到队列清空,宏任务完成,执行第 4 步;
- 查看是否有下一个宏任务,有的话则执行第 1 步,没有则结束。
异步的实现历程
回调函数
回调函数是一个函数被当做参数传递给另一个函数,另一个函数完成之后执行回调。比如 Ajax 请求、IO 操作、定时器的回调等。
下面是 setTimeout 例子:
console.log('setTimeout 调用之前')
setTimeout(() => {console.log('setTimeout 输出')}, 0);
console.log('setTimeout 调用之后')
// 结果
setTimeout 调用之前
setTimeout 调用之后
setTimeout 输出
setTimeout 回调放入任务队列中,当主线程的同步代码执行完之后,才会执行任务队列的回调,所以是如上的输出结果。
优缺点
优点:回调函数相对比较简单、容易理解。
缺点:不利于代码的阅读和维护,各个部分之间高度耦合,流程会很混乱,而且每个任务只能指定一个回调函数,易形成回调函数地狱。如下:
setTimeout(function(){
let value1 = step1()
setTimeout(function(){
let value2 = step2(value1)
setTimeout(function(){
step3(value2)
},0);
},0);
},0);
Promise
Promise 是 ES6 新增的异步编程的方式,在一定程度上解决了回调地域的问题。简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。
使用 Promise 首先要明白以下特点:
- Promise 有三种状态 pending、rejected、resolved,状态一旦确定就不能改变,且只能够由 pending 状态变成 rejected 或者 resolved 状态;
- Promise 实例最主要的方法就是 then 的实现,有两个参数。 Promise 执行成功时,调用 then 方法的第一个回调函数,失败则调用第二个回调函数,而且 then 方法会返回一个新的 Promise 实例。
- 其次常用的就是 catch 方法,catch 方法实际是 then 方法第一个参数是 null 的情况,用于指定发生错误时的回调函数。
- 还有很多其他的 finally、all、race、allSettled、any、resolve、reject 等一系列 Api。
下面的例子就是常见的异步操作,主要是使用的 then 和 catch:
new Promise((resolve) => {
resolve(step1())
}).then(res => {
return step2(res)
}).catch(err => {
console.log(err)
})
step1 和 step2 是异步操作,step1 执行完之后的返回值会透传给 then 回调,当做 step2 的入参,通过 then 一层层的代替回调地域。其中 then 的回调会加入微任务队列。
Promise 为什么是微任务呢?
当 Promise 入参是同步代码时:
console.log('start')
new Promise((resolve) => {
console.log('开始 resolve')
resolve('resolve 返回值')
}).then(data => {
console.log(data)
})
console.log('end')
// 原生 promise 输出结果
start
开始 resolve
end
resolve 返回值
首先看下 Promise 的极简实现:
class Promise {
constructor (executor) {
// 回调值
this.value = ''
// 成功的回调
this.onResolvedCallbacks = []
executor(this.resolve.bind(this))
}
resolve (value) {
this.value = value
this.onResolvedCallbacks.forEach(callback => callback())
}
then (onResolved, onRejected) {
this.onResolvedCallbacks.push(() => {
onResolved(this.value)
})
}
}
// 此时上面例子执行结果如下
start
开始 resolve
end
由于 Promise 是延迟绑定机制(回调在业务代码的后面),executor 是同步代码时,在执行到 resolve 的时候,还没有执行 then,所以 onResolvedCallbacks 是空数组。这个时候需要让 resolve 延后执行,可以先加一个定时器。如下:
resolve (value) {
setTimeout(() => {
this.value = value
this.onResolvedCallbacks.forEach(callback => callback())
})
}
输出结果和预期是一致的,这里使用 setTimeout 来延迟执行 resolve。但是 setTimeout 是宏任务,效率不高,这里只是用 setTimeout 代替,在浏览器中,JavaScript 引擎会把 Promise 回调映射到微任务,既可以延迟被调用,又提升了代码的效率。
优缺点
优点:
- 将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。
- 提供统一的接口,使得控制异步操作更加容易。
缺点:
- 无法取消 Promise,一旦新建它就会立即执行,无法中途取消。
- 如果不设置回调函数,Promise 内部抛出的错误,不会反应到外面。
- 当处于 pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
Generator/yield
Generator 是 ES6 提供的异步解决方案,其最大的特点就是可以控制函数的执行。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器,异步操作需要暂停的地方,都用 yield 语句注明。
Generator 函数的特征:
- function 关键字与函数名之间有一个星号;
- 函数体内部使用 yield 表达式,定义不同的内部状态;
- 通过 yield 暂停执行;
- next 恢复执行,并且返回一个包含 value 和 done 属性的对象,其中 value 表示 yield 表达式的值,done 表示遍历器是否完成;
- next 方法也可以接受参数, 作为上一次 yield 语句的返回值。
function* getData () {
let value1 = yield 111
let value2 = yield value1 + 111 // 这里的 value1 就是下面传入的 val1.value
return value2
}
let meth = getData()
let val1 = meth.next()
console.log(val1) // { value: 111, done: false }
let val2 = meth.next(val1.value)
console.log(val2) // { value: 222, done: false }
let val3 = meth.next(val2.value)
console.log(val3) // { value: 222, done: true }
- 调用 getData 函数,会返回一个内部指针 meth(即遍历器);
- 调用指针 meth 的 next 方法,移动内部指针,指向第一个遇到的 yield 语句,输出返回值为
{value: 111, done: false}
; - 再次调用指针 meth 的 next 方法,入参为 111,赋值给 value1,移动内部指针,指向下一个 yield 语句,输出表达式的返回值为
{value: 222, done: false}
; - 持续调用指针 meth 的 next 方法,入参为 222,赋值给 value2,遇到 return 结束遍历器,输出返回值
{ value: 222, done: true }
。
Generator 是怎么实现暂停和恢复执行的呢?
Generator 是协程的一种实现方式。
协程:协程是一种比线程更加轻量级的存在,协程处在线程的环境中,一个线程可以存在多个协程,可以将协程理解为线程中的一个个任务。通过应用程序代码进行控制。
上面的例子中协程具体流程如下:
- 通过生成器函数 getData 创建一个协程 meth,创建之后没有立即执行;
- 调用 meth.next() 让协程执行;
- 协程执行时,通过关键字 yield 暂停协程;
- 协程执行时,遇到 return,JavaScript 引擎结束当前协程,并把结果返回给父协程。
meth 协程和父协程在主线程上交替执行,通过 next() 和 yield 进行控制,只有用户态,切换效率高。
优缺点
优点:Generator 是以一种看似顺序、同步的方式实现了异步控制流程,增强了代码可读性。
缺点:需要手动 next 执行下一步。
async/await
async/await 将 Generator 函数和自动执行器,封装在一个函数中,是 Generator 的一种语法糖,简化了外部执行器的代码,同时利用 await 替代 yield,async 替代生成器的(*)号。
async 和 Generator 相比改进的地方:
- 内置执行器,不需要使用 next() 手动执行。
- await 命令后面可以是 Promise 对象或原始类型的值,如果是原始值,会 Promise 化。
- async 返回值是 Promise。返回非 Promise 时,async 函数会把它包装成 Promise 返回。
下面来看个 sleep 的例子:
function sleep(time) {
return new Promise((resolve, reject) => {
time+=1000
setTimeout(() => {
resolve(time);
}, 1000);
});
}
async function test () {
let time = 0
for(let i = 0; i < 4; i++) {
time = await sleep(time);
console.log(time);
}
}
test()
// 输出结果
1000
2000
3000
执行结果每隔一秒会输出 time,await 是等待的意思,等待 sleep 执行完毕后通过 resolve 返回,才会继续执行,间隔至少一秒。
把 async/await 转成 Generator 和 Promise 来实现。
function test () {
let time = 0
// stepGenerator 生成器
function* stepGenerator() {
for (let i = 0; i < 4; i++) {
let result = yield sleep(time);
console.log(result);
}
}
let step = stepGenerator()
let info
return new Promise((resolve) => {
// 自执行 next()
function stepNext () {
info = step.next(time)
// 执行结束则返回 value
if (info.done) {
resolve(info.value)
} else {
// 遍历没有结束 ,继续执行
return Promise.resolve(info.value).then((res) => {
time = res
return stepNext()
})
}
}
stepNext()
})
}
test()
- 首先把 async 包装成 Promise,async/await 转换成 stepGenerator 生成器,yield 替换 await;
- 执行 stepNext();
- stepNext 里,step 遍历器会执行 next()。done 为 false 时,说明遍历没有完成,通过 Promise.resolve 等待执行结果,获取结果之后继续执行 next(),直到 done 为 true,async 的 resolve 把最终返回。
优缺点
优点:是 Generator 更简化的方式,相当于自动执行 Generator,代码更清晰,更简单。
缺点:滥用 await 可能会导致性能问题,因为 await 会阻塞代码,非依赖代码失去并发性。
多个异步的执行顺序问题
多个异步的执行顺序问题是很考验对异步的理解的。下面我们把 setTimeout、Promise、async/await 放在一起,看下返回结果和预想的是否一致:
console.log('start')
setTimeout(function() {
console.log('setTimeout')
}, 0);
async function test () {
let a = await 'await-result'
console.log(a)
}
test()
new Promise(function(resolve) {
console.log('promise-resolve')
resolve()
}).then(function() {
console.log('promise-then')
})
console.log('end')
//执行结果
start
promise-resolve
end
await-result
promise-then
setTimeout
上述例子中,外层主程序 和 setTimeout 都是宏任务,Promise 和 async/await 是微任务,所以整个流程如下:
- 第一个宏任务(主程序)开始执行 ------ 输出 start
- setTimeout 加入宏任务队列
- 执行 test(),async/await 加入微任务队列
- Promise 初始入参是同步代码,主程序一起执行 ------ 输出 promise-resolve
- Promise 的 then 回调加入微任务队列
- 继续执行主程序 ------ 输出 end
- 执行第一个微任务 ------ 输出 await-result
- 执行第二个微任务 ------ 输出 promise-then
- 再执行下一个宏任务(setTimeout) ------ 输出 setTimeout
总结
前端程序员日常代码经常会用到异步编程,了解异步运行的机制和顺序有助于更流畅清晰的实现异步代码,这里主要分析了异步的由来和异步代码实现,可结合不同的场景和要求进行选择。
参考资料
- es6.ruanyifeng.com/
- www.imooc.com/article/287…
- www.ruanyifeng.com/blog/2012/1…
- 极客时间 - 李兵(time.geekbang.org/column/intr…)
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!