JavaScript 中的 Promises 一直牢牢掌握着自己的命运。一个 Promise 在何时解决(或者更通俗地说,“定居”)取决于构造时提供的执行器函数。一个简单的例子:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
if(Math.random() < 0.5) {
resolve("已解决!")
} else {
reject("已拒绝!");
}
}, 1000);
});
promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});
这个 API 的设计影响了我们如何组织异步代码。如果你使用了一个 promise,你需要接受它拥有该代码的执行权。
大多数情况下,这种模型是可行的。但偶尔,我们希望从构造函数外部控制一个 promise,解决或拒绝它。我本来想在这里使用“远程引爆”作为隐喻,但希望你的代码做的是一些不那么…具有破坏性的事情。所以让我们换个说法:你雇了一个会计师来做你的税务。他们可以跟着你到处跑,一边走一边计算数字,然后告诉你他们完成了。或者,他们可以在整个城市的办公室里完成所有工作,然后给你发送结果。我说的就是后者。
通常,这种事情是通过从外部作用域重新分配变量,然后在需要时使用它们来完成的。基于前面的例子,这是外部作用域方法的样子:
let outerResolve;
let outerReject;
const promise = new Promise((resolve, reject) => {
outerResolve = resolve;
outerReject = reject;
});
// 从 promise 外部解决!
setTimeout(() => {
if (Math.random() < 0.5) {
outerResolve("已解决!")
} else {
outerReject("已拒绝!");
}
}, 1000);
promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});
它完成了工作,但在人体工程学上感觉有点不对劲,特别是因为我们需要在更广的作用域中声明变量,只是为了稍后重新赋值。
一种更灵活的方式来解决 Promises
新的 Promise.withResolvers()
方法使得远程解决 promise 更加简洁。这个方法返回一个包含三个属性的对象:一个用于解决的函数,一个用于拒绝的函数,以及一个新的 promise。这些属性可以很容易地被解构并准备就绪:
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => {
if (Math.random() < 0.5) {
resolve('已解决!');
} else {
reject('已拒绝!');
}
}, 1000);
promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});
由于它们来自同一个对象,resolve()
和 reject()
函数绑定到那个特定的 promise,这意味着它们可以被随意调用。你不再受构造函数的束缚,也不需要从不同作用域重新分配变量。
探索一些例子
这是一个简单的特性,但它可以为你设计一些异步代码的方式带来新的活力。让我们看几个例子。
简化 Promise 构造
假设我们正在触发一个由 Web Worker 管理的作业来进行一些资源密集型的处理。当一个作业开始时,我们希望用一个 promise 来表示它,然后根据其成功来处理结果。为了确定这个结果,我们正在监听三个事件:message
、error
和 messageerror
。使用传统的 promise,这意味着要设置类似的东西:
const worker = new Worker("/path/to/worker.js");
function triggerJob() {
return new Promise((resolve, reject) => {
worker.postMessage("开始作业");
worker.addEventListener('message', function (e) {
resolve(e.data);
});
worker.addEventListener('error', function (e) {
reject(e.data);
});
worker.addEventListener('messageerror', function(e) {
reject(e.data);
});
});
}
triggerJob()
.then((result) => {
console.log("成功!");
})
.catch((reason) => {
console.error("失败!");
});
这会奏效,但我们把很多内容塞进了 promise 本身。代码变得更加难以阅读,而且你正在增加 triggerJob()
函数的责任(这里不仅仅是“触发”)。
但是有了 Promise.withResolvers()
我们有更多选项来整理这个:
const worker = new Worker("/path/to/worker.js");
function triggerJob() {
worker.postMessage("开始作业");
return Promise.withResolvers();
}
function listenForCompletion({ resolve, reject, promise }) {
worker.addEventListener('message', function (e) {
resolve(e.data);
});
worker.addEventListener('error', function (e) {
reject(e.data);
});
worker.addEventListener('messageerror', function(e) {
reject(e.data);
});
return promise;
}
const job = triggerJob();
listenForCompletion(job)
.then((result) => {
console.log("成功!");
})
.catch((reason) => {
console.error("失败!");
})
这次,triggerJob()
真的只是触发作业,没有构造函数塞东西。单元测试可能也更容易,因为函数的目的更狭窄,副作用更少。
等待用户操作
这个特性也可以使处理用户输入变得更有趣。假设我们有一个 <dialog>
提示用户审查一个新的博客评论。当用户打开对话框时,“批准”和“拒绝”按钮出现了。如果不使用任何 promises,处理这些按钮点击可能看起来像这样:
reviewButton.addEventListener('click', () => dialog.show());
rejectButton.addEventListener('click', () => {
// 处理拒绝
dialog.close();
});
approveButton.addEventListener('click', () => {
// 处理批准
dialog.close();
});
再次,它是有效的。但我们可以使用 promise 集中一些事件处理,同时保持我们的代码相对平坦:
const { promise, resolve, reject } = Promise.withResolvers();
reviewButton.addEventListener('click', () => dialog.show());
rejectButton.addEventListener('click', reject);
approveButton.addEventListener('click', resolve);
promise
.then(() => {
// 处理批准
})
.catch(() => {
// 处理拒绝
})
.finally(() => {
dialog.close();
});
通过这个改变,用户操作的处理程序不需要分散在多个事件监听器中。它们可以更容易地被集中在一起,并且节省一些重复的代码,因为我们可以在单个 .finally()
中放置需要对 每个 动作运行的任何内容。
减少函数嵌套
这里还有一个例子,突出了这种方法的微妙人体工程学好处。当防抖一个昂贵的函数时,通常会看到所有内容都包含在那个单一函数中。通常没有返回值。
想想一个实时搜索表单。请求和 UI 更新可能在同一次调用中处理。
function debounce(func) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, 1000);
};
}
const debouncedHandleSearch = debounce(async function (query) {
// 获取数据。
const results = await search(query);
// 更新 UI。
updateResultsList(results);
});
input.addEventListener('keyup', function (e) {
debouncedHandleSearch(e.target.value);
});
但你可能有充分的理由只防抖异步请求,而不是把 UI 更新和它混在一起。
这意味着增强 debounce()
返回一个 promise,有时解决结果(当请求被允许通过时)。它和更简单的基于超时的方法没有太大不同。我们只需要确保我们也适当地解决或拒绝一个 promise。
在 Promise.withResolvers()
可用之前,代码看起来会非常…层次分明:
function asyncDebounce(callback) {
let timeout = null;
let reject = null;
return function (...args) {
reject?.('rejected_pending');
clearTimeout(timeout);
return new Promise((res, rej) => {
reject = rej;
timeout = setTimeout(() => {
res(callback.apply(this, args));
}, 500);
});
};
}
这是一个令人眩晕的函数嵌套量。我们有一个返回一个函数的函数,它构建了一个接受一个包含一个定时器的函数的 promise,它又接受另一个函数。只有在 那个 函数中,我们才能调用解决器,最终调用提供的函数,就像47个函数之前一样。
但现在,我们可以至少稍微简化一下事情:
function asyncDebounce(callback) {
let timeout = null;
let resolve, reject, promise;
return function (...args) {
reject?.('rejected_pending');
clearTimeout(timeout);
({ promise, resolve, reject } = Promise.withResolvers());
timeout = setTimeout(() => {
resolve(callback.apply(this, args));
}, 500);
return promise;
};
}
这样,更新 UI 同时丢弃被有意拒绝的调用可能看起来像这样:
input.addEventListener('keyup', async function (e) {
try {
const results = await debouncedSearch(e.target.value);
appendResults(results);
} catch (e) {
// 丢弃有意拒绝的异常,
// 但其他异常则抛出。
if(e !== 'rejected_pending') {
throw e;
}
}
});
这不是一个戏剧性的变化,但它平滑了完成此类任务的一些粗糙边缘。
一个保持更多选项开放的工具
正如你看到的,这个特性并没有引入什么概念上的突破性创新。相反,它是那些“生活质量”的改进之一。一些偶尔在构建异步代码时缓解烦恼的东西。即便如此,我很惊讶地发现,我开始在日常工作中看到这个工具的使用案例越来越频繁,以及许多其他在过去几年中引入的 Promise 属性。
如果有什么的话,我认为这一切都证实了基于 Promise 的异步开发有多么基础和有价值,无论是在浏览器中运行还是在服务器上。我渴望看到我们如何在未来继续提升这个概念及其周围 API 的水平。