异步,有一个从丑小鸭变成白天鹅的故事

JS中异步存在的必要性

丑小鸭在孤独和凌乱的时候常常在想:“我是从哪里来?为什么我和别鸭(同步任务)不一样?”

从童话世界跳出来,我们也需要知道,为什么会有异步,为什么会有那么多的异步方法需要我们去学习? 首先我们都知道JS是单线程的,这就意味着同一时刻只能做一件事。所有的任务都要排队依次等待执行。在JS中有很多比较耗时的任务,最为典型的就是ajax请求。同步就意味着不管当前的任务要执行多久,后面的任务都只能等待它有响应,执行完成。这样的机制很浪费时间和性能。

为了解决上述的问题,JS的设计者改变了下方案。我们可以先让这些执行时间漫长,需要等待的任务在主线程中挂起,由任务对应的线程(事件触发线程、定时触发器线程、异步http请求线程等)先处理着,主线程先去执行后面的任务,等到这些被挂起的任务,有了结果后,所属的线程再回过头来通知主线程这些任务可以执行了。

这也正是异步的起初与概念的根本。那么处理异步任务的线程,怎么与主线程之间建立连接,告诉主线程可以开始执行这个异步任务了呢?

这件事情只有一等公民函数可以完成,处理异步任务的线程将异步任务的回调函数放入任务队列中去排队,等待主线程去获取并执行。

回调函数就是实现异步编程起初与根本。它解决了同步的阻塞问题,但同时也带来了很多其他的问题。

下面我们详尽的讲述一下由回调函数衍生而来的异步发展史。

异步发展历程概述

异步发展的历程好比丑小鸭变白天鹅的故事。它的几个阶段想必你已经滚瓜烂熟:回调函数、promise、生成器函数(generator)& co、async/await。

为了避免俗套,我们不去赘述上述四个历程的使用。而是去分别比对一下每一个阶段究竟比上一个阶段美在哪里,为什么说是一种进步,又有哪些要注意的坑。

  • 回调函数
    首先什么是回调函数,他又为什么会被嫌弃?
    回调本质上是一种设计模式,一个函数A当做参数传递给函数B,在B的内部去执行函数A。这时候函数A就被称作回调函数。
    从概念我们可以看出,回调函数实现的方式实际就是函数嵌套函数,那么当代码逻辑相对复杂的时候,难免会出现多层嵌套的问题。
    下面我们用一个小球碰撞的例子来说明一下回调函数的问题,为了方便各个历程的对比,这个例子也将贯穿全文。
    假设我们的页面上现在有三个小球,均处于页面的最左端,我们依次将他们移动到页面的指定位置。

    html代码片段:

    <div id="box">
        <div id="ball-1"></div>
        <div id="ball-2"></div>
        <div id="ball-3"></div>
    </div>
    复制代码

    css 代码片段:

    #box div{
      width: 100px;
      height: 100px;
      background: red;
      border-radius: 50%;
      position: relative;
      margin-top: 10px;
      left: 0;
    }
    复制代码

    callback方式实现交互的代码:

    // 小球运动的基本方法
    function move(ele, position) {
      return new Promise((resolve, reject) => {
        let left = 0;
        let timer = setInterval(() => {
          left += 5;
          if (left >= position) {
            clearInterval(timer);
            ele.style.transform = `translateX(${position}px)`;
            resolve();
          } else {
            ele.style.transform = `translateX(${left}px)`;
          }
        }, 15);
      })
    }
    // 回调函数方式实现
    move(ball1,500,function () {
        move(ball2, 500, function () {
            move(ball3, 500, function () {
                console.log('终于挪动完了');
            })
        })
    })
    复制代码

    仅仅是挪动3个小球,我们就要嵌套3层,那要是100个、1000个呢?

    当然这里仅仅是举个例子,这个应用场景因为有规律可循,我们还是可以采用递归进行优化的。但是这个例子已经足以说明回调函数带来的问题——回调地狱。

    层层的嵌套让代码的可读性和可维护性差。每一步的异常捕获也只能在对应的回调函数中进行,很容易被漏掉。

    额!人家真的有那么丑嘛~被你们这么嫌弃~~

    说到这里,我们要感谢promise的到来,它把我们从无底的回调地狱中解救了出来。

  • promise
    哇塞!终于可以变美一点了。

    在promise的世界里,可以通过then的链式调用来解决回调地狱的问题,我们可以把层层的嵌套展平来写,使代码的逻辑更为清晰。

    promise的catch方法轻松的解决了回调函数异常处理难得问题。

    来面镜子吧!我们看看到底有没有变美那么一点点。

    // promise方式实现
    move(ball1,500).then(data=>{
        return move(ball2,500)
    }).then(data=>{
        return move(ball3,200);
    }).then(data=>{
        console.log('终于挪动完了');
    })
    复制代码

    确实是变美了那么一点,但是promise为什么可以实现链式调用呢?它靠的是什么?其实很简单,就是then中返回另一个 promise 即可。这与jquery中链式调用返回this是一个道理。

    又由于promise具有值穿透的特性,所以我们可以在所有then的最后,加上一个catch方法,来捕获前面then执行发生的异常及其错误的情况。其实promise中catch方法本质上就是省略了onFulfilled函数的then方法。

    这里我们不去详细论述promise的原理,后面我会写一篇关于promise实现的文章来重点分析这块。

    我们说promise有哪些缺点呢?我觉得唯一的缺点就是长得太像异步,和同步调用的习惯还是相差有点远。

    这个时候generator悄悄的登场了。

  • generator & co
    generator从定义形式相比于promise更像是同步。执行generator函数它不会立即执行内部的代码,而是返回一个遍历器(指向内部状态的指针对象)。
    我们要想真正的执行函数里面的代码,需要调用遍历器对象的next方法,使得指针移向下一个状态。让函数逐步的执行。换言之,我们可以使用next方法控制函数的执行了。
    通常情况下generator函数需要配合co库来使用。所谓co,就是借助于Promise,让你可以使用更加优雅的方式编写非阻塞代码。
    我们先看下通过generator函数实现小球运动的例子吧!

    // 这里的co是我们自己实现的一个简单的co逻辑
    function co(it) {
      return new Promise((resolve, reject) => {
        function next(data) {
          let { done, value } = it.next(data);
          if (done) return resolve(value);
          value.then(data => {
            next(data);
          }, reject);
        }
        next();
      })
    }
    // 小球动起来
    function* m() {
      yield move(ball1, 500);
      yield move(ball2, 500);
      yield move(ball3, 500);
    }
    
    co(m()).then(data => {
      console.log('终于挪动完了');
    })
    复制代码

    我们发现generator是很接近同步的写法了,但是它需要别人他辅助它来执行,这事就变得不完美了。是不是总感觉差点什么?
    这个时候generator & co 结合的语法糖出现了--async/await。

  • async/await
    async函数是对 Generator函数的改进,实际上就是把Generator自动执行给封装起来,同时返回的是 Promise 对象更便于操作。
    这个封装让js在异步发展的慢慢长路中一下子变得完美了。既在写法上接近了同步的外表,又在功能上实现了异步。终于不用再层层嵌套,不用在需要其他库的辅助。

    async function m() {
      await move(ball1, 500);
      await move(ball2, 500);
      await move(ball3, 500);
    }
    m().then(data=>{
      console.log('终于挪动完了');
    });
    复制代码

    就这样丑小鸭一步一步变成了白天鹅 ! 不禁感叹:“原来我(异步编程)可以这么美。”