9
本文首发于本人博客

众所周知JS是单线程的,这种设计让JS避免了多线程的各种问题,但同时也让JS同一时刻只能执行一个任务,若这个任务执行时间很长的话(如死循环),会导致JS直接卡死,在浏览器中的表现就是页面无响应,用户体验非常之差。

因此,在JS中有两种任务执行模式:同步(Synchronous)和异步(Asynchronous)。类似函数调用、流程控制语句、表达式计算等就是以同步方式运行的,而异步主要由setTimeout/setInterval、事件实现。

传统的异步实现

作为一个前端开发者,无论是浏览器端还是Node,相信大家都使用过事件吧,通过事件肯定就能想到回调函数,它就是实现异步最常用、最传统的方式。

不过要注意,不要以为回调函数就都是异步的,如ES5的数组方法Array.prototype.forEach((ele) => {})等等,它们也是同步执行的。回调函数只是一种处理异步的方式,属于函数式编程中高阶函数的一种,并不只在处理异步问题中使用。

举个栗子🌰:

// 最常见的ajax回调
this.ajax('/path/to/api', {
    params: params
}, (res) => {
    // do something...
})

你可能觉得这样并没有什么不妥,但是若有多个ajax或者异步操作需要依次完成呢?

this.ajax('/path/to/api', {
    params: params
}, (res) => {
    // do something...
    this.ajax('/path/to/api', {
      params: params
    }, (res) => {
        // do something...
        this.ajax('/path/to/api', {
          params: params
        }, (res) => {
          // do something...
        })
        ...
    })
})

回调地狱就出现了。。。😢

为了解决这个问题,社区中提出了Promise方案,并且该方案在ES6中被标准化,如今已广泛使用。

Promise

使用Promise的好处就是让开发者远离了回调地狱的困扰,它具有如下特点:

  1. 对象的状态不受外界影响:

    • Promise对象代表一个异步操作,有三种状态:Pending(进行中)、Resolved(已完成,又称 Fulfilled)和Rejected(已失败)。
    • 只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
  2. 一旦状态改变,就不会再变,任何时候都可以得到这个结果。

    • Promise对象的状态改变,只有两种可能:从Pending变为Resolved和从Pending变为Rejected。
    • 只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。
    • 如果改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。
    • 这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
  3. 一旦声明Promise对象(new Promise或Promise.resolve等),就会立即执行它的函数参数,若不是函数参数则不会执行

上面的代码可以改写成如下:

this.ajax('/path/to/api', {
    params: params
}).then((res) => {
    // do something...
    return this.ajax('/path/to/api', {
        params: params
    })
}).then((res) => {
    // do something...
    return this.ajax('/path/to/api', {
        params: params
    })
})
...

看起来就直观多了,就像一个链条一样将多个操作依次串了起来,再也不用担心回调了~😄

同时Promise还有许多其他API,如Promise.allPromise.racePromise.resolve/reject等等(可以参考阮老师的文章),在需要的时候配合使用都是极好的。

API无需多说,不过这里我总结了一下自己之前使用Promise踩到的坑以及我对Promise理解不够透彻的地方,希望也能帮助大家更好地使用Promise:

  1. then的返回结果:我之前天真的以为then要想链式调用,必须要手动返回一个新的Promise才行

    Promise.resolve('first promise')
    .then((data) => {
        // return Promise.resolve('next promise')
        // 实际上两种返回是一样的
        return 'next promise'
    })
    .then((data) => {
        console.log(data)
    })

    总结如下:

    • 如果then方法中返回了一个值,那么返回一个“新的”resolved的Promise,并且resolve回调函数的参数值是这个值
    • 如果then方法中抛出了一个异常,那么返回一个“新的”rejected状态的Promise
    • 如果then方法返回了一个未知状态(pending)的Promise新实例,那么返回的新Promise就是未知状态
    • 如果then方法没有返回值时,那么会返回一个“新的”resolved的Promise,但resolve回调函数没有参数
  2. 一个Promise可设置多个then回调,会按定义顺序执行,如下

    const p = new Promise((res) => {
      res('hahaha')
    })
    p.then(console.log)
    p.then(console.warn)

    这种方式与链式调用不要搞混,链式调用实际上是then方法返回了新的Promise,而不是原有的,可以验证一下:

    const p1 = Promise.resolve(123)
    const p2 = p1.then(() => {
        console.log(p1 === p2)
        // false
    })
  3. thencatch返回的值不能是当前promise本身,否则会造成死循环

    const promise = Promise.resolve()
    .then(() => {
        return promise
    })
  4. then或者catch的参数期望是函数,传入非函数则会发生值穿透

    Promise.resolve(1)
      .then(2)
      .then(Promise.resolve(3))
      .then(console.log)
    // 1
  5. process.nextTickpromise.then都属于microtask,而setImmediatesetTimeout属于macrotask

    process.nextTick(() => {
      console.log('nextTick')
    })
    Promise.resolve()
      .then(() => {
        console.log('then')
      })
    setImmediate(() => {
      console.log('setImmediate')
    })
    console.log('end')
    // end nextTick then setImmediate

    有关microtaskmacrotask可以看这篇文章,讲得很细致。

但Promise也存在弊端,那就是若步骤很多的话,需要写一大串.then(),尽管步骤清晰,但是对于我们这些追求极致优雅的前端开发者来说,代码全都是Promise的API(thencatch),操作的语义太抽象,还是让人不够满意呀~

Generator

Generator是ES6规范中对协程的实现,但目前大多被用于异步模拟同步上了。

执行它会返回一个遍历器对象,而每次调用next方法则将函数执行到下一个yield的位置,若没有则执行到return或末尾。

依旧是不再赘述API,对它还不了解的可以查阅阮老师的文章

通过Generator实现异步:

function* main() {
   const res = yield getData()
   console.log(res)
}
// 异步方法
function getData() {
   setTimeout(() => {
       it.next({
           name: 'yuanye',
           age: 22
       })
   }, 2000)
}
const it = main()
it.next()

先不管下面的next方法,单看main方法中,getData模拟的异步操作已经看起来很像同步了。但是追求完美的我们肯定是无法忍受每次还要手动调用next方法来继续执行流程的,为此TJ大神为社区贡献了co模块来自动化执行Generator,它的实现原理非常巧妙,源码只有短短的200多行,感兴趣可以去研究下。

const co = require('co')

co(function* () {
  const res1 = yield ['step-1']
  console.log(res1)
  // 若yield后面返回的是promise,则会等待它resolved后继续执行之后的流程
  const res2 = yield new Promise((res) => {
    setTimeout(() => {
      res('step-2')
    }, 2500)
  })
  console.log(res2)
  return 'end'
}).then((data) => {
  console.log('end: ' + data)
})

这样就让异步的流程完全以同步的方式展示出来啦😋~

Async/Await

ES7标准中引入的async函数,是对js异步解决方案的进一步完善,它有如下特点:

  1. 内置执行器:不用像generator那样反复调用next方法,或者使用co模块,调用即会自动执行,并返回结果
  2. 返回Promise:generator返回的是iterator对象,因此还不能直接用then来指定回调
  3. await更友好:相比co模块约定的generator的yield后面只能跟promise或thunk函数或者对象及数组,await后面既可以是promise也可以是任意类型的值(Object、Number、Array,甚至Error等等,不过此时等同于同步操作)

进一步说,async函数完全可以看作多个异步操作,包装成的一个Promise对象,而await命令就是内部then命令的语法糖

改写后代码如下:

async function testAsync() {
  const res1 = await new Promise((res) => {
    setTimeout(() => {
      res('step-1')
    }, 2000)
  })
  console.log(res1)
  const res2 = await Promise.resolve('step-2')
  console.log(res2)
  const res3 = await new Promise((res) => {
    setTimeout(() => {
      res('step-3')
    }, 2000)
  })
  console.log(res3)
  return [res1, res2, res3, 'end']
}

testAsync().then((data) => {
  console.log(data)
})

这样不仅语义还是流程都非常清晰,即便是不熟悉业务的开发者也能一眼看出哪里是异步操作。

总结

本文汇总了当前主流的JS异步解决方案,其实没有哪一种方法最好或不好,都是在不同的场景下能发挥出不同的优势。而且目前都是Promise与其他两个方案配合使用的,所以不存在你只学会async/await或者generator就可以玩转异步。没准以后又会出现一个新的方案,将已有的这几种方案颠覆呢 ~

在这不断变化、发展的时代,我们前端要放开自己的眼界,拥抱变化,持续学习,才能成长,写出优质的代码😜~


小歪
161 声望5 粉丝

初入前端,在努力学习充实自己,目标成为顶级js技术栈工程师!