异步编程

305 阅读11分钟

单线程JavaScript异步方案

内容概要

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

单线程的JavaScript

  • JavaScript作为浏览器脚本语言,主要用途是与用户互动,以及操作DOM。因为DOM只有一个,于是从一诞生,JavaScript就是单线程。
  • 因为单线程这一特性,同一个时间只能执行一个任务,有多个任务则需要按顺序一个接一个去执行。
  • 单线程缺点:可能会阻塞代码,导致代码执行效率低下,页面未能及时响应,最终影响用户体验。
  • 解决办法:为了避免这个问题,出现了异步编程。异步的概念则是不保证同步的概念,也就是说,一个异步过程的执行将不再与原有的序列有顺序关系。
  • 简单而言,异步就是从主线程发射一个独立的子线程来完成任务。因为子线程独立于主线程,所以即使出现阻塞也不会影响主线程的运行(像发起网络请求、读取文件等容易发生阻塞的操作,我们应该采用异步编程的方式来实现)。
  • 子线程局限:一旦发射了以后就会与主线程失去同步,我们无法确定它的结束,如果结束之后需要处理一些事情,比如处理来自服务器的信息,我们是无法将它合并到主线程中去的。
  • 解决办法:JavaScript 中的异步操作函数往往通过回调函数来实现异步任务的结果处理。
  • 回调函数:它是在我们启动一个异步任务的时候就告诉它:等你完成了这个任务之后要干什么。这样一来主线程几乎不用关心异步任务的状态了,他自己会善始善终。

同步模式

在这一模式下,代码当中的任务是依次执行的,同一个时间只执行一个任务,有多个任务则需要按照代码编写的顺序一个接一个去执行。看上去是不是很简单明了?是的,这是它的优点,然而缺点就是容易引发代码阻塞。

  • 总结:
    • 优点:简单直观,可读性高,有利于代码的编写;
    • 缺点:容易引发代码阻塞

异步模式

  • 异步的概念则是不保证同步的概念,也就是说,一个异步过程的执行将不再与原有的序列有顺序关系。
  • 简单而言,异步就是从主线程发射一个独立的子线程来完成任务。因为子线程独立于主线程,所以即使出现阻塞也不会影响主线程的运行(像发起网络请求、读取文件等容易发生阻塞的操作,我们应该采用异步编程的方式来实现)
  • 子线程局限:一旦发射了以后就会与主线程失去同步,我们无法确定它的结束,如果结束之后需要处理一些事情,比如处理来自服务器的信息,我们是无法将它合并到主线程中去的。
  • 解决办法:JavaScript 中的异步操作函数往往通过回调函数来实现异步任务的结果处理。
  • 回调函数:它是在我们启动一个异步任务的时候就告诉它:等你完成了这个任务之后要干什么。这样一来主线程几乎不用关心异步任务的状态了,他自己会善始善终。
  • 总结:
    • 优点:异步模式让单线程的JavaScript语言具备了同时处理大量耗时任务的能力
    • 缺点:代码的执行顺序比较灵活,提高了代码编写的难度。

消息队列

同步任务在主线程上排队执行,异步任务不进入主线程,由“任务队列”管理。这个“任务队列”也称为“消息队列”,回调函数就是归“消息队列”管理了。

事件循环

具体来说,异步执行的运行机制如下:

  • (1)所有同步任务都在主线程上执行,形成一个执行栈。
  • (2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  • (3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  • (4)主线程不断重复上面的第三步。 主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为事件循环(Event Loop)。所以, Event Loop是一种运行机制,是我们经常使用JavaScript进行异步编程的基础。

异步编程的实现方式

回调函数

回调函数可以说是所有异步编程方案的根基。 假定有两个函数f1和f2,后者等待前者的执行结果。

  f1();

  f2();

如果f1是一个很耗时的任务,可以考虑改写f1,把f2写成f1的回调函数。

  function f1(callback){
    setTimeout(function () {
      // f1的任务代码
      callback();
    }, 1000);
  }

执行代码就变成下面这样:

  f1(f2);

采用这种方式,我们把同步操作变成了异步操作,f1不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行。

  • 总结:
    • 优点:简单、容易理解和部署
    • 缺点:不利于代码的阅读和维护,各个部分之间高度耦合(Coupling),流程会很混乱

事件监听

另一种思路是采用事件驱动模式。任务的执行不取决于代码的顺序,而取决于某个事件是否发生。

还是以f1和f2为例。首先,为f1绑定一个事件(这里采用的jQuery的写法)。

  f1.on('done', f2);

上面这行代码的意思是,当f1发生done事件,就执行f2。然后,对f1进行改写:

  function f1(){
    setTimeout(function () {
      // f1的任务代码
      f1.trigger('done');
    }, 1000);
  }

f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2。

  • 总结:
    • 优点:比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以"去耦合"(Decoupling),有利于实现模块化
    • 缺点:整个程序都要变成事件驱动型,运行流程会变得很不清晰。

发布/订阅

上一节的"事件",完全可以理解成"信号"。

我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式"(publish-subscribe pattern),又称"观察者模式"(observer pattern)。

这个模式有多种实现,下面采用的是Ben Alman的Tiny Pub/Sub,这是jQuery的一个插件。

首先,f2向"信号中心"jQuery订阅"done"信号。

  jQuery.subscribe("done", f2);

然后,f1进行如下改写:

  function f1(){
    setTimeout(function () {
      // f1的任务代码
      jQuery.publish("done");
    }, 1000);
  }

jQuery.publish("done")的意思是,f1执行完成后,向"信号中心"jQuery发布"done"信号,从而引发f2的执行。

此外,f2完成执行后,也可以取消订阅(unsubscribe)。

  jQuery.unsubscribe("done", f2);

这种方法的性质与"事件监听"类似,但是明显优于后者。因为我们可以通过查看"消息中心",了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

除此之外,还有Promise异步方案Generator异步方案,下面单独分开来写。

Promise异步方案

Promises对象是CommonJS工作组提出的一种规范,目的是为异步编程提供统一接口。 简单说,它的思想是,每一个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数。比如,f1的回调函数f2,可以写成:

  f1().then(f2);

Promise的三种状态:

  • pending:等待的状态 Promise对象实例创建时候的初始状态
  • fulfilled :成功的状态
  • rejected :失败的状态 注意: 状态不可回退
const p1 = new Promise((resolve, reject)=>{
    resolve('success');
});
const p2 = new Promise((resolve, reject)=>{
    reject('fail');
}).catch(error => {
    console.log(error) 	// fail
});
p1.then(res => {
  console.log(res) 
})
// success

resolve方法

将Promise对象的状态从“等待”变为“成功”(即从 pending 变为 fulfilled),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;

reject方法

将Promise对象的状态从“等待”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

then方法

Promise对象的then方法会返回一个全新的Promise对象,所以Promise支持then方法的调用

const p1 = new Promise((resolve, reject)=>{
    resolve('success');
});
p1.then(res => {
	console.log(res);
    return 'hello'
}).then(res => {
	console.log(res);
    return 'world'
}).then(res => {
	console.log(res);
    return 'done'
}).then(res => {
	console.log(res);
})
// success
// hello
// world
// done

catch方法

Promise.prototype.catch(onRejected) 添加一个拒绝(rejection) 回调到当前 promise, 返回一个新的promise。当这个回调函数被调用,新 promise 将以它的返回值来resolve,否则如果当前promise 进入fulfilled状态,则以当前promise的完成结果作为新promise的完成结果.

const p3 = new Promise((resolve, reject)=>{
    reject('fail');
}).catch(error => {
    console.log('catch u,', error) 	// catch u, fail
});

finally方法

添加一个事件处理回调于当前promise对象,并且在原promise对象解析完毕后,返回一个新的promise对象。回调会在当前promise运行完毕后被调用,无论当前promise的状态是完成(fulfilled)还是失败(rejected)

const p4 = new Promise((resolve, reject)=>{
    reject('fail');
}).catch(error => {
    console.log('catch u,', error) 	// catch u, fail
}).finally(res => {
    console.log('finally,', res) 	// finally, undefined
});

宏任务与微任务

  • ES6 规范中,宏任务(macrotask) 称为 task,微任务(microtask) 称为 jobs
  • 宏任务是由宿主发起的,而微任务由JavaScript自身发起。
  • 在ES5之后,JavaScript引入了Promise,这样,不需要浏览器,JavaScript引擎自身也能够发起异步任务了。

总结一下,两者区别为:

宏任务(macrotask)微任务(microtask)
谁发起的宿主(Node、浏览器)JS引擎
具体事件script、setTimeout/setInterval、setImmediate、UI事件Promise、MutaionObserver、process.nextTick
谁先运行后运行先运行

Generator异步方案

generator(生成器)

ES6标准引入的新的数据类型。一个generator看上去像一个函数,但可以返回多次。

  • 特征:
    • function命令与函数名之间有一个*
    • 函数体内部使用yield语句定义不同的内部状态
    • 直接调用 Generator函数并不会执行,也不会返回运行结果,而是返回一个遍历器对象(Iterator Object)
    • 依次调用遍历器对象的next方法,遍历 Generator函数内部的每一个状态
function *show(x, y){
    yield x;
    yield y;
    return x+y;
}
let genObj = show(1, 2);
genObj.next();  // {value: 1, done: false}
genObj.next();  // {value: 2, done: false}
genObj.next();  // {value: 3, done: true }

yield 表达式

由于 Generator 函数返回的遍历器对象,只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield表达式就是暂停标志。

使用Generator函数

我们来使用Generator来进行异步任务的封装

function * gen () {
  var url = "https://api.github.com/users/github";
  var result = yield fetch(url);
  console.log(result.bio);  
}

先读取一个远程接口,然后从json格式的数据解析信息,这段代码非常像同步操作,除了加上yield命令,执行这段代码的方法:

var g = gen();
var result = g.next();
result.value.then( function (data) {
  return data.json();
}).then(function(data){
  g.next(data);
});

首先执行generator函数,获取遍历器对象,然后使用next方法(第二行),执行异步任务的第一阶段,由于fetch模块返回的是一个promise对象,因此要用then方法调用下一个next方法。

Async/Await语法糖

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。

async 函数是什么?一句话,它就是 Generator 函数的语法糖。 它可以搭配着await 一起使用,让异步编程像编写同步代码那样直观

async function getUser () {
  const url = "https://api.github.com/users/github";
  const result= await fetch(url);
  return result
}
const data = getUser()
console.log(data)