JavaScriptの非同期処理完全ガイド|Promise・async/await・fetchの使い方と実践的なAPI通信の実装


はじめに

前回の記事ではCSSアニメーションとトランジションのパフォーマンスを意識した実装方法を解説しました。その中でフォームの送信処理に async/awaitfetch を使いました。今回はその基礎となるJavaScriptの非同期処理を改めて整理します。Promiseasync/awaitfetch の仕組みと使い方を順を追って解説して、実際の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,  '&amp;'  )
        .replace( /</g,  '&lt;'   )
        .replace( />/g,  '&gt;'   )
        .replace( /"/g,  '&quot;' )
        .replace( /'/g,  '&#039;' );
}

⚠️ 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 が主流
  • Promisependingfulfilledrejected の3状態を持つ非同期処理の約束オブジェクト
  • 並列実行できる処理は Promise.all でまとめて実行して処理時間を短縮する
  • fetch はHTTPエラーでrejectしないため response.ok を必ず確認する
  • AbortController でタイムアウトを実装してUXを保護する
  • 共通のAPIクライアントを作ることでベースURL・ヘッダー・エラー処理を一元管理できる
  • APIから取得したデータは必ず escapeHtml() でエスケープしてからHTMLに出力する
  • UIはローディング・エラー・データの3状態を常に考慮した設計にする

次の記事では、LocalStorageとSessionStorageの使い方と、JavaScriptでデータを永続化する実践的なパターンを解説します。お楽しみに!

コメント

タイトルとURLをコピーしました