2

I am a newbie to JS and I am trying to understand how Promise should work under the hood. Here is a custom implementation that looks reasonably good to me:

class MyPromise {

    constructor(executor) {
        this._resolutionQueue = [];
        this._rejectionQueue = [];
        this._state = 'pending';
        this._value;
        this._rejectionReason;

        try {
            executor(this._resolve.bind(this), this._reject.bind(this));
        } catch (e) {
            this._reject(e);
        }
    }

    _runRejectionHandlers() {

        while(this._rejectionQueue.length > 0) {
            var rejection = this._rejectionQueue.shift();

            try {
                var returnValue = rejection.handler(this._rejectionReason);
            } catch(e) {
                rejection.promise._reject(e);
            }

            if (returnValue && returnValue instanceof MyPromise) {
                returnValue.then(function (v) {
                    rejection.promise._resolve(v);
                }).catch(function (e) {
                    rejection.promise._reject(e);
                });
            } else {
                rejection.promise._resolve(returnValue);
            }
        }
    }

    _runResolutionHandlers() {
        while(this._resolutionQueue.length > 0) {
            var resolution = this._resolutionQueue.shift();

            try {
                var returnValue = resolution.handler(this._value);
            } catch(e) {
                resolution.promise._reject(e);
            }

            if (returnValue && returnValue instanceof MyPromise) {
                returnValue.then(function (v) {
                    resolution.promise._resolve(v);
                }).catch(function (e) {
                    resolution.promise._reject(e);
                });
            } else {
                resolution.promise._resolve(returnValue);
            }
        }
    }

    _reject(reason) {
        if (this._state === 'pending') {
            this._rejectionReason = reason;
            this._state = 'rejected';

            this._runRejectionHandlers();

            while(this._resolutionQueue.length > 0) {
                var resolution = this._resolutionQueue.shift();
                resolution.promise._reject(this._rejectionReason);
            }
        }
    }

    _resolve(value) {
        if (this._state === 'pending') {
            this._value = value;
            this._state = 'resolved';

            this._runResolutionHandlers();
        }
    }

    then(resolutionHandler, rejectionHandler) {
        var newPromise = new MyPromise(function () {});

        this._resolutionQueue.push({
            handler: resolutionHandler,
            promise: newPromise
        });

        if (typeof rejectionHandler === 'function') {
            this._rejectionQueue.push({
                handler: rejectionHandler,
                promise: newPromise
            });
        }

        if (this._state === 'resolved') {
            this._runResolutionHandlers();
        }

        if (this._state === 'rejected') {
            newPromise._reject(this._rejectionReason);
        }

        return newPromise;
    }

    catch(rejectionHandler) {
        var newPromise = new MyPromise(function () {});

        this._rejectionQueue.push({
            handler: rejectionHandler,
            promise: newPromise
        });

        if (this._state === 'rejected') {
            this._runRejectionHandlers();
        }

        return newPromise;
    }

}

module.exports = MyPromise;

As you see, this implementation has nothing to do with multi-threading itself, it's just coded in pure javascript without using any WebAPIs. Also, people say the built-in Promise is implemented without multi-threading in StackOverflow.

This MyPromise just work fine for most cases. However, MyPromise doesn't work the same as the built-in Promise in some cases and 'why?' is my question.

Here is the problematic code snippet:

new MyPromise((resolve, reject) => {
    console.log("first promise");
    resolve(1);
}).then((res) => {
    console.log("it's in then");
    return res+1;
}); console.log("it's in the end");

Executing the code spits out "first promise" -> "it's in then" -> "It's in the end", However,

new Promise((resolve, reject) => {
    console.log("first promise");
    resolve(1);
}).then((res) => {
    console.log("it's in then");
    return res+1;
}); console.log("it's in the end");

On the other hand, this spits out "first promise" -> "it's in the end" -> "it's in then"

The behavior of the builtin Promise doesn't look right unless the 'then' method implementation is fundamentally different from 'MyPromise.then'. Even taking 'task queue' and 'event loop' into account, I don't see a good explanation on why the difference.

I thought 'new Promise(f1).then(f2).then(f3);f4()' must be executed in the order of f1, f2, f3 and then f4, in a series, unless they include WebAPIs like setTimeout or $Ajax inside. But my little experiment doesn't say so, f1, f4, f2, ... you got the idea.

Is the 'then' method based upon some worker thread or something? I am totally lost.

Please shed some light on me. Thanks.

  • 1
    there's many many many implementations of Promise in javascript - see [this list](https://promisesaplus.com/implementations) - you could read someone elses code to see what they do - some of them have source code that is documented with references to the part of [the promise A+ spec](https://promisesaplus.com/) that is being handled - very enlightening some of them are – Jaromanda X Dec 23 '19 at 05:45
  • The way I implemented in my custom Promise is as [such](https://gist.github.com/xxMrPHDxx/bfa819c4584fe3e44e2098d6d3a6ec5e). I used `setInterval` to do the job. I don't know if it's better though. And the code output as expected. – xxMrPHDxx Dec 23 '19 at 06:07

1 Answers1

4

Each .then or .catch on a resolved or rejected Promise should run only during a microtask, after the rest of the current running synchronous code has completed. For example, with the following code:

Promise.resolve()
  .then(() => console.log('foo'));
console.log('bar');

bar should be logged before foo.

For your code, the simplest tweak would be to change _runRejectionHandlers (and _runResolutionHandlers) so that they run their associated callbacks after a delay, rather than immediately:

class MyPromise {

  constructor(executor) {
    this._resolutionQueue = [];
    this._rejectionQueue = [];
    this._state = 'pending';
    this._value;
    this._rejectionReason;

    try {
      executor(this._resolve.bind(this), this._reject.bind(this));
    } catch (e) {
      this._reject(e);
    }
  }

  _runRejectionHandlers() {
    setTimeout(() => {
      while (this._rejectionQueue.length > 0) {
        var rejection = this._rejectionQueue.shift();

        try {
          var returnValue = rejection.handler(this._rejectionReason);
        } catch (e) {
          rejection.promise._reject(e);
        }

        if (returnValue && returnValue instanceof MyPromise) {
          returnValue.then(function(v) {
            rejection.promise._resolve(v);
          }).catch(function(e) {
            rejection.promise._reject(e);
          });
        } else {
          rejection.promise._resolve(returnValue);
        }
      }
    });
  }

  _runResolutionHandlers() {
    setTimeout(() => {
      while (this._resolutionQueue.length > 0) {
        var resolution = this._resolutionQueue.shift();

        try {
          var returnValue = resolution.handler(this._value);
        } catch (e) {
          resolution.promise._reject(e);
        }

        if (returnValue && returnValue instanceof MyPromise) {
          returnValue.then(function(v) {
            resolution.promise._resolve(v);
          }).catch(function(e) {
            resolution.promise._reject(e);
          });
        } else {
          resolution.promise._resolve(returnValue);
        }
      }
    });
  }

  _reject(reason) {
    if (this._state === 'pending') {
      this._rejectionReason = reason;
      this._state = 'rejected';

      this._runRejectionHandlers();

      while (this._resolutionQueue.length > 0) {
        var resolution = this._resolutionQueue.shift();
        resolution.promise._reject(this._rejectionReason);
      }
    }
  }

  _resolve(value) {
    if (this._state === 'pending') {
      this._value = value;
      this._state = 'resolved';

      this._runResolutionHandlers();
    }
  }

  then(resolutionHandler, rejectionHandler) {
    var newPromise = new MyPromise(function() {});

    this._resolutionQueue.push({
      handler: resolutionHandler,
      promise: newPromise
    });

    if (typeof rejectionHandler === 'function') {
      this._rejectionQueue.push({
        handler: rejectionHandler,
        promise: newPromise
      });
    }

    if (this._state === 'resolved') {
      this._runResolutionHandlers();
    }

    if (this._state === 'rejected') {
      newPromise._reject(this._rejectionReason);
    }

    return newPromise;
  }

  catch (rejectionHandler) {
    var newPromise = new MyPromise(function() {});

    this._rejectionQueue.push({
      handler: rejectionHandler,
      promise: newPromise
    });

    if (this._state === 'rejected') {
      this._runRejectionHandlers();
    }

    return newPromise;
  }

}

new MyPromise((resolve, reject) => {
  console.log("first promise");
  resolve(1);
}).then((res) => {
  console.log("it's in then");
  return res + 1;
});
console.log("it's in the end");

Ideally, the delay would be done via a microtask, like:

Promise.resolve()
  .then(() => {
    // rest of the code
  });

But since Promise is the sort of functionality you're trying to implement already, you may not want to do that, so you can use a macrotask instead:

setTimeout(() => {
  // rest of the code
});

That won't be fully spec-compliant, but I'm not sure there are any other options.

CertainPerformance
  • 356,069
  • 52
  • 309
  • 320
  • Couldn't user3126624 just push into the promise (microtask) queue by using [queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/queueMicrotask)? – Travis J Dec 23 '19 at 05:16
  • Yep, that would sound even better than `Promise.resolve`, but any environment that supports `queueMicrotask` also supports `Promise` already. `queueMicrotask` is also *very* new, much newer than Promises, I'm hesitant to use it without a polyfill (and, given that the objective here *is* basically to polyfill it, by yourself...) – CertainPerformance Dec 23 '19 at 05:19
  • Since this was tagged with Node, it seemed that perhaps the executing environment would lend it self to the availability of new features, which was why I brought it up. I fully agree that in a web environment a polyfill is more applicable, although not entirely possible for the reasons you correctly state (macrotask vs microtask). – Travis J Dec 23 '19 at 19:40
  • Thanks to you, now I know why the behavior, microtask and macrotask. I'll look into the concepts and see if I can bring those in for my toy implementation. Assumably, that might require my own event loop system, right? – user3126624 Dec 25 '19 at 23:33
  • @user3126624 Macrotask timing is implementation-dependent anyway. and that implementation isn't really visible at the level of Javascript - it's in browser/environment internals. I wouldn't bother, easier to just use the provided functions (like `setTImeout` and `queueMicrotask`) that hook into them. – CertainPerformance Dec 25 '19 at 23:38