07.异步不可怕「死记硬背」+ 实践拿下(1)

异步是前端开发的一个重点内容,也是难点之一。JavaScript 语言在各个历史阶段,为更优雅地实现异步,进行过多种尝试。但是由于异步天生的「复杂度」,使得开发者并不能够轻松地吃透理论并上手实践。

理论方面, 我们知道 JavaScript 是单线程的,那它又是如何实现异步的呢?在这个环节中,浏览器或 NodeJS 又起到了什么样的作用?什么是宏任务,什么是微任务?

实践上,从 callback 到 promise,从 generator 到 async/await,到底应该如何更优雅地实现异步操作?

本课让我们来一探究竟。
相关知识点如下:


图片

整个内容分为两节课:第一节课,实践上,我们从需求开始慢慢打磨,让我们都能成为运筹帷幄的「老司机」;第二节课,从多种例题入手,分析语言和浏览器行为。

另外,由于这门课程的目标是「进阶」,这里不会再单独介绍类似 Promise 的使用,async/await 等基本概念和知识点请大家提前做好相关知识储备。

异步流程初体验
让我们先从一个需求开始,来实现一个「运动路径动画」流程:

移动页面上元素 target(document.querySelectorAll('#man')[0])

先从原点出发,向左移动 20px,之后再向上移动 50px,最后再次向左移动 30px,请把运动动画实现出来。

我们将移动的过程封装成一个 walk 函数,该函数要接受以下三个参数。

  • direction:字符串,表示移动方向,这里简化为「left」、「top」两种枚举
  • distance:整型,可正或可负
  • callback:动作执行后回调

direction 表示移动方向,distance 表示移动距离。通过 distance 的正负值,我们可以实现四个方向的移动。

回调方案

因为每一个任务都是相互联系的:当前任务结束之后,将会马上进入下一个流程,如何将这些流程串联起来呢?我们采用最简单的 callback 实现,明确指示下一个任务。

const target = document.querySelectorAll('#man')[0];
target.style.cssText = `
position: absolute;
left: 0px;
top: 0px
`;

const walk = (direction, distance, callback) => {
  setTimeout(() => {
    let currentLeft = parseInt(target.style.left, 10);
    let currentTop = parseInt(target.style.top, 10);

    const shouldFinish =
      (direction === 'left' && currentLeft === -distance) ||
      (direction === 'top' && currentTop === -distance);

    if (shouldFinish) {
      // 任务执行结束,执行下一个回调
      callback && callback();
    } else {
      if (direction === 'left') {
        currentLeft--;
        target.style.left = `${currentLeft}px`;
      } else if (direction === 'top') {
        currentTop--;
        target.style.top = `${currentTop}px`;
      }

      walk(direction, distance, callback);
    }
  }, 20);
};

walk('left', 20, () => {
  walk('top', 50, () => {
    walk('left', 30, Function.prototype);
  });
});

有几点需要大家注意。

  • 为了简化问题,我们将目标元素的定位进行了初始化设定:
position: absolute;
left: 0px;
top: 0px

且不再考虑边界 case(如移除屏幕外等)。

  • 为了能够展现出动画,我们将 walk 函数的执行逻辑包裹在 20 毫秒的定时器当中,每次执行一像素的运动时,都会有一个停留定格。

这样的实现完全面向过程,代码比较「丑」,读者只需体会使用回调来解决异步任务的处理方案。也要发现:

walk('left', 20, () => {
   walk('top', 50, () => {
       walk('left', 30, Function.prototype)
   })
})

这样的回调嵌套很不优雅,有几次位移任务,就会嵌套几层,是名副其实的回调地狱。

Promise 方案

我们再来看一下如何用 Promise 解决问题:

const target = document.querySelectorAll('#man')[0];
target.style.cssText = `
position: absolute;
left: 500px;
top: 500px
`;

const walk = (direction, distance) =>
  new Promise((resolve, reject) => {
    const innerWalk = () => {
      setTimeout(() => {
        let currentLeft = parseInt(target.style.left, 10);
        let currentTop = parseInt(target.style.top, 10);

        const shouldFinish =
          (direction === 'left' && currentLeft === -distance) ||
          (direction === 'top' && currentTop === -distance);

        if (shouldFinish) {
          // 任务执行结束
          resolve();
        } else {
          if (direction === 'left') {
            currentLeft--;
            target.style.left = `${currentLeft}px`;
          } else if (direction === 'top') {
            currentTop--;
            target.style.top = `${currentTop}px`;
          }

          innerWalk();
        }
      }, 20);
    };
    innerWalk();
  });

walk('left', 20)
  .then(() => walk('top', 50))
  .then(() => walk('left', 30));

几个注意点:

  • walk 函数不再嵌套调用,不再执行 callback,而是函数整体返回一个 promise,以利于后续任务的控制和执行
  • 设置 innerWalk 进行每一像素的递归调用
  • 在当前任务结束时(shouldFinish 为 true),resolve 当前 promise

对比上述实现,我们发现使用 promise 的解决方案明显更加清晰、易读。

generator 方案

ES Next 中生成器其实并不是天生为解决异步而生的,但是它又天生非常适合解决异步问题。用 generator 方案解决异步任务也同样优秀:

const target = document.querySelectorAll('#man')[0];
target.style.cssText = `
position: absolute;
left: 0px;
top: 0px
`;

const walk = (direction, distance) =>
  new Promise((resolve, reject) => {
    const innerWalk = () => {
      setTimeout(() => {
        let currentLeft = parseInt(target.style.left, 10);
        let currentTop = parseInt(target.style.top, 10);

        const shouldFinish =
          (direction === 'left' && currentLeft === -distance) ||
          (direction === 'top' && currentTop === -distance);

        if (shouldFinish) {
          // 任务执行结束
          resolve();
        } else {
          if (direction === 'left') {
            currentLeft--;
            target.style.left = `${currentLeft}px`;
          } else if (direction === 'top') {
            currentTop--;
            target.style.top = `${currentTop}px`;
          }

          innerWalk();
        }
      }, 20);
    };
    innerWalk();
  });

function* taskGenerator() {
  yield walk('left', 20);
  yield walk('top', 50);
  yield walk('left', 30);
}
const gen = taskGenerator();

//我们定义了一个 taskGenerator 生成器函数,并实例化出 gen,手动执行:
gen.next() //将会向左偏移 20 像素。

// 再次手动执行:
gen.next() // 将会向上偏移 50 像素。

// 再次手动执行:
gen.next() //将会向左偏移 30 像素。

整个过程掌控感十足,唯一的不便之处就是需要我们反复手动执行 gen.next()。为此社区上早有方案,kj 大神的 co 库,能够自动包裹 generator 并执行,源码实现并不复杂,推荐给大家阅读。但是在新时代里,作为 generator 的语法糖,async/await 也许将会是「更优雅、更终极」解决方案。

async/await 方案

基于以上基础,改造成 async/await 方案也并不困难。

直接看代码:

const target = document.querySelectorAll('#man')[0];
target.style.cssText = `
position: absolute;
left: 0px;
top: 0px
`;

const walk = (direction, distance) =>
  new Promise((resolve, reject) => {
    const innerWalk = () => {
      setTimeout(() => {
        let currentLeft = parseInt(target.style.left, 10);
        let currentTop = parseInt(target.style.top, 10);

        const shouldFinish =
          (direction === 'left' && currentLeft === -distance) ||
          (direction === 'top' && currentTop === -distance);

        if (shouldFinish) {
          // 任务执行结束
          resolve();
        } else {
          if (direction === 'left') {
            currentLeft--;
            target.style.left = `${currentLeft}px`;
          } else if (direction === 'top') {
            currentTop--;
            target.style.top = `${currentTop}px`;
          }

          innerWalk();
        }
      }, 20);
    };
    innerWalk();
  });

const task = async function () {
  await walk('left', 20);
  await walk('top', 50);
  await walk('left', 30);
};

task() // 只需要直接执行 task() 即可。

通过对比 generator 和 async/await 这两种方式,读者应该准确认识到,async/await 就是 generator 的语法糖,它能够自动执行生成器函数,更加方便地实现异步流程。

红绿灯任务控制

有了前面内容的热身,我们直接趁热打铁,再来看一道比较典型的问题。

红灯 3s 亮一次,绿灯 1s 亮一次,黄灯 2s 亮一次;如何让三个灯不断交替重复亮灯?

三个亮灯函数已经存在:

function red() {
   console.log('red');
}
function green() {
   console.log('green');
}
function yellow() {
   console.log('yellow');
}

这道题其实和开头部分「异步流程初体验」的题目类似,更复杂的地方在于需要「交替重复」亮灯,而不是「移动完了」就结束的一锤子买卖。

请读者对着上述内容,比葫芦画瓢试着实现。

还是从最简单、最容易理解的 callback 方案入手:

const task = (timer, light, callback) => {
   setTimeout(() => {
       if (light === 'red') {
           red()
       }
       else if (light === 'green') {
           green()
       }
       else if (light === 'yellow') {
           yellow()
       }
       callback()
   }, timer)
}


task(3000, 'red', () => {
   task(1000, 'green', () => {
       task(2000, 'yellow', Function.prototype)
   })
})

上述代码有可优化空间,这里为了大家方便理解,我不再进行简化。同时存在一个明显的 bug:代码只是完成了一次流程,执行后红黄绿灯分别只亮一次。该如何让它交替重复进行呢?

我们上面提到过递归,那么该递归谁呢?当然是递归亮灯的一个周期:

const step = () => {
   task(3000, 'red', () => {
       task(1000, 'green', () => {
           task(2000, 'yellow', step)
       })
   })
}

step()

注意看黄灯亮的回调里,我们又再次调用了 step 方法 以完成循环亮灯。

用 promise 实现:

const task = (timer, light) =>
   new Promise((resolve, reject) => {
       setTimeout(() => {
           if (light === 'red') {
               red()
           }
           else if (light === 'green') {
               green()
           }
           else if (light === 'yellow') {
               yellow()
           }
           resolve()
       }, timer)
   })

const step = () => {
   task(3000, 'red')
       .then(() => task(1000, 'green'))
       .then(() => task(2000, 'yellow'))
       .then(step)
}

step()

我们将回调移除,在一次亮灯结束后,resolve 当前 promise,并依然使用递归进行。

同时给出 async/await 的实现,供大家参考:

const taskRunner =  async () => {
   await task(3000, 'red')
   await task(1000, 'green')
   await task(2000, 'yellow')
   taskRunner()
}

taskRunner()

毫无疑问,还是 async/await 的方案更加舒服。

可见,熟悉 Promise 是基础,是理解 async/await 的必要知识,学习 async/await 代表了学习「最先进的生产力」。我曾经的文章:ES6 Async/Await 完爆 Promise 的 6 个原因,对比过 async/await 的优秀之处,感兴趣的读者可以进行了解

当然再次重申:async/await 是语法糖,它更有魅力,但是学习 promise 是消化这颗糖的前提。

复杂的真实场景案例

下面将一步一步制造一个较为复杂的场景,贴近真实环境,在实战中将异步操作用到极致。

请求图片进行预先加载
假设预先有 urlIds 数组,数组的每一项都可以按照规则拼接成一个完整的图片地址。根据这个数组,依次请求图片进行预加载。

这个比较简单,我们先实现一个请求图片的方法:

const loadImg = urlId => {
   const url = `https://www.image.com/${urlId}`

   return new Promise((resolve, reject) => {
       const img = new Image()
       img.onerror = function() {
           reject(urlId)
       }

       img.onload = function() {
           resolve(urlId)
       }
       img.src = url
   })
}

该方法进行 promise 化(promisify),在图片成功加载时进行 resolve,加载失败时 reject。

依次请求图片:

const urlIds = [1, 2, 3, 4, 5]
urlIds.reduce((prevPromise, urlId) => {
   return prevPromise.then(() => loadImg(urlId))
}, Promise.resolve())

我们使用了数组 reduce 方法,当然也可以面向过程实现:

const loadImgOneByOne = index => {
   const length = urlIds.length

   loadImg(urlIds[index]).then(() => {
       if (index === length - 1) {
           return
       }
       else {
           loadImgOneByOne(++index)
       }
   })

}
loadImgOneByOne(0)

当然也可以采用 async/await 实现:

const loadImgOneByOne = async () => {
   for (i of urlIds) {
       await loadImg(urlIds[i])
   }
}
loadImgOneByOne()

上述代码的请求都是依次执行的,只有成功加载完第一张图片,才继续进行下一张图片的加载。

如果要求提高效率,将所有图片的请求一次性发出,该如何做呢?

const urlIds = [1, 2, 3, 4, 5]

const promiseArray = urlIds.map(urlId => loadImg(urlId))

Promise.all(promiseArray)
   .then(() => {
       console.log('finish load all')
   })
   .catch(() => {
       console.log('promise all catch')
   })

继续提出需求,我们希望控制最大并发数为 3,最多 3 个请求一起发出,剩下 2 个一起发出,这就需要我们实现一个 loadByLimit 方法,实现可以考虑使用 Promise.race API:

const loadByLimit = (urlIds, loadImg, limit) => {
const urlIdsCopy = […urlIds]

if (urlIdsCopy.length <= limit) {
 // 如果数组长度小于最大并发数,直接全部请求
 const promiseArray = urlIds.map(urlId => loadImg(urlId))
    return Promise.all(promiseArray)
}

// 注意 splice 方法会改变 urlIdsCopy 数组
const promiseArray = urlIdsCopy.splice(0, limit).map(urlId => loadImg(urlId))

urlIdsCopy.reduce(
 (prevPromise, urlId) =>
  prevPromise
   .then(() => Promise.race(promiseArray))
   .catch(error => {console.log(error)})
   .then(resolvedId => {
    // 将 resolvedId 剔除出 promiseArray 数组
    // 这里的删除只是伪代码,具体删除情况要看后端 Api 返回结果
    let resolvedIdPostion = promiseArray.findIndex(id => resolvedId === id)
    promiseArray.splice(resolvedIdPostion, 1)
    promiseArray.push(loadImg(urlId))
   })
 ,
 Promise.resolve()
)
.then(() => Promise.all(promiseArray))
}

代码解读:Promise.race 接受一个 promise 数组,并返回这个数组中第一个 resolve 的 promise 的返回值。在有 Promise.race 返回后,我们不断地将已经 resolve 的 promise 从 promise 数组(promiseArray)中剔除,再添加进新的 promise 进入 promiseArray,重复执行,始终保持当前并发请求数小于等于 limit 值。

到此为止,我们已经掌握了比较基本的操作。再来看一个更加复杂的问题,这个问题出自阿里某部门 P7+ 的面试题。

改编自阿里某部门的面试题

这道题目我改编自阿里某(最核心)部门的面试题,并进行了一定程度的简化,希望大家不要刻意外传。

假设现在后端有一个服务,支持批量返回书籍信息,它接受一个数组作为请求数据,数组储存了需要获取书目信息的书目 id,这个服务 fetchBooksInfo 大概是这个样子:

const fetchBooksInfo = bookIdList => {
   // ...
   return ([{

           id: 123,
           // ...

       },
       {
           id: 456
           // ...
       },
       // ...
   ])
}

fetchBooksInfo 已经给出,但是这个接口最多只支持 100 个 id 的查询。

现在需要开发者实现 getBooksInfo 方法,该方法:

  • 支持调用单个书目信息:
getBooksInfo(123).then(data => {console.log(data.id)}) // 123
  • 短时间(100 毫秒)内多次连续调用,只发送一个请求,且获得各个书目信息:
getBooksInfo(123).then(data => {console.log(data.id)}) // 123
getBooksInfo(456).then(data => {console.log(data.id)}) // 456

注意这里必须只发送一个请求,也就是说调用了一次 fetchBooksInfo。

  • 要考虑服务端出错的情况,比如批量接口请求 [123, 446] 书目信息,但是服务端只返回了书目 123 的信息。此时应该进行合理的错误处理。

  • 对 id 重复进行处理

我们来将思路清理一下:

  • 100 毫秒内的连续请求,要求进行合并,只触发一次网络请求。因此需要一个 bookIdListToFetch 数组,并设置 100 毫秒的定时。在 100 毫秒以内,将所有的书目 id push 到 bookIdListToFetch 中,bookIdListToFetch 长度为 100 时,进行 clearTimeout,并调用 fetchBooksInfo 发送请求

  • 因为服务端可能出错,返回的批量接口结果可能缺少某个书目信息。我们需要对相关的调用进行抛错,比如 100 毫秒内连续调用:

getBooksInfo(123).then(data => {console.log(data.id)}) // 123
getBooksInfo(456).then(data => {console.log(data.id)}) // 456

我们要归并只调用一次 fetchBooksInfo:

fetchBooksInfo(123, 456)

如果返回有问题,只返回了:

[{
   id: 123
   //...
}]

没有返回 id 为 456 的书信息,需要:

getBooksInfo(456).then(data => {console.log(data.id)}).catch(error => {
   console.log(error)
})

捕获错误。

这样一来,我们要对每一个 getBooksInfo 对应的 promise 实例的 reject 和 resolve 方法进行存储,存储在内存 promiseMap 中,以便在合适的时机进行 reject 或 resolve 对应的 promise 实例。

请看代码(对边界 case 的处理省略),我加入了关键注释:

// 储存将要请求的 id 数组
let bookIdListToFetch = []

// 储存每个 id 请求 promise 实例的 resolve 和 reject
// key 为 bookId,value 为 resolve 和 reject 方法,如:
// { 123: [{resolve, reject}]}
// 这里之所以使用数组存储 {resolve, reject},是因为可能存在重复请求同一个 bookId 的情况。其实这里我们进行了滤重,没有必要用数组。在需要支持重复的场景下,记得要用数组存储
let promiseMap = {}

// 用于数组去重
const getUniqueArray = array => Array.from(new Set(array))

// 定时器 id
let timer

const getBooksInfo = bookId => new promise((resolve, reject) => {
   promiseMap[bookId] = promiseMap[bookId] || []
   promiseMap[bookId].push({
       resolve,
       reject
   })

   const clearTask = () => {
       // 清空任务和存储
       bookIdListToFetch = []
       promiseMap = {}
   }

   if (bookIdListToFetch.length === 0) {
       bookIdListToFetch.push(bookId)

       timer = setTimeout(() => {
           handleFetch(bookIdListToFetch, promiseMap)

           clearTask()
       }, 100)
   }
   else {
       bookIdListToFetch.push(bookId)

       bookIdListToFetch = getUniqueArray(bookIdListToFetch)

       if (bookIdListToFetch.length >= 100) {
           clearTimeout(timer)

           handleFetch(bookIdListToFetch, promiseMap)

           clearTask()
       }
   }
})

const handleFetch = (list, map) => {
   fetchBooksInfo(list).then(resultArray => {
       const resultIdArray = resultArray.map(item => item.id)

       // 处理存在的 bookId
       resultArray.forEach(data => promiseMap[data.id].forEach(item => {
           item.resolve(data)
       }))

       // 处理失败没拿到的 bookId
       let rejectIdArray = []
       bookIdListToFetch.forEach(id => {
           // 返回的数组中,不含有某项 bookId,表示请求失败
           if (!resultIdArray.includes(id)) {
               rejectIdArray.push(id)
           }
       })

       // 对请求失败的数组进行 reject
       rejectIdArray.forEach(id => promiseMap[id].forEach(item => {
           item.reject()
       }))
   }, error => {
       console.log(error)
   })
}

做出这道题的关键是:

  • 准确理解题意,因为这个题目完全贴近实际场景需求,准确把控出题者的意图是第一步
  • 对 Promise 熟练掌握
  • 进行 setTimeout 合并 100 毫秒内的请求
  • 存储每个 bookId 的请求 promise 实例,存储该 promise 实例的 resolve 和 reject 方法,以便在批量数据返回时进行对应处理
  • 错误处理

总结

异步任务的处理,因其重要性,始终在前端开发中是一个不可忽视的考察点;又因其复杂性而考点灵活多变。需要开发者熟悉各种异步方案,同时每一种异步方案都是相辅相成的。如果你没有完全理解 callback,那你也许就很难理解 promise;如果 promise 没有熟练掌握,那么 generator 和 async/await 更无从谈起。

异步很多场景都涉及到网络、涉及到高风险计算,但本节还没有涉及到异步中错误处理这个重要内容,这方面的信息,我们会在后续课程《你以为我真的要你写一个 Promise 吗?》中进行穿插。

异步的整个学习过程需要我们从最基础开始,步步为营。如果一次理解不了,那就两次、三次。相信我,这一定是一个吃经验,吃重复次数的「水滴石穿」过程。

阅读原文

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,864评论 6 494
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,175评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,401评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,170评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,276评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,364评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,401评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,179评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,604评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,902评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,070评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,751评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,380评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,077评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,312评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,924评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,957评论 2 351

推荐阅读更多精彩内容