はじめに
前回の記事ではCSSアニメーションとトランジションのパフォーマンスを意識した実装方法を解説しました。その中でフォームの送信処理に async/await と fetch を使いました。今回はその基礎となるJavaScriptの非同期処理を改めて整理します。Promise・async/await・fetch の仕組みと使い方を順を追って解説して、実際のAPIとの通信・エラーハンドリング・ローディング状態の管理まで実践的な実装方法をまとめます。
1. 非同期処理とは何か
1.1 同期処理と非同期処理の違い
JavaScriptはシングルスレッドで動作します。通常は上から順に1行ずつ処理を実行しますが(同期処理)、サーバーへのリクエストやタイマーのように「結果が返ってくるまで時間がかかる処理」をそのまま待っていると画面が固まってしまいます。これを解決するのが非同期処理です。
// ❌ 同期的な発想(JavaScriptでは実現できない)
const data = fetchFromServer(); // ← 時間がかかるのでここで固まる
console.log( data ); // ← dataが返るまで実行されない
// ✅ 非同期処理(JavaScriptの実際の動き)
fetchFromServer().then( data => {
console.log( data ); // ← データが届いたら実行する
} );
console.log( 'この行はfetchを待たずに実行される' );
1.2 コールバック・Promise・async/awaitの歴史
非同期処理の書き方の進化:
① コールバック関数(古い書き方)
getData( function( data ) {
getMore( data, function( result ) {
// ネストが深くなる(コールバック地獄)
} );
} );
② Promise(ES2015〜)
getData()
.then( data => getMore( data ) )
.then( result => console.log( result ) )
.catch( err => console.error( err ) );
③ async/await(ES2017〜)現在の主流
try {
const data = await getData();
const result = await getMore( data );
console.log( result );
} catch ( err ) {
console.error( err );
}
2. Promiseの仕組みを理解する
2.1 Promiseとは
Promise は「非同期処理の結果を将来的に受け取る約束」を表すオブジェクトです。3つの状態を持ちます。
| 状態 | 内容 |
|---|---|
pending(待機中) | 処理が完了していない初期状態 |
fulfilled(成功) | 処理が正常に完了した状態 |
rejected(失敗) | 処理が失敗した状態 |
// Promiseを自分で作る
const myPromise = new Promise( ( resolve, reject ) => {
// 非同期処理をここに書く
setTimeout( () => {
const success = true;
if ( success ) {
resolve( '成功!データを返します' ); // fulfilled状態に移行
} else {
reject( new Error( '失敗しました' ) ); // rejected状態に移行
}
}, 1000 );
} );
// Promiseの結果を受け取る
myPromise
.then( result => console.log( result ) ) // fulfilledのとき
.catch( error => console.error( error ) ) // rejectedのとき
.finally( () => console.log( '必ず実行される' ) ); // 常に実行
2.2 Promiseチェーン
.then() の返り値もPromiseになるため、チェーンして繋げることができます。
fetch( 'https://api.example.com/users' )
.then( response => {
if ( !response.ok ) {
throw new Error( `HTTP Error: ${response.status}` );
}
return response.json(); // JSONをパースするPromiseを返す
} )
.then( users => {
console.log( users );
return users.filter( user => user.active ); // 次のthenに渡す
} )
.then( activeUsers => {
console.log( 'アクティブユーザー:', activeUsers );
} )
.catch( error => {
console.error( 'エラー:', error );
} );
2.3 Promise.allとPromise.allSettled
複数の非同期処理を同時に実行したい場合に使います。
// Promise.all:すべて成功したら結果を配列で返す(1つでも失敗したらrejectになる)
const [ users, posts, comments ] = await Promise.all( [
fetch( '/api/users' ).then( r => r.json() ),
fetch( '/api/posts' ).then( r => r.json() ),
fetch( '/api/comments' ).then( r => r.json() ),
] );
// Promise.allSettled:失敗した項目も含めてすべての結果を返す
const results = await Promise.allSettled( [
fetch( '/api/users' ).then( r => r.json() ),
fetch( '/api/posts' ).then( r => r.json() ),
] );
results.forEach( result => {
if ( result.status === 'fulfilled' ) {
console.log( '成功:', result.value );
} else {
console.error( '失敗:', result.reason );
}
} );
💡 並列実行できる処理はまとめて Promise.all で実行しましょう: 順番に依存しない複数のAPIリクエストを1つずつ await すると、前の処理が終わるまで次が始まりません。Promise.all で同時実行すると処理時間を大幅に短縮できます。
3. async/awaitの書き方
3.1 基本構文
// async関数はPromiseを返す
async function getUsers() {
// awaitはPromiseが解決されるまで待つ
const response = await fetch( 'https://api.example.com/users' );
const users = await response.json();
return users; // Promiseでラップされて返る
}
// アロー関数でもasyncを使える
const getUsers = async () => {
const response = await fetch( 'https://api.example.com/users' );
return response.json();
};
// 呼び出し側でもawaitを使う(または.then()で受け取る)
const users = await getUsers();
3.2 try/catchでエラーを処理する
async function getUser( userId ) {
try {
const response = await fetch( `https://api.example.com/users/${userId}` );
// HTTPエラーチェック(fetchはHTTPエラーをrejectしないため手動でチェック)
if ( !response.ok ) {
throw new Error( `HTTP Error: ${response.status} ${response.statusText}` );
}
const user = await response.json();
return user;
} catch ( error ) {
if ( error instanceof TypeError ) {
// ネットワークエラー(オフラインなど)
console.error( 'ネットワークエラー:', error.message );
} else {
// HTTPエラーなどその他のエラー
console.error( 'エラー:', error.message );
}
throw error; // 呼び出し元にエラーを伝播させる場合は再throwする
} finally {
console.log( 'ローディング終了' );
}
}
⚠️ fetch はHTTPエラー(404・500など)でもrejectしません: fetch がrejectするのはネットワーク障害など「リクエスト自体が送れなかった」場合のみです。404や500のレスポンスが返ってきても catch には入りません。response.ok または response.status を必ず確認しましょう。
4. fetchAPIの実践的な使い方
4.1 GETリクエスト
// 基本的なGETリクエスト
async function fetchPosts( page = 1, limit = 10 ) {
const params = new URLSearchParams( {
_page: page,
_limit: limit,
} );
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?${params}`
);
if ( !response.ok ) {
throw new Error( `Failed to fetch posts: ${response.status}` );
}
return response.json();
}
4.2 POSTリクエスト
// JSONを送信するPOSTリクエスト
async function createPost( postData ) {
const response = await fetch( 'https://api.example.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`, // 認証トークン
},
body: JSON.stringify( postData ),
} );
if ( !response.ok ) {
const errorData = await response.json().catch( () => null );
throw new Error( errorData?.message || `HTTP Error: ${response.status}` );
}
return response.json();
}
// 使い方
const newPost = await createPost( {
title: '新しい記事',
body: '記事の内容',
userId: 1,
} );
4.3 FormDataを送信する
// フォームデータをそのまま送信する
async function submitContactForm( formElement ) {
const formData = new FormData( formElement );
const response = await fetch( formElement.action, {
method: 'POST',
body: formData,
// Content-Typeはブラウザが自動でmultipart/form-dataに設定する
// 明示的にheadersを書くと壊れるので注意
} );
if ( !response.ok ) {
throw new Error( `送信に失敗しました(${response.status})` );
}
return response.json();
}
4.4 タイムアウトを設定する
// AbortControllerでタイムアウトを実装する
async function fetchWithTimeout( url, options = {}, timeout = 10000 ) {
const controller = new AbortController();
const timeoutId = setTimeout( () => controller.abort(), timeout );
try {
const response = await fetch( url, {
...options,
signal: controller.signal, // キャンセル用シグナルを渡す
} );
return response;
} catch ( error ) {
if ( error.name === 'AbortError' ) {
throw new Error( `リクエストがタイムアウトしました(${timeout}ms)` );
}
throw error;
} finally {
clearTimeout( timeoutId ); // タイムアウトタイマーをクリアする
}
}
// 使い方
const response = await fetchWithTimeout(
'https://api.example.com/data',
{},
5000 // 5秒でタイムアウト
);
5. 再利用可能なAPIクライアントを作る
5.1 fetchのラッパー関数
// 共通のAPIクライアント
const API_BASE_URL = 'https://api.example.com';
async function apiRequest( endpoint, options = {} ) {
const url = `${API_BASE_URL}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
};
// オプションをマージする
const mergedOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers,
},
};
const response = await fetch( url, mergedOptions );
// エラーレスポンスの処理
if ( !response.ok ) {
let errorMessage = `HTTP Error: ${response.status}`;
try {
const errorBody = await response.json();
errorMessage = errorBody.message || errorMessage;
} catch {
// JSONパースに失敗してもエラーは握りつぶさない
}
throw new Error( errorMessage );
}
// 204 No Contentなどボディがない場合の処理
if ( response.status === 204 ) {
return null;
}
return response.json();
}
// 便利メソッドを追加する
const api = {
get: ( endpoint, params = {} ) => {
const query = Object.keys( params ).length
? '?' + new URLSearchParams( params ).toString()
: '';
return apiRequest( `${endpoint}${query}` );
},
post: ( endpoint, data ) => apiRequest( endpoint, {
method: 'POST',
body: JSON.stringify( data ),
} ),
put: ( endpoint, data ) => apiRequest( endpoint, {
method: 'PUT',
body: JSON.stringify( data ),
} ),
delete: ( endpoint ) => apiRequest( endpoint, {
method: 'DELETE',
} ),
};
// 使い方
const users = await api.get( '/users', { page: 1, limit: 10 } );
const user = await api.post( '/users', { name: '山田太郎' } );
6. UIへの反映パターン
6.1 ローディング・エラー・データの状態管理
// 状態を管理するクラス
class AsyncState {
constructor( containerEl ) {
this.container = containerEl;
}
showLoading( message = '読み込み中...' ) {
this.container.innerHTML = `
<div class="state-loading" role="status" aria-live="polite">
<div class="spinner" aria-hidden="true"></div>
<p>${message}</p>
</div>
`;
}
showError( message, retryFn = null ) {
this.container.innerHTML = `
<div class="state-error" role="alert">
<p class="state-error__message">${message}</p>
${retryFn ? '<button class="btn-retry">再試行</button>' : ''}
</div>
`;
if ( retryFn ) {
this.container.querySelector( '.btn-retry' )
?.addEventListener( 'click', retryFn );
}
}
showData( html ) {
this.container.innerHTML = html;
}
}
// 使い方
async function loadPosts() {
const state = new AsyncState( document.querySelector( '#posts-container' ) );
state.showLoading();
try {
const posts = await api.get( '/posts' );
const html = posts
.map( post => `
<article class="post-card">
<h2 class="post-card__title">${escapeHtml( post.title )}</h2>
<p class="post-card__body">${escapeHtml( post.body )}</p>
</article>
` )
.join( '' );
state.showData( html );
} catch ( error ) {
state.showError(
'投稿の読み込みに失敗しました。',
loadPosts // 再試行ボタンに渡す
);
}
}
6.2 XSS対策:出力をエスケープする
// APIから取得したデータをHTMLに出力する際は必ずエスケープする
function escapeHtml( str ) {
const div = document.createElement( 'div' );
div.textContent = str;
return div.innerHTML;
}
// または自分でエスケープ関数を書く
function escapeHtml( str ) {
return String( str )
.replace( /&/g, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' )
.replace( /"/g, '"' )
.replace( /'/g, ''' );
}
⚠️ APIから取得したデータを innerHTML に直接渡さないでください: 悪意あるサーバーや汚染されたデータにスクリプトが含まれていた場合にXSS(クロスサイトスクリプティング)攻撃が成立します。必ず escapeHtml() を通してから出力します。
7. 無限スクロールの実装
7.1 IntersectionObserverと組み合わせる
class InfiniteScroll {
constructor( options ) {
this.container = options.container;
this.sentinel = options.sentinel; // 監視対象の末尾要素
this.loadFn = options.loadFn; // データ取得関数
this.page = 1;
this.isLoading = false;
this.hasMore = true;
this.observer = new IntersectionObserver(
entries => this.onIntersect( entries ),
{ rootMargin: '0px 0px 200px 0px' } // 200px手前でトリガー
);
this.observer.observe( this.sentinel );
}
async onIntersect( entries ) {
const entry = entries[0];
if ( !entry.isIntersecting || this.isLoading || !this.hasMore ) return;
await this.loadNext();
}
async loadNext() {
this.isLoading = true;
this.showSpinner();
try {
const data = await this.loadFn( this.page );
if ( data.length === 0 ) {
this.hasMore = false;
this.observer.unobserve( this.sentinel );
this.showEndMessage();
return;
}
this.appendData( data );
this.page++;
} catch ( error ) {
this.showError();
} finally {
this.isLoading = false;
this.hideSpinner();
}
}
showSpinner() {
this.sentinel.insertAdjacentHTML(
'beforebegin',
'<div class="loading-spinner" id="scroll-spinner"></div>'
);
}
hideSpinner() {
document.querySelector( '#scroll-spinner' )?.remove();
}
appendData( data ) {
const html = data
.map( item => `<div class="item">${escapeHtml( item.title )}</div>` )
.join( '' );
this.sentinel.insertAdjacentHTML( 'beforebegin', html );
}
showEndMessage() {
this.sentinel.insertAdjacentHTML(
'beforebegin',
'<p class="end-message">すべての記事を表示しました。</p>'
);
}
showError() {
this.sentinel.insertAdjacentHTML(
'beforebegin',
'<p class="error-message">読み込みに失敗しました。</p>'
);
}
}
// 使い方
const scroll = new InfiniteScroll( {
container: document.querySelector( '#posts-list' ),
sentinel: document.querySelector( '#scroll-sentinel' ),
loadFn: ( page ) => api.get( '/posts', { page, limit: 10 } ),
} );
8. 実践チェックリスト
✓ fetch のHTTPエラーを response.ok で確認しているか
✓ try/catch/finally でエラーと後処理を適切にハンドリングしているか
✓ 並列実行できる処理は await を個別に書かずに Promise.all でまとめているか
✓ 長時間かかる可能性のあるリクエストに AbortController でタイムアウトを設定しているか
✓ APIレスポンスを innerHTML に渡す前に escapeHtml() でエスケープしているか
✓ ローディング・エラー・データ表示の3つの状態をUIで表現しているか
✓ エラー時に「再試行」ボタンを表示してユーザーが操作を継続できるようにしているか
✓ role="status" と aria-live="polite" でスクリーンリーダーに状態を伝えているか
✓ APIクライアントを共通化してベースURLや共通ヘッダーを1箇所で管理しているか
まとめ
今回はJavaScriptの非同期処理の仕組みと実践的なAPI通信の実装方法を解説しました。ポイントをまとめると:
- 非同期処理はコールバック→Promise→async/awaitと進化してきた。現在は
async/awaitが主流 Promiseはpending・fulfilled・rejectedの3状態を持つ非同期処理の約束オブジェクト- 並列実行できる処理は
Promise.allでまとめて実行して処理時間を短縮する fetchはHTTPエラーでrejectしないためresponse.okを必ず確認するAbortControllerでタイムアウトを実装してUXを保護する- 共通のAPIクライアントを作ることでベースURL・ヘッダー・エラー処理を一元管理できる
- APIから取得したデータは必ず
escapeHtml()でエスケープしてからHTMLに出力する - UIはローディング・エラー・データの3状態を常に考慮した設計にする
次の記事では、LocalStorageとSessionStorageの使い方と、JavaScriptでデータを永続化する実践的なパターンを解説します。お楽しみに!
コメント