JavaScript 异步详解

一些概念


单线程&非阻塞I/O

  • JavaScript语言本身是单线程的。什么是单线程?可以理解为一次只能执行一个任务,所有的任务在执行开始前排成一个队列,等待顺序执行

  • 为什么是单线程?如果JS有是多线程的,一个线程在某个DOM节点上添加内容,同时另一个线程删除了这个节点,那浏览器以谁为准?所以为了避免复杂性,JavaScript从诞生起就是单线程的

  • 非阻塞I/O:I/O即Input/Output,非阻塞和阻塞的区别就在于在系统接收输入到输出期间,能不能接收其他的输入

    这里举一个例子:食堂排队打饭 / 餐厅点餐

    • 食堂排队打饭: 我们排成一队打饭,阿姨为排到的人打饭(Input)时,是不会理会后边的人想要什么的,直到给当前的人打完餐(Output)后,才会接受下一个人的需求(下一个Input),这就是阻塞I/O
    • 餐厅点餐:我们进入餐厅后,服务员来为我们点餐(Input),点餐结束后,服务员将菜单传给后厨(扔进任务队列----后边会详细介绍);接着服务员会为下一个人点餐(下一个Input),直到后厨做好,服务员根据餐桌位置把菜端上来(Output);在此期间,服务员不断地点餐,送餐,后边的人不需要等待前边的人上完菜再点餐,这就是非阻塞I/O
  • 因为非阻塞I/O的特性,也造就了JS能够高效率地执行代码,也能够承载高并发

JS异步

  • 异步(Asynchronous, async)是与同步(Synchronous, sync)相对的概念,我们来看下面这张图:
    在这里插入图片描述

    同步的流程中,所有任务需要等待上一个任务完成后才能开始执行; ----- 同步流程中,总执行时间是所有同步任务执行时间的总和。
    异步的任务则不需要等待前边的任务执行完成,就可以开始本次任务的执行,任务之间互相不受干扰; ---- 异步流程中,总执行时间是耗时最长的异步任务所需时间。

  • 对于JS来说,执行代码过程中有能够 立即执行 的操作(比如声明、赋值、循环等,也称同步任务),还有一些 非常耗时 的操作(比如定时器、网络请求、文件读写、事件监听等,也称异步任务),如果让他们像前边的任务一样老老实实地等待,这样对于单线程的JS来说执行效率就非常低,甚至会形成假死状态
  • 所以JS的宿主环境(浏览器、Node.js)会为这些耗时的操作单独开辟线程,比如网络请求线程、定时器触发线程、浏览器事件触发线程、文件读写线程等,等这些任务被其他线程执行完成后,再通过回调的方式返回,这就是JS异步

事件循环(Event Loop)

先上个画了1个小时的图
在这里插入图片描述

  • JS主线程顺序读取代码,形成一个执行栈(execution context stack)
  • 碰到耗时的操作,也就是需要异步执行的代码,主线程就根据异步类型分派给其他的异步线程去处理这些操作,当他们处理完成后,将结果扔给任务队列
  • 当主线程把所有执行栈中的内容执行完成后,开始循环读取任务队列中,有完成的,就把这些异步的回调(callback)拉到主线程继续执行,如此反复,称为事件循环(Event Loop)

JS处理异步的历程


callback

  • 最开始,我们基本都是通过函数传参callback回调函数来解决异步问题
  • 来看下面这段代码,假设我们请一位先生吃饭,就会有:
    let status = 'hungry' // 饿了
    function eat() {
      // 吃饭是需要花时间的,这里给个定时器模拟
      setTimeout(() => {
        status = 'full' // 吃饱了
      }, 500)
    }
    eat() // 开始吃饭
    console.log(status) // 吃饱了么?
    
    执行一下,控制台告诉我们结果是hungry。白吃了?其实并不是,这就相当于人刚说要开始吃,还没动筷子呢,咱就问人吃饱没,那不是很不礼貌么
  • 所以,咱得礼貌些,一定要在人吃完了再问。这里我们用callback回调函数的方式来问
    let status = 'hungry' // 饿了
    /**
     * @param callback {function} 吃完饭后的回调函数
     * */
    function eat(callback) {
      // 吃饭是需要花时间的,这里给个定时器模拟
      setTimeout(() => {
        status = 'full' // 吃饱了
        callback && callback() // 吃完后调用回调函数,这里对回调函数做一个判空处理
      }, 500)
    }
    // 开始吃饭
    eat(() => {
      console.log(status) // 吃饱了么? --- full
    })
    
    再次执行,那指定是饱了full,因为咱是在人家吃完以后才问的
  • 我们再假设另外一种情况,如果这位先生饭量比较大,一碗饭根本不够吃,最终吃几碗能饱完全看人心情,但是我们的钱包只够吃三碗饭的,实在吃不饱也没办法了:
    let status = 'hungry'
    function eat(callback) {
      setTimeout(() => {
      	// 我们给个随机数模拟吃饱的概率
        if(Math.random() > 0.8) {
          status = 'full'
        }
        callback && callback()
      }, 500)
    }
    // 第一碗
    eat(() => {
      if(status === 'full') {
        console.log('full at 1st')
      } else {
        // 第二碗
        eat(() => {
          if(status === 'full') {
            console.log('full at 2nd')
          } else {
            // 第三碗
            eat(() => {
              if(status === 'full') {
                console.log('full at 3rd')
              } else {
                // 三碗都吃不饱,没钱了,再见吧
                 console.log('bye')
              }
            })
          }
        })
      }
    })
    
    先不管这个人吃没吃饱,我们看代码,三碗吃饭下来,一层套一层的回调函数+条件判断,代码就显得很乱。这里逻辑还算是简单的,如果碰到复杂的,或者嵌套层数更多的情况,代码维护起来就很困难,这就是经典的回调地狱callback hell)问题。下面引入Promise来解决回调地狱

Promise

  • Promise是es6中很重要的一个概念,也是我们最常用的异步解决方案。它是一个构造函数,所以我们需要使用new关键字来创建一个Promise:

    let promise = new Promise()
    
  • Promise译为承诺,表示承诺在未来有一个确切的答复;分别有以下三个状态,也称为状态机

    • pending ---- 未解决状态,也是初始状态
    • resolved/fulfilled ---- 成功状态
    • rejected ---- 失败状态

    我们实例化一个Promise的时候,它就会进入pending状态,直到我们告诉它是成功resolve()还是失败reject(),Promise才会改变状态;

    这里还有一点需要注意,Promise的回调默认会返回一个新的Promise,如果回调中是return语句,返回的Promise就是resloved状态,如果回调中采用throw error语法,返回的Promise则是rejected状态的(这一点在后边讲解async/await也会提及)

  • resolve的内容会走到then回调中,reject的内容会走到Promise后的第一个catch(后边会介绍)中,不管成功失败,都会走一个finally

    // 面试
    function interview() {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if(Math.random() > 0.5) {
            resolve('success') // 面试成功
          } else {
            reject(new Error('fail')) // 面试失败
          }
        }, 500)
      })
    }
    // 开始面试
    interview()
      .then(res => {
        console.log(res)
      })
      .catch(err => {
        console.log(err.message)
      })
      .finally(() => {
        console.log('whatever')
      })
    
  • 解决回调地狱,我们拟定三轮面试,三轮面试全部成功才算成功,否则就是失败

    /**
     * @param round {string} 面试轮数
     * */
    function interview(round) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if(Math.random() > 0.5) {
            resolve('success') // 面试成功
          } else {
            reject(new Error('fail at ' + round)) // 面试失败
          }
        }, 500)
      })
    }
    // 第一轮
    interview('1st')
      .then(() => {
        return interview('2nd') // 第二轮
      })
      .then(() => {
        return interview('3rd') // 第三轮
      })
      .then(() => {
        console.log('success')
      })
      .catch(err => {
        console.log(err.message) // 所有的reject都会走到这,因为这是所有Promise后的第一个catch
      })
    

    看得出来,所有的回调变成了链式调用,错误捕捉只需要一个catch拦截即可,这样大大提高了代码的可读性和可维护性

  • 并发异步问题:假定一个场景,我们同时面试多家公司,只有都成功了才说明咱是大牛,否则就是菜鸡;正常思维我们会开启一个计数器,面试通过就对计数器+1,最后等待一定时间,再通过判断计数器是否到达面试总数来决定自己是菜鸡还是大牛:

    /**
     * @param name {string} 公司名称
     * */
    function interview(name) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if(Math.random() > 0.5) {
            resolve('success') // 面试成功
          } else {
            reject(new Error('fail at ' + name)) // 面试失败
          }
        }, 500)
      })
    }
    let count = 0;
    
    // 同时面试三家
    interview('alibaba').then(() => {
      count++
    }).catch(err => {
      console.log(err.message)
    })
    interview('baidu').then(() => {
      count++
    }).catch(err => {
      console.log(err.message)
    })
    interview('tencent').then(() => {
      count++
    }).catch(err => {
      console.log(err.message)
    })
    
    // 等待一定时间判断是否都面试成功
    setTimeout(() => {
      if(count === 3) {
        console.log('you are perfect!')
      }
    }, 600)
    

    这是我们知道每个Promise的结束时间,最终等待超过最长的即可,但是如果每轮面试的时间都是未知的呢,怎么在最后做判断?往下看

  • 引入Promise.all()解决异步并发问题Promise.all([])接受一个由 Promise组成的数组 作为参数,当参数中所有的Promise都成功后才会进入Promise.allthen回调中,并且会将三个resolve返回值作为数组返回给Promise.allthen,否则都会进入catch回调中

    /**
     * @param name {string} 公司名称
     * */
    function interview(name) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if(Math.random() > 0.5) {
            resolve('success') // 面试成功
          } else {
            reject(new Error('fail at ' + name)) // 面试失败
          }
        }, 500)
      })
    }
    // 同时面试三家
    Promise
      .all([interview('alibaba'), interview('baidu'), interview('tencent')])
      .then((res) => {
        console.log('you are perfect!')
        // 这里可以吧res打印出来看一下,是三个Promise返回结果组成的数组
        console.log(res) // [ 'success', 'success', 'success' ]
      })
      .catch(err => {
        console.log(err.message)
      })
    

    这样一来,不管每个面试多长时间,我们都能清晰地判断是否都完事了

async/await

  • async/await可以让我们用同步的思维去编写异步代码,被称为JS异步问题的终极解决方案
  • async是修饰function的关键字,async function 其实是一个 Promise 的语法糖。观察下边代码,我们执行async function返回的结果就是一个Promise
    const success = async function() {
      return 'success'
    }
    const fail = async function() {
      throw new Error('fail')
    }
    console.log(success) // [AsyncFunction: success]
    console.log(fail) // [AsyncFunction: fail]
    console.log(success()) // Promise { 'success' }
    console.log(fail()) // Promise { <rejected> }
    
  • awaitasync functon 中的一个关键字,用来阻止后边的代码立即执行,并且可以用同步的方法获取Promise的执行结果
    (async function() {
      let status = 'hungry'
      await new Promise((resolve) => {
        setTimeout(() => {
          status = 'full'
          resolve()
        }, 500)
      })
      console.log(status) // full
    }())
    
    执行结果是full,但是看代码,并没有在回调中打印状态,而是直接在外部打印,并且能得到full的状态,是因为await关键字起了作用,它的存在让异步编程回归到了同步
  • 那么用async/await的方式来写三轮面试的方式呢
    /**
     * @param round {string} 面试轮数
     * */
    function interview(round) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if(Math.random() > 0.5) {
            resolve('success') // 面试成功
          } else {
            reject(new Error('fail at ' + round)) // 面试失败
          }
        }, 500)
      })
    }
    (async function() {
      try {
        let round1 = await interview('1st')
        console.log('round 1st', round1)
        let round2 = await interview('2nd')
        console.log('round 2nd', round2)
        let round3 = await interview('3rd')
        console.log('round 3rd', round3)
      } catch (e) {
        return console.log(e.message)
      }
      console.log('all success')
    }())
    
    这段用同步的方式写出来的异步代码,我们拿来运行一下,就能够清晰地知道面试的过程,包括哪一轮通过,到第几轮失败。
    注意:我们正常在外部是无法使用try/catch来捕捉Promise返回的error,但是使用async/await 就可以轻松在外部捕捉到

总结


优先级

介绍了三种异步解决方案,那么我们在编码过程中应该如何选择呢?
推荐优先级:async/await > Promise > callback

  • Promise > callback
    • Promise 的链式调用能够让我们用线性思维去编写代码
    • Promise 能够解决 callback 方式所产生的回调地狱问题
  • async/await > Promise
    • async/await 允许我们用更容易理解的同步思维去编写代码
    • async/await 能够通过 try/catch 来捕捉 Promise只能在catch()中捕获的错误
    • 通过async/await解决三轮面试的案例可以看出,async/await 在接受中间值方面表现得更加优秀

兼容性

因为 Promise是 es6 语法,async/await是 es8 语法,所以兼容性方面还是有所欠缺,以下是它们在 can i use 网站中的表现:
在这里插入图片描述
在这里插入图片描述
but,这些问题在我们编码的时候可以通过构建工具来解决,如 babel 可以把我们的高版本JS代码转换成浏览器能够通吃的兼容性代码

结束语

异步的问题一直是JS三座大山(原型与原型链,作用域及闭包,异步和单线程)之一,平常编码也经常碰到,希望本文能对大家有所帮助!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值