Promise的特点、用法

261 阅读14分钟

现在有一个这样的场景:我们需要通过node.js来读取本地文件并输出到控制台上显示,下面是文件内容:

  • 1.txt 内容:111
  • 2.txt 内容:222
  • 3.txt 内容:333

创建一个 callback.js 文件,其中的代码如下

const fs = require('fs');// 导入node.js文件系统模块 fs
const path = require('path'); // 导入node.js处理路径的模块 path

// 参数 fpath 代表文件路径
function getFileByPath(fpath) {
    fs.readFile(fpath, 'utf-8', (err, data) => {
        if (err) {
            throw err
        }else{
            return data
        }
    })
}

const result = getFileByPath(path.join(__dirname, './1.txt'));
console.log(result); // undeined

使用命令:node callback.js 运行,你会发现控制台打印出的是 undefined !为什么呢?我们来分析一下:

  • 我们调用了getFileByPath函数,然后传入了文件路径fpath,如果读入失败,就会抛出错误到控制台,但是控制台并没有抛出错误呀,这可以说明文件读取是成功的
  • 既然读取成功了,那么就应该返回读到的数据data,可是,result的值却是 undefined,所以问题就出在data上面——data没有被正确的返回

原因就在于readFile 是一个异步执行的函数,异步任务并不是按照代码中书写的顺序来执行的

readFile函数并不会被放到主线程上立即执行,而是被放入“任务队列”,这就造成getFileByPath函数瞬间执行完毕而不是等待着readFile函数执行完毕返回data,所以getFileByPath函数返回的就是 undefined。也就是说:当 readFile 函数(异步任务)执行完毕时,getFileByPath函数(同步函数)早就执行完了,所以它的返回值就是 undefined

回调函数

在异步任务中使用回调

为了获取到readFile函数读取文件的返回值,可以对getFileByPath函数传入一个回调函数,当readFile执行完毕就调用回调函数并把读取到的data数据传入,这样我们就一定能获取到异步操作的结果。对函数做如下改进:

// (【fpath——文件路径】,【successCallback——读文件成功执行的回调函数】,【errCallback——读文件失败执行的回调函数】)
function getFileByPath(fpath, successCallback, errCallback) {
    fs.readFile(fpath, 'utf-8', function(err, data){
        if (err) {
            errCallback(err);
        } else {
            successCallback(data);
        }
    })
}

getFileByPath(path.join(__dirname, './1.txt'), function(data){
    console.log(data)
}, function(err){
    console.log(err.message)
});

可以看到,我们传入了两个函数,分别用于处理发生错误的情况和读取成功的情况。一旦成功就会调用 successCallback() 并且把读到的数据 data 传入回调函数;如果失败就会调用 errCallback() ,并且也会把错误信息传入回调函数。这样我们就能成功的读到 data 的数据了

回调地狱

现在,我们需要按顺序依次读出 1.txt、2.txt、3.txt;由于 readFile 是异步函数,所以无法保证执行顺序;不过,嵌套回调函数就行啦

getFileByPath(path.join(__dirname, './1.txt'), function(data){
    
    // 如果 1.txt 读取成功,就去读取 2.txt
    getFileByPath(path.join(__dirname, './2.txt'), function(data){
        
        // 如果 2.txt 读取成功,就去读取 3.txt
        getFileByPath(path.join(__dirname, './3.txt'), function(data){
            console.log(data)
        }, function(err){
            console.log('1.txt 读取失败',err.message)
        })
    }, function(err){
        console.log('2.txt 读取失败',err.message)
    })
}, function(err){
    console.log('3.txt 读取失败',err.message)
})

通过嵌套回调函数,我们成功的实现了按顺序执行的异步任务

现在我们只是读取3个文件,就嵌套了3层回调函数,即使是这样,看起来也非常臃肿;假如我们要读取1000个文件,那么我们就要写上1000个嵌套函数!这就是回调地狱,写起来就像这个样子

上图描述的就是回调函数的缺点,即:

  • 无法解决嵌套过多的问题
  • 不便于书写和维护
  • 重复代码过多,可读性很低

Promise

回调函数是Javascript中处理异步任务一种手段或者方式,我们不能因为它容易变臃肿就不可能不用这种手段,只能慢慢的改进它,所以,社区里的编程大神们想出了 Promise 这个好东西。

Promise 能大大简化回调函数的书写,它本质上还是使用的回调函数,它只是换了一种形式,让我们能更方便的使用回调函数

我们创建一个 promise.js 文件,在其中写入代码,使用Promise读取文件就像下面这样:

const promise = new Promise(function () {
    fs.readFile('./1.txt', 'utf-8', (err, data) => {
        if(err){
            throw err
        }else{
            console.log(data); // 111
        }
    })
})

Promise立即执行

直接运行上面的代码,你会发现,当你 new 一个 Promise之后,控制台立马就会打印出结果,这与普通的对象有很大不同;Promise的定义就是:只要实例化,就会立即执行

上面的Promise实例会立即执行,但是我们此时不需要它立即执行,希望它能被我们手动执行,所以,可以将 promise实例放进一个函数里,只有当我们去调用函数,promise才会被执行

function getFileByPath(fpath) {
    const promise = new Promise(function () {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) throw err;
            console.log(data); // 111
        })
    })
}

getFileByPath('./2.txt'); // 222

Promise的状态

只是实例化一个Promise对象,并不能解决我们的问题,因为要处理失败和成功两种异步操作的结果,所以要给Promise实例传入处理失败和成功的回调函数,从而为失败和成功这两种状态指定下一步的操作

Promise有三种状态:Pending(进行中),Fulfilled(已成功),Rejected(已失败),Javascript提供了下面两个回调函数来分别处理失败的操作和成功的操作,它们是内置的,不用自己部署

  • fulfilled:将Promise的状态从Pending变为Fulfilled,如果异步操作成功,就会调用这个函数
  • rejected:将Promise的状态从Pending变为Rejected,如果异步操作失败,则会调用这个函数

无论是 Promise 的状态从Pending变为Fulfilled还是从Pending变为Rejected,只要发生了变化,之后就再也不会改变,就像整个 Promise 实例凝固了,也称为 resolved(已定型)

为Promise指定回调函数

function getFileByPath(fpath) {
    const promise =  new Promise(function (resolve, reject) {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        })
    })
    
    // 为了在外部获取到 Promise 实例,所以要将其return
    return promise;
}

现在,已经为Promise构造函数指定了回调函数,那就是形参resolve和reject;可问题是,我们并没有去定义resolve和reject这两个函数的内部细节,那要怎样去定义这两个函数呢?

Promise.then()

通常,Promise的回调函数,由调用者去指定,谁调用谁就负责指定;Promise规定,使用 Promise.then() 方法来指定回调

function getFileByPath(fpath) {
    const promise =  new Promise(function (resolve, reject) {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        })
    })
    
    // 为了在外部获取到 Promise 实例,所以要将其return
    return promise;
}

// 定义一个常量 p 用来保存返回的promise实例
const p = getFileByPath('./1.txt');

//使用 then 方法定义回调函数
//then 的形参顺序与 promise构造函数的形参顺序保持一致
//第一个函数代表 resolve(已成功)回调,第二个代表reject(已失败)回调
p.then(function(data){
    // promise 内部调用了 resolve 传入获取到的数据 data
    console.log(data);
},function(err){
    // promise 内部调用了 reject 传入发生的错误信息 err
    console.log(err);
})

我们非常有必要搞清楚这段代码的执行顺序:

  1. 调用 getFileByPath ,继而函数内部创建了一个 Promise 实例,并且立即执行
  2. 变量 p 获得 getFileByPath 返回的 Promise ,立即执行 then 方法的定义
  3. 此时,主线程执行完了所有同步代码,开始执行异步代码(readFile函数)
  4. readFile函数执行完毕,调用then方法指定的回调函数
  5. 异步任务的操作结果被输出到 控制台

通过分析我们可以发现,异步任务总是等着同步任务都执行完了才会被Javascript主线程执行,不过不用担心,我们已经通过 Promise.then() 为异步任务指定了未来——它执行完毕以后的操作

仔细观察上面的代码,就会发现,我们创建了许多中间变量,比如 promise和p,其实它们都是不必要的,为了写出更加精炼的代码,我们可以这样:

function getFileByPath(fpath) {
    // 使用 return 直接返回 Promise 实例
    return new Promise(function (resolve, reject) {
        fs.readFile(fpath, 'utf-8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        })
    })
}

// getFileByPath函数的返回值 就是Promise实例,所以在后面直接使用then方法
getFileByPath('./1.txt').then(function(data){
    console.log(data);
},function(err){
    console.log(err.message);
})

Promise.catch()

Promise的第二个参数

现在,我们使用 Promise 来按顺序读取文件,给每个then方法都定义了作为第二个参数的回调函数,第一个参数处理成功的读取,第二个参数处理错误的读取。这种使用多个 then()来指定回调的方式,也叫做链式调用

getFileByPath('./1.txt')
.then(function (data) {
    console.log(data);
    return getFileByPath('./2.txt')
}, function(err){
    console.log(err.message);
    return getFileByPath('./2.txt')
})
.then(function (data) {
    console.log(data);
    return getFileByPath('./3.txt')
}, function(err){
    console.log(err.message);
    return getFileByPath('./3.txt')
})
.then(function (data) {
    console.log(data);
}, function(err){
    console.log(err.message);
})

运行上面的代码,你会发现,如果其中一个文件读取出错,代码不会立即停止执行,而是抛出错误,继续执行后面的操作;即,前面的错误不会阻塞会后面的执行;其实这样并不好,绝大多数情况下,在一系列操作中,如果前面某个步骤发生错误,应该抛出错误停止执行,因为再去执行后面的是没有意义的,我们想要得到的是一个严格正确的结果。那么怎样才能让程序只返回完全正确的结果呢?

使用 catch()

Promise实例抛出的错误,具有冒泡的性质,也就是说错误会沿着作用域链一直向后传递,前面的then方法抛出的错误,总是会被传递到写在最后的catch方法,所以我们要使用 catch方法来捕获错误,不必使用then 方法中的第二个参数,我们可以这样写:

getFileByPath('./0.txt').then(function (data) {
    console.log(data)
    // 如果当前的文件读取成功,那么就返回一个新的 Promise 实例,用于继续读取后面的文件
    return getFileByPath('./2.txt');
}).then(function (data) {
    console.log(data)
    return getFileByPath('./3.txt');
}).then(function (data) {
    console.log(data)
})
.catch(function (err) {
    console.log(err.message)
})

运行上面的代码,如果某个文件读取出错,那么Promise就会抛出错误到 catch ,并且立即停止执行

**注意:**Promise内部发生的错误不会传递到外部,也就是说如果不用 catch 来捕获错误,我们根本看不到Promise报出的错误,整个程序还是会继续执行

高下立判

在读取文件这个异步操作中:

  • 使用回调函数
getFileByPath(path.join(__dirname, './1.txt'), function(data){
    console.log(data);
    getFileByPath(path.join(__dirname, './2.txt'), function(data){
        console.log(data);
        getFileByPath(path.join(__dirname, './3.txt'), function(data){
            console.log(data);
        }, function(err){
            console.log(err.message);
        });
    }, function(err){
        console.log(err.message);
    });
}, function(err){
    console.log(err.message);
});
  • 前面的Promise中使用了很多匿名函数,所以可以用箭头函数将其替代,使用 Promise
getFileByPath('./1.txt').then(data => {
    console.log(data);
    return getFileByPath('./2.txt')
}).then(data => {
    console.log(data);
    return getFileByPath('./3.txt')
}).then(data => {
    console.log(data);
}).catch(err => console.log(err.message));

现在你应该懂了,为什么要使用 Promise 来代替回调函数了吧,Promise 能让我们的异步任务流程更加的清晰,它的链式调用方式非常易用,代码体验比回调函数的嵌套可是好了太多了!

高级用法

Promise.all()

all() 用来一次性处理多个 Promise 实例。它可以将多个 Promise 实例包装成一个新的 Promise 实例,接受一个数组(或者不是数组,但是一定要可迭代),它的用法是:const P = Promise.all([p1, p2, p3])。根据下面两种情况返回各自的结果:

  • 只有当p1p2p3 的状态都是Fulfilled时,P的状态才转变为 Fulfilled ;此时p1p2p3的返回值组成一个数组,传递给P的回调函数
  • 只要p1p2p3其中有一个的状态变成Rejectedp的状态就变成 Rejected ,此时第一个状态为Rejected 的实例的返回值,会传递给P的回调函数

还是通过上面读取TXT文件的例子,我们实例化3个 Promise ,分别来读取TXT文件:

const p1 = new Promise((resolve, reject) => {
    fs.readFile('./1.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p2 = new Promise((resolve, reject) => {
    fs.readFile('./2.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p3 = new Promise((resolve, reject) => {
    fs.readFile('./3.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

然后,我们来使用 Promise.all() 将这三个实例合并起来管理

const P = Promise.all([p1, p2, p3])

P.then(values => {
    console.log('This is printed by Promise.all', values) 
    // 'This is printed by Promise.all' [111, 222 ,333]
}).catch(err => {
    console.log(err.message)
})

查看控制台输出发现,Promise.all() 的状态变为了 Fulfilled,因为三个实例都成功的读到了TXT文件(状态变为 Fulfilled),并且三个实例都将自己的读取结果传入了 P.then() 指定的回调函数中。

上面代码演示了第一种情况,下面我们接着来看第二种情况,我们让其中一个 Promise 实例发生错误:

const p2 = new Promise((resolve, reject) => {
    // 给出一个不存在的文件路径
    fs.readFile('./hello.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p3 = new Promise((resolve, reject) => {
    // 给出一个不存在的文件路径
    fs.readFile('./good.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

然后再来查看控制台输出:

P.then(values => {
    console.log('This is printed by Promise.all', values) 
}).catch(err => {
    console.log(err.message) // ENOENT: no such file or directory hello.txt
})

此时,因为发生了错误,导致p2p3 的状态都是 Rejected,所以 P 的状态变成了 Rejected,catch() 也捕获到了错误信息,注意,p3 也发生了错误,但是只有第一个 Rejected 的实例(本例中的 p2)的返回值会被传递给 P 的 catch 方法。**注意:**如果其中一个 Promise 实例,自己定义了 catch() 来捕获错误,那么错误不会冒泡到 P.catch() 中,而只会触发自己的 catch()

Promise.allSettled()

Promise.allSettled() 也用来管理多个 Promise 实例,它会等到所有 Promise 实例的状态都凝固了(无论是 Fulfilled 还是 Rejected),才返回实例执行的结果,因为如此,Promise.allSettled()最终的状态总是fulfilled。我们还是用读取TXT的例子来演示Promise.allSettled() 的用法

const p1 = new Promise((resolve, reject) => {
    fs.readFile('./1.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p2 = new Promise((resolve, reject) => {
    // 让 p2 发生错误
    fs.readFile('./hello.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const p3 = new Promise((resolve, reject) => {
    fs.readFile('./3.txt', 'utf-8', (err, data) => {
        if (err) {
            reject(err)
        } else {
            resolve(data)
        }
    })
})

const P = Promise.allSettled([p1, p2, p3])

P.then(results => {
    console.log('This is printed by Promise.allSettled', results)
}).catch(err => {
    console.log(err.message)
})

查看控制台的打印结果,你能清楚的看到每一个 Promise 的状态、哪一个 Promise 实例发生了错误以及错误的详细信息:

 [
    { status: 'fulfilled', value: '111' },
    {
    	status: 'rejected',
    	reason: ['Error: ENOENT: no such file or directory, open hello.txt'] {
      				errno: -4058,
      				code: 'ENOENT',
      				syscall: 'open',
      				path: 'hello.txt'
    			}
  	},
   	{ status: 'fulfilled', value: '111' }
]

Promise.allSettled() 最大的用途就是能让你清楚的了解到每个 Promise 实例的确切执行情况。再者,有时候,我们不关心异步操作的结果,只关心这些操作有没有结束。这时,Promise.allSettled()方法就很有用

Promise.race()

Promise.race()方法同样是用来管理多个 Promise 实例,只要有一个实例率先改变状态,p的状态就跟着改变。那个最先改变状态的 Promise 实例的返回值,就传递给Promise.race()的回调函数

其实可以这样来理解, race 意为“竞赛、比赛”,多个 Promise 实例之间就好像在“竞赛”,谁最先完成任务,那么谁就决定了Promise.race()最终的状态

const P = Promise.race([p1, p2, p3])
P.then(results => {
    console.log('This is printed by Promise.race', results)
    // This is printed by Promise.race 111
}).catch(err => {
    console.log(err.message)
})

可以看到,1.txt 最先读取完成,所以 p1 的状态最先变为 Fulfilled ,p1 执行的结果也就能传递给 P 了

Promise.resolve() & Promise.reject()

Promise.resolve() 返回一个状态为 resolve 的 Promise 实例,Promise.reject() 返回一个状态为 reject 的 Promise 实例。它们接受任意参数,反正最后返回的都是状态已经凝固了的 Promise 实例。

有时需要将现有对象转为 Promise 对象Promise.resolve()方法就可以完成这个功能;Promise.reject() 的功能在于:通过使用Error的实例获取错误原因reason,对调试和选择性错误捕捉很有帮助

Promise.resolve() 的用法,有几条规则需要了解:

  • 如果参数是 Promise 实例,那么Promise.resolve将不做任何修改,直接返回这个实例
  • 可以不传入参数调用,直接返回一个resolved状态的 Promise 对象
  • 参数不是对象,会返回一个 Promise 对象,状态为 resolved

Promise.finally()

finally()方法用于指定,不管 Promise 对象最后状态如何,都会执行的操作,类似于try catch语句的finally的功能。

promise.then(result => {···})
.catch(error => {···})
.finally(() => {···})

上面的代码中,不管 promise 的状态如何,finally的回调函数都会被执行,其实 finally 就是 then 的一个特例。

总结

本文通过一个读取TXT文件的例子,引出了回调函数,然后逐步的又引入了 Promise 对象。

回调函数跟 Promise 都是Javascript中处理异步任务的方式,但是回调函数存在许多缺点,所以未来将会被 Promise 所取代。而且,在ES6标准中,Promise 的优先级要高于 CallBack 回调函数的优先级,具体内容可以阅读这篇文章 《Understanding Asynchronous JavaScript》

Promise 是一个正在不断进化的异步编程方案,文中并没列出 Promise 的全部方法和属性,只考虑了目前可以稳定使用的属性和方法。

参考链接:

www.bilibili.com/video/BV1T4…

es6.ruanyifeng.com/#docs/promi…

developer.mozilla.org/zh-CN/docs/…