🤔

「JavaScript Promise」を学ぶ

2022/02/24に公開

「JavaScript Promise」を学んだので、大まかに学んだ内容と感想を以下に書きたいと思います。

Promiseとは?

非同期化処理を抽象化したオブジェクトとそれを操作する仕組みのこと。

ECMAScript2015(ES6)で導入された。

(一般的な)同期処理とは?

複数のタスクを実行する際に1つずつ順番にタスクが実行される方式のこと。

Googleで「プログラミング 同期処理とは」で検索 ⇒

https://www.rworks.jp/system/system-column/sys-entry/21730/#:~:text=同期処理とは、複数,しやすいメリットがあります。

(一般的な)非同期処理とは

1つのタスクを実行中であっても他のタスクを実行できる実行方式のこと。

Googleで「プログラミング 非同期処理とは」で検索 ⇒

https://www.rworks.jp/system/system-column/sys-entry/21730/#:~:text=非同期処理とは、一,な要素になります。

JavaScriptはシングルスレッド

シングルスレッドとは、文字通り単一のスレッドだけでプログラムを実行するということ。
スレッドというのはCPUの利用単位のことで、最近だとCPUには複数コアを持つものが多いですが、JavaScriptなその中の1つのコアだけを使って実行している。

そのため、JavaScriptの非同期処理は、スレッドを複数用意した並行 / 並列処理(マルチスレッドとも言う)を行っていない。

シングルスレッドで「①優先順位をつけて②処理内容をキューに格納して③順番に実行する」ことで非同期処理を実現している。

console.log("Start!") // 優先順位:高
setTimeout(() => {
   console.log("Timeout!") // 優先順位:低
}, 0)
Promise.resolve("Promise!").then(value => {
   console.log(value) // 優先順位:中
})
console.log("End!") // 優先順位:高
/* 結果
Start!
End!
Promise!
Timeout!
*/

Promiseが生まれた背景

JavaScriptで非同期処理といえば、コールバックを使うことが多い。(コールバック = 非同期ではない)

コールバックを用いる際、第一引数に Errorオブジェクトを渡すというルールがあるが、あくまでコーディングルールであって、強制されているわけではない。

であれば、「非同期処理のちゃんと決められたルールがあったら良いよね」という経緯から産まれたのが Promise になる。

// 非同期処理をコールバックで書いた時のサンプル
getAsync("fileA.txt", (error, result) => { 
    if (error) { // 取得失敗時の処理
        throw error;
    }
    // 取得成功の処理
});
// 非同期処理をPromiseで書いた時のサンプル
const promise = getAsyncPromise("fileA.txt"); 
promise.then((result) => {
    // 取得成功の処理
}).catch((error) => {
    // 取得失敗時の処理
});

Promiseの使い方

Promiseを使うには、まずnewをして、promiseオブジェクトを生成する必要がある。

第一引数にresolve(成功)、第二引数にreject(失敗)のオプショナルな関数を持つ。これらの関数は非同期処理の最後に呼び出すコールバック関数で使用する。

const promise = new Promise((resolve, reject) => {
   // 非同期の処理
   // 処理が終わったら、resolve または rejectを呼ぶ
});

さきほど生成したpromiseオブジェクトに対して、thenを使って値が返ってきたときのコールバック、catchを使ってエラーとなった場合のコールバックを設定する。

// resolve(成功)した時、onFulfilledが呼ばれる、reject(失敗)した時、onRejectedが呼ばれる
promise.then(onFulfilled, onRejected);
// reject(失敗)した時、onRejectedが呼ばれる
promise.catch(onRejected);

これらを使ってPromiseのサンプルを以下に記載する。

// 1.
function asyncFunction() {
   return new Promise((resolve) => {
       setTimeout(() => {
           resolve("Async Hello world");
       }, 16);
   });
}
// 2.
asyncFunction().then((value) => {
   console.log(value); // => 'Async Hello world'
}).catch((error) => {
   console.error(error);
});

/*
1. asyncFunction関数では、newしたpromiseオブジェクトを返します。
   setTimeoutを使って16秒後にresolve(成功)のコールバック関数を返します。
2. promiseオブジェクトに対して、 thenで値が返ってきたときのコールバック
   catchでエラーとなった場合のコールバックを設定しています。
   thenには第一引数にresolve(成功)のコールバック関数の処理を、
   第二引数にreject(失敗)のコールバック関数の処理を書けます。
   今回は、第二引数のrejectの処理を省略してcatchを使っています。

new Promise以外のPromise

普段意識していないかもしれないが、fetchやaxiosなどのライブラリを使用してpromiseオブジェクトを生成しているケースも少なくない。

const promise = fetch("https://kodak4400.github.io/tests/sample.json") // => fetchはpromiseオブジェクトを返す
promise.then(res => {
 console.log(res.status) // => 200
}).catch(error => {
 console.error(error)
});
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
const promise = axios.default.get("https://kodak4400.github.io/tests/sample.json") // => axios.getはpromiseオブジェクトを返す
promise.then(res => {
 console.log(res.status) // => 200
}).catch(error => {
 console.error(error)
});

Promiseの状態

new Promise でインスタンス化したpromiseオブジェクトには以下の3つの状態が存在する。

  • Fulfilled ・・・ resolve(成功)した時。このとき onFulfilled が呼ばれる。
  • Rejected ・・・ reject(失敗)した時。このとき onRejected が呼ばれる。
  • Pending ・・・ FulfilledまたはRejectedではない時。つまりpromiseオブジェクトが作成された初期状態等が該当する。

また、Fulfilledまたは、 Rejectedの状態になったことをSettledと表現される場合があるので、こちらも覚えておく。

これらの状態は、ECMAScriptのPromiseで定められたもので、これらの状態をプログラムで直接触る方法はない。

そのため、プログラムを書く時に気にする必要はないですが、これら3つの状態があることは理解しておく。

Promiseは常に非同期

Promiseは常に非同期であることに注意する。

非同期とは、そのままの意味で、同期ではないこと。thenで登録した関数が呼ばれるのは、非同期になる。

const promise = new Promise((resolve) => {
    console.log("inner promise"); // 1
    resolve(42);
});
promise.then((value) => {
    console.log(value); // 3
});
console.log("outer promise"); // 2

/* 出力結果
inner promise // 1
outer promise // 2
42            // 3
*/

同期と非同期の混在の問題

非同期コールバックを同期的に呼んではいけない。

同期と非同期が混在したときは、かならず非同期で処理を呼ぶようにする。

たとえば、以下のような例だと、このコードは配置する場所によって、 コンソールに出てくるメッセージの順番が変わってしまう。

function onReady(fn) {
   const readyState = document.readyState;
   if (readyState === "loading") {
     fn(); // 同期で処理を呼ぶ
   } else {
      // 非同期で処理を呼ぶ
     window.addEventListener("DOMContentLoaded", fn);
   }
}
onReady(() => {
  console.log("DOM fully loaded and parsed");
});
console.log("==Starting==");

常に非同期で呼び出すように統一することで、この問題は解決できる。

function onReadyPromise() {
   return new Promise((resolve) => { // 常に非同期で処理を呼ぶ
     const readyState = document.readyState;
     if (readyState === "loading") {
        resolve();
     } else {
        window.addEventListener("DOMContentLoaded", resolve);
     }
   });
}
onReadyPromise().then(() => {
  console.log("DOM fully loaded and parsed");
});
console.log("==Starting==");

Promise Chain

.then().catch()とメソッドチェーンで繋げて書いていたことからもわかるように、 Promiseではいくらでもメソッドチェーンを繋げて処理を書いていくことができる。

function taskA() {
    console.log("Task A");
}
function taskB() {
    console.log("Task B");
}
function onRejected(error) {
    console.log("Catch Error: A or B", error);
}
function finalTask() {
    console.log("Final Task");
}

const promise = Promise.resolve();
promise
    .then(taskA)
    .then(taskB)
    .catch(onRejected)
    .then(finalTask);

/* 出力結果
Task A // 1
Task B // 2
Final Task // 3
*/

Promise Chainでの値渡し

Task Aの処理で return した値がTask Bが呼ばれるときに引数に設定されます。

function doubleUp(value) {
    return value * 2;
}
function increment(value) {
    return value + 1;
}
function output(value) {
    console.log(value);// => (1 + 1) * 2
}

const promise = Promise.resolve(1);
promise
    .then(increment)
    .then(doubleUp)
    .then(output)
    .catch((error) => {
        // promise chain中にエラーが発生した場合に呼ばれる
        console.error(error);
    });

Promise#thenは常に新しいPromiseを返す

aPromise.then(…).catch(…) は一見すると、すべて最初の aPromise オブジェクトにメソッドチェーンで処理を書いてるように見える。

しかし、実際には then で新しいpromiseオブジェクト、catch でも別の新しいpromiseオブジェクトを作成して返している。

const aPromise = new Promise((resolve) => {
    resolve(100);
});
const thenPromise = aPromise.then((value) => {
    console.log(value);
});
const catchPromise = thenPromise.catch((error) => {
    console.error(error);
});
console.log(aPromise !== thenPromise); // => true
console.log(thenPromise !== catchPromise);// => true

この仕組みはPromiseを拡張する時は意識しないと、いつのまにか触ってるpromiseオブジェクトが別のものであったということが起こりえるかもしれません。

また、then は新しいオブジェクトを作って返すということがわかっていれば、 次の then の使い方では意味が異なることに気づくでしょう。

// 1: それぞれの `then` は同時に呼び出される
const aPromise = new Promise((resolve) => {
    resolve(100);
});
aPromise.then((value) => {
    return value * 2;
});
aPromise.then((value) => {
    return value * 2;
});
aPromise.then((value) => {
    console.log("1: " + value); // => 100
});

// vs

// 2: `then` はpromise chain通り順番に呼び出される
const bPromise = new Promise((resolve) => {
    resolve(100);
});
bPromise.then((value) => {
    return value * 2;
}).then((value) => {
    return value * 2;
}).then((value) => {
    console.log("2: " + value); // => 100 * 2 * 2
});

Promiseの概要

Constructor

Promiseオブジェクトを作成するには、Promise constructorをnewしてインスタンス化します。

const promise = new Promise((resolve, reject) => {
    // 非同期の処理
    // 処理が終わったら、resolve または rejectを呼ぶ
});

Instance Method

  • then()

newしてインスタンスされたPromiseは、resolve(成功)/reject(失敗)したときに呼ばれるコールバックを登録するためのthen()というインスタンスメソッドが用意されていまる。

// resolve(成功)した時、onFulfilledが呼ばれる
// reject(失敗)した時、onRejectedが呼ばれる
promise.then(onFulfilled, onRejected);
  • catch()

失敗時の処理、たとえばエラー処理だけを書きたい場合に使用する。

// reject(失敗)した時、onRejectedが呼ばれる
promise.catch(onRejected);
  • finaly()

ECMAScript2018に新規似追加された。 成功、失敗時の処理にかかわらず、必ず実行される関数。

// resolve(成功)、reject(失敗)に関わらず、必ずonFinallyが呼ばれる
promise.finally(onFinally); 

// サンプル
Promise.resolve("成功").finally(() => {
    console.log("成功時に実行される");
});
Promise.reject(new Error("失敗")).finally(() => {
    console.log("失敗時に実行される");
});

Static Method

  • Promise.resolve()

new Promise() をつかってインスタンス化する以外の方法の1つ。 new Promise() のショートカットとなるメソッド。

Promise.resolve(42).then((value: number) => {
    console.log(value);
});

/* 以下のシンタックスシュガー
new Promise((resolve) => {
    resolve(42);
});
*/
  • Promise.reject()

new Promise() をつかってインスタンス化する以外の方法の1つ。 new Promise() のショートカットとなるメソッド。

rejectにErrorオブジェクトを設定して、then()または、catch()でエラー処理をするのが定番な使い方。

Promise.reject(new Error('thenError')).then(undefined, (error: unknown) => {
  if (error instanceof Error) {
    console.error(error.message)
  }
});

Promise.reject(new Error('catchError')).catch((error: unknown) => {
  if (error instanceof Error) {
    console.error(error.message)
  }
});
  • Promise.all()

渡されたpromiseオブジェクトの配列がすべてresolveされた時に、 新たなpromiseオブジェクト作る。また、その値でresolveされる。

// `delay`ミリ秒後にresolveする
function timerPromisefy(delay) {
   return new Promise((resolve) => {
       setTimeout(() => {
           resolve(delay);
       }, delay);
   });
}
// 全てのpromiseオブジェクトのresolveが返るまで待つ
// 新しいpromiseオブジェクトを作り、その値でresolveされる
Promise.all([
   timerPromisefy(1),
   timerPromisefy(32),
   timerPromisefy(64),
   timerPromisefy(128)
]).then((value) => {
   console.log(value); // => [1, 32, 64, 128]
});
  • Promise.race()

渡されたpromiseオブジェクトの配列のうち、 一番最初にresolve(成功)または、reject(失敗)した時に、 新たなpromiseオブジェクトを作る。また、その値でresolveまたはrejectされる。

// `delay`ミリ秒後にresolveする
function timerPromisefy(delay) {
  return new Promise((resolve) => {
      setTimeout(() => {
          resolve(delay);
      }, delay);
  });
}
// 複数のpromiseオブジェクトの内、一番最初にresolve または rejectされるまで待つ
// 新しいpromiseオブジェクトを作り、その値でresolve または rejectされる
Promise.race([
  timerPromisefy(1),
  timerPromisefy(32),
  timerPromisefy(64),
  timerPromisefy(128)
]).then((value) => {
  console.log(value); // => 1
});
  • Promise.allSettled()

ECMAScript2020で新規追加された。渡されたpromiseオブジェクトの配列がすべてresolve(成功)または、reject(失敗)された時に、新たなpromiseオブジェクト作る。また、その値でresolveされる。(rejectされるわけではない)

// `delay`ミリ秒後にdelayが32以外はrejectする
function timerPromisefy(delay) {
  return new Promise((resolve, reject) => {
      setTimeout(() => {
          delay === 32 ? resolve(delay) : reject(delay);
      }, delay);
  });
}
// 複数のpromiseオブジェクトの内、一番最初にresolve または rejectされるまで待つ
// 新しいpromiseオブジェクトを作り、その値でresolve または rejectされる
Promise.allSettled([
  timerPromisefy(1),
  timerPromisefy(32),
  timerPromisefy(64),
  timerPromisefy(128)
]).then((value) => {
  console.log(value); // => [{"status": "rejected", "reason": 1}, {"status": "fulfilled", "value": 32}, {"status": "rejected", "reason": 64}, {"status": "rejected", "reason": 128}]
});
  • Promise.any()

ECMAScript2021で新規追加されました。渡されたpromiseオブジェクトの配列のうち、一番最初にresolve(成功)した時に、新たなpromiseオブジェクトを作る。promiseオブジェクトの配列のうち、すべてがrejectされた場合にのみrejectされる。

// `delay`ミリ秒後にdelayが32以外はrejectする
function timerPromisefy(delay) {
  return new Promise((resolve, reject) => {
      setTimeout(() => {
          delay === 32 ? resolve(delay) : reject(delay);
      }, delay);
  });
}
// 複数のpromiseオブジェクトの内、一番最初にresolve されるまで待つ
// 新しいpromiseオブジェクトを作り、その値でresolve される
Promise.any([
  timerPromisefy(1),
  timerPromisefy(32),
  timerPromisefy(64),
  timerPromisefy(128)
]).then((value) => {
  console.log(value); // => 32
});

Async Function

Async Functionとは非同期処理を行う関数を定義する構文です。 Async Functionは通常の関数とは異なり、必ずPromiseインスタンスを返す関数を定義する構文です。

つまり、以下はイコールです。

async function doAsync() {
    return "値";
}
// doAsync関数はPromiseを返す
doAsync().then((value) => {
    console.log(value); // => "値"
});
function doAsync() {
    return Promise.resolve("値");
}
doAsync().then((value) => {
    console.log(value); // => "値"
});

Async Function - await式 -

Async Functionはasync/awaitとも呼ばれることがあります。 この呼ばれ方からもわかるように、Async Functionとawait式は共に利用します。await式はAsync Function内でのみ利用できます。

await式は右辺のPromiseインスタンスがFulfilledまたはRejectedになるまで、その行(文)で非同期処理の完了を待ってから処理します。

また、await式のもう1つ特徴として、Fulfilledになった時はresolveの値を返し、Rejectedになった時はエラーをthrowします。

つまり、try-catch文が使えるので、非同期処理を同期処理のように書くことができます。

async function doAsync() {
  return "値";
}

;(async () => {
 const value = await doAsync()
 console.log(value) // => "値"
})()
async function doAsync() {
  return Promise.reject(new Error('Async Error'))
}

;(async () => {
 try {
   const value = await doAsync()
   console.log(value)
 } catch(error) {
   console.log(error) // => "Async Error"
 }
})()

Async FunctionとPromiseを組み合わせて使おう

Async Functionのawait式は、非同期処理を同期処理と同じように書けてとても便利だけど、注意が必要です。たとえば、非同期処理で順番に処理を終える必要のない場合において、await式を使ってforループ文とか使っていると、ムダな待ち時間が生じてしまいまう。

そんな時は、Promise.allとawait式を組み合わせて使うと、ムダな待ち時間を排除て処理が書けます。

// `delay`ミリ秒後にdelayが32以外はrejectする
function timerPromisefy(delay) {
 return new Promise((resolve, reject) => {
     setTimeout(() => {
         resolve(new Date());
     }, delay);
 });
}

;(async () => {
 for (const x of [10, 20, 30]) {
   console.log(await timerPromisefy(x)) // => 悪い例: 10秒, 20秒, 30秒ごとに関数を実行
 }
 // => 良い例: 10秒ごとに関数を実行
 console.log(await Promise.all([timerPromisefy(10), timerPromisefy(20), timerPromisefy(30)]))
})()

さいごに

私自身、ECMAScript2020 / 2021でStatic Mthoedが新規追加されていたことや、Promise内でのthrowがNGだとわかったり、色々と調べていて学びになりました。

ちなみに、このJavaScript Promiseの内容は、以下を参考にさせて頂いています。

JavaScript Promiseの本 => https://azu.github.io/promises-book

JavaScript Visualized: Promises & Async/Await => https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke

Discussion