资讯专栏INFORMATION COLUMN

从源码看 Promise 概念与实现

kel / 885人阅读

摘要:从源码看概念与实现是异步编程中的重要概念,它较好地解决了异步任务中回调嵌套的问题。这些概念中有趣的地方在于,标识状态的变量如都是形容词,用于传入数据的接口如与都是动词,而用于传入回调函数的接口如及则在语义上用于修饰动词的副词。

从源码看 Promise 概念与实现

Promise 是 JS 异步编程中的重要概念,它较好地解决了异步任务中回调嵌套的问题。在没有引入新的语言机制的前提下,这是如何实现的呢?上手 Promise 时常见若干晦涩的 API 与概念,它们又为什么存在呢?源码里隐藏着这些问题的答案。

下文会在介绍 Promise 概念的基础上,以一步步代码实现 Promise 的方式,解析 Promise 的实现机制。相应代码参考来自 PromiseJS 博客 及 You don’t know JS 的若干章节。

Why Promise
(有使用 Promise 经验的读者可忽略本段)

基于 JS 函数一等公民的优良特性,JS 中最基础的异步逻辑一般是以向异步 API 传入一个函数的方式实现的,这个函数里包含了异步完成后的后续业务逻辑。与普通的函数参数不同的是,这类函数需在异步操作完成时才被调用,故而称之为回调函数。以异步 Ajax 查询为例,基于回调的代码实现可能是这样的:

ajax.get("xxx", data => {
 // 在回调函数里获取到数据,执行后续逻辑
 console.log(data)
 // ...
})

从而,在需要多个异步操作依次执行时,就需要以回调嵌套的方式来实现,例如这样:

ajax.get("xxx", dataA => {
 // 第一个请求完成后,依赖其获取到的数据发送第二个请求
 // 产生回调嵌套
 ajax.get("yyy" + dataA, dataB => {
 console.log(dataB)
 // ...
 })
})

这样一来,在处理越多的异步逻辑时,就需要越深的回调嵌套,这种编码模式的问题主要有以下几个:

代码逻辑书写顺序与执行顺序不一致,不利于阅读与维护。
异步操作的顺序变更时,需要大规模的代码重构。
回调函数基本都是匿名函数,bug 追踪困难。
回调函数是被第三方库代码(如上例中的 ajax )而非自己的业务代码所调用的,造成了 IoC 控制反转。
其中看似最无关紧要的控制反转,实际上是纯回调编码模式的最大问题。 由于回调函数是被第三方库调用的,因此回调中的代码无法预期自己被执行时的环境 ,这可能导致:

回调被执行了多次
回调一次都没有被执行
回调不是异步执行而是被同步执行
回调被过早或过晚执行
回调中的报错被第三方库吞掉
……
通过【防御性编程】的概念,上述问题其实都可以通过在回调函数内部进行各种检查来逐一避免,但这毫无疑问地会严重影响代码的可读性与开发效率。这种异步编码模式存在的诸多问题,也就是臭名昭著的【回调地狱】了。

Promise 较好地解决了这个问题。以上例中的异步 ajax 逻辑为例,基于 Promise 的模式是这样的:

// 将 ajax 请求封装为一个返回 Promise 的函数
function getData (){
 return new Promise((resolve, reject) => {
 ajax.get("xxx", data => {
 resolve(data)
 })
 })
}

// 调用该函数并在 Promise 的 then 接口中获取数据
getData().then(data => {
 console.log(data)
})

看起来变得啰嗦了?但在上例中需要嵌套回调的情况,可以改写成下面的形式:

function getDataA (){
 return new Promise((resolve, reject) => {
 ajax.get("xxx", dataA => {
 resolve(dataA)
 })
 })
}

function getDataB (dataA){
 return new Promise((resolve, reject) => {
 ajax.get("yyy" + dataA, dataB => {
 resolve(dataB)
 })
 })
}

// 使用链式调用解开回调嵌套
getDataA()
 .then(dataA => getDataB(dataA))
 .then(dataB => console.log(dataB))

这就解决了异步逻辑的回调嵌套问题。那么问题来了,这样优雅的 API 是如何实现的呢?

基础概念
非常笼统地说,Promise 其实应验了 CS 的名言【所有问题都可以通过加一层中间层来解决】。在上面回调嵌套的问题中,Promise 就充当了一个中间层,用来【把回调造成的控制反转再反转回去】。在使用 Promise 的例子中,控制流分为了两个部分:触发异步前的逻辑通过 new传入 Promise,而异步操作完成后的逻辑则传入 Promise 的 then 接口中。通过这种方式,第一方业务和第三方库的相应逻辑都由 Promise 来调用,进而在 Promise 中解决异步编程中可能出现的各种问题。

这种模式其实和观察者模式是接近的。下面的代码将 resolve / then 换成了 publish / subscribe ,将通过 new Promise 生成的 Promise 换成了通过 observe 生成的 observable 实例。可以发现,这种调用同样做到了回调嵌套的解耦。这就是 Promise 魔法的关键之一。

// observe 相当于 new Promise
// publish 相当于 resolve
let observable = observe(publish => {
 ajax.get("xxx", data => {
 // ...
 publish(data)
 })
})

// subscribe 相当于 then
observable.subscribe(data => {
 console.log(data)
 // ...
})

到这个例子为止,都还没有涉及 Promise 的源码实现。在进一步深入前,有必要列出在 Promise 中常见的相关概念:

resolve / reject : 作为 Promise 暴露给第三方库的 API 接口,在异步操作完成时由第三方库调用,从而改变 Promise 的状态。
fulfilled / rejected / pending : 标识了一个 Promise 当前的状态。
then / done : 作为 Promise 暴露给第一方代码的接口,在此传入【原本直接传给第三方库】的回调函数。
这些概念中有趣的地方在于,标识状态的变量(如 fulfilled / rejected / pending )都是形容词,用于传入数据的接口(如 resolve 与 reject )都是动词,而用于传入回调函数的接口(如 then 及 done )则在语义上用于修饰动词的副词。在阅读源码的时候,除了变量的类型外,其名称所对应的词性也能对理解代码逻辑起到帮助,例如:

标识数据的变量与 OO 对象常用名词( result / data / Promise )
标识状态的变量常用形容词( fulfilled / pending )
被调用的函数接口常用动词( resolve / reject )
用于传入函数的参数接口常用副词(如 then / onFulfilled 等,毕竟函数常用动词,而副词本来就是用来修饰动词的)
预热了 Promise 相关的变量名后,就可以开始实现 Promise 了。下文的行文方式既不是按行号逐行介绍,也不是按代码执行顺序来回跳跃,而是按照实际编码时的步骤一步步地搭建出相应的功能。相信这种方式比直接在源码里堆注释能更为友好一些。

状态机
一个 Promise 可以理解为一个状态机,相应的 API 接口要么用于改变状态机的状态,要么在到达某个状态时被触发。因此首先需要实现的,是 Promise 的状态信息:

const PENDING = 0
const FULFILLED = 1
const REJECTED = 2

function Promise (){
 // 存储该 Promise 的状态信息
 let state = PENDING

 // 存储 FULFILLED 或 REJECTED 时带来的数据
 let value = null

 // 存储 then 或 done 时调用的成功或失败回调
 var handlers = []
}

状态迁移
指定状态机的状态后,可以实现基本的状态迁移功能,即 fulfill 与 reject 这两个用于改变状态的函数,相应实现也十分简单:

const PENDING = 0
const FULFILLED = 1
const REJECTED = 2

function Promise (){
 // 存储该 Promise 的状态信息
 let state = PENDING

 // 存储 FULFILLED 或 REJECTED 时带来的数据
 let value = null

 // 存储 then 或 done 时调用的成功或失败回调
 let handlers = []
 
 function fulfill (result){
 state = FULFILLED
 value = result
 }

 function reject (error){
 state = REJECTED
 value = error
 }
}

在这两种底层的状态迁移基础上,我们需要实现一种更高级的状态迁移方式,这就是 resolve了:

const PENDING = 0
const FULFILLED = 1
const REJECTED = 2

function Promise (){
 // 存储该 Promise 的状态信息
 let state = PENDING

 // 存储 FULFILLED 或 REJECTED 时带来的数据
 let value = null

 // 存储 then 或 done 时调用的成功或失败回调
 let handlers = []
 
 function fulfill (result){
 state = FULFILLED
 value = result
 }

 function reject (error){
 state = REJECTED
 value = error
 }

 function resolve (result){
 try {
 let then = getThen(result)
 if (then) {
 // 递归 resolve 待解析的 Promise
 doResolve(then.bind(result), resolve, reject)
 return
 }
 fulfill(result)
 } catch (e) {
 reject(e)
 }
 }
}

resolve 既可以接受一个 Promise,也可以接受一个基本类型。当 resolve 一个 Promise 时,就使用 doResolve 辅助函数来执行这个 Promise 并等待其完成。通过暴露 resolve 而隐藏底层的 fulfill 接口,从而保证了一个 Promise 一定不会被另一个 Promise 所 fulfill 。在这个过程中所用到的辅助函数如下:

/**
 * 检查一个值是否为 Promise
 * 若为 Promise 则返回该 Promise 的 then 方法
 *
 * @param {Promise|Any} value
 * @return {Function|Null}
 */
function getThen (value){
 let t = typeof value
 if (value && (t === "object" || t === "function")) {
 const then = value.then
 // 可能需要更复杂的 thenable 判断
 if (typeof then === "function") return then
 }
 return null
}

/**
 * 传入一个需被 resolve 的函数,该函数可能存在不确定行为
 * 确保 onFulfilled 与 onRejected 只会被调用一次
 * 在此不保证该函数一定会被异步执行
 *
 * @param {Function} fn 不能信任的回调函数
 * @param {Function} onFulfilled
 * @param {Function} onRejected
 */
function doResolve (fn, onFulfilled, onRejected){
 let done = false
 try {
 fn(function (value){
 if (done) return
 done = true
 // 执行由 resolve 传入的 resolve 回调
 onFulfilled(value)
 }, function (reason){
 if (done) return
 done = true
 onRejected(reason)
 })
 } catch (ex) {
 if (done) return
 done = true
 onRejected(ex)
 }
}

resolve 接口
在完整完成了内部状态机的基础上,还需要向用户暴露用于传入第一方代码的 new Promise接口,及传入异步操作回调的 done / then 接口。下面从 resolve 一个 Promise 开始:

const PENDING = 0
const FULFILLED = 1
const REJECTED = 2

function Promise (fn){
 // 存储该 Promise 的状态信息
 let state = PENDING

 // 存储 FULFILLED 或 REJECTED 时带来的数据
 let value = null

 // 存储 then 或 done 时调用的成功或失败回调
 let handlers = []
 
 function fulfill (result){
 state = FULFILLED
 value = result
 }

 function reject (error){
 state = REJECTED
 value = error
 }

 function resolve (result){
 try {
 let then = getThen(result)
 if (then) {
 // 递归 resolve 待解析的 Promise
 doResolve(then.bind(result), resolve, reject)
 return
 }
 fulfill(result)
 } catch (e) {
 reject(e)
 }
 }
 
 doResolve(fn, resolve, reject)
}

可以发现这里重用了 doResolve 以执行不被信任的 fn 函数。这个 fn 函数可以多次调用 resolve 和 reject 接口,甚至抛出异常,但 Promise 中对其进行了限制,保证每个 Promise 只能被 resolve 一次,且在 resolve 后不再发生状态转移。

观察者 done 接口
到此为止已经完成了一个完整的状态机,但仍然没有暴露出一个合适的方法来观察其状态的变更。我们的最终目标是实现 then 接口,但由于实现 done 接口的语义要容易得多,因此可首先实现 done 。

下面的例子中要实现的是 promise.done(onFulfilled, onRejected) 接口,使得:

onFulfilled 与 onRejected 二者只有一个被调用。
该接口只会被调用一次。
该接口总是被异步执行。
调用 done 的执行时机与调用时 Promise 是否已 resolved 无关。

const PENDING = 0
const FULFILLED = 1
const REJECTED = 2

function Promise (fn){
 // 存储该 Promise 的状态信息
 let state = PENDING

 // 存储 FULFILLED 或 REJECTED 时带来的数据
 let value = null

 // 存储 then 或 done 时调用的成功或失败回调
 let handlers = []
 
 function fulfill (result){
 state = FULFILLED
 handlers.forEach(handle)
 handlers = null
 }

 function reject (error){
 state = REJECTED
 value = error
 handlers.forEach(handle)
 handlers = null
 }

 function resolve (result){
 try {
 let then = getThen(result)
 if (then) {
 // 递归 resolve 待解析的 Promise
 doResolve(then.bind(result), resolve, reject)
 return
 }
 fulfill(result)
 } catch (e) {
 reject(e)
 }
 }
 
 // 保证 done 中回调的执行
 function handle (handler){
 if (state === PENDING) {
 handlers.push(handler)
 } else {
 if (state === FULFILLED &&
 typeof handler.onFulfilled === "function") {
 handler.onFulfilled(value)
 }
 if (state === REJECTED &&
 typeof handler.onRejected === "function") {
 handler.onRejected(value)
 }
 }
 }
 
 this.done = function (onFulfilled, onRejected){
 // 保证 done 总是异步执行
 setTimeout(function (){
 handle({
 onFulfilled: onFulfilled,
 onRejected: onRejected
 })
 }, 0)
 }
 
 doResolve(fn, resolve, reject)
}

从而在 Promise 的状态迁移至 resolved 或 rejected 时,所有通过 done 注册的观察者 handler 都能被执行。并且这个操作总是在下一个 tick 异步执行的。

观察者 then 方法
在实现了 done 方法的基础上,就可以实现 then 方法了。它们没有本质的区别,但 then 能够返回一个新的 Promise:

this.then = function (onFulfilled, onRejected){
 const _this = this
 return new Promise(function (resolve, reject){
 return _this.done(function (result){
 if (typeof onFulfilled === "function") {
 try {
 return resolve(onFulfilled(result))
 } catch (ex) {
 return reject(ex)
 }
 } else return resolve(result)
 }, function (error){
 if (typeof onRejected === "function") {
 try {
 return resolve(onRejected(error))
 } catch (ex) {
 return reject(ex)
 }
 } else return reject(error)
 })
 })
}

最后梳理一下典型场景下 Promise 的执行流程。以一个 ajax 请求的异步场景为例,整个异步逻辑分为两部分:调用 ajax 库的代码及异步操作完成时的代码。前者被放入 Promise 的构造函数中,由 doResolve 方法执行,在这部分业务逻辑通过调用 resolve 与 reject 接口,在异步操作完成时改变 Promise 的状态,从而调用后者,即调用 Promise 中通过 then 接口传入的 onFulfilled 与 onRejected 后续业务逻辑代码。这个过程中, doResolve 对第三方 ajax 库的各种异常行为(多次调用回调或抛出异常)做了限制,而 then 下隐藏的 done 则封装了 handle 接口,保证了多个通过 then 传入的 handler 总是异步执行,并能得到合适的返回结果。由于then 中的代码总是异步执行并返回了一个新的 Promise,因此可以通过链式调用的方式来串联多个 then 方法,从而实现异步操作的链式调用。

总结
阅读了 Promise 的代码实现后可以发现,它的魔法来自于将【函数一等公民】和【递归】的结合。一个 resolve 如果获得的结果还是一个 Promise,那么就将递归地继续 resolve 这个 Promise。同时,Promise 的辅助函数中解决了诸多异步编程时的常见问题,如回调的多次调用及异常处理等。

介绍 Promise 时不少较为晦涩的 API 其实也来自于对 Promise 编码实现时的涉及的若干底层功能。例如, fulfilled 这个概念就被封装在了 resolve 下,而 done 方法则是 then 方法的依赖等。这些概念在 Promise 的演化中被封装在了通用的 API 下,只有在阅读源码时才会用到。Promise 的 API 设计也是简洁的,其接口命名和英语的词性也有相当大的联系,这也有利于理解代码实现的相应功能。

除了上文中从状态机的角度理解 Promise 以外,其实还可以从函数式编程的角度来理解这个模式。可以将 Promise 看做一个封装了异步数据的 Monad,其 then 接口就相当于这个 Monad 的map 方法。这样一来,Promise 也可以理解为一个特殊的对象,这个对象【通过一个函数获取数据,并通过另一个函数来操作数据】,用户并不需要关心其中潜在的异步风险,只需要提供相应的函数给 Promise API 即可(这展开又是一篇长文了)。

希望本文对 Promise 的分析对理解异步编程有所帮助

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/107940.html

相关文章

  • JavaScript 异步

    摘要:从最开始的到封装后的都在试图解决异步编程过程中的问题。为了让编程更美好,我们就需要引入来降低异步编程的复杂性。写一个符合规范并可配合使用的写一个符合规范并可配合使用的理解的工作原理采用回调函数来处理异步编程。 JavaScript怎么使用循环代替(异步)递归 问题描述 在开发过程中,遇到一个需求:在系统初始化时通过http获取一个第三方服务器端的列表,第三方服务器提供了一个接口,可通过...

    tuniutech 评论0 收藏0
  • JS笔记

    摘要:从最开始的到封装后的都在试图解决异步编程过程中的问题。为了让编程更美好,我们就需要引入来降低异步编程的复杂性。异步编程入门的全称是前端经典面试题从输入到页面加载发生了什么这是一篇开发的科普类文章,涉及到优化等多个方面。 TypeScript 入门教程 从 JavaScript 程序员的角度总结思考,循序渐进的理解 TypeScript。 网络基础知识之 HTTP 协议 详细介绍 HTT...

    rottengeek 评论0 收藏0
  • 浏览器多进程到JS单线程,JS运行机制最全面的一次梳理

    摘要:如果看完本文后,还对进程线程傻傻分不清,不清楚浏览器多进程浏览器内核多线程单线程运行机制的区别。因此准备梳理这块知识点,结合已有的认知,基于网上的大量参考资料,从浏览器多进程到单线程,将引擎的运行机制系统的梳理一遍。 前言 见解有限,如有描述不当之处,请帮忙及时指出,如有错误,会及时修正。 ----------超长文+多图预警,需要花费不少时间。---------- 如果看完本文后,还...

    wanghui 评论0 收藏0
  • 【自己读源码】Netty4.X系列(三) Channel Register

    摘要:我想这很好的解释了中,仅仅一个都这么复杂,在单线程或者说串行的程序中,编程往往是很简单的,说白了就是调用,调用,调用然后返回。 Netty源码分析(三) 前提概要 这次停更很久了,原因是中途迷茫了一段时间,不过最近调整过来了。不过有点要说下,前几天和业内某个大佬聊天,收获很多,所以这篇博文和之前也会不太一样,我们会先从如果是我自己去实现这个功能需要怎么做开始,然后去看netty源码,与...

    darkbug 评论0 收藏0
  • 【全文】狼叔:如何正确的学习Node.js

    摘要:感谢大神的免费的计算机编程类中文书籍收录并推荐地址,以后在仓库里更新地址,声音版全文狼叔如何正确的学习简介现在,越来越多的科技公司和开发者开始使用开发各种应用。 说明 2017-12-14 我发了一篇文章《没用过Node.js,就别瞎逼逼》是因为有人在知乎上黑Node.js。那篇文章的反响还是相当不错的,甚至连著名的hax贺老都很认同,下班时读那篇文章,竟然坐车的还坐过站了。大家可以很...

    Edison 评论0 收藏0

发表评论

0条评论

kel

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<