使用 Promise.withResolvers() 随意控制 JavaScript Promises

原文信息: 查看原文查看原文

Control JavaScript Promises from Anywhere Using Promise.withResolvers()

- Alex MacArthur

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 来表示它,然后根据其成功来处理结果。为了确定这个结果,我们正在监听三个事件:messageerrormessageerror。使用传统的 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 的水平。

分享于 2024-06-07

访问量 55

预览图片