17
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React v18 + TypeScript と ASP.NET Core (C#) でホロジュール Web アプリを作り直した

Last updated at Posted at 2023-02-25

はじめに

1年ほど前、React + ASP.NET Core を使用して、個人用のホロジュール Web アプリを作りました。

React と ASP.NET Core (C#) でホロジュール Web アプリをつくってみた

当時は React v17 でクラスコンポーネントを利用し、更新や状態を力技で制御した感じでしたので、あらためて React を学習し直し、React v18 + TypeScript と ASP.NET Core でこんな感じに作り直してみました。

img01.jpg

学習方法

Udemy で下記講座を受講しつつ、TypeScript や React のドキュメントやチュートリアルを利用して学習しました。

  1. 【JS】ガチで学びたい人のためのJavaScriptメカニズム

  2. モダンJavaScriptの基礎から始める挫折しないためのReact入門

  3. Reactに入門した人のためのもっとReactが楽しくなるステップアップコース完全版

  4. 今後のフロントエンド開発で必須知識となるReact v18の機能を丁寧に理解する

JavaScript をある程度頭に入れたうえで「JavaScriptメカニズム」で理解を深め、その後に「React」を段階的に学習したため、これまでよりも JavaScript と仲良くできたかなと思います。

ただし、React v17 で学習を進めてから v18 に切り替えているため、 React v18 については引き続き学習中です。

TypeScript については、上記ステップアップコース内で触れてから、サバイバルTypeScript を用いて補完しました。

開発環境

React v18

React v17 で学習を開始してから React v18 + React Router v6 へ切り替えたため、目立つところとして下記のような影響がありました。

  1. ReactDOM.render ではなく ReactDOM.createRoot を利用する。
  2. React Router v6 でのルーティングは Switch ではなく Routes で、<Route> の中身は element を指定する。
  3. useHistory ではなく useNavigate を利用する。
  4. strict を有効にすると、開発モードの場合に useEffect が2回実行される。
  5. v17 では関数コンポーネント自体の型定義として VFC を利用していたが、v18 では children が除外された React.FC を利用する。
  6. Suspense や useTransition や新しい Hooks が追加された。
useEffect(() => {
    // strict モード対策
    if (process.env.NODE_ENV === "development") {
        if (didMountRef.current) {
            didMountRef.current = false;
            return;
        }
    }
    getHolodules(dateString)
}, [getHolodules, dateString]);

副作用について確認したうえで、useRef を利用して、開発モードの場合に useEffect が2回実行されることを抑止してみました。

Chakra UI

UI コンポーネントとして Chakra UI を利用してみました。

Chakra UI は UI コンポーネントライブラリのひとつで、1からCSSを記述することなく、一貫性を持ったスタイルのUIを実現できるとのこと。

過去に利用した Bootstrap よりも理解しやすく使いやすいと感じました。その理由は使い方のシンプルさにあるのかなと思います。

import { Center, Spinner } from "@chakra-ui/react";

{loading ? (
    <Center h='100px' w="100%">
        <Spinner
            thickness='4px'
            speed='0.65s'
            emptyColor='gray.200'
            color='blue.500'
            size='xl' />
    </Center>
) : (
    ...
)}

例えばこのようにするだけで loading が Truthy の場合に、中央にスピナーを表示することができます。

アトミックデザイン

デザイン手法としてアトミックデザインを採用してみました。

デザインをパーツやコンポーネント単位で定義していく手法で、UIの要素を5段階に分類し、それらの要素を組み合わせてデザインしていきます。

  • Atoms(原子)

    • アトミックデザインにおいて UI の最小単位を表すもの。HTMLタグで表現できるものとほぼ同等のイメージ。
  • Molecules(分子)

    • 複数の Atoms を組み合わせたもの。複数のHTMLタグををひとまとまりにしたイメージ。
  • Organisms(有機体)

    • 複数の Atoms や Molecules を組み合わせたもの。HTMLタグやそのまとまりではなく、Web サイトの構成要素のイメージ。
  • Templates(テンプレート)

    • Atoms や Molecules や Organisms をまとめて Web サイトの基礎となるもの。Web サイトの基本的な機能やデザインとなる。
  • Pages(ページ)

    • Templates に具体的なコンテンツを挿入したもの。特定の Web サイト固有のページとなる。

これらの考え方をそのまま各コンポーネントの粒度や分け方を決める判断材料にできたので、個人的には取り入れやすかったのですが、チーム開発で利用する場合は方針や基準をじっくり決める必要がありそうです。

TypeScript

型に縛られるのが大好きなので TypeScript を利用してみました。

TypeScript はマイクロソフトが開発したオープンソースの言語で、JavaScriptに型の概念を取り入れているため、安全でバグが少なく開発が行えるとのこと。

コンポーネント指向のReactとの相性が良いということも利用してみた理由となります。

import { FC, memo } from "react";
import { Text } from "@chakra-ui/react";

import { DateHelper } from "../../utils/DateHelper";

// Props の型を指定
type Props = {
    date: Date;
};

// 関数コンポーネントの型を指定
export const StreamDate: FC<Props> = memo((props) => {
    const { date } = props;

    return (
        <Text fontSize='xl' as='b'>
            {DateHelper.formatDate(date,"/")}
        </Text>
    );
});

このように、React の Props や関数コンポーネントも型を指定しています。

Visual Studio 2022

フロントエンドに React、バックエンドに ASP.NET Core を利用するため、前回と同様に、開発環境として Visual Studio を利用しました。

Visual Studio のプロジェクトテンプレートとして、「React.js での ASP.NET Core」を利用しましたが、今回の開発で不要な設定や開発に必要な追加設定などがあるため、下記のように整理しました。

└─holoduler
    ├─Properties
    │  └─launchSettings.json   変更なし
    ├─ClientApp
    │  ├─public
    │  │  ├─favicon.ico        変更なし
    │  │  ├─index.html         変更なし(ファイルエンコードをBOMなしに変更)
    │  │  └─manifest.json      変更なし
    │  ├─src
    │  │  ├─components         後で利用する空ディレクトリ
    │  │  ├─App.tsx            変更あり ※1
    │  │  ├─index.css          変更あり ※2
    │  │  ├─index.tsx          変更あり ※3
    │  │  └─setupProxy.js      変更あり ※4
    │  ├─.env                  変更なし
    │  ├─.gitignore            変更なし
    │  ├─aspnetcore-https.js   変更なし
    │  ├─aspnetcore-react.js   変更なし
    │  ├─package.json          変更あり ※5
    │  └─tsconfig.json         新規作成 ※6
    ├─Controllers              後で利用する空ディレクトリ
    ├─.editorconfig            新規作成 ※7
    ├─.gitignore               変更なし
    ├─appsettings.json         変更なし
    └─Program.cs               変更あり ※8

変更点1

App.js を TypeScript JSXファイルの App.tsx に変更し、とりあえず最低限のコンポーネントにしました。

import React from 'react';

export default function App() {
    return (
        <p>ここにコンポーネントを組み込む</p>
    );
}

変更点2

custom.css を index.css に変更し、こちらも最低限のスタイルにしました。

.App {
    font-family: sans-serif;
    text-align: center;
}

変更点3

index.js を TypeScript JSXファイルの index.tsx に変更し、React v18 の記述にしました。

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from "./App";

const root = ReactDOM.createRoot(
    document.getElementById("root") as HTMLElement
);

root.render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
);

変更点4

setupProxy.js の React から API への開発時の橋渡しとなるプロキシのパスフィルタを変更しました。

const { createProxyMiddleware } = require('http-proxy-middleware');
const { env } = require('process');

const target = env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` :
    env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:11346';

const context = ["/api/"]; // ここを変更

module.exports = function (app) {
    const appProxy = createProxyMiddleware(context, {
        target: target,
        secure: false,
        headers: {
            Connection: 'Keep-Alive'
        }
    });

    app.use(appProxy);
};

変更点5

package.json の React 関連を v17 から v18 へ変更しました。
あとで利用する chakra-ui なども含めておきます。

{
  "private": true,
  "name": "holoduler",
  "version": "0.1.0",
  "description": "Web application for viewing Hololive stream information",
  "author": "kerobot",
  "license": "MIT",
  "dependencies": {
    "http-proxy-middleware": "^2.0.6",
    "merge": "^2.1.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.3.0",
    "react-scripts": "^5.0.1",
    "@chakra-ui/icons": "2.0.17",
    "@chakra-ui/react": "2.4.9",
    "@emotion/react": "11.10.5",
    "@emotion/styled": "11.10.5",
    "@types/react-router-dom": "5.3.3",
    "axios": "1.2.3",
    "framer-motion": "8.5.2"
  },
  "devDependencies": {
    "@types/react": "^18.0.27",
    "@types/react-dom": "^18.0.10",
    "ajv": "^8.11.0",
    "cross-env": "^7.0.3",
    "eslint": "^8.18.0",
    "eslint-config-react-app": "^7.0.1",
    "eslint-plugin-flowtype": "^8.0.3",
    "eslint-plugin-import": "^2.26.0",
    "eslint-plugin-jsx-a11y": "^6.6.0",
    "eslint-plugin-react": "^7.30.1",
    "nan": "^2.16.0",
    "typescript": "^4.7.4"
  },
  "overrides": {
    "autoprefixer": "10.4.5"
  },
  "resolutions": {
    "css-what": "^5.0.1",
    "nth-check": "^3.0.1"
  },
  "scripts": {
    "prestart": "node aspnetcore-https && node aspnetcore-react",
    "start": "rimraf ./build && react-scripts start",
    "build": "react-scripts build",
    "test": "cross-env CI=true react-scripts test --env=jsdom",
    "eject": "react-scripts eject",
    "lint": "eslint ./src/"
  },
  "eslintConfig": {
    "extends": [
      "react-app"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

変更点6

TypeScript を利用するため tsconfig.json を追加しました。

{
  "compilerOptions": {
    /* Language and Environment */
    "target": "es2018",
    "lib": [ "dom", "dom.iterable", "esnext" ], 
    "jsx": "react-jsx", 
    /* Modules */
    "module": "esnext", 
    "moduleResolution": "node", 
    "resolveJsonModule": true, 
    /* JavaScript Support */
    "allowJs": true, 
    /* Emit */
    "noEmit": true, 
    /* Interop Constraints */
    "isolatedModules": true, 
    "allowSyntheticDefaultImports": true, 
    "esModuleInterop": true, 
    "forceConsistentCasingInFileNames": true, 
    /* Type Checking */
    "strict": true, 
    "noFallthroughCasesInSwitch": true, 
    /* Completeness */
    "skipLibCheck": true 
  },
  "include": [ "src", "custom.d.ts" ]
}

変更点7

プロジェクトで新規作成したファイルエンコードを utf-8 とするように .editorconfig を追加しました。

[*]
charset = utf-8

変更点8

Program.cs で ASP.NET のビューを利用しないため、AddControllers() に変更し、規則ルーティング(Conventional routing)から、Web API 2 の属性ルーティング (Attribute routing)へ変更しました。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); // ここを変更
var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers(); // ここを変更
}); 
app.MapFallbackToFile("index.html"); ;
app.Run();

ここまでで開発を始めるための土台を準備できました。
念のため ClientApp ディレクトリの node_modules ディレクトリと package-lock.json ファイルを削除したうえで、npm install しておきます。

> node --version
v18.13.0
> npm --version
8.19.3
> npm install
npm WARN deprecated stable@0.1.8: Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility
npm WARN deprecated rollup-plugin-terser@7.0.2: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser
npm WARN deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
npm WARN deprecated w3c-hr-time@1.0.2: Use your platform's native performance.now() and performance.timeOrigin.
npm WARN deprecated svgo@1.3.2: This SVGO version is no longer supported. Upgrade to v2.x.x.

added 1577 packages, and audited 1578 packages in 2m

232 packages are looking for funding
  run `npm fund` for details

6 high severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

依存関係的に古いパッケージの警告などが表示されますが、今回はこのまま進めてしまいます。

Visual Studio 2022 でデバッグ実行すると下記画面が表示されます。

img02.jpg

バックエンド (ASP.NET Core Web API) の作成

以前開発した既存のホロジュール Web API を利用しますが、React から直接呼び出すのではなく、ASP.NET Core の Web API を経由して呼び出すようにします。

モデルクラスの作成

Web API で利用するモデルクラスをいくつか作成します。

using Newtonsoft.Json;

namespace holoduler.Modls
{
    // Web API に渡す認証情報を保持するクラス
    [JsonObject]
    public class Auth
    {
        [JsonProperty("username")]
        public string Username { get; private set; }

        [JsonProperty("password")]
        public string Password { get; private set; }

        public Auth(string username, string password)
        {
            this.Username = username;
            this.Password = password;
        }
    }
}
using Newtonsoft.Json;

namespace holoduler.Modls
{
    // Web API から返却されるアクセストークンを保持するクラス
    [JsonObject]
    public class Token
    {
        [JsonProperty("access_token")]
        public string? AccessToken { get; private set; }

        public Token()
        {
        }
    }
}

サービスクラスの作成

Web API で利用する設定情報を扱う、サービスコンテナへ登録するサービスクラスを作成します。

namespace holoduler.Services
{
    /// <summary>
    /// Web API で利用する設定情報を扱う、サービスコンテナへ登録するサービスのインターフェース
    /// </summary>
    public interface IDataService
    {
        string UserName { get; }
        string Password { get; }
        string Endpoint { get; }
    }
}

上記インターフェースを実装するクラスを作成します。

namespace holoduler.Services
{
    /// <summary>
    /// Web API で利用する設定情報を扱う、サービスコンテナへ登録するサービスクラス
    /// </summary>
    public class DataService : IDataService
    {
        private readonly string _userName;
        private readonly string _password;
        private readonly string _endpoint;

        public DataService(string userName, string password, string endpoint)
        {
            _userName = userName;
            _password = password;
            _endpoint = endpoint;
        }

        public string UserName => _userName;
        public string Password => _password;
        public string Endpoint => _endpoint;
    }
}

Program.cs の修正

作成したサービスクラスをサービスコンテナへ登録することで依存関係を注入します。

using holoduler.Services;

var builder = WebApplication.CreateBuilder(args);
// 環境変数から取得
var userName = Environment.GetEnvironmentVariable("API_USERNAME")!;
var password = Environment.GetEnvironmentVariable("API_PASSWORD")!;
var endpoint = Environment.GetEnvironmentVariable("API_ENDPOINT")!;
// サービスコンテナへ登録
builder.Services.AddTransient<IDataService>(_ => new DataService(userName, password, endpoint));
// コントローラーを使用
builder.Services.AddControllers();
var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// Web API 2 の属性ルーティング(Attribute routing)
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});
app.MapFallbackToFile("index.html"); ;
app.Run();

コントローラーの作成

既存のホロジュール Web API を利用するアクションメソッドを作成します。

using holoduler.Modls;
using holoduler.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using RestSharp;

namespace holoduler.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class HolodulesController : ControllerBase
    {
        private readonly IDataService _dataService;
        private readonly ILogger<HolodulesController> _logger;

        public HolodulesController(ILogger<HolodulesController> logger, IDataService dataService)
        {
            _logger = logger;
            _dataService = dataService;
        }

        [HttpGet("{date}")]
        public async Task<string?> Get(string date)
        {
            // トークンの取得
            _logger.LogInformation("request access token.");

            var auth = new Auth(_dataService.UserName, _dataService.Password);
            var json = JsonConvert.SerializeObject(auth);
            var client = new RestClient(_dataService.Endpoint);

            var postRequest = new RestRequest("/holoapi/login").AddJsonBody(json);
            var postResponse = await client.PostAsync(postRequest);
            if (!postResponse.IsSuccessful || postResponse.Content == null)
            {
                return "{ \"status\": \"login error\" }";
            }

            Token? token = JsonConvert.DeserializeObject<Token>(postResponse.Content);
            if (token == null)
            {
                return "{ \"status\": \"token error\" }";
            }

            // スケジュールの取得
            _logger.LogInformation("request holodules {date}.", date);

            var getRequest = new RestRequest($"/holoapi/holodules/{date}");
            getRequest.AddHeader("Content-Type", "application/json");
            getRequest.AddHeader("Authorization", $"Bearer {token.AccessToken}");
            var getResponse = await client.GetAsync(getRequest);
            if (!getResponse.IsSuccessful)
            {
                return "{ \"status\": \"holodules error\" }";
            }

            return getResponse.Content;
        }
    }
}

React からの呼び出しを簡単にするために、Web API の認証とスケジュール取得の処理をまとめてしまっています。
認証と処理をわけて、リフレッシュトークン込みで実装したいところです。

Web API の動作確認

Web API で利用する設定情報を環境変数に設定しておきます。
Visual Studio 2022 でデバッグ実行を行い、作成した Web API を呼び出してみます。

img03.jpg

ホロジュール Web API を呼び出し、JSON を取得できました。

フロントエンド (React + Chakra-UI) の作成

ここまでで作成したバックエンドの Web API を利用するフロントエンドを開発します。

theme

シンプルなテーマを作成しておきます。

import { extendTheme } from "@chakra-ui/react";

const theme = extendTheme({
    styles: {
        global: {
            body: {
                backgroundColor: "gray.100",
                color: "gray.800"
            }
        }
    }
});

export default theme;

types

配信者情報と配信予定情報の型を定義しておきます。

// 配信者
export type Member = {
    name: string;
    img: string;
    ch: string;
};
// 配信予定
export type Holodule = {
    key: string;
    code: string;
    video_id: string;
    datetime: string;
    name: string;
    title: string;
    url: string;
    description: string;
};

utils

日付関連や配信関連のユーティリティを作成しておきます。

export class DateHelper {
    // 日付加算
    static addDays(date: Date, days: number): Date {
        return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
    }

    // 日付フォーマット
    static formatDate(date: Date, separator: string = ""): string {
        const y = date.getFullYear();
        const m = ("00" + (date.getMonth() + 1)).slice(-2);
        const d = ("00" + date.getDate()).slice(-2);
        return `${y}${separator}${m}${separator}${d}`;
    }

    // 時間フォーマット
    static formatTime(date: Date, separator: string = ""): string {
        const h = date.getHours();
        const m = ("00" + date.getMinutes()).slice(-2);
        return `${h}${separator}${m}`;
    }

    // 文字列からDate型
    static stringToDateTime(stringDateTime: string): Date {
        const year = Number(stringDateTime.substr(0, 4));
        const month = Number(stringDateTime.substr(4, 2)) - 1;
        const day = Number(stringDateTime.substr(6, 2));
        const hour = Number(stringDateTime.substr(9, 2));
        const minute = Number(stringDateTime.substr(11, 2));
        const second = Number(stringDateTime.substr(13, 2));
        return new Date(year, month, day, hour, minute, second);
    }
}
import { Member } from "../types/member";

export class StreamerHelper {
    // アイコンURL
    static getImageUrl(name: string): string {
        return `${process.env.PUBLIC_URL}/img/${StreamerHelper.members[name].img}`;
    }

    // チャンネルURL
    static getChannelUrl(name: string): string {
        return `https://www.youtube.com/channel/${StreamerHelper.members[name].ch}`;
    }

    // サムネイルURL(HD画質固定)
    static getThumbnailUrl(video_id: string): string {
        return `http://img.youtube.com/vi/${video_id}/hqdefault.jpg`;
    }

    // 配信者定義
    static members: { [key: string]: Member } = {
        "ホロライブ": { name: "ホロライブ", img: "hololive.jpg", ch: "UCJFZiqLMntJufDCHc6bQixg" },

        "ときのそら": { name: "ときのそら", img: "tokino_sora.jpg", ch: "UCp6993wxpyDPHUpavwDFqgg"},
        "ロボ子さん": { name: "ロボ子さん", img: "robokosan.jpg", ch: "UCDqI2jOz0weumE8s7paEk6g" },
        "さくらみこ": { name: "さくらみこ", img: "sakura_miko.jpg", ch: "UC-hM6YJuNYVAmUWxeIr9FeA" },
        "星街すいせい": { name: "星街すいせい", img: "hoshimachi_suisei.jpg", ch: "UC5CwaMl1eIgY8h02uZw7u8A" },
        "AZKi": { name: "AZKi", img: "azki.jpg", ch: "UC0TXe_LYZ4scaW2XMyi5_kw" },

        "夜空メル": { name: "夜空メル", img: "yozora_mel.jpg", ch: "UCD8HOxPs4Xvsm8H0ZxXGiBw" },
        "アキ・ローゼンタール": { name: "アキ・ローゼンタール", img: "aki_rosenthal.jpg", ch: "UCFTLzh12_nrtzqBPsTCqenA" },
        "赤井はあと": { name: "赤井はあと", img: "haachama.jpg", ch: "UC1CfXB_kRs3C-zaeTG3oGyg" },
        "白上フブキ": { name: "白上フブキ", img: "shirakami_fubuki.jpg", ch: "UCdn5BQ06XqgXoAxIhbqw5Rg" },
        "夏色まつり": { name: "夏色まつり", img: "natsuiro_matsuri.jpg", ch: "UCQ0UDLQCjY0rmuxCDE38FGg" },

        "湊あくあ": { name: "湊あくあ", img: "minato_aqua.jpg", ch: "UC1opHUrw8rvnsadT-iGp7Cg" },
        "紫咲シオン": { name: "紫咲シオン", img: "murasaki_shion.jpg", ch: "UCXTpFs_3PqI41qX2d9tL2Rw" },
        "百鬼あやめ": { name: "百鬼あやめ", img: "nakiri_ayame.jpg", ch: "UC7fk0CB07ly8oSl0aqKkqFg" },
        "癒月ちょこ": { name: "癒月ちょこ", img: "yuzuki_choco.jpg", ch: "UC1suqwovbL1kzsoaZgFZLKg" },
        "大空スバル": { name: "大空スバル", img: "oozora_subaru.jpg", ch: "UCvzGlP9oQwU--Y0r9id_jnA" },

        "大神ミオ": { name: "大神ミオ", img: "ookami_mio.jpg", ch: "UCp-5t9SrOQwXMU7iIjQfARg" },
        "猫又おかゆ": { name: "猫又おかゆ", img: "nekomata_okayu.jpg", ch: "UCvaTdHTWBGv3MKj3KVqJVCw" },
        "戌神ころね": { name: "戌神ころね", img: "inugami_korone.jpg", ch: "UChAnqc_AY5_I3Px5dig3X1Q" },

        "兎田ぺこら": { name: "兎田ぺこら", img: "usada_pekora.jpg", ch: "UC1DCedRgGHBdm81E1llLhOQ" },
        "潤羽るしあ": { name: "潤羽るしあ", img: "uruha_rushia.jpg", ch: "UCl_gCybOJRIgOXw6Qb4qJzQ" },
        "不知火フレア": { name: "不知火フレア", img: "shiranui_flare.jpg", ch: "UCvInZx9h3jC2JzsIzoOebWg" },
        "白銀ノエル": { name: "白銀ノエル", img: "shirogane_noel.jpg", ch: "UCdyqAaZDKHXg4Ahi7VENThQ" },
        "宝鐘マリン": { name: "宝鐘マリン", img: "housyou_marine.jpg", ch: "UCCzUftO8KOVkV4wQG1vkUvg" },

        "天音かなた": { name: "天音かなた", img: "amane_kanata.jpg", ch: "UCZlDXzGoo7d44bwdNObFacg" },
        "桐生ココ": { name: "桐生ココ", img: "kiryu_coco.jpg", ch: "UCS9uQI-jC3DE0L4IpXyvr6w" },
        "角巻わため": { name: "角巻わため", img: "tsunomaki_watame.jpg", ch: "UCqm3BQLlJfvkTsX_hvm0UmA" },
        "常闇トワ": { name: "常闇トワ", img: "tokoyami_towa.jpg", ch: "UC1uv2Oq6kNxgATlCiez59hw" },
        "姫森ルーナ": { name: "姫森ルーナ", img: "himemori_luna.jpg", ch: "UCa9Y57gfeY0Zro_noHRVrnw" },

        "獅白ぼたん": { name: "獅白ぼたん", img: "shishiro_botan.jpg", ch: "UCUKD-uaobj9jiqB-VXt71mA" },
        "雪花ラミィ": { name: "雪花ラミィ", img: "yukihana_lamy.jpg", ch: "UCFKOVgVbGmX65RxO3EtH3iw" },
        "尾丸ポルカ": { name: "尾丸ポルカ", img: "omaru_polka.jpg", ch: "UCK9V2B22uJYu3N7eR_BT9QA" },
        "桃鈴ねね": { name: "桃鈴ねね", img: "momosuzu_nene.jpg", ch: "UCAWSyEs_Io8MtpY3m-zqILA" },
        "魔乃アロエ": { name: "魔乃アロエ", img: "mano_aloe.jpg", ch: "UCYq8Zfxf9iYTci5EGEGnkLw" },

        "ラプラス": { name: "ラプラス・ダークネス", img: "laplus_darknesss.jpg", ch: "UCENwRMx5Yh42zWpzURebzTw" },
        "鷹嶺ルイ": { name: "鷹嶺ルイ", img: "takane_lui.jpg", ch: "UCs9_O1tRPMQTHQ-N_L6FU2g" },
        "博衣こより": { name: "博衣こより", img: "hakui_koyori.jpg", ch: "UC6eWCld0KwmyHFbAqK3V-Rw" },
        "風真いろは": { name: "風真いろは", img: "kazama_iroha.jpg", ch: "UC_vMYWcDjmfdpH6r4TTn1MQ" },
        "沙花叉クロヱ": { name: "沙花叉クロヱ", img: "sakamata_chloe.jpg", ch: "UCIBY1ollUsauvVi4hW4cumw" }
    };
}

hooks

カスタムフックを作成しておきます。

import { useCallback } from "react";
import { useToast } from "@chakra-ui/react";

type Props = {
    title: string;
    status: "info" | "warning" | "success" | "error";
};

// メッセージを表示するカスタムフック
export const useMessage = () => {
    const toast = useToast();

    const showMessage = useCallback((props: Props) => {
        const { title, status } = props;
        toast({
            title,
            status,
            position: "top",
            duration: 2000,
            isClosable: true
        });
    }, [toast]);

    return { showMessage };
};

メッセージをトーストで表示しています。Chakra-UI はこのような UI を簡単に組み込めます。

import { useCallback, useState } from "react";
import axios from "axios";

import { Holodule } from "../types/api/holodule";
import { useMessage } from "./useMessage";

// Web API を呼んで配信予定を取得するカスタムフック
export const useHolodules = () => {
    const { showMessage } = useMessage();

    const [loading, setLoading] = useState(false);
    const [holodules, setHolodules] = useState<Array<Holodule>>([]);

    const getHolodules = useCallback((dateString: string) => {
        setLoading(true);
        axios
            .get<Array<Holodule>>(`api/Holodules/${dateString}`)
            .then((res) => setHolodules(res.data))
            .catch(() =>
                showMessage({ title: "スケジュールの取得に失敗しました", status: "error" })
            )
            .finally(() => setLoading(false));
    }, [showMessage]);

    return { getHolodules, loading, holodules };
};

axios はリクエストの記述がシンプルで使いやすいです。

conponents/atoms

ボタンや日時を表示するコンポーネントを作成します。

import { FC } from "react";
import { Button } from "@chakra-ui/react";
import { ArrowRightIcon } from "@chakra-ui/icons";

type Props = {
    onClick: () => void;
};

// Nextボタンコンポーネント
export const NextButton: FC<Props> = (props) => {
    const { onClick } = props;

    return (
        <Button
            rightIcon={<ArrowRightIcon />}
            colorScheme='blue'
            variant='solid'
            display="block"
            onClick={onClick}
        >Next</Button>
    );
};
import { FC } from "react";
import { Button } from "@chakra-ui/react";
import { ArrowLeftIcon } from "@chakra-ui/icons";

type Props = {
    onClick: () => void;
};

// Prevボタンコンポーネント
export const PrevButton: FC<Props> = (props) => {
    const { onClick } = props;

    return (
        <Button
            leftIcon={<ArrowLeftIcon />}
            colorScheme='blue'
            variant='solid'
            display="block"
            onClick={onClick}
        >Prev</Button>
    );
};
import { FC, memo } from "react";
import { Text } from "@chakra-ui/react";

import { DateHelper } from "../../utils/DateHelper";

type Props = {
    date: Date;
};

// 配信日付表示コンポーネント
export const StreamDate: FC<Props> = memo((props) => {
    const { date } = props;

    return (
        <Text fontSize='xl' as='b'>
            {DateHelper.formatDate(date,"/")}
        </Text>
    );
});
import { FC, memo } from "react";
import { Text } from "@chakra-ui/react";

import { DateHelper } from "../../utils/DateHelper";

type Props = {
    date: Date;
    today: Date;
};

// 配信時間表示コンポーネント
export const StreamTime: FC<Props> = memo((props) => {
    const { date, today } = props;

    const timeColor = date.getTime() < today.getTime() ? "gray.500" : "blue.500";

    return (
        <Text fontSize="md" as="b" noOfLines={1} color={timeColor}>
            {DateHelper.formatTime(date, ":")}
        </Text>
    );
});

conponents/molecules

Atoms を組み合わせたコンポーネントを作成します。

import { FC, memo } from "react";
import { ButtonGroup, Center } from "@chakra-ui/react";

import { PrevButton } from "../atoms/PrevButton";
import { NextButton } from "../atoms/NextButton";
import { StreamDate } from "../atoms/StreamDate";

type Props = {
    date: Date;
    onClickPrev: () => void;
    onClickNext: () => void;
};

// 日付移動と日付表示を行うコンポーネント
export const DateControl: FC<Props> = memo((props) => {
    const {
        date,
        onClickPrev,
        onClickNext
    } = props;

    return (
        <ButtonGroup gap='2'>
            <PrevButton onClick={onClickPrev} />
            <Center><StreamDate date={date} /></Center>
            <NextButton onClick={onClickNext} />
        </ButtonGroup>
    );
});

日付移動のクリックイベントを親コンポーネントから指定しています。

import { FC, memo } from "react";
import { Box, Heading } from "@chakra-ui/react";

type Props = {
    onClickToday: () => void;
};

// タイトルを表示するコンポーネント
export const TitleControl: FC<Props> = memo((props) => {
    const { onClickToday } = props;

    return (
        <Box as="a" _hover={{ cursor: "pointer" }} onClick={onClickToday}>
            <Heading size='lg'>HOLODULER</Heading>
        </Box>
    );
});

components/organisms

Atoms や Molecules を組み合わせたコンポーネントを作成します。

import { memo, useCallback, useState, FC } from "react";
import { Flex, Spacer } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";

import { TitleControl } from "../molecules/TitleControl";
import { DateControl } from "../molecules/DateControl";
import { DateHelper } from "../../utils/DateHelper";

// ヘッダーコンポーネント
export const Header: FC = memo(() => {
    const [dateState, setDateState] = useState(new Date());
    const navigate = useNavigate();

    // 指定した日付を state に保持してページ遷移
    const navigateDate = useCallback((date: Date) => {
        setDateState(date);
        navigate(`/${DateHelper.formatDate(date)}`);
    }, [navigate]);

    // 当日に移動
    const onClickToday = useCallback(() => {
        navigateDate(new Date());
    }, [navigateDate]);

    // 前日に移動
    const onClickPrev = useCallback(() => {
        navigateDate(DateHelper.addDays(dateState, -1));
    }, [navigateDate, dateState]);

    // 翌日に移動
    const onClickNext = useCallback(() => {
        navigateDate(DateHelper.addDays(dateState, 1));
    }, [navigateDate, dateState]);

    return (
        <Flex minWidth='max-content' alignItems='center' gap='2' p='3' h="20" w="100%">
            <TitleControl onClickToday={onClickToday} />
            <Spacer />
            <DateControl date={dateState} onClickPrev={onClickPrev} onClickNext={onClickNext} />
        </Flex>
    );
});

state は次にレンダリングされるタイミングまで反映されないことに注意が必要ですね。

import { memo, FC } from "react";
import { Box, Image, Text, Link } from "@chakra-ui/react";

import { Holodule } from "../../types/api/holodule";
import { StreamerHelper } from "../../utils/StreamerHelper";
import { DateHelper } from "../../utils/DateHelper";
import { StreamTime } from "../atoms/StreamTime";

type Props = {
    holodule: Holodule;
    today: Date;
};

// 配信予定カードコンポーネント
export const StreamCard: FC<Props> = memo((props) => {
    const { holodule, today } = props;

    return (
        <Box w="300px" h="220px" bg="white" borderRadius="10px" shadow="md" p={2} _hover={{ opacity: 0.8 }}>
            <Box w='280px' overflow='hidden' textAlign="center">
                <Box display='flex'>
                    <Box w="120px" textAlign="center">
                        <Box fontWeight='semibold' p="1">
                            <StreamTime date={DateHelper.stringToDateTime(holodule.datetime)} today={today} />
                        </Box>
                        <Box>
                            <Link href={StreamerHelper.getChannelUrl(holodule.name)} isExternal>
                                <Image
                                    borderRadius="full"
                                    boxSize="50px"
                                    m="auto"
                                    src={StreamerHelper.getImageUrl(holodule.name)}
                                    alt={holodule.name}/>
                            </Link>
                        </Box>
                        <Box p="1">
                            <Text fontSize="md" as="b" noOfLines={2}>
                                {holodule.name}
                            </Text>
                        </Box>
                    </Box>
                    <Box w="160px">
                        <Link href={holodule.url} isExternal>
                            <Image src={StreamerHelper.getThumbnailUrl(holodule.video_id)} w="100%" />
                        </Link>
                    </Box>
                </Box>
                <Text fontSize="sm" mt="1" noOfLines={3}>
                    {holodule.title}
                </Text>
            </Box>
        </Box>
    );
});

配信予定を表示するカードは Chakra-UI の Box を主に利用して作成しました。

conponents/pages

ページコンポーネントを作成します。

import { memo, useEffect, FC, useRef } from "react";
import { useParams } from "react-router-dom";
import { Center, Spinner, Wrap, WrapItem, Text } from "@chakra-ui/react";

import { DateHelper } from "../../utils/DateHelper";
import { StreamCard } from "../organisms/StreamCard";
import { useHolodules } from "../../hooks/useHolodules";

// 配信予定ページコンポーネント
export const Holoduler: FC = memo(() => {
    const { date } = useParams();
    const { getHolodules, loading, holodules } = useHolodules();

    const today = new Date();
    const dateString = date || DateHelper.formatDate(today);
    const didMountRef = useRef(false);

    useEffect(() => {
        // strict モード対策
        if (process.env.NODE_ENV === "development") {
            if (didMountRef.current) {
                didMountRef.current = false;
                return;
            }
        }
        getHolodules(dateString)
    }, [getHolodules, dateString]);

    return (
        <>
            {loading ? (
                <Center h='100px' w="100%">
                    <Spinner
                        thickness='4px'
                        speed='0.65s'
                        emptyColor='gray.200'
                        color='blue.500'
                        size='xl' />
                </Center>
            ) : (
                <Wrap>
                    {
                        holodules.length > 0 ? (
                            holodules.map((holodule) => (
                                <WrapItem key={holodule.key} mx="auto">
                                    <StreamCard holodule={holodule} today={today} />
                                </WrapItem>
                            ))
                        ) : (
                            <Text fontSize="md" as="b">予定がありません</Text>
                        )
                    }
                </Wrap>
            )}
        </>
    );
});

読み込み中はスピナーを表示するようにしています。

import { memo, FC } from "react";

// 404ページコンポーネント
export const Page404: FC = memo(() => {
    return <p>ページが存在しません</p>;
});

conponents/templates

テンプレートコンポーネントを作成します。

import { memo, ReactNode, FC } from "react";
import { Flex } from "@chakra-ui/react";

import { Header } from "../organisms/Header";

type Props = {
    children: ReactNode;
};

// テンプレートコンポーネント
export const HeaderLayout: FC<Props> = memo((props) => {
    const { children } = props;

    return (
        <>
            <Flex as="header" position="fixed" zIndex="10" backgroundColor="white" w="100%" top="0px">
                <Header />
            </Flex>
            <Flex as="main" mt="20" p="3">
                {children}
            </Flex>
        </>
    );
});

テンプレートコンポーネントでヘッダーとページのコンポーネントを組み込んでいます。

router

ルーターコンポーネントを作成します。

import { Route, Routes } from "react-router-dom";

import { HeaderLayout } from "../components/templates/HeaderLayout";
import { Holoduler } from "../components/pages/Holoduler";
import { Page404 } from "../components/pages/Page404";

// ルーターコンポーネント
export const Router = () => {
    return (
        <Routes>
            <Route path="/" element={<HeaderLayout><Holoduler /></HeaderLayout>} />
            <Route path="/:date" element={<HeaderLayout><Holoduler /></HeaderLayout>} />
            <Route path="*" element={<Page404 />} />
        </Routes>
    );
};

App.tsx

最後に、ルーターコンポーネントを App.tsx へ組み込みます。

import { ChakraProvider } from "@chakra-ui/react";
import { BrowserRouter } from "react-router-dom";

import theme from "./theme/theme";
import { Router } from "./router/Router";

export default function App() {
    return (
        <ChakraProvider theme={theme}>
            <BrowserRouter>
                <Router />
            </BrowserRouter>
        </ChakraProvider>
    );
}

Visual Studio 2022 でデバッグ実行を行います。

img04.jpg

ホロジュール Web アプリができました。
選択した日付で ホロジュール Web API を呼び出し、配信時間降順で配信予定を表示します。

おわりに

今回も小規模なプログラムですが、前回よりは React らしく作れたのではないかと思います。
Udemy の Reactに入門した人のためのもっとReactが楽しくなるステップアップコース完全版 の内容やサンプルが、React 開発の参考になりました。

まだ React hooks の理解が甘く、v18 の新機能も学習中ですので、引き続き何か作ってみようと思います。

17
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?