JavaScript の fetch() と Promise と async/await
はじめに
たとえばこんなコード。
fetch(url)
.then((response) => response.json())
.then((data) => {
console.log(data); // 描画処理など
})
.catch((e) => {
console.log(e); // エラー処理
})
メソッドチェーンとクロージャーで構造がわかりにくいですが、分解するとこうなります。
let thenFunc1 = (response) => response.json();
let thenFunc2 = (data) => {
console.log(data); // 描画処理など
}
let catchFunc = (e) => {
console.log(e); // エラー処理
}
let a = fetch(url)
let b = a.then(thenFunc1)
let c = b.then(thenFunc2)
c.catch(catchFunc)
a, b, c の型はそれぞれ何しょうか?
質問を変えれば、then(), catch() は誰のメソッドでしょうか?
・・・
その前に、歴史のお話
最初のコードは、感覚的には、
let response = fetch(url);
let data = response.json();
console.log(data);
と書きたくなります。
しかし、fetch() 関数は非同期処理であり、
url の取得完了を待たず、次の行へ処理が進んでしまいます。
非同期処理の中で実行順を担保するため、かつては、コールバックがよく用いられました。
request(
url1,
function (response1) {
request(
url2 + response1.id,
function (response2) {
request(
url3 + response2.id,
function (response3) {
console.log('成功');
},
function () {
console.log('エラー3');
},
);
},
function () {
console.log('エラー2');
},
);
},
function () {
console.log('エラー1');
},
);
function request(url, successFunc, errorFunc) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = "json";
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
successFunc(xhr.response);
} else if (xhr.readyState === 4) {
errorFunc();
}
};
xhr.send();
}
上記の XMLHttpRequest は、JavaScript に古くから存在する、非同期通信の(APIを叩く)ためのオブジェクトですが、
上記のようにラッパー関数を作ってもなお、可読性・メンテナンス性の悪いコードに陥りがちでした。
俗に言う 「コールバック地獄」 と呼ばれる状態です。
そこで、非同期処理を制御しやすくするための機構として、Promise オブジェクトが登場しました。
さらに時代が進んで、async / await が登場し、非同期処理を同期的に扱うことができるようになり、現在ではこちらが主流ではありますが、
async / await は Promise を前提に動作するものですので、まずは Promise のお話をします。
Promise とは
先程のコードを、Promise を使って書き直してみます。
request(url1)
.then((response1) => {
return request(url2 + response1.id)
})
.then((response2) => {
return request(url3 + response1.id)
})
.then((response3) => {
console.log("成功");
})
.catch(() => {
console.log("エラー");
})
function request(url) {
return new Promise(function (resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = "json";
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
resolve(xhr.response);
} else if (xhr.readyState === 4) {
reject();
}
};
xhr.send();
});
}
コードのネストがなくなり、かなりスッキリしました。
上記の request() 関数は、fetch() の原型と言ってよいと思います。
Promise オブジェクトは、コンストラクタに関数(クロージャ)を受け取ります。
その関数は2つの引数を受け取りますが、その引数はそれぞれ resolve, reject という関数です。
また、Promise オブジェクトは、then()、catch() などのメソッドを持っており、
どちらも引数に関数(クロージャ)を受け取ります。
コンストラクタに渡された関数は直ちに実行され、その中で resolve 関数が呼ばれると、
then() に渡された関数が実行されます。
同様に reject 関数が呼ばれると、catch() に渡された関数が実行されます。
・・・言葉で説明するとややこしいのですが、イメージとしてはこんな感じです(あくまでイメージです)。
// なんちゃってPromise
class Promise {
thenFunc = () => {};
catchFunc = () => {};
fillayFunc = () => {};
constructor(func) {
func(this.resolve, this.reject);
}
resolve(arg) {
this.thenFunc(arg);
this.finallyFunc(arg);
}
reject(arg) {
this.catchFunc(arg);
this.finallyFunc(arg);
}
then(func) {
this.thenFunc = func;
return this;
}
catch(func) {
this.catchFunc = func;
return this;
}
finally(func) {
this.finallyFunc = func;
return this;
}
}
先程のコード(一部抜粋・改変)と照らしみると・・・
let a = request(url1);
let b = a.then(
// 成功時処理
function (response1) {
return request(url2 + response1.id)
}
);
b.catch(
// エラー時処理
function () {
console.log("エラー");
}
);
function request(url) {
return new Promise(
// メイン処理
function (resolve, reject) {
// 略(通信処理)
// 成功時
resolve(xhr.response);
// 失敗時
reject();
}
);
}
まず、request() の中で Promise がインスタンス化され、
コンストラクタに渡された function (メイン処理)が、即時開始されます。
しかし、メイン処理内の通信には時間がかかるため、
メイン処理が完了する前に a.then() が実行されます。
a には、request() の戻り値である Promise オブジェクトが格納されています。
then() の引数に渡された function (成功時処理)が、 Promise の thenFunc に格納されます。
then() も Promise オブジェクト(自分自身)を返すため、b にも Promise オブジェクトが格納されています。
続いて b.catch() が実行され、引数の function (エラー時処理)が、Promise の catchFunc に格納されます。
そうこうしているうちに、メイン処理内の通信が終わり、resolve() が実行されます。
Promise のメイン処理の resolve には Promise の resolve() メソッドが代入されていますので、
thenFunc に格納された、成功時処理が実行されます。
もし、通信に失敗した場合は、同様に reject() が実行され、catchFunc に格納された、エラー時処理が実行されます。
このようにして、本来非同期な処理を、then() や catch() を使って時系列潤に記述できるような仕組みが実現されています。
上記のなんちゃって Promise では再現できていませんが、
実際には複数の .then() をチェーンすると、Promise はネストされていき、内側から順に解決(resolve)されていきます。
そして、そのどこかでエラー(reject)が発生すると、一気に1番外側の .catch() まで飛ぶようになっています。
(作った人すごい。)
改めて fetch()
最初のコードに戻りましょう。
fetch(url)
.then((response) => response.json())
.then((data) => {
console.log(data); // 描画処理など
})
.catch((e) => {
console.log(e); // エラー処理
})
fetch() 関数は、Promise を返す非同期通信処理です。
JSer が前述のように非同期通信の扱いに四苦八苦していたある日、
JavaScript の新機能として追加されました。
通信が完了すると、Promise が resolve され、
resolve の引数には、通信のレスポンス(Responseオブジェクト)が渡されます。
つまり、Responseオブジェクトが、then() の引数である下記クロージャに渡ってきます。
(response) => response.json()
Responseオブジェクト の json() メソッドもまた、Promise を返します。
レスポンスボディをJSONデコードし、変換に成功すると、resolve されます。
resolve の引数には、JSONデコードされたオブジェクトが渡ります。
こうして、オブジェクト化されたレスポンスが、次の then() のクロージャに渡ってくることになります。
(data) => {
console.log(data); // 描画処理など
}
なお、then() には Promise を返す処理しか渡せないのかといえば、そんなことはなく、
Promise 以外を返す処理が渡された場合、その返り値を引数として、直ちに次の then() の処理が実行されます。
async と await
ちなみに、こちらのコードは
fetch(url)
.then((response) => response.json())
.then((data) => {
console.log(data); // 描画処理など
})
.catch((e) => {
console.log(e); // エラー処理
})
await を使用して、
try {
let response = await fetch(url);
let data = await response.json();
console.log(data); // 描画処理など
} catch (e) {
console.log(e); // エラー処理
}
のように書くことができます。
本来 Promise を返す関数に、await をつけて呼び出すと、
Promise が resolve() されるまで処理が待機され、resolve() の引数に渡された値が、
戻り値として返されます。
また、 Promise が reject() された場合には、例外がスローされます。
async 関数内でしか使えないという制約はありますが、
await により、Promise ベースの処理をさらに直列的に記述できるようになります。
なお await はあくまで Promise ありきの記法ですので、
Promise を返さない関数に付与しても、挙動は何らかわりません。
たとえば
await setTimeout(() => {}, 1000);
などとしても、1秒待つこと無く次の行の処理へ移ります。
ちなみに、async を付与された関数は、自動的に、Promise を返すようになります。
おわりに
fetch() 関連の処理をレビューするにあたって、Promise をうまく説明できなかったので、
改めて整理してみました。
fetch() や async/await の登場により、
非同期処理がずいぶんシンプルに記述できるようになりましたが、
使いこなすためには、改めて Promise の理解が欠かせないなと思いました。
Discussion