关于promise的思考


本篇内容是翻译自We have a problem with promises水平有限,希望大家查看原文,不吝赐教。

各位JavaScript开发者,是时候承认我们对promise还是有些错误的认识,并不是说promise本身有问题。promise遵循promise A+规范,设计出众。

有一个大问题,就是在过去的一年里,我看到随着许多开发者对于PouchDB API或者其他promise封装api 的使用中,其实好多开发者在使用过程中并没有真正的理解。

如果你觉得很难相信,请考虑一下我最近我在Twitter上发布的这个题:

Q: 以下四种promises 有何不同 ?

1
2
3
4
5
6
7
8
9
10
11
doSomething().then(function () {
return doSomethingElse();
});

doSomething().then(function () {
doSomethingElse();
});

doSomething().then(doSomethingElse());

doSomething().then(doSomethingElse);

如果你知道答案,那么恭喜你:你已经是一位promise 忍者了。你大可选择终止阅读本篇博客。

对于其他99.99%在大厂的人,没有人能够回答解决我Twitter上的问题,这令我很吃惊,尽管这只是一个测试。

答案就在文章的最后,但是首先我想探究一下为什么这个问题是是如此的棘手,以至于这么多不论新手还是专家被这个问题绊倒。我也会提供我的独到见解,一个小把戏,理解promise将不在话下,当然我也相信在你读了文章之后能够理解。

开始之前,让我们挑战一下一些常见的promise设想

为什么出现promise

如果你读过关于promise的一些文章,你会经常看到可怕的回调占满屏幕,promise 的出现就是为了解决这个问题的,但是这不仅仅只是为了缩进好看。正如在演讲中提到的“走出回调地狱”,回调的真正问题是它们剥夺了关键词return,throw。 相反,我们的程序的整个流程是基于副作用:一个功能耦合调用另一个。事实上,回调函数做的更加险恶:他们改变了我们通常理所当然认为的编程语言的堆栈。编写代码没有堆栈很像驾驶一辆没有刹车:你没有意识到你是多么的需要它,直到你用到时发现你够不到它。

promise出现的关键就是我们使用异步回调的时候能够自然的使用语言的基础,比如 return,throw,以及堆栈。但是为了更好的使用promise在使用之前一定要搞清楚。

新手的问题

有些人试图使用卡通的方式解释,或者以一种非常名词化的方式解释:“哦,这是你可以传递的,它代表了一个异步值”。我认为这样的解释并没有帮助理解。对于我来说,promise就是代码结构和流程控制。因此,我认为最好是列出一些常见的错误并说明如何修复它们。我把这些“菜鸟错误”解释为“你现在是菜鸟,小子,但你很快就会成为职业选手的。”

快速的题外话:promise对于不同的人有不同的见解,但这篇文章的目的,我只想谈谈官方规则,如暴露在现代的浏览器window.promise,不是所有的浏览器都支持,但是你可以使用精巧的lie库做个兼容。

新手的问题#1:promise 金字塔

看人们如何使用pouchdb,许多都是基于promise的API,我看到很多可怜的promise模式。最常见的坏习惯是这个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
remotedb.allDocs({
include_docs: true,
attachments: true
}).then(function (result) {
var docs = result.rows;
docs.forEach(function(element) {
localdb.put(element.doc).then(function(response) {
alert("Pulled doc with id " + element.doc._id + " and added to local db.");
}).catch(function (err) {
if (err.name == 'conflict') {
localdb.get(element.doc._id).then(function (resp) {
localdb.remove(resp._id, resp._rev).then(function (resp) {
.....
}
}
}
}
}
}

是的,你用promise又回到了回调,大材小用,建议你不要这样用。

如果你认为这种错误只限于绝对的初学者,你会惊讶地发现我实际上是从黑莓官方开发者博客上获取了以上代码 旧的回调习惯难改啊!

一个好的代码风格:

1
2
3
4
5
6
7
8
9
remotedb.allDocs(...).then(function (resultOfAllDocs) {
return localdb.put(...);
}).then(function (resultOfPut) {
return localdb.get(...);
}).then(function (resultOfGet) {
return localdb.put(...);
}).catch(function (err) {
console.log(err);
});

这才被称为promise的结构,也是promise强大之处。每一个函数只在前一个promise被解析返回新的promise对象时调用。

新手的问题#2:xxx,我如何在promise中使用forEach()

这就是大多数人在理解promise时奔溃的地方,当他们想使用熟悉的forEach()循环时(或者是for循环,while循环)时就懵逼了。所以就会写出如下代码:

1
2
3
4
5
6
7
8
// I want to remove() all docs
db.allDocs({include_docs: true}).then(function (result) {
result.rows.forEach(function (row) {
db.remove(row.doc);
});
}).then(function () {
// I naively believe all docs have been removed() now!
});

这段代码的问题是什么? 问题就是第一个函数始终返回undefined,就意味着第二个函数不会去等待执行db.remove()操作,事实上没有任何等待,并且随机数量的文档被删除了!

对于此处的问题写了个测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const db = {
docs: [1, 2, 3, 4, 5, 6, 7],
allDocs() {
const db = this
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(db.docs)
}, 1000)
})
},
remove(item) {
const db = this
return new Promise((resolve, reject) => {
setTimeout(() => {
const index = db.docs.indexOf(item)
if (index !== -1) {
console.log('删了一个', db.docs[index])
db.docs.splice(index, 1)
resolve(true)
} else {
resolve(false)
}
}, 1000)
})
},
}

// I want to remove() all docs
db
.allDocs({ include_docs: true })
.then(function(result) {
console.log('result', result)
result.forEach(function(row) {
db.remove(row)
})
})
.then(function() {
console.log('// I naively believe all docs have been removed() now!')
})

这是一个很阴险的bug,假设PouchDB很快的删除了文档更行了你的ui,可能你还没有意识到哪错了,错误可能会在奇数的情况下出现,也可能在某些浏览器中出现,你几乎无法调试。

当你遇到循环的时候,这并不是你期望的结构,你需要Promise.all()

1
2
3
4
5
6
7
db.allDocs({include_docs: true}).then(function (result) {
return Promise.all(result.rows.map(function (row) {
return db.remove(row.doc);
}));
}).then(function (arrayOfResults) {
// All docs have really been removed() now!
});

发生了什么?基本上Promise.all()用一个promise数组作为输入, 然后它给了你另一个promise,只有当每一个promsie都resolve了,才执行resolve()方法。
等价于异步的for循环

Promise.all() 还将处理结果传递给下一个函数,是非常有用的,例如如果你是循环的使用PouchDB 的get()方法。还有一点是all()中如果有一个出现了
rejected状态,也会整体执行reject()方法。

新手的问题#3:忘记添加.catch()

这是一个常见的错误,许多的开发者忘记添加.catch()方法就感觉他们特别的有信心他们的promise永远不会报错,不幸的是它们真的即使错了也不会在控制台输出,错误被淹没,调试异常痛苦。

我养成了简单地在我的promise链中添加以下代码的习惯,避免这种可怕的场景。

1
2
3
4
5
somePromise().then(function () {
return anotherPromise();
}).then(function () {
return yetAnotherPromise();
}).catch(console.log.bind(console)); // <-- this is badass

即使你从不希望错误,还是要添加catch(),如果出错了,调试起来就轻松了。

新手的问题#4:使用 “deferred”

新手的问题#5:使用副作用代替返回

以下代码有何问题

1
2
3
4
5
6
somePromise().then(function () {
someOtherPromise();
}).then(function () {
// Gee, I hope someOtherPromise() has resolved!
// Spoiler alert: it hasn't.
});

这是一个很好的例子来讨论以前你所不知道的promise的一些事情。

说真的,这是一个奇怪的把戏,一旦你理解了它,就能避免我所说的所有错误。你准备好了吗?

,但是具体是如何实践的呢?
每一个promise 都活给你一个then()方法(或者catch(),其实它是then(null, …)的语法糖)这里讨论then()方法:

1
2
3
somePromise().then(function () {
// I'm inside a then() function!
});

在这里我们能做什么?有三件事可做:

  1. return另一个promise
  2. return一个同步的值(或者undefined)
  3. throw一个同步错误

只要你理解了这三点,也就差不过多理解promise了

1.return另一个promise

一个常见的例子:

1
2
3
4
5
getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id);
}).then(function (userAccount) {
// I got a user account!
});

这里我返回了一个promise, return是关键的,我能在下一个函数中接收到user 信息,但是我如果没有return,我将接受到一个 undefined

2.return一个同步的值(或者undefined)

返回一个undefined,但是返回一个同步的值是一种很好的方法替代返回promise,例如我们对user信息有一个缓存,我们可这样做:

1
2
3
4
5
6
7
8
getUserByName('nolan').then(function (user) {
if (inMemoryCache[user.id]) {
return inMemoryCache[user.id]; // returning a synchronous value!
}
return getUserAccountById(user.id); // returning a promise!
}).then(function (userAccount) {
// I got a user account!
});

是不是很棒,第二个函数不用关心userAccount是同步还是异步获取的,第一个函数也可返回一个同步和异步的值。

不幸的是,有一个不争的事实,即JavaScript中的非返回函数在技术上返回未定义,这意味着当您要返回某些东西时,很容易无意中引入副作用。鉴于这这原因,我经常会有一个个人习惯就是经常在then()函数中return或者throw,我建议你也这样做。

3.throw一个同步错误

说道throw,这是promie更爽的一个地方,那么我们就以用户登出是要抛出一个错误为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
getUserByName('nolan').then(function (user) {
if (user.isLoggedOut()) {
throw new Error('user logged out!'); // throwing a synchronous error!
}
if (inMemoryCache[user.id]) {
return inMemoryCache[user.id]; // returning a synchronous value!
}
return getUserAccountById(user.id); // returning a promise!
}).then(function (userAccount) {
// I got a user account!
}).catch(function (err) {
// Boo, I got an error!
});

我们的catch()方法将在用户登出是接收到一个同步错误,或者是任何promise rejected状态时接受到一个异步错误,它不关心到底是什么错误。

这是特别有用的,应为在开发中可以定位错误。例如,当你在then()内部使用JSON.parse()它有可能抛出异常。使用常规的回调函数,错误很可能被掩埋, 但是使用promise,我们可以使用catch()方法捕获。

高级错误

好了,现在你已经知道了promise的简单诀窍,让我们讨论一下非常见问题,总有一些特殊问题。

这些问题我总结为‘高级问题’,因为我看到好多的开发者已经深入的使用promise了。但是我们如果想解决开篇提出的问题,还需要讨论一下。

高级错误#1:不懂 Promise.resolve()

promise对于把同步代码封装为异步是非常有用的,如下展示的代码,但是你会发现你有点啰嗦了:

1
2
3
new Promise(function (resolve, reject) {
resolve(someSynchronousValue);
}).then(/* ... */);

你可以很简洁的使用Promise.resolve()

1
Promise.resolve(someSynchronousValue).then(/* ... */);

同样对于捕获同步代码的错误是非常有用,以至于我养成了如下返回promise API的习惯

1
2
3
4
5
6
function somePromiseAPI() {
return Promise.resolve().then(function () {
doSomethingThatMayThrow();
return 'foo';
}).then(/* ... */);
}

记住:抛出错误总比你debug错误来的方便,如果你使用Promise.resolve(),你总能在后边使用catch()捕获错误

同样的有一个Promise.reject()方法可以使用它立即进入rejected状态

1
Promise.reject(new Error('some awful error'));

高级错误#2: catch()不完全同等于then(null, ...)

以下的代码中catch就是一层语法糖,所以二者是等价的:

1
2
3
4
5
6
7
somePromise().catch(function (err) {
// handle error
});

somePromise().then(null, function (err) {
// handle error
});

然而下边的代码片段可不是等价的:

1
2
3
4
5
6
7
8
9
10
11
somePromise().then(function () {
return someOtherPromise();
}).catch(function (err) {
// handle error
});

somePromise().then(function () {
return someOtherPromise();
}, function (err) {
// handle error
});

如果你想知道为什么二者不等价,思考一下如果第一个函数抛出错误会发生什么?

1
2
3
4
5
6
7
8
9
10
11
somePromise().then(function () {
throw new Error('oh noes');
}).catch(function (err) {
// I caught your error! :)
});

somePromise().then(function () {
throw new Error('oh noes');
}, function (err) {
// I didn't catch your error! :(
});

结果证明,如果你使用then(resolveHandler, rejectHandler)这种模式,如果是resolveHandler本身抛出异常,rejectHandler是捕获不到的。

鉴于这种原因,我个人建议不要使用then()方法的第二个参数,要始终使用catch()方法。例外情况是,当我编写异步 Mocha 测试时,我可能会编写一个测试来确保错误被抛出:

1
2
3
4
5
6
7
it('should throw an error', function () {
return doSomethingThatThrows().then(function () {
throw new Error('I expected an error!');
}, function (err) {
should.exist(err);
});
});

高级错误#3: promises vs promise factories

假设你想依次执行一系列的promise,就像Promise.all一样,但是并不是并行执行。

你也许会很天真的这样写:

1
2
3
4
5
6
7
function executeSequentially(promises) {
var result = Promise.resolve();
promises.forEach(function (promise) {
result = result.then(promise);
});
return result;
}

很不幸的是,并不是你预期的执行。你传递给executeSequentially的promise依旧会并行执行。每个promise只要被创建就开始执行,所以你正真需要的是一个promise 数组工厂

1
2
3
4
5
6
7
function executeSequentially(promiseFactories) {
var result = Promise.resolve();
promiseFactories.forEach(function (promiseFactory) {
result = result.then(promiseFactory);
});
return result;
}
1
2
3
function myPromiseFactory() {
return somethingThatCreatesAPromise();
}

…..未完,待续


文章作者: Callable
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Callable !
评论
  目录