Promise 技术调研 - 回调地狱的产生原因与解决方式

本贴最后更新于 2286 天前,其中的信息可能已经天翻地覆

产生原因

在前端技术刀耕火种时代,让人闻之变色的一个词就是“回调”。因为设计原因导致 Javascript 这门语言是单线程执行的,这就导致一些耗时的操作会阻塞当前运行线程。
为了解决这个问题,机智的开发者们引入了“同步”和“异步”这两个概念。
打个很简单的比方,去肯德基买汉堡,在柜台上排队买就是同步的,因为必须等待前面的人买好取到餐才能轮到我们;而使用手机点餐就是异步的,下完单之后我们可以想干嘛干嘛,等到收银员喊到我的号的时候再去取餐即可。
在以上场景中,去取餐就是我向肯德基注册的一个回调函数,当我的餐准备就绪,收银员喊我,就相当于调用了回调函数。
但是为什么程序员们谈回调色变呢?究其原因是因为层层回调会造成所谓的“回调地狱(callback hell)”
就像这样:

fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

好吧,不管我们能不能理解以上代码。总之,当多个异步任务需要顺序执行的时候,在刀耕火种的年代,程序员们不得不忍受着这样的煎熬。

解决办法

其实解决回调地狱的办法有很多,从代码书写层面就可以将绝大部分回调代码写的尽量简单易懂。但这都不是我们今天的主角,我们今天主要讲讲 Promise

Promise

Promise 自 ES6 起成为 Javascript 的语言标准。但是其最早是由 Javascript 社区提出并实现的。Promise 规范和标准了异步操作 API,基本上所有的异步操作都可以使用 Promise 的写法处理。Promise 对象内部保存着异步操作的结果,并通过链式调用的方式避免了回调函数层层嵌套的写法。

基本用法

new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('success')
  }, 1000)
}).then(res => {
  console.log(res)  // success
})

Promise 构造函数接收一个函数作为参数,这个函数的两个参数分别为 resolvereject。这也是两个函数,其值会由 Javascript 传入,使用者只需要在异步操作完成时调用 resolve 函数并传入下一步操作所需要的值即可。使用者可以通过链式调用的方式为 Promise 对象添加后续操作。
reject 函数则是在异步操作发生异常时被调用,此时 Promise 可以捕获到传入 reject 参数中的值。

new Promise((resolve, reject) => {
  setTimeout(() => {
    reject('error')
  }, 1000)
})
.then(res => console.log('进入then: ' + res))
.catch((e) => console.log('进入catch: ' + e)) // 进入catch: error

值得一提的是,Promise 代码不同于其他函数,对传入 Promise 构造方法中的函数不需要显示的调用执行,其会直接执行,且是作为同步任务被执行的。

setTimeout(() => console.log('timeout'))
new Promise((resolve, reject) => {
  console.log('Promise')
});
console.log('main')

/*
 * Promise
 * main
 * timeout
 */

改写回调函数

在远古时期,使用 jQuery 发送 ajax 请求的代码类似于以下:

$.ajax({
  url: '/api/user/getInfo',
  data: {},
  dataType: 'json',
  success: function (data) {
    // process success
  },
  error: function(err) {
    // process error
  }
})

如果此时需要有操作在 ajax 请求之后执行,则就需要在 success 上挂载回调函数。如果此时这个操作内又包含了异步操作,那代码就会变得冗长乏味,像老太太的裹脚布一般。
而在有了 Promise 之后,我们可以将普通的 ajax 方法封装为 Promise 方法

function ajax(url, data = {}) {
  return new Promise((resolve, reject) => {
    $.ajax({
      url,
      data,
      dataType: 'json',
      success: resolve
      error: reject
    })
  })
}

注意 ajax 函数,函数构造了 Promise 对象并将其 return 出来,这是帮助我们书写可读性高的异步代码的关键。
之后,我们可以使用 Promise 的链式调用方式来处理请求。

ajax('/api/user/getInfo')
 .then(result => {
  // process result
  return ajax('/api/user/getOrder', { id: result.userId }))
}).then(result => {
  // process result
  return ajax('/api/user/getMessage', { id: result.userId })
}).then(result => {
  // process result
  // do something ... 
})

注意每一个 then 的参数函数内我们又调用了个 ajax 函数,即返回了一个 Promise 对象,这也是 Promise 的链式调用的关键所在。

缺陷

  1. Promise 函数改变了之前回调地狱的写法,但是在根本上还是函数套函数,看起来不是那么的美观
  2. Promise 一经执行,无法中断,除非抛出异常
  3. Promise 外部无法通过 try/catch 的方式捕获 Promise 内部抛出的异常。

Async/Await

可以延展的说一下 async/await。尽管这是一个 ES7 标准内的语法。
async/await 可以将 Promise 代码组织的更像同步代码一样,其书写方式就和之前写同步代码一样,只是需要加上相应关键字。
例如,将之前的 Promise 代码改写为 async/await

async function request(id) {
  const result1 = await ajax('/api/user/getInfo', { id })
  // process result1
  const result2 = await ajax('/api/user/getOrder', { id })
  // process result2
  const result3 = await ajax('/api/user/getMessage', { id })
  // process result3
}
request(1)

必须记住的是在函数上添加 async 关键字,从而可以在函数内使用 await,否则的话会报错。
尽管 async/await 的书写方式很像同步代码,但是这和同步代码是不同的。
打个比方,执行一段很耗时的操作,同步的方式时 JS 会想,我就在这等着你,你这个操作做完了我才能去做别的事。使用 async/await 时 JS 会想,反正闲着也是闲着,我可以先把手头上的工作(主执行栈)停一停,看看有没有其他事情(回调队列或者其它执行栈)可以做的。

const asyncFunc = (n) => new Promise(res => setTimeout(() => res(n), 5000))

const call = async (n) => {
  const result = await asyncFunc(n)
  console.log(result)
}
setTimeout(() => {
  console.log('event call!!')  
}, 2000)

call(50)

// event call!!
// 50

那么这里可以引入一个问题。
小张同学在看完这篇文章之后,希望使用 async/await 改写计算斐波那契数列的函数,从而达到在程序计算时也可以执行其他执行栈的函数。小张同学的代码如下,你知道他错在哪里了吗?

/**
 * 小张希望的输出是:
 * event call!!
 * fib(50)的值
 * 但是运行时却不是这样的,而且程序还会卡死
 * 不是说async/await可以将函数变为异步吗?那执行结果会与预期不一致呢?
 */

const fib = (n) => n === 0 ? 1 : n === 1 ? 1 : fib(n - 1) + fib(n - 2)
const asyncFunc = (n) => new Promise(res => res(fib(n)))

const call = async (n) => {
  const result = await asyncFunc(n)
  console.log(result)
}

setTimeout(() => {
  console.log('event call!!')  
}, 2000)

call(50)

展望未来

或许管道流式操作可能成为异步处理方式的新宠?

推荐阅读

  1. 说说 Event Loop - Jiahao.Zhang’s Blog
  2. Promise - 廖雪峰的官方网站
  3. Javascript 异步编程的 4 种方法 - 阮一峰的网络日志
  4. ECMAScript 6 入门
  5. Promise - JavaScript | MDN
  • 研究
    12 引用 • 34 回帖
  • 前端

    前端技术一般分为前端设计和前端开发,前端设计可以理解为网站的视觉设计,前端开发则是网站的前台代码实现,包括 HTML、CSS 以及 JavaScript 等。

    247 引用 • 1348 回帖 • 2 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • 88250

    深入浅出,言简意赅 👍

    1 回复
  • 其他回帖
  • someone

    我觉得解决回掉地狱问题还是要靠 协程。
    Project Loom / Quasar / Lua / OpenResty

  • someone

    ❤️

  • zjhch123
    作者

    😳😳😳没有深入没有深入,只有浅出

  • 查看全部回帖