4

Recently, I revisited the design explanation of Q/Promise , combined with my own understanding and some small optimizations, I decided to write an article about handwritten Promise. The content of this article is suitable for children's shoes who have a certain understanding of the use of Promise, because the basic operation of Promise will not be explained too much in the process. We start with a basic version and work through the Promise incrementally, sharing my understanding and perspective along the way. The content may be a bit long, so without further ado, let's get started.

base version

We first use the observer pattern as the cornerstone to build a basic version, which implements the following functions:

  1. The constructor accepts a function exector as a parameter, the first parameter of the function is resolve , the function is to change the state of the Promise object to "success".
  2. The prototype method then is used to register a callback function when the status becomes successful. When the callback is triggered, the parameter is the resolution value of resolve .
 function Promise(exector) {
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    if (this.pending) {
      this.value = value;
      for (const onFulfilled of this.pending) {
        // 通知观察者。
        onFulfilled(this.value);
      }
      this.pending = undefined;
    }
  };

  exector(resolve);
}

Promise.prototype.then = function (onFulfilled) {
  if (this.pending) {
    // 还没决议,先注册观察者。
    this.pending.push(onFulfilled);
  } else {
    // 已决议,直接通知。
    onFulfilled(this.value);
  }
};

// 测试一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
})

p.then(res => console.log('res: %s', res));

// 输出:
// res: 666

The code is very simple and should not need to be explained too much. The complete code above is here: p0.js .

There is an obvious problem with this basic version: then can't make chained calls, so let's optimize it.

then chain call

Chaining calls to then returns a new Promise, and the return value of the callback in then causes this new Promise to resolve to the "success" state.

 Promise.prototype.then = function (onFulfilled) {
  // “当前”Promise,对于返回的新 Promise 而言,也是“前一个”Promise。
  const prev = this;

  const promise = new Promise(resolve => {
    // 包装 onFulfilled,使其可以“传播”决议;
    // “前一个” Promise 决议后,决议返回的这个新 Promise。
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };

    if (prev.pending) {
      prev.pending.push(onSpreadFulfilled);
    } else {
      onSpreadFulfilled(prev.value);
    }
  });

  return promise;
};

// 测试一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
});

p.then(res => {
  console.log('res1: %s', res);
  return res + 1;
).then(res => {
  console.log('res2: %s', res);
);
  
// 输出:
// res1: 666
// res2: 667

The key to implementing chaining is how to resolve the new Promise that is returned? Here I made some meaningful names for the variables for easy understanding:

  1. prev then called. For the new Promise returned, it can be regarded as the "previous" Promise.
  2. Wrapping onFulfilled - After executing the currently registered onFulfilled, use its return value to determine the new Promise returned. This is a key step, named onSpreadFulfilled to reflect the action of propagation.
  3. Register onSpreadFulfilled as a successful callback to prev .

The full code for the above is here: p1.js .

Now there is a new problem, if the resolve of value is a Promise, or onfulfilled the value returned by the function should not be a Promise, then the chain propagation resolution should not be It is the Promise itself, but the resolution value of the Promise, that is, to support the state transfer of the Promise .

state transfer

Before implementing state transfer, let's first see how Kangkang determines whether a value is a Promise. We can use prototypal inheritance to determine:

 return value instanceof Promise;

The disadvantage of this is that the compatibility is poor, you can't force the user's runtime context to use only one Promise library, or pass Promise instances in different runtime contexts. So here we use duck type to judge Promise, focus on the behavior of the object , treat Promise as a thenable object.

 function isPromise(value) {
  // 如果这个对象上可以调用 then 方法,就认为它是一个“Promise”了。
  return value && typeof value.then === 'function';
}

The next step is to implement state transfer. The idea of implementation is based on duck typing and "notification transfer". Let's define a function first:

 function wrapToThenable(value) {
  if (isPromise(value)) {
    return value;
  } else {
    return {
      then: function (onFulfilled) {
        return wrapToThenable(onFulfilled(value));
      }
    };
  }
}

As the name implies, the function of this function is to wrap a value as a thenable object: if the value is a Promise, return it directly; if not, wrap it and return an object with a then method, That is thenable object. What is the function of this thenable object? Then look here:

 function Promise(exector) {
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    if (this.pending) {
      // 包装为 thenable。
      this.value = wrapToThenable(value);
      for (const onFulfilled of this.pending) {
        // 通知时改为调用 thenable 上的 then。
        this.value.then(onFulfilled);
      }
      this.pending = undefined;
    }
  };

  exector(resolve);
}

resolve When the resolution is made, there are two processing situations according to the type of value :

  1. If value is a common value, after wrapToThenable it will be packaged as thenable 1051509b7c55b383941d34f61538ddfc--- method call directly, which is equivalent to calling onFulfilled then onFulfilled .
  2. If value is a Promise, put onFulfilled registered to value on; wait value when the resolution will call onFulfilled . Remember onSpreadFulfilled when chaining calls? This is the "notification transfer", which transfers the responsibility of notifying the next Promise to value .

Of course then also make a little modification:

 Promise.prototype.then = function (onFulfilled) {
  const prev = this;

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };

    if (prev.pending) {
      prev.pending.push(onSpreadFulfilled);
    } else {
      // 这里也要改为调用 then。
      prev.value.then(onSpreadFulfilled);
    }
  });

  return promise;
};

// 测试一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
});

p.then(res => {
  console.log('res1: %s', res);
  return new Promise(resolve => {
    setTimeout(() => resolve(777), 100);
  });
}).then(res => {
  console.log('res2: %s', res);
});

// 输出:
// res1: 666
// res2: 777

Here is a summary of the design ideas of state transfer. The package is thenable the object is very critical, and its function is to maintain the behavior consistent with Promise, that is, the interface is consistent. In this way, when resolve , we do not need to specifically determine whether this value is a Promise, but can use a unified processing method to notify the observer; and also complete the "notification transfer" by the way, if value If there is no resolution yet, then then will be registered as a callback, and if it has been resolved, then will be executed immediately.

The full code for the above is here: p2.js . Next, let's improve reject .

failure status

When the Promise resolution fails, the then method will only execute the callback corresponding to the second parameter onRejected . First we need another wrapper function:

 function wrapToRejected(value) {
  return {
    then: function (_, onRejected) {
      return wrapToThenable(onRejected(value));
    }
  };
}

The function of this function is that once it happens reject(value) , we change the value to another thenable object, which will only call--- when executing then onRejected .

Then change the constructor a bit:

 function Promise(exector) {
  // pending 变为一个二维数组,里面存放的元素是 [onFulfilled, onRejected]。
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    if (this.pending) {
      this.value = wrapToThenable(value);
      for (const handlers of this.pending) {
        this.value.then.apply(this.value, handlers);
      }
      this.pending = undefined;
    }
  };

  const reject = value => {
    resolve(wrapToRejected(value));
  };

  exector(resolve, reject);
}

Now there is a big change: this.pending becomes a two-dimensional array. In this way this.value.then.apply there will be three situations during execution:

  1. this.value is the thenable object converted from a successful resolution, remember wrapToThenable ? then will only call onFulfilled when executed.
  2. this.value is the thenable object converted from the failed resolution, then will only be called when it is executed onRejected .
  3. this.value is a Promise to which the resolution will be transferred.

The same then method also needs to be modified:

 Promise.prototype.then = function (onFulfilled, onRejected) {
  const prev = this;
  
  // 注意这里给了 onFulfilled、onRejected 默认值。
  onFulfilled =
    onFulfilled ||
    function (value) {
      return value;
    };
  onRejected =
    onRejected ||
    function (value) {
      return wrapToRejected(value);
    };

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };
    const onSpreadRejected = function (value) {
      resolve(onRejected(value));
    };

    if (prev.pending) {
      prev.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      prev.value.then(onSpreadFulfilled, onSpreadRejected);
    }
  });

  return promise;
};

// 测试一下。
const p = new Promise((resolve, reject) => {
  setTimeout(() => reject(666), 100);
});

p.then(undefined, err => {
  console.log('err1: %s', err);
  return 1;
}).then(res => {
  console.log('res1: %s', res);
});

// 输出:
// err1: 666
// res1: 1

We should pay special attention to the addition of the default values of onFulfilled and onRejected . In actual use then , it may only focus on the callback of success or failure, but we need another state to continue to propagate. It may be a bit difficult to understand here, you can simulate it by substituting data. The full code for the above is here: p3.js .

It's time to think and summarize again, thenable this interface is the key. By using two wrapper objects to handle the status of success and failure respectively, a unified logic can be maintained when notifying the observer. Does this design feel wonderful?

Next we have to deal with the problem of exceptions when called.

exception handling

Let's first think about where exceptions will occur? The first is in the constructor exector when it is executed:

 function Promise(exector) {
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    // ...
  };

  const reject = value => {
    resolve(wrapToRejected(value));
  };

  try {
    exector(resolve, reject);
  } catch (e) {
    // 如果有异常产生,状态变为“失败”。
    reject(e);
  }
}

Then onFulfilled and onRejected are executed. When an exception is generated in the above two methods, the state must be changed to fail, and the exception needs to be propagated. then changes as follows:

 Promise.prototype.then = function (onFulfilled, onRejected) {
  // ...
  // 产生异常的时候包装一下。
  const errHandler = returnWhenError(err => wrapToRejected(err));
  onFulfilled = errHandler(onFulfilled);
  onRejected = errHandler(onRejected);

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };
    const onSpreadRejected = function (value) {
      resolve(onRejected(value));
    };

    if (prev.pending) {
      prev.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      prev.value.then(onSpreadFulfilled, onSpreadRejected);
    }
  });

  return promise;
};

// 封装为一个可重用的高阶函数。
// 如果 fun 执行失败了,则返回 onError 的结果。
function returnWhenError(onError) {
  return fun =>
    (...args) => {
      let result;

      try {
        result = fun(...args);
      } catch (e) {
        result = onError(e);
      }

      return result;
    };
}

Then we can add the catch method:

 Promise.prototype.catch = function (onRejected) {
  // 在 then 中忽略掉“成功”状态的回调。
  return Promise.prototype.then.call(this, undefined, onRejected);
};

// 测试一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
});

p.then(res => {
  console.log('res1: %s', res);
  throw new Error('test error1');
}).then(undefined, err => {
  console.log('err1: %s', err.message);
  throw new Error('test error2');
}).catch(err => {
  console.log('err2: %s', err.message);
});

// 输出:
// res1: 666
// err1: test error1
// err2: test error2

The full code for the above is here: p4.js .

At this point, basically the basic functions of Promise are almost completed. However, there are still some imperfections, let's continue to do some optimization.

some optimizations

encapsulate private variables

this.pending and this.value are readable and writable from the outside, not secure and robust enough. And I still want to use constructors and prototype methods, and don't want to use closures to encapsulate. I use WeakMap here to achieve the purpose, and the key modifications are as follows:

 const refMap = new WeakMap();

// ...

function Promise(exector) {
  // 用当前的实例引用作为 key,把想隐藏的数据放进一个对象里。
  refMap.set(this, {
    pending: [],
    value: undefined
  });

  const resolve = value => {
    // 取出封装的数据。
    const data = refMap.get(this);

    if (data.pending) {
      data.value = wrapToThenable(value);
      for (const handlers of data.pending) {
        data.value.then.apply(data.value, handlers);
      }
      data.pending = undefined;
    }
  };

  // ...
}

Similarly then also modify it:

 Promise.prototype.then = function (onFulfilled, onRejected) {
  // ...

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };
    const onSpreadRejected = function (value) {
      resolve(onRejected(value));
    };
    // 取出封装的数据。
    const data = refMap.get(prev);

    if (data.pending) {
      data.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      data.value.then(onSpreadFulfilled, onSpreadRejected);
    }
  });

  return promise;
};

The full code for the above is here: p5.js .

When the Promise instance is garbage collected, the reference to the private data object corresponding to the WeakMap will also be eliminated, and there is no memory leak problem. This solution is very suitable for encapsulating private variables.

call sequence

The current Promise has a call order problem when it is executed, such as:

 const p = new Promise(resolve => resolve(1));

p.then(res => {
  console.log('res1:', res);
  return res + 1;
}).then(res => {
  console.log('res2:', res);
});

p.then(res => {
  console.log('res3:', res);
});

console.log('Hi!');

// 目前的输出是:
// res1: 1
// res2: 2
// res3: 1
// Hi!

// 正确的输出应该是:
// Hi!
// res1: 1
// res3: 1
// res2: 2

A simple way is to use setTimeout to improve:

 function Promise(exector) {
  // ...
  
  const resolve = value => {
    const data = refMap.get(this);

    if (data.pending) {
      data.value = wrapToThenable(value);
      for (const handlers of data.pending) {
        // 延迟执行。
        enqueue(() => {
          data.value.then.apply(data.value, handlers);
        });
      }
      data.pending = undefined;
    }
  };
  
  // ...
}

Promise.prototype.then = function (onFulfilled, onRejected) {
  // ...

  const promise = new Promise(resolve => {
    // ...

    if (data.pending) {
      data.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      // 延迟执行。
      enqueue(() => {
        data.value.then(onSpreadFulfilled, onSpreadRejected);
      });
    }
  });

  return promise;
};

function enqueue(callback) {
  setTimeout(callback, 1);
}

The function of enqueue is to simulate the delayed execution of functions in the order of enqueuing. By deferred execution of all then calls are guaranteed to be executed in the correct registration order and resolution order, the complete code above is here: p6.js .

what's next?

Ahem, I think it's almost the same when I get here. After all, the purpose of this article is to share and exchange a Promise design idea and experience, not to create a perfect Promise. The result of writing a Promise by hand should not be our purpose, but observing the ideas and solutions in the evolution process is what we need to absorb. When I have time, I will add some missing interfaces, such as Promise.resolve , Promise.prototype.finally and so on.

Finally, I hope you can also gain something from this article, welcome to star and follow my JavaScript blog: Whisper Bibi JavaScript


deepfunc
776 声望634 粉丝