JavaScript|Promise

异步回调

基于回调的异步风格

一个简单的demo

1
2
3
4
5
6
7
8
9
function loadScript(src, callback) {
//创建脚本
let script = document.createElement('script');
//设置js标签的src属性
script.src = src;
//设置回调函数
script.onload = () => callback(script);
document.head.append(script)
}

之后函数调用方,将函数的回调方式作为参数传递

1
2
3
loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
alert(`脚本:${script.src}加载完成`)
});

上面的demo就是采用JS实现的基于回调的异步编程风格

异步执行某项功能的函数应该提供一个 callback 参数用于在相应事件完成时调用

Error优先回调风格

1
2
3
4
5
6
7
8
9
10
11
function loadScript(src, callback) {
//创建脚本
let script = document.createElement('script');
//设置js标签的src属性
script.src = src;
//设置回调函数
script.onload = () => callback(null, script);
//设置错误的回调
script.onError = () => callback(new Error(`Script load error for script ${src}`));
document.head.append(script)
}

之后在调用方

1
2
3
4
5
6
7
loadScript('./foo.js', function(err, script) {
if(err){
//Error对应处理逻辑
} else {
alert(`脚本:${script.src}加载完成`)
}
})

上面的方式则是Error优先的回调风格

Promise基础介绍

引入Promise

现在对于上述加载某个脚本的代码,我想要实现加载完某个脚本之后,再按顺序加载第二个脚本,那么在调用方,对应的代码就会变成如下的样子

1
2
3
4
5
6
7
8
9
loadScript('./foo.js', function(err, script) {
if (error) {
handleError(error);
} else {
loadScript('./bar.js', function(err, scirpt) {
//...
});
}
});

所以如果有这样的需求,原先的代码编写方式将会很臃肿而且可读性依托

JS 提供了 promise 的方式来进行优化

声明promise

1
2
3
let promise = new Promise((resolve, reject) => {
//executor
});

这里的 resolve 和 reject 是 JS 自带的函数,不需要关注,直接当接口使用

resolve/reject 只需要一个参数(或不包含任何参数),并且将忽略额外的参数。

promise对象属性

new Promise 构造器返回的 promise 对象具有以下内部属性:

  • state —— 最初是 "pending",然后在 resolve 被调用时变为 "fulfilled",或者在 reject 被调用时变为 "rejected"
  • result —— 最初是 undefined,然后在 resolve(value) 被调用时变为 value,或者在 reject(error) 被调用时变为 error

因此,executor 应该执行一项工作(通常是需要花费一些时间的事儿),然后调用 resolvereject 来改变对应的 promise 对象的状态。

Promise核心组成

executor生产者定义

1
2
3
4
5
6
7
let promise = new Promise((resolve, reject) => {
//do something
foo();
bar();
//End with resolve()/reject()
reject(new Error('Failed'));
});

如上代码,如果在 exector 中执行出了问题,executor 应该调用 reject。这可以使用任何类型的参数来完成(就像 resolve 一样)。但建议使用 Error 对象(或继承自 Error 的对象),因为可以给 catch 在下游进行捕获(下面会介绍)

并且从生产——消费的模型角度来看,我们定义的promise和executor其实是定义了一个生产者,promise作为”消息队列”进行连接

处理程序

then、catch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let promise = new Promise((resolve, reject) => {
//do something
foo();
bar();
//End with resolve()/reject()
reject(new Error('Failed'));
});

promise.then(
result => {
alert(result);
},
error => {
alert(error);
},
);

then中定义了当resolve和reject的时候分别需要做什么

例如如下的代码

1
2
3
4
5
6
7
8
let promise = new Promise((resolve, reject) => {
setTimeout(()=> resolve("done"), 1000);
})

promise.then(
result => alert(result), //显示done
error => altet(error) //不会运行
);

如果我们只对成功的情况感兴趣,可以只调用then()的时候只传入一个参数

1
2
3
4
5
let promise = new Promise((resolve, reject) => {
setTimeout(()=> resolve("done"), 1000);
})

promise.then(alert);

如果只对失败的情况感兴趣,可以直接调用catch

1
2
3
4
5
6
let promise = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error()), 1000);
});

//只关注失败的情况,等价于 promise.then(null, alert)
promise.catch(alert);

finally

finally(f) 类似于 then(f,f),表示同时将函数f运用到resolve和reject场景下

但是需要注意的是他的核心功能是设置一个处理程序在前面的操作完成之后,执行对应的不包含任何非异常返回值的操作

常见的场景有:

  • 停止加载指示器
  • 关闭不再需要的连接
1
2
3
4
5
6
new Promise((resolve, reject) => {
foo();
bar();
})
.finally(() => stop loading indicator)
.then(result => show result, error => show error);

可以看到,使用finally的时候:

  • 不接受参数,这就意味着finally不会得到前一个Promise处理程序的结果,反倒是会将结果或 error “传递” 给下一个合适的处理程序
  • 也不会返回任何内容,除了在处理这一步的的时候出现Error
1
2
3
4
5
6
7
8
new Promise((resolve, reject) => {
setTimeout(() => {
resolve('test');
}, 2000,
);
})
.finally(() => alert('Promise finally ready'))
.then(result => alert(result));

Promise&异步回调对比

回到之前加载脚本的例子

  • 使用朴素异步回调方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function loadScript(src, callback) {
//创建脚本
let script = document.createElement('script');
//设置js标签的src属性
script.src = src;
//设置回调函数
script.onload = () => callback(null, script);
//设置错误的回调
script.onError = () => callback(new Error(`Script load error for script ${src}`));
document.head.append(script)
}

loadScript('./foo.js', function(err, script) {
if(err){
//Error对应处理逻辑
} else {
alert(`脚本:${script.src}加载完成`)
}
});

我们发现,在调用loadscript的时候,需要指定callback,也就是在调用之前,我们必须知道应该如何处理结果,对应上面的匿名函数function(err, script)

  • 使用Promise方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function loadScript(src) {
return new Promise((resolve, reject) => {
let script = document.createElement('script');
script.src = src;

script.onload = () => resolve('Script loaded successfully');
script.onerror = () => reject(new Error(`Script ${src} loaded error`));

document.head.append(script)
});
}

let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");
promise.then(
result => alert(result),
error => alert(error)
);

我们可以按照自然方式进行编码,运行loadscript之后,通过then来进行处理,也就是将异步任务发布,和任务消费进行解耦

我们可以根据需要,在 promise 上多次调用 .then。每次调用,我们都会在“订阅列表”中添加一个新的“粉丝”,一个新的订阅函数。

Promise链

处理程序链式调用

通过上述的语法不难发现,catch finally then 都是支持链式调用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 1000);
})
.then(result => {
alert(result); //1
return result * 2;
})
.then(result => {
alert(result); //2
return result * 2;
})
.then(result => {
alert(result); //4
return result * 2;
});

这样之所以是可行的,是因为每个对 .then 的调用都会返回了一个新的 promise,因此我们可以在其之上调用下一个 .then

下面这种不是promise链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
});

promise.then(function(result) {
alert(result); // 1
return result * 2;
});

promise.then(function(result) {
alert(result); // 1
return result * 2;
});

promise.then(function(result) {
alert(result); // 1
return result * 2;
});

在处理程序中返回”Promise”

上面我们的处理程序只是简单计算返回结果

我们也可以在处理程序中再次返回promise,这种方式下,后续所有的其他处理程序都会阻塞等待,直到返回的promise状态为settled后再执行对应逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 1000);
})
.then(result => {
alert(result); //1
return new Promise(resolve => {
setTimeout(() => {
resolve(result * 2);
}, 1000);
});
})
.then(result => {
alert(result); //2
return new Promise(resolve => {
setTimeout(() => {
resolve(result * 2);
}, 1000);
});
})
.then(result => {
alert(result); //4
});

其实从严格角度来说,then中返回的不是Promise对象,而是thenable对象,这个对象也具有.then处理程序,它会被当做一个 promise 来对待

下面是一种高级用法,自定义Thenable对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Thenable {
constructor(num) {
this.num = num;
}

then(resolve, reject) {
alert(this.num); // 1, 构造器入参
alert(resolve); // function() {native code}
setTimeout(() => resolve(this.num * 2), 1000);
}
}

new Promise(resolve => resolve(1))
.then(result => {
return new Thenable(result); //自动调用对象的then方法
})
.then(alert);

当Promise的第一个then运行的时候,return new Thenable 会自动调用对象的 then 方法(类似Promise的executor)

实现按顺序加载脚本

所以回到我们最初的问题,我们想要实现按顺序来加载脚本,同时不希望出现error金字塔这种史山代码,就可以使用Promise链来进行简化

这里综合使用了Promise链以及处理程序中返回thenable的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function loadScript(src) {
return new Promise((resolve, reject) => {
let script = document.createElement('script');
script.src = src;

script.onload = () => resolve('Script loaded successfully');
script.onerror = () => reject(new Error(`Script ${src} loaded error`));

document.head.append(script)
});
}

loadScript('./script1')
.then(() => loadScript('./script2'))
.then(() => loadScript('./script3'))
.then(() => {
alert('All scripts are loaded!');
//可以使用三个脚本中定义的函数了,因为此时已经按顺序加载
//foo()
//bar()
});

最佳实践

在前端编程中,promise 通常被用于网络请求

1
let promise = fetch("http://bing.com");

fetch函数向指定 url 发出网络请求并返回一个 promise,当远程服务器返回 header(是在 全部响应加载完成前)时,该 promise 使用一个 response 对象来进行 resolve(也就是说fetch promise resolve的是一个response对象)

然后我们还可以调用response对象的text()来获取所有的响应内容,这个时候也会返回一个promise, 以刚刚下载完成的这个文本作为 result 进行 resolve;或者调用response对象的json()来将结果转为JSON对象作为promise resolve的结果

1
2
3
fetch('./foo.json') //{"name": "foo", "isAdmin": true}
.then(response => response.json())
.then(user => alert(user.name));

异步行为应当始终返回一个Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function showImage() {
fetch('./foo.json')
.then(response => response.json())
.then(user => fetch(`https://avatars.githubusercontent.com/${user.name}`))
.then(response => response.json())
.then(githubUser => {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = 'promise-avatar-example';
document.body.append(img);
//promise走到这里就结束了
setTimeout(() => img.remove(), 3000);
});
}

上述的实现确实是没什么大问题,但是如果我们还想再下游继续消费处理(例如显示一个用于编辑该用户或者其他内容的表单)则没有办法,如果需要实现,还需要调用then

所以一个合理的最佳实践是:为了保证Promise链的可扩展,异步行为应当始终返回一个Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function showImage() {
fetch('./foo.json')
.then((response) => response.json())
.then((user) => fetch(`https://avatars.githubusercontent.com/${user.name}`))
.then((response) => response.json())
.then(
(githubUser) =>
new Promise((resolve) => {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = 'promise-avatar-example';
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
}),
);
}

JavaScript|Promise
http://example.com/2025/02/21/JavaScript-Promise/
作者
Noctis64
发布于
2025年2月21日
许可协议