专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
码农翻身  ·  漫画 | 为什么大家都愿意进入外企? ·  昨天  
程序员的那些事  ·  OpenAI ... ·  昨天  
程序员的那些事  ·  印度把 DeepSeek ... ·  2 天前  
程序员小灰  ·  3个令人惊艳的DeepSeek项目,诞生了! ·  2 天前  
程序员的那些事  ·  惊!小偷“零元购”后竟向 DeepSeek ... ·  3 天前  
51好读  ›  专栏  ›  SegmentFault思否

ES6 系列之我们来聊聊 Promise

SegmentFault思否  · 公众号  · 程序员  · 2018-10-24 08:00

正文

前言

Promise 的基本使用可以看阮一峰老师的 《ECMAScript 6 入门》

我们来聊点其他的。

回调

说起 Promise,我们一般都会从回调或者回调地狱说起,那么使用回调到底会导致哪些不好的地方呢?

1. 回调嵌套

使用回调,我们很有可能会将业务代码写成如下这种形式:

  1. doA( function(){

  2.    doB();

  3.    doC( function(){

  4.        doD();

  5.    } )

  6.    doE();

  7. } );

  8. doF();

当然这是一种简化的形式,经过一番简单的思考,我们可以判断出执行的顺序为:

  1. doA()

  2. doF()

  3. doB()

  4. doC()

  5. doE()

  6. doD()

然而在实际的项目中,代码会更加杂乱,为了排查问题,我们需要绕过很多碍眼的内容,不断的在函数间进行跳转,使得排查问题的难度也在成倍增加。

当然之所以导致这个问题,其实是因为这种嵌套的书写方式跟人线性的思考方式相违和,以至于我们要多花一些精力去思考真正的执行顺序,嵌套和缩进只是这个思考过程中转移注意力的细枝末节而已。

当然了,与人线性的思考方式相违和,还不是最糟糕的,实际上,我们还会在代码中加入各种各样的逻辑判断,就比如在上面这个例子中,doD() 必须在 doC() 完成后才能完成,万一 doC() 执行失败了呢?我们是要重试 doC() 吗?还是直接转到其他错误处理函数中?当我们将这些判断都加入到这个流程中,很快代码就会变得非常复杂,以至于无法维护和更新。

2. 控制反转

正常书写代码的时候,我们理所当然可以控制自己的代码,然而当我们使用回调的时候,这个回调函数是否能接着执行,其实取决于使用回调的那个 API,就比如:

  1. // 回调函数是否被执行取决于 buy 模块

  2. import {buy} from './buy.js';

  3. buy(itemData, function(res) {

  4.    console.log(res)

  5. });

对于我们经常会使用的 fetch 这种 API,一般是没有什么问题的,但是如果我们使用的是第三方的 API 呢?

当你调用了第三方的 API,对方是否会因为某个错误导致你传入的回调函数执行了多次呢?

为了避免出现这样的问题,你可以在自己的回调函数中加入判断,可是万一又因为某个错误这个回调函数没有执行呢? 万一这个回调函数有时同步执行有时异步执行呢?

我们总结一下这些情况:

  1. 回调函数执行多次

  2. 回调函数没有执行

  3. 回调函数有时同步执行有时异步执行

对于这些情况,你可能都要在回调函数中做些处理,并且每次执行回调函数的时候都要做些处理,这就带来了很多重复的代码。

回调地狱

我们先看一个简单的回调地狱的示例。

现在要找出一个目录中最大的文件,处理步骤应该是:

  1. fs . readdir 获取目录中的文件列表;

  2. 循环遍历文件,使用 fs . stat 获取文件信息

  3. 比较找出最大文件;

  4. 以最大文件的文件名为参数调用回调。

代码为:

  1. var fs = require( 'fs');

  2. var path = require('path');

  3. function findLargest(dir, cb) {

  4.    // 读取目录下的所有文件

  5.    fs.readdir(dir, function(er, files) {

  6.        if (er) return cb(er);

  7.        var counter = files.length;

  8.        var errored = false;

  9.        var stats = [];

  10.        files.forEach(function (file, index) {

  11.            // 读取文件信息

  12.            fs.stat(path.join(dir, file), function(er, stat) {

  13.                if (errored) return;

  14.                if (er) {

  15.                    errored = true;

  16.                    return cb(er);

  17.                }

  18.                stats[index] = stat;

  19.                 // 事先算好有多少个文件,读完 1 个文件信息,计数减 1,当为 0 时,说明读取完毕,此时执行最终的比较操作

  20.                if (--counter == 0) {

  21.                    var largest = stats

  22.                        .filter(function(stat) { return stat.isFile() })

  23.                        .reduce(function(prev, next) {

  24.                            if (prev.size > next.size) return prev

  25.                            return next

  26.                        })

  27.                    cb(null, files[stats.indexOf(largest)])

  28.                }

  29.            })

  30.        })

  31.    })

  32. }

使用方式为:

  1. // 查找当前目录最大的文件

  2. findLargest('./', function(er, filename) {

  3.    if (er) return console.error(er)

  4.    console.log('largest file was:', filename)

  5. });

你可以将以上代码复制到一个比如 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、errored、stats 等,不仅写起来麻烦,而且如果你忽略了文件读取错误时的情况,不记录错误状态,就会接着读取其他文件,造成无谓的浪费。此外外层的变量,也可能被其它同一作用域的函数访问并且修改,容易造成误操作。

之所以单独讲讲回调地狱,其实是想说嵌套和缩进只是回调地狱的一个梗而已,它导致的问题远非嵌套导致的可读性降低而已。

Promise

Promise 使得以上绝大部分的问题都得到了解决。

1. 嵌套问题

举个例子:

  1. request(url, function(err, res, body) {

  2.    if (err) handleError(err);

  3.    fs.writeFile('1.txt', body, function(err) {

  4.        request(url2, function(err, res, body) {

  5.            if ( err) handleError(err)

  6.        })

  7.    })

  8. });

使用 Promise 后:

  1. request(url)

  2. .then(function(result) {

  3.    return writeFileAsynv('1.txt', result)

  4. })

  5. .then(function(result) {

  6.    return request(url2)

  7. })

  8. .catch(function(e){

  9.    handleError(e)

  10. });

而对于读取最大文件的那个例子,我们使用 promise 可以简化为:

  1. var fs = require('fs');

  2. var path = require('path');

  3. var readDir = function(dir) {

  4.    return new Promise(function(resolve, reject) {

  5.        fs.readdir(dir, function(err, files) {

  6.            if (err) reject(err);

  7.            resolve(files)

  8.        })

  9.    })

  10. }

  11. var stat = function(path) {

  12.    return new Promise(function(resolve, reject) {

  13.        fs.stat(path, function(err, stat) {

  14.            if (err) reject(err)

  15.            resolve(stat)

  16.        })

  17.    })

  18. }

  19. function findLargest(dir) {

  20.    return readDir(dir)

  21.         .then(function(files) {

  22.            let promises = files.map(file => stat(path.join(dir, file)))

  23.            return Promise.all(promises).then(function(stats) {

  24.                return { stats, files }

  25.            })

  26.        })

  27.        .then(data => {

  28.            let largest = data.stats

  29.                .filter(function(stat) { return stat. isFile() })

  30.                .reduce((prev, next) => {

  31.                    if (prev.size > next.size) return prev

  32.                    return next

  33.                })

  34.            return data.files[data.stats.indexOf(largest)]

  35.        })

  36. }

2. 控制反转再反转

前面我们讲到使用第三方回调 API 的时候,可能会遇到如下问题:

  1. 回调函数执行多次

  2. 回调函数没有执行

  3. 回调函数有时同步执行有时异步执行

对于第一个问题,Promise 只能 resolve 一次,剩下的调用都会被忽略。

对于第二个问题,我们可以使用 Promise.race 函数来解决:

  1. function timeoutPromise(delay) {

  2.    return new Promise( function(resolve,reject){

  3.        setTimeout( function(){

  4.            reject( "Timeout!" );

  5.        }, delay );

  6.    } );

  7. }

  8. Promise.race( [

  9.    foo(),

  10.    timeoutPromise( 3000 )

  11. ] )

  12. .then(function(){}, function(err){});

对于第三个问题,为什么有的时候会同步执行有的时候回异步执行呢?

我们来看个例子:

  1. var cache = {...};

  2. function downloadFile(url) {

  3.      if(cache.has(url)) {

  4.            // 如果存在cache,这里为同步调用

  5.           return Promise.resolve(cache.get(url));

  6.      }

  7.     return fetch(url).then(file => cache.set(url, file)); // 这里为异步调用

  8. }

  9. console.log('1');

  10. getValue.then(() => console.log('2'));

  11. console.log('3');

在这个例子中,有 cahce 的情况下,打印结果为 1 2 3,在没有 cache 的时候,打印结果为 1 3 2。

然而如果将这种同步和异步混用的代码作为内部实现,只暴露接口给外部调用,调用方由于无法判断是到底是异步还是同步状态,影响程序的可维护性和可测试性。

简单来说就是同步和异步共存的情况无法保证程序逻辑的一致性。

然而 Promise 解决了这个问题,我们来看个例子:

  1. var promise = new Promise(function (resolve){

  2.    resolve();

  3.    console.log(1);

  4. });

  5. promise.then(function(){

  6.    console.log(2);

  7. });

  8. console.log(3);

  9. // 1 3 2

即使 promise 对象立刻进入 resolved 状态,即同步调用 resolve 函数,then 函数中指定的方法依然是异步进行的。

PromiseA+ 规范也有明确的规定:

实践中要确保 onFulfilled 和 onRejected 方法异步执行,且应该在 then 方法被调用的那一轮事件循环之后的新执行栈中执行。

Promise 反模式

1.Promise 嵌套

  1. // bad

  2. loadSomething().then(function(something) {

  3.    loadAnotherthing().then(function(another) {

  4.        DoSomethingOnThem(something, another);

  5.    });

  6. });

  1. // good

  2. Promise.all([loadSomething(), loadAnotherthing()])

  3. .then(function ([something, another]) {

  4.    DoSomethingOnThem(...[something, another]);

  5. });

2.断开的 Promise 链

  1. // bad

  2. function anAsyncCall() {

  3.    var promise = doSomethingAsync();

  4.    promise .then(function() {

  5.        somethingComplicated();

  6.    });

  7.    return promise;

  8. }

  1. // good

  2. function anAsyncCall() {

  3.    var promise = doSomethingAsync();

  4.    return promise.then(function() {

  5.        somethingComplicated()

  6.    });

  7. }

3.混乱的集合

  1. // bad

  2. function workMyCollection(arr) {

  3.    var resultArr = [];

  4.    function _recursive(idx) {

  5.        if (idx >= resultArr.length) return resultArr;

  6.        return doSomethingAsync(arr[idx]).then(function(res) {

  7.            resultArr.push(res);

  8.            return _recursive(idx + 1);

  9.        });

  10.    }

  11.    return _recursive(0);

  12. }

你可以写成:

  1. function workMyCollection(arr) {

  2.    return Promise.all(arr.map(function(item) {

  3.        return doSomethingAsync(item);

  4.    }));

  5. }

如果你非要以队列的形式执行,你可以写成:

  1. function workMyCollection(arr) {

  2.    return arr.reduce(function(promise, item) {

  3.        return promise.then(function(result) {

  4.            return doSomethingAsyncWithResult(item, result);

  5.        });

  6.    }, Promise.resolve());

  7. }

4.catch

  1. // bad

  2. somethingAync.then(function() {

  3.    return somethingElseAsync();

  4. }, function(err) {

  5.    handleMyError(err);

  6. });

如果 somethingElseAsync 抛出错误,是无法被捕获的。你可以写成:

  1. // good

  2. somethingAsync

  3. .then(function() {

  4.    return somethingElseAsync()

  5. })







请到「今天看啥」查看全文