资讯专栏INFORMATION COLUMN

马蹄疾 | 详解 JavaScript 异步机制及发展历程(万字长文)

shaonbean / 1088人阅读

摘要:本文从入手,系统的回顾的异步机制及发展历程。需要提醒的是,文本没有讨论的异步机制。这就是之前提到的事件触发线程。其实无论是请求还是定时器还是事件,我们都可以统称它们为事件。第二阶段,引擎线程专注于处理事件。将外元素的事件回调放入调用栈。

functionshowImg(url){ varframeid="frameimg"+Math.random(); window.img="

本文从EventLoopPromiseGeneratorasyncawait入手,系统的回顾JavaScript的异步机制及发展历程。

需要提醒的是,文本没有讨论nodejs的异步机制。

本文是『horseshoe·Async专题』系列文章之一,后续会有更多专题推出

GitHub地址(持续更新):horseshoe

博客地址(文章排版真的很漂亮):matiji.cn

如果觉得对你有帮助,欢迎来GitHub点Star或者来我的博客亲口告诉我

事件循环

也许我们都听说过JavaScript是事件驱动的这种说法。各种异步任务通过事件的形式和主线程通信,保证网页流畅的用户体验。而异步可以说是JavaScript最伟大的特性之一(也许没有之一)。

现在我们就从Chrome浏览器的主要进程入手,深入的理解这个机制是如何运行的。

Chrome浏览器的主要进程

我们看一下Chrome浏览器都有哪些主要进程。

Browser进程。这是浏览器的主进程。

第三方插件进程。

GPU进程。

Renderer进程。

大家都说Chrome浏览器是内存怪兽,因为它的每一个页面都是一个Renderer进程,其实这种说法是不对的。实际上,Chrome支持好几种进程模型。

Process-per-site-instance。每打开一个网站,然后从这个网站链开的一系列网站都属于一个进程。这也是Chrome的默认进程模型。

Process-per-site。同域名范畴的网站属于一个进程。

Process-per-tab。每一个页面都是一个独立的进程。这就是外界盛传的进程模型。

SingleProcess。传统浏览器的单进程模型。

浏览器内核

现在我们知道,除了相关联的页面可能会合并为一个进程外,我们可以简单的认为每个页面都会开启一个新的Renderer进程。那么这个进程里跑的程序又是什么呢?就是我们常常说的浏览器内核,或者说渲染引擎。确切的说,是浏览器内核的一个实例。Chrome浏览器的渲染引擎叫Blink

由于浏览器主要是用来浏览网页的,所以虽然Browser进程是浏览器的主进程,但它充当的只是一个管家的角色,真正的一线业务大拿还得看Renderer进程。这也是跑在Renderer进程里的程序被称为浏览器内核(实例)的原因。

介绍Chrome浏览器的进程系统只是为了引出Renderer进程,接下来我们只需要关注浏览器内核与Renderer进程就可以了。

Renderer进程的主要线程

Renderer进程手下又有好多线程,它们各司其职。

GUI渲染线程。

JavaScript引擎线程。对于Chrome浏览器而言,这个线程上跑的就是威震海内的V8引擎。

事件触发线程。

定时器线程。

异步HTTP请求线程。

调用栈

进入主题之前,我们先引入调用栈(callstack)的概念,调用栈是JavaScript引擎执行程序的一种机制。为什么要有调用栈呢?我们举个例子。

conststr="biu";

console.log("1");

functiona(){
console.log("2");
b();
console.log("3");
}

functionb(){
console.log("4");
}

a();

我们都知道打印的顺序是1243

问题在于,当执行到b函数的时候,我需要记住b函数的调用位置信息,也就是执行上下文。否则执行完b函数之后,引擎可能就忘了执行console.log("3")了。调用栈就是用来干这个的,每调用一层函数,引擎就会生成它的栈帧,栈帧里保存了执行上下文,然后将它压入调用栈中。栈是一个后进先出的结构,直到最里层的函数调用完,引擎才开始将最后进入的栈帧从栈中弹出。

1 2 3 4 5 6 7 8
- - - - console.log("4") - - -
- - console.log("2") b() b() b() console.log("3") -
console.log("1") a() a() a() a() a() a() a()

可以看到,当有嵌套函数调用的时候,栈帧会经历逐渐叠加又逐渐消失的过程,这就是所谓的后进先出。

同时也要注意,诸如conststr="biu"的变量声明是不会入栈的。

调用栈也要占用内存,所以如果调用栈过深,浏览器会报UncaughtRangeError:Maximumcallstacksizeexceeded错误。

webAPI

现在我们进入主题。

JavaScript引擎将代码从头执行到尾,不断的进行压栈和出栈操作。除了ECMAScript语法组成的代码之外,我们还会写哪些代码呢?不错,还有JavaScript运行时给我们提供的各种webAPI。运行时(runtime)简单讲就是JavaScript运行所在的环境。

我们重点讨论三种webAPI。

consturl="https://api.github.com/users/veedrin/repos";
fetch(url).then(res=>res.json()).then(console.log);
consturl="https://api.github.com/users/veedrin/repos";
constxhr=newXMLHttpRequest();
xhr.open("GET",url,true);
xhr.onload=()=>{
if(xhr.status===200){
console.log(xhr.response);
}
}
xhr.send();

发起异步的HTTP请求,这几乎是一个网页必要的模块。我们知道HTTP请求的速度和结果取决于当前网络环境和服务器的状态,JavaScript引擎无法原地等待,所以浏览器得另开一个线程来处理HTTP请求,这就是之前提到的异步HTTP请求线程

consttimeoutId=setTimeout(()=>{
console.log(Date.now());
clearTimeout(timeoutId);
},5000);
constintervalId=setInterval(()=>{
console.log(Date.now());
},1000);
constimmediateId=setImmediate(()=>{
console.log(Date.now());
clearImmediate(immediateId);
});

定时器也是一个棘手的问题。首先,JavaScript引擎同样无法原地等待;其次,即便不等待,JavaScript引擎也得执行后面的代码,根本无暇给定时器定时。所以于情于理,都得为定时器多带带开一个线程,这就是之前提到的定时器线程

const$btn=document.getElementById("btn");
$btn.addEventListener("click",console.log);

按道理来讲,DOM事件没什么异步动作,直接绑定就行了,不会影响后面代码的执行。

别急,我们来看一个例子。

const$btn=document.getElementById("btn");
$btn.addEventListener("click",console.log);
consttimeoutId=setTimeout(()=>{
for(leti=0;i<10000;i++){
console.log("biu");
}
clearTimeout(timeoutId);
},5000);

运行代码,先绑定DOM事件,大约5秒钟后开启一个循环。注意,如果在循环结束之前点击按钮,浏览器控制台会打印什么呢?

结果是先打印10000个biu,接着会打印Event对象。

试想一下,你点击按钮的时候,JavaScript引擎还在处理该死的循环,根本没空理你。那为什么点击事件能够被响应呢(虽然有延时)?肯定是有另外一个线程在监听DOM事件。这就是之前提到的事件触发线程

任务队列

好的,现在我们知道有几类webAPI是多带带的线程在处理。但是,处理完之后的回调总归是要由JavaScript引擎线程来执行的吧?这些线程是如何与JavaScript引擎线程通信的呢?

这就要提到大名鼎鼎的任务队列(TaskQueue)。

其实无论是HTTP请求还是定时器还是DOM事件,我们都可以统称它们为事件。很好,各自的线程把各自的webAPI处理完,完成之后怎么办呢?它要把相应的回调函数放入一个叫做任务队列的数据结构里。队列和栈不一样,队列是先进先出的,讲究一个先来后到的顺序。

有很多文章认为任务队列是由JavaScript引擎线程维护的,也有很多文章认为任务队列是由事件触发线程维护的。

根据上文的描述,事件触发线程是专门用来处理DOM事件的。

然后我们来论证,为什么任务队列不是由JavaScript引擎线程维护的。假如JavaScript引擎线程在执行代码的同时,其他线程要给任务队列添加事件,这时候它哪忙得过来呢?

所以根据我的理解,任务队列应该是由一个专门的线程维护的。我们就叫它任务队列线程吧。

事件循环

JavaScript引擎线程把所有的代码执行完了一遍,现在它可以歇着了吗?也许吧,接下来它还有一个任务,就是不停的去轮询任务队列,如果任务队列是空的,它就可以歇一会,如果任务队列中有回调,它就要立即执行这些回调。

这个过程会一直进行,它就是事件循环(EventLoop)。

我们总结一下这个过程:

第一阶段,JavaScript引擎线程从头到尾把脚本代码执行一遍,碰到需要其他线程处理的代码则交给其他线程处理。

第二阶段,JavaScript引擎线程专注于处理事件。它会不断的去轮询任务队列,执行任务队列中的事件。这个过程又可以分解为轮询任务队列-执行任务队列中的事件-更新页面视图的无限往复。对,别忘了更新页面视图(如果需要的话),虽然更新页面视图是GUI渲染线程处理的。

这些事件,在任务队列里面也被称为任务。但是事情没这么简单,任务还分优先级,这就是我们常听说的宏任务和微任务。

宏任务

既然任务分为宏任务和微任务,那是不是得有两个任务队列呢?

此言差矣。

首先我们得知道,事件循环可不止一个。除了windoweventloop之外,还有workereventloop。并且同源的页面会共享一个windoweventloop。

Awindoweventloopistheeventloopusedbysimilar-originwindowagents.Useragentsmayshareaneventloopacrosssimilar-originwindowagents.

其次我们要区分任务和任务源。什么叫任务源呢?就是这个任务是从哪里来的。是从addEventListener来的呢,还是从setTimeout来的。为什么要这么区分呢?比如键盘和鼠标事件,就要把它的响应优先级提高,以便尽可能的提高网页浏览的用户体验。虽然都是任务,命可分贵贱呢!

所以不同任务源的任务会放入不同的任务队列里,浏览器根据自己的算法来决定先取哪个队列里的任务。

总结起来,宏任务有至少一个任务队列,微任务只有一个任务队列。

微任务

哪些异步事件是微任务?Promise的回调、MutationObserver的回调以及nodejs中process.nextTick的回调。

<divid="outer">
<divid="inner">请点击div>
div>
const$outer=document.getElementById("outer");
const$inner=document.getElementById("inner");

newMutationObserver(()=>{
console.log("mutate");
}).observe($inner,{
childList:true,
});

functiononClick(){
console.log("click");
setTimeout(()=>console.log("timeout"),0);
Promise.resolve().then(()=>console.log("promise"));
$inner.innerHTML="已点击";
}

$inner.addEventListener("click",onClick);
$outer.addEventListener("click",onClick);

我们先来看执行顺序。

click
promise
mutate
click
promise
mutate
timeout
timeout

整个执行过程是怎样的呢?

从头到尾初始执行脚本代码。给DOM元素添加事件监听。

用户触发内元素的DOM事件,同时冒泡触发外元素的DOM事件。将内元素和外元素的DOM事件回调添加到宏任务队列中。

因为此时调用栈中是空闲的,所以将内元素的DOM事件回调放入调用栈。

执行回调,此时打印click。同时将setTimeout的回调放入宏任务队列,将Promise的回调放入微任务队列。因为修改了DOM元素,触发MutationObserver事件,将MutationObserver的回调放入微任务队列。回顾一下,现在宏任务队列里有两个回调,分别是外元素的DOM事件回调setTimeout的回调;微任务队列里也有两个回调,分别是Promise的回调MutationObserver的回调

依次将微任务队列中的回调放入调用栈,此时打印promisemutate

将外元素的DOM事件回调放入调用栈。执行回调,此时打印click。因为两个DOM事件回调是一样的,过程不再重复。再次回顾一下,现在宏任务队列里有两个回调,分别是两个setTimeout的回调;微任务队列里也有两个回调,分别是Promise的回调MutationObserver的回调

依次将微任务队列中的回调放入调用栈,此时打印promisemutate

最后依次将setTimeout的回调放入调用栈执行,此时打印两次timeout

规律是什么呢?宏任务与宏任务之间,积压的所有微任务会一次性执行完毕。这就好比超市排队结账,轮到你结账的时候,你突然想顺手买一盒冈本。难道超市会要求你先把之前的账结完,然后重新排队吗?不会,超市会顺便帮你把冈本的账也结了。这样效率更高不是么?虽然不知道内部的处理细节,但是我觉得标准区分两种任务类型也是出于性能的考虑吧。

$inner.click();

如果DOM事件不是用户触发的,而是程序触发的,会有什么不一样吗?

click
click
promise
mutate
promise
timeout
timeout

严格的说,这时候并没有触发事件,而是直接执行onClick函数。翻译一下就是下面这样的效果。

onClick();
onClick();

这样就解释了为什么会先打印两次click。而MutationObserver会合并多个事件,所以只打印一次mutate。所有微任务依然会在下一个宏任务之前执行,所以最后才打印两次timeout

更新页面视图

我们再来看一个例子。

const$btn=document.getElementById("btn");

functiononClick(){
setTimeout(()=>{
newPromise(resolve=>resolve("promise1")).then(console.log);
newPromise(resolve=>resolve("promise2")).then(console.log);
console.log("timeout1");
$btn.style.color="#f00";
},1000);
setTimeout(()=>{
newPromise(resolve=>resolve("promise1")).then(console.log);
newPromise(resolve=>resolve("promise2")).then(console.log);
console.log("timeout2");
},1000);
setTimeout(()=>{
newPromise(resolve=>resolve("promise1")).then(console.log);
newPromise(resolve=>resolve("promise2")).then(console.log);
console.log("timeout3");
},1000);
setTimeout(()=>{
newPromise(resolve=>resolve("promise1")).then(console.log);
newPromise(resolve=>resolve("promise2")).then(console.log);
console.log("timeout4");
//alert(1);
},1000);
setTimeout(()=>{
newPromise(resolve=>resolve("promise1")).then(console.log);
newPromise(resolve=>resolve("promise2")).then(console.log);
console.log("timeout5");
//alert(1);
},1000);
setTimeout(()=>{
newPromise(resolve=>resolve("promise1")).then(console.log);
newPromise(resolve=>resolve("promise2")).then(console.log);
console.log("timeout6");
},1000);
newMutationObserver(()=>{
console.log("mutate");
}).observe($btn,{
attributes:true,
});
}

$btn.addEventListener("click",onClick);

当我在第4个setTimeout添加alert,浏览器被阻断时,样式还没有生效。

有很多人说,每一个宏任务执行完并附带执行完累计的微任务(我们称它为一个宏任务周期),这时会有一个更新页面视图的窗口期,给更新页面视图预留一段时间。

但是我们的例子也看到了,每一个setTimeout都是一个宏任务,浏览器被阻断时事件循环都好几轮了,但样式依然没有生效。可见这种说法是不准确的。

而当我在第5个setTimeout添加alert,浏览器被阻断时,有很大的概率(并不是一定)样式会生效。这说明什么时候更新页面视图是由浏览器决定的,并没有一个准确的时机。

总结

JavaScript引擎首先从头到尾初始执行脚本代码,不必多言。

如果初始执行完毕后有微任务,则执行微任务(为什么这里不属于事件循环?后面会讲到)。

之后就是不断的事件循环。

首先到宏任务队列里找宏任务,宏任务队列又分好多种,浏览器自己决定优先级。

被放入调用栈的某个宏任务,如果它的代码中又包含微任务,则执行所有微任务。

更新页面视图没有一个准确的时机,是每个宏任务周期后更新还是几个宏任务周期后更新,由浏览器决定。

也有一种说法认为:从头到尾初始执行脚本代码也是一个任务。

如果我们认可这种说法,则整个代码执行过程都属于事件循环。

初始执行就是一个宏任务,这个宏任务里面如果有微任务,则执行所有微任务。

浏览器自己决定更新页面视图的时机。

不断的往复这个过程,只不过之后的宏任务是事件回调。

第二种解释好像更说得通。因为第一种解释会有一段微任务的执行不在事件循环里,这显然是不对的。

迟到的承诺

Promise是一个表现为状态机的异步容器。

它有以下几个特点:

状态不受外界影响。Promise只有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。状态只能通过Promise内部提供的resolve()reject()函数改变。

状态只能从pending变为fulfilled或者从pending变为rejected。并且一旦状态改变,状态就会被冻结,无法再次改变。

newPromise((resolve,reject)=>{
reject("reject");
setTimeout(()=>resolve("resolve"),5000);
}).then(console.log,console.error);

//不要等了,它只会打印一个reject

如果状态发生改变,任何时候都可以获得最终的状态,即便改变发生在前。这与事件监听完全不一样,事件监听只能监听之后发生的事件。

constpromise=newPromise(resolve=>resolve("biu"));
promise.then(console.log);
setTimeout(()=>promise.then(console.log),5000);

//打印biu,相隔大约5秒钟后又打印biu

正是源于这些特点,Promise才敢于称自己为一个承诺

同步代码与异步代码

Promise是一个异步容器,那哪些部分是同步执行的,哪些部分是异步执行的呢?

console.log("kiu");

newPromise((resolve,reject)=>{
console.log("miu");
resolve("biu");
console.log("niu");
}).then(console.log,console.error);

console.log("piu");

我们看执行结果。

kiu
miu
niu
piu
biu

可以看到,Promise构造函数的参数函数是完完全全的同步代码,只有状态改变触发的then回调才是异步代码。为啥说Promise是一个异步容器?它不关心你给它装的是啥,它只关心状态改变后的异步执行,并且承诺给你一个稳定的结果。

从这点来看,Promise真的只是一个异步容器而已。

Promise.prototype.then()

then方法接受两个回调作为参数,状态变成fulfilled时会触发第一个回调,状态变成rejected时会触发第二个回调。你可以认为then回调是Promise这个异步容器的界面和输出,在这里你可以获得你想要的结果。

then函数可以实现链式调用吗?可以的。

但你想一下,then回调触发的时候,Promise的状态已经冻结了。这时候它就是被打开盒子的薛定谔的猫,它要么是死的,要么是活的。也就是说,它不可能再次触发then回调。

那then函数是如何实现链式调用的呢?

原理就是then函数自身返回的是一个新的Promise实例。再次调用then函数的时候,实际上调用的是这个新的Promise实例的then函数。

既然Promise只是一个异步容器而已,换一个容器也不会有什么影响。

constpromiseA=newPromise((resolve,reject)=>resolve("biu"));

constpromiseB=promiseA.then(value=>{
console.log(value);
returnvalue;
});

constpromiseC=promiseB.then(console.log);

结果是打印了两个biu。

constpromiseA=newPromise((resolve,reject)=>resolve("biu"));

constpromiseB=promiseA.then(value=>{
console.log(value);
returnPromise.resolve(value);
});

constpromiseC=promiseB.then(console.log);

Promise.resolve()我们后面会讲到,它返回一个状态是fulfilled的Promise实例。

这次我们手动返回了一个状态是fulfilled的新的Promise实例,可以发现结果和上一次一模一样。说明then函数悄悄的将return"biu"转成了returnPromise.resolve("biu")。如果没有返回值呢?那就是转成returnPromise.resolve(),反正得转成一个新的状态是fulfilled的Promise实例返回。

这就是then函数返回的总是一个新的Promise实例的内部原理。

想要让新Promise实例的状态从pending变成rejected,有什么办法吗?毕竟then方法也没给我们提供reject方法。

constpromiseA=newPromise((resolve,reject)=>resolve("biu"));

constpromiseB=promiseA.then(value=>{
console.log(value);
returnx;
});

constpromiseC=promiseB.then(console.log,console.error);

查看这里的输出结果。

biu
ReferenceError:xisnotdefined
at:6:5

只有程序本身发生了错误,新Promise实例才会捕获这个错误,并把错误暗地里传给reject方法。于是状态从pending变成rejected

Promise.prototype.catch()

catch方法,顾名思义是用来捕获错误的。它其实是then方法某种方式的语法糖,所以下面两种写法的效果是一样的。

newPromise((resolve,reject)=>{
reject("biu");
}).then(
undefined,
error=>console.error(error),
);
newPromise((resolve,reject)=>{
reject("biu");
}).catch(
error=>console.error(error),
);

Promise内部的错误会静默处理。你可以捕获到它,但错误本身已经变成了一个消息,并不会导致外部程序的崩溃和停止执行。

下面的代码运行中发生了错误,所以容器中后面的代码不会再执行,状态变成rejected。但是容器外面的代码不受影响,依然正常执行。

newPromise((resolve,reject)=>{
console.log(x);
console.log("kiu");
resolve("biu");
}).then(console.log,console.error);

setTimeout(()=>console.log("piu"),5000);

所以大家常常说"Promise会吃掉错误"。

如果状态已经冻结,即便运行中发生了错误,Promise也会忽视它。

newPromise((resolve,reject)=>{
resolve("biu");
console.log(x);
}).then(console.log,console.error);

setTimeout(()=>console.log("piu"),5000);

Promise的错误如果没有被及时捕获,它会往下传递,直到被捕获。中间没有捕获代码的then函数就被忽略了。

newPromise((resolve,reject)=>{
console.log(x);
resolve("biu");
}).then(
value=>console.log(value),
).then(
value=>console.log(value),
).then(
value=>console.log(value),
).catch(
error=>console.error(error),
);
Promise.prototype.finally()

所谓finally就是一定会执行的方法。它和then或者catch不一样的地方在于,finally方法的回调函数不接受任何参数。也就是说,它不关心容器的状态,它只是一个兜底的。

newPromise((resolve,reject)=>{
//逻辑
}).then(
value=>{
//逻辑
console.log(value);
},
error=>{
//逻辑
console.error(error);
}
);
newPromise((resolve,reject)=>{
//逻辑
}).finally(
()=>{
//逻辑
}
);

如果有一段逻辑,无论状态是fulfilled还是rejected都要执行,那放在then函数中就要写两遍,而放在finally函数中就只需要写一遍。

另外,别被finally这个名字带偏了,它不一定要定义在最后的。

newPromise((resolve,reject)=>{
resolve("biu");
}).finally(
()=>console.log("piu"),
).then(
value=>console.log(value),
).catch(
error=>console.error(error),
);

finally函数在链条中的哪个位置定义,就会在哪个位置执行。从语义化的角度讲,finally不如叫anyway

Promise.all()

它接受一个由Promise实例组成的数组,然后生成一个新的Promise实例。这个新Promise实例的状态由数组的整体状态决定,只有数组的整体状态都是fulfilled时,新Promise实例的状态才是fulfilled,否则就是rejected。这就是all的含义。

Promise.all([Promise.resolve(1),Promise.resolve(2),Promise.resolve(3)]).then(
values=>console.log(values),
).catch(
error=>console.error(error),
);
Promise.all([Promise.resolve(1),Promise.reject(2),Promise.resolve(3)]).then(
values=>console.log(values),
).catch(
error=>console.error(error),
);

数组中的项目如果不是一个Promise实例,all函数会将它封装成一个Promise实例。

Promise.all([1,2,3]).then(
values=>console.log(values),
).catch(
error=>console.error(error),
);
Promise.race()

它的使用方式和Promise.all()类似,但是效果不一样。

Promise.all()是只有数组中的所有Promise实例的状态都是fulfilled时,它的状态才是fulfilled,否则状态就是rejected

Promise.race()则只要数组中有一个Promise实例的状态是fulfilled,它的状态就会变成fulfilled,否则状态就是rejected

就是&&||的区别是吧。

它们的返回值也不一样。

Promise.all()如果成功会返回一个数组,里面是对应Promise实例的返回值。

Promise.race()如果成功会返回最先成功的那一个Promise实例的返回值。

functionfetchByName(name){
consturl=`https://api.github.com/users/${name}/repos`;
returnfetch(url).then(res=>res.json());
}

consttimingPromise=newPromise((resolve,reject)=>{
setTimeout(()=>reject(newError("网络请求超时")),5000);
});

Promise.race([fetchByName("veedrin"),timingPromise]).then(
values=>console.log(values),
).catch(
error=>console.error(error),
);

上面这个例子可以实现网络超时触发指定操作。

Promise.resolve()

它的作用是接受一个值,返回一个状态是fulfilled的Promise实例。

Promise.resolve("biu");
newPromise(resolve=>resolve("biu"));

它是以上写法的语法糖。

Promise.reject()

它的作用是接受一个值,返回一个状态是rejected的Promise实例。

Promise.reject("biu");
newPromise((resolve,reject)=>reject("biu"));

它是以上写法的语法糖。

嵌套Promise

如果Promise有嵌套,它们的状态又是如何变化的呢?

constpromise=Promise.resolve(
(()=>{
console.log("a");
returnPromise.resolve(
(()=>{
console.log("b");
returnPromise.resolve(
(()=>{
console.log("c");
returnnewPromise(resolve=>{
setTimeout(()=>resolve("biu"),3000);
});
})()
)
})()
);
})()
);

promise.then(console.log);

可以看到,例子中嵌套了四层Promise。别急,我们先回顾一下没有嵌套的情况。

constpromise=Promise.resolve("biu");

promise.then(console.log);

我们都知道,它会在微任务时机执行,肉眼几乎看不到等待。

但是嵌套了四层Promise的例子,因为最里层的Promise需要等待几秒才resolve,所以最外层的Promise返回的实例也要等待几秒才会打印日志。也就是说,只有最里层的Promise状态变成fulfilled,最外层的Promise状态才会变成fulfilled

如果你眼尖的话,你就会发现这个特性就是Koa中间件机制的精髓。

Koa中间件机制也是必须得等最后一个中间件resolve(如果它返回的是一个Promise实例的话)之后,才会执行洋葱圈另外一半的代码。

functioncompose(middleware){
returnfunction(context,next){
letindex=-1;
returndispatch(0);
functiondispatch(i){
if(i<=index)returnPromise.reject(newError("next()calledmultipletimes"));
index=i;
letfn=middleware[i];
if(i===middleware.length)fn=next;
if(!fn)returnPromise.resolve();
try{
returnPromise.resolve(fn(context,functionnext(){
returndispatch(i+1);
}));
}catch(err){
returnPromise.reject(err);
}
}
}
}

状态机

Generator简单讲就是一个状态机。但它和Promise不一样,它可以维持无限个状态,并且提出它的初衷并不是为了解决异步编程的某些问题。

一个线程一次只能做一件任务,并且任务与任务之间不能间断。而Generator开了挂,它可以暂停手头的任务,先干别的,然后在恰当的时机手动切换回来。

这是一种纤程或者协程的概念,相比线程切换更加轻量化的切换方式。

Iterator

在讲Generator之前,我们要先和Iterator遍历器打个照面。

Iterator对象是一个指针对象,它是一种类似于单向链表的数据结构。JavaScript通过Iterator对象来统一数组和类数组的遍历方式。

constarr=[1,2,3];
constiteratorConstructor=arr[Symbol.iterator];
console.log(iteratorConstructor);

//ƒvalues(){[nativecode]}
constobj={a:1,b:2,c:3};
constiteratorConstructor=obj[Symbol.iterator];
console.log(iteratorConstructor);

//undefined
constset=newSet([1,2,3]);
constiteratorConstructor=set[Symbol.iterator];
console.log(iteratorConstructor);

//ƒvalues(){[nativecode]}

我们已经见到了Iterator对象的构造器,它藏在Symbol.iterator下面。接下来我们生成一个Iterator对象来了解它的工作方式吧。

constarr=[1,2,3];
constit=arr[Symbol.iterator]();

console.log(it.next());//{value:1,done:false}
console.log(it.next());//{value:2,done:false}
console.log(it.next());//{value:3,done:false}
console.log(it.next());//{value:undefined,done:true}
console.log(it.next());//{value:undefined,done:true}

既然它是一个指针对象,调用next()的意思就是把指针往后挪一位。挪到最后一位,再往后挪,它就会一直重复我已经到头了,只能给你一个空值

Generator

Generator是一个生成器,它生成的到底是什么呢?

对咯,他生成的就是一个Iterator对象。

function*gen(){
yield1;
yield2;
return3;
}

constit=gen();

console.log(it.next());//{value:1,done:false}
console.log(it.next());//{value:2,done:false}
console.log(it.next());//{value:3,done:false}
console.log(it.next());//{value:undefined,done:true}
console.log(it.next());//{value:undefined,done:true}

Generator有什么意义呢?普通函数的执行会形成一个调用栈,入栈和出栈是一口气完成的。而Generator必须得手动调用next()才能往下执行,相当于把执行的控制权从引擎交给了开发者。

所以Generator解决的是流程控制的问题。

它可以在执行过程暂时中断,先执行别的程序,但是它的执行上下文并没有销毁,仍然可以在需要的时候切换回来,继续往下执行。

最重要的优势在于,它看起来是同步的语法,但是却可以异步执行。

yield

对于一个Generator函数来说,什么时候该暂停呢?就是在碰到yield关键字的时候。

function*gen(){
console.log("a");
yield13*15;
console.log("b");
yield15-13;
console.log("c");
return3;
}

constit=gen();

看上面的例子,第一次调用it.next()的时候,碰到了第一个yield关键字,然后开始计算yield后面表达式的值,然后这个值就成了it.next()返回值中value的值,然后停在这。这一步会打印a,但不会打印b

以此类推。return的值作为最后一个状态传递出去,然后返回值的done属性就变成true,一旦它变成true,之后继续执行的返回值都是没有意义的。

这里面有一个状态传递的过程。yield把它暂停之前获得的状态传递给执行器。

那么有没有可能执行器传递状态给状态机内部呢?

function*gen(){
consta=yield1;
console.log(a);
constb=yield2;
console.log(b);
return3;
}

constit=gen();

当然是可以的。

默认情况下,第二次执行的时候变量a的打印结果是undefined,因为yield关键字就没有返回值。

但是如果给next()传递参数,这个参数就会作为上一个yield的返回值。

it.next("biu");

别急,第一次执行没有所谓的上一个yield,所以这个参数是没有意义的。

it.next("piu");

//打印piu。这个piu是console.log(a)打印出来的。

第二次执行就不同了。a变量接收到了next()传递进去的参数。

这有什么用?如果能在执行过程中给状态机传值,我们就可以改变状态机的执行条件。你可以发现,Generator是可以实现值的双向传递的。

为什么要作为上一个yield的返回值?你想啊,作为上一个yield的返回值,才能改变当前代码的执行条件,这样才有价值不是嘛。这地方有点绕,仔细想一想。

自动执行

好吧,既然引擎把Generator的控制权交给了开发者,那我们就要探索出一种方法,让Generator的遍历器对象可以自动执行。

function*gen(){
yield1;
yield2;
return3;
}

functionrun(gen){
constit=gen();
letstate={done:false};
while(!state.done){
state=it.next();
console.log(state);
}
}

run(gen);

不错,竟然这么简单。

但想想我们是来干什么的,我们是来探讨JavaScript异步的呀。这个简陋的run函数能够执行异步操作吗?

functionfetchByName(name){
consturl=`https://api.github.com/users/${name}/repos`;
fetch(url).then(res=>res.json()).then(res=>console.log(res));
}

function*gen(){
yieldfetchByName("veedrin");
yieldfetchByName("tj");
}

functionrun(gen){
constit=gen();
letstate={done:false};
while(!state.done){
state=it.next();
}
}

run(gen);

事实证明,Generator会把fetchByName当做一个同步函数来执行,没等请求触发回调,它已经将指针指向了下一个yield。我们的目的是让上一个异步任务完成以后才开始下一个异步任务,显然这种方式做不到。

我们已经让Generator自动化了,但是在面对异步任务的时候,交还控制权的时机依然不对。

什么才是正确的时机呢?

在回调中交还控制权

哪个时间点表明某个异步任务已经完成?当然是在回调中咯。

我们来拆解一下思路。

首先我们要把异步任务的其他参数和回调参数拆分开来,因为我们需要多带带在回调中扣一下扳机。

然后yieldasyncTask()的返回值得是一个函数,它接受异步任务的回调作为参数。因为Generator只有yield的返回值是暴露在外面的,方便我们控制。

最后在回调中移动指针。

functionthunkify(fn){
return(...args)=>{
return(done)=>{
args.push(done);
fn(...args);
}
}
}

这就是把异步任务的其他参数和回调参数拆分开来的法宝。是不是很简单?它通过两层闭包将原过程变成三次函数调用,第一次传入原函数,第二次传入回调之前的参数,第三次传入回调,并在最里一层闭包中又把参数整合起来传入原函数。

是的,这就是大名鼎鼎的thunkify

以下是暖男版。

functionthunkify(fn){
return(...args)=>{
return(done)=>{
letcalled=false;
args.push((...innerArgs)=>{
if(called)return;
called=true;
done(...innerArgs);
});
try{
fn(...args);
}catch(err){
done(err);
}
}
}
}

宝刀已经有了,咱们去屠龙吧。

constfs=require("fs");
constthunkify=require("./thunkify");

constreadFileThunk=thunkify(fs.readFile);

function*gen(){
constvalueA=yieldreadFileThunk("/Users/veedrin/a.md");
console.log("a.md的内容是:
",valueA.toString());
constvalueB=yieldreadFileThunk("/Users/veedrin/b.md");
console.log("b.md的内容是:
",valueB.toString());
}


            
                     
             
               

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/6788.html

相关文章

  • 马蹄 | 详解 JavaScript 异步机制发展历程(万字长文)

    摘要:本文从入手,系统的回顾的异步机制及发展历程。需要提醒的是,文本没有讨论的异步机制。本文是专题系列文章之一,后续会有更多专题推出地址持续更新博客地址文章排版真的很漂亮如果觉得对你有帮助,欢迎来点或者来我的博客亲口告诉我本文从Event Loop、Promise、Generator、async await入手,系统的回顾 JavaScript 的异步机制及发展历程。 需要提醒的是,文本没有讨论 ...

    KoreyLee 评论0 收藏0
  • javasscript - 收藏集 - 掘金

    摘要:跨域请求详解从繁至简前端掘金什么是为什么要用是的一种使用模式,可用于解决主流浏览器的跨域数据访问的问题。异步编程入门道典型的面试题前端掘金在界中,开发人员的需求量一直居高不下。 jsonp 跨域请求详解——从繁至简 - 前端 - 掘金什么是jsonp?为什么要用jsonp?JSONP(JSON with Padding)是JSON的一种使用模式,可用于解决主流浏览器的跨域数据访问的问题...

    Rango 评论0 收藏0
  • javascript知识点

    摘要:模块化是随着前端技术的发展,前端代码爆炸式增长后,工程化所采取的必然措施。目前模块化的思想分为和。特别指出,事件不等同于异步,回调也不等同于异步。将会讨论安全的类型检测惰性载入函数冻结对象定时器等话题。 Vue.js 前后端同构方案之准备篇——代码优化 目前 Vue.js 的火爆不亚于当初的 React,本人对写代码有洁癖,代码也是艺术。此篇是准备篇,工欲善其事,必先利其器。我们先在代...

    Karrdy 评论0 收藏0
  • Deep in JS - 收藏集 - 掘金

    摘要:今天同学去面试,做了两道面试题全部做错了,发过来给道典型的面试题前端掘金在界中,开发人员的需求量一直居高不下。 排序算法 -- JavaScript 标准参考教程(alpha) - 前端 - 掘金来自《JavaScript 标准参考教程(alpha)》,by 阮一峰 目录 冒泡排序 简介 算法实现 选择排序 简介 算法实现 ... 图例详解那道 setTimeout 与循环闭包的经典面...

    enali 评论0 收藏0
  • JavasScript重难点知识

    摘要:忍者级别的函数操作对于什么是匿名函数,这里就不做过多介绍了。我们需要知道的是,对于而言,匿名函数是一个很重要且具有逻辑性的特性。通常,匿名函数的使用情况是创建一个供以后使用的函数。 JS 中的递归 递归, 递归基础, 斐波那契数列, 使用递归方式深拷贝, 自定义事件添加 这一次,彻底弄懂 JavaScript 执行机制 本文的目的就是要保证你彻底弄懂javascript的执行机制,如果...

    forsigner 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<