JavaScript | 1 日 120 分 × 7 日アプリ学習:API通信アプリ(Datamuse API)

APP JavaScript
スポンサーリンク

3日目のゴールと今日やること

3日目のテーマは
「fetch・async/await・エラーハンドリングの“型”はそのままに、Datamuse アプリを“使っていて気持ちいいツール”に近づける」
ことです。

ここまでであなたはすでに、

  • 単語を入力して Datamuse API から結果を取得する
  • モード(類義語 / 連想語 / 韻)を切り替えて検索する
  • ローディング中の状態管理(isLoading)
  • 通信失敗時のメッセージ分岐

といった「API 通信アプリの基本フォーム」を手に入れました。

3日目はここに、

  • 入力補完(前方一致)っぽい動き
  • スコア順の並び替え
  • お気に入り単語の簡易実装
  • fetch の「再利用しやすい形」への整理

を足して、「語彙を増やすためのミニツール」に育てていきます。


今日の完成イメージを先に描く

どんなアプリにしたいか

3日目の Datamuse アプリは、こんな動きを目指します。

  • 単語を入力して検索(類義語 / 連想語 / 韻)
  • 結果はスコア順に並ぶ
  • 結果の単語をクリックすると「お気に入り」に追加
  • 別枠にお気に入り単語一覧が表示される
  • 入力欄に文字を打つと、「その文字で始まる候補」を下に表示(簡易サジェスト)
  • ローディング中は状態表示と多重起動防止

技術的には、
「やることは全部 fetch・async/await・エラーハンドリングの応用」
です。


まず「共通の fetch 関数」を作る

なぜ共通化するのか

2日目までは、
fetchRelatedWords の中で直接 fetch(url) を呼んでいました。

3日目では、

  • 通常検索(ml / rel_trg / rel_rhy)
  • 入力補完用の前方一致検索(sp)

と、複数の用途で Datamuse を叩きたいので、
「Datamuse にリクエストする関数」を一箇所にまとめます。

Datamuse 用の共通関数

async function requestDatamuse(params) {
  const baseUrl = "https://api.datamuse.com/words";
  const url = `${baseUrl}?${params.toString()}`;

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`HTTPエラー(${response.status})`);
  }

  const data = await response.json();

  if (!Array.isArray(data)) {
    throw new Error("予期しないレスポンス形式です。");
  }

  return data;
}
JavaScript

ここでのポイントは、

  • URL の組み立ては URLSearchParams に任せる
  • HTTP エラーは throw で上に投げる
  • 「配列じゃなかったらおかしい」と判断してエラーにする

という「API 通信の型」を一段階抽象化していることです。


通常検索を「共通関数を使う形」に書き換える

検索用のパラメータを組み立てる

function buildSearchParams(word, mode) {
  const params = new URLSearchParams();

  if (mode === "ml") {
    params.set("ml", word);
  } else if (mode === "rel_trg") {
    params.set("rel_trg", word);
  } else if (mode === "rel_rhy") {
    params.set("rel_rhy", word);
  } else {
    params.set("ml", word);
  }

  params.set("max", "20");

  return params;
}
JavaScript

検索関数をシンプルにする

let isLoading = false;

function startLoading(message) {
  isLoading = true;
  statusDiv.textContent = message || "取得中です…";
  searchButton.disabled = true;
}

function endLoading() {
  isLoading = false;
  searchButton.disabled = false;
}

async function fetchWordsForMode(word, mode) {
  if (isLoading) return;

  startLoading("単語を取得中です…");
  resultDiv.textContent = "";

  try {
    const params = buildSearchParams(word, mode);
    const data = await requestDatamuse(params);

    if (data.length === 0) {
      const label = getModeShortLabel(mode);
      statusDiv.textContent = `${label}が見つかりませんでした。`;
      resultDiv.textContent = "";
      return;
    }

    const sorted = [...data].sort((a, b) => (b.score || 0) - (a.score || 0));

    const label = getModeShortLabel(mode);
    statusDiv.textContent = `${label}の取得に成功しました。`;
    renderWords(sorted, mode);

  } catch (error) {
    const label = getModeShortLabel(mode);
    statusDiv.textContent = `${label}の取得中にエラーが発生しました:${error.message}`;
    console.error(error);

  } finally {
    endLoading();
  }
}
JavaScript

ここでの深掘りポイントは、

  • fetch 自体は requestDatamuse に隠している
  • 検索関数は「パラメータを作る」「結果を並べる」「表示する」に集中している
  • エラーは「どのモードで失敗したか」を含めて表示している

という「役割分担」ができていることです。


結果の単語を「お気に入り」に追加する

お気に入り用の状態を持つ

const favorites = [];
const favoritesDiv = document.getElementById("favorites");
JavaScript

お気に入りに追加する関数

function addFavorite(word) {
  if (favorites.includes(word)) {
    statusDiv.textContent = "すでにお気に入りに追加されています。";
    return;
  }

  favorites.push(word);
  statusDiv.textContent = `「${word}」をお気に入りに追加しました。`;
  renderFavorites();
}
JavaScript

お気に入り一覧を表示する

function renderFavorites() {
  if (favorites.length === 0) {
    favoritesDiv.textContent = "お気に入り単語はまだありません。";
    return;
  }

  let html = "<h3>お気に入り単語</h3>";

  favorites.forEach((word) => {
    html += `<p>★ ${word}</p>`;
  });

  favoritesDiv.innerHTML = html;
}
JavaScript

結果表示に「お気に入りボタン」を付ける

renderWords を拡張します。

function renderWords(data, mode) {
  const label = getModeLabel(mode);
  let html = `<h3>${label}</h3>`;

  data.forEach((item, index) => {
    const word = item.word;
    const score = item.score;
    html += `
      <div class="word-item" data-index="${index}">
        <span>${word}(スコア: ${score})</span>
        <button class="fav-button" data-word="${word}">★ お気に入り</button>
      </div>
    `;
  });

  resultDiv.innerHTML = html;

  const buttons = resultDiv.querySelectorAll(".fav-button");
  buttons.forEach((btn) => {
    btn.addEventListener("click", () => {
      const word = btn.dataset.word;
      addFavorite(word);
    });
  });
}
JavaScript

ここでのポイントは、

  • API のレスポンス(data)から word を取り出して、そのままお気に入りに使っている
  • お気に入りは「単語の配列」というシンプルな形で持っている

という「状態の分離」です。
API の結果とアプリの状態を混ぜないのが大事です。


入力補完(前方一致)っぽい動きを作る

Datamuse の sp パラメータを使う

前方一致は sp を使います。

https://api.datamuse.com/words?sp=pro*&max=5

sp=pro* は「pro で始まる単語」を意味します。

サジェスト用の関数を作る

const suggestDiv = document.getElementById("suggest");

let suggestTimeoutId = null;

async function fetchSuggestions(prefix) {
  if (!prefix) {
    suggestDiv.textContent = "";
    return;
  }

  const params = new URLSearchParams();
  params.set("sp", prefix + "*");
  params.set("max", "5");

  try {
    const data = await requestDatamuse(params);

    if (data.length === 0) {
      suggestDiv.textContent = "";
      return;
    }

    renderSuggestions(data);

  } catch (error) {
    console.error("サジェスト取得中にエラー", error);
    suggestDiv.textContent = "";
  }
}
JavaScript

サジェストの表示

function renderSuggestions(data) {
  let html = "<h4>候補:</h4>";

  data.forEach((item) => {
    const word = item.word;
    html += `<button class="suggest-item" data-word="${word}">${word}</button>`;
  });

  suggestDiv.innerHTML = html;

  const buttons = suggestDiv.querySelectorAll(".suggest-item");
  buttons.forEach((btn) => {
    btn.addEventListener("click", () => {
      const word = btn.dataset.word;
      wordInput.value = word;
      suggestDiv.textContent = "";
    });
  });
}
JavaScript

入力イベントにサジェストを紐づける

wordInput.addEventListener("input", () => {
  const value = wordInput.value.trim();

  if (suggestTimeoutId) {
    clearTimeout(suggestTimeoutId);
  }

  suggestTimeoutId = setTimeout(() => {
    fetchSuggestions(value);
  }, 300);
});
JavaScript

ここでの深掘りポイントは、

  • 入力のたびに即 fetch せず、300ms 待ってから呼ぶ(簡易デバウンス)
  • サジェストは「ローディング状態」とは別枠で扱う(メイン検索とは独立)
  • それでも中身は結局 requestDatamuse を使っている

という「同じ fetch の型を、用途を変えて何度も使っている」感覚です。


通信失敗時の分岐を「用途ごと」に考える

メイン検索とサジェストでメッセージを変える

メイン検索が失敗したときは、
ユーザーにちゃんと伝える必要があります。

} catch (error) {
  const label = getModeShortLabel(mode);
  statusDiv.textContent = `${label}の取得中にエラーが発生しました:${error.message}`;
}
JavaScript

一方、サジェストは「おまけ機能」に近いので、
失敗しても画面を赤くする必要はありません。

} catch (error) {
  console.error("サジェスト取得中にエラー", error);
  suggestDiv.textContent = "";
}
JavaScript

ここでのポイントは、

  • どのエラーも「同じ重さ」で扱わなくていい
  • ユーザー体験にとって重要な処理ほど、丁寧にメッセージを出す

という「エラーハンドリングの優先度付け」です。


3日目の全体像(重要部分のコードまとめ)

const wordInput = document.getElementById("wordInput");
const searchButton = document.getElementById("searchButton");
const statusDiv = document.getElementById("status");
const resultDiv = document.getElementById("result");
const favoritesDiv = document.getElementById("favorites");
const suggestDiv = document.getElementById("suggest");

let isLoading = false;
const favorites = [];
let suggestTimeoutId = null;

function getSelectedMode() {
  const radios = document.querySelectorAll('input[name="mode"]');
  for (const r of radios) {
    if (r.checked) return r.value;
  }
  return "ml";
}

function getModeLabel(mode) {
  if (mode === "ml") return "類義語(意味が近い単語)";
  if (mode === "rel_trg") return "連想語(その単語から連想される単語)";
  if (mode === "rel_rhy") return "韻を踏む単語";
  return "関連する単語";
}

function getModeShortLabel(mode) {
  if (mode === "ml") return "類義語";
  if (mode === "rel_trg") return "連想語";
  if (mode === "rel_rhy") return "韻を踏む単語";
  return "関連語";
}

async function requestDatamuse(params) {
  const baseUrl = "https://api.datamuse.com/words";
  const url = `${baseUrl}?${params.toString()}`;

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`HTTPエラー(${response.status})`);
  }

  const data = await response.json();

  if (!Array.isArray(data)) {
    throw new Error("予期しないレスポンス形式です。");
  }

  return data;
}

function buildSearchParams(word, mode) {
  const params = new URLSearchParams();

  if (mode === "ml") {
    params.set("ml", word);
  } else if (mode === "rel_trg") {
    params.set("rel_trg", word);
  } else if (mode === "rel_rhy") {
    params.set("rel_rhy", word);
  } else {
    params.set("ml", word);
  }

  params.set("max", "20");

  return params;
}

function startLoading(message) {
  isLoading = true;
  statusDiv.textContent = message || "取得中です…";
  searchButton.disabled = true;
}

function endLoading() {
  isLoading = false;
  searchButton.disabled = false;
}

function addFavorite(word) {
  if (favorites.includes(word)) {
    statusDiv.textContent = "すでにお気に入りに追加されています。";
    return;
  }
  favorites.push(word);
  statusDiv.textContent = `「${word}」をお気に入りに追加しました。`;
  renderFavorites();
}

function renderFavorites() {
  if (favorites.length === 0) {
    favoritesDiv.textContent = "お気に入り単語はまだありません。";
    return;
  }

  let html = "<h3>お気に入り単語</h3>";
  favorites.forEach((word) => {
    html += `<p>★ ${word}</p>`;
  });

  favoritesDiv.innerHTML = html;
}

function renderWords(data, mode) {
  const label = getModeLabel(mode);
  let html = `<h3>${label}</h3>`;

  data.forEach((item) => {
    const word = item.word;
    const score = item.score;
    html += `
      <div class="word-item">
        <span>${word}(スコア: ${score})</span>
        <button class="fav-button" data-word="${word}">★ お気に入り</button>
      </div>
    `;
  });

  resultDiv.innerHTML = html;

  const buttons = resultDiv.querySelectorAll(".fav-button");
  buttons.forEach((btn) => {
    btn.addEventListener("click", () => {
      const word = btn.dataset.word;
      addFavorite(word);
    });
  });
}

async function fetchWordsForMode(word, mode) {
  if (isLoading) return;

  startLoading("単語を取得中です…");
  resultDiv.textContent = "";

  try {
    const params = buildSearchParams(word, mode);
    const data = await requestDatamuse(params);

    if (data.length === 0) {
      const label = getModeShortLabel(mode);
      statusDiv.textContent = `${label}が見つかりませんでした。`;
      resultDiv.textContent = "";
      return;
    }

    const sorted = [...data].sort((a, b) => (b.score || 0) - (a.score || 0));

    const label = getModeShortLabel(mode);
    statusDiv.textContent = `${label}の取得に成功しました。`;
    renderWords(sorted, mode);

  } catch (error) {
    const label = getModeShortLabel(mode);
    statusDiv.textContent = `${label}の取得中にエラーが発生しました:${error.message}`;
    console.error(error);

  } finally {
    endLoading();
  }
}

function renderSuggestions(data) {
  let html = "<h4>候補:</h4>";

  data.forEach((item) => {
    const word = item.word;
    html += `<button class="suggest-item" data-word="${word}">${word}</button>`;
  });

  suggestDiv.innerHTML = html;

  const buttons = suggestDiv.querySelectorAll(".suggest-item");
  buttons.forEach((btn) => {
    btn.addEventListener("click", () => {
      const word = btn.dataset.word;
      wordInput.value = word;
      suggestDiv.textContent = "";
    });
  });
}

async function fetchSuggestions(prefix) {
  if (!prefix) {
    suggestDiv.textContent = "";
    return;
  }

  const params = new URLSearchParams();
  params.set("sp", prefix + "*");
  params.set("max", "5");

  try {
    const data = await requestDatamuse(params);

    if (data.length === 0) {
      suggestDiv.textContent = "";
      return;
    }

    renderSuggestions(data);

  } catch (error) {
    console.error("サジェスト取得中にエラー", error);
    suggestDiv.textContent = "";
  }
}

searchButton.addEventListener("click", () => {
  const word = wordInput.value.trim();
  const mode = getSelectedMode();

  if (!word) {
    statusDiv.textContent = "単語を入力してください。";
    resultDiv.textContent = "";
    return;
  }

  fetchWordsForMode(word, mode);
});

wordInput.addEventListener("input", () => {
  const value = wordInput.value.trim();

  if (suggestTimeoutId) {
    clearTimeout(suggestTimeoutId);
  }

  suggestTimeoutId = setTimeout(() => {
    fetchSuggestions(value);
  }, 300);
});
JavaScript

今日いちばん深く理解してほしいこと

3日目の本質は、

  • fetch・async/await・エラーハンドリングの「型」はもう変えない
  • Datamuse へのリクエストを requestDatamuse にまとめることで、用途を増やしても迷子にならない
  • 「お気に入り」「サジェスト」などの機能は、全部この型の上に乗っているだけ

という感覚です。

あなたはもう、
「API を叩ける人」ではなく、「API を前提に機能を設計できる人」になりつつあります。

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