写给前任的JS异步教程

158 阅读18分钟

关于异步编程可以说是前端老生常谈的话题了,几乎每一个有体系规模的项目都会用到异步编程,所以理解好异步编程的思想,对我们写好前端代码以及学习前端更有帮助。本文默认读者是有一定的计算机基础的,所以一些计算机基础概念我在这里就不再赘述了,如果有不了解的术语概念,可以另自查阅,我尽可能由浅入深带大家去理解异步编程这个概念。有理解不到位的地方,欢迎大家评论区批评指正。

你需要知道的

在讲解异步编程之前,你需要知道以下的一些概念以帮助你更好的理解本文。

单线程

在平时看书的时候常常会看到类似“JavaScript是单线程语言”的语句,一种语言会有单线程多线程的说法的吗?不会,JavaScript本身是没有线程的概念的,我们说的单线程还是多线程都是相对执行该语言时的环境而言的。

我们知道进程是计算机资源分配的最小单位,而一个进程可以包含多个线程,一个线程又可以包含多个协程。

那么就浏览器而言怎么串联这三个概念呢? 07BD2BBF.png 有一种简单粗暴的说法:“浏览器中每打开一个新的网页就相当于创建了一个新的进程”实际上如果从同源策略考虑的话,这个说法就不严谨的,因为如果是符合同源策略的网页,实际上它们是共享一个进程的。也就是说浏览器本身是多进程的(当然这个多进程值的不是可以打开多个网页,而是其背后还有很多核心进程也会同时运行,如渲染进程、GPU进程、第三方插件进程等等)。

那么对于前端来说最核心的无疑是渲染进程了,页面的渲染与JS的执行等操作都是在这个进程中完成的。渲染进程里面又包含了很多线程,如GUI线程、JS引擎线程、事件触发线程等,而GUI线程与JS引擎线程是互斥的(JS可以操控DOM),这也解释了为什么我们说JS脚本会暂停页面的渲染的原因,如果一个JS执行的时间过长,就容易造成页面卡顿,影响用户体验。

而我们所说的JS单线程准确来说是JS引擎是单线程处理的,即使有多个线程,JS引擎同一时间也只能处理一个线程,(有点像http2.0中的多路复用的感jio)。

而JS引擎这样处理的原因,我个人觉得是为了避免多线程的造成的歧义性,试想如果一个线程要添加节点,另一个线程同时要删除其父节点,那么谁先谁后呢?

后来为了充分利用多核CPU的计算能力,HTML5新增了Wed Worker,允许JS创建子线程,但是这并不意味着改变了JS引擎单线程的特点,因为Worker创建的子线程完全受主线程控制,且不能操作DOM,所以只能说Worker是浏览器给JS开了外挂,专门来解决那些大量计算问题。

异步

首先我们来看一张图,解释同步与异步的区别

QQ截图20220327091657.png 上面的是同步,任务是线性执行的,一个任务必须等到前一个任务执行完毕之后才可以开始。如果一个任务阻塞或者那么后面的任务也就无法执行了,整个进程就会被挂起。 下面的是异步,任务是可以同时执行的,一个任务出现问题也不会影响到其他的任务正常执行。

虽然平时写代码的时候我们的JavaScript代码都是放在一个.js文件中的,但是实际上这个程序是由多个块构成的,在这些程序块中就分为 现在执行将来执行两部分,最常见的程序块单位就是函数了。事实上真正意义上的异步实在ES6之后才出现的,你可能会觉得有些不可思议,但是在ES6之前的setTimeout(),或者事件监听都不算真正意义上的异步。

好了简单的补了一下需要了解的背景知识,下面就进入主题吧

JS中的异步编程

JS的异步编程经过了相当长的时间发展迭代,直到ES7才相对形成一个较为成熟的范式。我们先来看个图

QQ截图20220327100107.png 从图上可以看出,在JS的发展历程中异步编程实际上都是在原回调函数的基础上进行封装优化,我们说学习一样东西就要顺藤摸瓜了解其来龙去脉。

回调函数

回调函数通俗的说就是将一个函数作为另一个函数的参数,回调函数逻辑易于理解,用法也简单,在很长一段时间人们都觉得回调函数就足以满足异步的需求了。

ajax(url,()=>{
...//some code
})

上面的代码看上去没啥问题,但是随着任务逻辑的增加,处理事务的个数增加,这种内部嵌套的形式就会形成著名的“回调地狱”

ajax1(url,()=>{
   ajax2(url,()=>{
       ajax3(url,()=>{
          .....
       })
   })
})

而且这样写,后期的维护与debug都是非常困难的,语义化也非常难以理解。虽然它长的丑,但是它还是在前端异步历史里面存在了相当长的一段时间。所以我们不能以貌取物,相遇就是缘,来简单的了解一下回调函数的基本应用也是很有价值的。

setTimeout

前面说了setTimeout()并不是严格意义上的异步处理,它只是一个定时器,我们知道JavaScript引擎本身是没有时间的概念的,它只是一个按需执行代码块的环境,它通过 事件循环机制运行程序中的代码块。

事件循环, 通俗来说就是在JavaScript代码执行中会用到两个东西:调用栈、事件循环队列(包含宏任务队列和微任务队列)。一个标签是一个宏任务,当然渲染事件、用户交互、事件请求、I/O也是一些常见的宏任务。当JavaScript代码开始执行的时候,第一个script宏任务进入宏任务队列,此时调用栈还没有要执行的事件,于是这个宏任务出队入栈,然后开始执行该宏任务里面的代码,每一个宏任务都会有自己的微任务队列,当执行完宏任务的时候就会去查找其对应的微任务队列,如果有任务就处理,没有就进入下一个宏任务,如此循环。

但是,setTimeout()是没有将你的回调函数放入事件循环队列的,它只设了一个定时器,如果队列中有等待事件,那么就会从队列中选取一个事件执行。当到达设定时间之后,就会摘取这个回调执行,如果有多个回调,就是排队。所以setTimeout()的精度不高,它只能保证不会在在指定时间之前执行,但是可能是在设定时间或者设定时间之后执行,而且因为定时器在时间到了之后是在有机会的时候才插入消息队列,而且当两个定时器连续的时候,是不能保证他们执行的顺序的,可能排在后面的会先执行。

addEventListener

事件监听,给一个DOM元素绑定一个监听器,这样事件被执行的顺序就与代码编写的顺序无关,而是与事件被触发的顺序有关。有时候对于一些嵌套、层叠的多个事件都需要监听的时候会使用到 事件委托 来优化性能。

事件委托就是将单个事件监听绑定到其对应的父元素上,利用冒泡机制来触发目标事件。

网络请求

这里一般是指Ajax的异步请求。(现在有一个叫navigator.sendBeacon()方法也是可以实现异步请求的,还未深入了解,有时间再整理出来吧)除了Ajax之外,Axios,Fetch也是可以的。

Promise+then

前面说了回调函数,随着互联网的发展,一个事件的触发往往会牵动其他的功能点事件的触发,请求体的关系结构越来越复杂,回调函数的结构也会越来越难看,“回调地狱”随之而来。那么这个时候划时代的英雄Promise横空出世!

Promise简单来说是一个容器,一个保存未来结束事件结果的容器,语法上它可以获取异步操作的信息。ES6中对Promise对象定义为一个构造函数,通过new方法创建,传入两个由JavaScript引擎提供的参数resolve与reject。

一个标准的Promise对象都有三种状态:pending(进行中)、fulfilled(已成功)、rejected(失败),且这三种状态只有两种可能:pending=>fulfilled;pending=>rejected,状态一旦转变就不可更改。

QQ截图20220331080926.png

  • then Promise实例具有then方法,且then方法是定义在原型上的,就是为promise实例添加状态改变时的回调函数。 then方法返回的是一个新的promise实例,因此可以对其采用链式写法,then方法后面可以连续调用多个then方法,每个都会返回一个新的promise实例。

  • catch catch方法也是挂在promise原型上的,跟在then方法之后,于指定发生错误时的回调函数。 一般我们可以理解成当promise实例状态返回的是resolved的时候会执行then方法, reject的时候会执行catch方法。 与try/catch代码块不同的是,在promise中如果没有使用catch方法处理错误回调函数,那么promise对象抛出的错误也不会传递到代码外层,浏览器会打印出错误,但是不会终止程序、退出进程,而是会在打印错误信息之后继续执行下面的代码。 也就是说对于外部代码“promise会吃掉错误” catch方法与then方法没有固定的位置限制,都是根据promise对象状态改变来调用的。 当然catch方法里面也是可以抛出错误的。也就是catch嵌套。

  • finally finally()用于指定不管promise对象最后状态如何都会执行。 promise .then(...) .catch(...) .finally(...) 上面的代码中在执行完then和catch的回调函数之后,都会执行finally指定的回调函数。

  • all、race、any promise.all()与promise.race()方法都可以用于将多个promise实例包装成一个新的promise实例。 但是两者的区别在于 all()要求每一个被包装的promise实例的状态都变成fulfilled最终返回的promise才会是fulfilled,否则就是reject。 race()是只要内部被包装的promise实例有一个率先改变状态,最终的实例就跟着改变。 any()方法接受一组promise实例作为参数,包装成一个新的promise实例返回。只要有一个参数实例变成fulfilled包装实例就是fulfilled,全部参数实例都是rejected时包装实例才会是rejected.

  • allSettled allSettled()适用于一组异步操作都结束了,不管每一个操作成功与否都会进行下一步操作。 allSettled()方法接收一组promise实例数组,数组中的每一个promise状态都改变之后,allSettled()方法的promise状态才会改变,且状态只会变成fulfilled,然后执行回调函数。 allSettled会返回一个数组给回调函数作为参数,数组的每一个成员对应之前被包装的promise实例对象。

Generator+yield+next+协程

Generator函数是ES6提供的一种异步解决方案。 形式上Generator是一个普通函数,有两个特征。

  1. function关键字与函数名之间有一个*号;
  2. 函数内部使用yield表达式,定义不同的内部状态。

通俗来说Generator是一个带星号的函数,不是真正意义上的函数,配合yield关键字来暂停或者执行函数。 语法上Generator函数可以看成一个状态机,封装了多个内部状态。 执行Generator函数会返回一个遍历器对象,也就说明Generator还是一个遍历器生成函数。返回的遍历器对象,可以依次遍历Generator函数的每个状态。 原理上就是在生成器的内部,如果遇到yield关键字之后,v8引擎将暂停生成器内部代码的执行,二区执行返回yeild关键字后面的内容。当需要恢复生成器的执行的时候在外部调 用.next()方法即可。

Generator函数

Generator函数是协程在ES6的实现,最大的特点就是可以交出函数的执行权(即暂停执行)整个Generator函数就是一个封装的异步任务,异步操作需要暂停的地方都用yield语句注明。 调用一个Generator函数,会返回一个内部指针(即遍历器),通过调用指针的.next()方法移动内部指针(即执行异步任务的前半段)指向第一个yield语句。

function *gen(1){
var y=yield x+2;
return y;
}
var g=gen(1);
g.next();//{value:3,done:false}

value属性是yield语句后面表达式的值,表示当前阶段的值; done属性是一个 布尔值,表示Generator函数是否执行完毕,即是否还有下一阶段

错误交换与错误处理

Generator函数的内外数据交换, value属性是Generator函数向外输出数据;next方法接受参数就是对内输入数据;

function *gen(x){
var y=yield x+2;
return y;
}
var y=gen(1);
g.next();
g.next(2);//输入数据
//Generator函数的错误处理可以在函数内部使用try...catch代码块来捕获异常,在外部指针调用throw方法抛出异常
g.throw("Eorr");
异步任务封装

如何利用Generator函数实现一个异步任务封装呢?

  1. 先定义一个Generator函数
  2. 调用其next方法并赋值给一个变量对象
  3. 取出返回的promise对象值并调用then方法。
function*gen(){
 var result =yield x+2;
 console.log(y);
}
var r=gen();
var ff=g.next();
ff.value.then(function(data){
return data;
})

可以看到Generator函数将异步操作表示的很简洁,但流程的管理不明晰,异步顺序开始的时间不确定。

Thunk函数

Thunk函数是自动执行Generator函数的一种方法。

Thunk函数诞生于上世纪60年代,那个时候编程语言刚刚起步,编译器怎么写比较好是计算机学家研究的重要内容。一个争论的焦点就是“求值策略”,即函数的参数到底何时求值。主要的意见有“传值调用”和“传名调用”

传值调用: 即在进入函数体之前就计算传入参数表达式的值,然后将该值传入函数。(C语言就是如此) f(x+5)=>f(6)//假设x初始值为1

传名调用: 即直接将表达式传入函数体,只在用到它的时候在对其进行求值。 哪一个策略更好是很难比较的,各有利弊。传值调用简单,但是如果函数最后并没有使用到该参数值,那么相对于白算了,就可能会造成性能损失。

编译器的“传名调用”往往是将参数放到一个函数体中,再将这个临时函数传入函数体。这个临时函数就叫Thunk函数。

co模块

co 模块是一个同样用于 Generator 函数自执行的工具。

基本使用 可以将定义的 Generator 函数作为一个参数传给 co 模块,将会返回一个 promise 对象,因此也可以用 then 方法为其添加回调函数。 实际上 co 模块是将 Thunk 函数与 promise 对象包装成一个模块。 因此使用 co 模块的前提条件就是 Generator 函数的 yield 命令后面只能是 Thunk 函数或者 promise 对象。

async/await

async /await是ES7新增的两个关键字,代表异步JavaScript编程范式的迁移。其改进了生成器的缺点,提供了在补阻塞主线程的情况下使用同步代码实现异步访问资源的能力。 事上async/await是Generator的语法糖,async函数实际上就是将Generator函数和自动化执行器包装在一个函数里面。她能实现的效果都能使用then链来实现,只不过是在其基础上优化了。

概念: 字面上async是“异步”的简写,await是等待。 使用async声明异步函数,包括函数声明、函数表达式、箭头函数、方法。await则使用在async声明函数内部,暂停异步代码的执行,等待promise解决。 await等的是什么? 一般来说是等一个async函数。实际上要清楚async函数会返回一个表达式,可以是promise对象也可以是其他值。await时必须要用在async函数中的,async函数调用不会造成阻塞,其内部的所有阻塞都会被封装在一个promise对象中异步执行。

特点: 前面说了async/await 是Generator函数的语法糖,那么对比Generator,async的优化在哪里呢?

  • 内置执行器 Generator 函数的执行必须靠执行器,所以才会出现 co 模块。而 async 函数自带执行器,可以和普通函数一样通过在函数名后面加()即可调用。
  • 更好的语义 async 和 await 比起 * 和 yield 语义更清晰,async 表示函数里右异步操作,await 表示紧跟在后面的表达式需要等待结果。
  • 更广的适用性 co 模块规定 yield 命令后面只能是 Thunk 函数或者 Promise 对象。而 async 函数的 await 后面可以是 Promise 对象也可以是原始类型值(如果是原始类型值会自动 resolved 转成 promise 对象)
  • 返回值是Promise async函数返回的是promise对象,可以使用then方法指定回调函数。而Generator 哈数返回的是Iterator 对象。

错误处理机制

async 前面说过async返回的是一个promise对象,那么就可以直接使用then方法。 如果async函数内部抛出错误,就会使得返回的promise对象状态为reject,抛出的错误对象就会被catch方法回调接收。 async函数返回的promise对象必须等到内部所有的await命令后面的promise对象执行完才会发生状态改变,除非遇到return语句或者抛出错误。

await 正常情况下await 命令后面是一个promise对象,返回该对象的结果,如果不是promise对象,就直接返回对应的值。 除此之外await命令后面是一个thenabe 对象(定义了then方法的对象),那么await会视其为一个promise对象处理。 任何一个await语句后面的promise对象变成reject状态时,那么整个async函数都会中断执行。 如果不希望中断,可以可能出错的await语句写在try...catch代码块中,这样不会出错的await就不会受其影响。

使用注意

  1. 为避免返回的promise对象状态rejected,最好将await命令放在try....catch语句块中
  2. 多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发
//没有同时触发
let foo = await getFoo();
let bar = await getBar();
//同步写法一
let [foo,bar] = await Promise.all([getFoo(),getBar()]);
//同步写法二
let fooPromise= getFoo();
let barPromise =getBar();
let foo=await fooPromise;
let bar =await barPromise;
3await只能放在async函数里面,如果放在普通函数里面就会出错。
4async函数可以保留运行堆栈
//没有async的异步函数
const a = ()=>{
  b().then(()=>c());
};
//b是异步任务,当b运行完,a已经结束,如果b.c出现错误,错误的堆栈将不包括a
//有async
const a=async () =>{
 await b();
 c();
 }
 //b运行时a暂停执行,上下文环境都保存着。一旦b或者c出错,错误堆栈也会包含a

最后

因为JS引擎单线程的处理机制,所以使用异步编程可以说是最重要的一种能力,路漫漫其修远兮,吾将上下而求以。

参考文章

[1]《你不知道的JavaScript》(中卷)

[2]《ES6标准入门》