promise 的一般用法

138 阅读10分钟
/**
1. 回调
说起Promise,我们一般从回调或者回调地狱说起,那么使用回调到底导致哪些不好的地方 
  1.1 回调嵌套
  使用回调,我们很有可能会将业务代码写成如下这种形式:
  doA(function() {
    doB()
    doC(function() {
      doD()
    })
    doE()
  })
  doF()
  当然这是一种简化的形式,经过判断得出执行顺序
  doA
  doF
  doB
  doC
  doE
  doD
  然而在实际的项目中,代码会更加杂乱,为了排查问题,我们需要绕过很多碍眼的内容,不断的在函数间进行跳转,使得排查问题的难度也在成倍增加。
  之所以导致这个问题,其实是因为这种嵌套的书写方式跟人线性的思考方式相违和,以至于我们要多花一些精力去思考真正的执行顺序,嵌套和缩进只是这个思考过程中转移注意力的细枝末节而已。
  当然,与人线性思考方式违和还不是最糟糕,实际上我们还会在代理里加上各种各样的逻辑判断,比如上边那个例子,doD必须在doC完成后才能完成,如果doC执行失败了呢?我们是要重试doC吗?还是直接转到其他错误处理函数中,我们将这些判断都加入这个流程中,很快代码就会很复杂难以维护和更新

  1.2 控制反转
    正常书写代码的时候,我们理所当然可以控制自己的代码,然而当我们使用回调的时候,这个回调函数是否能接着执行,其实取决于使用回调的那个 API,就比如:
    // 回调函数是否被执行取决于 buy 模块
    import {buy} from './buy.js'
    buy(itemDtata, function(res) {
      console.log(res)
    })
    对于我们经常会使用的 fetch 这种 API,一般是没有什么问题的,但是如果我们使用的是第三方的 API 呢?
    当你调用第三方API,对方不会因为某个错误导致你传入的回调函数执行了多少次呢?
    当然,你可以在自己的回调函数中加入判断,可是万一又因为某个错误这个回调函数没有执行呢? 万一这个回调函数有时同步执行有时异步执行呢?
    我们总结一下这些情况:
    回调函数执行多次
    回调函数没有执行
    回调函数有时同步执行有时异步执行
    对于这些情况,你可能都要在回调函数中做些处理,并且每次执行回调函数的时候都要做些处理,这就带来了很多重复的代码。

2. 回调地狱
    先看一个简单的回调地狱的示例
    找出一个目录中最大的文件,处理步骤应该如下
    1.用fs.readdir 获取目录中的文件列表
    2. 循环遍历文件,使用fs.stat获取文件信息
    3. 比较找出最大文件
    4. 以最大文件的文件名作为参数调用回调
    代码为
    var fs = require('fs')
    var path = require('path')
    function findLargest(dir, cb) {
      // 读取目录下的所有文件
      fs.readdir(dir, function(er, files) {
        if(er) return cb(er)
        var counter = files.length
        var errored = false
        var stats = []
        files.forEach(function(file, index) {
          // 读取文件信息
          fs.stat(path.join(dir, file), function(er, stat) {
            if(errored) return
            if(er) {
              errored = true
              return cb(er)
            }
            stats[index] = stat
            // 首先算好有多少个文件,读完1个文件信息 计数减一,当为0 时,说明读取完毕后,此时执行最终的比较操作
            if(--counter === 0) {
              var largest = stats.filter(function(stat) {return stat.isFile()}).reduce(function(prev, next) {
                if(prev.size > next.size) return prev
                return next
              })
              cb(null, files[stats.indexOF(largest)])
            }
          })
        })

      })
    }
    使用方式为:
    // 查找当前目录最大的文件
    findLargest('./', function(er, filename) {
      if(er) return console.error(er)
      console.log('largets file was:', filename)
    })
    你可以将以上代码复制到一个比如 index.js 文件,然后执行 node index.js 就可以打印出最大的文件的名称。
    看完这个例子,我们再来聊聊回调地狱的其他问题:
    1. 难以复用
    回调的顺序确认后,对其中某些环节进行复用很难
    举个例子,如果想对fs.stat读取文件信息这段代码复用,但是这段代码引用了其它的外层变量,所以还是对外层代码进行修改

    2. 堆栈信息被断开
    javaScript 引擎维护了一个执行上下文栈,当函数执行的时候,会创建该函数的执行上下文压入栈中,当函数执行完毕后,会将该执行上下文出栈。
    A 函数中调用了 B 函数,JavaScript 会先将 A 函数的执行上下文压入栈中,再将 B 函数的执行上下文压入栈中,当 B 函数执行完毕,将 B 函数执行上下文出栈,当 A 函数执行完毕后,将 A 函数执行上下文出栈。

    这样的好处在于,我们如果中断代码执行,可以检索完整的堆栈信息,从中获取任何我们想获取的信息。
    可是异步函数并非如此,比如执行fs.readdir时候,其实是将回调函数加入任务队列中,代码继续执行,直至主线程完成后,才会从任务对列选择已经完成的任务,并将其加入栈中,此时栈中 只有这一个执行上下文,如果回调报错,也无法获取调用这个异步操作时的栈中信息,不容易判断哪里出错
    此外,因为是异步的缘故,使用 try catch 语句也无法直接捕获错误。
    (不过 Promise 并没有解决这个问题)

    3. 借助外层变量
    当多个异步计算同时进行,比如这里遍历读取文件信息,由于无法预期完成顺序,必须借助外层作用域的变量,比如这里count stats等,不仅写起来麻烦,而且如果你忽略了文件读取错误时的情况,不记录错误状态,就会接着读取其它文件,造成无畏的浪费,此外外层的变量也可能被其它同一个作用域的函数访问并且修改,容易造成误操作
    之所以单独讲讲回调地狱,其实是想说嵌套和缩进只是回调地狱的一个梗而已,它导致的问题远非嵌套导致的可读性降低而已。

3. Promise
Promise 使得以上绝大部分的问题都得到了解决。
  3.1 嵌套问题
  例子
  request(url, function(err, res, body) {
    if(err) handleError(err)
    fs.writeFile('1.txt', body, function(err) {
      request(url2, function(err, res, body) {
        if(err) handleError(err)
      })
    })
  })
  用Promise后
  request(url)
  .then(function(reuslt) {
    return writeFileAsyn('1.txt', result)
  })
  .then(function(result) {
    return request(url2)
  })
  .catch(function(e) {
    handleError(e)
  })
  对于读取最大文件的那个例子,我们使用 promise 可以简化为:

  var fs = require('fs')
  var path = require('path')
  
  var readDir = function(dir) {
    return new Promise(function(resolve, reject) {
      fs.readdir(dir, function(err, files) {
        if(err) reject(err)
        resolve(files)
      })
    })
  }

  var stat = function(path) {
    return new Promise(function(resolve, reject) {
      fs.stat(path, function(err, stat) {
        if(err) reject(err)
        resolve(stat)
      })
    })
  }

  function findLargest(dir) {
    return readDir(dir)
      .then(function(files) {
        let promises = files.map(file => stat(path.join(dir, file)))
        return Promise.all(promises).then(function(stats) {
          return { stats, files }
        })
      })
      .then(data => {
        let largest = data.stats
          .filter(function(stat) { return stat.isFile() })
          .reduce((prev, next) => {
            if(prev.size > next.size) return prev
            return next
          })
          return data.files[data.stats.indexOf(largest)]
      })
  }

  3.2 控制反转再反转
  前面说到,我们讲到使用第三方API时候,可能遇到如下问题
  1. 回调函数执行多次
  2. 回调函数没有执行
  3. 回调函数有时 同步执行 有时异步执行
  对于第二个问题,我们可以使用Promise.race函数解决

  function timeoutPromise(delay) {
    return new Promise(function(resolve, reject) {
      setTimeout(function() {
        reject('timeout')
      }, dalay)
    })
  }
  Promise.race([
    foo(),
    timeoutPromise(3000)
  ]).then(function() {}, function(err){})

  对于第三个问题,为什么有的时候会同步执行有的时候回异步执行呢?
  我们来看个例子:
  var cache = {...}
  function downloadFile(url) {
    if(cache.has(url)) {
      // 如果存在cache,这里是同步回调
      return Promise.resolve(cache.get(url))
    }
    return fetch(url).then(file => cache.set(url, file)) // 这里是异步调用
  }
  console.log(1)
  getValue.then(() => console.log(2))
  console.log(3)
  // 1 3 2
  即使promise对象立刻进入resolved状态,即是同步调用resolve函数。then函数指定的方法依然是异步进行的
  PromiseA+规范也有明确规定
  实践中要确保onFulfilled和onReject方法异步执行,且应该在then方法被调用的那一轮事件循环之后的新执行栈中执行

4. Promise反模式
  1.Promise嵌套
  // bad
  loadSomething().then(function(something) {
    loadAnothering().then(function(another) {
      DoSomethingOnThem(something, another)
    })
  })

  // good
  Promise.all([loadSomething(), loadAnothnerthing()])
  .then(function ([something, another]) {
    DoSomethingOnThem(...[something, another])
  })

  2. 断开的Promise链
  // bad
  function anAsyncCall() {
    var promise = doSomethingAsync()
    promise.then(function() {
      somethingComplicated()
    })
    return promise
  }
  // good
  function anAsyncCall() {
    var promise = doSomethingAsync()
    return promise.then(function() {
      somethingComplicated()
    })
  }
  3. 混乱的集合
  // bad
  function workMyCoolection(arr) {
    var resultArr = []
    function _recursive(idx) {
      if (dix >= resultArr.length) return resultArr
      return doSomethingAsync(arr[idx]).then(function(res) {
        resultArr.push(res)
        return _recursive(idx + 1)
      })
    }
    return _recursive(0)
  }
  你可以写成
  function workMyCollection(arr) {
    return Promise.all(arr.map(function(item) {
      return doSomethingAsync(item)
    }))
  }
  如果你非要以队列的形式执行,你可以写成
  function workMyCollection(arr) {
    return arr.reduce(function(promise, item) {
      return promise.then(function(result) {
        return doSomethingAsyncWithResult(item, result)
      })
    }, Promise.resolve())
  }
  4. catch
  // bad
  somethingAsync.then(function() {
    return somethingElseAsync()
  }, function(err) {
    handleMyError(err)
  })
  如果 somethingElseAsync抛出错误,是无法被捕获的
  // good
  somethingAsync.then(function() {
    return somthingElseAsync()
  }).then(null, function(err) {
    handleMyError(err)
  })

5. 红绿灯问题
  红灯三秒亮一次,绿灯一秒亮一次,黄灯2秒亮一次;如何让三个灯不断交替重复亮灯?(用 Promse 实现)
  三个亮灯函数已经存在:
  function red() {
    console.log('red')
  }
  function green() {
    console.log('green')
  }
  function yellow() {
    console.log('yellow')
  }
  利用then和递归实现
  var light = function(timer, cb) {
    return new Promise(function(resolve, reject) {
      setTimeout(function() {
        cb()
        resovlve()
      }, timer)
    })
  }
  var step = function() {
    Promise.resolve().then(function() {
      return light(3000, red)
    }).then(function() {
      return light(2000,green)
    }).then(function() {
      return light(1000, yellow)
    }).then(function() {
      step()
    })
  }
  step()
6. Promisify

7. Promise局限性
  7.1 错误被吃掉
  首先我们要理解,什么是错误被吃掉,是指错误信息不被打印吗?
  并不是,举个例子:
  throw new Error('error')
  console.log(233333)
  因为 throw error 的缘故,代码被阻断执行,并不会打印 233333,再举个例子
  const promise = new Promise(null);
  console.log(233333);
  以上代码依然会被阻断执行,这是因为如果通过无效的方式使用 Promise,并且出现了一个错误阻碍了正常 Promise 的构造,结果会得到一个立刻跑出的异常,而不是一个被拒绝的 Promise
  let promise = new Promise(() => {
    throw new Error('error')
  });
  console.log(2333333);
  这次会正常的打印 233333,说明 Promise 内部的错误不会影响到 Promise 外部的代码,而这种情况我们就通常称为 “吃掉错误”。
  其实这并不是 Promise 独有的局限性,try..catch 也是这样,同样会捕获一个异常并简单的吃掉错误。
  而正是因为错误被吃掉,Promise 链中的错误很容易被忽略掉,这也是为什么会一般推荐在 Promise 链的最后添加一个 catch 函数,因为对于一个没有错误处理函数的 Promise 链,任何错误都会在链中被传播下去,直到你注册了错误处理函数。

  7.2 单一值
  promise 只能有一个完成值或一个拒绝原因,然而在真实使用的时候,往往需要传递多个值,一般做法都是构造一个对象或者数据,然后在传递,then中获得这个值后又会进行取值赋值的操作
  每次封装和解封都让代码变得笨重
  7.3 无法取消
  Promise 一旦新建它就会立即执行,无法中途取消。
  7.4 无法得知pending状态
  当处于 pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

8. 参考

 */