Shane Jix

Promise 必知必会

create:January 17, 2022  update:April 12, 2022  ☕️☕️☕️ 14 min read

同步链接: https://www.shanejix.com/posts/Promise 必知必会/

回调

🚩 异步 行为(action):现在开始执行的行为,但它们会在稍后完成(例如,setTimeout 函数就是一个这样的函数;例如加载脚本和模块)

实际中的异步行为的示例:

/**
 * 使用给定的 src 加载脚本
 * @param src
 **/
function loadScript(src) {
  // 创建一个 <script> 标签,并将其附加到页面
  // 这将使得具有给定 src 的脚本开始加载,并在加载完成后运行
  let script = document.createElement("script");
  script.src = src;
  document.head.append(script);
}

可以像这样使用这个函数:

// 在给定路径下加载并执行脚本
loadScript("/my/script.js");

// loadScript 下面的代码
// 不会等到脚本加载完成才执行
// ...

// 💡脚本是“异步”调用的,因为它从现在开始加载,但是在这个加载函数执行完成后才运行。如果在 loadScript(…) 下面有任何其他代码,它们不会等到脚本加载完成才执行

假设需要在新脚本加载后立即使用它,这将不会有效:

loadScript("/my/script.js"); // 这个脚本有 "function newFunction() {…}"

newFunction(); // 没有这个函数!

😭 到目前为止,loadScript 函数并没有提供跟踪加载完成的方法。脚本加载并最终运行,仅此而已。但是希望了解脚本何时加载完成,以使用其中的新函数和变量

💡 添加一个 callback 函数作为 loadScript 的第二个参数,该函数应在脚本加载完成时执行:

function loadScript(src, callback) {
  let script = document.createElement("script");
  script.src = src;
  script.onload = () => callback(script);
  document.head.append(script);
}

loadScript(
  "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js",
  (script) => {
    // 在脚本加载完成后,回调函数才会执行
    alert(`Cool, the script ${script.src} is loaded`);
    alert(_); // 所加载的脚本中声明的函数
  }
);

''这就是被称为“基于回调”的异步编程风格''”:异步执行某项功能的函数应该提供一个 callback 参数用于在相应事件完成时调用

🚩 回调地狱

如何依次加载两个脚本:第一个,然后是第二个?第三个?

loadScript("/my/script.js", function (script) {
  loadScript("/my/script2.js", function (script) {
    loadScript("/my/script3.js", function (script) {
      // ...加载完所有脚本后继续
    });
  });
});

加入处理 Error:

loadScript("1.js", function (error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript("2.js", function (error, script) {
      if (error) {
        handleError(error);
      } else {
        // ...
        loadScript("3.js", function (error, script) {
          if (error) {
            handleError(error);
          } else {
            // ...加载完所有脚本后继续 (*)
          }
        });
      }
    });
  }
});

这就是著名的“''回调地狱''”或“厄运金字塔”

💡 可以通过使每个行为都成为一个独立的函数来尝试减轻这种问题

loadScript("1.js", step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript("2.js", step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript("3.js", step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...加载完所有脚本后继续 (*)
  }
}

优缺点

- 没有深层的嵌套,独立为顶层函数

- 可读性差

- 没有重用

最好的方法之一就是 “''promise''

Promise

🚩 语法

let promise = new Promise(function (resolve, reject) {
  // executor
  // 当 promise 被构造完成时,executor自动执行此函数
  // executor 通常是异步任务
  // ...
})
  // handler
  .then(
    (result) => {
      // ...
    },
    (error) => {
      // ...
    }
  );
1.new Promise 被创建,executor 被自动且立即调用

2.new Promise 构造器返回的 promise 对象具有以下【内部属性】

    - state — 最初是 "pending",然后在 resolve 被调用时变为 "fulfilled",或者在 reject 被调用时变为 "rejected"

    - result — 最初是 undefined,然后在 resolve(value) 被调用时变为 value,或者在 reject(error) 被调用时变为 error

3.与最初的 “pending” promise 相反,一个 resolved 或 rejected 的 promise 都会被称为 “settled”

4.executor 只能调用一个 resolve 或一个 reject;任何状态的更改都是最终的(不可逆)

🚩 立即 resolve/reject 的 Promise

// executor 通常是异步执行某些操作,并在一段时间后调用 resolve/reject,但这不是必须的;还可以立即调用 resolve 或 reject

// 💡当开始做一个任务时,但随后看到一切都已经完成并已被缓存时,可能就会发生这种情况。这挺好😀

let promise = new Promise(function (resolve, reject) {
  // 不花时间去做这项工作
  resolve(123); // 立即给出结果:123
});

🚩 示例:加载脚本的 loadScript 函数

基于回调函数的变体版本:

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`Script load error for ${src}`));

  document.head.append(script);
}

// 用法:

loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
  // 在脚本加载完成后,回调函数才会执行
  alert(`${script.src} is loaded!`)alert( _ ); // 所加载的脚本中声明的函数
});

基于 Promise 重写的版本:

function loadScript(src) {
  return new Promise(function (resolve, reject) {
    let script = document.createElement("script");
    script.src = src;

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

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

// 用法:

let promise = loadScript(
  "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js"
);

promise.then(
  (script) => alert(`${script.src} is loaded!`),
  (error) => alert(`Error: ${error.message}`)
);

promise.then((script) => alert("Another handler..."));

Promise 链

🚩Promise 链:回忆回调中,何依次加载两个脚本:第一个,然后是第二个?第三个?

// 💡Promise 提供了一些方案来做到这一点:Promise 链

// like this

new Promise(function (resolve, reject) {
  setTimeout(() => resolve(1), 1000); // (*)
})
  .then(function (result) {
    // (**)

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

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

// 📌为什么可以?因为对 promise.then 的调用会返回了一个 promise,所以我们可以在其之上调用下一个 .then

// 当处理程序(handler)返回一个值时,它将成为该 promise 的 result,所以将使用它调用下一个 .then

// 💣''新手常犯的一个经典错误:从技术上讲,我们也可以将多个 .then 添加到一个 promise 上。但这并不是 promise 链(chaining)''

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 的几个处理程序(handler)。它们不会相互传递 result;相反,它们之间彼此独立运行处理任务

🚩 返回 promise

- .then(handler) 中所使用的处理程序(handler)可以创建并返回一个 promise

- 在这种情况下,其他的处理程序(handler)将【等待它 settled 后再获得其结果(result)】

示例:promise 化的 loadScript

loadScript("/article/promise-chaining/one.js")
  .then((script) => loadScript("/article/promise-chaining/two.js"))
  .then((script) => loadScript("/article/promise-chaining/three.js"))
  .then((script) => {
    // 脚本加载完成,我们可以在这儿使用脚本中声明的函数
    one();
    two();
    three();
  });

// 💡注意:这儿每个 loadScript 调用都返回一个 promise,并且在它 resolve 时下一个 .then 开始运行。然后,它启动下一个脚本的加载。所以,脚本是一个接一个地加载的

// 💡并且代码仍然是“扁平”的 — 它向下增长,而不是向右

// ...

// 从技术上讲,可以向每个 loadScript 直接添加 .then,就像这样:

loadScript("/article/promise-chaining/one.js").then((script1) => {
  loadScript("/article/promise-chaining/two.js").then((script2) => {
    loadScript("/article/promise-chaining/three.js").then((script3) => {
      // 此函数可以访问变量 script1,script2 和 script3
      one();
      two();
      three();
    });
  });
});

// 💡这段代码做了相同的事儿:按顺序加载 3 个脚本。但它是“向右增长”的。所以会有和使用回调函数一样的问题

// 👍刚开始使用 promise 的人可能不知道 promise 链,所以他们就这样写了。通常,链式是首选

Thenables

- 确切地说,处理程序(handler)返回的不完全是一个 promise,而是返回的被称为 “thenable” 对象 — 一个具有方法 .then 的任意对象

- thenable对象会被当做一个 promise 来对待

- 这个想法是,第三方库可以实现自己的“promise 兼容(promise-compatible)”对象;它们可以具有扩展的方法集,但也与原生的 promise 兼容,因为它们实现了 .then 方法


- 这个特性允许将自定义的对象与 promise 链集成在一起,而不必继承自 Promise

示例:

class Thenable {
  constructor(num) {
    this.num = num;
  }
  then(resolve, reject) {
    alert(resolve); // function() { native code }
    // 1 秒后使用 this.num*2 进行 resolve
    setTimeout(() => resolve(this.num * 2), 1000); // (**)
  }
}

new Promise((resolve) => resolve(1))
  .then((result) => {
    return new Thenable(result); // (*)
  })
  .then(alert); // 1000ms 后显示 2

🚩 作为一个好的做法:异步行为应该始终返回一个 promise

- 这样就可以使得之后计划后续的行为成为可能

- 即使现在不打算对链进行扩展,但之后可能会需要

示例:

function loadJson(url) {
  return fetch(url).then((response) => response.json());
}

function loadGithubUser(name) {
  return fetch(`https://api.github.com/users/${name}`).then((response) =>
    response.json()
  );
}

function showAvatar(githubUser) {
  return new Promise(function (resolve, reject) {
    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);
  });
}

// 使用它们:
loadJson("/article/promise-chaining/user.json")
  .then((user) => loadGithubUser(user.name))
  .then(showAvatar)
  .then((githubUser) => alert(`Finished showing ${githubUser.name}`));
// ...

错误处理

🚩Promise 链在错误(error)处理

- 当一个 promise 被 reject 时,控制权将移交至最近的 rejection 处理程序(handler);这在实际开发中非常方便

- .catch 不必是立即的;它可能在一个或多个 .then 之后出现

示例:

fetch("/article/promise-chaining/user.json")
  .then((response) => response.json())
  .then((user) => fetch(`https://api.github.com/users/${user.name}`))
  .then((response) => response.json())
  .then(
    (githubUser) =>
      new Promise((resolve, reject) => {
        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);
      })
  )
  .catch((error) => alert(error.message));

🚩 隐式 try…catch

- Promise 的执行者(executor)和 promise 的处理程序(handler)周围有一个“隐式的 try..catch”

- 如果发生异常,它(译注:指异常)就会被捕获,并被视为 rejection 进行处理

示例:

// excutor 中

new Promise((resolve, reject) => {
  throw new Error("Whoops!");
}).catch(alert); // Error: Whoops!

// 等同于

new Promise((resolve, reject) => {
  reject(new Error("Whoops!"));
}).catch(alert); // Error: Whoops!

// ...

// handler 中

new Promise((resolve, reject) => {
  resolve("ok");
})
  .then((result) => {
    throw new Error("Whoops!"); // reject 这个 promise
  })
  .catch(alert); // Error: Whoops!

🚩 再次抛出(Rethrowing)

- 如果在 .catch 中 throw,那么控制权就会被移交到下一个最近的 error 处理程序(handler)。如果处理该 error 并正常完成,那么它将继续到最近的成功的 .then 处理程序(handler)
// 执行流:catch -> then
new Promise((resolve, reject) => {
  throw new Error("Whoops!");
})
  .catch(function (error) {
    alert("The error is handled, continue normally");
  })
  .then(() => alert("Next successful handler runs"));
// 执行流:catch -> catch
new Promise((resolve, reject) => {
  throw new Error("Whoops!");
})
  .catch(function (error) {
    // (*)

    if (error instanceof URIError) {
      // 处理它
    } else {
      alert("Can't handle such error");

      throw error; // 再次抛出此 error 或另外一个 error,执行将跳转至下一个 catch
    }
  })
  .then(function () {
    /* 不在这里运行 */
  })
  .catch((error) => {
    // (**)

    alert(`The unknown error has occurred: ${error}`);
    // 不会返回任何内容 => 执行正常进行
  });

🚩 未处理的 rejection

new Promise(function () {
  noSuchFunction(); // 这里出现 error(没有这个函数)
}).then(() => {
  // 一个或多个成功的 promise 处理程序(handler)
}); // 尾端没有 .catch!

// ...

// 当一个 error 没有被处理会发生什么?

// 💡如果出现 error,promise 的状态将变为 “rejected”,然后执行应该跳转至最近的 rejection 处理程序(handler)。但是上面这个例子中并没有这样的处理程序(handler)。因此 error 会“卡住(stuck)”。没有代码来处理它

// 在实际开发中,就像代码中常规的未处理的 error 一样,这意味着某些东西出了问题

// 当发生一个常规的错误(error)并且未被 try..catch 捕获时会发生什么?脚本死了,并在控制台(console)中留下了一个信息。对于在 promise 中未被处理的 rejection,也会发生类似的事儿

JavaScript 引擎会跟踪此类 rejection,在这种情况下会生成一个全局的 error

- 在浏览器中,可以使用 unhandledrejection 事件来捕获这类 error
window.addEventListener("unhandledrejection", function (event) {
  // 这个事件对象有两个特殊的属性:
  alert(event.promise); // [object Promise] - 生成该全局 error 的 promise
  alert(event.reason); // Error: Whoops! - 未处理的 error 对象
});

new Promise(function () {
  throw new Error("Whoops!");
}); // 没有用来处理 error 的 catch

Promise API

在 Promise 类中,有 5 种静态方法

- Promise.all([iterable])

- Promise.allSettled([iterable])

- Promise.race([iterable])

- Promise.resolve()

- Promise.reject()

🚩Promise.all

语法

// 接受一个 promise 数组(可以是任何可迭代的)作为参数并返回一个新的 promise

let promise = Promise.all([iterable]);

注意

- 并行执行多个 promise,当所有给定的 promise 都被 成功 时,新的 promise 才会 resolve,并且其结果数组将成为新的 promise 的结果

- 结果数组中元素的顺序与其在源 promise 中的顺序相同(即使第一个 promise 花费了最长的时间)

- 如果任意一个 promise 被 reject,由 Promise.all 返回的 promise 就会立即 reject,并且带有的就是这个 error

🚩 如果出现 error,其他 promise 将被忽略

- 如果其中一个 promise 被 reject,Promise.all 就会立即被 reject,完全忽略列表中其他的 promise。它们的结果也被忽略

- 例如,如果有多个同时进行的 fetch 调用,其中一个失败,其他的 fetch 操作仍然会继续执行,但是 Promise.all 将不会再关心(watch)它们。它们可能会 settle,但是它们的结果将被忽略

- Promise.all 没有采取任何措施来取消它们,因为 promise 中没有“取消”的概念

🚩Promise.all(iterable) 允许在 iterable 中使用 non-promise 的“常规”值

// romise.all(...) 接受含有 promise 项的可迭代对象(大多数情况下是数组)作为参数。但是,如果这些对象中的任何一个不是 promise,那么它将被“按原样”传递给结果数组

Promise.all([
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(1), 1000);
  }),
  2,
  3,
]).then(alert); // 1, 2, 3

🚩Promise.allSettled

Promise.allSettled 等待所有的 promise 都被 settle,无论结果如何,结果数组具有:

- {status:"fulfilled", value:result} 对于成功的响应

- {status:"rejected", reason:error} 对于 error

Polyfill

if (!Promise.allSettled) {
  const rejectHandler = (reason) => ({ status: "rejected", reason });

  const resolveHandler = (value) => ({ status: "fulfilled", value });

  Promise.allSettled = function (promises) {
    const convertedPromises = promises.map((p) =>
      Promise.resolve(p).then(resolveHandler, rejectHandler)
    );
    return Promise.all(convertedPromises);
  };
}

🚩Promise.race

- 只等待第一个 settled 的 promise 并获取其结果(或 error)

示例

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) =>
    setTimeout(() => reject(new Error("Whoops!")), 2000)
  ),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)),
]).then(alert); // 1

🚩Promise.resolve/reject

语法

// 结果 value 创建一个 resolved 的 promise
Promise.resolve(value);

// 等同于

let promise = new Promise((resolve) => resolve(value));

//...

// Promise.reject() 类似
- 当一个函数被期望返回一个 promise 时,这个方法用于兼容性

- 💡这里的兼容性是指,直接从缓存中获取了当前操作的结果 value,但是期望返回的是一个 promise,所以可以使用 Promise.resolve(value) 将 value “封装”进 promise,以满足期望返回一个 promise 的这个需求

示例:

let cache = new Map();

function loadCached(url) {
  if (cache.has(url)) {
    return Promise.resolve(cache.get(url)); // (*)
  }

  return fetch(url)
    .then((response) => response.text())
    .then((text) => {
      cache.set(url, text);
      return text;
    });
}

// 💡可以使用 loadCached(url).then(…),因为该函数保证了会返回一个 promise。可以放心地在 loadCached 后面使用 .then。这就是 (*) 行中 Promise.resolve 的目的

Promisification

- “Promisification” 指将一个接受回调的函数转换为一个返回 promise 的函数

- 由于许多函数和库都是基于回调的,所以将基于回调的函数和库 promisify 是有意义的

示例:

function loadScript(src, callback) {
  let script = document.createElement("script");
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`Script load error for ${src}`));

  document.head.append(script);
}

// 用法:
// loadScript('path/script.js', (err, script) => {...})

// ...

// promisify

let loadScriptPromise = function (src) {
  return new Promise((resolve, reject) => {
    loadScript(src, (err, script) => {
      if (err) reject(err);
      else resolve(script);
    });
  });
};

// 用法:
// loadScriptPromise('path/script.js').then(...)

新的函数是对原始的 loadScript 函数的包装,在实际开发中,可能需要 promisify 很多函数

🚩promisify

function promisify(f) {
  return function (...args) { // 返回一个包装函数(wrapper-function) (*)
    return new Promise((resolve, reject) => {
      function callback(err, result) { // 对 f 的自定义的回调 (**)
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      }

      args.push(callback); // 将自定义的回调附加到 f 参数(arguments)的末尾

      f.call(this, ...args); // 调用原始的函数
    });
  };
}

// 用法:
let loadScriptPromise = promisify(loadScript);
loadScriptPromise(...).then(...);

🚩promisification 函数的模块(module)

- https://github.com/digitaldesignlabs/es6-promisify

- 在 Node.js 中,有一个内建的 promisify 函数 util.promisify

🚩Promisification 场景

- Promisification 不是回调的完全替代

- 请记住,一个 promise 可能只有一个结果,但从技术上讲,一个回调可能被调用很多次

- 因此,promisification 仅适用于调用一次回调的函数。进一步的调用将被忽略

references

作者:shanejix 出处:https://www.shanejix.com/posts/Promise 必知必会/ 版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。 声明:转载请注明出处!

Edit on GitHubDiscuss on GitHub


Shane Jix

Personal blog by Shane Jix. I explain with words and code.

LinksTools
© 2019 - 2022, Built withGatsby