前端异步编程的那些事
一、异步编程的运行机制
我们学习Javascript语言的时候就知道它的执行环境是”单线程“的。
所谓”单线程“,就是指一次只能处理一个任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。
所以为了解决“单线程”引发的出来的问题,就有了前端异步编程的这个解决方案,下面我们就来探讨一下浏览器的异步编程是一个怎么的运行机制。
1.1异步任务
前端中常见的异步任务有以下:
• 回调函数
• 事件绑定
• 定时器(settimeout ,setinterval)
• ajax
• Promise
是异步的吗?我们来验证下:
new Promise((resolve,reject)=>{
console.log(1);
resolve();
}).then(()=>{
console.log(2);
})
console.log(3);
我们看下控制台的输出顺序:
由上可见 new Promise 的时候传递的executor函数是立即执行的(同步),基于then、catch存放的方法是异步执行的。
• async/await
这是基于Generator语法糖,同样也是异步的,我们把上面的例子改成这样看看。
async function async1() {
console.log(1);
await 10;
console.log(2);
}
fn();
console.log(3);
我们看下控制台的输出顺序:
由上可见 await 表达式后面的代码是异步执行的 。
1.2Event Loop
那么JS是如何构建出异步编程的效果呢?那我们就来了解下事件循环机制(Event Loop )与事件队列(Event Queue)。
1、开始,任务执行。
2、同步任务直接在主栈(Call Stack)中等待被执行,异步的进入Event Table并注册函数。当指定的事情完成时,Event Table会将这个函数移入Event Queue。
3、当 Call Stack中没有任务了,就从Event Queue中拿出一个任务放入Call Stack执行。
而 Event Loop 指的就是这一整个圈圈:它不停检查 Call Stack 中是否有任务(也叫栈帧)需要执行,如果没有,就检查 Event Queue,从中弹出一个任务,放入主栈中,如此往复循环。
我们来看个例子:
setTimeout(()=>{
console.log(1);
},50)
setTimeout(()=>{
console.log(2);
},0)
console.time('for takes time:');
for(let i=0;i<1000000;i++){}
console.timeEnd('for takes time:');
setTimeout(()=>{
console.log(3);
},20)
console.log(4);
我们先看下执行结果再来分析。
代码分析如下:
这里主要容易疑惑点的点是T1先执行还是T3先执行,这里要看for 循环主要消耗的时间,for 循环用了5ms(假设是个整数),这时候队列中T1需要等待的时间还有45ms,所以执行完T2后,等待到了20ms 就会执行T3,最后才执行T1。如果把for循环耗的时间是100ms,那么执行顺序就是T2=>T1=>T3,遵守时间到先执行,先进队列先执行的原则。
我们来看个稍微复杂点的例子:
async function async1(){
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2(){
console.log('async2');
}
console.log('script start');
setTimeout(function(){
console.log('setTimeout');
})
async1();
new Promise(function(resolve){
console.log('promise1');
resolve();
}).then(function(){
console.log('promise2');
})
console.log('script end');
学习上面的Event Loop ,同步任务先执行,再遵守先进先出的原则从Event Queue中取出异步任务到主栈中执行,我自信的以为输出顺序为:
'script start'
'async1 start'
'async2'
'promise1'
'script end'
'setTimeout'
'async1 end'
'promise2'
然后迫不及待的去控制台执行下:
为什么顺序执行是这样子的?于是继续查资料。
1.3宏任务与微任务
Javacript中事件执行主要分为两种任务类型:宏任务(macro task)与微任务队列(micro task)。
• 宏任务包含有 :
script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)
• 微任务包含有 :
Promise.then/catch/finaly,
async/await(本质是对Promise 的一些封装)
Object.observe
MutaionObserver
process.nextTick(Node.js 环境)
1.4宏任务与微任务运行机制
1、执行一个宏任务(栈中没有就从Event Queue中取出)
2、遇到异步宏任务时它加入到Event Queue宏任务队列中;遇到异步微任务时把它加入Event Queue微任务队列中。
3、宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
4、当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
5、渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
如下图:
所以上面例子(代码5)的执行分析如下图所示:
1.5总结以上的内容
• javascript是一门单线程语言
• javascript事件循环就是一个Event Loop 过程
• Event Queue 中的又分为宏任务队列与微任务队列
• Javscript代码的执行顺序:同步任务=>异步微任务=>异步宏任务
二、异步编程使用场景
上面内容我们探讨了浏览器异步编程是的运行机制。我们一般会在哪些场景使用异步编程呢?下面我们看个例子:
现在有个业务需求就是要先请求接口1拿到“系统”的下拉数据后,并且要给表单赋值为下拉数据的第一个,拿到“系统”的value值后再请求接口2取到“表单分类”的下拉数据,并且要给表单赋值,拿到“表单分类“的值后再请求接口3,取表单的下拉数据。
我们传统的做法一般会用回调函数:
//获取系统
_self.ajaxFn({
url:url1,
success:(res)=>{
//获取表单分类
_self.ajaxFn({
url:url2,
success:(res)=>{
//获取表单
_self.ajaxFn({
url:url3,
success:(res)=>{
//处理业务
}
})
}
})
}
})
2.1回调地狱
回调函数是异步编程的一个传统解决方案,但是如果业务比较复杂,需要层层嵌套,就会引起回调地狱。
这样子的代码嵌套复杂,不易于维护 ,着实让开发者崩溃 。
三、Promise原理及实现
3.1Promise原理及实现
Promise是异步编程的一个解决方案,相比回调函数,使用Promise更为合理和强大,避免了回调函数之间的多嵌套,也使得代码结构更为清晰,便于维护。上面的例子我们用Promise来实现下。
new Promise((resolve,reject)=>{
//获取系统
_self.ajaxFn({
url:url1,
success:(res)=>{
resolve(res);
}
})
}).then(
value=>{
return new Promise((resolve,reject)=>{
//获取表单分类
_self.ajaxFn({
url:url2,
success:(res)=>{
resolve(res);
}
})
})
}
).then(
value=>{
//获取表单
_self.ajaxFn({
url:url1,
success:(res)=>{
//处理逻辑
}
})
}
)
接下来我们就要分析Promise是原理并且要自己实现Promise。
3.2 promise自定义实现的关键点
1、如何改变promise的状态?
1.1 resolve(value);状态从pending 转为了 resolved
1.2 rejecte(reason);状态从pending 转为了 rejected
1.3 抛出异常(throw);当前的pending 就变为了 rejected
2、Promise then/catch 存放的回调函数是异步微任务的 。
3、Promise.then()返回的新的Promise的结果状态由什么决定的呢?
3.1 简单描述:由then指定的回调函数执行的结果决定
3.2 详细描述:
3.2.1 如果抛出异常,新Promise变为rejected, reason 为抛出的异常;
3.2.2 如果返回的是Promise的任意值,新promise变为resolved,value 为返回的值
3.2.3 如果返回的是另一个Promise,此Promise的结果成为新Promise的结果
4、Promise.then()/catch()可链式调用,所以then(),catch方法必须返回一个的Promise对象。
3.3 自定义Promise
1、定义整体结构
(function(){
/*
构造函数
excutor:执行器(同步执行)
*/
function Promise(excutor){}
/*
Promise 原型对象的then()
指定成功和失败的回调函数
因为可链式调用,所以要返回一个promise对象
*/
Promise.prototype.then=function(onResolved,onRejected){ }
/*
Promise 原型对象的catch()
指定失败的回调函数
返回一个promise对象
*/
Promise.prototype.catch=function(onRejected){ }
/*
只实现Promise关键 的构造方法以及catch/then方法,其它的方法大家可自行发挥啦
*/
window.Promise=Promise;
})(window)
2、Promise中回调的函数的异步执行
第一部分内容我们就知道Promise.then/catch中存放是任务是方式是异步执行,且是微任务。异步执行我们很容易就能想到setTimeout能实现,但是setTimeout是宏任务,所以不符合,于是我就找到了Vue中的nextTick的方法来模拟一下,它主要是用HTML5新的API MutationObserver(不熟悉的可自行了解下)来实现的,第一部分内容我们有说到MutationObserver是微任务。
const nextTick = (function () {
var callbacks = [];
var pending = false;
var timerFunc;
function nextTickHandler () {
pending = false;
var copies = callbacks.slice(0);
callbacks = [];
for (var i = 0; i < copies.length; i++) {
copies[i]()
}
}
//istanbul ignore if
if (typeof MutationObserver != 'undefined') { // 首选 MutationObserver
var counter = 1
var observer = new MutationObserver(nextTickHandler) // 声明 MO 和回调函数
var textNode = document.createTextNode(counter)
observer.observe(textNode, { // 监听 textNode 这个文本节点
characterData: true // 一旦文本改变则触发回调函数 nextTickHandler
})
timerFunc = function () {
counter = (counter + 1) % 2 // 每次执行 timeFunc 都会让文本在 1 和 0 间切换
textNode.data = counter
}
} else {
timerFunc = setTimeout // 如果不支持 MutationObserver, 退选 setTimeout
}
return function (cb, ctx) {
var func = ctx ? function () { cb.call(ctx) } : cb
callbacks.push(func)
if (pending) return
pending = true
timerFunc(nextTickHandler, 0)
}
})()
3、Promise 的构造函数的实现
/*
构造函数
excutor:执行器
*/
function Promise(excutor){
const _self=this;
_self.status='pending';//promise对象指定的status属性,初始值为pending;
_self.data=undefined;//promise对象指定一个用于存储结果数据的属性;
_self.callbacks=[];//失败或成功的回调函数数组,每个元素的结构:{onResolved(){},onRejected(){}}
function resolve(value){
if(_self.status!='pending'){
return;
}
_self.status='resolved'; //将状态改为resolved
_self.data=value; // 保存value
//如果有待将执行的回调函数,就立即异步执行回调函数 onResolved
nextTick(()=>{
_self.callbacks.forEach((callbacksObj)=>{
callbacksObj.onResolved(value);
})
})
}
function reject(reason){
if(_self.status!='pending'){
return;
}
_self.status='rejected'; //将状态改为rejected
_self.data=reason; // 保存value
//如果有待将执行的回调函数,就立即异步执行回调函数 onRejected
nextTick(()=>{
_self.callbacks.forEach((callbacksObj)=>{
callbacksObj.onRejected(reason);
})
})
}
//立即执行执行器
try{
excutor(resolve,reject);
}
catch(error){
reject(error);
}
}
4、Promise.prototype.then()方法的实现
/*
Promise 原型对象的then()方法
指定成功和失败的回调函数
因为可链式调用,所以必须要要返回一个promise对象
返回的promise 的结果是由onResolved/onRejected执行的结果决定的
*/
Promise.prototype.then=function(onResolved,onRejected){
var _self=this;
//指定回调函数的默认值,如果参数不是函数就指定个默认方法
onResolved=typeof onResolved==='function'? onResolved :value => value;
onRejected=typeof onRejected==='function'? onRejected :reason => {throw reason};
return new Promise((resolve,reject)=>{
/*
执行指定callback(onResolved/onRejected)的回调函数
根据执行的结果改变return的pormise的状态
*/
function handle(callback){
try{
const result=callback(_self.data);
if(result instanceof Promise){ //返回的是promise,返回promise的结果就是这个结果
result.then(resolve,reject)
}else{//返回的不是promise,返回的promise为成功,value就是返回值
resolve(result)
}
}catch(error){ //抛出异常,返回promise的结果为失败,reason为异常
reject(error)
}
}
if(_self.status==='resolved'){ //当前promise的状态是resolved
//异步执行成功的回调
nextTick(()=>{
handle(onResolved)
})
}else if(_self.status==='rejected'){//当前promise的状态是rejected
//异步执行失败的回调
nextTick(()=>{
handle(onRejected)
})
}else{ //当前promise 的状态是pending
//将成功和失败的回调函数保存到callback中去存起来
_self.callbacks.push({
onResolved(){
handle(onResolved);
},
onRejected(){
handle(onRejected);
}
})
}
})
5、Promise.prototype.catch()方法的实现
/*
Promise 原型对象的catch()
指定失败的回调函数
返回一个promise对象
*/
Promise.prototype.catch=function(onRejected){
return this.then(null,onRejected)
}
大功告成,其实主要是把then 方法实现,其它的都好实现了,其它的方法大家试下吧。
四、最后
这篇文章主要跟大家探讨了浏览器异步编程的运行机制还有Promise的实现原理。
前端之路漫漫其修远兮,吾将上下而求索,与君共勉!
—— E N D ——
排版:chuanrui