JavaScript异步

 JavaScript异步类型

  • 由宿主环境提供的API:setTimeout、setInterval、setImmediat、监听 new Image 加载状态、监听 script 加载状态、监听 iframe 加载状态、Message、Web Worker
  • EcmaScript: Promise

需要说明的是,在 ES6 之前,JavaScript 语言本身没有异步,延迟类型、监听类型的异步都是由宿主提供的,并非语言的核心部分。

前言

在了解异步之前,先问自己两个问题:

  1. 为什么JS是单线程的?
  2. 不是有了Web Worker吗?为什么还说JS是单线程的?

第一个问题的答案是:为了防止DOM操作冲突,进而引起UI的冲突

关于第二个问题,Web Worker 确实是由主线程发起的子线程,可异步执行,但有一些限制,最重要的是:

  • 不能操作DOM
  • 只能与主线程通信,受主线程控制

因此我们可以发现:

  • 所谓多线程时被阉割过的,但能够在一定程度上提升程序的执行效率
  • 与主线程的通信,onMessage 事件仍然是使用单线程的异步机制来运行

结论:并不改变单线程的本质。

JavaScript执行引擎如何实现单线程的异步

事件循环

渲染进程的主线程要处理非常多的事情,包含了各种内部消息类型:

  • 用户输入事件(鼠标、键盘等)
  • 微任务
  • 文件I/O
  • 定时器
  • websocket
  • 此外还有与页面相关的事件,如JS执行、解析DOM、样式计算、布局等等

要让这么多不同类型的任务有条不紊地运行,就需要一个统筹调度系统来管理这些任务,它就是任务队列和事件循环系统。

简单来说,其它线程或主体想要发起任务,将任务扔到任务队列中即可。主线程会依次读取并执行。另外,渲染进程中专门有一个IO线程来接收其他进程传来的消息,负责将其包装成任务扔到队列中去。

排版引擎、JS 执行引擎和渲染进程主线程之间的关系:

以 Chrome 为例,排版引擎 Blink 和 JavaScript 引擎 V8 都工作在渲染引擎的主线程上,并且互斥,同时只能执行一个。并且 V8 除了在主线程上执行 JS 代码,还要执行垃圾回收,当执行垃圾回收时停止主线程上其他所有任务。

消息队列存在的问题和改进

存在的问题:

  1. 如何处理优先级更高的任务?(兼顾执行效率和实时性)
  2. 如何解决单个任务执行时间过久的问题?

解释:

第一个问题:假如现在队列里已经有一些任务在排队了,这时用户触发了一个点击事件,需要立马响应,这是一个优先级高的任务,该怎么办呢?直接插入?如果不断地有优先级高的任务插入,整个队列的执行效率就会大打折扣。如果排到队尾,实时性又收到了影响。

解决:

第一个问题通过引入微任务队列来解决

第二个问题通过回调来规避

微任务

为什么需要微任务?

宏任务无法胜任对时间精度要求高的任务(无法在实时性和效率之间做一个有效的平衡)

宏任务可以满足我们大多数日常需求,不过对时间精度要求较高的需求就难以胜任了。是因为:添加消息队列这一操作是有浏览器操作的,JavaScript代码无法操作任务在队列中的位置,所以很难控制任务开始执行的时间。目前来看,支持微任务的技术有:Promise、MutationObserver

微任务是什么呢?我们可以理解它是一个需要执行的异步函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

在JS引擎层面,JavaScript开始执行一段脚本的时候,会先创建全局执行上下文,同时在内部创建一个微任务队列。在每个宏任务执行过程中,都有可能产生多个微任务,他们都会进入微任务队列。和宏任务相同的,微任务队列是给V8引擎内部使用的,无法通过JavaScript访问。

在这里需要说明一下宏任务和微任务的归属问题。网上的很多资料中都说,微任务和宏任务是两个独立的队列,又有人说每个宏任务内维护了一个微任务队列。我认为都对,只需要理解清楚微任务队列和宏任务队列是如何配合协作即可。这当中有两个重要的时间点:微任务何时产生 和 微任务队列的执行时机。

先来看看微任务如何产生,目前一般有两种方式:

  • 使用 MutationObserver 监控 DOM 节点,当 DOM 节点发生变化时,会产生一个微任务
  • 使用 Promise,当调用 Promise.reslove() 或 Promise.reject() 时,会产生微任务

微任务队列的执行时机

当前宏任务将要执行完时,JavaScript 引擎在清空调用栈前,会检查微任务队列,然后按照顺序执行。如果在执行过程中产生了新的微任务,也会被添加到当前微任务队列中,直到队列为空才算执行结束。

注意点:微任务的执行时长会影响当前宏任务的时长,所以在写代码的时候要注意。

延迟执行队列

对于某些定时任务,如果在普通队列总按照顺序去执行,是无法保证在指定时间执行的。因此,增加了一个延迟执行队列。延迟执行队列维护了定时器和 Chromium 内部一些需要延迟执行的任务。

延迟队列中的任务何时被执行呢? 普通宏任务队列 和 延迟队列 交替执行。

setTimeout 的一些注意事项:

  • 时效性。如果其它任务执行时间过久,会影响定时器任务的执行
  • 如果 setTimeout 存在嵌套调用,浏览器会设定最短时间间隔为 4 毫秒。当定时器嵌套调用5次以上,系统会判断该函数被阻塞了
  • 未激活的页面,setTimeout 执行最小时间间隔为 1000 毫秒。目的是减少后台页面的加载损耗和耗电量
  • 延迟时间有最大值。即 int 型存储的最大值(4个字节32位即2147483647约24.8天),当超过这个数字时会发生溢出,相当于被设置成了0

EcmaScript 异步编程

前言

我们先来讨论一下 Promise 的出现解决了什么问题,有什么优点。要想清楚这个问题,就要先看看,Promise 出现之前的异步编程存在什么问题

  • 异步编程的体验不好:代码逻辑不连续,容易形成回调地狱
  • 实时性不高:宏任务

原因是:

  • 嵌套调用。后面的任务依赖上个任务的结果,并且在上个任务的回调函数内执行新的业务逻辑
  • 任务的不确定性。每个任务都要做错误处理

来看一个例子。我们需要先请求 url-1,成功的话再请求 url-2,再次成功的话再请求 url-3,每一个请求都要依赖上一个请求的结果,如果全部成功即可得到最终的结果。可以看到,这里出现了3层迁到,已经让代码混乱不堪了。

$.ajax({
  url: 'url-1',
  type: 'POST',
  success: function(result) {
    $.ajax({
      url: 'url-2',
      type: 'POST',
      success: function(result) {
        $.ajax({
          url: 'url-3',
          type: 'POST',
          success: function(result) {
            console.log('成功', result);
          },
          error: function(e) {
            console.log(e);
          }
        });
      },
      error: function(e) {
        console.log(e);
      }
    });
  },
  error: function(e) {
    console.log(e);
  }
});

解决回调地狱:

  1. 消灭嵌套回调
  2. 合并多个任务的错误处理

这些 Promise 已经帮我们做了,它是如何消灭嵌套回调的?

首先,Promise 实现了回调函数的延时绑定

其次,将 resolve() 的值 及 then() 的返回值穿透到最外层

Promise

Promise 对象用于表示一个异步操作的最终状态,及结果值。

  Promise有几个特点:

  1. 对象的状态不受外界影响,有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。只有异步操作的结果可以决定当前是哪种状态,其他操作无法改变。
  2. 状态一旦改变,就不会再变,任何时候都可以得到这个结果。状态改变只可能是:pending -> fulfilled 或 pending -> rejected
  3. 实例化后,会立即执行一次。所以一般将其用函数包裹起来,使用的时候调用一次。
  4. 如果执行后的回调也要做一些异步操作,可以无限的.then下去,当然要保证有返回值

  方法:

  • 对象方法 reject、resolve、all、race、allSettled(ES2020)
  • 原型方法 then、catch、finally(ES9)
function promiseTest(n,msg) {
   return new Promise((resolve,reject)=>{
      setTimeout(function () {
         console.log(`执行第${n}个任务`);
         msg.code && resolve(msg.text);  // 当认为成功的时候,调用resolve函数
         !msg.code && reject(msg.text);   // 当认为失败的时候,调用reject函数
      },n*500)
   });
}
let pro = promiseTest(1,{code:true,text:"返回的数据1"});
/* 没有catch,每个then里两个回调函数,此时第一个为成功的回调,第二个为失败的回调 */
pro.then((data)=>{
   console.log(data); // 执行成功结果在这里
   // return promiseTest(2,{code:true,text:"返回的数据2"});
   return promiseTest(2,{code:false,text:"失败的数据"});
},(err)=>{
   console.log(err); // 执行失败的结果在这里
}).then((data)=>{console.log(data)},(err)=>{console.log(err)});

观察 then 和 catch 的用法:

  • 在多次 then 后最后跟一个 catch,可以捕获所有的异常
/* 多个then和一个catch */
pro.then((data)=>{
   console.log(data);
   return promiseTest(2,{code:false,text:"失败的数据"});
}).then((data)=>{
   console.log(data)
}).catch((err,data)=>{
   console.log("失败了",err);
});

all、rece 和 allSettled 的用法:(这三个方法都是将若干个 Promise 实例,包装成一个新的 Promise 实例)

  • all 接收一个 promise 对象数组,在所有异步操作执行完且全部成功的时候才执行 then 回调,只要有一个失败,就执行 catch 回调(只对第一个失败的promise 对象执行)。
  • race 也接收一个 promise 对象数组,不同的是,哪个最先执行完,对应的那个对象就执行 then 或 catch 方法( then 或 catch 只执行一次)。
  • allSettled 同样接收一个 promise 对象数组。当所有的 promise 对象都解决时(无论是 resolve 还是 reject ),才执行 then 回调,它带来了“我只要兑现所有承诺,我不在乎结果”。
/* all的用法 */
Promise.all([
   promiseTest(1,{code:true,text:"返回的数据1"}),
   promiseTest(2,{code:false,text:"返回的数据2"}),
   promiseTest(3,{code:false,text:"返回的数据3"})
]).then((res)=>{console.log("全部成功",res)}).catch((err)=>{console.log("失败",err);});

/* race的用法 */
Promise.race([
   promiseTest(1,{code:false,text:"返回的数据1"}),
   promiseTest(2,{code:false,text:"返回的数据2"}),
   promiseTest(3,{code:true,text:"返回的数据3"})
]).then((res)=>{console.log("成功",res)}).catch((err)=>{console.log("失败",err);});

Generator

Generator 叫做生成器,通过 function* 关键字来定义的函数称之为生成器函数(generator function),它总是返回一个 Generator 对象。生成器函数在执行时能暂停,又能从暂停处继续执行。调用一个生成器并不会立马开始执行里面的语句,而是返回这个生成器的 迭代对象( iterator )。

Generator 对象有3个方法,都有一样的返回值 { value, done } 【与 Python 生成器的用法一样】

  • .next(value)   返回一个由yield表达式生成的值。(value 为向生成器传递的值)
  • .return(value) 该方法返回给定的值并结束生成器。(value 为需要返回的值)
  • .throw(exception)  该方法用来向生成器抛出异常,并恢复生成器的执行。(exception 用于抛出的异常)

生成器的作用:

可以和 Promise 组合使用。减少代码量,写起来更方便。在没有 Generator 时,写 Promise 会需要很多的 then,每个 then 内都有不同的处理逻辑。现在,我们将所有的逻辑写进一个生成器函数(或者在生成器函数内用 yield 进行函数调用),Promise 的每个 then 内调用同一个函数即可。

定义生成器:

function add(a,b) {
   console.log("+");
   return a+b;
}
function cut(a,b) {
   console.log("-");
   return a-b;
}
function mul(a,b) {
   console.log("*");
   return a*b;
}
function division(a,b) {
   console.log("/");
   return a/b;
}
function* compute(a, b) {
  yield add(a,b);
  yield cut(a,b);
  let value = yield mul(a,b);
  console.log("value",value); // 第三次调用.next()时无法为value赋值,需要第四次调用才能为其赋值
  yield mul(a,b);
  yield division(a,b);
}

使用生成器:

// 执行一下这个函数得到 Generator 实例,调用next()方法执行,遇到yield暂停
let generator = compute(4, 2);

function promise() {
   return new Promise((resolve, reject) => {
      let res = generator.next();
      if(res.value > 5)
      {
         resolve("OK");
      }else
      {
         reject("小于5")
      }
   });
}

let proObj = promise();
proObj.then((data)=>{
   console.log(data);
   let res = generator.next();
   console.log("Promise res1",res);
}).then((data)=>{
   let res = generator.next();
   // let res = generator.return();
   console.log("Promise res2",res);
}).then((data)=>{
   let res = generator.next("qwe"); // 第四次next()时,向生成器传数据
   console.log("Promise res3",res)
}).catch((err)=>{
   console.log("出错",err);
});

Generator 函数的特点:

  • 最大特点就是可以交出函数的执行权(暂停执行)。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。
  • 可以将 yield 关键字使得生成器函数可以与外接交流:可以将内部的值传到外界,也可以将外接的值传入

yield 和 yield* :

  • 生成器函数在执行过程中,遇到 yield 会暂停执行,并返回一个值
  • yield* 表达式用于委托给另一个 generator 函数(即可以将当前生成器函数的执行权交给另一个生成器函数)或 可迭代对象 
function* g1() {
  yield 2;
  yield 3;
  yield 4;
}

function* g2() {
  yield 1;
  yield* g1();
  yield 5;
  yield* ["a",  "b"];
  yield* "cd";
}

var iterator = g2();

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: 4, done: false }
console.log(iterator.next()); // { value: 5, done: false }
console.log(iterator.next()); // { value: "a", done: false }
console.log(iterator.next()); // { value: "b", done: false }
console.log(iterator.next()); // { value: "c", done: false }
console.log(iterator.next()); // { value: "d", done: false }
console.log(iterator.next()); // { value: undefined, done: true }

async/await

优点:简洁,节约了不少代码

  • async 函数就是 Generator 函数的语法糖。要将 Generator 函数转换成 async 函数,只需将 * 替换成 async ,yield 替换成 await 即可
  • 被 async 修饰的函数,总会返回一个 Promise 对象。如果代码中返回值不是 promise 或者没有返回值,也会被包装成 promise 对象
  • await 只能在 async 函数内使用。它是一个操作符,等待一个函数或表达式。经过该操作符处理后,输出一个值。

如果在异步函数中,每个任务都需要上个任务的返回结果,可以这么做:

function takeLongTime(n) {
   return new Promise((resolve,reject) => {
      setTimeout(() => {resolve(n + 200)}, n);
   });
}

function step1(n) {
   console.log(`step1 with ${n}`);
   return takeLongTime(n);
}

function step2(m, n) {
   console.log(`step2 with ${m} and ${n}`);
   return takeLongTime(m + n);
}

function step3(k, m, n) {
   console.log(`step3 with ${k}, ${m} and ${n}`);
   return takeLongTime(k + m + n);
}


async function doIt() {
    console.time("doIt");
    const time1 = 300;
    const time2 = await step1(time1);
    const time3 = await step2(time1, time2);
    const result = await step3(time1, time2, time3);
    console.log(`result is ${result}`);
    console.timeEnd("doIt");
}
doIt();

如果这几个任务没有关联,可以这样做:

async function doIt() {  // 函数执行耗时2100ms
   console.time("doIt");
   await step1(300).catch((err)=>{console.log(err)});  // 异常处理
   await step1(800);
   await step1(1000);
   console.timeEnd("doIt");
}
doIt();

当然,最好这样做:

async function doIt() { // 函数执行耗时1000ms
   console.time("doIt");
   const time1Pro = step1(300);
   const time2Pro = step1(800);
   const time3Pro = step1(1000);
   await time1Pro;
   await time2Pro;
   await time3Pro;
   console.timeEnd("doIt");
}
或
async function doIt() { // 函数执行耗时1000ms
   console.time("doIt");
   const [ time1Pro, time2Pro, time3Pro ] = await Promise.all([step1(300), step1(800), step1(1000)])
   console.timeEnd("doIt");
}
doIt();

注意:

  1. async/await 并没有脱离 Promise,它的出现能够更好地协同 Promise 工作。
    • 怎么体现更好地协同?它替代了then catch的写法。使得等待 promise 值的操作更优雅,更容易阅读和书写。
  2. 函数仅仅加上 async 并没有意义,它仍然是同步函数,只有与 await 结合使用,它才会变成异步函数。
    • 这需要精准理解 await。它在等待的时候并没有阻塞程序,此函数也不占用 CPU 资源,使得整个函数做到了异步执行。当 async 函数在执行的时候,第一个 await 之前的代码都是同步执行的。
  3. doIt() 函数内部是串行执行的,但它本身是异步函数。
  4. 在这个异步函数内,可能会做很多操作 ABC,他们有执行的先后顺序。这时你可能会想,A、B、C之间没有关联,他们之间可以是并行执行的,并不需要串行,那怎么办?
    • 【错误想法】这样想没错,但是没必要。因为他们已经存在于异步函数内了,所有的操作已经是异步的。在同样的环境情景下,底层执行的效率是相同的,并不见得因为A和B之间互相异步而提高效率。
    • 【正确想法】这样想是有必要的。参照两个 doIt() ,调用的函数返回 promise 对象,前者是依次生成 promise 对象(依次执行任务),依次等待返回结果。等待总时长取决于所有任务执行时间之和。后者则是同时生成 promise 对象(同时执行任务),依次等待。等待总时长取决于耗时最长的任务。后者的 CPU 运用率更高。
  5. async 函数内任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行。为了不中断后面的操作,我们可以将 await 语句放在 try ... catch 结构内,或者在 await 后面的 Promise 对象跟一个 catch 方法。
  6. 错误处理。最标准的方法是使用 try...catch 语句,但是它不仅会捕捉到 promise 的异常,还会将所有出现的异常捕获。因此,可以使用 .catch ,只会捕获 promise 相关的异常。

关于错误处理,可以这样做:

function takeLongTime(n) {
   return new Promise((resolve,reject) => {
      setTimeout(() => {resolve(n + 200)}, n);
   }).then(data=>[data,null]).catch(err=>[null,err]);
}

async doIt(){
    let [data, err] = await takeLongTime(1000);
  console.log(data, err);  
}

另外,async函数有多种使用形式:

// 函数声明
async function foo() {}

// 函数表达式
const foo = async function () {};
const foo = async () => {};

// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)

// Class 的方法
class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jake').then(…);

异步生成器函数

即异步函数和生成器函数的结合体:async function*() {}。它就是 Generator 和 async-await 的完美结合,支持两者的用法和特性。

以前我以为,async-await 可以完全代替 Generator ,但其实不然,前者的优点在于更优雅地处理异步操作,后者能够支持函数内外进行数据交流。

异步生成器函数会返回一个异步迭代器,这个异步迭代器有两种使用方式:

  1. 通过 for await of 遍历得到值,非常方便
  2. 通过循环 .next() 得到

两种方式又有不同:

  1. 前者不能得到异步生成器内 return 的值,后者可以
  2. 前者不能给 yield 传值,后者可以通过 .next() 方法传值
  3. 除此之外,可以将后者看成前者的手动实现

如何进一步理解异步生成器呢?其实可以看成是为异步函数提供了一种异步返回、多次返回的机制。在非异步生成器函数中,return 只能有一个,且是函数结束的标志。而异步生成器函数就可以做到:间断地返回多个值,不同的返回值之间可以有同步操作也可以有异步操作。这正是集 Generator 和 async-await 的优点于一身,有利于解耦,有利于逻辑的分离。

关于异步迭代器的遍历顺序:完全按照 yield 的顺序来,没有变化。不会因为哪个耗时短而改变顺序。await 也是一样,多个 await 相互之间的顺序是固定的,无法调整,在这里只能串行执行。

关于性能:对于 ES6(+) 本身来说,以上所有的异步方式性能都 OK,但在真实的生产环境中都要由 babel 编译成 ES5 语法,结果会导致代码体积增加,执行过程中会执行另外一段代码,总体性能会低一些。

实验代码:

const asyncFunc1 = () => new Promise((resolve, reject) => {
    setTimeout(() => { resolve("async-1") }, 1000);
});

const asyncFunc2 = () => new Promise((resolve, reject) => {
    setTimeout(() => { resolve("async-2") }, 1500);
});

const asyncGenerator = async function* () {
    const promise1 = asyncFunc1(); // 1000ms
    const promise2 = asyncFunc2(); // 1500ms
    const res1 = await promise1;
    const res2 = await promise2;
    yield res1
    yield res2;
    
    // const a = yield res1;
    // const b = yield res2;
    return "这是异步生成器返回值";
};

const iter = asyncGenerator();
const array = [];

/* 通过 for await of 遍历 */
(async () => {
    console.time("记时");
    for await (const i of iter) {
        array.push(i);
        console.timeLog("记时");
        console.log("遍历", i);
    }
    console.timeEnd("记时");
    console.log("遍历结果", array);
})()

/* 通过循环 .next() 获得 */
// (async() => {
//     console.log("手动循环.next()循环")
//     while(true) {
//         const next = iter.next("next传值");
//         console.log("得到next", next);
//         const { value, done } = await next;
//         console.log(value, done);
//         if (done) break;
//     }
// })()

 

posted @ 2019-08-30 21:17  学霸初养成  阅读(477)  评论(0编辑  收藏  举报