努力让学习成为一种习惯,自信来源于充分的准备
如果你觉得该文章对你有帮助,欢迎大家点赞、关注和分享
前言
Promise是JavaScript中用于异步编程的一种机制
本文将会从实际问题出发讲述promise出现的前后因果,并说出我个人认为的理解promise的核心关键(这里不会涉及promise的原理以及过多api的讲解,Promise实现原理会在后续文章从零实现一个)
promise
promise中文解释为:契约、承诺。深呼吸下,仔细思考一下一般我们想到承诺这类词的脑海第一浮现的感受是什么?是安全感、是舒适放心。Javscript中的promise是一种异步编程的规范,它给我们带来的异步解决方案同样让我们舒适放心充满安全感!
在介绍promise前,我们先看看promise出现前,Javascipt的异步处理有哪些痛点
我一直觉得一项技术,其本身原理并不是最关键的。背后解决的痛点问题,技术诞生的来龙去脉更加重要,找到并理解了真正的问题所在,那么答案自然而然的浮出水面
不舒适
说到JavaScript的异步解决方案,大多数想到的就是回调函数。早期绝大多数场景采用的都是回调函数(node、jquery等),而回调函数有一个名场面便是回调地狱
引入一张经典的梗图😱
大家经常调侃“嵌套层数”、“无限缩进”等。这虽然是个梗,但回调函数带来的问题远不止这个,我们通过一个例子来逐渐说明回调函数的其他“罪恶”
下面的例子是读取两个文本文件的内容,对其进行数据转化得到最后的数据,将最后转化的内容写入到某个文件
const fs = require('fs');
fs.readFile('file1.txt', 'utf8', (err, data1) => {
if (err) {
console.error(err);
return;
}
fs.readFile('file2.txt', 'utf8', (err, data2) => {
if (err) {
console.error(err);
return;
}
// 对file1.txt、file2.txt生成的数据进行中间处理。用于最后的输出
const finalData = transFormData(data1, data2)
fs.writeFile('output.txt', `${finalData}`, (err) => {
if (err) {
console.error(err);
return;
}
console.log('Data written to output.txt');
});
});
});
由于回调自身的特性,导致后续的回调步骤往往会使用上层回调中的变量。这会带来以下问题
-
动一处而影响整体,上面例子中如果
data1的结构发生了变化(或者中途某段逻辑对data1进行了处理),那么很大可能你需要同时修改transFormData中的逻辑。data2也是同样的道理。随着业务逐渐复杂。每一个回调函数自身有大量的变量以及处理逻辑。可想而知,代码几乎动不了 -
代码不好复用,比如我想把转化读取这一块逻辑单独抽离出来。但是由于依赖
data1、data2。一旦这两个变量结构发生变化,transFormData跟着修改。要么就是增加一段兼容逻辑。保证传入transFormData的数据结构不变,感觉也不好
另外由于回调函数里面的逻辑是异步的,导致遇到报错的时候,调用栈
总之,利用回调函数处理异步,无论是开发还是维护上都很不“舒适”
不安全感
参数、逻辑不安全
Node是采用回调函数作为异步函数的唯一参数,并将一个参数设置为Error对象(如果有报错的话)
fs.readFile('file1.txt', 'utf8', (err, file1Data) => {
if (err) {
console.error(err);
return;
}
xxxx
});
这种统一回调参数的写法貌似也还行,但毕竟是编码规约。不采用这种写法也不会报错(比如,回调函数中新增两个参数或者只使用其中一个参数)。但这存在以下几个问题
- 如果某个回调中忘记对错误进行处理,代码会继续往下执行(这完全是不应该的,报错了后续的逻辑就不应该继续执行,继续执行大概率会出现问题)
fs.readFile('file1.txt', 'utf8', (err, file1Data) => {
// 没有对错误进行对应的处理
xxxx
});
- 回调函数的参数具体是怎么样的取决于调用函数。
node这种肯定不会有问题。如果是一些其他不知名的第三方库或者是团队内部其他成员写的呢。这些具体的细节得使用者看文档,没有文档那只能看源码。如果是一些个性化的组件库或者特定场景插件函数库这样不太有问题。但这是javaScript语言本身异步场景的方案,百家争鸣不太好吧
代码执行顺序的不安全感
在基于回调的 API 中,回调函数何时以及如何被调用取决于API的实现者。回调可能是同步调用的,也可能是异步调用的,因此代码的执行顺序是不确定的
引用mdn的例子
let val = 1
function asyncFunc (cb) {
if (Math.random() * 100 > 50) {
cb()
} else {
setTimeout(() => {
cb()
}, 0);
}
}
asyncFunc(() => {
val = 2
})
console.log('val :>> ', val);
上面这段代码,变量val输出是1还是2?
function onReady(fn) {
var readyState = document.readyState;
if (readyState === 'interactive' || readyState === 'complete') {
setTimeout(fn, 0);
} else {
window.addEventListener('DOMContentLoaded', fn);
}
}
onReady(function () {
console.log('DOM fully loaded and parsed');
});
console.log('==Starting==');
上面这段代码。onReady函数中的回调是同步/异步调用,取决于onReady函数执行的时候dom是否加载完成
通过这两个例子相信大家已经能够体会到通过回调函数处理异步场景的种种弊端了。关键在于回调函数内部的所有细节全部取决于API的实现者。对于API的调用者来说这完全是个黑盒
总结
我们可以发现基于回调函数的异步方案会有很多弊端,其根本在于回调函数API设计的不确定
如果你有一个接受回调的API,有时这个回调会被立即调用,有时这个回调会在将来的某个时候被调用,那么你将使任何使用这个API的代码无法推理,并导致Zalgo的发布
有关更多回调函数设计的细节可以参考:Callbacks, synchronous and asynchronous
promise
接下来我们来看promise是怎么一一解决上面这些问题的
回调地狱
为了方便阅读,这里把文章最开始的例子挪过来
const fs = require('fs');
fs.readFile('file1.txt', 'utf8', (err, data1) => {
if (err) {
console.error(err);
return;
}
fs.readFile('file2.txt', 'utf8', (err, data2) => {
if (err) {
console.error(err);
return;
}
// 对file1.txt、file2.txt生成的数据进行中间处理。用于最后的输出
const finalData = transFormData(data1, data2)
fs.writeFile('output.txt', `${finalData}`, (err) => {
if (err) {
console.error(err);
return;
}
console.log('Data written to output.txt');
});
});
});
我们基于promise的方式改写下
const fs = require('node:fs/promises');
let file1Data
fs.readFile('file1.txt', 'utf-8')
.then((data1) => {
file1Data = data1
return fs.readFile('file2.txt', 'utf-8')
})
.then(data2 => {
return transFormData(file1Data, data2)
})
.then(result => {
return fs.writeFile('output.txt')
})
当然我们可以借助Promise.all让其进一步简洁
const readFile1P = fs.readFile('file1.txt', 'utf-8')
const readFile2P = fs.readFile('file2.txt', 'utf-8')
Promise.all([readFile1P, readFile2P])
.then(([file1Data, file2Data]) => transFormData(file1Data, file2Data))
.then(result => fs.writeFile('output.txt', result))
可以看出之前层层嵌套的模式变成了链式调用
结果、错误处理
在promise中,我们不需要在每一个then对应的回调函数里面处理错误情况。只需在链条的最后加一个 catch便可以捕获整个过程中抛出错误(“拒绝”的结果)
const fs = require('node:fs/promises');
let file1Data
fs.readFile('file1.txt', 'utf-8')
.then(xxx)
.then(xxx)
.then(xxx)
.catch(err => {
// 在这里处理错误
xxx
})
Promise也会存在捕获不到的错误,以及吞并错误
吞并错误
Promise内部代码的错误并不会影响到Promise外层代码的执行
Promise.resolve()
.then(() => {
// 这里a没有定义,会抛出错误
console.log(a)
})
.catch(err => {
// 错误会在这里被捕获
})
// 控制台仍旧会打印 1
console.log(1)
这也意味着如果我们不手动catch捕获错误。哪怕代码与预期不一致,控制台也不会有任何错误。风平浪静。这其实不利于我们排查错误(一个比较常见的场景是调用接口,接口数据结构变化或者为空数据等。赋值出现错误),所以需要养成Promise对象尾部跟随catch的好习惯
无法捕获的错误
- 异步错误(这点与基于回调函数的异步方式一样,都存在)
Promise.resolve().then(() => {
setTimeout(() => {
console.log('a :>> ', a);
}, 0);
}).catch(err => {
// 错误不会捕获
})
上面代码,Promise指定在下一次事件循环里面抛出错误,显然到那个时机Promise已经执行完成。换句话说这个错误是在Promise外部抛出的
- 中断的
Promise链条
Promise.resolve()
.then(() => {
Promise.reject(1)
})
.catch(err => {
console.log('err :>> ', err);
})
Promise.resolve()
.then(() => {
Promise.resolve().then(() => {
console.log('a :>> ', a);
})
// Promise.reject(1)
})
.catch(err => {
console.log('err :>> ', err);
})
上面这两种场景没有return,导致Promise链条中断了。另外这种写法还会有更严重的问题,这个下面会说到
安全感
为了方便阅读,我们把之前例子挪过来
let val = 1
function asyncFunc (cb) {
if (Math.random() * 100 > 50) {
cb()
} else {
setTimeout(() => {
cb()
}, 0);
}
}
asyncFunc(() => {
val = 2
})
console.log('val :>> ', val);
由于回调函数的调用时机完全由API开发者决定,API调用者会有很大的心智负担,不安全感
引用 MDN的表述
Promise 是一种控制反转的形式——API 的实现者不控制回调何时被调用。相反,维护回调队列并决定何时调用回调的工作被委托给了 Promise 的实现者,这样一来,API 的使用者和开发者都会自动获得强大的语义保证,包括:
- 被添加到
then()的回调永远不会在 JavaScript 事件循环的当前运行完成之前被调用。 - 即使异步操作已经完成(成功或失败),在这之后通过
then()添加的回调函数也会被调用。 - 通过多次调用
then()可以添加多个回调函数,它们会按照插入顺序进行执行
这里最关键的一点就是:无论怎么样 Promise.then中的回调永远是异步的,这就保证了代码的执行顺序
我们将上面的例子用Promise改造下
let val = 1
function asyncFunc () {
return new Promise((resolve, reject) => {
if (Math.random() * 100 > 50) {
resolve()
} else {
setTimeout(resolve, 0);
}
})
}
asyncFunc().then(() => {
val = 2
})
// val的值输出的一定是 1
console.log(val)
这样我们能够确保val = 2一定是在下次事件循环中执行
使用注意点
了解了Promise的来龙去脉,接下来说下我个人认为在使用Promise的时候需要特别注意的点
注意意外中断Promise链
在前面我们提到过一嘴,这里再举一个例子,加深大家的印象
const arr = []
Promise.resolve()
.then(() => {
// 注意这里没有return
delay(0).then(() => {
arr.push(1)
})
})
.then(() => {
// 这里的 arr永远是[]
console.log('arr :>> ', arr);
})
function delay(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, ms);
})
}
上面代码中我们使用delay函数模拟异步请求。如果delay没有return,promise断开了。那么最后一个then回调中的arr永远会是空数组
then
对于Promise来说,抛开细枝末节,核心关键点有两个:
Promise构造函数本身Promise实例then方法
其中我认为then是掌握并理解Promise的关键点
我们知道,then方法用于注册Promise兑现或者拒绝的回调函数,当Promise实例状态发生变化的时候其状态对应的回调函数会被调用,并且它会返回一个全新的 promise,可以验证下
const p1 = Promise.resolve()
const p2 = p1.then()
// false
console.log(p1 === p2)
这意味着下面这段代码所有的then几乎是同时执行的,因为都是对同一个Promise实例操作,并没有达到链式传递的效果
const p = Promise.resolve(1)
p.then((value) => {
value++
return value
})
p.then((value) => {
value++
return value
})
p.then((value) => {
value++
// 2
console.log('value :>> ', value);
return value
})
then不但能注册回调,它还能将回调函数的返回值进行转换(使用Promise.resolve包装),创建并返回一个新的 Promise对象(这是Promise可以链式调用的核心)
// 返回基本值
Promise.resolve().then(() => 1)
// 等价于
Promise.resolve().then(() => Promise.resolve(1))
// 没有返回值
Promise.resolve().then(() => {
xxxx
})
// 等价于
Promise.resolve().then(() => {
return Promise.resolve(undefined)
})
// then第一个参数传的不是函数
Promise.resolve().then(1)
// 等价于
Promise.resolve().then((x) => x)
可见,其关键在于Promise.resolve这个静态方法了,这里理下Promise.resolve的包装场景
1.非thenable对象、非Promise实例。返回的Promise对象直接以该值兑现
Promise.resolve(1).then(v => {
// 1
console.log(v)
})
Promise实例。会将这个Promise实例原封不动的返回
const p1 = Promise.resolve()
const p2 = Promise.resolve(p1)
console.log(p1 === p2) // true
thenable对象
const thenable = {
then(resolve) {
console.log('then :>> ');
resolve(1)
}
}
Promise.resolve(thenable).then(v => {
console.log('v :>> ', v); // 1
})
另外Promise.then中注册的方法永远是异步的,不会压入当前函数执行栈中
最后
Promise诞生前,社区大部分是采用基于回调函数的方式处理异步。然而这种方式会有各种“不舒适”,“不安全感”。于是Promise出现了。但是这么说并不准确
到底什么是Promise,其实有两种含义:1.Promise A+规范。2.ES6 定义的 Promise构造函数
这里就不在展开讲了,留在下篇文章
下篇文章将深入Promise本身,并手写一个基于Promise A+规范的Promise ,一起期待吧