[万字详解]JavaScript 中的异步模式及 Promise 使用

Posted GoldenaArcher

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[万字详解]JavaScript 中的异步模式及 Promise 使用相关的知识,希望对你有一定的参考价值。

JavaScript 原生的设计使用的就是单线程模式进行开发,这个与刚开始它被设计出来的目的有关——只参与浏览器中 DOM 节点的操作。如果同时出现多个线程对同一个 DOM 节点进行操作,那么浏览器就无法正确判断应该执行哪一个线程的操作,因此,JavaScript 在实现的时候决定只使用一个线程去执行任务。

单线程就代表最大能够执行的任务只有一个,所有的任务都会按照队列的模式进行排队。它的缺点就在于,如果一个任务特别的耗时,就会造成页面的阻塞,而在 JavaScript 运行的时候,html 是不会进行渲染的。

为了解决这个问题,JavaScript 又提出了同步模式和异步模式的解决方案。

同步模式与异步模式

同步模式

同步模式指的就是,代码执行的顺序按照代码实现的顺序执行,如下列代码会按照顺序执行:

function getString() {
  return 'hello world';
}

function print() {
  const str = getString();
  console.log(str);
}

print(); // hello world

输出结果为: hello world

其原理就是将执行代码一个个推入 调用栈 中,因为 栈 是一个 LIFO(后进先出) 的数据结构,所以它的调用步骤大致如下:

  1. 调用 print 函数后,将 print 推入调用栈中

    print
  2. 调用 getString 函数后,将 getString 推入调用栈中

    getString
    print
  3. getString 返回字符串,调用结束,getString 从调用栈中弹出

    print
  4. print 在命令行输出 hello world,print 调用结束,从调用栈中弹出,调用栈为空

注*:函数的声明不会将函数推入调用栈,只有 调用 函数才会

异步模式

与会产生阻塞的同步模式不同,异步模式不会阻塞的原因就在于,在异步模式中,它会调用对应的函数后,继续运行下一个任务,而后续的逻辑会通过回调函数去继续执行。

如:

// 异步函数
setTimeout(function () {
  console.log('in timeout');
}, 2000);

// 同步函数
console.log('after timeout');

// after timeout
// in timeout

比起同步模式来说,异步模式的结构更加复杂一些,除了 调用栈 之外,它还有 消息队列事件循环 这两个额外的机制。

这里回调函数,也就是匿名函数 function() {} 之外的函数都会在同步模式下进行,所以执行的顺序是:

  1. 调用 setTimeout,将 setTimeout 推入 调用栈
  2. 将 setTimeout 中的 Web API 会负责在一旁倒计时
  3. setTimeout 执行完毕,从调用栈中弹出
  4. 执行下一步输出
  5. 大约两秒钟后,Web API 中的等待时间到了,它会将 setTimeout 中的匿名函数推进消息队列中
  6. 事件循环 会监听消息队列中的变化,当调用栈为空时,而 消息队列中 的函数完成执行后,事件循环会将 消息队列中 中完成的函数推入执行栈中,这个情况下也就是匿名函数
  7. 匿名函数执行完毕,被推出消息栈

图解如下:

这里的流程看起来是有顺序的,但是 事件循环 监听 消息队列 的过程是随机的,一旦调用栈空了,而 消息队列 中又有合适的函数可以被推进调用栈,消息队列 就会执行操作。

也因此,异步模式最大的问题就在于执行顺序的混乱,这个部分的解决问题除了多看,就没有其他的办法了。

*注:JavaScript 的执行过程是单线程的,但是这并不代表 浏览器/运行时 并不是单线程的,例如说调用的 setTimeout,会有专门的线程去处理,在执行完毕后将其放入 消息队列 中。

回调函数

在了解了异步操作后,再来设想一下业务场景:有几个 ajax 需要调用,并且彼此之间数据有依赖关系

将对应的 ajax 调用封装一下之后,传统意义上的回调函数执行就变成了这样:

function fetchData(callback) {
  fetchData2((err, data) => {
    if (err) return handleRejected2;
    fetchData3((err, data) => {
      if (err) return handleRejected3;
      fetchData4((err, data) => {
        // ...
      });
    });
  });
}

从可读性上来说非常的差,这种也被称之为 回调地狱(callback hell),从 ES6 之后,ECMAScript 提出了一个能够解决回调地狱的方法,将函数扁平化:Promise。

Promise 异步方案

Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由 CommonJS 社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了 Promise 对象。

Promise 是一个对象,用来表述一个异步任务在执行结束之后返回的结果,它有 3 个状态:pending, fulfilled, rejected,其执行流程如下:

成功
失败
pending
fulfilled
rejected

在 pending 状态中,表示这个 promise 中的异步函数还未执行完毕,此时存在一个不确定性。而当 promise 中的异步函数执行完毕之后,它只会走向两个结果:

  • fulfilled,表示成功
  • rejected,表示失败

一旦最终状态从 pending 改为 fulfilled/rejected 后,状态就再也不可变了。

总结一下,Promise 对象有以下两个特点。

  1. 对象状态不受外界影响——一旦被唤起之后,函数就交由 web api 去处理,在函数主体中对它执行操作也没有用

  2. 一旦状态从 pending 到 resolved 之后,它的状态就不会再被改变,再去同伙 promise 去调用,也只会获得同样的结果

Promise 的基本用法

下面的代码会创造一个 Promise 的实例:

const promise = new Promise(function (resolve, reject) {
  if (successful) {
    resolve(value);
  } else {
    rejectet(error);
  }
});

在这个案例中,Promise 构造函数接收一个函数作为参数,该函数分别又接受两个参数,为 resolvereject,其具体实现过程由 JavaScript 负责,不用自行实现。

resolve 的作用是将 Promise 对象的状态从 pending 变为 resolved 在异步操作成功时调用,并将成功调用的结果作为值返回。。

reject 的作用是将 Promise 对象的状态从 pending 变为 rejected,在异步操作失败时调用,并将失败的报错信息作为值返回。

注:reject 是一个可选参数,并不是必要参数。

具体案例使用如下:

// 语法
// promise.then(onFulfilled[, onRejected]);
promise.then(function(value) {
    // success
} function(error) {
    // failure
})

大概的语法都已经了解了,接下来就开始施展过程,看一下 Promise 具体是怎么使用的了。

Promise 的使用案例

这里的使用案例会通过 fs 异步地调用本地的文件——promise 毕竟主要用来处理异步调用函数,如果文件存在,则在控制台上输出文件的内容;反之就会抛出异常。

以下是逻辑部分实现代码:

// Include fs module
const fs = require('fs');

const readFile = (filename) => {
  // 返回一个 promise 以供 then去调用
  const promise = new Promise(function (resolve, reject) {
    // 使用 readFile 去异步地读取文件,异步调用也是 promise 函数的意义
    // 注意这个函数是 错误优先 的逻辑
    fs.readFile(filename, (err, data) => {
      // 如果文件读取失败,就调取 reject 去消化异常
      if (err) {
        reject(err);
      } else {
        // 如果成功,就调取 resolve 去返回调用成功的数据
        resolve(data);
      }
    });
  });
  return promise;
};

然后是测试代码:

// test.txt 存在
const existedFile = readFile('./test.txt');
existedFile.then(
  (data) => {
    console.log('content: ', Buffer.from(data).toString());
  },
  (error) => {
    console.log(error);
  }
);

// fail.txt 不存在
const failFile = readFile('./fail.txt');
failFile.then(
  (data) => {
    console.log(Buffer.from(data).toString());
  },
  (err) => {
    console.log(err);
  }
);

console.log('successful will be printed first');

输出结果:

promise-dir>node promise.js
successful will be printed first
[Error: ENOENT: no such file or directory, open 'D:\\test-folder\\fail.txt'] {
  errno: -4058,
  code: 'ENOENT',
  syscall: 'open',
  path: 'D:\\\\test-folder\\\\fail.txt'
}
content:  This is a test file.

AJAX 的调用也可以用同样的逻辑进行调用。

Promise 的常见误区

Promise 的本质也还是通过回调函数去进行逻辑的处理,只不过这里的回调函数是通过 then 函数去调用的。依旧以上面的函数为例,这里新建两个文件 file1.txt 和 file2.txt,其内容如下,分别包含彼此的文件名。

业务逻辑处理部分不变,下层的调用稍微变下:

readFile('file1.txt').then((filename) => {
  console.log('ln42', Buffer.from(filename).toString());
  readFile(filename).then((filename) => {
    console.log('ln44', Buffer.from(filename).toString());
    readFile(filename).then((filename) => {
      console.log('ln46', Buffer.from(filename).toString());
      readFile(filename).then((filename) => {
        console.log('ln48', Buffer.from(filename).toString());
        // 继续嵌套调用
      });
    });
  });
});

这里的文件可以正常调用,但是依旧陷入了回调地狱的深层嵌套。而且,如果是这么使用 Promise 的话,并没有让情况变得简单,反而因为在 readFile 外部多嵌套了一层 Promise,让业务实现变的更加复杂。

这种循环调用就是 Promise 使用时的常见误区,我写第一个项目的时候也踩过这个坑,觉得写起来好麻烦,完全不能理解为什么要有 Promise 而不是直接使用 AJAX 调用。

Promise 的链式调用

回到代码之中,依旧使用同样的基础逻辑代码进行链式调用的实现。与其使用深层嵌套的方式,可以善用 Promise 提供的 then 函数去扁平化代码结构。

具体使用方式如下:

let readFileResult = readFile('file1.txt');

let readFileResult2 = readFileResult.then((filename) => {});

console.log(readFileResult2 instanceof Promise); // true

console.log(readFileResult == readFileResult2); // false

由此可见,then 函数可以返回一个新的 Promise,这也就意味着这里可以用函数去接收一个 then 返回的值。

而且,Promise 的 then 函数并不会在方法内部返回一个 this,因为 readFileResult == readFileResult2 的值是 false。

通过这样的设计,也就意味着使用 Promise 可以扁平化代码,增强代码的可读性:

readFile('file1.txt')
  .then((filename) => {
    console.log('ln57', Buffer.from(filename).toString()); // ln57 file2.txt
    // 注意这里要return 一个合适的 promise对象 让下一个 promise 去调用
    return readFile(filename);
  })
  .then((filename) => {
    console.log('ln62', Buffer.from(filename).toString()); // ln62 file1.txt
    return readFile(filename);
  })
  .then((filename) => {
    console.log('ln66', Buffer.from(filename).toString()); // ln66 file2.txt
    return readFile(filename);
  })
  .then((filename) => {
    console.log('ln70', Buffer.from(filename).toString()); // ln70 file1.txt
    return readFile(filename);
  })
  .then((filename) => {
    console.log('ln74', Buffer.from(filename).toString()); // ln74 file2.txt
    // 注意这里 return 的是 filename,而不是一个 promise
    return filename;
  })
  .then((filename) => {
    console.log(filename instanceof Promise); // false
    console.log(filename); // <Buffer 66 69 6c 65 32 2e 74 78 74>
    // 注意下一行的输出并不是 filename1.txt
    console.log('ln82', Buffer.from(filename).toString()); // ln2 file2.txt
    // 注意这里没有返回
  })
  .then((filename) => {
    console.log('ln86', filename); // ln86 undefined
  });

从层级结构上来说,链式调用能够更好的扁平化结构,使得调用所处位置一眼可见。

链式调用中,调用完成的返回值

另外,最后几个 then 函数中的返回值,以及 log 日志输出的变化。