JavaScript异步
JavaScript异步类型
- 由宿主环境提供的API:setTimeout、setInterval、setImmediat、监听 new Image 加载状态、监听 script 加载状态、监听 iframe 加载状态、Message、Web Worker
- EcmaScript: Promise
需要说明的是,在 ES6 之前,JavaScript 语言本身没有异步,延迟类型、监听类型的异步都是由宿主提供的,并非语言的核心部分。
前言
在了解异步之前,先问自己两个问题:
- 为什么JS是单线程的?
- 不是有了Web Worker吗?为什么还说JS是单线程的?
第一个问题的答案是:为了防止DOM操作冲突,进而引起UI的冲突
关于第二个问题,Web Worker 确实是由主线程发起的子线程,可异步执行,但有一些限制,最重要的是:
- 不能操作DOM
- 只能与主线程通信,受主线程控制
因此我们可以发现:
- 所谓多线程时被阉割过的,但能够在一定程度上提升程序的执行效率
- 与主线程的通信,onMessage 事件仍然是使用单线程的异步机制来运行
结论:并不改变单线程的本质。
JavaScript执行引擎如何实现单线程的异步
事件循环
渲染进程的主线程要处理非常多的事情,包含了各种内部消息类型:
- 用户输入事件(鼠标、键盘等)
- 微任务
- 文件I/O
- 定时器
- websocket
- 此外还有与页面相关的事件,如JS执行、解析DOM、样式计算、布局等等
要让这么多不同类型的任务有条不紊地运行,就需要一个统筹调度系统来管理这些任务,它就是任务队列和事件循环系统。
简单来说,其它线程或主体想要发起任务,将任务扔到任务队列中即可。主线程会依次读取并执行。另外,渲染进程中专门有一个IO线程来接收其他进程传来的消息,负责将其包装成任务扔到队列中去。
排版引擎、JS 执行引擎和渲染进程主线程之间的关系:
以 Chrome 为例,排版引擎 Blink 和 JavaScript 引擎 V8 都工作在渲染引擎的主线程上,并且互斥,同时只能执行一个。并且 V8 除了在主线程上执行 JS 代码,还要执行垃圾回收,当执行垃圾回收时停止主线程上其他所有任务。
消息队列存在的问题和改进
存在的问题:
- 如何处理优先级更高的任务?(兼顾执行效率和实时性)
- 如何解决单个任务执行时间过久的问题?
解释:
第一个问题:假如现在队列里已经有一些任务在排队了,这时用户触发了一个点击事件,需要立马响应,这是一个优先级高的任务,该怎么办呢?直接插入?如果不断地有优先级高的任务插入,整个队列的执行效率就会大打折扣。如果排到队尾,实时性又收到了影响。
解决:
第一个问题通过引入微任务队列来解决
第二个问题通过回调来规避
微任务
为什么需要微任务?
宏任务无法胜任对时间精度要求高的任务(无法在实时性和效率之间做一个有效的平衡)
宏任务可以满足我们大多数日常需求,不过对时间精度要求较高的需求就难以胜任了。是因为:添加消息队列这一操作是有浏览器操作的,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); } });
解决回调地狱:
- 消灭嵌套回调
- 合并多个任务的错误处理
这些 Promise 已经帮我们做了,它是如何消灭嵌套回调的?
首先,Promise 实现了回调函数的延时绑定
其次,将 resolve() 的值 及 then() 的返回值穿透到最外层
Promise
Promise 对象用于表示一个异步操作的最终状态,及结果值。
Promise有几个特点:
- 对象的状态不受外界影响,有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。只有异步操作的结果可以决定当前是哪种状态,其他操作无法改变。
- 状态一旦改变,就不会再变,任何时候都可以得到这个结果。状态改变只可能是:pending -> fulfilled 或 pending -> rejected
- 实例化后,会立即执行一次。所以一般将其用函数包裹起来,使用的时候调用一次。
- 如果执行后的回调也要做一些异步操作,可以无限的.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();
注意:
- async/await 并没有脱离 Promise,它的出现能够更好地协同 Promise 工作。
- 怎么体现更好地协同?它替代了then catch的写法。使得等待 promise 值的操作更优雅,更容易阅读和书写。
- 函数仅仅加上 async 并没有意义,它仍然是同步函数,只有与 await 结合使用,它才会变成异步函数。
- 这需要精准理解 await。它在等待的时候并没有阻塞程序,此函数也不占用 CPU 资源,使得整个函数做到了异步执行。当 async 函数在执行的时候,第一个 await 之前的代码都是同步执行的。
- doIt() 函数内部是串行执行的,但它本身是异步函数。
- 在这个异步函数内,可能会做很多操作 ABC,他们有执行的先后顺序。这时你可能会想,A、B、C之间没有关联,他们之间可以是并行执行的,并不需要串行,那怎么办?
- 【错误想法】这样想没错,但是没必要。因为他们已经存在于异步函数内了,所有的操作已经是异步的。在同样的环境情景下,底层执行的效率是相同的,并不见得因为A和B之间互相异步而提高效率。
- 【正确想法】这样想是有必要的。参照两个 doIt() ,调用的函数返回 promise 对象,前者是依次生成 promise 对象(依次执行任务),依次等待返回结果。等待总时长取决于所有任务执行时间之和。后者则是同时生成 promise 对象(同时执行任务),依次等待。等待总时长取决于耗时最长的任务。后者的 CPU 运用率更高。
- async 函数内任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行。为了不中断后面的操作,我们可以将 await 语句放在 try ... catch 结构内,或者在 await 后面的 Promise 对象跟一个 catch 方法。
- 错误处理。最标准的方法是使用 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 ,但其实不然,前者的优点在于更优雅地处理异步操作,后者能够支持函数内外进行数据交流。
异步生成器函数会返回一个异步迭代器,这个异步迭代器有两种使用方式:
- 通过 for await of 遍历得到值,非常方便
- 通过循环 .next() 得到
两种方式又有不同:
- 前者不能得到异步生成器内 return 的值,后者可以
- 前者不能给 yield 传值,后者可以通过 .next() 方法传值
- 除此之外,可以将后者看成前者的手动实现
如何进一步理解异步生成器呢?其实可以看成是为异步函数提供了一种异步返回、多次返回的机制。在非异步生成器函数中,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; // } // })()