COMPANY SERVICE STAFF BLOG NEWS CONTACT

STAFF BLOG

スタッフブログ

TECHNICAL

テクログ

2024.12.27

ReactデビューでViteがライト!初学者が社員旅行のしおりをファイトShimasuyo!②

テクログ

こんにちは!中田です!

本日で今年の業務は終了となりますが、今年最後に、以前書かせていただきました記事の続きを書かせていただきます!

今回はガッツリ技術の話、いやむしろ実装したコンポーネントのソースコードを公開といった形になります(初学者のため優しい気持ちで見ていただければと思います。。)。

制作環境・仕様について

復習になりますが以下がベースとなる環境となっております。

  • ・Vite
  • ・React
  • ・TypeScript
  • ・PostCSS
  • ・PWA 対応

大まかな機能

基本的な機能は以下です。

1. グループ選択

  • グループ A/B の場合、初期画面でグループ選択を行う。

2. ローカルストレージ

  • ローカルストレージでグループ情報を保持(groupA / groupB)

3. コンポーネントの表示切り替え

  • ストレージに保存されているグループのステータスによって、各コンポーネントの表示を切り替える。

主なコンポーネント

主要となるコンポーネントのみ一部抜粋してご紹介いたします。
(初学者のソースコードなので何卒。。)

App

アプリケーションのメインコンポーネント。全体のレイアウトやルーティングを管理します。

ActivitySection

複数のタブを持ち、それぞれのタブに対応するアクティビティの詳細情報を表示します。

const ActivitySection: React.FC<ActivitySectionProps> = ({
  activeActivityTab, // アクティビティタブのアクティブ状態
  handleActivityTabClick, // アクティビティタブのクリックイベント
}) => {
  // タブ内のコンテンツを定義
  const tabContents = [
    <TabContentA key="A" />,
    <TabContentB key="B" />,
    <TabContentC key="C" />,
  ];

  return (
    <section id="anc04" className="wrap-radius wrap-radius--pt03">
      <div className="wrap-radius__inner">
        <div className="tab-activity">
          <ul className="tab-activity__btn-lst">
            // タブボタンを生成
            {["A", "B", "C"].map((label, index) => (
              <TabButton
                key={index}
                label={label}
                index={index}
                active={activeActivityTab === index}
                onClick={() => handleActivityTabClick(index)}
              />
            ))}
          </ul>
          // タブ内のコンテンツを表示
          <div className="tab-activity__area">
            {tabContents[activeActivityTab]}
          </div>
        </div>
      </div>
    </section>
  );
};

CountDown

カウントダウンを表示するコンポーネント。旅行までの残り日数を表示します。

const Countdown: React.FC<CountdownProps> = ({ onCountdownEnd, status }) => {
  // onCountdownEnd: カウントダウン終了時のコールバック関数, status: カウントダウンの状態
  const [timeLeft, setTimeLeft] = useState<string>(""); // 残り時間
  const [isVisible, setIsVisible] = useState(true); // 表示状態
  const [isSlidingOut, setIsSlidingOut] = useState(false); // スライドアウト状態

  useEffect(() => {
    const targetDate = getTargetDate(status); // カウントダウンの終了時刻

    // カウントダウンのメイン処理
    const countdown = () => {
      const now = new Date();
      const distance = targetDate.getTime() - now.getTime();

      // カウントダウン終了時の処理
      if (distance < 0) {
        setIsVisible(false); // カウントダウンを非表示にする
        onCountdownEnd(); // カウントダウン終了時の処理を実行
        return;
      } else {
        setTimeLeft(formatTimeLeft(distance)); // 残り時間を更新
      }
      window.requestAnimationFrame(countdown); // 次のフレームで再描画
    };

    window.requestAnimationFrame(countdown); // カウントダウンのメイン処理を開始

    // カウントダウン終了時の処理
    const timer = setTimeout(() => {
      setIsSlidingOut(true); // スライドアウト状態にする
      setTimeout(() => {
        setIsVisible(false); // カウントダウンを非表示にする
        onCountdownEnd(); // カウントダウン終了時の処理を実行
      }, 500);
    }, 2000);

    return () => {
      clearTimeout(timer);
    };
  }, [status, onCountdownEnd]); // status が変更されたときに再実行

  if (!isVisible) return null;

  return (
    <div className={`area-count ${isSlidingOut ? "slide-out" : "visible"}`}>
      <div
        className="area-count__main"
        dangerouslySetInnerHTML={{ __html: timeLeft }}
      />
    </div>
  );
};

FvSection

メインビジュアルを表示するコンポーネント。背景画像をスライドさせながらフェードで表示します。

const FvSection: React.FC<FvSectionProps> = ({
  status,
  activeTab,
  isFading,
  handleTabClick,
  imgRef1,
  imgRef2,
}) => {
  const [currentImageIndex, setCurrentImageIndex] = useState(0);
  const [isImageFading, setIsImageFading] = useState(false);
  const [left, setLeft] = useState("auto");
  const [images, setImages] = useState<string[]>([]);

  useEffect(() => {
    // 往路の画像を先に読み込む
    const outwardImages = [
      "bg-outward-fv01.webp",
      "bg-outward-fv02.webp",
      "bg-outward-fv03.webp",
    ];
    // 復路の画像を読み込む
    const returnImages = [
      "bg-return-fv01.webp",
      "bg-return-fv02.webp",
      "bg-return-fv03.webp",
    ];
    const imagesToLoad = activeTab === 0 ? outwardImages : returnImages; // 往路か復路かで読み込む画像を切り替える

    const preloadImages = (images: string[]) => {
      // 画像の読み込みをPromiseで管理
      const promises = images.map((src) => {
        return new Promise<void>((resolve, reject) => {
          const img = new Image(); // 画像を読み込むためのImageオブジェクトを生成
          img.src = src; // 画像のパスを設定
          img.onload = () => resolve(); // 画像の読み込みが完了したらresolveを呼ぶ
          img.onerror = () => reject(); // 画像の読み込みに失敗したらrejectを呼ぶ
        });
      });
      return Promise.all(promises);
    };

    // 画像の読み込みが完了したらsetImagesで画像をセット
    preloadImages(imagesToLoad)
      .then(() => {
        setImages(imagesToLoad);
      })
      .catch((error) => {
        console.error("Error preloading images:", error);
      });
  }, [activeTab]);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setIsImageFading(true); // 画像をフェードアウト
      // 画像をフェードアウトした後、次の画像に切り替えてフェードイン
      setTimeout(() => {
        setLeft("0"); // 画像を左にスライド
        setCurrentImageIndex((prevIndex) => (prevIndex + 1) % images.length); // 画像のインデックスを更新
        setIsImageFading(false); // 画像をフェードイン
        setLeft("auto"); // 画像を元の位置に戻す
      }, 1000);
    }, 10000);

    return () => clearInterval(intervalId); // コンポーネントがアンマウントされたらクリア
  }, [images]);

  return (
    <div className="fv">
      <div
        className={`fv__inner ${isImageFading ? "is-fading" : ""}`}
        style={{ left }}
      >
        <p className="fv__img-wrap">
          <img
            src={images[currentImageIndex]}
            alt=""
            className="fv__img"
            width={1960}
            height={392}
            decoding="async"
          />
        </p>
      </div>
      <div className="fv__bg">
        <Header status={status} />
        <TicketContainer
          status={status}
          activeTab={activeTab}
          isFading={isFading}
          handleTabClick={handleTabClick}
          imgRef1={imgRef1}
          imgRef2={imgRef2}
        />
      </div>
    </div>
  );
};

MeetingSection

集合日時・場所を表示するコンポーネント。往路・復路のパターンによって表示を切り替えます。
IntersectionObserver の監視対象として、画面内に表示された際にカードの回転やバスを移動させるアニメーションを実行します。

const MeetingSection: React.FC<MeetingSectionProps> = ({ activeTab, handleTabClick, status }) => {
    // IntersectionObserverを使って要素が表示されたかどうかを判定
	const { ref: wrapStdRef, inView: isWrapStdInView } = useInView({ threshold: 0.2 });
	const { ref: wrapTicketRef, inView: isWrapTicketInView } = useInView({ threshold: 0.2 });
・
・
・
<section
    id="anc01"
    ref={wrapStdRef}
    className={`wrap-std ${activeTab === 0 ? 'wrap-std--pt01' : 'wrap-std--pt02'} ${
        isWrapStdInView ? 'is-wrap-std-view' : ''
    }`}>
・
・
・

Navigation

ナビゲーションを表示するコンポーネント。クリックによる該当の箇所をスムーススクロールで移動します。

const Navigation: React.FC<NavigationProps> = ({ toggleBodyClass }) => {
	const [status, setStatus] = useState<string | null>(null);
	const isMobile = useMediaQuery('(max-width: 600px)'); // モバイル判定

	// ナビゲーションリンク先とテキスと管理
	const navItems: NavItem[] = [
		{ href: '#anc01', text: '集合時間・場所' },
		{ href: '#anc02', text: '宿泊ホテル' },
		{ href: '#anc03', text: 'スケジュール' },
		{ href: '#anc04', text: 'アクティビティの案内' },
		{ href: '#anc05', text: 'お土産情報' },
		{ href: '#anc06', text: '注意事項' },
	];

	useEffect(() => {
		const savedStatus = localStorage.getItem('status'); // ローカルストレージからグループ情報を取得
		if (savedStatus) {
			setStatus(savedStatus);
		}
	}, []);
・
・
・
<ul className="nav-global__lst">
    {filteredNavItems.map((item, index) => (
        <li key={index} className="nav-global__item">
            {isMobile ? (
                <AnchorLink
                    href={item.href}
                    className="nav-global__lnk"
                    onClick={handleLinkClickMobile}
                    offset={item.href === '#anc02' ? 45 : 65}>
                    {item.text}
                </AnchorLink>
            ) : (
                <AnchorLink
                    href={item.href}
                    className="nav-global__lnk"
                    onClick={(e) =>
                        handleLinkClickPC(
                            e,
                            item.href,
                            item.href === '#anc02' ? -20 : 0
                        )
                    }>
                    {item.text}
                </AnchorLink>
            )}
        </li>
    ))}
</ul>

WeatherBbs

天気情報を表示するコンポーネント。旅行先の天気情報を api から取得して電光掲示板風に表示します。
weatherUtils のweatherCodeToText 関数を使って天気コードをテキストに変換します。

const WeatherBbs: React.FC<WeatherBbsProps> = ({
  forecast,
  getDateLabel,
  status,
}) => {
  return (
    <div className="l-area-foot">
      <div className="l-area-foot__inner">
        {status !== "groupEX" && (
          <AnchorLink href="#anc07" className="btn-img" offset="65">
            <img
              src="btn-emergency.webp"
              width={70}
              height={80}
              alt="緊急連絡"
              className="btn-img__core"
            />
          </AnchorLink>
        )}
        <div className="slide-txt">
          <ul className="slide-txt__lst">
            {forecast.length > 0 ? (
              forecast.map((item, index) => (
                <li key={index} className="slide-txt__item">
                  {`那覇の${getDateLabel(
                    item.index
                  )}の天気 : ${weatherCodeToText(item.weatherCode)}、最高気温${
                    item.maxTemp
                  }℃、最低気温${item.minTemp}℃`}
                </li>
              ))
            ) : (
              <li className="slide-txt__item">Loading...</li>
            )}
          </ul>
        </div>
      </div>
    </div>
  );
};

WeatherInfo

天気情報を表示するコンポーネント。旅行先の天気情報 api から取得してステータスに応じて天気画像を切り替えます。
weatherUtilsのweatherCodeToIcon 関数を使って天気コードを画像に変換します。

const WeatherInfo: React.FC<WeatherInfoProps> = ({
  weatherIcon,
  weatherDescription,
  temperature,
  imgRef,
}) => {
  return (
    <div className="hdr-weather">
      <p ref={imgRef} className="hdr-weather__img">
        {weatherIcon !== "default.svg" ? (
          <img
            src={`${weatherIcon}`}
            alt={weatherDescription}
            className="hdr-weather__img-item"
            width={24}
            height={25}
          />
        ) : (
          weatherDescription
        )}
      </p>
      <p className="hdr-weather__desc">
        只今の
        <br />
        那覇の天気
      </p>
      <p className="hdr-weather__temp">
        {temperature}
        <span className="hdr-weather__temp-item">℃</span>
      </p>
    </div>
  );
};

主なユーティリティ

weatherUtils

天気情報を取得するユーティリティ。api から天気情報を取得し、ステータスに紐づく情報にテキストや画像に変換します。

export const weatherCodeToText = (code: number): string => {
  const weatherDescriptions: { [key: number]: string } = {
    0: "快晴",
    1: "晴れ",
    2: "一部曇り",
    3: "曇り",
    45: "霧",
    48: "霧氷",
    51: "弱い霧雨",
    53: "霧雨",
    55: "強い霧雨",
    56: "弱い凍結霧雨",
    57: "強い凍結霧雨",
    61: "弱い雨",
    63: "雨",
    65: "強い雨",
    66: "弱い凍結雨",
    67: "強い凍結雨",
    71: "弱い雪",
    73: "雪",
    75: "強い雪",
    77: "雪粒",
    80: "弱いにわか雨",
    81: "にわか雨",
    82: "激しいにわか雨",
    85: "弱いにわか雪",
    86: "強いにわか雪",
    95: "雷と雨",
    96: "弱い雷と雨",
    99: "強い雷と雨",
  };
  return weatherDescriptions[code] || "不明な天気";
};

export const weatherCodeToIcon = (code: number): string => {
  const weatherIcons: { [key: number]: string } = {
    0: "ico-sunny.webp",
    1: "ico-sunny.webp",
    2: "ico-cloud02.webp",
    3: "ico-cloud02.webp",
    45: "ico-cloud02.webp",
    48: "ico-cloud02.webp",
    51: "ico-rain.webp",
    53: "ico-rain.webp",
    55: "ico-rain.webp",
    56: "ico-rain.webp",
    57: "ico-rain.webp",
    61: "ico-rain.webp",
    63: "ico-rain.webp",
    65: "ico-rain.webp",
    66: "ico-rain.webp",
    67: "ico-rain.webp",
    71: "logo-core.svg",
    73: "logo-core.svg",
    75: "logo-core.svg",
    77: "logo-core.svg",
    80: "ico-rain.webp",
    81: "ico-rain.webp",
    82: "ico-rain.webp",
    85: "logo-core.svg",
    86: "logo-core.svg",
    95: "ico-thunder.webp",
    96: "ico-thunder.webp",
    99: "ico-thunder.webp",
  };
  return weatherIcons[code] || "logo-core.svg";
};

最後にReact初学者の感想文(語ります)

まず最初に感じたのは、「DOMを直接操作しなくていい楽さ」です。Reactでは基本的にuseStateやpropsで状態を管理し、UIをそれに紐付ける形で構築します。これまでだと、document.querySelectorやappendChild、innerHTMLで直接DOMをいじっていたのが、状態を更新するだけでReactが勝手にいい感じにレンダリングしてくれる。これ、最初は少し不安でした。「本当にこれで動いてるの?」って(笑)。でも実際に動かしてみると、余計なコードが減ってスッキリしているし、何よりバグが減る予感がしました。

次に驚いたのが、コンポーネントの考え方です。バニラJSでは「コードの再利用」といえば関数に切り出す程度で、複雑なUIは複数のファイルやロジックをぐちゃぐちゃにしながら作っていました。でもReactでは、UIを「部品化」して、それぞれ独立して作ることができる。たとえば、ボタン一つにしてもButtonコンポーネントを作れば、どこでもその見た目や動作を統一できます。この「再利用性」には感動しました。

ただ、最初はTSXに違和感を覚えました。HTMLとTSが混ざったコードを書くことに抵抗があったんです。でもよく考えたら、これって「UIのロジックと見た目を一緒に管理する」という意味で合理的なんですよね。慣れるとむしろ「HTMLテンプレートを分けて書くより楽じゃん」と思うようになりました。

あと、Reactの「状態の流れ」には少し戸惑いました。特にpropsを使ってデータを親から子に渡す部分は、「バニラJSなら普通にグローバル変数使っちゃうよね」と思ったり。でも、Reactはその代わりに「どこで何が変わっているのか」が明確で、結果的にコードが読みやすい気がします。

最後に、「useEffect」の存在が魔法みたいでした。これ、バニラJSの「DOMContentLoaded」や「addEventListener」に相当するものだと思ったんですが、それだけじゃなくて状態の変化に応じて発火するところがすごい。正直、最初は「依存配列?これ何?」って感じでしたが、仕組みがわかるとめちゃくちゃ便利。

総評として、Reactは「効率的でモダンなUI開発のツール」という印象です。最初はバニラJSとの違いに戸惑いましたが、「一度慣れると戻れない」感がありますね。まだまだ学ぶことが多いですが、Reactのエコシステム(Next.jsやReduxとか)をもっと深掘りしていきたいと思います。

今回の実装の大きな反省点としては、開発前にもっと詳細に仕様を固めておけば、コンポーネントをもっと細分化できたと思いました。いつまで経っても納得のいく制作物ができないものですね。。

ここまで読んでいただきありがとうございました。。。

この記事を書いた人

中田

入社年2024年

出身地東京都

業務内容コーディング

特技・趣味ゲーム、ギター

テクログに関する記事一覧

TOP