为什么这个循环每次迭代都重复两次?

5
下面的函数会将每个数字打印两次。有人能解释一下它是如何工作的吗?我尝试过调试,但我所看到的是变量i只在每第二次迭代时增加。

async function run(then) {
    for (let i = 1; i <= 10; i++) {
        console.log(i);
        then = await { then };
    }
}

run(run);

具体来说,有两件事我不理解。

  • 为什么在每次迭代中i都没有增加?
  • then = await { then };的确切作用是什么? 我最初猜测它会等待嵌套的异步调用run完成后再进入下一次迭代,但这似乎并不是情况。

1
这更多地与 run(run); 相关。 - VLAZ
3
问题是“为什么会这样工作”,而不是“我该如何修复它”。 - Ivar
2
@JakeAve 这是错误的,await 对于这段代码的操作至关重要。 - VLAZ
2
{ then } 看起来像是一个自定义的 thenable 对象。非常聪明。我想知道这个的起源是什么。 - Wiktor Zychla
3
我几乎把这看作是对一个应该保持空缺的职位的面试问题。 - Wiktor Zychla
显示剩余10条评论
1个回答

8
我们可以通过进行小幅改写并添加日志记录来使其更加清晰:

async function run(callback) {
    let then = callback;
    for (let i = 1; i <= 10; i++) {
        console.log(callback === run ? "A" : "B", i);
        then = await { then };
    }
}

run(run);
.as-console-wrapper { max-height: 100% !important; }

这表明实际上有两个循环开始了。为简单起见,称其为 A 和 B。它们记录并 await,这意味着它们的日志交错并导致 A1、B1、A2、B2 等。

这是因为第一条语句:run(run)。它将相同的函数作为回调函数传递给自身。这不会 调用 回调函数,但这是解开它的第一步。


理解正在发生的事情的下一步是 await。你可以 await 任何值,在 大多数情况下,如果它不是一个 promise,那就无所谓了。如果你有 await 42; 它只是假装这个值是 Promise.resolve(42),并立即在下一个 tick 中继续操作。对于 大多数 非 promises 来说,这是正确的。唯一的例外是 thenables——具有 .then() 方法的对象。

当等待 thenable 时,它的 then() 方法会被调用:

const thenable = {
  then() {
    console.log("called");
  }
};

(async () => {
  await thenable;
})()

这就解释了await { then }语句,它使用了{ then: then }的简写形式,其中then是传递给run的回调函数。因此,它创建了一个thenable对象,当被await时,将执行回调函数。

这意味着第一次执行run()并在循环A的第一次迭代中,代码实际上是await { then: run },它将再次执行run,然后启动循环B。

每次都会覆盖then的值,因此它只能同时运行两个循环,而不是更多。


理解这段代码还涉及到thenable的更多内容。我之前展示了一个简单的例子,仅说明等待它会调用该方法。但实际上,await thenable会使用两个参数调用.then() - 成功和失败的函数。与Promise构造函数类似。

const badThenable = {
  then() {
    console.log("bad called");
  }
};

(async () => {
  await badThenable;
  console.log("never reached");
})();

const goodThenable = {
  then(resolve, reject) { //two callbacks
    console.log("good called");
    resolve(); //at least one needs to be called
  }
};

(async () => {
  await goodThenable;
  console.log("correctly reached");
})();

这很重要,因为run()期望回调,当执行await { then: run }时,它会调用run(builtInResolveFunction),然后将其传递给下一个await { then: builtInResolveFunction },反过来又会解决导致await解决。
总之,交错的日志记录只是任务解析方式的一个因素。

(async () => {
  for (let i = 1; i <= 10; i++){
    console.log("A", i);
    await Promise.resolve("just to force a minimal wait");
  } 
})();

(async () => {
  for (let i = 1; i <= 10; i++) {
    console.log("B", i);
    await Promise.resolve("just to force a minimal wait");
  } 
})();

如果有两个异步函数正在运行且没有什么需要真正等待的话:
  1. 其中一个将一直运行,直到它遇到await,然后暂停。
  2. 另一个将一直运行,直到它遇到await,然后暂停。
  3. 重复1和2,直到没有更多的await

1
我喜欢这样一个看起来很小/简单的代码片段可以有如此多的深度。讲解得很清楚。 :-) - Ivar
2
@Ivar,实际上还有一点。但是现在是睡觉时间了,明天我会比较忙。我会尽快添加的。本质上,A和B将解析器函数互相传递,并在其上调用await,*解析另一个await*并允许另一个循环继续。因此,例如对于A中的i = 3await { then }解析了B中i = 2await { then }。这种来回传递不仅使整个过程能够正常工作,而且还意味着对于B i = 10,等待永远不会解决,因为A已经完成。我想为它制作一个序列图以更好地说明。 - VLAZ
我相信你可以改进一下解释为什么没有无限递归的片段。目前还不是很清楚。 - Wiktor Zychla
因为 run(run) 只执行一次,而在 await { then: run } 内部也只执行一次。then = await {then} 重新分配了它。但每次调用 await { then } 实际上都会将解析器函数传递给 .then() 方法。这有点难以理解,因为同时发生了很多事情。很难完全清晰地解释。下一节从 "There is more to thenables that is relevant to fully grasp this code." 开始解释了解析器的内容。我想一次解释一个概念。await 行在这里做了很多事情。 - VLAZ
谢谢。回想起来,我现在认为很明显必须有两个循环交替运行才能给出重复迭代的假象。我从未想过可以使用等待承诺的回调函数改变异步代码的流程,但这也是很有道理的。我仍在努力弄清楚这两个循环是如何交替的。当我调用run(run(run))(3次run)时,我得到一个更复杂但仍可重现的模式。 - Leonor

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