Promise: 异步编程的理解和使用

一、什么是 Promise

1.1 Promise 的背景介绍

Promise 最早出现在 1988 年,由 Barbara LiskovLiuba Shrira 首创(论文:Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems)。并且在语言 MultiLispConcurrent Prolog 中已经有了类似的实现。

时间线 里程碑
1949 回调函数的概念诞生
1958 回调函数在 Fortran II 中应用
1988 Promise 的概念诞生
1995 JavaScript 诞生
2009 JavaScript 的非官方 Promise 库 Q 诞生
2011 jQuery 1.5 新增 Deferred() 方法
2015 ECMAScript 官方添加了 Promise 特性

JavaScript 中,Promise 的流行是得益于 jQuery 的方法 jQuery.Deferred(),其他也有一些更精简独立的 Promise 库,例如:QWhenBluebird

// Q/2009-2017
import Q from 'q'

function wantOdd () {
    const defer = Q.defer()
    const num = Math.floor(Math.random() * 10)
    if (num % 2) {
        defer.resolve(num)
    } else {
        defer.reject(num)
    }
    return defer.promise
}

wantOdd()
    .then(num => {
        console.log(`Success: ${num} is odd.`) // Success: 7 is odd.
    })
    .catch(num => {
        console.log(`Fail: ${num} is not odd.`)
    })

由于 jQuery 并没有严格按照规范来制定接口,促使了官方对 Promise 的实现标准进行了一系列重要的澄清,该实现规范被命名为 Promise/A+。后来 ES6(也叫 ES2015,2015 年 6 月正式发布)也在 Promise/A+ 的标准上官方实现了一个 Promise 接口。

new Promise( function(resolve, reject) {...} /* 执行器 */  )

想要实现一个 Promise,必须要遵循如下规则:

  1. Promise 是一个提供符合标准then() 方法的对象。
  2. 初始状态是 pending,能够转换成 fulfilledrejected 状态。
  3. 一旦 fulfilledrejected 状态确定,再也不能转换成其他状态。
  4. 一旦状态确定,必须要返回一个值,并且这个值是不可修改的。
状态

ECMAScript's Promise global is just one of many Promises/A+ implementations.

主流语言对于 Promise 的实现:Golang/promisePython/promiseC#/Real-Serious-Games/c-sharp-promisePHP/Guzzle PromisesJava/IOUObjective-C/PromiseKitSwift/FutureLibPerl/stevan/promises-perl

// Golang Example
func main() {
  p1 := promise.New(func(resolve func(int), reject func(error)) {
    factorial := findFactorial(20)
    resolve(factorial)
  })
  p2 := promise.New(func(resolve func(string), reject func(error)) {
    ip, err := fetchIP()
    if err != nil {
      reject(err)
      return
    }
    resolve(ip)
  })
  factorial, _ := p1.Await()
  fmt.Println(factorial)
  IP, _ := p2.Await()
  fmt.Println(IP)
}
// Other Code...

1.1.1 旨在解决的问题

由于 JavaScript 是单线程事件驱动的编程语言,通过回调函数管理多个任务。在快速迭代的开发中,因为回调函数的滥用,很容易产生被人所诟病的回调地狱问题。Promise 的异步编程解决方案比回调函数更加合理,可读性更强。

传说中比较夸张的回调:

回调地狱

现实业务中依赖关系比较强的回调:

// 回调函数
function renderPage() {
  const secret = genSecret()
  // 获取用户登录态
  getUserToken({
    secret,
    success: token => {
      // 获取游戏列表
      getGameList({
        token,
        success: data => {
          // 渲染游戏列表
          render({
            list: data.list,
            success: () => {
              // 埋点数据上报
              report()
            },
            fail: err => {
              console.error(err)
            }
          })
        },
        fail: err => {
          console.error(err)
        }
      })
    },
    fail: err => {
      console.error(err)
    }
  })
}

实际上更真实的情况,往往是一个回调函数在多个文件间透传,要搞清楚最终在哪里触发需要翻越整个项目。

使用 Promise 梳理流程后:

// Promise
function renderPage() {
  const secret = genSecret()
  // 获取用户登录态
  getUserToken(token)
    .then(token => {
      // 获取游戏列表
      return getGameList(token)
    })
    .then(data => {
      // 渲染游戏列表
      return render(data.list)
    })
    .then(() => {
      // 埋点数据上报
      report()
    })
    .catch(err => {
      console.error(err)
    })
}

若其中某个流程需要复用,单独把它抽离出来即可。

// 获取游戏列表
// 仅为示例,与实际业务无关
function getGameXYZ() {
  const secret = genSecret()
  // 获取用户登录态
  return getUserToken(token)
    .then(token => {
      // 获取游戏列表
      return getGameList(token)
    })
}
// 渲染页面
function renderPage() {
  getGameXYZ()
    .then(data => {
      // 渲染游戏列表
      return render(data.list)
    })
    .then(() => {
      // 埋点数据上报
      report()
    })
    .catch(err => {
      console.error(err)
    })
}
// 其他场景
function doABC() {
  getGameXYZ()
    .then(data => {
      // ...
    })
    .then(data => {
      // ...
    })
    .catch(err => {
      console.error(err)
    })
}

1.2 实现一个超简易版的 Promise

Promise 的运转实际上是一个观察者模式,then() 中的匿名函数充当观察者,Promise 实例充当被观察者。

const p = new Promise(resolve => setTimeout(resolve.bind(null, 'from promise'), 3000))

p.then(console.log.bind(null, 1))
p.then(console.log.bind(null, 2))
p.then(console.log.bind(null, 3))
p.then(console.log.bind(null, 4))
p.then(console.log.bind(null, 5))
// 3 秒后
// 1 2 3 4 5 from promise
观察者模式
// 实现
const defer = () => {
  let pending = [] // 充当状态并收集观察者
  let value = undefined
  return {
    resolve: (_value) => { // FulFilled!
      value = _value
      if (pending) {
        pending.forEach(callback => callback(value))
        pending = undefined
      }
    },
    then: (callback) => {
      if (pending) {
        pending.push(callback)
      } else {
        callback(value)
      }
    }
  }
}

// 模拟
const mockPromise = () => {
  let p = defer()
  setTimeout(() => {
    p.resolve('success!')
  }, 3000)
  return p
}

mockPromise().then(res => {
  console.log(res)
})

console.log('script end')
// script end
// 3 秒后
// success!

二、Promise 怎么用

2.1 使用 Promise 异步编程

Promise 出现之前往往使用回调函数管理一些异步程序的状态。

回调函数
// 常见的异步 Ajax 请求格式
ajax(url, successCallback, errorCallback)

Promise 出现后使用 then() 接收事件的状态,且只会接收一次。

案例:插件初始化

工作中使用封装好的插件时,往往需要等待插件初始化成功后进行下一步操作。

使用回调函数:

<!-- 1. <script... -->
<script src="https://example.com/pplugin@latest/pplugin.min.js"></script>
<script>
  // PPlugin Init
  PPlugin.init(data => {
    console.log('初始化完成', data)
  })
</script>

// 2. NPM
import PPlugin from 'PPlugin'

// ...
// 其他代码
// ...
PPlugin.init(data => {
  console.log('初始化完成', data)
})

插件代码:

const PPlugin = {/* Pass */ }
// 此处为理想情况,随着业务快速迭代,会变得不可控,往往需要多个状态判断
let ppInitStatus = false
let ppInitCallback = null
PPlugin.init = callback => {
  if (ppInitStatus) {
    callback && callback(/* 数据 */)
  } else {
    ppInitCallback = callback
  }
}
// 客户端桥接...
// 服务端接口...
// 经历了一系列同步异步程序后初始化完成
ppInitCallback && ppInitCallback(/* 数据 */)
ppInitStatus = true

使用 Promise

<!-- 使用方式同上 -->
<script src="https://example.com/pplugin@latest/pplugin.min.js"></script>
<script>
  // PPlugin Init
  PPlugin.init(data => {
    console.log('初始化完成', data)
  })
</script>
<!-- 其余省略 -->

插件代码:

const PPlugin = {/* Pass */ }
let initOk = null
const ppInitStatus = new Promise(resolve => initOk = resolve)
PPlugin.init = callback => {
  ppInitStatus.then(callback).catch(console.error)
}
// 客户端桥接...
// 服务端接口...
// 经历了一系列同步异步程序后初始化完成
initOk(/* 数据 */)

相对于使用回调函数,逻辑更清晰,什么时候初始化完成和触发回调一目了然,不再需要重复判断状态和回调函数。当然更好的做法是只给使用方输出状态数据,至于如何使用由使用方决定。

插件代码:

const PPlugin = {/* Pass */ }
let initOk = null
PPlugin.init = new Promise(resolve => initOk = resolve)
// 客户端桥接...
// 服务端接口...
// 经历了一系列同步异步程序后初始化完成
initOk(/* 数据 */)

使用插件:

<!-- 使用方式已变化 -->
<script src="https://example.com/pplugin@latest/pplugin.min.js"></script>
<script>
  PPlugin.init.then(callback).catch(console.error)
</script>

2.2 链式调用

then() 必然返回一个 Promise 对象,Promise 对象又拥有一个 then() 方法,这正是 Promise 能够链式调用的原因。

const p = new Promise(r => r(1))
  .then(res => {
    console.log(res) // 1
    return Promise.resolve(2)
      .then(res => res + 10) // === new Promise(r => r(1))
      .then(res => res + 10) // 由此可见,每次返回的是实例后面跟的最后一个 then
  })
  .then(res => {
    console.log(res) // 22
    return 3 // === Promise.resolve(3)
  })
  .then(res => {
    console.log(res) // 3
  })
  .then(res => {
    console.log(res) // undefined
    return '最强王者'
  })

p.then(console.log.bind(null, '是谁活到了最后:')) // 是谁活到了最后: 最强王者

由于返回一个 Promise 结构体永远返回的是链式调用的最后一个 then(),所以在处理封装好的 Promise 接口时没必要在外面再包一层 Promise

// 包一层 Promise
function api() {
  return new Promise((resolve, reject) => {
    axios.get(/* 链接 */).then(data => {
      // ...
      // 经历了一系列数据处理
      resolve(data.xxx)
    })
  })
}

// 更好的做法:利用链式调用
function api() {
  return axios.get(/* 链接 */).then(data => {
    // ...
    // 经历了一系列数据处理
    return data.xxx
  })
}

2.3 管理多个 Promise 实例

Promise.all() / Promise.race() 可以将多个 Promise 实例包装成一个 Promise 实例,在处理并行的、没有依赖关系的请求时,能够节约大量的时间。

function wait(ms) {
  return new Promise(resolve => setTimeout(resolve.bind(null, ms), ms))
}

// Promise.all
Promise.all([wait(2000), wait(4000), wait(3000)])
  .then(console.log)
// 4 秒后 [ 2000, 4000, 3000 ]

// Promise.race
Promise.race([wait(2000), wait(4000), wait(3000)])
  .then(console.log)
// 2 秒后 2000

2.4 Promiseasync&await

async&await 实际上只是建立在 Promise 之上的语法糖,让异步代码看上去更像同步代码,所以 async&await 在 JavaScript 线程中是非阻塞的,但在当前函数作用域内具备阻塞性质。

let ok = null
async function foo() {
  console.log(1)
  console.log(await new Promise(resolve => ok = resolve))
  console.log(3)
}
foo() // 1
ok(2) // 2 3

2.4.1 使用 async&await 的优势

2.4.1.1 简洁干净

写更少的代码,不需要特地创建一个匿名函数,放入 then() 方法中等待一个响应。

// Promise
function getUserInfo() {
  return getData().then(
    data => {
      return data
    }
  )
}

// async / await
async function getUserInfo() {
  return await getData()
}
2.4.1.2 处理条件语句

当一个异步返回值是另一段逻辑的判断条件,链式调用将随着层级的叠加变得更加复杂,很容易让人混淆。使用 async&await 将使代码可读性变得更好。

// Promise
function getGameInfo() {
  getUserAbValue().then(
    abValue => {
      if (abValue === 1) {
        return getAInfo().then(
          data => {
            // ...
          }
        )
      } else {
        return getBInfo().then(
          data => {
            // ...
          }
        )
      }
    }
  )
}

// async / await
async function getGameInfo() {
  const abValue = await getUserAbValue()
  if (abValue === 1) {
    const data = await getAInfo()
    // ...
  } else {
    // ...
  }
}
2.4.1.3 处理中间值

异步函数常常存在一些异步返回值,作用仅限于成为下一段逻辑的入场券,如果经历层层链式调用,很容易成为另一种形式的“回调地狱”。

// Promise
function getGameInfo() {
  getToken().then(
    token => {
      getLevel(token).then(
        level => {
          getInfo(token, level).then(
            data => {
              // ...
            }
          )
        }
      )
    }
  )
}

// async / await
async function getGameInfo() {
  const token = await getToken()
  const level = await getLevel(token)
  const data = await getInfo(token, level)
  // ...
}

对于多个异步返回中间值,搭配 Promise.all 使用能够提升逻辑性和性能。

// async / await & Promise.all
async function foo() {
  // ...
  const [a, b, c] = await Promise.all([promiseFnA(), promiseFnB(), promiseFnC()])
  const d = await promiseFnD()
  // ...
}
2.4.1.4 靠谱的 await

await 'str' 等于 await Promise.resolve('str')await 会把任何不是 Promise 的值包装成 Promise,看起来貌似没有什么用,但是在处理第三方接口的时候可以 “Hold” 住同步和异步返回值,否则对一个非 Promise 返回值使用 then() 链式调用则会报错。

2.4.2 避免滥用 async&await

await 阻塞 async 函数中的代码执行,在上下文关联性不强的代码中略显累赘。

// async / await
async function initGame() {
  render(await getGame()) // 等待获取游戏执行完毕再去获取用户信息
  report(await getUserInfo())
}

// Promise
function initGame() {
  getGame()
    .then(render)
    .catch(console.error)
  getUserInfo() // 获取用户信息和获取游戏同步进行
    .then(report)
    .catch(console.error)
}

2.4.3 ES2021 新特性:Top-level await

Node.js 14+ 版本后,可以在 JavaScript module 中使用 await 操作符。在这之前,只能通过在 async 声明的场景中使用 await 操作符。

MDN 官方案例

// fetch request
const colors = fetch('../data/colors.json')
  .then((response) => response.json())

export default await colors

2.5 错误处理

1. 链式调用中尽量结尾跟 catch 捕获错误,而不是第二个匿名函数。因为规范里注明了若 then() 方法里面的参数不是函数则什么都不做,所以 catch(rejectionFn) 其实就是 then(null, rejectionFn) 的别名。

anAsyncFn().then(
  resolveSuccess, // 无法捕获
  rejectError // `rejectError` 捕获 `anAsyncFn`
)

↑在以上代码中,anAsyncFn() 抛出来的错误 rejectError 会正常接住,但是 resolveSuccess 抛出来的错误将无法捕获,所以更好的做法是永远使用 catch

anAsyncFn()
  .then(resolveSuccess)
  .catch(rejectError) // 尽量使用 `catch`

若想错误管理精细一点,也可以通过 rejectError 来捕获 anAsyncFn() 的错误,catch 捕获 resolveSuccess 的错误。

anAsyncFn()
  .then(
    resolveSuccess,
    rejectError // 捕获 `anAsyncFn()`
  )
  .catch(handleError) // 捕获 `resolveSuccess`

2. 通过全局属性监听未被处理的 Promise 错误。

浏览器环境(window)的拒绝状态监听事件:

  • unhandledrejection 当 Promise 被拒绝,并且没有提供拒绝处理程序时,触发该事件。
  • rejectionhandled 当 Promise 被拒绝时,若拒绝处理程序被调用,触发该事件。
// 初始化列表
const unhandledRejections = new Map()
// 监听未处理拒绝状态
window.addEventListener('unhandledrejection', e => {
  unhandledRejections.set(e.promise, e.reason)
})
// 监听已处理拒绝状态
window.addEventListener('rejectionhandled', e => {
  unhandledRejections.delete(e.promise)
})
// 循环处理拒绝状态
setInterval(() => {
  unhandledRejections.forEach((reason, promise) => {
    console.log('handle: ', reason.message)
    promise.catch(e => {
      console.log(`I catch u!`, e.message)
    })
  })
  unhandledRejections.clear()
}, 5000)

注意:Promise.reject()new Promise((resolve, reject) => reject()) 这种方式不能直接触发 unhandledrejection 事件,必须是满足已经进行了 then() 链式调用的 Promise 对象才行。

2.6 取消一个 Promise

当执行一个超级久的异步请求时,若超过了能够忍受的最大时长,往往需要取消此次请求,但是 Promise 并没有类似于 cancel() 的取消方法,想结束一个 Promise 只能通过 resolvereject 来改变其状态,社区已经有了满足此需求的开源库 Speculation

或者利用 Promise.race() 的机制来同时注入一个会超时的异步函数,但是 Promise.race() 结束后主程序其实还在 pending 中,占用的资源并没有释放。

Promise.race([anAsyncFn(), timeout(5000)])

2.7 迭代器的应用

若想按顺序执行一堆异步程序,可使用 reduce。每次遍历返回一个 Promise 对象,在下一轮 await 住从而依次执行。相同的场景,也可以使用递归实现,但是在 JavaScript 中随着数量增加,超出调用栈最大次数,便会报错。

function wasteTime(ms) {
  return new Promise(resolve => setTimeout(() => {
    resolve(ms)
    console.log('waste', ms)
  }, ms))
}

// 依次浪费 3 4 5 3 秒 === 15 秒
const arr = [3000, 4000, 5000, 3000]
arr.reduce(async (last, curr) => {
  await last
  return wasteTime(curr)
}, undefined)

三、总结

  1. 每当要使用异步代码时,请考虑使用 Promise
  2. Promise 中所有方法的返回类型都是 Promise
  3. Promise 中的状态改变是一次性的,建议在 reject() 方法中传递 Error 对象。
  4. 尽量为所有的 Promise 添加 then()catch() 方法。
  5. 使用 Promise.all() 去运行多个 Promise
  6. 倘若想在 then()catch() 后都做点什么,可使用 finally()
  7. 可以将多个 then() 挂载在同一个 Promise 上。
  8. async (异步)函数返回一个 Promise,所有返回 Promise 的函数也可以被视作一个异步函数。
  9. await 用于调用异步函数,直到其状态改变(fulfilled or rejected)。
  10. 使用 async / await 时要考虑上下文的依赖性,避免造成不必要的阻塞。

版权声明

本博客所有的原创文章,作者皆保留版权。转载必须包含本声明,保持本文完整,并以超链接形式注明作者后除和本文原始地址:https://blog.mazey.net/understand-promise

(完)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,829评论 1 331
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,603评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,846评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,600评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,780评论 3 272
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,695评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,136评论 2 293
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,862评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,453评论 0 229
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,942评论 2 233
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,347评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,790评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,293评论 3 221
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,839评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,448评论 0 181
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,564评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,623评论 2 249