Skip to content

用动图的方式了解 JavaScript 异步(二)

Published:

前言

回顾上一篇文章了解到,异步编程实际上是通过执行环境(例如浏览器、Node.js)提供的 API 来实现同时处理多个任务的。

简而言之,JavaScript 引擎一次只能执行一项任务,但执行环境提供了方法,使得 JavaScript 可以以异步方式执行。以下是一个简单的问题,来检查一下对上一篇文章的理解:

function cat() {
  console.log("🐱");
}

function dog() {
  setTimeout(() => {
    console.log("🐶");
  }, 0);
}

function human() {
  console.log("✋🏻");
}

cat();
dog();
human();

从字面上的顺序和意义来看,结果应该是:🐱 > 🐶 > ✋🏻,但实际上答案是:🐱 > ✋🏻 > 🐶。

这是因为 dog 函数中的 setTimeout 是异步的,会被推入 Callback Queue 中等待当前 Call Stack 上的任务都执行完之后才会被执行。

在此之前,会先继续完成当前主执行线程上的任务,所以先是 ✋🏻,然后才是 🐶。

exam.gif

实际的异步程序

前面的例子非常简单,大不了就是指定一段代码在特定时间后返回处理,但实际会遇到的情况往往会复杂得多,实际案例来说像是:

遇到这些情况就必须考虑到错误情境的处理、程序的执行顺序的问题,这时候就需要一个好的异步方式,让代码更容易阅读、维护,也能更有效率地处理异步的程序。

三种常见的异步处理方法,由浅入深:

实际编写并提出每种方法的特点以及要注意的地方。接着会用以下这个非常简单的例子作为示例,并尝试不同的写法。

// 说明:这是一个同步的函数,作用是计算正方形面积 (边长 x 边长)
// 后面都会以这个简单的例子作为代码示例,改写为非同步程序
// 需求:计算正方形面积,但是要等 1 秒后才能回传结果

function getSquareArea(side) {
  return side * side;
}

getSquareArea(2); // 4

回调函数 Callback

回调函数” 是一个函数,将其作为参数传入另外一个函数的时候就可以被称作是回调函数。

为什么要使用回调函数呢?

为什么学习异步和回调函数相关呢?

想一想我们经常使用的 addEventListener​ 或 setTimeout​ 方法,它们都是将异步事件的执行任务包装为一个函数,并作为参数传递进去的例子:

<button>点击我触发事件👆🏻</button>

<script>
  const button = document.querySelector("button");
  button.addEventListener("click", function (event) {
    console.log(event);
    alert(`你点击了按钮!请打开浏览器控制台查看结果。`);
  });
</script>

通过回调函数,可以将非同步的代码包装为函数,并在非同步事件发生后执行该函数。拿前面计算正方形面积的程序来改写,当成功或错误就会返回对应的结果到回调函数内:

function getRectangleArea(side, callback) {
  // 定义错误情境 - 非数字
  if (typeof side !== "number") {
    callback(new TypeError("请输入数字"));
    return;
  }
  // 定义错误情境 - 非正数
  if (side <= 0) {
    callback(new Error("请输入正数"));
    return;
  }
  // 执行非同步行为,并且在非同步事件发生后执行 callback
  setTimeout(() => callback(null, side ** 2), 1000);
}

getRectangleArea(2, (error, result) => {
  // 如果有错误,就会在这里被捕捉到
  if (error !== null) {
    console.error(error);
    return;
  }
  console.log(result);
});

这就是回调函数的基本概念,总结一下它的优缺点:

Promise

Promise🔗」 是一个代表未来可能完成或失败的操作的对象。

为了克服回调函数的缺点,ES6 引入了 Promise,它提供了一种标准化的方式来处理异步操作。Promise 有三种状态:

先写一个全新的 Promise 来了解看看:

// resolve 与 reject 可自由命名
const promise = new Promise((resolve, reject) => {
  // 一些非同步的代码
  if (/* 判断结果 */){
    resolve(value);
  } else {
    reject(error);
  }
});

可以看见传入 Promise 的回调函数需要两个参数:成功时与失败时该执行的函数名称。我们可以轻易的在这个 Promise 中定义成功与失败的条件,像以下代码中只需要在成功时返回 resolve,失败时返回 reject 就可以了:

// Promise 会在 1 秒后返回结果,如果出错就会返回失败,成功就返回结果
function getRectangleArea(side) {
  return new Promise((resolve, reject) => {
    // rej
    if (typeof side !== "number") {
      reject(new TypeError("请输入数字"));
      return;
    }
    // rej
    if (side <= 0) {
      reject(new Error("请输入正数"));
      return;
    }
    // res
    setTimeout(() => {
      resolve(side ** 2);
    }, 1000);
  });
}

现在成功的把回呼函数改为使用 Promise 了,但拿到了 Promise 之后该如何使用呢?直接同步的去使用 Promise 吗?

答案是不行的,因为当非同步行为执行时的当下 Promise 的状态会是 Pending,直接存取 Promise 是没办法将需要等待完成的值给取出来的。

// 无法在同步下直接调用 Promise
// 会输出 <Pending>
console.log(getRectangleArea(2));

如何取得 Promise 的返回值?

可以使用 Promise.then 方法去应对该 Promise 执行完后成功与失败的情境:

Promise.then(success, error);

更常见还是会使用 .catch 来捕捉错误的情境,它们之间细节上有一些不同,不过使用 .catch 的方法会比较全面且直观,建议绝大多时候这样写即可:

Promise.then(success).catch(error);

换上前面设定好的题目就可以用这样的方式处理 getRectangleArea 这个函式回传的 Promise 物件:

getRectangleArea(2)
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error(error);
  });

这就是 Promise 的基本概念,来总结一下 Promise 的特点:

Async / Await

Async / Await」是一个语法糖,能够写出同步风格的异步代码。

Async / Await 是在 Promise 基础上引入的语法糖,可以让异步代码看起来像同步代码。

Async

async 关键字可以让 JavaScript 引擎了解目前正在编写一个异步的函数,并且让整个函数回调一个 Promise。

Await

await 关键字仅能在 async 函数内部使用,将其放置在 Promise 之前,它可以帮助我们等待 Promise 的解决,并取得其值。

async function asyncFunction() {
  const value = await getRectangleArea(2);
}

还可以加上 try…catch 语法 去捕捉错误,写起來已經非常像同步代码了:

async function calcRectangleArea(side) {
  const rectangleArea = await new Promise((resolve, reject) => {
    if (typeof side !== "number") {
      reject(new TypeError("请输入数字"));
      return;
    }
    if (side <= 0) {
      reject(new Error("请输入正数"));
      return;
    }
    setTimeout(() => resolve(side ** 2), 5000);
  });
  console.log(rectangleArea);
}

calcRectangleArea(4);

该使用哪种方式处理非同步?

端看团队与个人偏好,并没有一定对错的答案。对我来说,如果没有包袱(版本问题、维护遗留代码)就用 Promise + Async / Await 即可,保持语法简洁且使用上也更为直观与一致,前提是最好理解了非同步的概念再使用会更好。

可以混用回调函数、Promise.then()、Async / Await 吗?

可以,但最好不要。应当统一方法避免造成不必要的混乱(圣战)。

参考资料