クモのようにコツコツと

フロントエンドエンジニア イイダリョウの技術ブログ。略称「クモコツ」

【React/Next.js】「ビートルズDB」を作った(ビートルズの楽曲を検索できるアプリ)

以前、Next.jsの中でFetch APIでいろいろなデータを読み込みました。これをベースにもっとたくさんのデータを読み込んで検索ができるアプリ「ビートルズDB」を作りました。今回、Next.jsの動的ページのルーティング、動的APIのルーティング、APIのデータ整形の設定を経験できました。ビートルズが結成から解散までにリリースした楽曲(1961〜1970年)のデータベースです。同時期にリリースしたバックバンド活動、ソロ活動の作品も掲載しています。それではいきましょう!

【目次】

※参考:前回記事
【React】Realtime Database、Firestore(&スプレッドシート)のデータをFetch APIで読み込む(Next.js環境) - クモのようにコツコツと

※参考:【React】ReactでWebアプリを作るシリーズまとめ qiita.com

作ったもの

トップページでは楽曲情報全370曲が一覧になっている。

カテゴリなどで絞り込むこともできる。

検索窓からフリーワード検索もできる。

楽曲名を押すと楽曲の詳細情報を見ることができる。 (詳細情報上のリンクから楽曲の絞り込みもできる)


作ったアプリがこちら

※参考:ビートルズDB

アプリの使い方

※参考:このアプリについて | ビートルズDB

ソースコード

※参考:GitHub - ryo-i/beatles-db

なぜこのアプリを作ったのか

ビートルズ好きとして、自分の中で曖昧になっていることをいつでも調べられるアプリを作りたかった

  • ビートルズって曲がたくさんあるけどどんな順番でリリースされたんだっけ?(シングル、アルバム、EP含め)
  • 曲によって作曲、ボーカル、演奏パートが異なるのを把握したい
  • 録音、ジャケット、映像など裏方の人間関係もわかるようにしたい
  • 並行して作られたバックバンド活動やソロ活動の楽曲も含めた情報を把握したい
  • それらのデータをいろんな切り口で絞り込みや検索をできるようにしたい

などなど。


内容は英語版Wikipediaのビートルズ関連のページから主に引用した。情報が足りない部分は日本語版Wikipedia、公式サイト、CDライナーノートなどから補った。

※参考:The Beatles discography - Wikipedia


なお、今後も各楽曲情報のページの一番下に備考(イイダリョウによる楽曲解説)をコツコツと追記していく予定ですー

このアプリで経験できたこと

このアプリで下記のことを経験できた。

  • API RoutesでAPIルーティング設定でレスポンスを返す
  • Dynamic Routesで動的ルーティングでページ生成
  • indexOfで絞り込みや検索設定
  • 区切り文字をsplit()で分割して配列作成

他にもいろいろなことを経験できた。やったことや参考になった記事など、詳細はこちら。

※参考:ビートルズデータの読み込み、表示を実装 · Issue #3 · ryo-i/beatles-db · GitHub

スプシからJSONデータを取得

前回のこちらの記事の方法でスプレッドシート上につくったデータをJSONデータにした。

※参考:【React】Realtime Database、Firestore(&スプレッドシート)のデータをFetch APIで読み込む(Next.js環境) - クモのようにコツコツと


スプレッドシートの元データ

Google Sheets APIでJSONデータを取得

データは「pages/api/data」フォルダの中に格納した


当初はFirebase上にJSONデータを置いてみたが、認証なしでread OKにするとセキュリティ警告メールが来るため、断念した。

※参考:ビートルズデータの読み込み、表示を実装 · Issue #3 · ryo-i/beatles-db · GitHub

API RoutesでAPIルーティング設定でレスポンスを返す

上記のJSONデータは懸念がいくつかあった

  • このままだと使いづらい形式で、ブラウザ側でページ上には不要なデータを読み込み、不要なデータを削除する必要がある
  • APIを細かいパスで指定してデータを取得するとデータ量は減るが通信回数が増え、また複数のレスポンスの内容を組み合わせる日強うがある

データ量やデータ通信回数を抑えるにはサーバ側でAPIの設定を行いたい。

※参考:Webプログラミングでは通信量を減らすためにAPIへのリクエストの最適化などが常に行われていますが、動画や画像と比べてたかだか数KBのテキストのやりとりを時間をかけて最適化する意味はあるのでしょうか? - Quora
※参考:Web API 設計入門


API Routes機能によってデータ整形をサーバ上で行い、最適な形にしてレスポンスで返すことができた!

※参考:Next.js の API Routes 機能で Web API を作成する|まくろぐ
※参考:API Routes: はじめに | Next.js

APIの設定ファイル名に[hoge].jsと角カッコをつけると動的なルーティングもできる!

※参考:API Routes: 動的APIルーティング | Next.js


API設定は「/pages/api/beatles」フォルダで行った

※参考:beatles-db/pages/api/beatles at main · ryo-i/beatles-db · GitHub

例えば楽曲情報のファイルは「/track/[track].ts 」に設定している。この[track]の部分は動的に変更できる。

※参考:beatles-db/[track].ts at main · ryo-i/beatles-db · GitHub

「beatles.json」のJSONデータをbeatlesDataで読み込み

import beatlesData from '../../data/beatles.json';

引数のトラックナンバーをもとにbeatlesDataから該当する楽曲の情報を取得する関数

// Get Track Obj Data
const getTrackObjData = (track) => {
  const keyArray = beatlesData.values[0];
  const valArray = beatlesData.values[track];
  const thisObj = {};

  if (keyArray.length === valArray.length) {
    for (var i = 0; i < keyArray.length; i++) {
      thisObj[keyArray[i]] = valArray[i];
    }
  }
  return thisObj;
};

API設定本体

// Response
export default (req, res) => {
  const {
      query: { track }
  } = req;

  // console.log('req.query', req.query);
  const trackObjData = getTrackObjData(track);
  // console.log('trackObjData', trackObjData);
  res.status(200).json(trackObjData);
}
  • リクエストreq:クエリqueryから情報トラックナンバーtrackを取得
  • 変数trackObjData:トラックナンバーtrackgetTrackObjData()の引数に入れてデータから楽曲情報を取得
  • レスポンスres:ステータスコードstatusの値200(成功)とJSONデータでtrackObjDataを返す

ここらへんの書き方は以前、Node.jsやExpressで経験した内容と似ていてわかりやすく感じた♪

※参考:Node.js / Expressを習得するためにやったことまとめ(随時更新) - Qiita

Dynamic Routesで動的ルーティングでページ生成

上記のAPIへのリクエストはページ上から行う。このページも動的に生成したい。(フリーワードの検索結果や370曲分の楽曲情報ページを作るのは現実的ではない。

Dynamic Routes機能によって動的なルーティング設定でページ生成ができた!

※参考:Routing: Dynamic Routes | Next.js

APIのルーティングと同様、ファイル名に[hoge].jsと角カッコをつけると動的なルーティングでページ生成ができる!


例えば楽曲情報のページは「/pages/track/[number].tsx」の場合

※参考:beatles-db/[number].tsx at main · ryo-i/beatles-db · GitHub

// Get TrackInfo
export async function getStaticProps({ params }) {
    const number = params.number;
    const res = await fetch(`https://beatles-db.vercel.app/api/beatles/track/${number}`);
    const trackInfo = await res.json();
    // console.log('number', number);
    // console.log('trackInfo', trackInfo);
    return { props: { trackInfo } };
}
  • 引数paramsでパスの[number]から楽曲ナンバーを取得
  • Fetch APIで動的ルーティングにnumberを入れて楽曲情報のレスポンスを取得
  • レスポンスの楽曲情報をprops. trackInfoとして取得

trackInfoidtrackはフックに入れてInnerTrackコンポーネントにコンテキストとして渡している

// Component
const Track = ({ trackInfo }) => {
    const [trackNumber, setTrackNumber] = useState(trackInfo.id);
    const [trackName, setTrackName] = useState(trackInfo.track);

    // 中略

    return (
        <>
        // 中略
        <main>
            <h1>楽曲情報</h1>
            <numberContext.Provider value={{trackNumber, setTrackNumber, trackName, setTrackName}} >
                <InnerTrack />
            </numberContext.Provider>
        </main>
        // 中略
        </>
    );
}

フックのコンテキスト設定
※参考:beatles-db/numberContext.ts at main · ryo-i/beatles-db · GitHub

import React, { createContext }  from 'react';

export const numberContext = createContext({} as {
    trackNumber: string;
    setTrackNumber: React.Dispatch<React.SetStateAction<string>>;
    trackName: string;
    setTrackName: React.Dispatch<React.SetStateAction<string>>;
});

参考になった記事
※参考:【TypeScript】useContextとuseStateを組み合わせて、子孫コンポーネントから直接先祖コンポーネントのstateを編集する - Qiita
※参考:React Hooks: 子コンポーネントから親の状態をレンダー時に変えたら叱られた ー Warning: Cannot update a component while rendering a different component - Qiita
※参考:コンテクスト – React

indexOfで絞り込みや検索設定

indexOfを使うことで楽曲一覧ページ上で様々な絞り込みや検索設定を実現できた。


InnerIndexコンポーネント

※参考:beatles-db/InnerIndex.tsx at main · ryo-i/beatles-db · GitHub

useEffect()でAPIへの通信をリクエスト。レスポンスからページ表示に必要な処理を実行

useEffect(() => {
    // 中略

    // fetch
    const url: string = isCategory ? '../api/beatles' + queryText : 'api/beatles' + queryText;
    // console.log('url', url);
    async function getTracksData (url) {
      try {
        const res = await fetch(url);
        const resJson = await res.json();
        const data = resJson;
        // console.log('data', data);
        setTracksData(data.trackList);
        setYearList(data.yearList);
        setFormatList(data.formatList);
        setPageInfo(data.pageInfo);
        setIsLoaded(true);
      } catch(error) {
        setError(error);
        console.log('err', error);
        setIsLoaded(true);
      }
    };

    // 中略
  }, [router, queryParam, categoryName]);

API側の処理

※参考:beatles-db/index.ts at main · ryo-i/beatles-db · GitHub

// Response
export default (req, res) => {
  let resultData = beatlesData.values;
  const query = req.query;

  // 中略

  // Get Filter Data
  if (query.year) {
    resultData = getFilterData(resultData, 'year', query.year, 'exact');
  // 中略(他の条件)
  }

  // 中略 (データ整形処理)

  const tracksData = {};
  tracksData['pageInfo'] = pageInfo;
  tracksData['yearList'] = yearsArray;
  tracksData['formatList'] = formatsArray;
  tracksData['trackList'] = tracksArray;
  res.status(200).json(tracksData);
}
  • まずresultDatabeatlesDataの全てのデータを取得
  • queryにリクエストのクエリ情報を取得
  • クエリの条件によってgetFilterData()関数でデータを絞りこむ
  • 絞り込んだデータを整形したtracksDataをレスポンスとして返す

getFilterData()関数による絞り込み

※参考:beatles-db/getFilterData.ts at main · ryo-i/beatles-db · GitHub

const getFilterData = (data, key, value, match) => {
  const isExact = match === 'exact';
  const isPartial = match === 'partial';
  const isSeach = match === 'search';

  const result = data.filter((item, index) => {
    if (isExact && item[keyNumbers[key]] === value) {
      return item;
    } else if (isPartial && item[keyNumbers[key]].indexOf(value) !== -1) {
      return item;
    } else if (isSeach) {
      for (let i = 0; i < item.length; i++) {
        const isMatch = item[i].toLowerCase().indexOf(value.toLowerCase()) !== -1;
        if (isMatch) {
          return item;
        }
      }
    }
  });
  • isExactは完全一致の処理。値valuekey列の値が===で一致したら配列resultに追加
  • isPartialは部分一致の処理。値valuekey列の値がindexOf-1以外であれば配列resultに追加
  • isSeachはフリーワード検索。値valueとデータ全体がindexOf-1以外であれば配列resultに追加
    toLowerCase()で値同士を小文字に変換して比較している)

の大文字小文字を区別しない文字列比較の参考

※参考:JavaScript での大文字小文字を区別しない文字列比較 | Delft スタック


フリーワード検索で前後の空白文字の削除は検索窓のコンポーネント側で行った

※参考:beatles-db/Header.tsx at main · ryo-i/beatles-db · GitHub

const getSearchValue = (searchValue) => {
    let thisValue = searchValue;
    thisValue = thisValue.replace(/^\s+|\s+$/g, '');
    setSearchValue(thisValue);
    return thisValue;
  }

replace()で正規表現を使って削除

※参考:入力された文字列に含まれる空白文字を削除する方法 - JavaScript TIPSふぁくとりー


あと、検索実行をinputタグでエンターでも実行できるようにした。

※参考:beatles-db/Header.tsx at main · ryo-i/beatles-db · GitHub

return (
    <form className="search"  onSubmit={(e) => e.preventDefault()}>
      <input
        type="text"
        value={searchValue}
        onChange={(e) => setSearchValue(e.target.value)}
        onKeyPress={(e) => {
          if (e.key == 'Enter') {
            e.preventDefault();
            doSearch();
          }
        }} />
      <button type="button" onClick={() => doSearch() }>検索</button>
    </form>
  );

指定方法はe.key == 'Enter'で簡単だった。スマホの「改行」でも動いた♪

※参考:ReactでInputフォームのEnterキーで処理を行う | freks blog

区切り文字をsplit()で分割して配列作成

テキストの中の区切り文字で配列に分割するモジュールを作った。

※参考:beatles-db/getDividedArray.ts at main · ryo-i/beatles-db · GitHub

const getDividedArray = (text) => {
    let resultArray = [];
    const delimiterSlash = ' / ';
    const isMultipleSlash = text.indexOf(delimiterSlash) !== -1;

    if (isMultipleSlash) {
      resultArray = text.split(delimiterSlash).map((item) => {
        return item;
      });
    } else {
      resultArray.push(text);
    }

    return resultArray;
  };

  export { getDividedArray };
  • 引数のtextの中に区切り文字' / 'があるかをチェック
  • もし区切り文字' / 'があればtextsplit()で区切り文字で分割し、配列に入れる
  • なければtextそのものを配列に入れる

※参考:String.prototype.split() - JavaScript | MDN

たとえばこういう連想配列があったとして

{
  source: "https://en.wikipedia.org/wiki/My_Bonnie_Lies_over_the_Ocean / https://en.wikipedia.org/wiki/My_Bonnie"
};

この値の中の/を区切り文字にして分割した配列を作成する。

[
  'https://en.wikipedia.org/wiki/My_Bonnie_Lies_over_the_Ocean',
  'https://en.wikipedia.org/wiki/My_Bonnie'
];

取得した配列をmap()でjsxタグに入れて表示する

    const sourceArray = getDividedArray(props.source);

    return (
      <ul>
        {sourceArray.map((data, index) =>
          <li key={index}><a href={data} target="_blank">{data}</a></li>
        )}
      </ul>
    );

また、こちらを参考に複数の区切り文字で多次元配列を作ることもできた!

※参考:独自の文字列を二次元配列にする - Qiita

つくったモジュール

※参考:beatles-db/getPeopleArray.ts at main · ryo-i/beatles-db · GitHub

その他

その他にも作っている過程でいろいろなエラーがあって、Reactの仮想DOM生成とその他の処理の実行タイミングによるエラーが多かったように思う。ここらへんのライフサイクルの理解を深めたい。

Styled-componentsでもレンダリングのたびにclass名が変わるエラーが起こった(ローカル環境のみ)。いろいろな方法を試したが、最終的にこちらのフラグをつけて実行する方法で解決できた。

※参考:Warning: Prop `className` did not match.Server: xxx の対処

その他、いろいろなつまづきの度にたくさんの記事に助けられました。

※参考:ビートルズデータの読み込み、表示を実装 · Issue #3 · ryo-i/beatles-db · GitHub

最後に

ということで今回は動的なAPI設定、動的なページ設定、API設定でデータ整形などを経験できました。これらの機能を使えば大量のデータを元にした大量のページのアプリを作れることができそうです。

今回のようにデータを読み込むだけであれば関してはNext.jsで完結できそうです。ここからさらにデータを書き込み、修正、削除までを行う場合は、やはりバックエンド側にちゃんとしたデータベースを作る必要はありそうです。その際にはセキュリティ的に認証設定を行う必要性も感じます。

ただ、一つのブラウザで完結させるだけであれば、localstrageを連携すれば簡易的な書き込み、修正、削除は実現できそうな気はします。

今後ですが、同じくデータ系で戦国時代の年表データベースを作ってみたい。あと、最近またクリエイティブ系のコーディングに興味が寄っていっているので、3D、アニメーション、音楽などを組み合わせた作品も作っていきたいです。Next.js環境でAPIで通信した内容を元に動的なコンテンツが作るところまで目指していきたい。

それではまた!


※参考:【React】ReactでWebアプリを作るシリーズまとめ qiita.com