阅读 4123

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

本文从Event LoopPromiseGeneratorasync await入手,系统的回顾 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。每一个页面都是一个独立的进程。这就是外界盛传的进程模型。

  • Single Process。传统浏览器的单进程模型。

浏览器内核

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

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

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

Renderer进程的主要线程

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

  • GUI渲染线程。

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

  • 事件触发线程。

  • 定时器线程。

  • 异步HTTP请求线程。

调用栈

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

const str = 'biu';

console.log('1');

function a() {
    console.log('2');
    b();
    console.log('3');
}

function b() {
    console.log('4');
}

a();
复制代码

我们都知道打印的顺序是1 2 4 3

问题在于,当执行到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()

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

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

调用栈也要占用内存,所以如果调用栈过深,浏览器会报Uncaught RangeError: Maximum call stack size exceeded错误。

webAPI

现在我们进入主题。

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

我们重点讨论三种webAPI。

const url = 'https://api.github.com/users/veedrin/repos';
fetch(url).then(res => res.json()).then(console.log);
复制代码
const url = 'https://api.github.com/users/veedrin/repos';
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = () => {
    if (xhr.status === 200) {
        console.log(xhr.response);
    }
}
xhr.send();
复制代码

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

const timeoutId = setTimeout(() => {
    console.log(Date.now());
    clearTimeout(timeoutId);
}, 5000);
复制代码
const intervalId = setInterval(() => {
    console.log(Date.now());
}, 1000);
复制代码
const immediateId = 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);
const timeoutId = setTimeout(() => {
    for (let i = 0; i < 10000; i++) {
        console.log('biu');
    }
    clearTimeout(timeoutId);
}, 5000);
复制代码

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

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

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

任务队列

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

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

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

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

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

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

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

事件循环

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

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

我们总结一下这个过程:

  • 第一阶段,JavaScript引擎线程从头到尾把脚本代码执行一遍,碰到需要其他线程处理的代码则交给其他线程处理。
  • 第二阶段,JavaScript引擎线程专注于处理事件。它会不断的去轮询任务队列,执行任务队列中的事件。这个过程又可以分解为轮询任务队列-执行任务队列中的事件-更新页面视图的无限往复。对,别忘了更新页面视图(如果需要的话),虽然更新页面视图是GUI渲染线程 处理的。

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

宏任务

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

此言差矣。

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

A window event loop is the event loop used by similar-origin window agents. User agents may share an event loop across similar-origin window agents.

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

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

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

微任务

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

<div id="outer">
    <div id="inner">请点击</div>
</div>
复制代码
const $outer = document.getElementById('outer');
const $inner = document.getElementById('inner');

new MutationObserver(() => {
    console.log('mutate');
}).observe($inner, {
    childList: true,
});

function onClick() {
    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');

function onClick() {
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 1');
        $btn.style.color = '#f00';
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 2');
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 3');
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 4');
        // alert(1);
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 5');
        // alert(1);
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 6');
    }, 1000);
    new MutationObserver(() => {
        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。并且一旦状态改变,状态就会被冻结,无法再次改变。
new Promise((resolve, reject) => {
    reject('reject');
    setTimeout(() => resolve('resolve'), 5000);
}).then(console.log, console.error);

// 不要等了,它只会打印一个 reject
复制代码
  • 如果状态发生改变,任何时候都可以获得最终的状态,即便改变发生在前。这与事件监听完全不一样,事件监听只能监听之后发生的事件。
const promise = new Promise(resolve => resolve('biu'));
promise.then(console.log);
setTimeout(() => promise.then(console.log), 5000);

// 打印 biu,相隔大约 5 秒钟后又打印 biu
复制代码

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

同步代码与异步代码

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

console.log('kiu');

new Promise((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只是一个异步容器而已,换一个容器也不会有什么影响。

const promiseA = new Promise((resolve, reject) => resolve('biu'));

const promiseB = promiseA.then(value => {
    console.log(value);
    return value;
});

const promiseC = promiseB.then(console.log);
复制代码

结果是打印了两个 biu。

const promiseA = new Promise((resolve, reject) => resolve('biu'));

const promiseB = promiseA.then(value => {
    console.log(value);
    return Promise.resolve(value);
});

const promiseC = promiseB.then(console.log);
复制代码

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

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

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

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

const promiseA = new Promise((resolve, reject) => resolve('biu'));

const promiseB = promiseA.then(value => {
    console.log(value);
    return x;
});

const promiseC = promiseB.then(console.log, console.error);
复制代码

查看这里的输出结果。

biu
ReferenceError: x is not defined
    at <anonymous>:6:5
复制代码

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

Promise.prototype.catch()

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

new Promise((resolve, reject) => {
    reject('biu');
}).then(
    undefined,
    error => console.error(error),
);
复制代码
new Promise((resolve, reject) => {
    reject('biu');
}).catch(
    error => console.error(error),
);
复制代码

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

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

new Promise((resolve, reject) => {
    console.log(x);
    console.log('kiu');
    resolve('biu');
}).then(console.log, console.error);

setTimeout(() => console.log('piu'), 5000);
复制代码

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

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

new Promise((resolve, reject) => {
    resolve('biu');
    console.log(x);
}).then(console.log, console.error);

setTimeout(() => console.log('piu'), 5000);
复制代码

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

new Promise((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方法的回调函数不接受任何参数。也就是说,它不关心容器的状态,它只是一个兜底的。

new Promise((resolve, reject) => {
    // 逻辑
}).then(
    value => {
        // 逻辑
        console.log(value);
    },
    error => {
        // 逻辑
        console.error(error);
    }
);
复制代码
new Promise((resolve, reject) => {
    // 逻辑
}).finally(
    () => {
        // 逻辑
    }
);
复制代码

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

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

new Promise((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实例的返回值。

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

const timingPromise = new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('网络请求超时')), 5000);
});

Promise.race([fetchByName('veedrin'), timingPromise]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
复制代码

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

Promise.resolve()

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

Promise.resolve('biu');
复制代码
new Promise(resolve => resolve('biu'));
复制代码

它是以上写法的语法糖。

Promise.reject()

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

Promise.reject('biu');
复制代码
new Promise((resolve, reject) => reject('biu'));
复制代码

它是以上写法的语法糖。

嵌套Promise

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

const promise = Promise.resolve(
    (() => {
        console.log('a');
        return Promise.resolve(
            (() => {
                console.log('b');
                return Promise.resolve(
                    (() => {
                        console.log('c');
                        return new Promise(resolve => {
                            setTimeout(() => resolve('biu'), 3000);
                        });
                    })()
                )
            })()
        );
    })()
);

promise.then(console.log);
复制代码

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

const promise = Promise.resolve('biu');

promise.then(console.log);
复制代码

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

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

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

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

function compose(middleware) {
    return function(context, next) {
        let index = -1;
        return dispatch(0);
        function dispatch(i) {
            if (i <= index) return Promise.reject(new Error('next() called multiple times'));
            index = i;
            let fn = middleware[i];
            if (i === middleware.length) fn = next;
            if (!fn) return Promise.resolve();
            try {
                return Promise.resolve(fn(context, function next() {
                    return dispatch(i + 1);
                }));
            } catch (err) {
                return Promise.reject(err);
            }
        }
    }
}
复制代码

🌖🌗🌘 状态机 🌒🌓🌔

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

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

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

Iterator

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

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

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

// ƒ values() { [native code] }
复制代码
const obj = { a: 1, b: 2, c: 3 };
const iteratorConstructor = obj[Symbol.iterator];
console.log(iteratorConstructor);

// undefined
复制代码
const set = new Set([1, 2, 3]);
const iteratorConstructor = set[Symbol.iterator];
console.log(iteratorConstructor);

// ƒ values() { [native code] }
复制代码

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

const arr = [1, 2, 3];
const it = 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() {
    yield 1;
    yield 2;
    return 3;
}

const it = 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');
    yield 13 * 15;
    console.log('b');
    yield 15 - 13;
    console.log('c');
    return 3;
}

const it = gen();
复制代码

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

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

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

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

function *gen() {
    const a = yield 1;
    console.log(a);
    const b = yield 2;
    console.log(b);
    return 3;
}

const it = 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() {
    yield 1;
    yield 2;
    return 3;
}

function run(gen) {
    const it = gen();
    let state = { done: false };
    while (!state.done) {
        state = it.next();
        console.log(state);
    }
}

run(gen);
复制代码

不错,竟然这么简单。

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

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

function *gen() {
    yield fetchByName('veedrin');
    yield fetchByName('tj');
}

function run(gen) {
    const it = gen();
    let state = { done: false };
    while (!state.done) {
        state = it.next();
    }
}

run(gen);
复制代码

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

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

什么才是正确的时机呢?

在回调中交还控制权

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

我们来拆解一下思路。

  • 首先我们要把异步任务的其他参数和回调参数拆分开来,因为我们需要单独在回调中扣一下扳机。
  • 然后yield asyncTask()的返回值得是一个函数,它接受异步任务的回调作为参数。因为Generator只有yield的返回值是暴露在外面的,方便我们控制。
  • 最后在回调中移动指针。
function thunkify(fn) {
    return (...args) => {
        return (done) => {
            args.push(done);
            fn(...args);
        }
    }
}
复制代码

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

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

以下是暖男版。

function thunkify(fn) {
    return (...args) => {
        return (done) => {
            let called = false;
            args.push((...innerArgs) => {
                if (called) return;
                called = true;
                done(...innerArgs);
            });
            try {
                fn(...args);
            } catch (err) {
                done(err);
            }
        }
    }
}
复制代码

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

const fs = require('fs');
const thunkify = require('./thunkify');

const readFileThunk = thunkify(fs.readFile);

function *gen() {
    const valueA = yield readFileThunk('/Users/veedrin/a.md');
    console.log('a.md 的内容是:\n', valueA.toString());
    const valueB = yield readFileThunk('/Users/veedrin/b.md');
    console.log('b.md 的内容是:\n', valueB.toString());
}

function run(gen) {
    const it = gen();
    const state1 = it.next();
    state1.value((err, data) => {
        if (err) throw err;
        const state2 = it.next(data);
        state2.value((err, data) => {
            if (err) throw err;
            it.next(data);
        });
    });
}

run(gen);
复制代码

卧槽,老夫宝刀都提起来了,你让我切豆腐?

这他妈不就是把回调嵌套提到外面来了么!我为啥还要用Generator,感觉默认的回调嵌套挺好的呀,有一种黑洞般的简洁和性感...

别急,这只是Thunk解决方案的PPT版本,接下来咱们真的要造车并开车了哟,此处@贾跃亭。

const fs = require('fs');
const thunkify = require('./thunkify');

const readFileThunk = thunkify(fs.readFile);

function *gen() {
    const valueA = yield readFileThunk('/Users/veedrin/a.md');
    console.log('a.md 的内容是:\n', valueA.toString());
    const valueB = yield readFileThunk('/Users/veedrin/b.md');
    console.log('b.md 的内容是:\n', valueB.toString());
}

function run(gen) {
    const it = gen();
    function next(err, data) {
        const state = it.next(data);
        if (state.done) return;
        state.value(next);
    }
    next();
}

run(gen);
复制代码

我们完全可以把回调函数抽象出来,每移动一次指针就递归一次,然后在回调函数内部加一个停止递归的逻辑,一个通用版的run函数就写好啦。上例中的next()其实就是callback()呢。

在Promise中交还控制权

处理异步操作除了回调之外,我们还有异步容器Promise。

和在回调中交还控制权差不多,于Promise中,我们在then函数的函数参数中扣动扳机。

我们来看看威震海内的co

function co(gen) {
    const it = gen();
    const state = it.next();
    function next(state) {
        if (state.done) return;
        state.value.then(res => {
            const state = it.next(res);
            next(state);
        });
    }
    next(state);
}
复制代码

其实也不复杂,就是在then函数的回调中(其实也是回调啦)移动Generator的指针,然后递归调用,继续移动指针。当然,需要有一个停止递归的逻辑。

以下是暖男版。

function isObject(value) {
    return Object === value.constructor;
}

function isGenerator(obj) {
    return typeof obj.next === 'function' && typeof obj.throw === 'function';
}

function isGeneratorFunction(obj) {
    const constructor = obj.constructor;
    if (!constructor) return false;
    if (constructor.name === GeneratorFunction || constructor.displayName === 'GeneratorFunction') return true;
    return isGenerator(constructor.prototype);
}

function isPromise(obj) {
    return typeof obj.then === 'function';
}

function toPromise(obj) {
    if (!obj) return obj;
    if (isPromise(obj)) return obj;
    if (isGenerator(obj) || isGeneratorFunction(obj)) {
        return co.call(this, obj);
    }
    if (typeof obj === 'function') {
        return thunkToPromise.call(this, obj);
    }
    if (Array.isArray(obj)) {
        return arrayToPromise.call(this, obj);
    }
    if (isObject(obj)) {
        return objectToPromise.call(this, obj);
    }
    return obj;
}

function typeError(value) {
    return new TypeError(`You may only yield a function, promise, generator, array, or object, but the following object was passed: "${String(value)}"`);
}

function co(gen) {
    const ctx = this;
    return new Promise((resolve, reject) => {
        let it;
        if (typeof gen === 'function') {
            it = gen.call(ctx);
        }
        if (!it || typeof it.next !== 'function') {
            return resolve(it);
        }
        onFulfilled();
        function onFulfilled(res) {
            let ret;
            try {
                ret = it.next(res);
            } catch (err) {
                return reject(err);
            }
            next(ret);
        }

        function onRejected(res) {
            let ret;
            try {
                ret = it.throw(res);
            } catch (err) {
                return reject(err);
            }
            next(ret);
        }
        function next(ret) {
            if (ret.done) {
                return resolve(ret.value);
            }
            const value = toPromise.call(ctx, ret.value);
            if (value && isPromise(value)) {
                return value.then(onFulfilled, onRejected);
            }
            return onRejected(typeError(ret.value));
        }
    });
}
复制代码

co是一个真正的异步解决方案,因为它暴露的接口足够简单。

import co from './co';

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

function *gen() {
    const value1 = yield fetchByName('veedrin');
    console.log(value1);
    const value2 = yield fetchByName('tj');
    console.log(value2);
}

co(gen);
复制代码

直接把Generator函数传入co函数即可,太优雅了。

🌖🌗🌘 也许是终极异步解决方案 🌒🌓🌔

上一章我们了解了co与Generator结合的异步编程解决方案。

我知道你想说什么,写一个异步调用还得引入一个npm包(虽然是大神TJ写的包)。

妈卖批的npm!

当然是不存在的。如果一个特性足够重要,社区的呼声足够高,它就一定会被纳入标准的。马上我们要介绍的就是血统纯正的异步编程家族终极继承人——爱新觉罗·async。

import co from 'co';

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

co(function *gen() {
    const value1 = yield fetchByName('veedrin');
    console.log(value1);
    const value2 = yield fetchByName('tj');
    console.log(value2);
});
复制代码
function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

async function fetchData() {
    const value1 = await fetchByName('veedrin');
    console.log(value1);
    const value2 = await fetchByName('tj');
    console.log(value2);
}

fetchData();
复制代码

看看这无缝升级的体验,啧啧。

灵活

别被新的关键字吓到了,它其实非常灵活。

async function noop() {
    console.log('Easy, nothing happened.');
}
复制代码

这家伙能执行吗?当然能,老伙计还是你的老伙计。

async function noop() {
    const msg = await 'Easy, nothing happened.';
    console.log(msg);
}
复制代码

同样别慌,还是预期的表现。

只有当await关键字后面是一个Promise的时候,它才会显现它异步控制的威力,其余时候人畜无害。

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

async function fetchData() {
    const name = await 'veedrin';
    const repos = await fetchByName(name);
    console.log(repos);
}
复制代码

虽然说await关键字后面跟Promise或者非Promise都可以处理,但对它们的处理方式是不一样的。非Promise表达式直接返回它的值就是了,而Promise表达式则会等待它的状态从pending变为fulfilled,然后返回resolve的参数。它隐式的做了一下处理。

注意看,fetchByName('veedrin')按道理返回的是一个Promise实例,但是我们得到的repos值却是一个数组,这里就是await关键字隐式处理的地方。

另外需要注意什么呢?await关键字只能定义在async函数里面。

const then = Date.now();

function sleep(duration) {
    return new Promise((resolve, reject) => {
        const id = setTimeout(() => {
            resolve(Date.now() - then);
            clearTimeout(id);
        }, duration * 1000);
    });
}

async function work() {
    [1, 2, 3].forEach(v => {
        const rest = await sleep(3);
        console.log(rest);
        return '睡醒了';
    });
}

work();

// Uncaught SyntaxError: await is only valid in async function
复制代码

行吧,那我们把它弄到一个作用域里去。

import sleep from './sleep';

function work() {
    [1, 2, 3].forEach(async v => {
        const rest = await sleep(3);
        console.log(rest);
    });
    return '睡醒了';
}

work();
复制代码

不好意思,return '睡醒了'没等异步操作完就执行了,这应该也不是你要的效果吧。

所以这种情况,只能用for循环来代替,async和await就能长相厮守了。

import sleep from './sleep';

async function work() {
    const things = [1, 2, 3];
    for (let thing of things) {
        const rest = await sleep(3);
        console.log(rest);
    }
    return '睡醒了';
}

work();
复制代码

返回Promise实例

有人说async是Generator的语法糖。

naive,朋友们。

async可不止一颗糖哦。它是Generator、co、Promise三者的封装。如果说Generator只是一个状态机的话,那async天生就是为异步而生的。

import sleep from './sleep';

async function work() {
    const needRest = await sleep(6);
    const anotherRest = await sleep(3);
    console.log(needRest);
    console.log(anotherRest);
    return '睡醒了';
}

work().then(res => console.log('🙂', res), res => console.error('😡', res));
复制代码

因为async函数返回一个Promise实例,那它本身return的值跑哪去了呢?它成了返回的Promise实例resolve时传递的参数。也就是说return '睡醒了'在内部会转成resolve('睡醒了')

我可以保证,返回的是一个真正的Promise实例,所以其他特性向Promise看齐就好了。

并发

也许你发现了,上一节的例子大概要等9秒多才能最终结束执行。可是两个sleep之间并没有依赖关系,你跟我说说我凭什么要等9秒多?

之前跟老子说要异步流程控制是不是!现在又跟老子说要并发是不是!

我…满足你。

import sleep from './sleep';

async function work() {
    const needRest = await Promise.all([sleep(6), sleep(3)]);
    console.log(needRest);
    return '睡醒了';
}

work().then(res => console.log('🙂', res), res => console.error('😡', res));
复制代码
import sleep from './sleep';

async function work() {
    const onePromise = sleep(6);
    const anotherPromise = sleep(3);
    const needRest = await onePromise;
    const anotherRest = await anotherPromise;
    console.log(needRest);
    console.log(anotherRest);
    return '睡醒了';
}

work().then(res => console.log('🙂', res), res => console.error('😡', res));
复制代码

办法也是有的,还不止一种。手段都差不多,就是把await往后挪,这样既能搂的住,又能实现并发。

大总结

关于异步的知识大体上可以分成两大块:异步机制与异步编程。

异步机制的精髓就是事件循环。

通过控制权反转(从事件通知主线程,到主线程去轮询事件),完美的解决了一个线程忙不过来的问题。

异步编程经历了从回调Promiseasync的伟大探索。异步编程的本质就是用尽可能接近同步的语法去处理异步机制。

async目前来看是一种比较完美的同步化异步编程的解决方案。

但其实async是深度集成Promise的,可以说Promiseasync的底层依赖。不仅如此,很多API,诸如fetch也是将Promise作为底层依赖的。

所以说一千道一万,异步编程的底色是Promise

Promise是通过什么方式来异步编程的呢?通过then函数,then函数又是通过回调来解决的。

所以呀,回调才是刻在异步编程基因里的东西。你大爷还是你大爷!

回调换一种说法也叫事件。

这下你理解了为什么说JavaScript是事件驱动的吧?

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

GitHub地址(持续更新):horseshoe

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

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

关注下面的标签,发现更多相似文章
评论