はじめに
Web 開発をしてみたい! ということで、React+TypeScript をやってみました。
初学者が「どこで困るか?」「どこで〇〇に触れるだろうか?」という参考にしていただければと思います!
この記事は初学者の動きを観察する記事なので、読みにくい部分もあると思いますが、初学者の思考や躓きポイントの分析にご活用くだされば幸いです!
現在は別のものを作りたくなったので、今回作ったものはチュートリアルとして考え、一区切りをつけています。
作ったもの:FeelingWeather
リポジトリ:FeelingWeather の GitHub リポジトリ
選択した市区町村の現在のお天気情報を取得して表示します。
経験
- C++、C#くらいしかやったことがない
- React+TypeScript など Web 系の言語はほぼノータッチ
- HTML、CSS、はこのサイトの作成時に触っている
今回やってみたこと
- API キーを安全に管理しつつ API を叩く
- テストと ESLint を GitHubActions を使って PR で自動で行うように
- CSS モジュール
- サーバーレス関数
構成
無料、もしくは十分な無料枠があるサービスのみを使用しています。
また、最初はとにかく動かして知ろう、というスタイルなのでとりあえずポピュラーっぽいところを選んでいる部分が多いです。
- React
- TypeScript
- テスト:jest、testing-library
- バージョン管理:git、GitHub
- モジュールバンドラー:Vite
- ホスティング、サーバーレス関数:Netlify
- お天気 API:OpenWetherMapAPI
- 市区町村名データ:デジタル庁アドレス・ベース・レジストリからダウンロードしたデータを加工
React+TypeScript
型がある言語しかやっていなかったので、型だー、わーい! の気持ちで飛びつきました。
フロントエンドエンジニアの募集要項をみるとよくみかけるなあ、という印象です!
Jest、testing-library
React+TypeScript でテストって調べるとよく見かけた名前だったので、選びました。
testing-library のタコちゃんかわいいなあって思います。
Vite
知ったきっかけ自体は ChatGPT です。
いろいろあるようでしたが、軽量でビルドとかがはやいらしい、ということで選びました。
いろいろあるみたいなので、モジュールバンドラーの比較とかちゃんとしてみたいですね!
Netlify
こちらも ChatGPT に教えてもらったのでそのまま採用。
ホスティングや GitHub の PR からの自動ビルド、サーバーレス関数などを利用しました。
秘密変数として API キーを登録できたので、非常に便利でした!
お天気 API:OpenWetherMapAPI
今回は Free Access の「Current Weather data」を使用しています。
緯度経度を渡すと、現在の天気を返してくれます。
デジタル庁のアドレス・ベース・レジストリ
デジタル庁のデータには市区町村名と緯度経度などのデータがあったので、それらを後述するデータ形式に加工した json ファイルを使用しています。
市区町村名を今回作成する Web ページで入力してもらい、それをもとに緯度経度を取得してお天気 API に渡す流れです。
そこでユーザーは市区町村名、それをもとに緯度経度を取得という流れのために市区町村名と緯度経度のセットの一覧が必要でした。
ということで、デジタル庁のアドレス・ベース・レジストリから csv の zip データをダウンロードして、必要な json 形式に加工して使用しました。
データの流れ
- ユーザーが市区町村名入力フォームで市区町村名を選択
- 市区町村名をもとに緯度経度を事前に用意している json データから取得
- ローカル有効なキャッシュがあるか確認
- ローカルに有効なキャッシュがあれば、キャッシュからデータを返す
- ローカルに有効なキャッシュがなければ、API にリクエストを投げる
- データをフロントに返す
- 受け取ったデータをフロントで表示
プロジェクト作成
今回は全く知らない領域だったので、ChatGPT にいろいろやり方を聞きながら、細かいところは検索して公式ドキュメントを読みながら進めました。
基本的にコミットコメントに残した通りです。
- [add] npm init
- [update] npm install react react-dom
- [update] npm install --save-dev typescript @types/react @types/react-dom vite
- [update] npm create vite@latest . -- --template react-ts
- [update] npm install
- [update] npm install netlify-cli
- [update] npx netlify init
FeelingWeather
というプロジェクト名にしました。
以下、その前提で進めます。
FeelingWeather
フォルダを作成- Node.js プロジェクトの初期化
npm init -y
-y
というオプションはなにかな、と思ったら「コマンド実行時の質問にすべて yes で答える」でした。
特に問題なかったので、今回は-y
オプションを使いました。 - React と TypeScript の依存関係を追加
npm install react react-dom
npm install --save-dev typescript @types/react @types/react-dom vite
- Vite を使って React+TypeScript のプロジェクトを作成
npm create vite@latest . -- --template react-ts
- 作成したプロジェクトに必要な依存関係をインストール
npm install
- Netlify のアカウント作成
- Netlify CLI のインストール
npm install netlify-cli
- Netlify のアカウントに CLI でログイン
npx netlify login
- プロジェクトを Netlify に CLI からリンク
npx netlify init
基本的には ChatGPT の提案をもとにセットアップしました。
npm install
の際に-g
オプションを追加すると、グローバル(フォルダの外)にも入ってしまいます。
後々プロジェクトごとの管理がしにくくなるのは嫌だったので、ところどころ-g
がついていた部分は無視して、ローカルにインストールしました。
GitHubActions のワークフロー追加
追加したのは2つです。
ESLint のチェックと、テストの実行です。
どちらも PR が作成されたときに自動実行されるようにしました。
テストは最初は失敗があれば、失敗するだけでしたが、せっかくなので Coverage を PR にコメントするものを追加しています。
自動テストのワークフローがあることで、テストで動作を確認することを PR で自動でやってくれます。
これがあることで、関係ないと思われた変更の影響にすぐに気付けます。
1 つ大事なことがあり、テストのワークフローは無駄があります。
現在作成している 2 つ目のプロジェクトでは修正していますので、ここではそのコードを載せておきます。
参考:Jest カバレッジレポートを Github Actions で PR に自動コメント
▼ ESLint のワークフロー
name: ESLint Check
on:
workflow_dispatch:
pull_request:
branches:
- main
- develop
push:
branches:
- main
jobs:
eslint:
runs-on: ubuntu-latest
steps:
# リポジトリをチェックアウト
- name: Checkout code
uses: actions/checkout@v4
# Node.js をセットアップ
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "latest"
# 必要な依存関係をインストール
- name: Install dependencies
run: npm install
# ESLint を実行
- name: Run ESLint
run: npm run lint
▼ テストワークフロー
name: Run Tests
on:
workflow_dispatch:
pull_request:
branches:
- main
- develop
permissions:
checks: write
contents: write
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Test coverage
uses: ArtiomTr/jest-coverage-report-action@v2
with:
github-token: $
test-script: npx jest --coverage --silent --ci --testLocationInResults --json --outputFile="report.json" --passWithNoTests
API とやり取りする
API とやり取りする時はfetch
やaxios
を使うことができます。
これらのメソッドに URL やパラメータを渡すことで、レスポンスを受け取ることなどができます。
fetch と axios どちらでも今回の目的は達成できそうでしたが、以下の記事を読んで今回は使いやすさ重視で axios を選択しました!
基本的に以下のファイルを経由して、フロントエンドからのリクエストを必要であれば
バックエンド(今回は NetlifyFunctions を使ったサーバーレス関数)に投げ直しています。
フロントエンド側
-
フロントエンドから呼び出される
-
localStorage
に同じ緯度経度のデータがあるか?localStorage
にデータがあれば、取得const storedData = localStorage.getItem(storeKey);
- 取得した
localStorage
のデータが保存された時から 30 分経っていなければ、localStorage
のデータを返す!if (diffMinutes < 30) { return restoreStoreData(convertedStoreData); }
- 同じ緯度経度のデータがあったものの、30 分以上経過していたら削除しておく
else { localStorage.removeItem(storeKey); }
-
localStorage
に有効なデータがなければ、バックエンドにリクエストを投げ直す
ここで axios を使っています!const response = await axios.get<CurrentWeather>( `${functionsUrl}/getCurrentWeather/`, { params: { lat, lon, }, } );
-
バックエンドにリクエストしたデータが返ってきたら、それを
localStorage
に保存localStorage.setItem(storeKey, JSON.stringify(newStoreData));
-
返ってきたデータをフロントエンドに返す!
import axios from "axios";
import { CurrentWeather } from "../types/CurrentWeather.type";
const functionsUrl = `/.netlify/functions`;
export const getCurrentWeather = async (
lat: number,
lon: number
): Promise<CurrentWeather> => {
const storeKey = `currentWeather-lat${lat}-lon${lon}`;
const storedData = localStorage.getItem(storeKey);
const now = Date.now();
if (storedData) {
const convertedStoreData = JSON.parse(storedData) as StoreData;
if (convertedStoreData !== null) {
const diffMinutes = (now - convertedStoreData.storedDate) / 60000;
if (diffMinutes < 30) {
return restoreStoreData(convertedStoreData);
} else {
localStorage.removeItem(storeKey);
}
}
}
const response = await axios.get<CurrentWeather>(
`${functionsUrl}/getCurrentWeather/`,
{
params: {
lat,
lon,
},
}
);
const newStoreData: StoreData = {
data: response.data,
storedDate: now,
};
localStorage.setItem(storeKey, JSON.stringify(newStoreData));
return response.data;
};
type StoreData = {
data: CurrentWeather;
storedDate: number;
};
/**
* 格納していたデータを復元する
* @param {StoreData} storeData - 格納していたデータ
* @returns {CurrentWeather} 復元したデータ
*/
function restoreStoreData(storeData: StoreData): CurrentWeather {
return {
weather: storeData.data.weather,
main: storeData.data.main,
wind: storeData.data.wind,
dt: new Date(storeData.data.dt),
sys: {
sunrise: new Date(storeData.data.sys.sunrise),
sunset: new Date(storeData.data.sys.sunset),
},
};
}
バックエンド側(NetlifyFunctions のサーバーレス関数)
基本的にはフロントエンドからリクエストが来たら、それを外部 API である OpenWeatherMapAPI の CurrentWeather data に投げ直しています。
ファイル全体は長いですが、実際にリクエストを投げ直しているのは前半部分だけです。
後半部分は OpenWeatherMapAPI からのレスポンスを、フロントエンドで利用しやすいように加工しているだけです。
後述のファイルの中でフロントエンドからのリクエストを OpenWeatherMapAPI に投げ直しているのは以下の部分です。
const response = await axios.get<RawCurrentWeather>(
`${baseUrl}?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric&lang=ja`
);
フロントエンド側は修正してあるのですが、こちらではパラメータを URL の組み立ての際に組み込んでいます。
フロントエンドのようにparams
で渡す方法を知ったのがこれを書いたときよりもあとだったので、フロントエンドだけ params に渡す形式になっています。
個人的にはこちらのほうがわかりやすいので、今後はparams
にわたす形式でやろうと思っています。
const response = await axios.get<CurrentWeather>(
`${functionsUrl}/getCurrentWeather/`,
{
params: {
lat,
lon,
},
}
);
▼ netlify/functions/getCurrentWeather.mts
import { Context } from "@netlify/functions";
import { RawCurrentWeather } from "../../src/types/RawCurrentWeather.type";
import { CurrentWeather, Weather } from "../../src/types/CurrentWeather.type";
import axios from "axios";
const baseUrl = "https://api.openweathermap.org/data/2.5/weather";
export default async (request: Request, context: Context) => {
var url = new URL(request.url);
const lat = url.searchParams.get("lat");
const lon = url.searchParams.get("lon");
const apiKey = Netlify.env.get("OPENWEATHER_API_KEY");
if (!lat || !lon || !apiKey) {
return new Response("Invalid parameters", { status: 400 });
}
const response = await axios.get<RawCurrentWeather>(
`${baseUrl}?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric&lang=ja`
);
const data = response.data;
return new Response(JSON.stringify(convertRawCurrentWeather(data)));
};
/**
* APIから取得した生の天気データをアプリケーションで扱いやすい形式に変換する
* @param raw APIから取得した生の天気データ
* @returns {CurrentWeather} アプリケーションで扱いやすい形式の天気データ
*/
function convertRawCurrentWeather(raw: RawCurrentWeather): CurrentWeather {
const weather: Weather =
raw.weather.length > 0
? raw.weather[0]
: { id: 0, main: "", description: "", icon: "" };
return {
weather: weather,
main: {
temp: raw.main.temp,
feels_like: raw.main.feels_like,
temp_min: raw.main.temp_min,
temp_max: raw.main.temp_max,
humidity: raw.main.humidity,
},
wind: {
speed: raw.wind.speed,
deg: raw.wind.deg,
},
dt: new Date((raw.dt + raw.timezone) * 1000),
sys: {
sunrise: new Date((raw.sys.sunrise + raw.timezone) * 1000),
sunset: new Date((raw.sys.sunset + raw.timezone) * 1000),
},
};
}
困ったこと
NetlifyFunctions をローカルで試すならサイトのリンクをする必要があった
サイトのリンク、大事です!
すっかりそれを忘れて接続ができず再度マニュアルを読みました。
サイトのリンクとリンク解除
npx netlify link
NetlifyFunctions に繋がらない
これは叩くときの指定方法の問題でした。
ドメインは指定する必要がなかったので、指定しないように変更すると、NetlifyFunctions にアクセスできるようになりました。
ローカルでの確認時はドメインが localhost になってしまうので、それで繋がらなかったようです。
ちなみに URL から origin(ドメイン)をがっちゃんこすることでも解決しました。
→ このあとに「そもそもドメインいらないじゃん」と気付き変更しました。
レンダリング中の内容変更はしてはいけなかった
▼ エラー内容
Warning: Cannot update a component (App) while rendering a different component (コンポーネント名). To locate the bad setState() call inside SearchableDropdown, follow the stack trace as described in https://reactjs.org/link/setstate-in-render
レンダリング中に setState などが起きて、レンダリング内容に変更がかかってしまうのがよくないとのこと。
useEffect や遅延を ChatGPT にすすめられたので、useEffect をここで知りました。
遅延は場当たり対処っぽさがあったので useEffect を使うようにしました。
CSS モジュール使用している場合のテスト
CSS モジュールの存在を知らなかったのですが、ChatGPT が教えてくれたので使っていました。
CSS にスコープの概念が入ったので大喜びしました。
この形式でコンポーネントごとに CSS を書いていたのですが、テストの際にモックする必要がありました。
▼ エラーメッセージ
Jest encountered an unexpected token
Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.
Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel
▼ mocks/styleMock.js
module.exports = {};
▼ jest.config.js
moduleNameMapper: {
'\\.module\\.css$': '<rootDir>/mocks/styleMock.js',
},
気にしたこと
API からの返答をキャッシュした
localStorageを使って、30 分だけ同じ市区町村名を求められたらキャッシュからデータを返すようにしました。
API のリクエスト数を減らす目的です。
市区町村名(実際は緯度経度)をキーにキャッシュし、30 分以内の再リクエストの場合は API にリクエストを投げないようにしました。
市区町村名を絞り込めるようにした
市区町村名がたくさんあるので、単純なドロップダウンでの選択ではなく、input 要素を組み合わせて絞り込めるようにしました。
input 要素に入力された文字列を含む市区町村名をドロップダウンっぽく表示するようにしています。
自動化したほうがよさそうなところは自動化した
最初からテストや ESLint の確認を GitHubActions で自動化しました。
これのおかげで、PR を上げたときに自動で動いてくれるので、テストや ESLint を活用できました。
API キーはフロントで触らないようにした
API キーは公開してはならない、ということでフロントエンドで触る必要がないようにしました。
Netlify に API キーを秘密変数としてに登録でき、それに NetlifyFunctions でアクセスできます。
フロントから NetlifyFunctions にリクエストを投げ、
NetlifyFunctions 内で秘密変数として API キーを使用してお天気 API に投げ直すようにしました。
実際どんな順番で作成していたか
コミットログの通りではあるのですが、モチベーション維持のためにつけている時間やコメントの記録もあるので、それも含めてご紹介します。
一区切りつけるまでの記録時間は 54.5 時間でした。
期間としては 12 日間、土日土の 3 休日が含まれています。
- 01 日目(火):プロジェクトの作成
- 02 日目(水):ESLint を GitHubActions で違反がないか確認するワークフローの追加。テスト環境構築とテストワークフローの追加。
- 03 日目(木):せっかくなら、とテストのレポートを PR にコメントするように
- 04 日目(金):
npm install
の--save-dev
の意味を知る。気温を固定値(25 を入れるだけ)で表示する場所を作成する。 - 05 日目(土):コードの関数へのコメントの主流な形式を調べる。JSDoc コメント形式を使うように。市区町村名入力 input を作る。
- 06 日目(日):市区町村名と緯度経度のデータ準備。デジタル庁のデータを加工して使うことに。Python で指定の形式に加工できるスクリプト追加。
- 07 日目(月):Generics との邂逅。C#の generics と感覚は同じ。
<T,>
の,
があることでタグと勘違いさせない目的があるらしい。 - 08 日目(火):型の明示的な指定方法を知る。jest のモックモジュールと格闘。
- 09 日目(水):フックと型ガードを知る。Netlify Functions が見つからなくて悩む。
- 10 日目(木):Netlify Functions で API を叩くのに成功。axios の Mock をするのに格闘。
- 11 日目(金):Netlify Functions から受け取ったデータをフロントで必要な形に加工して渡せるように。
- 12 日目(土):選択した市区町村名から天気データを取得、気温を表示できるように。市区町村名選択部分の見た目を最低限調整。モジュール CSS はモックが必要らしいと知る。
以上のような感じでした!
次!
次はユーザー認証あり、データ保存あり、Web アプリを作っています。
基本的には FirebaseAuthentication でユーザー認証を行い、Firebase Firestore でデータ保存を行います。
この記事を書いている頃は、メールアドレス&パスワードの新規ユーザー登録ができるようになってきゃっきゃし、
Authentication のテストのモックと戦ってちょうど勝利したところです。
次の記事は Authentication のモック(初学者ゆえに非常に単純なところに気付けなかっただけ)の話を書く予定です!
むしろそれを書くために、途中まで書いていたこの記事を書き上げている今、という感じです。
どんどこやりますよー!