JavaScript 异步开发攻略

向作者提问
大家好,我叫翟路佳,花名“肉山”,这个名字跟 Dota 没关系,从高中起伴随我到现在。 我热爱编程,喜欢学习,喜欢分享,从业十余年,投入的比较多,学习积累到的也比较多,对前端方方面面都有所了解,希望能与大家分享。 我兴趣爱好比较广泛,尤其喜欢旅游,欢迎大家相互交流。 你可以在这里找到我: 博客:http://blog.meathill.com GitHub:https://github.com/meathill 微博:http://weibo.com/meathill
查看本场Chat

前言

为解决异步函数的回调陷阱,开发社区不断摸索,终于折腾出 Promise/A+。它的优势非常显著:

  1. 不增加新的语法,可以立刻适配几乎所有浏览器。

  2. 以队列的形式组织代码,易读好改。

  3. 捕获异常方案也基本可用。

这套方案在迭代中逐步完善,最终被吸收进 ES2015。不仅如此,ES2017 中还增加了 Await/Async,可以用顺序的方式书写异步代码,甚至可以正常抛出捕获错误,维护同一个栈。可以说彻底解决了异步回调的问题。

现在大部分浏览器和 Node.js 都已原生支持 Promise,很多类库也开始返回 Promise 对象,更有各种降级适配策略。Node.js 7+ 则实装了 Await/Async。如果您现在还不会使用,那么我建议您尽快学习一下。本场 Chat 我准备结合近期的开发经验,全面介绍 现代化的 JavaScript 异步开发。

目标读者要求

  1. 前端水平:初级、中级。

  2. 了解 JavaScript。

  3. 最好有异步开发经验,希望写出更好的代码。

名词及约定

  • ES6 = ES2015

  • ES7 = ES2016 + ES2017

  • 异步函数 = Async Functions = Await/Async

范例代码会使用 ES6 的语法,也会混用 ES6 Module 和 CommonJS,请大家不要见怪。我会在代码当中加注释,其中会有一些关键内容,请大家不要忽略。

本文中所有代码均以 Node.js 7.0 为基础。

作者介绍

大家好,我叫翟路佳,花名“肉山”,这个名字跟 Dota 没关系,从高中起伴随我到现在。

我热爱编程,喜欢学习,喜欢分享,从业十余年,投入的比较多,学习积累到的也比较多,对前端方方面面都有所了解,希望能与大家分享。

我兴趣爱好比较广泛,尤其喜欢旅游,欢迎大家相互交流。

你可以在这里找到我:

反馈

如果您对于文中的内容有任何疑问,请在评论中告诉我。亦可发邮件给我:meathill[at]gmail.com。谢谢。

异步的问题

之所以会出现这样那样的解决方案,我之所以写这样的文章介绍这些解决方案,肯定是异步本身有问题。

是的,异步就是那样让人难以割舍,又那样让人不易亲近。

异步的起源

故事必须从头说起,在很久很久以前……

为校验表单,JavaScript 诞生了

在那个拨号上网的洪荒年代,浏览器还非常初级,与服务器进行数据交互的唯一方式就是提交表单。用户填写完成之后,交给服务器处理,如果内容合规当然好,如果不合规就麻烦了,必须打回来重填。那会儿网速还是论 Kb 的,比如我刚上网那会儿开始升级到 33.6Kb,主流还是 22.4Kb……

所以很容易想象:当用户填完100+选项,按下提交按钮,等待几十秒甚至几分钟之后,反馈回来的信息却是:“您的用户名不能包含大写字母”,他会有多么的崩溃多么的想杀人。为了提升用户体验,网景公司的布兰登·艾克大约用10天时间,开发出 JavaScript 的原型,从此,这门注定改变世界的语言就诞生了。

只是当时大家都还没有认识到这一点,发明它的目的,只是为校验表单。

JavaScript 中存在大量异步计算

同样为了提升用户体验,HTML DOM 也选择了边加载、边生成、边渲染的策略。再加上要等待用户操作,大量交互都以事件来驱动。于是,JavaScript 里很早就存在着大量的异步计算。

这也带来一个好处,作为一门 UI 语言,异步操作帮 JavaScript 避免了页面冻结。

为什么异步操作可以避免界面冻结呢?

同步的利弊

假设你去到一家饭店,自己找座坐下了,然后招呼服务员拿菜单来。

服务员说:“对不起,我是‘同步’服务员,我要服务完这张桌子才能招呼你。”

那一桌人明明已经吃上了,你只是想要菜单,这么小的一个动作,服务员却要你等待别人的一个大动作完成。你是不是很想抽ta?

这就是“同步”的问题:顺序交付的工作1234,必须按照1234的顺序完成。

不过“同步”也有“同步”的好处:逻辑非常简单。你不用担心每步操作会消耗多少时间,反正每一步操作都会在上一步完成之后才进行,只管往后写就是了。

异步的利弊

与之相反,异步,则是将耗时很长的 A 交付的工作交给系统之后,就去继续做 B 交付的工作。等到系统完成之后,再通过回调或者事件,继续做 A 剩下的工作。

从观察者的角度,看起来 AB 工作的完成顺序,和交付他们的时间顺序无关,所以叫“异步”。

那些需要大量计算(比如 Service Worker),或者复杂查询(比如 Ajax)的工作,JS 引擎把它们交给系统之后,就立刻返回继续待机了,于是再进行什么操作,浏览器也能第一时间响应,这让用户的感觉非常好。

有利必有弊,异步的缺点就是:必须通过特殊的语法才能实现,而这些语法就不如同步那样简单、清晰、明了。

异步计算的实现

异步计算有两种常见的实现形式。

事件侦听

这种形式在浏览器里比较常见,比如,我们可以对一个 <button> 的用户点击行为增加侦听,在点击事件触发后调用函数进行处理。

document.getElementById('#button').addEventListener('click', function (event) {
  // do something
}, false);

也可以使用 DOM 节点的 onclick 属性绑定侦听函数:

document.getElementById('#button').onclick = function (event) {
  // do something
}

回调

到了 Node.js(以及其它 Hybrid 环境),由于要和引擎外部的环境进行交互,大部分操作都变成回调。比如用 fs.readFile() 读取文件内容:

const fs = require('fs');

fs.readFile('path/to/file.txt', 'utf8', (err, content) => {
  if (err) {
    throw err;
  }
  console.log(content);
});

如果你不熟悉 Node.js 也没关系,jQuery 里也有类似的操作,最常见的就是侦听页面加载状态,加载完成后启动回调函数:

$(function () {
  // 绑定事件
  // 创建组件
  // 以及其它操作
});

回调陷阱

这个问题其实是最直观的问题,也是大家谈的最多的问题。比如下面这段代码:

a(function (resultA) {
  b(resultA, function (resultB) {
    c(resultB, function (resultC) {
      d(resultC, function (resultD) {
        e(resultD, function (resultE) {
          f(resultE, function (resultF) {
            // 子子孙孙无穷尽也
            console.log(resultF);
          });
        });
      });
    });
  });
});

嵌套层次之深令人发指。这种代码很难维护,有人称之为“回调地狱”,有人称之为“回调陷阱”,还有人称之为“回调金字塔”,其实都无所谓,带来的问题很明显:

  1. 难以维护。 上面这段只是为演示写的示范代码,还算好懂;实际开发中,混杂了业务逻辑的代码更多更长,更难判定函数范围,再加上闭包导致的变量使用,那真的难以维护。

  2. 难以复用。 回调的顺序确定下来之后,想对其中的某些环节进行复用也很困难,牵一发而动全局,可能只有全靠手写,结果就会越搞越长。

更严重的问题

面试的时候,问到回调的问题,如果候选人只能答出“回调地狱,难以维护”,在我这里顶多算不功不过,不加分。要想得到满分必须能答出更深层次的问题。

为了说明这些问题,我们先来看一段代码。假设有这样一个需求:

遍历目录,找出最大的一个文件。

// 这段代码来自于 https://medium.com/@wavded/managing-node-js-callback-hell-1fe03ba8baf 我加入了一些自己的理解
/**
 * @param dir 目标文件夹
 * @param callback 完成后的回调
 */
function findLargest(dir, callback) {
  fs.readdir(dir, function (err, files) {  // [1]
    if (err) return callback(err); // {1}
    let count = files.length; // {2}
    let errored = false; // {2}
    let stats = []; // {2}
    files.forEach( file => { // [2]
      fs.stat(path.join(dir, file), (err, stat) => { // [3]
        if (errored) return; // {1}
        if (err) {
          errored = true;
          return callback(err);
        }
        stats.push(stat); // [4] {2}

        if (--count === 0) { // [5] {2}
          let largest = stats
            .filter(function (stat) { return stat.isFile(); })
            .reduce(function (prev, next) {
              if (prev.size > next.size) return prev;
              return next;
            });
          callback(null, files[stats.indexOf(largest)]); // [6]
        }
      });
    });
  });
}

findLargest('./path/to/dir', function (err, filename) { // [7]
  if (err) return console.error(err);
  console.log('largest file was:', filename);
});

这里我声明了一个函数 findLargest(),用来查找某一个目录下体积最大的文件。它的工作流程如下(参见代码中的标记“[n]”):

灌汤包
看不明白
Raven: 说明你基础太差了 赶紧先去恶补下
KING
好文,而且是长文,promise讲解中用到的读取文件的例子非常棒,但需要有一些node和promise基础; 同时由于主要讲promise的最佳用法,感觉有些东西没能细致的讲理论和原理,比如thenable是啥,这些还是得自己去琢磨和研究。
微信扫描登录