目录
前言
为何这样设计
回调函数
介绍Promises
使用Promises
创建自己的Promises
Promise 链
传递数据
错误处理Promises
async / await
结语
❤️❤️❤️
前言
在学习JavaScript的过程中往往会面临很多挑战,其中最让人头疼的要数Promises了。想真正理解Promises,需要深入了解JavaScript的工作原理以及它的局限性。如果没有这些背景知识,Promises可能会难以理解,因为如今Promises API已经非常重要,几乎成为了处理异步代码的标准方式。现代的Web API大多是基于Promises构建的。因此,如果想要高效地使用JavaScript,就必须掌握Promises,在本篇文章中,我会从最基础开始,并且会分享一些关键的背景知识,希望在教程结束时,能让你对Promises有一个更加深入的理解最好是可以熟练地运用它们。
目标受众
本篇博文面向初级到中级 JavaScript 开发人员,需要具备一些基本的 JavaScript 语法知识,也可以多多交流。
为何这样设计
假设我们想建立一个新年快乐!倒计时,如下所示:
function newYearsCountdown() {console.log("3");sleep(1000);console.log("2");sleep(1000);console.log("1");sleep(1000);console.log("新年快乐! 🎉");
}
在这个代码片段中,当程序执行到sleep()
调用时会将会暂停,并在指定的时间过后继续执行。不过实际上JavaScript中并没有sleep
函数,这是因为JavaScript是一种单线程语言。*所谓“单线程”是指JavaScript只有一个可以执行代码的线程,所以它一次只能做一件事,无法同时处理多项任务。这带来了一个问题:如果JavaScript的线程忙于处理倒计时器,那么它就无法执行其他操作。
当我第一次学习这些概念时,我并没有立刻意识到为什么这是个问题。如果当前唯一正在运行的任务就是倒计时器,那JavaScript线程在这段时间内完全被占用,似乎没什么问题,对吧?
尽管JavaScript没有sleep
函数,但它确实有一些其他会占用主线程较长时间的函数。我们可以通过这些方法来窥探一下,如果JavaScript有一个sleep
函数会是什么样子,例如,window.prompt()
。这个函数用于从用户那里获取信息,它会像上面的sleep()
函数那样暂停js代码的执行。
<script>function askForName() {const name = window.prompt('你的名字?');const elem = document.querySelector('#greeting');elem.innerText = `你好 ${name}!`}
</script><button onclick="askForName()">点 击
</button>
<div id="greeting"></div>
点击这个示例中的按钮,然后在提示框弹出时尝试与页面进行交互:
很明显当提示打开时,页面完全没有响应,无法滚动、单击任何链接或选择任何文本!JavaScript 线程正忙于等待用户输入一个值之后点击确定按钮,以便它可以完成运行后续代码,在等待期间,它无法执行任何其他操作,因此浏览器锁定了 UI,其他语言有多个线程,所以如果其中一个线程占用了一段时间也没什么大不了,但在 JavaScript 中,我们只有一个线程,它用于处理页面上的所有事情:处理交互事件、管理网络请求、更新 UI 等。
如果想要创建一个倒计时,需要找到一种不阻塞线程的方法来实现它。
为什么整个浏览器窗口都会冻结?
在上面的例子中,当我们使用
window.prompt()
时,浏览器在等待我们输入值的过程中,整个用户界面(UI)都会变得无响应。这看起来有点奇怪……毕竟,浏览器的页面滚动或文本选择并不完全依赖于JavaScript。那么,为什么我们在这段时间内无法进行这些操作呢?
我认为,浏览器之所以设计成这样,是为了防止出现错误。比如,滚动页面时会触发“滚动”事件,这些事件可以被JavaScript捕获并处理。如果在滚动事件发生时JavaScript线程正被其他任务占用,那相关的代码就永远无法执行。如果开发者假设这些滚动事件总是会被处理,那么就有可能导致错误。这也可能是为了用户体验考虑;也许浏览器禁用了UI,以防止用户忽略提示。不过,无论是哪种情况,如果JavaScript中真的有
sleep
函数,它可能也会以类似的方式工作,以避免潜在的错误。
回调函数
解决这类问题的主要工具是setTimeout
。setTimeout
是一个函数,它接受两个参数:
- 一段将在未来某个时间点执行的代码。
- 等待的时间长度。
以下是一个示例:
console.log('开始');
setTimeout(() => {console.log('已经过去一秒钟');},1000
);
这段代码是通过一个函数传递进去的,这种模式被称为回调函数。
对于之前的sleep()
函数,举个简洁明了的例子 他就像是你给售后打电话一直等待下一个有空的售后客服,而setTimeout()
则更像是按下某一个键,让他们售后客服有空时回拨给你。这样你可以挂断电话,继续做其他事情。
setTimeout()
被称为异步函数,这意味着它不会阻塞线程。相比之下,window.prompt()
是同步的,因为在等待用户输入时,JavaScript线程不能做其他任何事情。
当然如果你使用异步代码的话,它可能导致js代码不会按照线性顺序执行。如下:
console.log('1. setTimeout之前');
setTimeout(() => {console.log('2. etTimeout');
}, 500);
console.log('3. setTimeout之后');
你可能会以为这些日志按顺序从上到下依次输出:1 > 2 > 3。但是,回调函数的本质是其实就是在安排一个延后执行的任务。JavaScript线程不会停下来等待回调,而是继续执行后续的代码。
为了更好地理解这一点,直接看这段代码的运行结果:
-
00:000
: Log "1. Before setTimeout之前". -
00:001
: 执行timeout. -
00:002
: Log "3. setTimeout之后". -
00:501
: Log "2. setTimeout".
setTimeout()
的作用是注册回调函数,注册回调的过程只需要一瞬间,一旦完成,JavaScript就会继续执行程序的其他部分,回调函数在JavaScript中被广泛应用,不仅仅是用于计时器。例如,下面是如何监听指针事件的:
window.addEventListener('pointermove', (event) => {const container = document.querySelector('#data');container.innerText = `${event.clientX} • ${event.clientY}`;
});
window.addEventListener()
用于注册一个回调函数,当检测到某个事件时,这个回调函数就会被调用。在这个例子中,监听的是指针的移动,每当用户移动鼠标时,都会触发相应的代码,与setTimeout
类似,JavaScript线程并不会专门盯着鼠标事件,而是告诉浏览器当用户移动鼠标的时候,通知一声!当事件触发时,JavaScript线程就会回过头来执行回调函数。不过话说回来,好像我们有点偏离最初的问题了--- 如果我们想设置一个3秒倒计时,该怎么做呢?
在过去,最常见的解决方案是设置嵌套回调,大致像这样:
console.log("3…");
setTimeout(() => {console.log("2…");setTimeout(() => {console.log("1…");setTimeout(() => {console.log("新年快乐!!");}, 1000);}, 1000);
}, 1000);
就上面这一坨代码,不堪入目惨不忍睹!!在setTimeout
回调函数内部又创建了自己的setTimeout
回调!这种模式的代码,一般只有刚入门的新手敢这么写,虽然可能写这种函数的人也会意识到这种做法并不理想,这种写法就是大家通常说的“回调地狱”。
为了解决这种“回调地狱”的问题,Promises应运而生。
setTimeout
是怎么知道什么时候触发回调的呢?
setTimeout
API接受一个回调函数和一个置顶时间。指定的时间过后,回调函数就会被调用。但它是怎么做到的呢?如果JavaScript线程并没有盯着这个计时器,又是怎么知道该何时调用回调函数的呢?关于这些可以看我之前的一篇博文,叫做事件循环(event loop)的机制。当我们调用setTimeout
时,会有一条消息被添加到一个队列中。每当JavaScript线程没有执行代码时,它就会关注事件循环,检查是否有新的消息。如果JavaScript线程此时没有在做其他事情,它会立刻执行setTimeout
传递的回调函数。这也意味着计时器的时间并不是百分之百准确的。JavaScript只有一个线程,如果当消息到达时,它正忙于处理其他任务,比如滚动事件或等待
window.prompt()
的输入,那么回调的执行可能会稍微延迟。如果我们设置了1000毫秒的计时器,至少会有1000毫秒过去,但实际执行回调的时间可能会稍微长一些。具体的更加深入的内容可以在CSDN上了解更多关于js事件循环的内容。
介绍Promises
正如前面讨论的那样,我们不能简单地让JavaScript暂停执行代码等待一段时间,因为这样会阻塞线程。我们需要找到一种方法,把工作分解成异步的片段来处理。但是,与其使用嵌套回调,我们能不能将这些任务串联起来呢?就像是告诉JavaScript:“先做这个,再做那个,然后再做这个”?如果想要随意改变setTimeout
函数的工作方式该怎么做呢:
console.log('3');
setTimeout(1000).then(() => {console.log('2');return setTimeout(1000);}).then(() => {console.log('1');return setTimeout(1000);}).then(() => {console.log('新年快乐!!');});
与其直接将回调函数传递给setTimeout
,导致嵌套和“回调地狱”的出现,我们能不能使用一种特殊的.then()
方法将它们串联起来呢?
这就是Promises的核心理念。Promise是一种特殊的结构,作为JavaScript在2015年一次重大语言更新的一部分被引入,不过setTimeout
仍然使用旧的回调方式,因为setTimeout
的出现实际上是早于Promises的出现;如果改变它的工作方式,可能会导致一些老的旧的网站出现问题,由此可见向后兼容是非常重要,但这也意味着某些东西会显得有些混乱。不过,现代的Web API大多是基于Promises构建的。
使用Promises
fetch()
函数是JavaScript中用于发起网络请求的API,通常用来从服务器获取资源或发送数据。
看看下面这段代码:
const fetchValue = fetch('/api/get-data');
console.log(fetchValue);
// -> Promise {<pending>}
当调用fetch()
时,它开始发起网络请求。这是一个异步操作,所以JavaScript线程不会停下来等待,而是继续执行后续的代码。那么,fetch()
函数实际上产生了什么呢?它不可能直接返回来自服务器的实际数据,因为请求刚刚开始,需要一段时间才能完成。相反,fetch()
返回的是一种“承诺”(Promise),就像浏览器给你的一张IOU(欠条),意思是:“我现在还没有数据,但我保证很快会有!”更具体地说,Promise是JavaScript的一个对象。Promise内部始终处于以下三种状态之一:
pending
(待定):工作正在进行中,还未完成。fulfilled
(已完成):工作已经成功完成。rejected
(已拒绝):出现了问题,Promise未能完成。
当Promise处于pending
状态时,称它为“未完成”(unresolved)。当工作完成时,Promise变为“已完成”(resolved),无论是成功完成(fulfilled
)还是遇到问题(rejected
),都属于“已完成”状态,实际情况中通常都希望在Promise完成时执行某些操作,这里可以通过.then()
方法来实现这一点:
fetch('/api/get-data').then((response) => {console.log(response);// Response { type: 'basic', status: 200, ...}});
fetch()
返回一个Promise,使用.then()
方法来附加一个回调函数。当浏览器收到响应后,这个回调函数就会被调用,并且响应对象会作为参数传递给回调函数。
等待JSON
如果你之前使用过Fetch API,你可能注意到在获取需要的JSON数据时,还需要执行第二步操作:
fetch('/api/get-data').then((response) => {return response.json();}).then((json) => {console.log(json);// { data: { ... } }});
response.json()
会生成一个全新的Promise,这个Promise会在响应数据完全转化为JSON格式时转为完成态fulfilled。但为什么response.json()
是异步的呢?我们已经等到响应了,数据不是应该已经是JSON格式的吗?其实不一定,Web底层的一个核心功能是服务器可例如视频),但也可以用于传输较大的JSON数据,当浏览器收到服务器传来的第一个字节数据时,fetch()
返回的Promise就转为完成态fulfilled。而response.json()
返回的Promise是在浏览器接收到所有数据后才会转为完成态fulfilled。在实际应用中,JSON数据通常不会分块发送,因此这两个Promise通常会在同一时间被解决。不过,为了支持流式响应,Fetch API被设计成这种结构,因此需要多一步的处理来确保数据完整性。
创建自己的Promises
当使用Fetch API时,Promise是由fetch()
函数在后台创建的。但如果使用的API不支持Promises怎么办?
例如,setTimeout
是在Promises出现之前创建的。如果我们想在使用setTimeout
时避免“回调地狱”,我们需要自己创建Promises。以下是创建Promise的语法示例:
const demoPromise = new Promise((resolve) => {//添加些异步操作 then//调用 `resolve()`
});
demoPromise.then(() => {// promise执行完成时调用此处
})
Promises是通用的,它们本身不“做”任何事情。当使用new Promise()
创建一个新的Promise实例时,需要提供一个函数,用于执行想要的特定异步工作。这个工作可以是任何事情:发起网络请求、设置延迟等等,当这个工作完成时调用resolve()
,这会通知Promise一切顺利,并将其状态设置为已解决(resolved)。
话再说回来创建一个倒计时计时器。
在这种情况下,异步工作就是等待setTimeout
的到期,可以创建一个基于Promise的辅助函数,将setTimeout
包裹起来,代码如下:
function wait(duration) {return new Promise((resolve) => {setTimeout(resolve, duration);});
}
const timeoutPromise = wait(1000);
timeoutPromise.then(() => {console.log('1秒之后!')
});
这段代码看起来挺复杂,接下来试着把它拆解一下:
-
新建的工具函数
wait
:这个函数接收一个参数duration
,希望将其用作类似于“休眠”的功能,不过这里它是完全异步的。 -
在
wait
函数内部,创建并返回了一个新的Promise。需要注意的是,Promise本身不会主动做任何事情;我们需要在异步工作完成后调用resolve
函数。 -
在Promise内部,启动了一个新的定时器,使用
setTimeout
。将从Promise中获取的resolve
函数以及用户提供的duration
传递给它。 -
当定时器到期时,它会调用传入的回调函数。这就像是一种连锁反应:
setTimeout
调用resolve
,这表明Promise已经完成,然后触发.then()
中的回调函数。
即使这段代码让你感觉有点晕😅也没关系。这里结合了很多复杂的概念!希望你能够理解大致内容~~~
在上面的代码中,我将resolve
函数直接传递给了setTimeout
。或者也可以像之前那样一层一层来调用resolve
函数:
function wait(duration) {return new Promise((resolve) => {setTimeout(() => resolve(),duration);});
}
实际上JavaScript 中有“头等函数”(first-class functions),是指在程序设计语言中,函数被当作头等公民。这意味着,函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中。这是一个很棒的特性,但要真正熟练运用,可能需要一些时间让它变得直观。上述的替代写法虽然间接一些,但功能完全相同。所以,如果这种方式对你来说更清晰,你完全可以这样结构化代码!
Promise 链
关于 Promise,有一件重要的事情需要理解:它只能被解决(resolved)一次。一旦一个 Promise 被完成(fulfilled)或被拒绝(rejected),它的状态就会永久保持不变,这意味着 Promise 并不是万能的,也有不适合的某些场景,比如事件监听器:
window.addEventListener('mousemove', (event) => {console.log(event.clientX);
})
这个回调函数会在用户每次移动鼠标时触发,可能会触发数百次甚至数千次。对于这种情况,Promise 并不是一个好的选择。那么,对于之前提到的“倒计时”场景呢?虽然不能重新触发同一个 wait
Promise,但可以将多个 Promise 链接在一起:
wait(1000).then(() => {console.log('2');return wait(1000);}).then(() => {console.log('1');return wait(1000);}).then(() => {console.log('新年快乐!!');});
当我们的原始 Promise 被完成时,.then()
回调函数会被调用,这个回调创建并返回一个新的 Promise,整个过程就会重复进行。
传递数据
说了这么多,到目前为止,在调用 resolve
函数时并未传递任何参数,只是用它来表示异步工作已经完成而已,但在某些情况下,可能有一些数据需要传递给下一个处理步骤!下面是一个使用回调的例子:
function getUser(userId) {return new Promise((resolve) => {// 这里的异步操作是根据用户ID查找用户信息db.get({ id: userId }, (user) => {// 一旦获取到完整的用户对象,就调用 resolve 并传递用户数据resolve(user);});});
}
getUser('abc123').then((user) => {// 在这里我们可以处理获取到的用户对象console.log(user);// 输出类似于 { name: 'Josh', ... } 的用户对象
})
在这个例子中,定义了一个 getUser
函数,它接受一个 userId
参数,并返回一个 Promise。当调用 getUser('abc123')
时,返回的 Promise 会在用户数据准备好后被解析,然后在 .then()
回调中接收到用户对象并处理它,通过这种方式,我们不仅可以使用 resolve
函数通知 Promise 已经完成,还可以将数据传递给下一个处理步骤,使整个异步操作链更加灵活和强大。
错误处理Promises
在 JavaScript 中,Promises 并不总是能准确无误的完成,有时候,它们可能会被“打破”。例如,在使用 Fetch API 时,我们不能保证网络请求一定会成功!可能是网络连接不稳定,或者服务器出现故障。在这些情况下,Promise 会被拒绝(rejected),而不是完成(fulfilled),这种情况可以使用 .catch()
方法来处理这些被拒绝的 Promises:
fetch('/api/get-data').then((response) => {// ...}).catch((error) => {console.error(error);});
当一个 Promise 被完成时,.then()
方法会被调用,而当 Promise 被拒绝时,.catch()
方法会被调用,可以把它看作是两条独立的路径,根据 Promise 的状态来选择哪条路径被执行。
Fetch 例外情况
假设服务器返回了一个错误,比如 404 Not Found 或 500 Internal Server Error。这会导致 Promise 被拒绝吗?
并不会。在这种情况下,Promise 仍然会被认为是“完成”的,而
Response
对象会包含关于错误的信息:Response { ok: false, status: 404, statusText: 'Not Found', }
虽然有点意外,但实际上是有道理的:成功地接收到了来自服务器的响应!虽然这个响应可能不是我们想要的,但我们确实得到了一个响应!!
这在某种程度上符合了Promise的逻辑:已经得到了响应,即使它不是期望的那种数据。
在手动创建 Promises 时,可以通过传递第二个回调参数 reject
来拒绝 Promise。这个 reject
回调函数会被调用,以表示 Promise 由于某种原因未能成功完成。
new Promise((resolve, reject) => {someAsynchronousWork((result, error) => {if (error) {reject(error);return;}resolve(result);});
});
如果在 Promise 内部遇到问题,可以调用 reject()
函数将 Promise 标记为拒绝状态。传递给 reject()
的参数(通常是一个错误)将会传递到 .catch()
回调中被捕获。
正如之前所看到的,Promises 始终处于三种可能的状态之一:pending(等待中)、fulfilled(已完成)、rejected(已拒绝)。然而,Promise 是否被“解决”(resolved)是一个独立的概念。那么,参数名称不应该是 “fulfill” 和 “reject” 吗?
实际上,
resolve()
函数通常会将 Promise 标记为已完成,但这并不是绝对的保证,如果用另一个 Promise 来解决原始的 Promise,情况会变得比较复杂。原始 Promise 会“锁定”到这个后续 Promise 上。即使原始 Promise 仍然处于等待状态,它也会被认为是“已解决”的,因为 JavaScript 线程已经转到下一个 Promise 上。这是我在发布这篇博客文章时刚刚学到的,不过我认为99% 的人都不需要担心这件事情。如果你确实想深入了解,可以查阅这个文档:States and Fates。
async / await
新的JavaScript 有一个很棒的特性是 async
/ await
语法,使用这种语法可以实现接近理想的倒计时结构:
async function countdown() {console.log("5…");await wait(1000);console.log("4…");await wait(1000);console.log("3…");await wait(1000);console.log("2…");await wait(1000);console.log("1…");await wait(1000);console.log("新年快乐!");
}
but!!!!等一下,不是说过这种做法是不可能的!不能在 JavaScript 函数执行到一半时暂停,这不是会阻塞线程,使其无法执行其他任务吗?
实际上,这种新语法背后是基于 Promises 的。如果仔细研究一下,就会发现它是如何工作的:
async function addNums(a, b) {return a + b;
}
const result = addNums(1, 1);
console.log(result);
// -> Promise {<fulfilled>: 2}
上面代码期望返回值是数字 2
,但实际上返回的是一个解析为 2
的 Promise。当在一个函数前加上 async
关键字时,这个函数就一定会返回一个 Promise,即使这个函数内部没有任何异步操作!!实际上等同于:
function addNums(a, b) {return new Promise((resolve) => {resolve(a + b);});
}
同样,await
关键字可以算是 .then()
回调的语法糖:
// 这段代码...
async function pingEndpoint(endpoint) {const response = await fetch(endpoint);return response.status;
}
// ...等同于这段代码:
function pingEndpoint(endpoint) {return fetch(endpoint).then((response) => {return response.status;});
}
Promises 使得 JavaScript能够提供看起来像是同步的语法,但实际上在内部依然是异步的~~~不过也确实是挺厉害的!!
结语
懒得写了,都看到这里了的话,估计你眼睛也累了,休息休息吧!码字不易别忘了点赞哦。