JavaScript promises的执行顺序是什么?

52

我想了解以下使用JavaScript Promise的代码片段的执行顺序。

Promise.resolve('A')
  .then(function(a){console.log(2, a); return 'B';})
  .then(function(a){
     Promise.resolve('C')
       .then(function(a){console.log(7, a);})
       .then(function(a){console.log(8, a);});
     console.log(3, a);
     return a;})
  .then(function(a){
     Promise.resolve('D')
       .then(function(a){console.log(9, a);})
       .then(function(a){console.log(10, a);});
     console.log(4, a);})
  .then(function(a){
     console.log(5, a);});
console.log(1);
setTimeout(function(){console.log(6)},0);

结果是:

1
2 "A"
3 "B"
7 "C"
4 "B"
8 undefined
9 "D"
5 undefined
10 undefined
6

我对执行顺序 1 2 3 7 ... 感到好奇,而不是值为 "A""B" ... 的顺序。

我的理解是如果一个promise被解决,则then函数会被放入浏览器事件队列中。所以我期望的结果是 1 2 3 4 ...。

为什么输出的日志顺序不是 1 2 3 4 ...?


1
承诺通过它们的return值工作,它们并不神奇 :) 如果你不从then返回,它就不会起作用。如果你在添加函数时添加return,它将按你的预期工作。这样的重复有大约100个 - 我们只需等待bergi或jfriend指出一个好的。 - Benjamin Gruenbaum
4
我对执行顺序1 2 3 7…感到好奇,而不是关于值"A"、"B"的问题。 - T.J. Crowder
2
如果您没有显式地从 Promise 中 return,它将隐式返回 undefined - Srle
3个回答

128

评论

首先,在.then()处理程序中运行承诺并且不从.then()回调中返回这些承诺会创建一个完全不同的未连接的承诺序列,与任何父承诺都不同步。通常,这是一个错误,事实上,一些承诺引擎在您这样做时实际上会发出警告,因为这几乎永远不是所需的行为。唯一需要这样做的时间是当您正在执行某种“点火和忘记”操作时,您不关心错误,也不关心与世界其他部分的同步。

因此,在.then()处理程序中的所有Promise.resolve()承诺都会创建独立于父链的新承诺链。对于实际异步操作,您没有非连接的独立承诺链的确定行为。这有点像并行启动四个ajax调用。您不知道哪一个会先完成。现在,由于那些Promise.resolve()处理程序中的所有代码恰好是同步的(因为这不是真实世界的代码),所以您可能会得到一致的行为,但这不是承诺的设计重点,因此我不会花太多时间尝试弄清楚哪个只运行同步代码的Promise链将首先完成。在现实世界中,这并不重要,因为如果顺序很重要,那么您不会以这种方式留下事情的机会。总结
  1. .then()处理程序在当前执行线程完成后异步调用(正如Promises/A+规范所说,当JS引擎返回到“平台代码”时)。即使是同步解决的承诺,例如Promise.resolve().then(...),也是如此。这样做是为了编程一致性,以便无论承诺是立即解决还是稍后解决,.then()处理程序始终被异步调用。这可以防止一些时间错误,并使调用代码更容易看到一致的异步执行。

  2. 没有规范确定setTimeout()与计划的.then()处理程序的相对顺序,如果两者都排队并准备好运行。在您的实现中,挂起的.then()处理程序总是在挂起的setTimeout()之前运行,但Promises/A+规范规定这不是确定的。它说.then()处理程序可以以许多方式安排,其中一些将在挂起的setTimeout()调用之前运行,而另一些可能在挂起的setTimeout()调用之后运行。例如,Promises/A+规范允许使用setImmediate()setTimeout()安排.then()处理程序,前者将在挂起的setTimeout()调用之前运行,后者将在挂起的setTimeout()调用之后运行。因此,您的代码不应依赖于任何顺序。

  3. 多个独立的Promise链没有可预测的执行顺序,您不能依赖于任何特定的顺序。这就像并行发射四个ajax调用,您不知道哪一个会首先完成。

  4. 如果执行顺序很重要,请不要创建依赖于微小实现细节的竞争。而是链接承诺链以强制执行特定的执行顺序。

  5. 通常情况下,您不希望在未从处理程序返回的情况下创建独立的承诺链。除了罕见的无需错误处理的情况外,这通常是一个错误。

逐行分析

下面是你代码的分析。我添加了行号并清理了缩进,以便更容易讨论:

1     Promise.resolve('A').then(function (a) {
2         console.log(2, a);
3         return 'B';
4     }).then(function (a) {
5         Promise.resolve('C').then(function (a) {
6             console.log(7, a);
7         }).then(function (a) {
8             console.log(8, a);
9         });
10        console.log(3, a);
11        return a;
12    }).then(function (a) {
13        Promise.resolve('D').then(function (a) {
14            console.log(9, a);
15        }).then(function (a) {
16            console.log(10, a);
17        });
18        console.log(4, a);
19    }).then(function (a) {
20        console.log(5, a);
21    });
22   
23    console.log(1);
24    
25    setTimeout(function () {
26        console.log(6)
27    }, 0);
第一行开始一个Promise链并附加了一个.then()处理程序。由于Promise.resolve()立即解决,Promise库将在此JavaScript线程完成后安排第一个.then()处理程序运行。在Promises/A+兼容的Promise库中,所有.then()处理程序都在当前执行线程完成并且JS返回事件循环时异步调用。这意味着在此线程中的任何其他同步代码,例如console.log(1),将会接下来运行,这就是你看到的。

所有其他顶级.then()处理程序(第4、12、19行)都在第一个处理程序之后链接,并且只有在第一个处理程序获得机会后才会运行。它们本质上在此时被排队。

由于setTimeout()也在此初始执行线程中,因此它会运行,从而安排定时器。

这就是同步执行的结束。现在,JS引擎开始运行计划在事件队列中的东西。

据我所知,无法保证在此执行线程后立即运行的setTimeout(fn, 0).then()处理程序哪个先执行。由于.then()处理程序被认为是“微任务”,所以不会让我惊讶它们在setTimeout()之前运行。但是,如果您需要特定的顺序,则应编写可确保顺序的代码,而不是依赖于此实现细节。
无论如何,在第1行定义的.then()处理程序接下来运行。因此,您会从console.log(2, a)看到输出2 "A"
接下来,由于上一个.then()处理程序返回了一个普通值,因此该承诺被视为已解决,因此在第4行定义的.then()处理程序运行。这是您正在创建另一个独立的承诺链并引入通常是错误的行为的地方。 第5行,创建了一个新的Promise链。它解决了初始承诺,然后安排两个.then()处理程序在当前执行线程完成时运行。在当前执行线程中是第10行的console.log(3, a),这就是为什么你看到下一个的原因。然后,这个执行线程完成并返回调度程序以查看接下来要运行什么。
现在我们有几个.then()处理程序在队列中等待下一步运行。有我们刚刚在第5行安排的处理程序,还有更高级别链中的下一个处理程序在第12行。如果你在第5行这样做:
return Promise.resolve(...).then(...)

如果你将这些promise链接在一起,它们将按顺序协调。但是,由于没有返回promise值,您启动了一个全新的promise链,它与外部更高级别的promise不协调。在您的特定情况下,promise调度程序决定先运行更深层嵌套的.then()处理程序。老实说,我不知道这是根据规范、约定还是一个promise引擎与另一个之间的实现细节。如果顺序对您很重要,那么您应该通过按特定顺序链接promises来强制排序,而不是依赖于谁赢得了第一场比赛。
无论如何,在您的情况下,这是一个调度竞赛,您正在运行的引擎决定接下来运行定义在第5行的内部.then()处理程序,因此您可以看到在第6行指定的7“C”。然后它返回空,因此该promise的已解决值变为undefined

回到调度程序,它在第12行上运行.then()处理程序。这又是一个竞争,即等待运行的第7行上的处理程序与之竞争。我不知道为什么它选择了其中一个而不是另一个,除了说这可能是不确定的或因承诺引擎而异,因为代码没有指定顺序。无论如何,第12行上的.then()处理程序开始运行。这再次创建了一个新的独立或不同步的承诺链,就像前一个一样。它再次安排.then()处理程序,然后您会从该.then()处理程序中的同步代码获得4 "B"。所有同步代码都在该处理程序中完成,现在它返回调度程序以获取下一个任务。

在调度程序中,它决定在第7行运行.then()处理程序,你会得到8 undefined。那里的 promise 是 undefined,因为该链中之前的 .then() 处理程序没有返回任何内容,因此其返回值为 undefined,因此这是该 promise 链的解析值在该点处。
此时,到目前为止的输出为:
1
2 "A"
3 "B"
7 "C"
4 "B"
8 undefined

再次强调,所有同步代码都是这样完成的,它会返回到调度程序,然后决定运行第13行定义的.then()处理程序。该处理程序运行并输出9 "D",然后又返回到调度程序。

与之前嵌套的Promise.resolve()链一致,调度程序选择运行下一个外部.then()处理程序,该处理程序在第19行定义。它运行并输出5 undefined。这仍然是undefined,因为该链中的上一个.then()处理程序没有返回值,因此Promise的解析值为undefined

到目前为止,输出结果如下:

1
2 "A"
3 "B"
7 "C"
4 "B"
8 undefined
9 "D"
5 undefined

此时只有一个 .then() 处理程序被安排运行,因此它运行在 第15行 上定义的处理程序,接下来您将获得输出 10 undefined
最后,setTimeout() 被执行,并且最终输出为:
1
2 "A"
3 "B"
7 "C"
4 "B"
8 undefined
9 "D"
5 undefined
10 undefined
6

如果有人试图准确预测这个程序运行的顺序,那么就会有两个主要问题。
  1. 未决的 .then() 处理程序与等待执行的 setTimeout() 调用之间的优先级如何。
  2. 在等待运行的多个 .then() 处理程序中,Promise 引擎如何决定优先级。根据您使用此代码的结果,它不是先进先出(FIFO)的。
对于第一个问题,我不知道这是否符合规范或者只是 Promise 引擎/JS 引擎中的一种实现选择,但你报告的实现似乎优先处理所有未决的 .then() 处理程序,然后再处理任何 setTimeout() 调用。你的情况有点奇怪,因为除了指定 .then() 处理程序之外,你没有任何实际的异步 API 调用。如果你在这个 Promise 链的开头有任何实际需要花费时间来执行的异步操作,那么你的 setTimeout() 将在真正的异步操作上的 .then() 处理程序之前执行,因为真正的异步操作需要实际时间来执行。因此,这是一个有点牵强的例子,不是真实代码的通常设计情况。
对于第二个问题,我看到一些讨论涉及如何优先处理不同嵌套级别的待处理.then()处理程序。我不知道这个讨论是否在规范中得到解决。我更喜欢以一种方式编写代码,使得这个细节对我没有影响。如果我关心我的异步操作的顺序,那么我会链接我的承诺链来控制顺序,这个实现细节的层次不会对我产生影响。如果我不关心顺序,那么我就不关心顺序,因此这个实现细节的层次也不会对我产生影响。即使这是在某个规范中,它似乎也是不应该被信任的细节,除非你已经在你要运行的所有地方都测试过(不同的浏览器、不同的Promise引擎)。因此,我建议在存在未同步的承诺链时不要依赖特定的执行顺序。

您可以通过像这样链接所有承诺链(返回内部承诺以便将其链接到父级链)来使订单100%确定:

Promise.resolve('A').then(function (a) {
    console.log(2, a);
    return 'B';
}).then(function (a) {
    var p =  Promise.resolve('C').then(function (a) {
        console.log(7, a);
    }).then(function (a) {
        console.log(8, a);
    });
    console.log(3, a);
    // return this promise to chain to the parent promise
    return p;
}).then(function (a) {
    var p = Promise.resolve('D').then(function (a) {
        console.log(9, a);
    }).then(function (a) {
        console.log(10, a);
    });
    console.log(4, a);
    // return this promise to chain to the parent promise
    return p;
}).then(function (a) {
    console.log(5, a);
});

console.log(1);

setTimeout(function () {
    console.log(6)
}, 0);

在Chrome中,这将产生以下输出:
1
2 "A"
3 "B"
7 "C"
8 undefined
4 undefined
9 "D"
10 undefined
5 undefined
6

而且,由于承诺已经全部链接在一起,因此承诺顺序由代码定义。唯一剩下的实现细节是 setTimeout() 的时间安排,就像您的示例一样,在所有待处理的 .then() 处理程序之后。

编辑:

经过检查Promises/A+规范,我们发现以下内容:

2.2.4 必须在执行上下文堆栈仅包含平台代码时才能调用 onFulfilled 或 onRejected。[3.1]

....

3.1 这里的“平台代码”指引擎、环境和承诺实现代码。实际上,这个要求确保 onFulfilled 和 onRejected 在 then 被调用的事件循环后异步执行,并使用新的堆栈。这可以使用“宏任务”机制(如 setTimeout 或 setImmediate)或“微任务”机制(如 MutationObserver 或 process.nextTick)来实现。由于承诺实现被认为是平台代码,因此它本身可能包含一个任务调度队列或“跳板”,其中处理程序被调用。

这段文字表明,.then()处理程序必须在调用栈返回到平台代码后异步执行,但完全由实现决定如何执行,无论是使用类似于setTimeout()的宏任务还是类似于process.nextTick()的微任务。因此,根据此规范,它是不确定的,不应该依赖它。
我在ES6规范中没有找到关于宏任务、微任务或承诺.then()处理程序与setTimeout()相关的时间信息。这也许并不奇怪,因为setTimeout()本身不是ES6规范的一部分(它是一个主机环境函数,而不是语言特性)。
我没有找到任何支持这一观点的规范,但这个问题的答案Difference between microtask and macrotask within an event loop context解释了在浏览器中宏任务和微任务通常是如何工作的。

如果您想了解更多关于微任务和宏任务的信息,这里有一篇有趣的参考文章:Tasks, microtasks, queues and schedules


Promises/A+ 规范 中,添加了有关待处理的 .then() 处理程序和 setTimeout() 执行时间的信息。 - jfriend00
据我所知,没有任何保证哪个先执行,这完全取决于承诺的实现。setTimeout 实际上有一个最小超时延迟(大约是 15ms),但是 Promise 可能会使用 setImmediate,当然会在 JS 事件循环中添加一个与同时设置的计时器相同的计时器。Promise 的实现通常使用 setTimeout(fn, 0),然后按照它们的最小计时器到期的顺序解决,这将按照它们被调用的顺序发生。 - zzzzBov
另外,我应该提一下我没有投反对票。我认为你的回答非常好。 - zzzzBov
是的,我只是在详细阐述,包括我认为对该部分有关的要点。 - zzzzBov
这个回答很好,但我认为有点太长了。而且它包含了太多的“我不知道”和重复。回答idk的问题:确实没有指定多个独立链之间的回调顺序。A+只保证在像p.then(a).then(b)p.then(a); p.then(b)这样的情况下,a会在b之前被调用。有关参考,请参见https://github.com/promises-aplus/promises-spec/issues/77和https://github.com/promises-aplus/promises-spec/issues/92。也许你可以把你的帖子压缩一下 :-) - Bergi
显示剩余11条评论

2
浏览器的JavaScript引擎有一个叫做“事件循环”的东西。一次只有一个JavaScript代码线程在运行。当按钮被点击或AJAX请求或任何其他异步完成时,新事件将被放入事件循环中。浏览器按顺序执行这些事件。
你正在查看的是异步运行的代码。当异步代码完成后,它会向事件循环添加一个适当的事件。事件添加的顺序取决于每个异步操作完成所需的时间。
这意味着如果你使用类似AJAX的东西,你无法控制请求完成的顺序,你的承诺每次执行的顺序可能都不同。

3
实际上,在大多数浏览器中,.then 回调会在微任务队列中执行,在当前执行完成后,事件循环进入主循环之前将其清空。 - jib

2
HTML事件循环包含各种任务队列和一个微任务队列。在每个事件循环的开始时,将从其中一个任务队列中取出一个新任务,这些被俗称为“宏任务”。然而,微任务队列不仅在每个事件循环迭代时访问一次。只要JS调用栈为空,就会访问它。这意味着在单个事件循环迭代期间可以多次访问它(因为在事件循环迭代中执行的所有任务都不来自任务队列)。此外,微任务队列的另一个特殊之处是,在出队列时排队的微任务将立即在同一检查点中执行,而不会让事件循环执行其他任何操作。
在您的示例中,第一个Promise.resolve("A")中的所有链接或内部内容都是同步的,或者正在排队一个新的微任务,而没有任何实际排队的(宏)任务。这意味着当事件循环进入微任务检查点以执行第一个Promise反应回调时,它将在执行完最后一个排队的微任务之前不离开该微任务检查点。因此,您的超时在这里是无关紧要的,它将在所有这些Promise反应之后执行。
澄清了这一点之后,我们现在可以遍历您的代码,并将每个Promise反应替换为它将调用的底层queueMicrotask(回调)。然后很清楚执行顺序是什么了。

queueMicrotask(function(a) { // first callback
  console.log(2, a, 1);

  queueMicrotask(function(a) { // second callback
    // new branch
    queueMicrotask(function(a) { // third callback
      console.log(7, a, 3);
      queueMicrotask(function(a) { // fifth callback
        console.log(8, a, 5);
      });
    }.bind(null, "C"));

    // synchronous (in second callback)
    console.log(3, a, 2);

    //main branch
    queueMicrotask(function(a) { // fourth callback (same level as third, but called later)
      // new branch
      queueMicrotask(function(a) { // sixth callback
        console.log(9, a, 6);
        queueMicrotask(function(a) { // eighth callback
          console.log(10, a, 8);
        });
      }.bind(null, "D"));

      // synchronous
      console.log(4, a, 4);

      // main branch
      queueMicrotask(function(a) { // seventh callback
        console.log(5, a, 7);
      });
    }.bind(null, a))
  }.bind(null, "B"));
}.bind(null, "A"));

// synchronous
console.log(1);
// irrelevant
setTimeout(function() {
  console.log(6);
});

或者,如果我们将每个回调函数从链条中提取出来:

function first(a) {
  console.log(2, a, 1);
  queueMicrotask(second.bind(null, "B"));
}
function second(a) {
  queueMicrotask(third.bind(null, "C"));
  console.log(3, a, 2);
  queueMicrotask(fourth.bind(null, a));
}
function third(a) {
  console.log(7, a, 3);
  queueMicrotask(fifth);
}
function fourth(a) {
  queueMicrotask(sixth.bind(null, "D"));
  console.log(4, a, 4);
  queueMicrotask(seventh);
}
function fifth(a) {
  console.log(8, a, 5);
};
function sixth(a) {
  console.log(9, a, 6);
  queueMicrotask(eighth);
}
function seventh(a) {
  console.log(5, a, 7);
}
function eighth(a) {
  console.log(10, a, 8);
}
queueMicrotask(first.bind(null, "A"));

现在我应该注意的是处理已经解决(或立即解决)的 Promise 不是你每天都会遇到的事情,所以要注意,一旦其中一个 Promise 反应实际上绑定到一个异步任务,顺序将不再可靠,而且由于不同(macro)任务队列可能有不同的优先级由 UA 定义。
然而,我认为了解 microtask-queue 的工作原理仍然很重要,避免通过期望 Promise.resolve() 让事件循环呼吸来阻塞事件循环。

非常棒的答案,特别是使用 queueMicrotask 调用。在混合立即解决异步函数和实际执行任务的异步函数时非常有帮助。 - Seth

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接