LoginSignup
244
285

最低限の知識で簡単なWebアプリを作る(生JavaScript vs React&TypeScript、やりたいこと別サンプル集)

Last updated at Posted at 2023-03-12

はじめに

ユーザーが何の準備も必要なく、PCでもスマホでも動かすことができるWebアプリの魅力に惹かれてJavaScriptを勉強しました。
Web初心者のわたしが最終的にはこんなアプリたちを作ることができました。
完全に独学なので「もっといい方法がある」とか「もっときれいに書ける」とかはあるかもしれませんが、これらを実装した経験を通して、必要だった最低限な知識をまとめようと思います。

サーバーとのやりとりは一切しない(したい方はすればよい)ので、定義によっては「Webアプリ」とは言わないかもしれませんが、ここでは便宜上そう呼ぶことにします。(あとがきも参照)

対象読者

  • Webアプリって難しそう
  • 完全無料でアプリやゲームを公開したい
  • サーバー立てたくない
  • JavaScriptやったことない初心者
  • Reactって何?TypeScriptって何?便利なの?生JSでよくね?
  • フロントエンドだけ学びたい

Webアプリを作る

ここでは2つの方法を紹介します

  • 何の準備もいらないナイーブな生JavaScriptで作る
  • Node.jsを入れてReactとTypeScriptで作る

どちらにも共通していることは、プログラムがクライアント(利用ユーザー)側で実行されることです。
また今回は公開方法はどちらもgithub pagesを使います。

ナイーブな生JavaScriptで作る

そもそもJavaScriptとは?

  • 多くのブラウザで動作可能なインタプリタ言語
  • htmlドキュメントをプログラムで変更できる

環境構築

以下があればよいので特になし。

  • 任意のブラウザ(ChromeやFireFoxなど)
  • 任意のテキストエディタ(VS Codeやnotepad++など)

前提知識

以下が基本的なhtmlテンプレートです。
今回はこの中にJavaScriptのコードを書いていきます。
以下をindex.htmlという名前で保存してブラウザで開いてみよう。

index.html
<!DOCTYPE html>
<html>

<head>
	<meta charset="UTF-8">
	<title>ページタイトル</title>
	<style>
		/* CSSを書くところ */
		h1 {
			color: blue;
		}
	</style>
</head>

<body>
	<!-- 実際に表示される領域 -->
	<h1>Hello Html</h1>
	<script>
		// JavaScriptを書くところ
		console.log("Hello JavaScript");
	</script>
</body>

</html>

「Hello Html」という文字が青色で表示され、コンソールには「Hello JavaScript」と表示されます。
ChromeやFireFoxではF12キーでコンソールを表示することができます。
基本的にこのコンソールも見ながら開発していきます。
ソースを更新して確認したくなったらページをリロード(F5)して確認します。
template.png

  • <body></body>内に表示したい内容を書く
  • <script></script>内にJavaScriptコードを書く(プログラム
    • <script src="hogehoge.js"></script>のように外部ファイルから読み取ることも可能
  • <style></style>内にCSSを書く(見た目を調整するもの
    • <link rel="stylesheet" type="text/css" href="hogehoge.css">のように外部ファイルから読み取ることも可能

やりたいことから学ぶJavaScript

言語自体の文法はネット上に転がっている入門サイトに任せるとして、他の言語を知っている人向けに表現すると、for/if/whileなどの制御文はC++やJavaなどとほぼ同じで、型のゆるさはPythonという感じです。

jsの雰囲気
function evenSum(end) {
	let sum = 0;
	for (let i = 0; i < end; i++) {
		if (i % 2 === 0)
			sum += i;
	}
	return sum;
}
console.log("合計", evenSum(100)); // => 合計 2450 (コンソール出力)

入門サイトはサバイバルTypeScriptがオススメです。
TypeScriptを学びたくない人はJavaScript Primer 迷わないための入門書が良さそうです。

やりたいことに応じて難易度を★で表しました。
とりあえず雑に読み進めたい方は難易度高いものは読み飛ばして、github pagesでの公開方法まで進んでよいです。

これから紹介するコードはJavaScriptサンプル集で動作を試すことができます。

ボタンでhtmlドキュメントを変更する(★)

body内
<input id="input">
<button onclick="clicked();">ボタン</button>
<div id="output"></div>

<script>
	function clicked() {
		const input = document.getElementById("input");
		const output = document.getElementById("output");
		output.innerHTML = `<b>${input.value}</b>`;
	}
</script>

この例では、ボタンを押すと、入力されたテキストの値を取得し、divタグ内に太字で表示します。
output.gif

この例に限らず、基本的に参照/変更したいHTML要素にidをつけておき、document.getElementByIdで要素を取得した後、好きなように参照/変更して動的なページにしていきます。document.getElementByIdを覚えるだけでいろんな事ができるようになります。
innerHTMLには、自由にhtmlを記述できるので、プログラム内でhtmlタグの文字列を結合して<table>を出現させたり、<svg>で書いた図を出現させたりできます。
今回は、クリックされたときの動作を記述するonclickでclicked関数を呼んでます。

この知識を使うとこんなものが作れます。

キーボードきっかけでドキュメントを変更する(★)

body内
<input id="input">
<div id="output"></div>

<script>
	const input = document.getElementById("input");
	input.addEventListener("keydown", (event) => {
		console.log(event);
		if (event.code === "Enter") {
			const output = document.getElementById("output");
			output.innerHTML += `<b>${input.value}</b><br>`;
			input.value = "";
		}
	})
</script>

この例ではinputフィールドでEnterを押すたびに、入力内容をhtmlドキュメントに追加し、inputフィールドをクリアしていきます。
output.gif
要素.addEventListener(イベント名, 関数);の形式でHTML要素に起こるイベントをトリガーに、発火させる関数を登録することができます。
(event) => { ... }はラムダ式で、function (event) { ... }と同じです。
画面全体のキー入力を監視したい場合は、window.addEventListener("keydown", (e)=>{ ... })で可能です。

この知識を使うとこんなものが作れます。

数秒間処理を休ませる【スリープ】(★★)

body内
<button onclick="clicked(this);">ボタン</button>
<span id="output"></span>
<script>
	function sleep(ms) {
		return new Promise(resolve => setTimeout(resolve, ms));
	}
	async function clicked(button) {
		button.disabled = true; // 処理中はボタンを無効化
		const output = document.getElementById("output");
		for (let i = 0; i < 10; ++i) {
			output.innerHTML = i;
			await sleep(1000); // 1秒待機
		}
		button.disabled = false;
	}
</script>

ボタンを押すと、0から9まで、1秒おきに数字がカウントアップしていくプログラムです。
output.gif
Promise非同期処理を行う機能で、Promise( resolve => { 非同期処理; resolve(); })のように、任意の処理をしたあとにresolveを呼んで正常終了したことを伝えます。
setTimeout(関数, 時間)は指定した時間が経過したら、指定した関数を実行する機能です。
定義したsleep関数に待機させたいミリ秒を記載し、awaitで指定された秒数が経過するまで待ちます。

数秒おきに画面を更新する【定期実行】(★★)

body内
<button onclick="clicked(this);">ボタン</button>
<span id="output"></span>
<script>
	function clicked(button) {
		button.disabled = true;
		const output = document.getElementById("output");
		let i = 0;
		output.innerHTML = i;
		const timerId = setInterval(() => {
			if (++i < 10) {
				output.innerHTML = i;
			} else {
				clearInterval(timerId); // 定期実行の終了
				button.disabled = false;
			}
		}, 1000); // 1秒おきに実行
	}
</script>

先程と同じくボタンを押すと、0から9まで、1秒おきに数字がカウントアップしていくプログラムです。
setInterval(関数, 時間)を実行すると、指定したミリ秒の頻度で関数を定期実行します。
clearInterval(id)で定期実行を停止できます。

注意点

  • JSは基本シングルスレッドで実行されるため、定期実行したいタイミングで、他の処理がされていると後回しにされます
  • ブラウザで別のタブを見ていると定期実行が止まることがあります

この知識を使うとこんなものが作れます。

時間がかかる重い処理をしたい・自由にキャンセルしたい【無理やり】(★★)

CPUを使う時間がかかる重い処理を行いたいとき、たとえ非同期処理として投げたても、JSは基本シングルスレッドで実行されるため、CPUリソースを取られてブラウザがフリーズしてしまいます。
進行状況を表示させようと思ってinnerHTMLに書き込んでも、描画処理も後回しにされるため、進行状況を確認することもできません。(console.logは可能)
queue.png

ここでは

  • フリーズせずに実行させる方法
  • 途中でキャンセルする方法

をまとめて紹介します。

body内
<button onclick="startJob(this);">start</button>
<button onclick="endJob();">end</button>
<span id="output"></span>
<script>
	function sleep(ms) {
		return new Promise(resolve => setTimeout(resolve, ms));
	}
	const output = document.getElementById("output");
	let canceled = false; // キャンセルフラグ
	async function heavyFunction() {// 非常に重い処理
		let sum = 0;
		for (let i = 0; i < 1e10; ++i) {
			sum += i;
			if (i % 1e6 === 0) {
				output.innerHTML = i; // 進捗状況の更新
				await sleep(0); // 重い処理内で定期的に他のジョブへ譲る
				if (canceled)
					return -1;
			}
		}
		return sum;
	}
	async function startJob(button) {
		button.disabled = true;
		canceled = false;
		const ans = await heavyFunction(); // 重い処理が終わるまで待つ
		console.log(ans); // 計算結果取得
		canceled = false;
		button.disabled = false;
	}
	function endJob() {
		canceled = true;
	}
</script>

この例では、1e10までの整数の和を計算する非常に重い処理をします。
output.gif
重い処理中に、定期的にCPUリソースの解放と、キャンセルの確認をするようにします。
sleep関数で休む時間は0ミリ秒ですが、CPUを必要としている他のタスク(描画処理など)へCPUを譲り、CPUを必要とするキューの後ろに回されるため、進行状況がちゃんと表示されます。
ちなみに、sleepを呼んでいる行をコメントアウトすると、直前で書き換えているinnerHTMLの描画も更新されずフリーズします。
sleep.png
なお、heavyFunctionをawaitで待っているからフリーズするんだと思う人がいるかもしれませんが、次のように書き換えてもsleepを挟まなければフリーズします。
startJob関数はすぐに終わるけど、重い処理が結局CPUを占領してしまうためです。

startJobの別の書き方
function startJob(button) {
	button.disabled = true;
	const ans = heavyFunction(); // heavyFunctionは待たずにstartJob関数を終了させる
	ans.then( // 終了時の動作を登録しておく
		async () => {
			console.log(await ans); // 計算結果取得
			canceled = false;
			button.disabled = false;
		}
	);
}

asyncがついた関数はawaitで待たない場合にはPromiseを返しますが、thenで終了時の処理を登録しておけます。

この知識を使うとこんなものが作れます。

時間がかかる重い処理をしたい・自由にキャンセルしたい【Web Worker】(★★★)

先程はかなり無理やりな方法だったので、Web Workerを使ったよりキレイな方法を紹介します。
無理やりやる方法より速く動作します。
output.gif

body内
<button id="start" onclick="startJob();">start</button>
<button onclick="endJob();">end</button>
<span id="output"></span>
<script>
const output = document.getElementById("output");
const start = document.getElementById("start");
const code = `
	console.log("worker started");
	function heavyFunction(end) {
		let sum = 0;
		for (let i = 0; i < end; ++i) {
			sum += i;
			if (i % 1e6 === 0)
				postMessage({status: "doing", value: i});
		}
		return sum;
	}
	onmessage = (e) => {
		console.log("worker received message", e.data);
		const ans = heavyFunction(e.data);
		postMessage({status: "done", value: ans});
	};
`;
let worker;
async function startJob() {
	start.disabled = true;
	worker = new Worker("data:text/javascript;base64," + btoa(code))
	worker.onmessage = (e) => {
		console.log("parent received message:", e.data);
		output.innerHTML = e.data.value;
		if (e.data.status === "done") {
			worker.terminate();
			start.disabled = false;
		}
	};
	worker.postMessage(1e10);
}
function endJob() {
	worker?.terminate();
	start.disabled = false;
}
</script>

本来Web Workerはnew Worker("worker.js")のように、外部のjsファイルを実行するものですが、ローカルで実行するとブラウザに怒られるので、"worker.js"に書くはずだったソースコード文字列をbase64エンコードして渡してあげます。
(ちなみに、ホスティングすればブラウザに怒られないですが、こんな方法もあるという紹介です。node.jsをインストール済みであればnpx serveでlocalhostを立てられます。)
workerとは、メッセージの送信(postMessage)と受信(onmessage) を通してデータのやり取りをします。
worker側のコード(本来"worker.js"に書くべきだったcode文字列内)には、workerが受け取ったメッセージe.data(=1e10)に基づいてheavyFunctionを実行する記述をonmessageに書きます。
worker側のコードで、postMessageを行うことで、現在の状況を親に教えてあげます。
親がworkerからメッセージを受け取ったときの処理として、その結果をinnerHTMLに書き込む記述をworker.onmessageに書きます。
親からworkerへのメッセージはworker.postMessageで与えれます。
途中でworkerをキャンセルするのは、worker.terminate()で可能です。
worker.png

web worker側worker.jsでjsファイルをimportしたい場合、importScripts("hogehoge.js". "hogefuga.js");といった形でインポートすることができます。

注意点

  • worker内でhtmlドキュメントを変更することはできません(メッセージでoutputを渡してもエラーになります)
  • btoa関数は日本語対応していないため、codeの文字列に日本語を含むとエラーになります

この知識を使うとこんなものが作れます。

バックグラウンドで止まらない定期実行【Web Worker】(★★★)

単純なsetIntervalでは、別のタブを見ていると止まってしまうことがあることを述べました。
ここでは、Web Workerを使ってバックグラウンドでも止まらない定期実行を紹介します。

body内
<span id="output"></span>
<script>
	const output = document.getElementById("output");
	const code = `
		onmessage = (e) => {
			setInterval(() => self.postMessage(null), e.data);
		};
	`;
	const worker = new Worker("data:text/javascript;base64," + btoa(code));
	let i = 0;
	output.innerHTML = i;
	worker.onmessage = (e) => {
		output.innerHTML = ++i;
	};
	worker.postMessage(1000);
</script>

ページを開いてからの秒数をひたすら表示し続けるプログラムです。
バックグラウンド処理ができるworker側でsetIntervalを実行し、親にメッセージを送信します。
親はメッセージを受け取ったタイミングで値を更新します。

この知識を使うとこんなものが作れます。

音を鳴らす(★)

body内
<button onclick="play();">play</button>
<script>
	function play() {
		const ctx = new AudioContext();
		const oscillator = ctx.createOscillator();
		let current_time = ctx.currentTime;

		const freqAndTime = [[400, 1], [300, 2]];
		for (const [freq, time] of freqAndTime) {
			oscillator.frequency.setValueAtTime(freq, current_time);
			current_time += time;
		}

		oscillator.connect(ctx.destination);
		oscillator.start(ctx.currentTime);
		oscillator.stop(current_time);
	}
	window.AudioContext = window.AudioContext || window.webkitAudioContext;
</script>

ボタンを押すと400Hzを1秒、300Hzを2秒鳴らすプログラムです。
oscillator.frequency.setValueAtTime(周波数, 開始からの経過時刻)で周波数の切り替えタイミングを指定します。
oscillator.startで音を鳴らし初め、oscillator.stopで終了時間を指定します。

注意点

  • ページを開いた瞬間に音をなるようにコーディングするとブラウザに怒られます

この機能を使うとこんなのが作れます。

選択したテキスト・画像・音楽・動画ファイルの読み込み(★)

body内
<input type="file" onChange="readFile(this.files);">
<div id="output"></div>
<script>
	const output = document.getElementById("output");
	function readFile(files) {
		if (files.length === 0) return;
		const file = files[0];
		console.log(file.type)
		const reader = new FileReader();
		if (file.type.match("text.*")) {
			reader.onload = (event) => { output.innerHTML = `<xmp>${event.target.result}</xmp>`; }
			reader.readAsText(file, "utf-8");
		} else if (file.type.match("image.*")) {
			reader.onload = (event) => { output.innerHTML = `<img src="${event.target.result}" width="500">`; }
			reader.readAsDataURL(file);
		} else if (file.type.match("audio.*")) {
			reader.onload = (event) => { output.innerHTML = `<audio controls src="${event.target.result}"></audio>`; }
			reader.readAsDataURL(file);
		} else if (file.type.match("video.*")) {
			reader.onload = (event) => { output.innerHTML = `<video controls src="${event.target.result}" width="500"></video>`; }
			reader.readAsDataURL(file);
		}
	}
</script>

選択したファイルを表示するプログラムです。
reader.onloadに読み込み完了時の動作を書きます。
テキストであればreadAsText、メディアであればreadAsDataURLでURLを取得してimg/audio/videoタグで表示させます。

この知識を使うとこんなものが作れます。

入力された文字をテキストとしてダウンロードする(★)

ユーザーが操作した結果をダウンロードしたいことは多々あります。
テキストエリアに入力された文字をテキストとして保存するプログラムです。

body内
<textarea id="input" cols="40" rows="20"></textarea>
<button onclick="clicked();">ダウンロード</button>
<script>
	function download(text, filename) {
		const blob = new Blob([text], { type: "text/plain" });
		const link = document.createElement("a");
		link.href = URL.createObjectURL(blob);
		link.download = filename;
		link.click();
	}
	function clicked() {
		const input = document.getElementById("input");
		download(input.value, "sample.txt");
	}
</script>

キーポイントはBlobです。Blobは「Binary Large OBject」の略で、バイナリファイルを扱うのに使います。jsの文字列からテキストファイルを生成してます。
その後、そのファイルへのリンクをクリックした動作を発生させています。document.createElement("a")<a>タグ(リンクに使うもの)を作ったあと、生成したオブジェクトへリンクを張り、クリックすることでダウンロードを実現してます。

やりたいことから学ぶCSS

指定したhtmlタグの見た目を変える

style内
h1, h2 {
	color: blue;
	background-color: yellow;
}

この例では全てのh1タグ、h2タグのフォント色を青に、背景色を黄色にしてます。
h1.png

指定したクラスの見た目を変える

style内
.hogehoge {
	padding: 10px;
	margin: 5px;
}

classにhogehogeと指定すると、要素内部に5pxの余白、要素外部に10pxの余白をあける例です。
htmlタグ側で<button class="hogehoge">ボタン</button>のように使います。

左側にだけclassを指定した例
button.png

また、.の代わりに#を使うと、クラスではなくidに対して適用することができます。
.hogehoge span { font-size: x-large; }のように、指定クラス内のあるタグにだけスタイルを適用する書き方もできます。

htmlタグ側で見た目を変える

<button style="color: red;">ボタン</button>

style=""で指定することでその場で気軽に見た目を変えることができます。
button-inline.png

環境に依存しないフォントを使いたい

CSSでフォントを指定しても、使うデバイスによってはフォントが無く、思うような見た目にならないことがある。
Google Fontsを使うとそれを解消できる。
好きなフォントを選んで、+ボタンを押し、右上のメニューから開くと、htmlに記載すべき内容が現れます。
gfont.png

右側のパネルの

To embed a font, copy the code into the

of your html

の下に書いてあるコードをheader内に貼り付けて、

CSS rules to specify families

の下に書いてある内容をCSSに貼り付けて使います。

以下は「Rampart One」というフォントをbody全体に使う例です。

header内
	<link rel="preconnect" href="https://fonts.googleapis.com">
	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
	<link href="https://fonts.googleapis.com/css2?family=Rampart+One&display=swap" rel="stylesheet">
	<style>
		body {
			font-family: 'Rampart One', cursive;
		}
	</style>

font.png

タグに装飾をつける

style内
h2::before {
    content: "🐦";
    margin-right: 3px;
}
h2::after {
    content: "🕊";
    margin-left: 3px;
}

beforeAfter.png

::beforeでタグの前に、::afterでタグの後に表示する内容を書くことができます。

ページ下にフッターを表示する

style内
footer {
    width: 100%;
    text-align: center;
    background-color: lightblue;
    bottom: 0; /* 一番下に表示 */
    position: absolute;
}

footer::after {
    content: "Copyright: @birdwatcher"
}

body {
    min-height: calc(100vh - 30px); /* ウィンドウサイズ縦幅 - 30ピクセル */
    position: relative;
    box-sizing: border-box;
    padding-bottom: 50px;
}

footer.png

書いている内容が少ないときにも一番下に表示できるように、bodyのサイズの最小値をcalcで計算してます。
<footer></footer>タグで表示できます。

GitHub Pagesで公開する

githubレポジトリのrootか、docs内にindex.htmlを配置します。
githubのSetting→Pages→Branchから対象ブランチを選択して、「(root)/」または「docs/」を選択してSaveを押すとデプロイが開始されます。
プロジェクト直下にindex.htmlがある場合は「(root)/」を、docsフォルダ内にある場合は「docs/」を選択します。
デプロイの状況は上部のActionsから確認できます。
無事にデプロイが完了すると、この画面にURLが現れます。
gh.png
URLはhttps://ユーザー名.github.io/レポジトリ名/になります。
対象ブランチが更新されるたびにActionsが自動でデプロイしてくれます。

ReactとTypeScriptで作る

Reactを使った場合でもTypeScriptを使わずに、JavaScriptで開発することもできますが、実行前にエラーを発見しやすいTypeScriptがおすすめです。
なお、JavaScriptしか使わない人にもTypeScriptの勉強はオススメです。

そもそもTypeScriptとは?

  • JavaScriptに型をアノテーションできるようにした言語(PythonのType Hintみたいな感じ)
  • 型を記述するため実行前にエラーに気づきやすくなる
  • JavaScriptに変換され実行される

Reactとは?

  • xml(html)タグをプログラム内でそのまま扱える(JSX記法)
  • いい感じのUIコンポーネントを使える(Material UI)
  • 状態を意識した書き方ができる

環境構築

  1. Node.jsをインストール
    • node.jsはjavascriptを実行する環境です
    • npxやnpmコマンドが使えるようになります
  2. npm install -g typescriptでTypeScriptコンパイラのインストール
    • TypeScriptのコンパイル: tsc hogehoge.ts(tsからjsが生成される)
    • JavaScriptの実行: node hogehoge.js

Reactプロジェクトの作り方

  1. npx create-react-app --template typescript プロジェクト名でプロジェクトを作成
    • --template typescriptを書かなければjavascriptでプロジェクトが作成されます
  2. cd プロジェクト名でプロジェクトフォルダへ移動し、npm startでlocalhostサーバーを起動
    • これから開発するWebアプリの画面が立ち上がります

ここまででテンプレートができたので、画面に書いてあるとおりソースファイルを編集していきます。
基本はsrc/App.tsxを変更していくことになります。

基本知識

拡張子

  • .ts: 普通のTypeScriptコード
  • .tsx: コード内にXMLタグが書けるTypeScript(TSX形式)
  • .js: 普通のJavaScriptコード
  • .jsx: コード内にXMLタグが書けるJavaScript(JSX形式)

すべてtsx/jsxにしてもよいと思うかもしれないが、純粋なアルゴリズムの処理と明示的に分離する意味で、拡張子を使い分けたほうがよいです。 → tsx/jsxならUIが絡んでいる、ts/jsならUIが絡んでないとわかる。

Reactでは、ソースコードを編集して保存したタイミングでリアルタイムで変更が画面に反映されます。(リロードの必要がない)
output.gif

JSX(TSX)は、生JavaScriptのときhtmlタグ文字列をつなげてinnerHTMLに代入していたものを、文法としてサポートしてくれているイメージです。
どんなものなのかは説明するよりコードを見たほうが速いので早速見ていきましょう。

useStateで状態管理(★)

Reactは状態(State)を管理し、更新のあった部分だけをレンダリングしなおすという思想があり、useStateはそれを実現するものです。
useStateの例を示します。App.tsxを以下のように書き換えました。

App.tsx
import React, { useState } from 'react';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <MakeButton />
      </header>
    </div>
  );
}

function MakeButton() {
  const [count, setCount] = useState(0);
  const buttonClick = () => { setCount(count + 1); };
  return (<button onClick={buttonClick}> {count} </button>);
}

export default App;

App関数に書いたタグの内容がそのまま見た目に反映されます。(App関数がメインのindex.tsxから呼ばれているため。)
htmlタグをreturnで返す関数は、<関数名 />で呼び、タグを繋げることができます。
App関数内にある<MakeButton />には、下のMakeButton関数の結果が格納されます。

この例はボタンをクリックすると数字が増えていくサンプルです。(App.cssでボタンサイズを変更してます)

useStateは次のような形式をしています。

useStateについて
const [現在の値, set関数] = useState<>(初期値);

状態の値を更新するには、現在の値+=1ではなく、set関数(現在の値+1)のように更新する必要があります。
これにより、現在の値と新しい値に差があればレンダリングされることになります。

さらに理解を深める例をもう一つ見てみましょう。

function MakeButton() {
  const [array, setArray] = useState([0, 1, 2]);
  const buttonClick = (i: number) => {
    const newArray = array.concat();// 配列のコピー
    newArray[i]++;
    setArray(newArray);
    // 更新されないダメな例↓
    // array[i]++;
    // setArray(array);
  };
  return (<div>
    {array.map((x, i) =>
      <button key={i} onClick={() => buttonClick(i)}>{x}</button>
    )}
  </div>);
}
export default App;

配列を状態として持ち、配列からボタンを作り、クリックされたボタンのカウントが増える例です。

buttonClick関数内で、配列をコピーしてから変更し、setArrayをしています。
コピーをせずに元のオブジェクトを変更してsetしても更新されません。
buttonタグのkeyは、無いと警告が出るのでつけてます。
キーはhtmlツリーの兄弟内で一意である必要があるので、この例では配列のindexを付与してます。

この2つの例でReactの状態管理の雰囲気がつかめたと思います。
TypeScriptを初めて見た人は、型が全然書いてないと思ったかもしれませんが、型推論が効く部分は型を記載しなくてもよいことになってます。

useRefでタグを参照する(★)

useRefはgetElementByIdの代わりに、事前に取得したいタグに参照をつけておけます。

App.tsx
import React, { useState, useRef } from 'react';
import './App.css';

function App() {
  const inputElement = useRef<HTMLInputElement>(null);
  const [output, setOutput] = useState("");
  const buttonClick = () => {
    setOutput(inputElement.current?.value ?? "");
  };
  return (
    <div className="App">
      <header className="App-header">
        <input ref={inputElement} />
        <button onClick={buttonClick}> ボタン </button>
        {output}
      </header>
    </div>
  );
}

export default App;

ボタンを押すと、入力されたテキストを表示するサンプルです。

useRefについて
const 部品 = useRef<>(null);

部品.currentでhtml要素(DOM)を取得できます。(getElementByIdで取得した結果と同じイメージ)
<タグ ref={部品}>で、そのタグの値を参照できます。

useEffectでUI以外の処理を書く(★★)

useEffectは、UIのレンダリングが済んだあとに実行したい処理を書きます。

App.tsx
import React, { useState, useEffect } from 'react';
import './App.css';

function App() {
  const [state, setState] = useState(0);
  const update = () => { setState(state + 1); };
  useEffect(() => {
    const timeId = setInterval(update, 1000);
    return () => clearInterval(timeId);
  }, [state]);
  return (
    <div className="App">
      <header className="App-header">
        {state}
      </header>
    </div>
  );
}

export default App;

1秒おきに数字をカウントアップしていくサンプルです。

useEffectについて
useEffect(() => { 処理内容; return キャンセル関数; }, [依存するものたち]);

useEffectは、依存するものが更新されたら呼ばれたときに、処理したい内容を登録します。
キャンセル関数は、前回の処理を停止させる目的でreturnします。

今回の定期実行setIntervalの例がわかりやすく、stateの状態に依存して定期実行を登録したが、stateが変わったら処理を変えて、clearIntervalで前の定期実行は停止させるというイメージです。

その他の例としては、EventListenerの登録があります。
キーボード入力に対する処理を登録するが、状態に依存して処理を変え、前の登録は削除するイメージです。

useEffect(() => {
  document.addEventListener("keydown", 関数);
  return () => document.removeEventListener("keydown", 関数);
}, [state]);

useMemoとuseCallbackでメモ化して軽量化(★★)

useMemoは関数の結果をキャッシュしておける機能で、同じ入力には同じ出力を返す関数につけるものです。(pythonでいうlru_cache)

App.tsx
import React, { useState, useMemo } from 'react';
import './App.css';

function App() {
  const [state, setState] = useState(1e9);
  const [count, setCount] = useState(0);

  const totalWithMemo = useMemo(() => {
    let sum = 0;
    for (let i = 0; i < state; ++i)
      sum += i;
    return sum;
  }, [state]);
  return (
    <div className="App">
      <header className="App-header">
        <button onClick={() => { setState(state + 1); }}>重い計算</button>
        sum 0 to {state} = {totalWithMemo}
        <button onClick={() => { setCount(count + 1); }}>軽い計算</button>
        {count}
      </header>
    </div>
  );
}

export default App;

メモ化あり:軽い計算の方は軽い

メモ化なし:軽い計算の方も重い

useMemoについて
useMemo(() => { 処理内容 }, [依存するものたち]);

依存するものが変わったときだけ処理をやり直しますが、変わらなければ前の結果をそのまま返します。

次は、useCallbackについてです。
useCallbackは、関数自体をメモ化する機能で、useCallback(fn, deps)useMemo(() => fn, deps)と等価です。
「関数自体の生成コストが高いことなんてある?」って思いましたが、「依存が連鎖しているケースなのかな?」と思って無理やりサンプルを作ってみました。
基本は先程のプログラムとほとんど同じですが、合計を計算する部分を関数として切り出しました。

App.tsx
import React, { useState, useMemo, useCallback } from 'react';
import './App.css';

function App() {
  const [state, setState] = useState(1e9);
  const [count, setCount] = useState(0);

  const total = useCallback(() => {
    let sum = 0;
    for (let i = 0; i < state; ++i)
      sum += i;
    return sum;
  }, [state]); // stateに依存している

  const totalDivided1000 = useMemo(() => {
    return total() / 1000;
  }, [total]); // total関数に依存している

  return (
    <div className="App">
      <header className="App-header">
        <button onClick={() => { setState(state + 1); }}>重い計算</button>
        sum 0 to {state} / 1000 = {totalDivided1000}
        <button onClick={() => { setCount(count + 1); }}>軽い計算</button>
        {count}
      </header>
    </div>
  );
}

export default App;

totalDivided1000関数の結果をuseMemoでメモ化するようにしてます。内部でtotal関数を呼んでいるので、依存しているのはtotal関数を指定してます。
そしてtotal関数にuseCallbackを指定して、依存しているstateを指定してます。
このコードでは先程と同じように、正しく「軽い計算」ボタンで軽く動作してくれます。

一方、このコードからuseCallbackを外すと、total関数が毎回生成されるため、「軽い計算」ボタンでも重くなります。

もちろんtotalDivided1000関数の依存関係に[state]を指定すれば解決しますが、total関数を使う人がいちいち関数の実装を見に行って依存する変数を取り出すのは大変であるということだと理解しました。

useCallbackについて
useCallback(関数, [依存するものたち]);

MUIを使っていい感じの見た目のUIを作る(★)

Material UIを使うと今どきな見た目のコンポーネントを簡単に作ることができます。
mui.png
準備
インストールガイドに従って、まずはデフォのものを入れておきます。

npm install @mui/material @emotion/react @emotion/styled

使ってみる
たとえば、ボタンコンポーネントから好きな見た目のものを探して、「<>」からフルのソースコードを表示します。
src.png
ソースコードをコピペして、適当なファイル名で保存します。

IconLabelButtons.tsx
import * as React from 'react';
import Button from '@mui/material/Button';
import DeleteIcon from '@mui/icons-material/Delete';
import SendIcon from '@mui/icons-material/Send';
import Stack from '@mui/material/Stack';

export default function IconLabelButtons() {
  return (
    <Stack direction="row" spacing={2}>
      <Button variant="outlined" startIcon={<DeleteIcon />}>
        Delete
      </Button>
      <Button variant="contained" endIcon={<SendIcon />}>
        Send
      </Button>
    </Stack>
  );
}

デフォのものしかインストールしていないので、'@mui/icons-material/Delete'などはimportエラーになります。インストールガイドから当該のパッケージをインストールします。

npm install @mui/icons-material

これでimportエラーがなくなったので、App.tsx側で呼び出してあげると表示されます。
delsend.png

App.tsx
import React, { useState, useRef } from 'react';
import './App.css';
import IconLabelButtons from './IconLabelButtons';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <IconLabelButtons />
      </header>
    </div>
  );
}

export default App;

コピペでモダンなUIが作れるので簡単ですね。
生JSだと、自分でCSSを頑張って書くか、ネット上で良さげなCSSを探してくることにりますね。

GitHub Pagesへの公開

まず、package.jsonに以下を追加します。
※mainブランチへpushする設定です。必要に応じてmainをmasterに変えてください。

package.json
  (略)
  "homepage": "https://GitHubアカウント名.github.io/GitHubリポジトリ名/",
  "scripts": {
    (略)
    "rm": "rm -rf ./docs",
    "mv": "mv build ./docs",
    "git": "git add . && git commit && git push origin main",
    "deploy": "npm run rm && npm run build && npm run mv && npm run git"
  },
  (略)

githubレポジトリ直下に、package.jsonが来るように全体を移動する。(すなわちgithubレポジトリフォルダ=Reactプロジェクトフォルダにする)

npm run deployを実行する(package.jsonに登録したコマンドが実行されます)
githubのSetting→Pages→Branchから対象ブランチを選択して、「docs/」を選びSaveを押す。

npm run deployで以下のようなエラーが出る人へ

Please supply the message using either -m or -F option.

gitのエディタが指定されてないエラーなので、git config --global core.editor emacsのようにエディタを指定してあげると大丈夫です。

#Please enter the commit message for your changes. ...

というエディタ画面が開かれるので、一番上の空欄行にコミットメッセージを書いて保存して終了です。
何も書かないとエラーになります。

番外編: npmパッケージを使ったJSをgithub pagesに公開する

生JSだけではやりたいことができないとき、npmパッケージ(ライブラリ)を使いたくなります。(たとえば、PDFをパースしたいとか文字コードを変換したいなど)
しかし、これらのライブラリはNode.js上で動くもので、そのまま公開しても動きません。
github pagesで動くようにするには、ビルドして公開する必要があります。
その方法を紹介します。

今回は、文字コードを扱うライブラリであるencoding-japaneseを例に説明します。

既にNode.jsをインストール済みであるとします。
適当にプロジェクトフォルダを作ったあと、フォルダ内で以下を実行します。

npm init -y

すると、使っているパッケージを管理するpackage.jsonが生成されます。(poetryでいうtomlファイル)
次に、今回例として扱うパッケージをインストールします。

npm i encoding-japanese

node_modulesという依存しているライブラリを格納したフォルダができるので、.gitignoreに追加しておきましょう。(pythonでいう.venv

.gitignore
node_modules

次はメインとなるindex.htmlとindex.jsを作りましょう。
index.htmlからindex.jsをimportするためにbody内に以下を記述します。

index.htmlのbody内
<script type="module" src="./index.js"></script>

簡単のため、今回はindex.jsにはimportだけを記述していることにします。

index.js
import Encoding from "encoding-japanese";

動作確認やビルドにはviteを使いますが、github pagesでうまく動くようにvite.config.jsを作っておきます。

vite.config.js
export default {base:""}

開発中の動作確認は次のコマンドでlocalhostを立てながら行います。

npx vite dev

表示されたlocalhostにアクセスすると、index.htmlの内容が表示されます。
index.htmlを編集するとリアルタイムで更新されます。

さて、github pagesで公開するためにビルドします。

npx vite build

生成されたdistをdocsという名前に変えてgithubへpushし、docs以下をホスティングすることで公開できます。

なお、index.htmlのbuttonのonclickからindex.jsの関数を呼び出すことはできないので、index.js側で次のようにEventListenerを追加してあげる必要があります。

index.js内
document.getElementById("ボタンのID").addEventListener("click", () => { クリック時の処理 });

ちゃんと意味のあるコードを書いたらこんなものができました。

JavaScriptのTips

学んでいく中で他の言語と違うところとか、特殊なことのメモ。

const vs let vs var

変数の宣言には3つあるが、次のルールで使うと良い。
基本的にconst、変更の可能性があるならlet、varは使わない。
※プリミティブ型でなければ、constを指定してもメンバ変数は変更できることに注意

配列の初期化、コピー、ループ

サイズnのゼロ埋め初期化

const ary0 = new Array(n).fill(0); // 0の代わりに{x:0,y:0}を与えるとすべて同じ参照が渡されるため注意
const ary1 = [...new Array(n)].map(() => 0); // スプレッド構文。0の代わりに{x:0,y:0}を与えても大丈夫

配列のコピー

const copy0 = ary0.concat();
const copy1 = [...ary1];

2次元配列のディープコピー

const copy = ary.map(x => x.concat());

ループ

// 普通のfor
for(let i = 0; i < ary.length; ++i)
    console.log(i, ary[i]);
// for of
for (const x of ary)
    console.log(x);
for (const [i, x] of ary.entries()) // indexもほしいとき
	console.log(i, x);
// map
ary.map(x=>{console.log(x);});
ary.map((x,i)=>{console.log(i, x);}); // indexもほしいとき
// forEach
ary.forEach((x,i)=>{console.log(i, x);});

JSのビット演算の注意点

JSの数値計算は、基本的にすべて64ビット浮動小数点演算される。(bigintは除く)
ただし、ビット演算子だけは32ビットの整数に変換してから処理されます。

ビット演算をして整数にしたとしても、基本符号あり整数として扱われるため、ビット演算と算術演算を組み合わせて使う場合は注意が必要です。

console.log(~0); // => -1: ビット反転
console.log(~0>>>0); // => 4294967295: 符号なしに変換する
console.log((~0>>>0)|(0>>>0)); // => -1: 符号なし同士のOR演算が符号ありになっている

import {xxx}import xxx の違い

参照される側でexport default xxxしているものはimport xxxで、export xxxしているものはimport {xxx}とする。
export defaultは1つしか指定できない。
exportは複数指定できるimport { xxx, yyy }し、import時に名前を変更import { xxx as aaa }できる。

canvas vs svg

図を描くときにcanvasを使うかsvgを使うか。
canvasはビットマップ画像として保存したいときに使う。
svgはクリックされたオブジェクトなどを判定したいときに使う。

まとめ

生JSのよく使う機能まとめ

コード 説明
console.log(値) コンソールへ出力
alert(メッセージ) アラートダイアログの表示
confirm(メッセージ) 確認ダイアログの表示。戻り値で分岐可能。
document.getElementById(ID) 要素の取得
要素.innerHTML 要素内のHTML文書。上書きすれば見た目が変化
要素.addEventListener(イベント名, 関数) 要素にイベントが起きたときの処理を登録する
setTimeout(関数, 時間) 指定時間(ミリ秒)経過後に関数を実行
setInterval(関数, 時間) 指定時間(ミリ秒)の間隔で関数を実行
clearInterval(ID) setIntervalを解除できる
await new Promise(resolve => setTimeout(resolve, ミリ秒)) 指定時間待つ。待っている間CPUリソースを開放する
worker = new Worker("worker.js") ワーカーを生成する。並列処理できる。動作確認はnpx serveなどのlocalhost上で。
worker.onmessage = (e) => { 処理 } ワーカーから受信したときの処理を登録。e.dataで受信したメッセージを取得できる
worker.postMessage(メッセージ) ワーカーへメッセージを送信
worker.terminate() ワーカーを終了させる
location.search URLの?部分を取得(?も含む)。new URLSearchParams(location.search)get(キー)で値を取得可能
location.hash URLの#部分を取得(#も含む)

簡単な対応表

項目 生js React 使い方
状態管理 グローバル変数/メンバ変数 useState const [現在の値, set関数] = useState<型>(初期値);
参照方法 getElementById useRef const 部品 = useRef<型>(null);
レンダリング後の処理 自由 useEffect useEffect(() => { 処理内容; return キャンセル関数; }, [依存するものたち]);
メモ化 object/Map useMemo useMemo(() => { 処理内容 }, [依存するものたち]);
関数のメモ化 意識しない useCallback useCallback(関数, [依存するものたち]);

結局どっちがいいの?

使ってみた個人的な感想です。

項目 生JS React + TS コメント
お手軽さ getElementByIdさえ覚えれば使えるjs。1つのファイルで完結できるjs。環境構築がいらないjs。
易しさ 適当に書いても動くjs、概念を意識しないとうまく動かないReact
コードの綺麗さ 自由すぎる生jsコードは乱れがち
UIの綺麗さ MUI使うと簡単に綺麗に書けるReact
容量 テンプレートだけで300MB以上あるReact(node_modulesが重い。削除しても復元はできるけど...)

本格的な開発をするのであれば、ReactとTypeScriptがあったほうが長期的にみて管理しやすいと思いましたが、趣味レベルの小さな開発であればどっちでもいいかなという気持ちになりました。
小さい機能しか作らないのにわざわざReactを持ち出すのは逆に面倒そうです。
(特に、React + TypeScriptでWeb Workerを動かすにはどうしたらいいんだろう。Worker引数に与えるのはjsファイルになるわけだから、無理やりな方法しか思いつかない。)

生JSだと文字列を結合しまくって汚くなる問題は、関数としてまとめたり、書き方を工夫することで一定回避できそうです。

'<button>'+x+'</button><img src="'+src+'" width="'+width+'">' // わかりにくい
`<button>${x}</button><img src="${src} width="${width}">` // わかりやすくする

TypeScripの勉強は、新しい発見があり、JSをちゃんと理解するためにやっておいて損はないと思いました。
Reactを使わなくても、TypeScriptで書いて、jsに変換したものをhtmlから読み込む開発がシンプルでキレイかもしれません。

あとがき

普通、Webアプリを作るなら、利用者側に見える部分である「フロントエンド」(今回紹介した部分)、複雑な処理をする「バックエンド」( + データを管理する「データベース」)に分かれると思います。
今回、フロントのみにしたのは、完全に無料で行いたかったからです。
最初は、バックエンドサーバーもGCP無料枠を使えば、ランニングコストゼロで作れると考えましたが、以下のリスクがあると考えました。

  • 悪意を持った人がリクエストを叩きまくると無料枠のリクエスト上限を超えてお金が発生する
  • 無料枠の弱小サーバーではリクエストを捌ききれない可能性
  • (セキュリティ詳しくない)

性善説に立てば別によいのかもしれませんが、万が一のことを考えるとやりたくないという気持ちでした。
一方、フロントだけであれば、すべてローカルにダウンロードされたあとでJavaScriptが動くだけなので、Webサイトだけをホスティングできれば完全無料です。
もちろん以下のデメリットがあります。

  • 本来サーバー側で行うような重たい処理をクライアント側で行うのでふさわしくない
  • クライアント端末の処理能力に依存してしまう

しかし、「趣味で簡単なアプリを公開したい」くらいのモチベーションだったので、フロントのみの構成になりました。

JavaScriptを勉強する前のフロントの経験としては、Win32APIでWindows用のアプリを作ったり、JavaのAWT/SwingでJRE上で動くUI、アプレットを作った経験がありました。
しかし、いざgithubで公開しても、

  • それを実行できる環境がないと動かない
  • ダウンロードする手間がある
  • スマホで動かない

という利用者にとってハードルの高いものになってしまいます。
たとえば就活で技術力をアピールするのに「こんなアプリを作りました!」ってgithubのプロジェクトURLを渡しても、DL&環境構築してまで試しに使ってもらえないかもしれません。
一方でJavaScriptはブラウザで動作するので、URLを渡すだけで試しに動かしてもらえるので、安直ですが「おお!すげー!」という印象を持たせることができるかもしれません。

244
285
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
244
285