WebGL Tutorial
and more

Promise

撰写时间:2023-05-29

修订时间:2024-12-31

Promise完全就是一个玩转函数回调机制的游戏。

实现自定义Promise

在这一节中,我们一步步地实现自己的Promise。但为了更易于明白,我借用了服务器端-客户端的语境。

一个简单的回调函数

function clientFuncA(num) { pc.log(`clientFuncA is called with ${num}.`); } function clientFuncB(msg) { pc.log(`clientFuncB is called with "${msg}".`); } function serverFunc(funcA, funcB) { funcA(5); funcB('Hello'); } serverFunc(clientFuncA, clientFuncB);

函数serverFunc2个参数,参数的数据类型均为函数。在其函数体内,以分别以数值5与字符串Hello作为参数,回调了作为参数所进来的两个函数funcAfuncB

digraph { node [shape=record]; clientFuncA [label="{{clientFuncA | ( | num | )}}"]; clientFuncB [label="{{clientFuncB | ( | msg | )}}"]; serverFunc [label="{{serverFunc | ( | funcA | , | funcB | )} | let num=5; funcA(num) | let msg='Hello'; funcB(msg) }"]; clientFuncA -> serverFunc:f0 [color="gray"]; clientFuncB -> serverFunc:f1 [color="gray"]; serverFunc:f2 -> clientFuncA [label=5]; serverFunc:f3 -> clientFuncB [label="Hello", color=cyan]; {rank=same;clientFuncA, clientFuncB}; }

通过回调机制,serverFunc将自身的数据,分别传递给两个回调函数使用。

你给我一个函数指针,我将用自身的数据作为参数来回调该函数。因此,回调机制的特点是通过函数指针来反向传递数据。

MockPromise的基本实现

class MockPromise { constructor(clientMainFunc) { this.clientMainFunc = clientMainFunc; } then(clientFunc) { let resultValue; function serverResolveFunc(clientValue) { pc.log(`I get the client value: ${clientValue}`); resultValue = clientValue; }; this.clientMainFunc(serverResolveFunc); clientFunc(resultValue); } } function paramFunc(resolve, reject = null) { resolve(10); } let mockPromise = new MockPromise(paramFunc); mockPromise.then((value) => { pc.log(value); });

第一步,在客户端声明了一个回调函数paramFuncparamFunc函数共有2个数据类型为函数的参数,其名称分别为resolvereject。而在paramFunc中,必须至少调用resolve函数。该函数的作用是,通过回调函数的机制,由客户端向服务器端传送数据。因此在该函数中,客户端是数据提供者。下面我们将看到这一点。

第二步,以paramFunc为构造参数,创建了MockPromise的一个实例mockPromise

第三步,在MockPromise构造函数中,将paramFunc存为clientMainFunc的一个实例属性。以供实例方法then使用。

第四步,客户端调用mockPromisethen方法。此时,在该方法内,很神奇的事情发生了。

  1. 客户端调用服务器的then方法。

    这里,调用实参为客户端所声明的一个函数。这为后面埋下了伏笔。

  2. 在服务器端取得客户端的数据。

    主要通过以下的方式来进行。

    1. 服务器端回调客户端。 function serverResolveFunc(clientValue) { ... }; this.clientMainFunc(serverResolveFunc);

      服务器端先声明一个名为serverResolveFunc的函数,然后以该函数为参数,回调客户端的paramFunc函数。

    2. 客户端回调服务器端。

      此时,paramFunc将变成:

      function paramFunc(serverResolveFunc, reject = null) { serverResolveFunc(10); }

      paramFunc函数中,客户端回调服务器端的serverResolveFunc函数,将客户端所拥有的数据10向服务器端传送。

      这样,服务器端就可以使用客户端传来的数据clientValue了:

      function serverResolveFunc(clientValue) { pc.log(`I get the client value: ${clientValue}`); resultValue = clientValue; };

      上面,将该值赋值于服务器端的变量resultValue

  3. 服务器端回调客户端。

    上面then方法的参数是由客户端所传进来的一个函数。则服务器端再次回调此函数:

    then(clientFunc) { ... clientFunc(resultValue); } 现在,客户端得到了最终的数据: mockPromise.then((value) => { pc.log(value); });

从上面代码可以看出,MockPromise的内部充斥了回调函数的应用。

最后,我们回头再看构建MockPromise的参数paramFunc的代码:

function paramFunc(resolve, reject = null) { resolve(10); }

参数resolve函数将由服务器端调用以取得客户端的数据,因此,我们必须通过调用该函数来提供客户端的数据。同时,这也是客户端自主地决定提供何种数据的地盘,客户端可在这里实现复杂的业务逻辑,例如:

function paramFunc(resolve, reject = null) { let numArray = SomeUtils.GetRandomInts(1, 200, 50); let max = getMaxFrom(numArray); resolve(max); }

当然,真正的Promise对象还包括了强大、丰富的异步功能,而我们实现MockPromise的目的,能让我们更清楚地了解Promise内部回调机制细节。

何为Promise

Promise是一个用于异步执行的对象。它在上一步的工作成功后,可以使用then方法来执行成功后的下一步工作。它可以用来取代原来的回调函数。下面是传统的异步工作机制:

function loadFile(successfulFunc, failedFunc) { // load data let data = ...; if (successful) { successfulFunc(data); } else { failedFunc(errMsg); } }

两个回调函数作为参数传入。这样可能会导致嵌套的代码过长。而Promise可以编写下面的代码:

let a = new Promise(...); a.then((value) => { trim(value); }).then((newValue) => { print(newValue); }).catch((errMsg) => { console.error(errMsg); });

then方法是一个回调函数,由JavaScript将上一步成功处理后的数据作为参数自动传入。在第二步中,如果trim函数也返回一个Promise对象,则可以继续串联下去。看起来简洁而有序。

而如果在串联过程中出现任何错误,则可使用catch语句来捕获它。

向下传递数据

从上面可以看出,Promise只关心两种情况:成功或失败。

如果成功,则返回一个回调函数,并将成功加工的数据作为参数向该回调函数传入。then方法,其参数就是这个回调函数。由于该函数已经注入了成功加工的数据,因此我们可以直接使用该数据。

如果失败,则抛出一个错误,并将失败原因为参数向该回调函数传入。用户可以使用catch方法进行捕获。

如何算成功,如何算失败,当然是业务逻辑的问题,应由用户自主定义。Promise只关心:1、如果成功,数据是什么?2、如果失败,出错信息是什么?

因此,Promise的构造函数只有一个参数,该参数是一个函数。这个函数,又带有两个回调函数作为其参数,分别负责接收成功的数据与失败的错误信息。

let paramFunc = function(resolveFunc, rejectFunc) { ... }; let aPromise = new Promise(paramFunc);

现在,假设我们随机生成一个[1, 100]范围内的随机数,如果在[51, 100]的范围内,则视为成功;否则,视为失败。

function getRandomIntInclusive(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1) + min); } let paramFunc = function(resolveFunc, rejectFunc) { let randNum = getRandomIntInclusive(1, 100); if (randNum >= 51 && randNum <=100) { resolveFunc(randNum); } else { rejectFunc('Number is too small'); } }; let aPromise = new Promise(paramFunc);

生成一个随机数,如果在[51, 100]范围内,则视为成功,则以该数作为参数调用resolveFunc方法。resolveFunc方法将作为参数传递给then方法。

如果随机数落在[1, 50]范围内,则视为失败,则以字符串Number is too small作为参数调用rejectFunc方法。rejectFunc方法将作为参数传递给catch方法。

此时运行程序,将出现错误:

Uncaught (in promise) Number is too small

未捕获异常的错误。

这是因为我们调用了rejectFunc回调函数,且一旦随机数小于等于50时,将触发此函数。此函数实际上是一个抛出异常的函数,该函数以我们所提供的错误信息作为参数构造一个Error对象并抛出此异常。故需要用户予以捕获。综上,下面的代码:

aPromise .then( (value) => {console.log(value);} ) .catch( (errMsg) => {console.error(errMsg);} );

将使得程序正常运行。

注意,Promisecatch也是一个回调方法,而不是一个语句。

简单的原型

let aPromise = new Promise((resolve) => { resolve("Well done!"); }); pc.log(aPromise);

Promise原型只有then, catch, finally3种方法。

只有resolveFunc

如果我们不需要抛出异常,则如下所示,Promise的构造方法的参数paramFunc的参数可以只带有一个resolveFunc函数。

function getRandomIntInclusive(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1) + min); } let paramFunc = function(resolveFunc) { let randNum = getRandomIntInclusive(1, 100); let count = 1; while(randNum <= 50) { console.log(`${count++}. small number: ${randNum}`); randNum = getRandomIntInclusive(1, 100); } resolveFunc({count: count, number: randNum}); }; let aPromise = new Promise(paramFunc); aPromise .then((value) => { console.log(`${value.count}. big number: ${value.number}`); });

在上面的代码中,如果生成了一个小数,与其抛出异常,不如直接重新生成一个随机数,直至该随机数为大数为止。然后,我们以一个对象{count: count, number: randNum}的形式作为参数传给resolveFunc。而在then方法中,对其进行解包即可。

多次运行这个例子,有时可能一次就能得到大数;而有时可能有好几次都是小数,最后一次才是大数。

1. small number: 45 2. small number: 27 3. small number: 33 4. small number: 12 5. small number: 27 6. big number: 59

这个例子也演示了Promise的真实本质:它能够以异步的方式,不知疲倦地干着许多见不得人的脏活、累活,当这些工作都完成后,它才通过then方法向我们一次性地递交最终的成果。

使用非自定义的Promise

上面都是我们自己创建Promise实例的例子。但更多时候,我们会经常遇到相应函数或方法的返回值为Promise的一个实例。最典型的,莫过于fetch函数的返回值。

fetch('examples/demo.txt') .then(response => response.text()) .then(text => pc.log(text));

Stream, Service Workers等相关技术,则是脱离了Promise就无法运行。

查看状态

Promise3种排他的状态,开始时其状态为pending;如果成功,其状态为fullfilled;如果失败,其状态为rejected

除此之外,如果Promise的状态是fullfilledrejected,也称为settledresolved

但,我们不能直接使用下面的代码来查看其状态:

console.log(aPromise.status); // undefined

只能查看aPromise的全部内部状态:

console.log(aPromise); // all inner states

下面的代码可查看其全部内部状态的细节:

let aPromise = new Promise((resolve) => { setTimeout(() => { resolve("Well done!"); }, 5 * 1000); }); let isJobDone = false; function showStatus(aPromise) { console.log(aPromise); if (isJobDone) { clearInterval(tickId); } } let tickId = setInterval(() => showStatus(aPromise), 1 * 1000); aPromise.then(() => { console.log('Time up'); isJobDone = true; });

首先,在创建aPromise对象时,令其5秒后完成工作。

其次,每隔1秒,查看其状态。

第三,在工作完成后,最后一次查看其状态,并取消定时器。

运行代码,显示:

Promise {status: "pending"} Promise {status: "pending"} Promise {status: "pending"} Promise {status: "pending"} Time up Promise {status: "fulfilled", result: "Well done!"}

需注意的是,在Promise内部,使用属性名称result来存储resolve的参数值。

虽然只能看而不能调用,但至少可以帮助我们了解Promise的内部细节,以加深对其了解。

并发控制

Promise有几个静态方法,可以有效地管理多个Promises如何协同工作。

先看all方法。此方法在所有promises对象都成功,或只要有一个失败时,就会触发。

let promise1 = new Promise((resolve) => { setTimeout(() => { resolve(`First job done`); }, 1 * 1000); }); let promise2 = new Promise((resolve) => { setTimeout(() => { resolve(`Second job done`); }, 2 * 1000); }); let promise3 = new Promise((resolve) => { setTimeout(() => { resolve(`Third job done`); }, 3 * 1000); }); Promise.all([promise1, promise2, promise3]) .then((values) => { console.log(values); // ["First job done", "Second job done", "Third job done"] });

共有3个promises对象,分别在1秒、2秒、3秒后完成各自的工作。上面的代码将在3秒后,将所有工作成果都打包进一个数组中并打印出来。类似于接力赛跑,3个选手都跑到终点线后,方可打印成绩。而如果期间有任何一个选手出局,也会立即打印该队的成绩。

allSettled方法则不管成败,必须等待他们每个人的最后结果。

any方法则不管成败,只要有一个完成了工作,或所有人都出局,就会触发。

race方法则当一人成功则触发成功,或当一人出局,就会触发出局。

将AJAX转换为Promise

import {ajax} from '/js/esm/ajax.js'; function ajaxLoadTextAsPromise() { return new Promise((resolve) => { ajax.loadText('/test.txt', (data) => { resolve(data); }); }); } ajaxLoadTextAsPromise().then((data) => { console.log(data); });

神奇的resolve静态方法

在上面,我们使用这样的代码来创建一个Promise对象:

let a = new Promise((resolve) => { resolve(5); });

这样的代码还不够简练。我们可以编写等效的代码如下:

let a = Promise.resolve(5); console.log(a); // Promise {<fullfilled>: 1}

Promise的静态方法resolve直接创建并返回一个Promise对象,并且直接为then回调函数传入参数值5。因此,我们可以直接调用athen方法:

a.then(value => { console.log(value); });

用这种方法创建并返回一个Promise对象,非常方便。

串联

let a = Promise.resolve(1); let b = a.then(value => { value += 1; return Promise.resolve(value); }); console.log(a); // Promise {<fullfilled>: 1} console.log(b); // Promise {<pending>}

变量a在调用then方法时,在其内部,对数值加1,再返回一个新的Promise对象。

此时查看两个对象的状态,变量b由于未调用then方法,因此还属于pending状态。

b.then(value => { console.log(value); // 2 }); console.log(b); // Promise {<pending>}

上面的代码引入了另一个新的变量用以存储所返回的Promise对象。但更直接的,我们可以不引入新对象,直接串联:

let a = Promise.resolve(1); a.then(value => { value += 1; return Promise.resolve(value); }).then(value => { console.log(value); // 2 }); console.log(a); // Promise {<fullfilled>: 1}

因此,如果需要串联,只需在then方法中返还一个新的Promise对象即可。

一个稍微复杂的例子

结合上面几节的内容,我们现在可以编写一个实用的用例代码。

static load六十四卦Text(卦名) { return ajax.loadTextAsPromise('/data/txt/六十四卦.txt') .then(text => { let regexp = new RegExp(`(${卦名}(卦.{1,3})\\n(.+\\n){16}(用.+\\n.+\\n)?)`, 'g'); let resultArr; let 六十四卦Text; while((resultArr = regexp.exec(text)) !== null) { 六十四卦Text = resultArr[0]; } return Promise.resolve(六十四卦Text); }); }

此例中,loadTextAsPromise返回一个Promise对象。虽是文本文件,但所有六十四卦的内容都在里面,故需要根据特定的卦名取出相应的卦即可。上面代码使用正则表达式来快速取出符合条件的卦。由于需要对数据进行进一步加工,故在then方法中又再次返回一个Promise对象。

这样,在客户端就可以使用这样的代码:

MhysTextLoader.load六十四卦Text('大有').then(data => { console.log(data); });

虽然涉及到的对象较多,但思路清晰后,应该不难理解。

await

await的本质

let promise = new Promise((resolve) => { setTimeout(() => { resolve(25); }, 1 * 1000); }); promise.then(value => { pc.log(value); }); pc.log('End of main thread.');

上面代码,有2条打印语句。第一条打印aPromise的结果,第二条语句打印主线程代码结束的信息。从代码编写顺序来讲,第一条打印语句应先运行。但由于aPromise1秒后才会产生值,因此最终结果为,第二条语句,也即主线程中的语句先被打印了。

Promise为异步机制,它不会堵塞主线程。这意味着主线程不会停下来等它。因此,在使用Promise时,我们须清楚地知道:

  1. 存在主线程与异步线程;
  2. 主线程的代码会依据代码编写的顺序立即依序执行;
  3. 异步线程会单独执行,异步线程结束的时间依各个Promise而定。
  4. 主线程的代码,即使是在最后编写,也可能先得到执行。

异步线程的一个典型应用是,在异步线程中处理数据,但又不会堵塞UI主线程的执行。

但有时,我们需要在主线程在等待异步线程的结果才能运行后续的主线程代码。此时,有2种解决方案。

第一种是将依赖于异步线程的主线程代码放在异步线程的then方法中进行排队。

let promise = new Promise((resolve) => { setTimeout(() => { resolve(25); }, 1 * 1000); }); promise.then(value => { let x = value * 10; pc.log(`The result is: ${x}`); pc.log('End of asynchronous thread.'); }); pc.log('End of main thread.');

但由于主线程要干的活很多,从这么多的活中挑选哪些活需要等待哪个异步线程,并改在相应的异步线程代码中编写代码,不是一个好办法。

换个角度。因为异步线程数量较少,如果我们知道哪些异步线程需要向后面的代码提供基础数据,我们干脆让此异步线程参与到主线程中。具体实际思路是,使用关键字await等待此异步线程执行完毕,然后继续执行主线程代码。

let promise = new Promise((resolve) => { setTimeout(() => { resolve(25); }, 1 * 1000); }); async function getValue() { let result = await promise; pc.log(result); pc.log('End of asynchronous thread.'); } await getValue(); pc.log('End of main thread.');

首先,await须放在async的环境中。

其次,await promise将自动调用aPromisethen方法,取得并返回结果。

第三,async环境下await后面的代码,将自动等待await的代码执行完毕后,才依序执行。

第四,await只影响其所在scope作用域内的后续的代码。上面使用了2await,第一个只在getValue函数内有效,而第二个在整体范围内有效。作为对比,取消第二个await的代码及效果如下:

let promise = new Promise((resolve) => { setTimeout(() => { resolve(25); }, 1 * 1000); }); async function getValue() { let result = await promise; pc.log(result); pc.log('End of asynchronous thread.'); } getValue(); pc.log('End of main thread.');

取消第二个await后,最后一行语句无须等待getValue函数执行完毕,就先被执行了。

async自调用函数

只要使用了await的作用域,必须带有async。这就导致了其带有向上污染的特点。我们可以通过使用匿名函数自调用的技巧,将asyncawait统统限制在一个特定的区域中。语法如下:

await (async() => { // run await statements // ... })();

下面为使用上面代码的一个例子:

let promise = Promise.resolve(50); await (async() => { let value = await promise; pc.log(value); pc.log('End of asynchronous thread.'); })(); pc.log('End of main thread.');

将Promise返回值向变量赋值

let promise = Promise.resolve(50); let num = await (async() => { pc.log('In asynchronous thread.'); return await promise; })(); pc.log(num); pc.log('end');

for await ... of 语句

先看一个调用Promise.all方法的例子。

function genPromise(id, secs) { return new Promise((resolve) => { setTimeout(() => { resolve({id: id, secs: secs}); }, secs * 1000); }); } let promises = []; promises.push(genPromise(1, 3)); promises.push(genPromise(2, 1)); promises.push(genPromise(3, 2)); Promise.all(promises) .then(values => { for (let {id, secs} of values) { pc.log(`id: ${id}, secs: ${secs}`); } }); pc.log("End of main thread");

先创建3Promise实例,每个实例经解析后都得到一个{id: m, secs: n}的对象,后者表示该实例经多少秒后才解析完毕。第一个3秒,第一个1秒,第一个2秒。所有实例都打包进一个promises数组中。

接着,调用Promise.all方法,待其全部解析完毕后,再调用for ... of语句来遍历promises数组。

从运行效果来看,因为已经全部解析后才遍历,因此主线程代码先运行。而在遍历过程中,按各元素在数组中的顺序来遍历。

再看直接使用for ... of语句的例子。

function genPromise(id, secs) { return new Promise((resolve) => { setTimeout(() => { resolve({id: id, secs: secs}); }, secs * 1000); }); } let promises = []; promises.push(genPromise(1, 3)); promises.push(genPromise(2, 1)); promises.push(genPromise(3, 2)); for (let promise of promises) { promise.then(({id, secs}) => { pc.log(`id: ${id}, secs: ${secs}`); }); pc.log('End of an iteration'); } pc.log("End of main thread");

主线程中的打印语句先运行,而在各个promise中,依据解析时间的顺序来遍历。

再看使用for await ... of语句的例子。

function genPromise(id, secs) { return new Promise((resolve) => { setTimeout(() => { resolve({id: id, secs: secs}); }, secs * 1000); }); } let promises = []; promises.push(genPromise(1, 3)); promises.push(genPromise(2, 1)); promises.push(genPromise(3, 2)); let index = 1; for await (let promise of promises) { pc.log(promise); pc.log(`End of iteration ${index++}`); } pc.log('End of main thread');

在遍历方法体的代码中,其效果和第一次调用Promise.all方法完全一样。其特点为:

  1. 先等待所有promises都解析完毕后,才进行遍历。
  2. for await ... of语句块中的各个promise,均已得到解析,可直接使用其解析后的值。
  3. for await ... of语句块中的所有代码,不管是否属于处理Promise对象的语句,都会按代码顺序运行。
  4. 在遍历过程中,按各元素在数组中的顺序来遍历。

而与Promise.all不同的是,for await ... of语句将堵塞主线程,即先运行完该语句,再继续运行主线程后面的语句。此特点比较便利。

参考资源

  1. MDN Using promises
  2. MDN Promise constructor
  3. MDN Promise object
  4. MDN await
  5. MDN Promise.then()