JavaScript 异步编程

334 阅读18分钟

内容概述

众所周知目前主流javascript环境都是以单线程的模式去执行的javascript代码。

采用单线程模式工作的原因

采用单线程工作的原因与它最早的涉及初衷有关,最早的javascript语言就是一门运行在浏览器端的脚本语言,目的就是用来实现页面上的动态交互,而实现动态交互的核心就是Dom操作,这也就决定了必须使用单线程模型否则就会出现很复杂的线程同步问题。
假设我们同时有多个线程同时工作,有一个线程修改了某个Dom,另一个线程又删除了某个Dom,那么此时浏览器无法明确该以哪个线程的结果为准,所以为了避免这些问题,从一开始javascript就设计成了单线程模式去工作。

  • 单线程:单线程指的是在js执行环境中负责执行代码的环境只有一个。
  • 优点:更安全更简单
  • 缺点:耗时任务会阻塞执行

为解决耗时任务阻塞执行的问题,javascript将任务的执行模式分为了两种。(同步模式,异步模式)。本文重点说的是在javascript与异步模式先关的内容。主要包括以下几点:

  • 同步模式(Synchronous)与异步模式(Asynchronous)
  • 事件循环与消息队列
  • 异步编程的几种方式
  • Promise 异步方案、宏任务、微任务队列
  • Generator 异步方案 、Async/await 语法糖

同步模式

同步模式指的是我们代码中的任务依次执行,后一个任务就必须要等待前一个任务结束才能开始执行。执行顺序与我们代码的编码顺序完全一致。

  • 同步指的并不是同时执行,而是排队执行

我们可以用代码来演示一下:

console.log("global begin");

function bar() {
  console.log("我是foo");
}

function foo() {
  console.log("我是bar");
  bar();
}
foo();
console.log("global end");

//执行结果
// global begin
// 我是foo
// 我是bar

执行机制:

  • 开始执行JS执行引擎会把整体代码加载进来,然后在调用栈中压入一个匿名的调用(可以理解为把全部的代码放到了一个匿名函数中去执行),接着开始逐行执行

  • 首先第一行遇到console.log('global begin')调用,它就会把console.log('global begin')压入我们的调用栈,控制台打印global begin执行完成之后弹栈,继续执行

  • 接着是两个函数声明,不管函数还是变量的声明都不会产生调用,接着往下执行。

  • 再往下就是foo()函数的调用,和第一行一样,先压栈

  • 在bar函数内部遇到console.log("我是foo")先压栈,控制台打印我是foo,然后弹栈。

  • 接着遇到bar()函数调用,先压栈

  • 在foo函数内部遇到console.log("我是bar"),压栈,控制台打印我是bar,然后弹栈

  • bar函数执行完毕,弹栈

  • 接着bar函数执行完毕,弹栈

  • 再往下遇到console.log("global end"),先压栈,控制台打印global end",然后弹栈

  • 整体代码全部执行完毕,我们代码就会清空掉


如果某一个任务或者某一行执行时间过长,就会出现阻塞,对用户而言就会出现页面卡顿或者卡死,因此就必须要有异步操作来解决不可避免的耗时操作。

异步模式

  • 浏览器端的ajax操作
  • nodeJs中的大文件读写
  • 定时任务 如setTimeout

介绍

  • 异步模式的api不会等待当前任务结束才开始执行下一个任务,对于耗时操作都是开启之后就立即执行下一个任务
  • 耗时任务的后续逻辑一般通过回调函数的方式定义,等异步任务执行结束后就会调用回调函数。
  • 异步模式对JavaScript非常重要,如果没有异步模式单线程的JavaScript语法就无法同时处理大量耗时任务
  • 对于开发人员来说,异步模式下的代码执行顺序是跳跃式的,不会像同步代码那样通俗易懂。

接下来我们看一段代码来分析异步模式是如何执行的:

console.log("global begin");
setTimeout(function timer1() {
  console.log("timer1 invoke");
}, 1800);
setTimeout(function timer2() {
  console.log("timer2 invoke");
  setTimeout(function inner() {
    console.log("inner invoke");
  }, 1000);
}, 1000);
console.log("global end");
  • 最开始也是加载整体代码,然后压入全局匿名调用
  • 然后console.log("global begin")压栈,控制台打印global beginconsole.log("global begin")出栈。
  • 接着压入setTimeout(timer1)压入调用栈,但是这个函数内部是异步调用,我们需要关心内部api做了什么事情,很明显在内部为time1开启了一个倒计时器,然后单独放到一边(这个计时器是单独工作的,并不会受JS单线程的影响)开启过后对于setTimeout来讲调用就完成了所以就会弹栈继续往下执行。
  • 接着又遇到一个setTimeout调用,同理先压栈,然后开启另一个倒计时器,然后弹栈
  • 最后又遇到console.log("global end")先压栈,控制台打印,然后弹栈
  • 打印完最后的global end之后那么对于整体的匿名调用就执行了,那么清空调用栈
  • 这个时候Event Loop就会发挥作用,负责监听调用栈和时间队列(一旦调用栈结束了,就从事件队列取出第一个回调函数,然后引入调用栈)
  • timer2计时器先结束,然后timer2函数放到消息队列的第一位
  • 然后timer1计时器结束,将timer1函数放到消息队列的第二位
  • 一旦消息队列发生变化,事件循环就会监听到并把消息队列的第一个函数(timer2)函数取出来,压入调用栈继续执行timer2(相当于开启了新一轮的的执行),执行过程和之前是一致的,如果遇到异步调用也是相同的情况。
  • 往后就是不断的调用重复,直到调用栈和消息队列当中都没有需要执行的任务了,那整体任务就算结束。


如图,JavaScript线程某个时刻发起异步调用,然后继续往后执行其他任务,同时异步线程会去执行异步任务,异步任务执行完成之后会把异步任务放入消息队列,js线程完成所有任务之后,会依次执行消息队列的任务(注意:JavaScript是单线程的而浏览器并不是单线程的,更具体说通过JavaScript调用的某些内部api并不是单线程的,例如上述这个倒计时器内部就会有一个单独的线程去进行倒数)。

异步模式对单线程的 JavaScript 非常重要,同时也是 JavaScript 的核心特点,也是因为异步模式 API 的关系,写出来的代码不容易读,执行顺序也会复杂很多,因此诞生了很多为异步而生的语法,特别是在es2015过后退出的一系列新语法,新特性,它们慢慢弥补了JavaScript这块的不足。

回调函数

回调函数是实现异步编程的根本方式,其实所有的异步方案的根本都是回调函数,回调函数可以理解为知道要执行什么事情,但是不知道这个事情依赖的任务什么时候完成,所以说最好的办法就是把这些事情的步骤写到一个函数(回调函数)当中,交给任务的执行者,执行者是知道什么时候结束的,等结束之后执行你想做的事情(回调函数)。以ajax为例子,ajax就是希望拿到数据之后去做一些处理,但是请求什么时候完成不知道,所以得把请求响应之后要执行的事情统一放到函数中,ajax执行完就会自动执行这个函数,这些由调用者定义,交给执行者执行的函数就被称之为回调函数,具体实现方法也很简单,就是把函数作为参数传递,只不过这种方式不利于阅读,而且执行顺序也会非常混乱。

Promise

回调函数是JavaScript异步编程方式的根基,但是直接使用传统的回调函数方式去完成复杂的异步流程就会出现大量回调函数嵌套,这就会导致常说的的回调地狱问题,为了避免回调地狱的问题CommonJS社区就提出了Promise规范,目的就是为JavaScript提供一种更合理,更强大的异步编程方案,后来在es2015中被标准化,成为语言规范。
所谓Promise就是一个对象用来去表示一个异步任务最终结束过后最终是成功还是失败,就像是内部对外部做出了一个承诺,最开始承诺是待定状态(pending)最终可能成功(Fulfilled)也可能是失败(rejected),不管是成功还是失败都会有相应的反应onFulFilled、onRejected,在承诺结束之后都会有相应的任务被执行,而且还有一个明显的特点,就是一旦明确了结果就不可以被改变了。
image.png

基本使用

// Promise 基本示例
// Promise 的构造函数需要接受一个函数作为参数,这个函数就可以理解为对象承诺的逻辑
// 这个函数会在构造 Promise 的过程中被同步执行
// 这个函数接受两个参数 resolve/reject,分别将 Promise 的状态改为成功、失败
// 状态是确定的,也就是说只会调用 resolve/reject 之一

const promise = new Promise(function (resolve, reject) {
  // 这里用于“兑现”承诺

  // resolve(100) // 承诺达成

  reject(new Error('promise rejected')) // 承诺失败
})

promise.then(function (value) {
  // 即便没有异步操作,then 方法中传入的回调仍然会被放入队列,等待下一轮执行
  console.log('resolved', value)
}, function (error) {
  console.log('rejected', error)
})

console.log('end')

ajax案例

// Promise 方式的 AJAX

function ajax(url) {
  return new Promise(function (resolve, reject) {
    let xhr = new XMLHttpRequest();
    xhr.open('GET', url)
    xhr.responseType = "json";
    xhr.onload = function () {
      if (this.status === 200) {
        resolve(this.response)
      } else {
        reject(new Error(this.statusText))
      }
    }
    xhr.send()
  });
}

ajax("/api/get.json").then(
  function (response) {
    console.log(response);
  },
  function (error) {
    console.log(error);
  }
);

Promise常见误区

从上述演示代码我们发现Promise本质上也是使用回调函数的方式去定义异步任务结束后所需要执行的任务,只不过这里的回调函数是通过then方法传递进去的,而且Promise将回调分为两种(onFulfilled,onReject)
image.png
这时候我们思考后发现既然还是回调函数那如果我们需要连续串联执行多个异步任务这里仍然会出现回调函数嵌套的问题。

  • 按照传统的思考方式我们会出现这样的情况
//嵌套使用 Promise 是最常见的误区
ajax('/api/urls.json').then(function (urls) {
  ajax(urls.users).then(function (users) {
    ajax(urls.users).then(function (users) {
      ajax(urls.users).then(function (users) {
        ajax(urls.users).then(function (users) {

        })
      })
    })
  })
})
  • 正确的做法是借助于Promise then()方法的链式调用的特点,尽量保证异步任务的扁平化

正如我们前面所说一样,then()方法的作用就是为Promise对象去添加状态明确后的回调函数,其中失败后的回调函数是可以省略的,then方法最大的特点就是内部也会返回一个Promise对象,我们可以尝试一下

var promise = ajax('/api/users.json')
var promise2 = promise.then(
  function onFulfilled (value) {
    console.log('onFulfilled', value)
  },
  function onRejected (error) {
    console.log('onRejected', error)
  }
)

console.log(promise2)
console.log(promise2 === promise) //false

我们发现promise2和promise并不是同一个promise对象,then方法返回的是一个全新的promise对象,目的就是为了实现Promise的链条,例如:

ajax("/api/users.json")
  .then(function (value) {
    console.log(1111);
  }) // => Promise
  .then(function (value) {
    console.log(2222);
  }) // => Promise
  .then(function (value) {
    console.log(3333);
  }) // => Promise
  .then(function (value) {
    console.log(4444);
  });

//1111
// 2222
// 3333
// 4444

我们也可以在then方法内部手动返回一个Promise对象,例如:

ajax('/api/users.json')
  .then(function (value) {
    console.log(1111)
    return ajax('/api/urls.json')
  }) // => Promise
  .then(function (value) {
    console.log(2222)
    console.log(value)
    return ajax('/api/urls.json')
  }) // => Promise
  .then(function (value) {
    console.log(3333)
    return ajax('/api/urls.json')
  }) // => Promise
  .then(function (value) {
    console.log(4444)
    return 'foo'
  }) // => Promise
  .then(function (value) {
    console.log(5555)
    console.log(value)
  })

总结:

  • Promise对象的then方法会返回一个全新的Promise对象
  • 后面的then方法就是在为上一个then返回的Promise注册回调
  • 前面then方法回调函数的返回值会作为后面then方法回调的参数
  • 如果回调当中返回的是Promise,那后面then方法的回调会等待它的结束

Promise 异常处理

image.png
正如前面所说Promise结果一旦失败就会调用在then方法中传入的onRejected回调函数,例如在上述例子我们请求一个根本不存在的地址就会执行onRejected函数,除此之外我们在Promise执行的过程中出现了异常或者手动抛出了一个异常,那么onReject回调也会被执行。看如下代码:


function ajax (url) {
  return new Promise(function (resolve, reject) {
    // foo() 执行过程出现了异常,会执行 onReject回调
     throw new Error() // 尝试手动抛出一个异常 会执行 onReject回调
    var xhr = new XMLHttpRequest()
    xhr.open('GET', url)
    xhr.responseType = 'json'
    xhr.onload = function () {
      if (this.status === 200) {
        resolve(this.response)
      } else {
        reject(new Error(this.statusText))
      }
    }
    xhr.send()
  })
}

ajax('/api/users11.json')
  .then(function onFulfilled (value) {
    console.log('onFulfilled', value)
  })
  .then(undefined, function onRejected (error) {
    console.log('onRejected', error)
  }) 

关于onReject回调的注册其实还有一种更常用的用法,那就是promise实例的catch方法去注册onReject回调,代码如下:

ajax('/api/users11.json')
  .then(function onFulfilled (value) {
    console.log('onFulfilled', value)
  })
  .catch(function onRejected (error) {
    console.log('onRejected', error)
  })

结果同样可以正常捕获到异常,其实cath就是then的另一种体现,如下:

//then(onRejected) 实际上就相当于 then(undefined, onRejected)

ajax('/api/users11.json')
  .then(function onFulfilled (value) {
    console.log('onFulfilled', value)
  })
  .then(undefined, function onRejected (error) {
    console.log('onRejected', error)
  })

从结果来看我们没发现两者的区别,但是仔细对比就会发现有很大差异:

  • 因为每个then方法返回的都是一个全新的promise,我们在后边通过链式调用方式调用的catch实际是给前边then方法返回的Promise 执行失败的回调,并不是给第一个Promise对象指定的,只不过这是因为这是同一个Promise链条,前面Promise会一直被往后传递,所以才能捕获到第一个Promise的异常,而通过then第二个参数指定的回调函数 只是给当前Promise指定的异常

Promise 静态方法

Promise.resolve

//直接返回状态为fulfilled的 Promise对象  foo就会作为这个promise 所返回的值
//也就是说我们在onFulFilled回调中的拿到的参数就是foo
Promise.resolve('foo') 
  .then(function (value) {
    console.log(value)
  })

//上述代码等价于
new Promise(function (resolve,reject){
		resolve('foo')
}).then(function (res){
  console.log(res)//foo
})	

另外如果Promise.resolve接收到的是另一个Promise对象那么这个Promise对象就会原样返回,例如:


var promise = ajax('/api/users.json')
var promise2 = Promise.resolve(promise)
console.log(promise === promise2) //true

还有如果我们传入的是一个对象,而且也拥有和promise同样的then方法,那这个对象也可以作为一个Promise被执行,例如:

Promise.resolve({
 then:function (onFulFilled,onRejected){
    onFulFilled("foo")
 }
}).then(function (value) {
  console.log(value)
})

带有这种then方法的对象可以说是实现了 一个thenable的接口,支持这种对象的原因是因为原生promise没有普及之前都是使用第三方的库实现的Promise,如果说需要把第三方的Promise对象转换成原声的就可以借用这种机制。

promise.reject:

快速创建一个一定失败的Promise对象,无论传入什么参数都作为Promise失败的理由

并行执行

Promise.all

如果我们需要并行执行多个异步任务,比如任务之间没有什么依赖,最好的选择就是同时请求多个接口。代码如下:

ajax('/api/users.json')
 ajax('/api/posts.json')

但是会有问题,那我们怎么去判断所有的请求都已经结束的时机,传统做法是定义一个计数器,没成功一个请求就让它加一下,知道计数器和请求的数量相等时就表示所有任务都结束了,这种办法会非常麻烦还需要考虑出现异常的情况,为此我们可以使用Promise的all方法:

var promise = Promise.all([
  ajax('/api/users.json'),
  ajax('/api/posts.json')
])

promise.then(function (values) {
  console.log(values)
}).catch(function (error) {
  console.log(error)
})

all方法接收一个数组,数组中的每个元素都是一个Promise对象,这个方法会返回一个全新的Promise对象,当内部所有的Promise完成过后,我们返回的Promise才会完成,此时Promise对象拿到的结果就是一个数组,这个数组包含每个Promise的结果(只有全部的Promise执行完成才会触发),如果其中有一个Promise异常那么整个Proimse就会失败(rejected),代码如下:

ajax('/api/urls.json')
  .then(value => {
    const urls = Object.values(value)
    const tasks = urls.map(url => ajax(url))
    return Promise.all(tasks)
  })
  .then(values => {
    console.log(values)
  })

Promise.race

与Promise.all方法不同的是promise.rece跟着所有任务中第一个完成的Promise执行,代码如下:

// Promise.race 实现超时控制

const request = ajax('/api/posts.json')
const timeout = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('timeout')), 500)
})

Promise.race([
  request,
  timeout
])
.then(value => {
  console.log(value)
})
.catch(error => {
  console.log(error)
})

Generator

相比与传统的回调函数的方式,Promise处理异步调用最大的方式就是可以通过链式调用解决回调嵌套的问题。但是这样子仍然会有大量大的回调函数,如
image.png
还是没有办法达到传统同步代码的可读性,传统的同步代码方式,如下:

image.png
下面我们说两种更优的异步编程写法

Generator 生产器函数

  • 当我们调用生成器函数时,不会立即执行,而是得到一个生成器对象;
  • 直到我们调用生成器对象的 next 方法,这个函数才会开始执行;
  • 其次就是可以在函数内部使用 yield 向外返回一个值,另外在返回值中还有一个 done 属性,表示函数是否全部执行完成;
  • yield 不会像传统的 return 那样结束函数的执行,只是暂停函数的执行,直到外界下一次调用 next 方法时继续从 yield 的位置执行,
  • 如果在 next 函数中传入一个参数,这个参数可以在 yield 左侧接收;
  • 如果调用 generator.throw,生成器函数会抛出一个异常,如果不捕获就会直接停止当前执行;
  function * test(){
    console.log('test')
    const req1 = yield 1
    console.log(req1)
    try {
      const req2 = yield 2
      console.log(req2)
    } catch (error) {
      console.log(error)
    }
    yield 3
  }
  // 以下不会立即执行
  const generator  = test()
  console.log(generator.next())
  // test
  // {value: 1, done: false}
  console.log(generator.next('传入A'))
  // 传入A
  // {value: 2, done: false}
  generator.throw(new Error('Generator Error'))
  // Error: Generator Error
  //     at eval (eval at <anonymous> (VM1178:8), <anonymous>:17:17)
  //     at VM1179:18
  // {value: 3, done: false}

使用 Generator 管理异步流程:

  function timeOut (id) {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({ name: "小明" + id, age: id, id: id })
      }, 1000)
    })
  }
  function* main () {
    // 第一个异步函数以及处理
    const res1 = yield timeOut(1)
    console.log(res1)
    // 第二个异步函数以及处理
    const res2 = yield timeOut(12)
    console.log(res2)
    // 第三个异步函数以及处理
    const res3 = yield timeOut(18)
    console.log(res3)
    // 第四个异步函数以及处理
    const res4 = yield timeOut(24)
    console.log(res4)
  }
  const generator = main()
  const res1 = generator.next()
  res1.value.then(data => {
    const res2 = generator.next(data)
    res2.value.then(data => {
      const res3 = generator.next(data)
      res3.value.then(data => {
        generator.next(data)
      })
    })
  })
  // 依次输出:
  // {name: "小明1", age: 1, id: 1}
  // {name: "小明12", age: 12, id: 12}
  // {name: "小明18", age: 18, id: 18}

在上述例子中,我们使用生成器函数把 Promise 的回调函数写在了 yield 后,实现了异步问题的代码简化,对于生成器函数的回调可以使用递归解决:

  const g1 = main()
  function handleResult (result) {
    if (result.done) {
      return
    }
    result.value.then(data => {
      handleResult(g1.next(data))
    }, error=>{
      g
    })
  }
  handleResult(g1.next())
  // 依次输出:
  // {name: "小明1", age: 1, id: 1}
  // {name: "小明12", age: 12, id: 12}
  // {name: "小明18", age: 18, id: 18}
  // {name: "小明24", age: 24, id: 24}

加上异常处理后,我们可以封装一个 exec 函数用于生成器函数的调用:

function timeOut (id) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (id < 24) {
          resolve({ name: "小明" + id, age: id, id: id })
        } else {
          reject(new Error('ID 超过24'))
        }
      }, 1000)
    })
  }
  function* main () {
    try {
      const res1 = yield timeOut(1)
      console.log(res1)
      const res2 = yield timeOut(12)
      console.log(res2)
      const res3 = yield timeOut(18)
      console.log(res3)
      const res4 = yield timeOut(24)
      console.log(res4)
    } catch (error) {
      console.log(error)
    }
  }
  function exec (fun) {
    const generator = fun()
    function handleResult (result) {
      if (result.done) {
        return
      }
      result.value.then(data => {
        handleResult(generator.next(data))
      }, error => {
        generator.throw(error)
      })
    }
    handleResult(generator.next())
  }
  exec(main)
  // {name: "小明1", age: 1, id: 1}
  // {name: "小明12", age: 12, id: 12}
  // {name: "小明18", age: 18, id: 18}
  // Error: ID 超过24
  //     at <anonymous>:7:16

这样的生成器函数执行器在社区中有一个完善的库:github.com/tj/co
这种方案在之前很流行的,但是自从 es 有了 async await 之后就没那么普及了,不过使用 generator 方案的最大优势就是让我们的异步调用回到扁平化,这是 js 异步编程中很重要的一步。

Async Await

有了 Generator 之后,JavaScript 中的异步编程与同步代码就有类似的体验了,但是使用 Generator 这种方案还需要手动编写一个执行器函数,比较麻烦,在 es2017 中新增了一个 async await 同样提供了这种扁平化的异步编程体验,而且它是语言层面标准的语法,使用起来更加方便,其实 async await 就是生成器函数的一种更方便的语法糖,我们只要把生成器函数改成普通函数,使用 async 修饰,yield 修改成 await 就可以了:

  async function main () {
    try {
      const res1 = await timeOut(1)
      console.log(res1)
      const res2 = await timeOut(12)
      console.log(res2)
      const res3 = await timeOut(18)
      console.log(res3)
      const res4 = await timeOut(24)
      console.log(res4)
    } catch (error) {
      console.log(error)
    }
  }
  main()

async 函数的效果也和 Generator 一致,最大的好处就是不需要去配合类似于 co 这样的执行器,因为它是语言层面的标准异步语法,其次就是 async 会返回一个 Promise 对象,方便我们更好地控制代码,还有一个需要注意的是 await 只能出现在函数内部,不能再顶层作用域使用(现在已经在开发了,不久以后可能出现在标准中)。