chunpu / blog

personal blog render by jekyll
MIT License
51 stars 8 forks source link

为什么 Promise 的 onFulfilled 和 onRejected 必须是异步的? #96

Open chunpu opened 6 years ago

chunpu commented 6 years ago

Promise 有一道经典的前端面试题

console.log(1)
var promise = new Promise(resolve => {
  console.log(2)
  resolve()
})
promise.then(() => {
  console.log(3)
})
console.log(4)

问输出结果是什么?

本题考察的是面试者对 Promise 执行顺序的掌握

答案是 1, 2, 4, 3

为啥不是 1, 2, 3, 4 呢? 上网查一下, 面经会告诉你 then 是异步的, 3 会在 4 的后面执行

上一篇文章写道

Promise/A+ 规范要求 handler 执行必须是异步的, 具体可以参见标准 3.1 条

那问题又来了, 为什么规范会有这样一个 强制异步 的要求呢?

我们知道 Promise handler 执行时机其实很容易定义

  1. 如果父亲 Promise 的状态不是 pending, 就立马执行
  2. 否则, 等到父亲 Promise 状态变化时才执行

因此, 理论上 Promise handler 执行不管是异步还是同步都可以完成这个逻辑

那到底为什么标准要专门定义这一条强制异步呢?

其实, 标准建立之初, 就有很多人有这个分歧

正方与反方

我们把强制异步的标准派当做正方, 把认为同步异步都可以的当做反方

反方认为: 如果本来是同步的逻辑被强制异步执行, 肯定是会有性能损失, 标准不应该强制异步

正方则认为: 流程可预测(predictably)是最为重要的, 标准需要强制异步

最终, 正方胜利, 标准制定者认为 Promise 主要解决的问题是 流程抽象规范一致性, 性能并不是首要考虑的

var foo
promise.then(() => {
  foo = {}
})
console.log(foo.bar) // always crash

任何一个 Promise 使用者都应该知道这段代码是会报错的

老问题类比

我们想到另一个经典的图片加载问题, 代码如下

var img = new Image()
img.src = 'foo.png'
img.onload = function() {
  alert('img loaded') // 未必执行
}

在一些旧版IE中, onload 可能不会执行, 原因是 img 可能被缓存到浏览器中了, 设置 img src 的时候直接读取了缓存并且立刻执行了 onload, 还没等到后面那行 img 监听 load 事件

因此我们只能把 src 和 onload 调换顺序, 确保 load 事件挂载在设置 src 前面

var img = new Image()
img.onload = function() {
  alert('img loaded') // 确保执行
}
img.src = 'foo.png'

这也是规范定制者担忧的问题, Unexpected! 意外的!

我们希望代码执行顺序是完全可以预测的, 流程控制最首要的就是可控!

完全可控的执行顺序

console.log(1)
var promise = new Promise(resolve => {
  console.log(2)
  resolve()
})
promise.then(() => {
  console.log(3)
})
console.log(4)

回到最初的面试题, 我们确保执行输出顺序永远是 1, 2, 4, 3

要注意另一个容易混淆的点, Promise 构造器的 executor 是同步的

这个也好理解, 顶级 Promise 的值是可以直接设置的, 并不会经过 handler

如果把 resolved 和 rejected 当成两个事件的话, 事件 handler 才需要强制异步, 而构造器本身不需要异步

参考文档

  1. https://github.com/promises-aplus/promises-spec/issues/4
  2. https://github.com/promises-aplus/promises-spec/issues/68
joeyhub commented 5 years ago

If a promise invokes anything asynchronous then it is not a promise.

The purpose of a promise is to represent a value where its availability may not be guaranteed.

If the promise influences the availability of the value in any way then it is not a promise but something more.

This is a common mistake in many promise designs.

Promises and futures are passive, they are like each side of a wire. When they interfere with the availability of the value they become active and are no longer only promises and futures.

It is the mechanism on one side of the wire that is responsible for determining when the signal is available, it is not the responsibility of the wire.

"Promise/A+" is just a socket type. It can be used if you want compatibility with other systems that have chosen to accept that plug socket.

If you make your own promises, you do not need to conform to "Promise/A+".

If you do not have compatibility with "Promise/A+" as a goal then you should not conform to "Promise/A+" as it is a defective design.

Originally this was not a problem. I would write my own code instead of relying on the works of inferior programmers. Now it is a big problem because "Promise/A+" was adopted for "async/await", a new native language construct and the promise implementation is forced. It cannot be replaced with another in user space (in the code).

The argument of this is an abstraction and that it can be opted out is no longer relevant. It is being used as though it is more than an abstraction but rather a correct representation within the javascript engine. It is not a correct representation.

Unfortunately many people are corrupt and those responsible for this design mistake refuse to disclose it.

I have disclosed the flaw here:

https://dev.to/joeyhub/async-await-a-slight-design-flaw-2h2j

The analogy is this:

If you flip a coin you expect one of two sides, it is inconsistent by nature. That inconsistency is real and useful data.

With promises they should also reflect if there was inconsistency, that is natural, there is nothing wrong with the inconsistency.

What we have with "Promise/A+" is called premature normalisation. It takes all coins and makes both sides the same. You can no longer find out which side the coin landed on.

To normalise is usually a second step in a two step operation. You flip the coin and then if it was heads you flip it to tails (the other side).

The specification of "Promise/A+" always flips the coin to tails after it has been tossed. Even if the coin would always consistently land on heads.

This violates natural law. For example, having a negative and positive charge is required. You cannot make all charges positive in all systems for consistency.

Normalising should be only applied when needed. By applying it in all cases, it gets applied when not needed. I can be applied even when we need to know which side the coin landed on!

var callback = function(wasAsync, error, result) {
    if(!wasAsync) {
        defer(callback, error, result);
        return;
    }

    doWork();
};

In this example we see explicit and optional normalisation rather than implicit mandatory normalisation. This is truly what is meant by controllable execution order.

Your example of controllable execution order is not controllable. It is forced.