[万字详解]JavaScript 中的异步模式及 Promise 使用
Posted GoldenaArcher
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[万字详解]JavaScript 中的异步模式及 Promise 使用相关的知识,希望对你有一定的参考价值。
[万字详解]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(后进先出) 的数据结构,所以它的调用步骤大致如下:
-
调用 print 函数后,将 print 推入调用栈中
print -
调用 getString 函数后,将 getString 推入调用栈中
getString print -
getString 返回字符串,调用结束,getString 从调用栈中弹出
print -
print 在命令行输出 hello world,print 调用结束,从调用栈中弹出,调用栈为空
注*:函数的声明不会将函数推入调用栈,只有 调用 函数才会
异步模式
与会产生阻塞的同步模式不同,异步模式不会阻塞的原因就在于,在异步模式中,它会调用对应的函数后,继续运行下一个任务,而后续的逻辑会通过回调函数去继续执行。
如:
// 异步函数
setTimeout(function () {
console.log('in timeout');
}, 2000);
// 同步函数
console.log('after timeout');
// after timeout
// in timeout
比起同步模式来说,异步模式的结构更加复杂一些,除了 调用栈 之外,它还有 消息队列 和 事件循环 这两个额外的机制。
这里回调函数,也就是匿名函数 function() {}
之外的函数都会在同步模式下进行,所以执行的顺序是:
- 调用 setTimeout,将 setTimeout 推入 调用栈 中
- 将 setTimeout 中的 Web API 会负责在一旁倒计时
- setTimeout 执行完毕,从调用栈中弹出
- 执行下一步输出
- 大约两秒钟后,Web API 中的等待时间到了,它会将 setTimeout 中的匿名函数推进消息队列中
- 事件循环 会监听消息队列中的变化,当调用栈为空时,而 消息队列中 的函数完成执行后,事件循环会将 消息队列中 中完成的函数推入执行栈中,这个情况下也就是匿名函数
- 匿名函数执行完毕,被推出消息栈
图解如下:
这里的流程看起来是有顺序的,但是 事件循环 监听 消息队列 的过程是随机的,一旦调用栈空了,而 消息队列 中又有合适的函数可以被推进调用栈,消息队列 就会执行操作。
也因此,异步模式最大的问题就在于执行顺序的混乱,这个部分的解决问题除了多看,就没有其他的办法了。
*注: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 状态中,表示这个 promise 中的异步函数还未执行完毕,此时存在一个不确定性。而当 promise 中的异步函数执行完毕之后,它只会走向两个结果:
- fulfilled,表示成功
- rejected,表示失败
一旦最终状态从 pending 改为 fulfilled/rejected 后,状态就再也不可变了。
总结一下,Promise 对象有以下两个特点。
-
对象状态不受外界影响——一旦被唤起之后,函数就交由 web api 去处理,在函数主体中对它执行操作也没有用
-
一旦状态从 pending 到 resolved 之后,它的状态就不会再被改变,再去同伙 promise 去调用,也只会获得同样的结果
Promise 的基本用法
下面的代码会创造一个 Promise 的实例:
const promise = new Promise(function (resolve, reject) {
if (successful) {
resolve(value);
} else {
rejectet(error);
}
});
在这个案例中,Promise 构造函数接收一个函数作为参数,该函数分别又接受两个参数,为 resolve
和 reject
,其具体实现过程由 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 日志输出的变化。
-
包含 ln74 的 then 代码
.then((filename) => { console.log('ln74', Buffer.from(filename).toString()); // ln74 file2.txt // 注意这里 return 的是 filename,而不是一个 promise return filename; })
注意这里返回的并不是一个 promise 对象,而只是 filename 这个变量
-
包含 ln82 的 then 代码
.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()); // ln8 file2.txt return filename; });
可以看出这里的的 filename 的类型就不是一个 Promise 了,它是一个 Buffer 对象,而且它的值与上一个 then 函数里的值是相同的——也就是说没有新的 Promise 生成。
-
包含 ln86 的 then 代码
.then((filename) => { console.log('ln86', filename)以上是关于[万字详解]JavaScript 中的异步模式及 Promise 使用的主要内容,如果未能解决你的问题,请参考以下文章