专业的编程技术博客社区

网站首页 > 博客文章 正文

2023如何成为优秀的 Promise 避坑高手?

baijin 2024-11-09 11:03:40 博客文章 11 ℃ 0 评论

家好,很高兴又见面了,我是"高级前端?进阶?",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

浏览器如今已经原生支持 async/await 调用,本质上是对 Promise 的语法包装。不幸的是,async/await 的错误方式并不友好,下文将就这个主题进行深入探讨。

1.抛出错误

当异步函数中抛出错误时,可以使用 try {} catch {} 捕获。

async function fails() {
  throw Error();
}

async function myFunc() {
  try {
    await fails();
  } catch (e) {
    console.log('that failed', e);
  }
}

以上代码类似于以前对 Promise 错误处理的语法糖:

fails().catch((e) => {
  console.log('That also failed', e);
});

事实上,在任何使用关键字 await 的地方都可以删除 await 并执行传统的.then() 和.catch() 调用, 这是因为 async 关键字隐式地为其函数创建了一个 Promise。

2.reject Promise

使用 Promises,除了使用 throw 之外,还有另一种抛出错误的方法,即调用 reject():

function fails2() {
  return new Promise((resolve, reject) => {
    reject(new Error());
  });
}
async function myFunc2() {
  try {
    await fails2();
  } catch (e) {
    console.log('that failed', e);
  }
}

传递给 reject() 的错误可以通过 try {} catch {} 和.catch() 方法捕获。

至此,有两种抛出错误以及捕获错误的方法,但是每种捕获错误的方法都能捕获两种抛出错误的方法,因此复杂性没有想象的那么高。

3.在不同的调用堆栈中抛出错误

如果自己创建 Promise,则很可能会使用 setTimeout() 或 setInterval(),或者在完成某些操作时以某种方式调用回调函数。

这些回调将在不同的调用堆栈中调用,这意味着抛出的错误将传播到不是您的代码的其他地方。考虑这个例子:

function fails3() {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      throw new Error();
    }, 100);
  });
}
async function myFunc3() {
  try {
    await fails3();
  } catch (e) {
    console.log('that failed', e);
    //<-- never gets called
  }

产生的错误永远不会被 try {} catch {} 捕获,因为它被抛出到不同的调用堆栈上,而且使用 .catch(() => {}) 方法也会有同样的问题。

让错误在此类回调中传播的方法是使用 reject/resolve 函数,如下所示:

function fails4() {
  return new Promise((resolve, reject) => {
    setTimeout(function () {
      reject(new Error());
    }, 100);
  });
}

async function myFunc4() {
  try {
    await fails4();
  } catch (e) {
    console.log('that failed', e);
    //<-- this gets called
  }
}

4.为什么 reject/resolve 差强人意

在 Promise 中调用 reject(new Error()) 与 throw Error() 非常相似,但有一个主要区别,即只是一个函数调用,因此它不会像 throw 那样破坏执行流程

这意味着开发者可以编写既 reject 又 resolve 的矛盾代码,如下所示:

function schrodinger() {
  return new Promise((resolve, reject) => {
    reject(new Error());
    // 不会破坏Promise流程
    resolve('great success');
  });
}

这里 reject() 和 resolve() 都会被调用。那么哪一个会赢呢?答案是先调用的哪个函数。比如下面的怪异示例:

function schr?dinger2() {
  return new Promise((resolve, reject) => {
    throw resolve('huh');
    //<-- this throw is executed
  });
}
async function callAsync() {
  try {
    await schr?dinger2();
  } catch (e) {
    console.log('caught error', e);
    //<-- yet, this is never reached
  }
}

try {} catch {} 永远不会被触发,这是因为调用了 resolve,而规则仍然是先调用的即获胜。 所以 throw 被执行了,但是它被运行时默默地吞掉了, 这势必会造成无尽的混乱

发生该问题的原因是 resolve() 和 reject() 与 return 和 throw 几乎重复。 而 resolve/reject 存在的唯一原因是能够跨调用堆栈边界移动错误。

由于多种原因,这也只是一个折中的方案,因为只会移动期望的错误,因此:例如意外的 NullReferenceException 不会跨边界移动,除非自己明确地调用 reject。

有一个更简洁的设计。 在人们开始在 JavaScript 中讨论 async/await 之前,C# 就已经有了 async/await。 在该语言中,异步回调中引发的异常被捕获,然后重新引发,以便传播到正在等待异步操作的地方。

JavaScript 可以通过提供 setTimeout 和 setInterval 的替代品以及新的错误语义来实现这一点,并且可以放弃这种 resolve/reject 的东西,转而使用 return /throw, 这也会将 Promises 规范减少 90%。

5.全局处理 Promise 的错误

当开发者想要为所有未处理的错误设置全局捕获错误处理程序(例如将这些错误传递到服务器)时该怎么办?

那么,如何判断 Promise 中的错误未得到处理呢? 在处理 Promise 时,开发者无法知道将来是否会抛出错误。 Promise 可能会调用 reject(),并且一些代码可能会在 10 分钟后出现并对该 Promise 调用 .catch(() => {}),在这种情况下,错误将被处理。

因此,Q 和 Bluebird 等 Promise 库中的全局错误处理程序被命名为 onPossibleUnhandledRejection,这是一个合适的名称。

在原生 Promises 中,此函数称为 onunhandledrejection,但仍然只能判断到目前为止是否尚未处理 reject。 因此,仅当当前运行的脚本完成并且控制权已交回运行时时才会触发 onunhandledrejection(如果在此期间没有其他捕获逻辑)。

下面为 async exceptions 和 reject 设置全局处理程序,如下所示:

window.onunhandledrejection = function (evt) {
  /*Your code*/
};
// 或者
window.addEventListener('unhandledrejection', function (evt) {
  /*Your code*/
});

evt 是 PromiseRejectionEvent 类型的对象。 evt.promise 是被拒绝的 promise,evt.reason 保存传递给 reject() 函数的任何对象。

6.常见疑难输出

6.1 Promise + setTimeout 类型

new Promise(function (resolve, reject) {
  setTimeout(() => {
    throw new Error('Whoops!');
  }, 1000);
}).catch(alert);

此时直接抛出错误,因为 setTimeout 内部没有通过闭包保持外部 Promise 的 resolve 和 reject 的引用,因此 Promise 的微任务执行后创建了新的宏任务,而宏任务直接在调用堆栈 Callback Stack 中抛出了错误,同时又没有全局 unhandledrejection 句柄。但是,可以通过下面方式保持对外部函数作用域的引用:

new Promise(function (resolve, reject) {
  setTimeout(() => {
    // 保持引用,导致外部作用域没有被回收
    reject(new Error('Whoops!'));
  }, 1000);
}).catch(alert);

可能会有人使用下面 try...catch 代码包裹相应逻辑,但是并不建议这么做。

new Promise(function (resolve, reject) {
  setTimeout(() => {
    try {
      throw new Error('Whoops!');
      // 注意:这里一定要有 throw,否则就变成了 new 一个Error对象
    } catch (e) {
      reject(e);
    }
  }, 1000);
}).catch(alert);

这是因为 Promise 本身会在其作用域内捕捉所有的错误,而不需要 try...catch 块。它确保在执行过程中抛出的所有异常都被获取并转换为 reject 的 Promise。

6.2 在 Promise 块内使用async/await 函数

async/await 是一种更高级的 ES6 语法,用于处理同步代码中的多个 Promise。当在一个函数声明前使用 async 关键字时,它会返回一个 Promise,此时可以使用 await 关键字来停止代码,直到正在等待的 Promise resolve 或 reject

但是,当把一个 async 函数和 Promise 放在一起 ,可能会有一些副作用。

假设想在 Promise 块中做一个异步操作,所以使用了 async 关键字,但,不巧的是代码抛出了一个错误。这样,即使使用 catch() 块或在 try...catch 块内 await Promise,也不能立即处理这个错误。

// 此代码无法处理错误
(async () => {
  try {
    await new Promise(async () => {
      // async函数放在Promise里面
      throw new Error('message');
    });
  } catch (e) {
    console.log(e.message);
  }
})();

只能按照如下方式自行捕获错误:

new Promise(async (resolve, reject) => {
  try {
    throw new Error('message');
  } catch (error) {
    reject(error);
  }
}).catch((e) => console.log(e.message));

//using async/await
(async () => {
  try {
    await new Promise(async (resolve, reject) => {
      try {
        throw new Error('message');
      } catch (error) {
        reject(error);
      }
    });
  } catch (e) {
    console.log(e.message);
  }
})();

6.3 then 方法继续返回一个 Promise

Promise 的 then 方法里面可以返回以下三种类型。

  • return 一个 promise 对象
  • return 一个同步的值或者是 undefined

返回 undefined 通常是一个错误,但返回同步值实际上是将同步代码转换为 Promise 代码的绝佳方法。例如,假设有一个用户的内存缓存,可以这么做:

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 中的非返回函数从技术上讲返回 undefined,这意味着当打算返回某些内容时很容易意外引入副作用。

建议大家养成总是从 then() 函数内部返回或抛出 Error 个人习惯

  • 同步的 throw 一个错误

假设想在用户注销时抛出同步错误,可以如下设置:

getUserByName('nolan')
  .then(function (user) {
    if (user.isLoggedOut()) {
      throw new Error('user logged out!');
      // 抛出同步错误
    }
    if (inMemoryCache[user.id]) {
      return inMemoryCache[user.id];
      // 返回同步值
    }
    return getUserAccountById(user.id);
    // 返回一个Promise
  })
  .then(function (userAccount) {
    // I got a user account!
  })
  .catch(function (err) {
    // Boo, I got an error!
  });

如果用户注销, catch() 将收到同步错误,如果任何 Promise 被 Reject,将收到异步错误。 同样,该函数并不关心收到的错误是同步还是异步。

这特别有用,因为可以帮助识别开发过程中的编码错误。 例如:如果在 then() 函数内部的任何点执行 JSON.parse(),如果 JSON 无效,它可能会抛出同步错误。 使用回调,该错误将被吞噬,但使用 Promise,可以简单地在 catch() 函数中处理。

6.4 then 方法传入 Promise 对象而非函数

比如下面的示例代码:

Promise.resolve('foo')
  .then(Promise.resolve('bar'))
  .then(function (result) {
    console.log(result);
  });

如果认为会打印出 bar,那就错了,实际上会打印 foo!

发生这种情况的原因是,当向 then() 传递一个非函数(例如:Promise)时,实际上将其解释为 then(null),这会导致前一个 Promise 的结果落空,比如下面的代码:

Promise.resolve('foo')
  .then(null)
  // 注意:这里的null可以换成任何内容,只要不是函数
  .then(function (result) {
    console.log(result);
  });

添加任意数量的 then(null) 仍然会打印 foo。简而言之,可以将 Promise 直接传递到 then() 方法中,但它不会执行认为它正在执行的操作。 then() 应该接受一个函数,所以可以如下处理:

Promise.resolve('foo')
  .then(function () {
    return Promise.resolve('bar');
  })
  .then(function (result) {
    console.log(result);
  });

正如期望的,程序输出'bar'。 所以提醒一下,始终将函数传递给 then()

6.5 then 支持两个参数

catch()只是糖,所以这两个片段是等价的:

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

但是,这并不意味以下两个片段是等效的:

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) 格式时,rejectHandler 实际上不会捕获由 resolveHandler 本身抛出的错误。因此,非常建议不使用 then() 的第二个参数,而总是使用 catch()。

6.6 Promise.resolve() 防止错误被吞噬

Promise 对于将同步代码包装为异步代码非常有用。但是,如果发现自己经常输入以下内容:

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

则可以使用 Promise.resolve() 更简洁地方式:

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

这种方法对于捕获任何同步错误也非常有用

function doSomethingThatMayThrow() {
  //throw new Error('同步错误抛出了');
}
function somePromiseAPI() {
  return Promise.resolve()
    .then(function () {
      doSomethingThatMayThrow();
      return 'foo';
    })
    .then((res) => alert(res))
    .catch((e) => alert('catch:' + e));
    // 能catch住
}

somePromiseAPI().then(res=>alert('res了'));

请记住:任何可能同步抛出错误的代码都会增加错误被吞噬的可能性,但如果将所有内容都包装在 Promise.resolve() 中,那么错误总是可以确保被 catch 住。

类似地,可以使用 Promise.reject() 返回立即拒绝的 Promise:

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

参考资料

https://catchjs.com/Docs/AsyncAwait

https://www.studocu.com/row/document/universite-sidi-mohamed-ben-abdellah-de-fes/informatique/stop-using-try-catch-to-catch-async-await-exceptions-by-omgzui-may-2023-java-script-in-plain-english/64051536

https://github.com/liangklfangl/react-article-bucket/blob/master/async-programing/readme.md

https://blog.csdn.net/iteye_3854/article/details/82677078

https://javascript.info/promise-error-handling

https://github.com/liangklfangl/react-article-bucket/blob/master/others/nodejs-QA/browser-QA.md

https://segmentfault.com/a/1190000042773867

https://blog.csdn.net/iteye_3854/article/details/82677078

https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.html

https://dmitripavlutin.com/javascript-promises-then-vs-then-catch/

https://tomduffytech.com/unhandled-promise-rejection/

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表