从js事件执行机制到异步,再从异步到Promise/async

548 阅读18分钟

近期在学习React相关的后端技术,因为之前面试的时候就觉得自己对异步和Promise了解的不够透彻,借此篇文章对此进行一次从头到尾的梳理

JS事件执行机制到异步

1.1 浏览器渲染引擎的进程

要理解JS事件的执行机制,首先得了解JS运行的环境

所以,先来讲一下浏览器内核的主要线程

image.png

1. GUI渲染线程

GUI 渲染线程负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。

当界面需要重绘(Repaint)或由于某种操作引发回流(Reflow)时,该线程就会执行。

2. JavaScript引擎线程

JavaScript 引擎线程负责解析 JavaScript 脚本并运行相关代码。

JavaScript 引擎一直等待着任务队列中任务的到来,然后进行处理,一个Tab页(Renderer 进程)中无论什么时候都只有一个 JavaScript 线程在运行 JavaScript 程序。

GUI 渲染线程与 JavaScript 引擎线程是互斥的,所以如果 JavaScript 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染被阻塞

3. 事件触发线程

当一个事件被触发时该线程会把事件添加到异步任务队列的队尾,等待 JavaScript 引擎的处理。

归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)

这些事件可以是当前执行的代码块如定时任务、也可来自浏览器内核的其他线程如鼠标点击、AJAX 异步请求等,但由于 JavaScript 引擎是单线程的,所有这些事件都得排队等待 JavaScript 引擎处理。

4. 定时器触发线程

日常开发中常用的 setInterval 和 setTimeout 就在该线程中

浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)

5. Http异步请求线程

在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求, 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript 引擎的异步任务队列中等待处理。


1.2 JS是单线程的

为什么JS的事件执行机制,也就是JS代码的执行顺序学起来如此糟心,是因为JS是“单线程”的,所以衍生出奇奇怪怪的代码执行顺序.....

JS的“单线程”特性是既是针对JS引擎线程和GUI线程互斥,也是针对JS引擎本身(个人看法)

怎么理解呢?

  • JS引擎线程和GUI线程互斥

作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。

比如,由上一节可知,GUI渲染线程和JS引擎线程是互斥的,就是为了防止渲染出现不可预知的结果(改这些元素属性同时渲染界面,那么渲染线程前后获得的元素数据就可能不一致了)。

也就是说,当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。 否则,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。 然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。

实际上,这一块并不是我今天要讲的主要内容,这个问题参考 Web Workers API 解决

  • 一个Tab页(renderer进程)中只有一个JS线程在运行JS程序

这个才是影响JS执行顺序的原因

比如当JS引擎执行包含 鼠标点击 的代码块时,会将对应任务添加到事件线程中。 当对应的事件符合触发条件被触发时(我点击了鼠标),该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理(等待JS引擎空闲之时)

也就是说虽然JS可以实现同时执行多个任务的效果,但是本质上还是通过一个JS的主线程来实现的。 “单线程”实际上指的是单个执行栈

image.png


1.3 异步任务和事件循环(异步任务又分为宏任务和微任务)

JS本质是单线程的。也就是说,它并不能像JAVA语言那样,两个线程并发执行。

但我们平时看到的JS,分明是可以同时运作很多任务的,这又是怎么回事呢?

首先,JS的任务,大致分为两类,同步任务和异步任务。

JS引擎的主线程负责执行代码,在往下执行代码的过程中。如果遇到同步任务立马执行;如果遇到异步任务,则将其存到任务队列尾部。

也就是说,同步任务和异步任务的执行时间是不同的。比如一个人浏览一个任务清单,遇到一个同步任务就先把它干完,遇到异步任务就把它写入待做事项,等把同步任务干完了,这个人再去把待做事项的积压的事情按顺序做完

1.jpg

1. 异步任务

异步任务总是通过回调函数来实现的(回调的意思就是“真的不是马上执行,而是回头再调用的”)

在JS中,所谓的异步任务,有三种:

  1. 鼠标键盘事件触发,例如onclick、onkeydown等等

  2. 网络事件触发,例如onload、onerror等等

  3. 定时器,例如setTimeout、setInterval


2. 微任务和宏任务

异步任务分为task(宏任务,也可称为macrotask)和microtask(微任务)两类。

当满足执行条件时,macrotask和microtask会被放入各自的队列中等待放入主线程执行,我们把这两个队列称为Task Queue(也叫Macrotask Queue)和Microtask Queue。

也就是说之前的图片中的异步任务队列实际上指的是两个队列,画图的作者为了读者更好理解,合并成了一个队列

宏任务macrotask有:

  • script中的代码
  • setTimeout
  • setInterval
  • I/O
  • requestAnimationFrame

微任务microtask有:

  • promise
  • Object.observe
  • MutationObserver
  • await后面的代码也要加入到microtask中

image.png


3. 事件循环EventLoop

先注意一下,浏览器中的事件循环和NodeJS中的事件循环不一样,这里指的是浏览器中的事件循环

主线程像是一个永不停歇的人,不停地在完成任务,他会遵循以下完成任务的顺序

  • 执行完主执行线程中的任务。
  • 取出Microtask Queue中任务执行直到清空。
  • 取出Macrotask Queue中一个任务执行。
  • 取出Microtask Queue中任务执行直到清空。
  • 重复3和4。

即为同步完成,一个宏任务,所有微任务,一个宏任务,所有微任务......一直循环下去,这个就是事件循环

用拟人的话说:主线程这个心机boy,他干完同步任务之后,就去异步任务队列中找活干,但是挑的都是轻活(微任务)干,只有干完轻活,他才去干重活(宏任务),每次干完一个重活,都要贪心地去看看有没有轻活干。此时,如果又有轻活来了,他撂挑子不干重活而去干轻活了,直到所有轻活全部干完,他才会不情愿地回去开始干重活,一直循环下去......

  • 举一个例子 jQuery 动画在底层均是使用 setTimeout 和 setInterval 来进行实现

事件循环器会不停的检查事件队列,如果不为空,则取出队首压入执行栈执行。当一个任务执行完毕之后,事件循环器又会继续不停的检查事件队列,不过在这间,浏览器会对页面进行渲染。这就保证了用户在浏览页面的时候不会出现页面阻塞的情况,这也使 JS 动画成为可能

如果我们同步的执行动画,那么我们不会看见任何渐变的效果,浏览器会在任务执行结束之后渲染窗口。反之我们使用异步的方法,浏览器会在每一个任务执行结束之后渲染窗口,这样我们就能看见动画的渐变效果了


1.4 Promise和async中的JS执行顺序

这里主要是写Promise和async在JS执行顺序中的影响。Promise在异步中的应用主要在第二节

1. Promise中的JS执行顺序

写在Promise中的代码是被当做同步任务立即执行的;Promise中的异步体现在then和catch中

new Promise(function (resolve,reject){
    console.log('Promise1') // 同步
    resolve()
}).then(function (){
    console.log('then1') // 异步
})
console.log('out1') // 同步

new Promise(function (resolve, reject){
    console.log('Promise2')
    resolve()
}).then(function (){
    console.log('then2')
})
console.log('out2')

//输出顺序:Promise1、out1、Promise2、out2、then1、then2

2. await中的JS执行顺序

await 等待的是一个表达式,这个表达式的返回值可以是一个promise对象也可以是其他值。

  1. await后面的表达式会先执行一遍
  2. 将await换行后的后面代码加入到microtask中
  3. 然后就会跳出整个async函数来执行后面的代码
// 下面两种方法是等价的
async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}

async function async1(){
    console.log('async1 start')
    Promise.resolve(async2()).then(()=>{
        console.log('async1 end')
    })
}

1.5 JS执行顺序的综合案例

来一个综合案例,把上面所有的知识点给串起来

注释No.x 表示是第几个log输出

async function async1(){
    console.log('async1 start') // No.2
    await async2()
    console.log('async1 end')   // No.6
}

async function async2(){
    console.log('async2')      // No.3
}

console.log('script start')    // No.1

setTimeout(function () {
    console.log('setTimeout')
},0)                           // No.8

async1()

new Promise(function (resolve, reject){
    resolve('Promise2')
    console.log('Promise1')    // No.4
}).then(function (data){
    console.log(data)    // No.7
})

console.log('script end')      // No.5
  • 综合案例分析
  1. 事件循环从宏任务队列开始。 此时,宏任务队列中只有一个script(整体代码)任务
  2. 定义了两个async函数
  3. 遇到同步任务,直接输出‘script start’
  4. 遇到setTimeout,送入宏任务队列,但不执行
  5. 执行async1()函数,因为await前的代码都是同步的,所以立即输出‘async1 start’。 遇到await,将await后边的表达式立即执行,因此输出‘async2’。 并将代码 console.log('async1 end')送入 微任务队列。 然后跳出async1()函数执行后面的代码
  6. 遇到Promise实例,Promise中的函数立即执行resolve(),.then的函数则被送入微任务队列,接着输出‘Promise1’。
  7. 遇到同步任务,直接输出‘script end’
  8. script任务执行完毕后,查找并执行微任务队列。此时,微任务队列有两个任务,所以按顺序输出‘async1 end’,‘promise2’。
  9. 此时,微任务队列已清空,则从宏任务队列寻找任务执行,此时执行setTimeout,输出‘setTimeout’,流程结束

从异步到Promise/await

终于说到了Promise

那时候刚开始学习JS的我,只要一提起Promise就想到为了解决“回调地狱”,但随着学习的深入,我发觉我对它的理解一定是粗浅的,这也是我写下这篇文章的动机。 前面从JS执行机制铺垫到了异步,也是为了引出Promise设计的真正目的

Promise就是异步操作的同步化解决方案

怎么理解?

我们从异步操作的历史说起

2.1 异步callbacks

异步callbacks 其实就是函数,只不过是作为参数传递给那些在后台执行的其他函数。也就是说,当我们把回调函数作为一个参数传递给另一个函数时,仅仅是把回调函数定义作为参数传递过去

回调函数并没有立刻执行,回调函数会在包含它的函数的某个地方异步执行,包含函数负责在合适的时候执行回调函数。

  • 注意:有一些回调函数是同步的,不能一提到回调函数就想是异步的
    //forEach() 需要的参数是一个回调函数,回调函数本身带有两个参数,数组元素和索引值。
    //它无需等待任何事情,立即运行。
    const gods = ['james', 'kobe'];
    gods.forEach(function (eachName, index){
        console.log(index + '. ' + eachName);
    });
    //0. james
    //1. kobe
  • 我们来看一个异步callbacks的例子
function loadAsset(url, type, callback) {
    let xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.responseType = type;
    // xhr.onload 是 XMLHttpRequest 请求成功完成时调用的函数
    xhr.onload = function() {
        callback(xhr.response);
    };
    xhr.send();
}
// displayImage作为异步回调函数
function displayImage(blob) {
    let objectURL = URL.createObjectURL(blob);

    let image = document.createElement('img');
    image.src = objectURL;
    document.body.appendChild(image);
}

loadAsset('coffee.jpg', 'blob', displayImage);

把URL,type,和回调函数同时都作为参数。函数用 XMLHttpRequest 获取给定URL的资源,在获得资源响应后再把响应作为参数传递给回调函数displayImage去处理。

  • callbacks毕竟不是为了异步所单独设计的,难免会出现很多不匹配

如果一个操作的结果作为输入传递给下一个操作,或者说一个操作依赖于另外一个操作的结果,如果使用callbacks就有可能会形成“回调地狱”的情况

const fs = require('fs')
// 依次读取A,B,C文件,顺序不能乱
// 也就是说只有读取A文件成功后才能读取B文件
fs.readFile('./A.txt','utf-8',(err,result1)=>{
    console.log(result1)
    fs.readFile('./B.txt','utf-8',(err,result2)=>{
        console.log(result2)
        fs.readFile('./C.txt','utf-8',(err,result3)=>{
            console.log(result3)
        })
    })
})

此外,回调函数并不一定会按照coder想要的顺序调用。


2.2 Promise

先来看一下Promise的官方定义:表示一个异步操作的最终完成 (或失败)及其结果值

Promise,顾名思义就是“承诺”,这个“承诺”里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

Promise是专门为异步操作而设计的,Promise就是为了让异步操作变得更加容易!

1. Promises 对比 callbacks

promises与callbacks有一些相似之处:它们本质上是一个返回的对象,你可以将回调函数附加到该对象上(then和catch中的函数就是类似于回调函数),而不必将回调作为参数传递给另一个函数。

然而,Promise是专门为异步操作而设计的,与callbacks相比具有许多优点:

  • 可以使用多个then()操作将多个异步操作链接在一起,并将其中一个操作的结果作为输入传递给下一个操作。这种链接方式对回调来说要难得多,会导致“回调地狱”
  • Promise总是严格按照它们放置在事件队列中的顺序调用。
  • 错误处理要好得多——所有的错误都由块末尾的一个.catch()块处理,而不是在“回调地狱”的每一层单独处理。

2. Promise的状态

new一个Promise出来就有结果(成功或失败)吗?并不是这样的。

创建promise时,它既不是成功也不是失败状态。这个状态叫作pending(待定)

当promise返回(大部分情况时一个异步操作结束以后)时,可能有两种状态:fullfilled(成功)和rejected(失败)。

当状态为fullfilled(成功)时。该promise就返回一个值,可以通过将.then()块链接到promise链的末尾来访问该值。 .then()块中的执行程序函数将包含promise的返回值。

一个不成功resolved的promise被称为rejected(失败)了。它返回一个原因(reason),一条错误消息,说明为什么拒绝promise。可以通过将.catch()块链接到promise链的末尾来访问此原因。

通过promise,我们用一种比较不反人类的语法来实现异步操作,这是promise被设计出来的最主要目的之一

fetch('coffee.jpg')
.then(response => {
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  } else {
    return response.blob();// response.blob()也是一个promise,从而可以进行链式操作
  }
})
.then(myBlob => {
  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
})
.catch(e => {
  console.log('There has been a problem with your fetch operation: ' + e.message);
});

通过链式操作,也可以实现异步操作顺序的安全可控,因为Promise总是严格按照它们放置在事件队列中的顺序调用。


2.3 async和await

历史总是在不断前进

promise 两段式的异步操作实际上并不符合我们平常编码的习惯。

ECMAScript 2017 JavaScript 添加了 async 和 await 关键字。它们是基于promises的语法糖,使得异步代码看起来更像是同步代码。

我们分开来看 async 和 await

1. async

将 async 关键字加到函数声明中,可以告诉它们返回的是 promise,而不是直接返回值。因此,它避免了同步函数为支持使用 await 带来的任何潜在开销。

我们用示例来一步一步地解析async关键字

  1. 普通函数声明前加async关键字 => 变成异步函数,默认返回promise对象

异步函数有一个很重要的特性:return关键字代替了promise中的resolve方法(log输出的promise状态时fulfilled),且return的结果会被包裹在一个状态已经变成fulfilled的promise对象中返回

async function fn(){
    return 'result'
}
console.log(fn())

image.png


  1. 调用异步函数,再调用then方法可以获取异步函数的执行结果
// async 版本
async function fn(){
    return '结果'
}
fn().then((arg)=>{
    console.log(arg)
})
//输出‘结果’
// promise 版本
let p1 = new Promise(function(resolve,reject){
    resolve('结果')
})
p1.then((data)=>{
    console.log(data)
})
// 输出‘结果’

  1. 还有一个很重要的特性:throw关键字代替了promise中的reject方法(log输出的promise状态时rejected),且throw出的错误会被包裹在一个状态已经变成rejected的promise对象中返回
async function fn(){
    throw '错误'
    return '结果' // reject方法携带'错误'参数先执行,因此这一段没执行
}
console.log(fn())

image.png

上图为什么报了一个错呢?因为我们调用异步函数的时候没有用catch方法获取异步函数执行的错误信息。

async function fn(){
    throw '错误'
    return 'result'
}

fn().then((arg)=>{
    console.log(arg) // 返回的promise对象的状态是‘rejected’,所以不会执行这部分
}).catch((err)=>{
    console.log(err)
})
// 输出‘错误’

我们再来总结一下关键字 async

  • 普通函数加上async,变异步函数,默认返回promise对象
  • async 异步函数内部使用 return 返回一个被包裹在promise对象中的结果
  • async 异步函数内部使用 throw 抛出程序异常
  • async 异步函数再调用 then 获取异步函数执行结果,catch 获取异步函数错误信息

  • 那么async的语法糖作用到底体现在哪里呢?

同步函数前加async关键字,就像把这个同步函数用一个Promise包裹起来。 并把resolve方法换成了return关键字,把reject方法换成了throw关键字

下面两种写法是等价的

new Promise(function fn(resolve, reject) {
    reject('错误')
    resolve('结果')
})
async function fn() {
    throw '错误'
    return '结果'
}

读到这里,我好像并没有看到什么比promise语法比较好的地方啊!!!

别急,我们再来看await关键字


2. await

当 await 关键字与异步函数一起使用时,它的真正优势就变得明显了(配合async使用,不过你非要new promise我也没办法哈哈哈)

await 只在异步函数里面才起作用。它可以放在任何异步的,基于 promise 的函数之前。它会暂停代码在该行上,直到 promise 完成,然后返回结果值。在暂停的同时,其他正在等待执行的代码就有机会执行了。

我们再倒回头看前文中提到的含有await关键字的JS执行顺序

  1. await后面的表达式会先执行一遍
  2. 将await换行后的后面代码加入到microtask中
  3. 然后就会跳出整个async函数来执行后面的代码

首先,await后边的表达式返回的只能是promise对象,也就是说await后面的表达式是正常执行的。

然后,就暂停了!不会再这个异步函数中继续往下执行下去,而是跳出这个函数继续往下执行。直到promise完成,变成fulfilled状态或者rejected状态返回结果值或者错误值,然后再执行该异步函数中剩下的未执行的代码(其实这里的说法是不严谨的,剩下的未执行的代码会放在microtask queue中等待JS主线程执行)

await关键字就是像它的字面意思一样,等待一个promise给它成功的结果,只有等到成功的结果后,才会继续从下一行开始执行! 这样做就可以改造到处都是 .then() 的代码块,使其看起来像一个同步的代码

此时,我们可以结合async和await关键字来对promise代码进行改造,下面是MDN的例子

// Promise 版本
fetch('coffee.jpg')
.then(response => response.blob())
.then(myBlob => {
  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
})
.catch(e => {
  console.log('There has been a problem with your fetch operation: ' + e.message);
});
// async await版本
async function myFetch() {
  let response = await fetch('coffee.jpg');
  let myBlob = await response.blob();

  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
}

myFetch()
.catch(e => {
  console.log('There has been a problem with your fetch operation: ' + e.message);
});

let response = await fetch('coffee.jpg');

解析器会在此行上暂停,直到当服务器返回的响应变得可用时。此时 fetch() 返回的 promise 将会完成(fullfilled),返回的 response 会被赋值给 response 变量。一旦服务器返回的响应可用,解析器就会移动到下一行,从而创建一个Blob......