20210225のReactに関する記事は5件です。

Spotify API + Next.jsで作る選曲支援Webアプリ

概要

Spotify APIの楽曲レコメンド機能を使って、DJ Mix用のプレイリストを作成できるWebアプリケーションを開発しました。まずはそのアプリケーションの内容をご紹介します。

実装はTypeScript + Next.jsで行いVercelにデプロイする形にしたところ、非常に高い開発体験が得られました。後半はSpotify APIの使い方を含めて、Next.jsでの実装やVercel上での設定について書いていきます。

できたもの

AUTOMISCE - Automate Your Mix with Spotify API

top

使い方

まず"Sign in with Spotify"ボタンでSpotifyにログインします。次に右側の検索欄で最初の曲を選んでプレイリストに追加します。するとその曲と似たテンポでかつテンション感が少し上の曲が"Upper Tracks"欄に、少し下の曲が"Downer Tracks"欄に表示されます。

recommend

そこから次々と曲を選んでいくとプレイリストができるので、あとは名前を付けて保存すればSpotifyのアプリなどから聴くことができます。またWebアプリ内で楽曲を再生することもできます。1

想定しているのはDJがミックス用の曲を選曲しているシーンです。曲同士をシームレスにつなぎ合わせるために、テンポとdanceability(踊りやすさ)が似た曲をレコメンドしています。またミックスの中でダンスフロアを盛り上げたり落ち着けたりするのもDJの仕事なので、選んだ曲よりテンション感が少し高いものと低いものを分けてレコメンドしました。

プロジェクト構成

  • 言語: TypeScript
  • フレームワーク: Next.js
  • インフラ: Vercel (+ AWS Route 53)

リポジトリ: yuki-oshima-revenant/spotify-mix-automation

型が付いていないと禁断症状に陥るのでTypeScriptにしました。またフレームワークは最近業務でも使い始めたNext.jsを採用しています。今回のようにログイン処理などでちょっとしたAPI呼び出し用のバックエンドが必要な場合、Next.jsのAPI Routeはベストプラクティスに近い便利さがあると感じました。

DBなどが必要ない純粋なフロントエンドのみの構成なのでインフラはVercelで完結します。GitHubにプッシュしておいてVercel画面上で連携させるだけでデプロイ/ビルド/配信までが簡単に終わるのでとてつもなく良い開発体験が得られました。

ただし、ドメインをAWS Route53で取得していたのでDNSだけRoute53が関わりました。VercelとRoute53の連携も非常に簡単に行えました。

実装2

Spotifyへのログイン

Spotify APIを利用する際は、まずSpotify for Developersにサインインします。その上でアプリケーションの設定を行います。こちらのヘルプ通りに進めました。

アプリケーションが作成できたら、ダッシュボードからそのアプリを選択してEDIT SETTINGボタンから各種設定を行います。認証後にリダイレクトされてくるURIをあらかじめ設定しておく必要があります。ローカルでの開発段階ではhttp://localhost:3000/api/auth/authorizeのようにlocalhostを設定しておきましょう。実際のドメインで動作させる際はhttps://automisce.unronritaro.net/api/auth/authorizeといったURIを追加します。

また同じくアプリケーションの詳細画面に表示されるClient IDClient Secretが認証処理に必要になります。(Client SecretSHOW CLIENT SECRETをクリックすると表示)

ユーザー認証は以下の手順で行います。

  1. ユーザーをアプリケーションからhttps://accounts.spotify.com/authorizeにリダイレクトさせる
  2. ユーザーが認証画面で権限を許可する
  3. 設定したリダイレクトURIにcodeというパラメータ付きでリダイレクトされる
  4. code、およびClient IDClient Secretを付けてhttps://accounts.spotify.com/api/tokenにリクエストを送る
  5. アクセストークンが得られる

そのあとはアクセストークンを使って各APIからデータを取得することができます。基本的にAuthorization Guide通りの実装です。

AuthG_AuthoriztionCode

今回のアプリケーションではSign in with Spotifyボタンを押すとリダイレクトするようにしました。その際にClient IDをパラメータにつけて送る必要があります。この値を環境変数から読み込むことにしました。

Next.jsは特に設定せずとも.env.localという名前の付いたファイルにCLIENT_ID = 'xxxxx'と書いて、プロジェクトルートディレクトリに置けばそれらを環境変数として読み込んでくれます。ただしそれはgetStaticPropsなどのdata fetching methodsやAPI Routeでのみ有効です。

By default all environment variables loaded through .env.local are only available in the Node.js environment, meaning they won't be exposed to the browser.

https://nextjs.org/docs/basic-features/environment-variables

今回はgetStaticPropsのなかでリダイレクト用のURLを作ってそれをコンポーネントに渡すようにしました。

// pages/index.tsx

const Index = ({ loginPath }: InferGetStaticPropsType<typeof getStaticProps>) => {

    const login = useCallback(() => {
        window.location.href = loginPath;
    }, [loginPath]);

    ...

    return (
        <button onClick={login}>
            Sign in with Spotify
        </button>
    )
});

export const getStaticProps: GetStaticProps = async () => {
    // https://accounts.spotify.com/authorizeへのリクエストパラメータに必要な項目を設定
    const scopes = ['streaming', 'user-read-email', 'user-read-private', 'playlist-modify-public', 'playlist-modify-private'];
    const params = new URLSearchParams();
    params.append('client_id', process.env.CLIENT_ID || '');
    params.append('response_type', 'code');
    params.append('redirect_uri', process.env.RETURN_TO || '');
    params.append('scope', scopes.join(' '));
    params.append('state', 'state');
    return {
        props: { loginPath: `https://accounts.spotify.com/authorize?${params.toString()}` }
    }
};

export default Index;

このリダイレクト先ページでユーザーが権限を許可すると、redirect_uriに指定したURL(http://localhost:3000/api/auth/authorizeなど)に?code=xxxxxxx&state=stateというパラメータが付いたGETリクエストが行われます。

これをAPI Routeで受け取って、tokenの取得処理を行いましょう。

// pages/api/auth/authorize.ts

type SpotifyAuthApiResponse = {
    access_token: string,
    token_type: string,
    scope: string,
    expires_in: number,
    refresh_token: string
}

const authorize = async (req, res) => {
    const { code, state } = req.query;

    const params = new URLSearchParams();
    params.append('grant_type', 'authorization_code');
    params.append('code', code as string);
    params.append('redirect_uri', process.env.RETURN_TO as string);

    const response = await axios.post<SpotifyAuthApiResponse>(
        'https://accounts.spotify.com/api/token',
        params,
        {
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': `Basic ${Buffer.from(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`, 'utf-8').toString('base64')}`
            }
        }
    );

    req.session.set('user', {
        accessToken: response.data.access_token,
    });

    res.status(200).redirect('/');
};

export default withSession(authorize);

URLパラメータからcodeを取り出して、https://accounts.spotify.com/api/tokenにリクエストする際のパラメータに入れます。またトークン取得用のAPIはAuthorizationヘッダーにClient IDClient Secretから作成した文字列をbase64でエンコードして送る必要があります。

ここでprocess.env.CLIENT_SECRETなどはNext.jsのAPI Route、つまりサーバ側でのみアクセス可能です。API Routeはこうしてフロントエンド側に露出したくないコードを単一のプロジェクト内に記述する際に役立ちます。言い換えると、API Routeによってアプリケーションの一部の処理をユーザーから隠蔽することができるわけです。もしこれがなければ、Spotify APIのヘルプにあるようにExpressなどでwebサーバを立ち上げる必要があるでしょう。Next.jsのAPI Routeによってその手間が不要になっています。

こうして得たSpotify APIのアクセストークンはセッション(Cookie)に保存しておきます。Next.jsでセッションを使うライブラリはいくつかありますが、今回はシンプルにCookieでセッションを貼ることができればいいのでnext-iron-sessionを利用しました。

最後にトップページにリダイレクトさせれば認証処理は完了です。

Spotify APIの利用

あとはアクセストークンを使ってSpotify APIにアクセスしていきます。たとえば楽曲のレコメンドを行うAPI Routeは以下のようにSpotify APIを呼び出しています。3

// pages/api/track/recommend.ts
type RecommendType = 'upper' | 'downer';

type AudioFeature = {
    danceability: number,
    energy: number,
    id: string,
    instrumentalness: number,
    key: number,
    liveness: number,
    loudness: number,
    mode: number,
    tempo: number,
    valence: number,
    track_href: string
};

type SpotifyRecommendApiResponse = {
    tracks: TrackItem[]
};

type TrackItem = {
    album: { href: string, name: string, images: { url: string, height: number }[] },
    artists: { href: string, name: string }[],
    href: string,
    id: string,
    name: string,
    uri: string,
    duration_ms:number,
};

type SpotifyFeaturesApiResponse = {
    audio_features: AudioFeature[]
};

const getRecommendTracks = async (audioFeature: AudioFeature, accessToken: string, type: RecommendType) => {
    // レコメンド対象の曲と似たdanceabilityの曲が対象
    const minDanceability = audioFeature.danceability * 0.8;
    const maxDanceability = audioFeature.danceability * 1.2;

    // レコメンドのタイプが'upper'なら元の曲からenergyの少し高い曲が、'downer'なら少し低い曲が対象
    const minEnergy = type === 'upper' ? audioFeature.energy : audioFeature.energy * 0.8;
    const maxEnergy = type === 'upper' ? audioFeature.energy * 1.2 : audioFeature.energy;

    const recommendationsParams = new URLSearchParams();
    recommendationsParams.set('seed_tracks', audioFeature.id);
    // DJが曲同士をつなぎ合わせやすいように似たテンポの曲が対象
    recommendationsParams.set('min_tempo', (audioFeature.tempo * 0.9).toString());
    recommendationsParams.set('max_tempo', (audioFeature.tempo * 1.1).toString());
    recommendationsParams.set('min_danceability', (minDanceability).toString());
    recommendationsParams.set('max_danceability', (maxDanceability).toString());
    recommendationsParams.set('min_energy', (minEnergy).toString());
    recommendationsParams.set('max_energy', (maxEnergy).toString());


    const recommendationsResponse = await axios.get<SpotifyRecommendApiResponse>(
        `https://api.spotify.com/v1/recommendations?${recommendationsParams.toString()}`,
        {
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': `Bearer ${accessToken}`
            }
        }
    );

    // recommendations APIで取得した楽曲の特徴情報を取得
    const featuresParams = new URLSearchParams();
    featuresParams.append('ids', recommendationsResponse.data.tracks.map((item => item.id)).join(','));
    const featuresResponse = await axios.get<SpotifyFeaturesApiResponse>(
        `https://api.spotify.com/v1/audio-features?${featuresParams.toString()}`,
        {
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': `Bearer ${accessToken}`
            }
        }
    );
    const audioFeatures = featuresResponse.data.audio_features;

    return recommendationsResponse.data.tracks.map((item) => {
        const targetItemFeature = audioFeatures.find((feature) => (feature.id === item.id));
        return { ...item, audioFeatures: targetItemFeature };
    });
};
const recommendedTracks = async (req, res) => {
        // このAPIへのリクエストからレコメンドする元の曲の特徴情報を受け取る
        const audioFeature = req.body.track.audioFeatures;
        // セッションからアクセストークンを取り出す
        const accessToken = req.session.get('user').accessToken;

        const [upperTracks, downerTracks] = await Promise.all(
            [
                getRecommendTracks(audioFeature, accessToken, 'upper'),
                getRecommendTracks(audioFeature, accessToken, 'downer')
            ]
        );
        res.status(200)
        res.json({
            upperTracks,
            downerTracks
        });
    }
};
export default withSession(recommendedTracks);

基本的に、Spotify APIを呼び出す際はAuthorizationヘッダーにBearer ${accessToken}というようにアクセストークンを含めて送ります。

このAPI Routeはユーザーが検索してプレイリストに追加した楽曲のメタ情報をもとにSpotify APIがレコメンドした曲を取得して返しています。レコメンド元となる曲と

  • danceabilityの値が近い
  • energyの値が元の曲より少し高いか低い
  • tempoの値が近い

ことを条件としてSpotify APIの楽曲レコメンドAPIを呼び出します。seed_tracksパラメータにレコメンド元の曲のidを入れておくだけでなく、こうしたパラメータを指定することで目的の曲に絞り込むことができるわけです。

Web Playback SDK

ブラウザ上でのSpotifyの楽曲再生にはWeb Playback SDKを利用しました。Reactでの実装はこちらのライブラリのソースコードが参考になります。use-spotify-web-playback-sdk

SDK自体はCDNから読み込む必要がありますが、型情報は@types/spotify-web-playback-sdkがあるのでnpmでインストールすればOKです。

npm install --save-dev @types/spotify-web-playback-sdk

自分のアプリケーションでは以下のように実装しました。

// pages/index.tsx

// ログイン情報を取得するAPI用のフック
// Web Playback SDKで使うため、セッションからアクセストークンを取得している
const { data: loginData, error: loginError, mutate: loginMutate } = useLoginApi();
// refにプレイヤーのインスタンスを格納しておく
const playerRef = useRef<Spotify.SpotifyPlayer | null>(null);
const [deviceId, setDeviceId] = useState<string>();

useEffect(() => {
    if (loginData && loginData.accessToken) {
        // window.onSpotifyWebPlaybackSDKReadyのコールバックを定義する
        // SDKが読み込まれたタイミングでこのコールバックが実行される
        window.onSpotifyWebPlaybackSDKReady = () => {
            const player = new Spotify.Player({
                name: 'AUTOMISCE Player',
                getOAuthToken: async (cb) => {
                    cb(loginData.accessToken as string);
                },
                volume: 0.5
            });
            player.addListener('ready', ({ device_id }) => {
            // ここで楽曲を再生する際に必要なdevice_idを取得してstateに格納しておく
                setDeviceId(device_id);
            });
            player.connect();
            playerRef.current = player;
        };
        if (!window.Spotify) {
            // Web Playback SDKを読み込む
            const scriptTag = document.createElement('script');
            scriptTag.src = 'https://sdk.scdn.co/spotify-player.js';
            document.head!.appendChild(scriptTag);
        }
    }
}, [loginData]);

...

あとはhttps://api.spotify.com/v1/me/player/playAPIにdevice_idと楽曲のuri(recommendations APIなどから取得可能)を送るとブラウザ上で再生が始まります。

一時停止などはSpotify.SpotifyPlayerインスタンスのtogglePlayメソッドを使いましょう。

レコメンドされて出てきた曲の再生ボタンは以下のようになっています。

// lib/component/TrackCard
// trackやsetState系は親コンポーネントから引数で取得

<AiFillPlayCircle
    className={styles.button}
    onClick={() => {
        // スマートフォンではSDKが動作しないのでopen.spotify.comのリンクを開く
        const userAgent = window.navigator.userAgent.toLowerCase();
        if (userAgent.indexOf('iphone') != -1 || userAgent.indexOf('ipad') != -1 || userAgent.indexOf('android') != -1) {
            window.open(`https://open.spotify.com/track/${track.id}`)
            return;
        }
        if (playingTrack?.id === track.id) {
            playerRef.current?.togglePlay();
        } else {
            playerRef.current?.pause();
            setPlayingTrack(track);
            try {
                // '/api/track/play'で'https://api.spotify.com/v1/me/player/play'にパラメータを送る
                axios.post('/api/track/play',
                    {
                        deviceId,
                        uris: [track.uri]
                    });
                setIsPlaying(true);
            } catch (e) {
                setPlayingTrack();
            }
        }
    }}
/>

曲のプレイヤーにあるシークバーは少し工夫が必要でした。こちらの記事を参考に実装しています。audioプレイヤーをシークバーで操作する - ゆったりWeb手帳

player

100ミリ秒ごと(適当)にSpotify.SpotifyPlayerインスタンスのgetCurrentStateから再生位置を取得して、stateに保持しておきます。その値に従ってcssスタイルのbackground-sizeを変更することで、背景色によって再生位置を表示することができるようになります。またシークバーの任意の場所をクリックされたときに、その位置の横幅全体から見た割合を計算してその位置に曲の再生位置を変更します。再生位置の変更はSpotify.SpotifyPlayerインスタンスのseekメソッドを使います。

// pages/index.tsx

    const [playingTrackPosition, setPlayingTrackPosition] = useState<number>(0);
    const seekbarRef = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        if (isPlaying) {
            const timeout = setInterval(async () => {
                const palyerState = await playerRef.current?.getCurrentState();
                setPlayingTrackPosition(palyerState?.position || 0);
            }, 100);
            return () => { clearInterval(timeout) };
        }
    }, [isPlaying]);

    return (
        ...
        <div
            className={styles.seekbar}
            ref={seekbarRef}
            style={{ backgroundSize: `${(playingTrackPosition / playingTrack.duration_ms) * 100}%` }}
            onClick={(e) => {
                const mouse = e.pageX;
                const rect = seekbarRef.current?.getBoundingClientRect();
                if (rect) {
                    const position = rect.left + window.pageXOffset;
                    const offset = mouse - position;
                    const width = rect?.right - rect?.left;
                    playerRef.current?.seek(Math.round(playingTrack.duration_ms * (offset / width)))
                }
            }}
        />
        ...
    );

デプロイ

Vercelへのデプロイ自体はログインしてGitHubのリポジトリを連携するだけで終わり非常に快適です。ここではドメインと環境変数の設定について書いていきます。

ドメイン

自分はドメイン(unronritaro.net)をAWS Route53で保有しているので、そのサブドメインをVercelにルーティングしようと思いました。その手順はこの記事の通りです。VercelでホスティングしているサイトにRoute53で取得したドメインをサブドメインとして設定する | DevelopersIO

Vercel上ではGitHubと連携したプロジェクトの「Settings」>「Domains」画面で以下のように設定します。automisce.unronritaro.netが追加したドメインです。

vercel_domain

Route 53側は以下のように設定しています。「ホストゾーン」からレコードを作成してレコードタイプはCNAME、ルーティング先はcname.vercel-dns.comを指定すればOKです。

route53_domain

Vercelは外部のドメインからのルーティングも簡単に設定できるよう設計されていると感じました。

環境変数

ローカルサーバで開発している間はClient IDClient Secretを.env.localファイルに書いて読み込んでいましたが、このファイルをGitHubのリポジトリに含めるわけにはいきません。これらの値はVercelの環境変数として設定します。

プロジェクトの「Settings」>「Environment Variables」画面から設定します。

vevrcel_env

  • typeは「Secret」を選択
  • nameはclient_idなど任意に設定
  • valueは「Create new Secret for…」を選択して表示されるモーダルに入力
  • 適用する環境は「Production」「Preview」が選ばれたデフォルトのままでOK

注意点として、画面上から設定した環境変数を適用するためにはもう一度ビルドする必要があります。「Deployments」画面から最新のデプロイのメニューを開き、「Redeploy」を選べば再ビルドされます。

まとめ

  • できたもの: AUTOMISCE - Automate Your Mix with Spotify API
  • Next.jsのAPI RouteはSpotify APIなど共通鍵を使うAPI呼び出しに使うとGood
  • Vercelでデプロイ、ドメイン/環境変数設定もストレスレス

  1. PCのブラウザで開いた場合のみ楽曲の再生が可能です。スマートフォンのブラウザでは後述するWeb Playback SDKが対応していないため、再生ボタンを押すとSpotifyのアプリが開く仕様にしました。 

  2. 本文中に示したコードは簡単のため一部省略しています。完全な実装はリポジトリのソースコードを参照してください。 

  3. Spotify APIの詳しい仕様: Get Recommendations / Get Audio Features for Several Tracks 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GraphQLの採用についての考察

はじめに

技術調査として前から気になっていたGraphQLを調査したので今後、新規プロジェクトにおいて採用できるかの考察をまとめておきます。
※ 今回は、REST APIと比較してGraphQLの利点と使いどろこをまとめています

検証環境

  • OS: macOS
  • python 3.9
  • Web Framework: FastAPI
  • Front: React.js & Material UI

GraphQLとは

GraphQL | A query language for your API
Facebookが開発元のオープンソースでREST APIと同じく特定エンドポイントにリクエストをしてレスポンスを受け取る仕組みにかわりはないのですが以下の点が違います

・Rest API
同一エンドポイントに対してデータ取得・登録・更新などの処理をGET・POST・PATCHなどのHTTPメソッドの使いわけによって行う。
また以下のユーザー情報に関してのリソースに対しては/userというパスになっているが、
これが、ECサイトの様にカートに対してのデータにアクセスする場合は /cartみたいにエンドポイントが増えていく。

curl -x GET http://localhost:3000/user/{id} (ユーザー情報取得)
curl -x POST -d '{ "uesr_name": "yamada", "first_anme": "Hanako", "last_name": "Yamada"} http://localhost:3000/user (ユーザー登録) 
curl -x PATCH -d '{ id:3, "last_name": "Tanaka"} http://localhost:3000/user (ユーザー情報更新) 

Screen Shot 2021-02-25 at 10.15.17.png
・GraphQL
全てのリソース(ユーザー情報・カートデータ)に対して同一エンドにリクエストを行う。
また、取得・登録・更新などの処理はオペレーション型と言われる、データ取得系のquery、データ更新系のmutation、サーバーサイドのイベントを受け取るsubscriptionをリクエストのボディに定義して行う

Endpoint: http://localhost:3000/graphql
# Full filed
query {
  getUser(id: 1) {
    id
    userName
    firstName
    lastName
  }
}

query {
  getUser(id: 1) {
    userName
    firstName
    lastName
  }
}


# Create & update
mutation createUser {
  createUser(newUser: {
    userName: "h-tanaka",
    firstName: "hanako",
    lastName: "Tanaka",
  })
  {
    id
    userName
        firstName
    lastName
  }
}

Screen Shot 2021-02-25 at 10.58.06.png

FastAPIで実際に試してみる

インストールからコーディングまでの手順は省略させて頂きます
上記に関してはこちらの記事を参考にして頂ければと思います。
安全なGraphQL API開発が出来るって本当? FastAPI+GraphQL+テストで安全なAPI開発

app.py
from fastapi import FastAPI
import graphene
from starlette.graphql import GraphQLApp
from .graph import schema
from .routers import user

app = FastAPI()

app.add_route('/graphql', GraphQLApp(schema=graphene.Schema(query=schema.Query, mutation=schema.Mutation)))

app.include_router(user.router)
schema.py
import graphene
from .serializers import UserInfoModel, UserModel, UserCreateModel

class Query(graphene.ObjectType):
    get_user = graphene.Field(graphene.List(UserInfoModel), id=graphene.NonNull(graphene.Int))

    @staticmethod
    def resolve_get_user(parent, info, id):
        return[
            UserModel(
                id=1,
                user_name='t-yamada',
                first_name='taro',
                last_name='yamada'
            )
        ]


class CreateUser(graphene.Mutation):
    class Arguments:
        new_user = UserCreateModel()

    Output = UserInfoModel

    @staticmethod
    def mutate(parent, info, new_user):
        print(new_user)

        return UserModel(
                id=1,
                user_name='k-yamada',
                first_name='kenji',
                last_name='yamada'
            )

class Mutation(graphene.ObjectType):
    create_user = CreateUser.Field()
serializers.py
from typing import List, Optional
from graphene_pydantic import PydanticInputObjectType, PydanticObjectType
from pydantic import BaseModel

class UserModel(BaseModel):
    id: Optional[int]
    user_name: str
    first_name: str
    last_name: str

class UserInfoModel(PydanticObjectType):
    class Meta:
        model = UserModel


class UserCreateModel(PydanticInputObjectType):
    class Meta:
        model = UserModel
        exclude_fields = ('id', )

試してみて

  1. 管理画面において複数グラフを表示する場合にRestだと複数エンドポイントにリクエストをしないといけないのがGraphQLだと一回で取得できそう
  2. Restだとレスポンスのフィールドが固定されている為、Reactなどで汎用的なテーブルコンポーネントを作成した場合にデータの間引きをする処理が必要になるが、GraphQLだと必要なフィールドのみを取得する事ができるのでフロントの実装が楽になりそう。
query {
  getUser(id: 1) {
    id
    userName
    firstName
    lastName
  }
}
// idを含めたくない場合のQuery
query {
  getUser(id: 1) {
    userName
    firstName
    lastName
  }
}
  • 汎用的なテーブルコンポーネント
table.tsx
import React from 'react'
import Table, { Size } from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import Button from '@material-ui/core/Button'
import EditIcon from '@material-ui/icons/Edit'
import styled from 'styled-components'
import { Rows, Row, RowValue, Headers } from '~/types'

type Props = {
  headers: Headers
  editable?: boolean
  size?: Size
  rows: Rows
  onClick?: (key?: number) => void
}

function mapRows(rows: Rows, headers: Headers): Rows {
  const columns = []
  for (const header of headers) {
    columns.push(header.accessor)
  }
  const newRows = rows.map((row) => {
    const newHash: Row = { key: null, value: null }
    const newValue: RowValue = {}
    for (const key of columns) {
      newValue[key] = row.value[key]
    }
    newHash['value'] = newValue
    newHash['key'] = row.key
    return newHash
  })
  return newRows
}

const DataGridView: React.FC<Props> = (props) => {
  const size = props.size || 'small'
  const isEditable = props.editable || false
  const newRows = mapRows(props.rows, props.headers)
  return (
    <React.Fragment>
      <DataTable stickyHeader aria-label="sticky table" size={size}>
        <TableHead>
          <TableRow>
            {props.headers.map((value) => (
              <TableCell key={value.accessor}>{value.header}</TableCell>
            ))}
            {isEditable && <TableCell />}
          </TableRow>
        </TableHead>
        <TableBody>
          {newRows.map((row) => (
            <TableRow key={row.key}>
              {Object.keys(row.value).map((key) => (
                <TableCell key={key}>{row.value[key]}</TableCell>
              ))}
              <TableCell>
                {isEditable && (
                  <Button
                    size="small"
                    color="primary"
                    onClick={() => props.onClick(row.key)}
                  >
                    <EditIcon fontSize="small" />
                  </Button>
                )}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </DataTable>
    </React.Fragment>
  )
}

const DataTable = styled(Table)`
  min-width: 750px;
  .MuiTableCell-stickyHeader {
    background: #f3f3f3;
  }
`

export default DataGridView

考察

まだまだQuery(取得)系以外のものに関しては調査等が必要だが、リアクトなどでフロントを作成する場合はSWRなどがGraphQLをサポートしてるのもあり、Rest APIと同時運用で特定ページのみGraphQLを採用し、徐々に移行しながら知見を深めていくのも良いかと感じました。
適用するサービスによりますが、社内システム等なら比較的採用しやすいのではと。

今回は、比較的ライトに書かせて頂きましたが。今後また知見が深まった段階で記事を更新していければと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React】TypeScript を使用しているのに PropTypes での型定義は必要か?

結論

TypeScript での props の型定義も、PropTypes での型定義も、どちらも設定した方が吉です。

Typescript と PropTypes の違い

  • TypeScript:コードを書いているときに役立ちます。
    • TypeScriptの場合は、エディタでコードを書いているときに型チェックしてメッセージを出してくれますが、 buildした後は型チェックをしてくれません(例.DevTools の Console でのエラーメッセージの出力は無し)。
  • PropTypes:たとえばAPIからJSONをロードする場合に役立ちます。
    • PropTypesの場合は、buildした後にも DevTools の Console 等でエラーメッセージを出力してくれます。

検証してみた

※サンプルコードの内容は適度に省略してあります。

header.tsx
import React from "react"
import PropTypes from "prop-types"
.
.
type Props = {
  siteTitle: string
} & typeof defaultProps;

const defaultProps = {
  siteTitle: `siteTitleが渡されなかった場合のデフォルトのタイトル`,
}
.
,
const Header: React.FC<Props> = ({ siteTitle }) => (
  <h1>{siteTitle}</h1>
)
.
.
// この propTypes を設定したりコメントアウトしたりして検証してみます。
Header.propTypes = {
  siteTitle: PropTypes.string.isRequired,
}
layout.tsx
import React from "react"
import PropTypes from "prop-types"
import Header from "./header"
.
.
// このsiteTitleは、本来ならstring型の値を渡さなければいけませんが、試しにnumber型の値を渡して意図的にエラーを吐いてくれるようにしてみます。
const Layout: React.FC = ({ children }) => (
  <Header siteTitle={155} />
)

PropTypes を定義した場合は、DevTools の Console で下記エラーメッセージを出力して怒ってくれますが、

index.js:2177 Warning: Failed prop type: Invalid prop siteTitle of type number supplied to Header, expected string.
in Header (at layout.tsx:28)
in Layout (at pages/index.tsx:9)
.
.

スクリーンショット 2021-02-25 1.52.05.png
スクリーンショット 2021-02-25 4.46.23.png

PropTypesを設定していない場合は、何も怒ってくれません。

スクリーンショット 2021-02-25 4.47.56.png

スクリーンショット 2021-02-25 1.53.05.png

よって、TypeScriptの使用の有無に関わらず、PropTypesも設定しておいた方が無難そうです。
(二重チェックになるので不必要である、と言うことは無さそうです。)

参考にさせていただいたURL

React+TypeScriptでPropTypesを使う

https://stackoverflow.com/questions/41746028/proptypes-in-a-typescript-react-application/54690878#54690878

https://github.com/Microsoft/TypeScript/issues/4833#issuecomment-282497313

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React: メモ化の前に考えるふたつの基本的な手法

Dan Abramov氏による記事「Before You memo()」が2月23日付で公開されました。描画を最適化するために、memouseMemoを使うことは有効です。けれど、その前に考えられる基本的な手法をふたつ紹介されています。記事に示されたコード例を、かいつまんでご説明してみます。

重たいコンポーネントが考えなしに放り込まれたアプリケーション

はじめにとりあげられるのが、重たいコンポーネントを考えなしに放り込んだつぎのコード例です。ExpensiveTreeが負荷の高いコンポーネントを意味します。

import { useState } from 'react';

export default function App() {
    let [color, setColor] = useState('red');
    return (
        <div>
            <input value={color} onChange={(e) => setColor(e.target.value)} />
            <p style={{ color }}>Hello, world!</p>
            <ExpensiveTree />
        </div>
    );
}

コード例のコンポーネントExpensiveTreeは、performance.now()で意図的に負荷を高めています。前掲のコンポーネントAppは、状態が変わるたびに、この重たいコンポーネントも再描画しなければならないわけです。

function ExpensiveTree() {
    let now = performance.now();
    while (performance.now() - now < 100) {
        // Artificial delay -- do nothing for 100ms
    }
    return <p>I am a very slow component tree.</p>;
}

Dan Abramov氏は、この作例からはじまるステップごとに、コードをCodeSandboxに公開しています。

状態を下層に移す

そこで、この問題に対処するひとつめの手法です。状態とその操作を子コンポーネント(Form)に切り分けて、下層に移します。すると、親コンポーネントからは状態がなくなり、子コンポーネントだけが再描画すれば済むのです。

export default function App() {
    // let [color, setColor] = useState('red');
    return (
        <div>
            {/* <input value={color} onChange={(e) => setColor(e.target.value)} />
            <p style={{ color }}>Hello, world!</p> */}
            <Form />

        </div>
    );
}

function Form() {
    let [color, setColor] = useState('red');
    return (
        <>
            <input value={color} onChange={(e) => setColor(e.target.value)} />
            <p style={{ color }}>Hello, world!</p>
        </>
    );
}

こちらがCodeSandboxの作例です。

親に状態をもたせたいとき

問題は、親に状態をもたせたいときです。このような例として、つぎのコードが示されています(作例)。

export default function App() {
    let [color, setColor] = useState('red');
    return (
        // <div>
        <div style={{ color }}>
            <input value={color} onChange={(e) => setColor(e.target.value)} />
            {/* <p style={{ color }}>Hello, world!</p> */}
            <p>Hello, world!</p>
            <ExpensiveTree />
        </div>
    );
}

この場合に使えるふたつめの手法です。やはり、状態は親から子コンポーネント(ColorPicker)に切り出します。そして、負荷の高いコンポーネントや要素もその中に包んでしまうのです。

export default function App() {
    // let [color, setColor] = useState('red');
    return (
        // <div style={{ color }}>
        <ColorPicker>
            {/* <input value={color} onChange={(e) => setColor(e.target.value)} /> */}
            <p>Hello, world!</p>
            <ExpensiveTree />
        {/* </div> */}
        </ColorPicker>
    );
}

子コンポーネント(ColorPicker)の状態とその操作は、前の例と変わりません。注目いただきたいのは、コンポーネントが親からchildrenプロパティを受け取ることです。そして、このプロパティは、状態をもたない親が変更することはありません。その場合、子コンポーネントの中でもchildrenの再描画は行われないのです。

function ColorPicker({ children }) {
    let [color, setColor] = useState('red');
    return (
        <div style={{ color }}>
            <input value={color} onChange={(e) => setColor(e.target.value)} />
            {children}
        </div>
    );
}

これで、重たいコンポーネントの再描画は避けることができました。こちらが、CodeSandboxの作例です。変化するものとしないものを分けるという基本的な考え方が、このふたつの手法に通じているといえるでしょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Spring Boot】 海外Teck系YouTuberを真似てみた!ReactJS+SpringBoot Full Stack Application

1,はじめに

この記事は、海外Teck系YouTuberの動画を参考に、同じプロジェクトを作成してみたものになります!
簡単にですが、動画を通して学べた技術や知識をまとめました!

2,学んだこと

主に、

① Axios HTTP Libraryを導入して、ReactSpringBootを連携させる。
② Postmanを使ってRESTApiの動作を確認する。

その他にも、npmを使ってrouteraxiosのインストール方法も学べました!

ゴール:triangular_flag_on_post:が見えているので、モチベーションにもなって効率良く学習が進めれました!
真似るだけでも、様々な技術の習得に繋がったのでぜひ、ぜひトライしてみてください!

完成したものは、下記に載せています:rainbow:

3,参考動画

URL: https://youtube.com/playlist?list=PLGRDMO4rOGcNLnW1L2vgsExTBg-VPoZHr

4,Tools and Technologies

【フロントエンド】

:star: React
:star: Modern JavaScript(ES6)
:star: NodeJS and NPM
:star: VS Code IDE
:star: Create React App CLI
:star: BootStrap 4.5 and Axios HTTP Library

【バックエンド】

:star: Spring Boot 2+
:star: SpringData JPA(Hibernate)
:star: Maven 3.2+
:star: JDK 1.8
:star: Tomcat 8.5+
:star: MySQL Database

5,完成:tada:

約5時間ぐらいかけて完成することができました:star:
2021-02-24_23h58_37.gif

補足

Postmanを使うと、

ネットワーク経由で外部のサーバーにアクセスし、必要な情報を取得することができます!

URL: Postman
2021-02-25_00h30_34.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む