Asset Tokenizationプロジェクト5日目(フロントエンドの実装)
Vite + React + TypeScript + Viem + Wagmiでフロントエンド構築
日付: 2026年1月5日
学習内容: モダンなWeb3フロントエンドの構築、Vite/React/TypeScript/Viem/Wagmiの統合、トークンセールDAppの実装
1. モダンなWeb3フロントエンドスタックの概要
1.1 使用する技術スタック
本プロジェクトでは、以下の最新技術を使用してトークンセールのフロントエンドを構築します:
| 技術 | 役割 | 特徴 |
|---|---|---|
| Vite | ビルドツール | 高速な開発サーバー、最適化されたビルド |
| React | UIフレームワーク | コンポーネントベース、豊富なエコシステム |
| TypeScript | 型システム | 型安全性、優れた開発体験 |
| Viem | Ethereumライブラリ | 軽量、型安全、モジュラー設計 |
| Wagmi | Reactフック | viemベース、宣言的なWeb3統合 |
| Vitest | テストフレームワーク | Viteネイティブ、高速実行 |
1.2 従来のスタックとの比較
従来のスタック(Web3.js + Truffle + Webpack):
Web3.js → 大きなバンドルサイズ
Webpack → 遅いビルド時間
JavaScript → 型チェックなし
新しいスタック(Viem + Wagmi + Vite):
Viem → 小さいバンドルサイズ、Tree-shakable
Vite → 超高速なHMR(Hot Module Replacement)
TypeScript → コンパイル時の型チェック
2. 各技術の詳細解説
2.1 Vite - 次世代フロントエンドビルドツール
Viteとは
Vite(フランス語で「速い」の意味)は、Evan You(Vue.jsの作者)によって作成された、高速なフロントエンド開発ツールです。
主な特徴:
- ネイティブESM: ブラウザのネイティブESモジュールを利用
- 高速なHMR: 変更を即座に反映
- 最適化されたビルド: Rollupベースのプロダクションビルド
- プラグインエコシステム: Rollupプラグインとの互換性
用語解説:
1. ESモジュール(ESM)とは:
ESモジュール(ECMAScript Modules)は、JavaScriptの標準的なモジュールシステムです。importとexport文を使用して、コードをモジュール単位で分割し、再利用可能にします。
// モジュールのエクスポート(module.js)
export function add(a, b) {
return a + b;
}
// モジュールのインポート(main.js)
import { add } from './module.js';
console.log(add(1, 2)); // 3
従来の方法との違い:
-
CommonJS(Node.js):
require()とmodule.exportsを使用(例:const fs = require('fs')) -
ESモジュール:
importとexportを使用(例:import fs from 'fs')
ViteでのネイティブESMの利点:
- 開発モードでは、ブラウザが直接ESモジュールを読み込むため、事前のバンドルが不要
- 起動が非常に高速(全ファイルをバンドルする必要がない)
- 変更されたモジュールのみを再読み込みできるため、HMRが高速
2. HMR(Hot Module Replacement)とは:
HMR(ホットモジュールリプレースメント)は、開発中にコードを変更した際に、ページ全体をリロードせずに変更箇所だけを更新する機能です。
従来の開発フロー:
コード変更 → ページ全体をリロード → 状態がリセットされる
HMRを使った開発フロー:
コード変更 → 変更されたモジュールのみ更新 → 状態が保持される
HMRの利点:
- ページのリロードが不要なため、開発が高速
- フォーム入力やスクロール位置などの状態が保持される
- エラーの修正が即座に反映される
例:
// App.tsxを編集
function App() {
return <div>Hello World</div>; // ← これを変更
}
// ブラウザは自動的にこの部分だけを更新
// ページ全体のリロードは発生しない
3. Rollupとは:
Rollupは、JavaScriptモジュールをバンドル(1つまたは複数のファイルにまとめる)するためのビルドツールです。ESモジュールを前提として設計されており、Tree-shaking(未使用コードの削除)に優れています。
Rollupの主な特徴:
- Tree-shaking: 使用されていないコードを自動的に削除し、バンドルサイズを最小化
- ESモジュール対応: モジュール形式を維持したままバンドル
- プラグインシステム: 様々な機能をプラグインで拡張可能
Tree-shakingの例:
// utils.js
export function usedFunction() {
return 'used';
}
export function unusedFunction() {
return 'unused';
}
// main.js
import { usedFunction } from './utils.js';
console.log(usedFunction());
// Rollupでバンドルすると...
// unusedFunctionは使用されていないため、バンドルから除外される
ViteでのRollupの使用:
- 開発モード: ネイティブESMを使用(Rollupは使用しない)
- プロダクションモード: Rollupを使用して最適化されたバンドルを生成
他のバンドラーとの比較:
- Webpack: より複雑な設定が可能だが、Tree-shakingがRollupほど効率的でない場合がある
- Rollup: シンプルで高速、Tree-shakingに優れる(ライブラリ開発に適している)
- esbuild: 非常に高速だが、プラグインエコシステムが限定的
なぜViteが速いのか:
従来のバンドラー(Webpack):
起動時に全ファイルをバンドル → 遅い起動
変更時に再バンドル → 遅いHMR
Vite:
起動時はバンドル不要 → 即座に起動
変更時は該当モジュールのみ更新 → 高速HMR
Viteの動作原理:
// 開発モード
import { something } from './module.js'
// ↓ ブラウザが直接ESモジュールとしてロード(バンドル不要)
// プロダクションモード
// ↓ Rollupで最適化してバンドル
Viteでサポートされる機能
標準機能:
- TypeScript(設定不要)
- JSX/TSX
- CSS/SCSS/Less
- 静的アセット処理
- JSON import
- Web Workers
- WebAssembly
各機能の詳細解説:
1. JavaScriptのサポート:
Viteは、JavaScript(ES6+)を完全にサポートしています。設定不要で、.jsファイルをそのまま使用できます。
// app.js - そのまま動作
export function greet(name) {
return `Hello, ${name}!`;
}
// main.js
import { greet } from './app.js';
console.log(greet('World')); // Hello, World!
JavaScriptとTypeScriptの違い:
- JavaScript(.js): 動的型付け、実行時にエラーが発見される
- TypeScript(.ts): 静的型付け、コンパイル時にエラーが発見される
ViteでのJavaScript使用例:
// utils.js
export function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
// app.js
import { calculateTotal } from './utils.js';
const total = calculateTotal([{ price: 10 }, { price: 20 }]);
console.log(total); // 30
2. JSX/TSXとは:
JSX(JavaScript XML)とTSX(TypeScript XML)は、JavaScript/TypeScript内でHTMLライクな構文を記述できる拡張構文です。Reactコンポーネントを記述する際に使用されます。
JSXの基本構文:
// App.jsx
function App() {
const name = 'World';
return (
<div>
<h1>Hello, {name}!</h1>
<button onClick={() => alert('Clicked!')}>
Click me
</button>
</div>
);
}
TSXの基本構文:
// App.tsx
interface Props {
name: string;
age: number;
}
function App({ name, age }: Props) {
return (
<div>
<h1>Hello, {name}!</h1>
<p>Age: {age}</p>
</div>
);
}
JSXとTSXの違い:
- JSX(.jsx): JavaScriptファイルでJSXを使用
- TSX(.tsx): TypeScriptファイルでJSXを使用(型チェックが可能)
ViteでのJSX/TSXの使用:
// Viteは自動的にJSX/TSXをトランスパイル
// vite.config.tsでReactプラグインを設定
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()], // JSX/TSXをサポート
});
3. 静的アセット処理とは:
静的アセットとは、画像、フォント、動画などの、コードではないファイルのことです。Viteは、これらのファイルを自動的に処理し、適切なパスに配置します。
静的アセットの種類:
-
画像:
.png,.jpg,.jpeg,.gif,.svg,.webp -
フォント:
.woff,.woff2,.ttf,.otf -
動画・音声:
.mp4,.webm,.mp3,.wav -
その他:
.pdf,.zipなど
Viteでの静的アセットの使用方法:
方法1: import文でインポート:
// 画像をインポート
import logo from './assets/logo.png';
import icon from './assets/icon.svg';
function App() {
return (
<div>
<img src={logo} alt="Logo" />
<img src={icon} alt="Icon" />
</div>
);
}
方法2: publicディレクトリに配置:
public/
├── favicon.ico
└── images/
└── hero.jpg
<!-- index.htmlで直接参照 -->
<img src="/images/hero.jpg" alt="Hero" />
方法3: 動的なインポート:
const imageUrl = new URL('./assets/image.png', import.meta.url).href;
Viteの静的アセット処理の特徴:
- 自動的な最適化: 画像の圧縮や最適化
-
ハッシュ付きファイル名: キャッシュバスティング(
logo.a1b2c3.png) - 小さいファイルのインライン化: 小さな画像はBase64として埋め込まれる
- アセットのサイズ制限: デフォルトで4KB以下のファイルはインライン化
4. JSON importとは:
JSON importは、JSONファイルをJavaScript/TypeScriptモジュールとして直接インポートできる機能です。設定ファイルやデータファイルを簡単に読み込めます。
JSON importの使用方法:
// config.json
{
"appName": "My App",
"version": "1.0.0",
"features": {
"darkMode": true,
"notifications": false
}
}
// app.ts
import config from './config.json';
console.log(config.appName); // "My App"
console.log(config.version); // "1.0.0"
console.log(config.features.darkMode); // true
JSON importの用途:
- 設定ファイル: アプリケーションの設定を管理
- データファイル: 初期データやマスターデータを読み込み
- 多言語対応: 翻訳データをJSONで管理
実用例:
// translations/ja.json
{
"welcome": "ようこそ",
"goodbye": "さようなら"
}
// translations/en.json
{
"welcome": "Welcome",
"goodbye": "Goodbye"
}
// App.tsx
import jaTranslations from './translations/ja.json';
import enTranslations from './translations/en.json';
const translations = {
ja: jaTranslations,
en: enTranslations
};
function App() {
const lang = 'ja';
return <h1>{translations[lang].welcome}</h1>;
}
型安全性の確保:
// config.d.ts
declare module '*.json' {
const value: any;
export default value;
}
// または、より厳密に
interface Config {
appName: string;
version: string;
features: {
darkMode: boolean;
notifications: boolean;
};
}
declare module './config.json' {
const config: Config;
export default config;
}
5. Web Workersとは:
Web Workersは、ブラウザのメインスレッドとは別のバックグラウンドスレッドでJavaScriptコードを実行するためのAPIです。重い計算処理をメインスレッドをブロックせずに実行できます。
Web Workersの用途:
- 重い計算処理: 画像処理、データ解析、暗号化処理
- バックグラウンド処理: データのダウンロード、キャッシュの更新
- UIの応答性向上: メインスレッドをブロックしない
ViteでのWeb Workersの使用方法:
// worker.ts
self.onmessage = function(e) {
const { data } = e;
// 重い計算処理
const result = heavyCalculation(data);
// 結果をメインスレッドに送信
self.postMessage(result);
};
function heavyCalculation(data: number[]): number {
return data.reduce((sum, num) => sum + num * num, 0);
}
// main.ts
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' }
);
worker.postMessage([1, 2, 3, 4, 5]);
worker.onmessage = (e) => {
console.log('Result:', e.data); // Result: 55
};
Web Workersの制限事項:
- DOMにアクセスできない
-
windowオブジェクトにアクセスできない - メインスレッドとメッセージパッシングで通信する必要がある
制限事項の詳細解説:
1. DOMにアクセスできない理由と影響:
なぜDOMにアクセスできないのか:
Web Workersは、メインスレッドとは完全に分離された別スレッドで実行されます。DOM(Document Object Model)はメインスレッドでのみ操作可能なため、Web Workersから直接DOMにアクセスすることはできません。これは、マルチスレッド環境での競合状態(race condition)を防ぐための設計です。
DOMアクセスを試みた場合のエラー:
// worker.ts
// ❌ これはエラーになる
const element = document.getElementById('myElement');
// Error: ReferenceError: document is not defined
const button = document.querySelector('button');
// Error: ReferenceError: document is not defined
element.innerHTML = 'Hello';
// Error: ReferenceError: element is not defined
DOM操作が必要な場合の対処法:
DOM操作が必要な場合は、Web Workerで計算処理を行い、結果をメインスレッドに送信して、メインスレッドでDOMを更新します。
// worker.ts - 計算処理のみ
self.onmessage = function(e) {
const { data } = e;
const result = heavyCalculation(data);
// 結果をメインスレッドに送信
self.postMessage({ result });
};
function heavyCalculation(data: number[]): number {
// 重い計算処理
return data.reduce((sum, num) => sum + num * num, 0);
}
// main.ts - DOM操作はメインスレッドで
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.postMessage([1, 2, 3, 4, 5]);
worker.onmessage = (e) => {
const { result } = e.data;
// ✅ メインスレッドでDOMを更新
const resultElement = document.getElementById('result');
if (resultElement) {
resultElement.textContent = `Result: ${result}`;
}
// ✅ ボタンの状態を更新
const button = document.querySelector('button');
if (button) {
button.disabled = false;
button.textContent = 'Calculate';
}
};
実用例: 進捗表示の更新:
// worker.ts
self.onmessage = function(e) {
const { items } = e.data;
items.forEach((item, index) => {
// 各アイテムを処理
const processed = processItem(item);
// 進捗をメインスレッドに送信
self.postMessage({
type: 'progress',
progress: ((index + 1) / items.length) * 100,
result: processed
});
});
self.postMessage({ type: 'complete' });
};
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.onmessage = (e) => {
const { type, progress, result } = e.data;
if (type === 'progress') {
// ✅ メインスレッドでプログレスバーを更新
const progressBar = document.getElementById('progress-bar');
if (progressBar) {
progressBar.style.width = `${progress}%`;
}
// ✅ 結果をリストに追加
const resultList = document.getElementById('result-list');
if (resultList) {
const li = document.createElement('li');
li.textContent = result;
resultList.appendChild(li);
}
} else if (type === 'complete') {
// ✅ 完了メッセージを表示
const status = document.getElementById('status');
if (status) {
status.textContent = 'Processing complete!';
}
}
};
2. windowオブジェクトにアクセスできない理由と影響:
なぜwindowオブジェクトにアクセスできないのか:
windowオブジェクトは、ブラウザのグローバルスコープを表し、メインスレッドでのみ利用可能です。Web Workersは独立したグローバルスコープ(self)を持っているため、windowオブジェクトにはアクセスできません。
windowオブジェクトへのアクセスを試みた場合のエラー:
// worker.ts
// ❌ これらはすべてエラーになる
const location = window.location;
// Error: ReferenceError: window is not defined
const localStorage = window.localStorage;
// Error: ReferenceError: window is not defined
window.alert('Hello');
// Error: ReferenceError: window is not defined
const fetch = window.fetch;
// Error: ReferenceError: window is not defined
Web Workersで利用可能なグローバルオブジェクト:
Web Workersでは、windowの代わりにselfを使用し、一部のAPIは利用可能です。
// worker.ts
// ✅ 利用可能なグローバルオブジェクト
console.log(self); // WorkerGlobalScope
// ✅ fetch APIは利用可能
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => {
self.postMessage({ data });
});
// ✅ setTimeout/setIntervalは利用可能
setTimeout(() => {
self.postMessage({ type: 'timeout' });
}, 1000);
// ✅ WebSocketは利用可能
const ws = new WebSocket('wss://example.com');
ws.onmessage = (event) => {
self.postMessage({ type: 'websocket', data: event.data });
};
// ✅ IndexedDBは利用可能
const request = indexedDB.open('myDatabase');
request.onsuccess = (event) => {
const db = event.target.result;
self.postMessage({ type: 'dbReady', db });
};
windowオブジェクトの機能が必要な場合の対処法:
1. ローカルストレージの代替:
// ❌ worker.ts - これは動作しない
const data = window.localStorage.getItem('key');
// ✅ 代替案1: IndexedDBを使用
// worker.ts
const request = indexedDB.open('myDB');
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction(['store'], 'readonly');
const store = transaction.objectStore('store');
const getRequest = store.get('key');
getRequest.onsuccess = () => {
self.postMessage({ data: getRequest.result });
};
};
// ✅ 代替案2: メインスレッドにリクエストを送信
// worker.ts
self.postMessage({ type: 'getStorage', key: 'myKey' });
// main.ts
worker.onmessage = (e) => {
if (e.data.type === 'getStorage') {
const value = localStorage.getItem(e.data.key);
worker.postMessage({ type: 'storageValue', value });
}
};
2. ロケーション情報の取得:
// ❌ worker.ts - これは動作しない
const url = window.location.href;
// ✅ 代替案: メインスレッドから情報を送信
// main.ts
worker.postMessage({
type: 'init',
location: window.location.href,
userAgent: navigator.userAgent
});
// worker.ts
let appLocation: string;
self.onmessage = (e) => {
if (e.data.type === 'init') {
appLocation = e.data.location;
// これでロケーション情報を使用可能
}
};
3. アラートやダイアログの表示:
// ❌ worker.ts - これは動作しない
window.alert('Processing complete!');
// ✅ 代替案: メインスレッドに通知を送信
// worker.ts
self.postMessage({
type: 'showAlert',
message: 'Processing complete!'
});
// main.ts
worker.onmessage = (e) => {
if (e.data.type === 'showAlert') {
// ✅ メインスレッドでアラートを表示
alert(e.data.message);
// または、より柔軟に
const notification = document.createElement('div');
notification.className = 'notification';
notification.textContent = e.data.message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
};
3. メッセージパッシングによる通信の詳細:
なぜメッセージパッシングが必要なのか:
Web Workersとメインスレッドは、異なるスレッドで実行されるため、直接変数を共有することはできません。データの受け渡しには、postMessageとonmessageを使用したメッセージパッシングが必要です。
メッセージパッシングの基本パターン:
// worker.ts
self.onmessage = function(e) {
const { type, data } = e.data;
switch (type) {
case 'calculate':
const result = calculate(data);
self.postMessage({ type: 'result', result });
break;
case 'process':
processData(data, (progress) => {
self.postMessage({ type: 'progress', progress });
});
break;
}
};
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));
// メッセージを送信
worker.postMessage({
type: 'calculate',
data: [1, 2, 3, 4, 5]
});
// メッセージを受信
worker.onmessage = (e) => {
const { type, result, progress } = e.data;
if (type === 'result') {
console.log('Result:', result);
} else if (type === 'progress') {
updateProgressBar(progress);
}
};
メッセージの転送可能オブジェクト(Transferable Objects):
大きなデータを効率的に転送するために、Transferable Objectsを使用できます。これにより、データのコピーではなく、所有権の転送が行われます。
// main.ts
const largeArray = new Uint8Array(1024 * 1024 * 100); // 100MB
// ✅ Transferable Objectsを使用(高速)
worker.postMessage(
{ data: largeArray },
[largeArray.buffer] // バッファの所有権を転送
);
// ⚠️ 転送後、元の配列は使用できなくなる
// console.log(largeArray); // 空の配列になる
エラーハンドリング:
// worker.ts
self.onmessage = function(e) {
try {
const result = processData(e.data);
self.postMessage({ type: 'success', result });
} catch (error) {
// ✅ エラーをメインスレッドに送信
self.postMessage({
type: 'error',
error: error.message
});
}
};
// main.ts
worker.onmessage = (e) => {
if (e.data.type === 'error') {
console.error('Worker error:', e.data.error);
showErrorMessage(e.data.error);
}
};
// Worker内で発生したエラーをキャッチ
worker.onerror = (error) => {
console.error('Worker error:', error);
};
実用例: 画像処理:
// imageWorker.ts
self.onmessage = function(e) {
const { imageData } = e.data;
// 画像のグレースケール変換
for (let i = 0; i < imageData.data.length; i += 4) {
const gray = imageData.data[i] * 0.299 +
imageData.data[i + 1] * 0.587 +
imageData.data[i + 2] * 0.114;
imageData.data[i] = gray;
imageData.data[i + 1] = gray;
imageData.data[i + 2] = gray;
}
self.postMessage({ imageData });
};
6. WebAssembly(WASM)とは:
WebAssemblyは、ブラウザ上で高速に実行できるバイナリ形式の低レベル言語です。C/C++、Rust、Goなどの言語からコンパイルでき、JavaScriptよりも高速に実行できます。
WebAssemblyの特徴:
- 高速実行: JavaScriptよりも数倍から数十倍高速
- 低レベル: メモリを直接操作可能
- ポータブル: 様々なプラットフォームで実行可能
- セキュア: サンドボックス環境で実行
WebAssemblyの用途:
- 高性能な計算: 3Dグラフィックス、物理シミュレーション
- 既存のC/C++コードの再利用: ライブラリをブラウザで使用
- 暗号化処理: 高速な暗号化・復号化
- ゲームエンジン: Unity、Unreal Engineなどのゲームをブラウザで実行
ViteでのWebAssemblyの使用方法:
// main.ts
async function loadWasm() {
const wasmModule = await import('./add.wasm');
const result = wasmModule.add(5, 3);
console.log(result); // 8
}
loadWasm();
RustからWebAssemblyを生成する例:
// add.rs
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
# RustをWebAssemblyにコンパイル
wasm-pack build --target web
WebAssemblyとJavaScriptの比較:
// JavaScript版(遅い)
function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// WebAssembly版(高速)
// C/C++やRustで実装し、WASMにコンパイル
// JavaScriptから呼び出し可能
実用例: 画像処理ライブラリ:
// imageProcessor.wasmを使用
import init, { processImage } from './imageProcessor.wasm';
async function handleImage(imageData: ImageData) {
await init(); // WASMモジュールを初期化
const processed = processImage(imageData); // 高速処理
return processed;
}
パフォーマンス最適化:
// 依存関係の事前バンドル(esbuild使用)
// node_modulesは初回のみバンドル、キャッシュ利用
// コード分割
const Component = lazy(() => import('./Component'))
// 自動的にChunkに分割してロード
2.2 React - UIライブラリ
Reactの基本概念
Reactは、Facebook(現在のMeta)によって開発されたUIライブラリで、コンポーネントベースの宣言的なUI構築を可能にします。
ReactはUIだけではなく、機能も提供します:
Reactは「UIライブラリ」と呼ばれますが、単にUIを描画するだけではありません。以下のような機能を提供します:
- 状態管理: コンポーネントの状態を管理し、状態変更に応じてUIを自動更新
- ライフサイクル管理: コンポーネントのマウント、更新、アンマウントを制御
- イベント処理: ユーザーインタラクション(クリック、入力など)を処理
- パフォーマンス最適化: 仮想DOMによる効率的な再レンダリング
- 副作用の管理: API呼び出し、タイマー、サブスクリプションなどの処理
- コンテキスト管理: コンポーネントツリー全体でデータを共有
Reactの特徴:
- 宣言的: 「何を表示するか」を記述し、「どうやって表示するか」はReactが処理
- コンポーネントベース: UIを小さな部品に分割して再利用
- 単方向データフロー: データは親から子へ流れ、予測可能な動作
- 仮想DOM: 実際のDOM操作を最小限に抑えてパフォーマンスを向上
コアコンセプトの詳細解説:
1. コンポーネント(Component)とは:
コンポーネントは、UIを構成する再利用可能な独立した部品です。各コンポーネントは、入力(Props)を受け取り、出力(JSX)を返します。
コンポーネントの種類:
- 関数コンポーネント: 関数として定義(推奨)
- クラスコンポーネント: クラスとして定義(レガシー)
関数コンポーネントの例:
// シンプルなコンポーネント
function Welcome({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>;
}
// 複雑なコンポーネント
function UserProfile({ user }: { user: User }) {
return (
<div>
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// コンポーネントの再利用
function App() {
return (
<div>
<Welcome name="Alice" />
<Welcome name="Bob" />
<UserProfile user={currentUser} />
</div>
);
}
コンポーネントの利点:
- 再利用性: 同じコンポーネントを複数箇所で使用可能
- 保守性: 変更が一箇所で済む
- テスト容易性: 各コンポーネントを独立してテスト可能
- 関心の分離: UIロジックを分離して管理
2. Props(Properties)とは:
Propsは、親コンポーネントから子コンポーネントへデータを渡すための仕組みです。Propsは読み取り専用で、子コンポーネント内で変更できません。
Propsの基本:
// 親コンポーネント
function App() {
const userName = "Alice";
const userAge = 30;
return (
<UserCard
name={userName}
age={userAge}
isActive={true}
/>
);
}
// 子コンポーネント
interface UserCardProps {
name: string;
age: number;
isActive?: boolean; // オプショナル
}
function UserCard({ name, age, isActive = false }: UserCardProps) {
return (
<div className={isActive ? 'active' : 'inactive'}>
<h3>{name}</h3>
<p>Age: {age}</p>
</div>
);
}
Propsの種類:
-
プリミティブ型:
string,number,boolean -
オブジェクト:
{ name: string, age: number } -
配列:
string[],number[] -
関数:
(value: string) => void -
React要素:
React.ReactNode
関数をPropsとして渡す例:
// 親コンポーネント
function App() {
const handleClick = (id: number) => {
console.log(`Clicked: ${id}`);
};
return (
<Button
label="Click me"
onClick={handleClick}
id={1}
/>
);
}
// 子コンポーネント
interface ButtonProps {
label: string;
onClick: (id: number) => void;
id: number;
}
function Button({ label, onClick, id }: ButtonProps) {
return (
<button onClick={() => onClick(id)}>
{label}
</button>
);
}
Propsの制約:
- 読み取り専用: Propsは変更できない(イミュータブル)
- 単方向データフロー: 親から子へのみデータが流れる
- 変更が必要な場合: Stateを使用する
3. State(状態)とは:
Stateは、コンポーネント内部で管理される変更可能なデータです。Stateが変更されると、Reactは自動的にコンポーネントを再レンダリングします。
Stateの基本:
import { useState } from 'react';
function Counter() {
// Stateの宣言: [現在の値, 更新関数] = useState(初期値)
const [count, setCount] = useState<number>(0);
return (
<div>
<p>Count: {count}</p>
{/* Stateを更新する関数を呼び出す */}
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<button onClick={() => setCount(count - 1)}>
Decrement
</button>
<button onClick={() => setCount(0)}>
Reset
</button>
</div>
);
}
StateとPropsの違い:
function UserProfile({ initialName }: { initialName: string }) {
// Props: 親から受け取る(読み取り専用)
const name = initialName; // ❌ 変更不可
// State: コンポーネント内部で管理(変更可能)
const [age, setAge] = useState<number>(0); // ✅ 変更可能
return (
<div>
<p>Name: {name}</p>
<p>Age: {age}</p>
<button onClick={() => setAge(age + 1)}>
Increase Age
</button>
</div>
);
}
複数のStateの管理:
function Form() {
// 複数のStateを独立して管理
const [name, setName] = useState<string>('');
const [email, setEmail] = useState<string>('');
const [age, setAge] = useState<number>(0);
// または、オブジェクトとして管理
const [formData, setFormData] = useState({
name: '',
email: '',
age: 0
});
// オブジェクトStateの更新
const updateName = (newName: string) => {
setFormData({ ...formData, name: newName });
};
return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
</form>
);
}
State更新の注意点:
function Counter() {
const [count, setCount] = useState(0);
// ❌ 間違い: 直接変更
const incrementWrong = () => {
count = count + 1; // Stateは直接変更できない
};
// ✅ 正しい: setState関数を使用
const incrementCorrect = () => {
setCount(count + 1);
};
// ✅ 関数形式の更新(推奨)
const incrementBetter = () => {
setCount(prevCount => prevCount + 1);
};
return <button onClick={incrementBetter}>Count: {count}</button>;
}
4. Hooks(フック)とは:
Hooksは、関数コンポーネントで状態管理や副作用処理を行うためのReactの機能です。クラスコンポーネントの機能を関数コンポーネントでも使用できるようにします。
Hooksの基本ルール:
- トップレベルでのみ呼び出す: 条件分岐やループ内では呼び出さない
- 関数コンポーネント内でのみ使用: 通常のJavaScript関数では使用できない
-
カスタムHooksは
useで始める:useCustomHookのように命名
主要なHooks:
useState: 状態管理
const [state, setState] = useState(initialValue);
// 例
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
useEffect: 副作用の処理
useEffect(() => {
// マウント時・更新時に実行される処理
// API呼び出し、イベントリスナーの登録など
return () => {
// クリーンアップ処理(オプション)
// アンマウント時・更新前に実行
};
}, [dependencies]); // 依存配列
// 例: API呼び出し
useEffect(() => {
fetchUserData(userId).then(setUser);
}, [userId]); // userIdが変更されたら再実行
// 例: イベントリスナー
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // マウント時のみ実行
useContext: コンテキストの使用
const value = useContext(MyContext);
// 例: テーマコンテキスト
const theme = useContext(ThemeContext);
return <div className={theme === 'dark' ? 'dark' : 'light'}>...</div>;
useCallback: 関数のメモ化
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]); // aまたはbが変更されたら再作成
// 例: 子コンポーネントに渡す関数をメモ化
const handleClick = useCallback((id: number) => {
console.log(`Clicked: ${id}`);
}, []); // 依存がないので一度だけ作成
useMemo: 値のメモ化
const memoizedValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]); // aまたはbが変更されたら再計算
// 例: 重い計算をメモ化
const expensiveResult = useMemo(() => {
return items.reduce((sum, item) => sum + item.price, 0);
}, [items]);
useRef: 可変な値を保持
const ref = useRef(initialValue);
// 例: DOM要素への参照
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />;
inputRef.current?.focus(); // フォーカスを設定
// 例: 前回の値を保持
const prevCountRef = useRef<number>();
useEffect(() => {
prevCountRef.current = count;
});
Hooksの実用例:
function TokenBalance({ address }: { address: string }) {
// State: トークン残高を管理
const [balance, setBalance] = useState<bigint>(0n);
const [isLoading, setIsLoading] = useState<boolean>(false);
// useEffect: アドレスが変更されたら残高を取得
useEffect(() => {
if (!address) return;
setIsLoading(true);
fetchBalance(address)
.then(setBalance)
.finally(() => setIsLoading(false));
}, [address]); // addressが変更されたら再実行
// useMemo: フォーマット済み残高をメモ化
const formattedBalance = useMemo(() => {
return formatEther(balance);
}, [balance]);
if (isLoading) {
return <div>Loading...</div>;
}
return <div>Balance: {formattedBalance} ETH</div>;
}
Reactが提供する機能のまとめ:
- UIレンダリング: JSXをDOMに変換
- 状態管理: useState、useReducer
- ライフサイクル: useEffect、useLayoutEffect
- パフォーマンス最適化: useMemo、useCallback、React.memo
- コンテキスト: useContext、createContext
- 参照管理: useRef
- カスタムHooks: 独自のロジックを再利用可能に
関数コンポーネントの例:
import { useState, useEffect } from 'react';
function TokenBalance({ address }: { address: string }) {
const [balance, setBalance] = useState<bigint>(0n);
useEffect(() => {
// アドレスが変更されたら残高を取得
fetchBalance(address).then(setBalance);
}, [address]);
return <div>Balance: {balance.toString()}</div>;
}
主要なReact Hooks:
// useState: 状態管理
const [count, setCount] = useState(0);
// useEffect: 副作用の処理
useEffect(() => {
// マウント時・更新時に実行
return () => {
// アンマウント時のクリーンアップ
};
}, [dependencies]);
// useCallback: 関数のメモ化
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
// useMemo: 値のメモ化
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
2.3 TypeScript - 型安全なJavaScript
TypeScriptの利点
TypeScriptは、JavaScriptに静的型システムを追加したスーパーセットです。
主な利点:
- 型安全性: コンパイル時にエラーを検出
- 優れたIDE支援: 自動補完、リファクタリング
- ドキュメント: 型が仕様を表現
- リファクタリング: 安全な大規模変更
リファクタリングとは:
リファクタリング(Refactoring)は、コードの動作を変えずに、コードの構造や品質を改善する作業です。TypeScriptでは、型情報を活用して安全にリファクタリングできます。
リファクタリングの種類と例:
1. 変数名の変更(Rename Symbol):
// 変更前
function getUserBalance(userAddress: string): bigint {
// ...
}
// 変数名を変更したい場合
const addr = '0x123...';
const bal = getUserBalance(addr);
// ✅ TypeScriptのリファクタリング機能を使用
// 1. `addr`を右クリック → "Rename Symbol"
// 2. `address`に変更
// 3. すべての使用箇所が自動的に更新される
// 変更後(自動的に更新)
const address = '0x123...';
const balance = getUserBalance(address);
2. 関数の抽出(Extract Function):
// 変更前: 長い関数
function processTransaction(tx: Transaction) {
// 複雑な処理...
const hash = keccak256(abi.encode(tx));
const signature = sign(hash, privateKey);
const verified = verify(signature, hash, publicKey);
// ...
}
// ✅ リファクタリング: 関数を抽出
function hashTransaction(tx: Transaction): string {
return keccak256(abi.encode(tx));
}
function signTransaction(hash: string, privateKey: string): string {
return sign(hash, privateKey);
}
function verifyTransaction(signature: string, hash: string, publicKey: string): boolean {
return verify(signature, hash, publicKey);
}
// 変更後: 読みやすくなった
function processTransaction(tx: Transaction) {
const hash = hashTransaction(tx);
const signature = signTransaction(hash, privateKey);
const verified = verifyTransaction(signature, hash, publicKey);
// ...
}
3. インターフェースの変更(Change Signature):
// 変更前
interface User {
name: string;
age: number;
}
function greetUser(user: User): string {
return `Hello, ${user.name}!`;
}
// ✅ リファクタリング: プロパティを追加
interface User {
name: string;
age: number;
email: string; // 新しいプロパティを追加
}
// TypeScriptが自動的にエラーを検出
// すべてのUserオブジェクトにemailを追加する必要がある
4. 型の統一(Find All References):
// 変更前: 同じ意味の異なる型が混在
type Address = string;
type UserAddress = string;
type WalletAddress = string;
function sendToken(to: Address, amount: bigint) { }
function getUser(address: UserAddress) { }
function getBalance(addr: WalletAddress) { }
// ✅ リファクタリング: 型を統一
type Address = `0x${string}`; // より厳密な型
// "Find All References"で使用箇所を確認
// すべての型を統一して変更
function sendToken(to: Address, amount: bigint) { }
function getUser(address: Address) { }
function getBalance(addr: Address) { }
5. コードの移動(Move Symbol):
// 変更前: utils.ts
export function formatEther(value: bigint): string {
return (Number(value) / 1e18).toFixed(4);
}
// ✅ リファクタリング: より適切な場所に移動
// ether.tsに移動
// すべてのインポートが自動的に更新される
TypeScriptによる安全なリファクタリングの利点:
1. 型チェックによる安全性:
// リファクタリング前
function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
// リファクタリング: 戻り値の型を変更
function calculateTotal(items: Item[]): bigint {
return items.reduce((sum, item) => sum + BigInt(item.price), 0n);
}
// ✅ TypeScriptが自動的にエラーを検出
// この関数を使用しているすべての箇所で型エラーが発生
// リファクタリングが不完全であることを即座に発見可能
2. 自動的な影響範囲の特定:
// 関数名を変更する場合
function getUserBalance(address: string): bigint { }
// ✅ IDEが自動的に使用箇所をハイライト
// すべての使用箇所を確認してから変更可能
// 見落としによるバグを防止
3. 一括変更の安全性:
// 変更前
interface Config {
apiUrl: string;
timeout: number;
}
// ✅ プロパティ名を変更
interface Config {
apiEndpoint: string; // apiUrl → apiEndpoint
requestTimeout: number; // timeout → requestTimeout
}
// TypeScriptがすべての使用箇所を検出
// 一括で変更可能で、見落としがない
4. リファクタリングの例: 大規模な変更:
// 変更前: 文字列でアドレスを管理
function transferToken(from: string, to: string, amount: string) {
// ...
}
// ✅ リファクタリング: 型安全なアドレス型を使用
type Address = `0x${string}`;
type Amount = bigint;
function transferToken(from: Address, to: Address, amount: Amount) {
// ...
}
// TypeScriptがすべての呼び出し箇所を検出
// 型エラーを確認しながら段階的に修正可能
IDEでのリファクタリング機能:
-
Rename Symbol (
F2): 変数、関数、型などの名前を変更 - Extract Function: 選択したコードを関数として抽出
- Extract Variable: 式を変数として抽出
- Change Signature: 関数のシグネチャ(引数、戻り値)を変更
- Move Symbol: シンボルを別のファイルに移動
-
Find All References (
Shift+F12): すべての使用箇所を検索 -
Go to Definition (
F12): 定義箇所に移動
型の例:
// プリミティブ型
let name: string = "Alice";
let age: number = 30;
let isActive: boolean = true;
// 配列型
let numbers: number[] = [1, 2, 3];
let strings: Array<string> = ["a", "b", "c"];
// オブジェクト型
interface User {
name: string;
age: number;
email?: string; // オプショナル
}
// 関数型
type CalculateFunction = (a: number, b: number) => number;
// ジェネリック型
function identity<T>(arg: T): T {
return arg;
}
// ユニオン型
type Status = "pending" | "success" | "error";
// インターセクション型
type UserWithTimestamp = User & { createdAt: Date };
Web3開発でのTypeScriptの重要性:
// ❌ JavaScriptでは実行時エラー
const balance = await contract.balanceOf(address);
const doubled = balance * 2; // エラー: BigIntは*演算子使えない
// ✅ TypeScriptでコンパイル時に検出
const balance: bigint = await contract.balanceOf(address);
const doubled = balance * 2n; // 型エラーで事前に検出
2.4 Viem - モダンなEthereumライブラリ
Viemとは
Viemは、型安全で軽量なEthereumライブラリです。Web3.jsやEthers.jsの後継として設計されています。
主な特徴:
- 型安全: TypeScript完全対応
- 軽量: Tree-shakableで必要な機能のみバンドル
- モジュラー: 機能ごとに分割されたAPI
- 高性能: 最適化されたJSON-RPC通信
Tree-shakableとは:
Tree-shaking(ツリーシェイキング)は、使用されていないコードをバンドルから自動的に削除する最適化技術です。ViemはTree-shakableな設計により、必要な機能のみをバンドルに含めることができ、バンドルサイズを大幅に削減できます。
Tree-shakingの仕組み:
// Viemのモジュール構造
import { createPublicClient, http } from 'viem';
import { parseEther } from 'viem';
// ✅ 使用した機能のみがバンドルに含まれる
// createPublicClient, http, parseEther のみが含まれる
// ❌ 使用していない機能はバンドルから除外される
// 例: formatEther, parseUnits などは含まれない
Tree-shakingの例:
1. 必要な機能のみをインポート:
// ✅ Tree-shakable: 必要な機能のみインポート
import { createPublicClient, http } from 'viem';
import { parseEther } from 'viem';
// バンドルサイズ: 小さい(必要な機能のみ)
// ❌ Tree-shakableでない: 全体をインポート
import * as viem from 'viem';
// バンドルサイズ: 大きい(すべての機能が含まれる)
2. 使用されていないコードの削除:
// utils.ts
export function usedFunction() {
return 'used';
}
export function unusedFunction() {
return 'unused';
}
// main.ts
import { usedFunction } from './utils';
// ✅ Tree-shaking後: unusedFunctionはバンドルから削除される
// バンドルには usedFunction のみが含まれる
ViemでのTree-shakingの利点:
1. バンドルサイズの削減:
// Web3.jsの場合(Tree-shakableでない)
import Web3 from 'web3';
const web3 = new Web3();
// バンドルサイズ: 約800KB(すべての機能が含まれる)
// 使用していない機能も含まれてしまう
// Viemの場合(Tree-shakable)
import { createPublicClient, http } from 'viem';
import { parseEther } from 'viem';
// バンドルサイズ: 約50KB(必要な機能のみ)
// 使用していない機能は自動的に除外される
2. 必要な機能のみを選択:
// パブリッククライアントのみ必要な場合
import { createPublicClient, http } from 'viem';
// ✅ バンドルサイズ: 最小限(読み取り機能のみ)
// ウォレット機能も必要な場合
import { createPublicClient, http } from 'viem';
import { createWalletClient, custom } from 'viem';
// ✅ バンドルサイズ: 必要な機能のみ追加される
// ユーティリティ関数も必要な場合
import { parseEther, formatEther } from 'viem';
// ✅ 必要なユーティリティのみが追加される
3. モジュラー設計による最適化:
// Viemのモジュラー設計
import { createPublicClient } from 'viem';
import { http } from 'viem';
import { mainnet } from 'viem/chains';
import { parseEther } from 'viem';
// 各機能が独立したモジュールとして提供される
// 使用したモジュールのみがバンドルに含まれる
// チェーン情報も必要な場合のみインポート
import { mainnet, sepolia, polygon } from 'viem/chains';
// ✅ 使用したチェーンの情報のみが含まれる
Tree-shakingが機能するための条件:
1. ESモジュール形式:
// ✅ Tree-shakable: ESモジュール形式
export function function1() { }
export function function2() { }
// ❌ Tree-shakableでない: CommonJS形式
module.exports = {
function1: function() { },
function2: function() { }
};
2. 副作用のないコード:
// ✅ Tree-shakable: 副作用がない
export function add(a: number, b: number): number {
return a + b;
}
// ❌ Tree-shakableでない: 副作用がある
export function add(a: number, b: number): number {
console.log('Adding...'); // 副作用
window.globalVar = a + b; // 副作用
return a + b;
}
3. 静的解析可能なインポート:
// ✅ Tree-shakable: 静的解析可能
import { createPublicClient } from 'viem';
// ❌ Tree-shakableでない: 動的インポート(条件付き)
if (condition) {
const { createPublicClient } = await import('viem');
}
ViemのTree-shaking実装例:
最小限のバンドル:
// 最小限の機能のみ使用
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http()
});
// バンドルサイズ: 約30KB
// 含まれる機能: Public Client, HTTP Transport, Mainnet Chain
機能を追加した場合:
// ウォレット機能を追加
import { createPublicClient, http } from 'viem';
import { createWalletClient, custom } from 'viem';
import { parseEther, formatEther } from 'viem';
// バンドルサイズ: 約50KB
// 追加された機能: Wallet Client, Custom Transport, ユーティリティ関数
Tree-shakingの効果測定:
# バンドルサイズの確認
npm run build
# 出力例:
# dist/assets/index-abc123.js 416.06 kB │ gzip: 126.66 kB
#
# Tree-shakingにより、使用していない機能は除外される
# 実際のバンドルサイズは使用する機能に応じて変動
Tree-shakingのベストプラクティス:
1. 名前付きインポートを使用:
// ✅ 推奨: 名前付きインポート
import { createPublicClient, http } from 'viem';
// ❌ 非推奨: 名前空間インポート
import * as viem from 'viem';
2. 必要な機能のみをインポート:
// ✅ 推奨: 必要な機能のみ
import { parseEther } from 'viem';
// ❌ 非推奨: すべてのユーティリティをインポート
import { parseEther, formatEther, parseUnits, formatUnits, ... } from 'viem';
3. 条件付きインポートの回避:
// ✅ 推奨: 常にインポート
import { createPublicClient } from 'viem';
// ❌ 非推奨: 条件付きインポート(Tree-shakingが効かない場合がある)
if (needed) {
const { createPublicClient } = await import('viem');
}
Web3.js/Ethers.jsとの比較:
// Web3.js(古いスタイル)
const web3 = new Web3(window.ethereum);
const balance = await web3.eth.getBalance(address);
// 型: string(不便)
// Ethers.js v5
const provider = new ethers.providers.Web3Provider(window.ethereum);
const balance = await provider.getBalance(address);
// 型: BigNumber(独自型)
// Viem
import { createPublicClient, http } from 'viem';
const client = createPublicClient({
transport: http()
});
const balance = await client.getBalance({ address });
// 型: bigint(ネイティブ)
Viemの主要API:
// 1. Public Client(読み取り専用)
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const publicClient = createPublicClient({
chain: mainnet,
transport: http()
});
// ブロック番号取得
const blockNumber = await publicClient.getBlockNumber();
// 残高取得
const balance = await publicClient.getBalance({
address: '0x...'
});
// コントラクト読み取り
const data = await publicClient.readContract({
address: '0x...',
abi: contractAbi,
functionName: 'balanceOf',
args: ['0x...']
});
// 2. Wallet Client(書き込み)
import { createWalletClient, custom } from 'viem';
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum)
});
// トランザクション送信
const hash = await walletClient.writeContract({
address: '0x...',
abi: contractAbi,
functionName: 'transfer',
args: ['0x...', parseEther('1')]
});
// 3. ユーティリティ関数
import { parseEther, formatEther, parseUnits } from 'viem';
const wei = parseEther('1.5'); // 1.5 ETH → wei
const eth = formatEther(1500000000000000000n); // wei → "1.5"
const usdc = parseUnits('100', 6); // 100 USDC (6 decimals)
Viemの型安全性:
// ABIから型を自動推論
const contract = {
address: '0x...',
abi: [
{
name: 'balanceOf',
inputs: [{ name: 'account', type: 'address' }],
outputs: [{ name: 'balance', type: 'uint256' }],
stateMutability: 'view',
type: 'function'
}
] as const, // as const が重要
};
// 型安全な呼び出し
const balance = await publicClient.readContract({
...contract,
functionName: 'balanceOf', // 自動補完される
args: ['0x...'] // 型チェックされる
});
// balance の型は bigint と推論される
2.5 Wagmi - React用Web3フック
Wagmiとは
Wagmiは、React用のWeb3フックコレクションで、Viemをベースに構築されています。
主な特徴:
- 宣言的: Reactの思想に沿ったAPI
- 型安全: TypeScript完全対応
- キャッシング: 自動的なデータキャッシュ
- SSR対応: Next.jsなどでも動作
- マルチチェーン: 複数のチェーンに対応
Wagmiのコアフック:
// 1. useAccount - アカウント情報
import { useAccount } from 'wagmi';
function Profile() {
const { address, isConnected, chain } = useAccount();
if (!isConnected) return <div>Not connected</div>;
return (
<div>
<p>Address: {address}</p>
<p>Chain: {chain?.name}</p>
</div>
);
}
// 2. useConnect - ウォレット接続
import { useConnect } from 'wagmi';
function Connect() {
const { connect, connectors } = useConnect();
return (
<>
{connectors.map((connector) => (
<button
key={connector.id}
onClick={() => connect({ connector })}
>
Connect {connector.name}
</button>
))}
</>
);
}
// 3. useReadContract - コントラクト読み取り
import { useReadContract } from 'wagmi';
function TokenBalance({ address }: { address: `0x${string}` }) {
const { data: balance, isLoading } = useReadContract({
address: '0x...',
abi: tokenAbi,
functionName: 'balanceOf',
args: [address],
});
if (isLoading) return <div>Loading...</div>;
return <div>Balance: {balance?.toString()}</div>;
}
// 4. useWriteContract - コントラクト書き込み
import { useWriteContract } from 'wagmi';
import { parseEther } from 'viem';
function Transfer() {
const { writeContract, isPending } = useWriteContract();
const handleTransfer = () => {
writeContract({
address: '0x...',
abi: tokenAbi,
functionName: 'transfer',
args: ['0x...', parseEther('1')],
});
};
return (
<button onClick={handleTransfer} disabled={isPending}>
{isPending ? 'Transferring...' : 'Transfer'}
</button>
);
}
// 5. useWatchContractEvent - イベント監視
import { useWatchContractEvent } from 'wagmi';
function EventListener() {
useWatchContractEvent({
address: '0x...',
abi: tokenAbi,
eventName: 'Transfer',
onLogs: (logs) => {
console.log('Transfer event:', logs);
},
});
return <div>Listening for transfers...</div>;
}
Wagmiの状態管理:
// Wagmiは内部的にTanStack Query(React Query)を使用
// 自動的にキャッシュ、再取得、エラーハンドリング
const { data, isLoading, isError, refetch } = useReadContract({
address: '0x...',
abi: tokenAbi,
functionName: 'balanceOf',
args: [address],
});
// 自動リフレッシュの設定
const { data } = useReadContract({
// ...
query: {
refetchInterval: 10000, // 10秒ごとに再取得
},
});
2.6 Vitest - Viteネイティブなテストフレームワーク
Vitestとは
Vitestは、Viteと同じ設定で動作する高速なテストフレームワークです。
主な特徴:
- Viteネイティブ: Viteの設定を共有
- Jest互換: JestのAPIとほぼ同じ
- 高速: esbuildによる高速トランスパイル
- TypeScript対応: 設定不要
- ウォッチモード: 変更を検知して自動実行
Vitestの基本的な使用法:
// 基本的なテスト
import { describe, it, expect } from 'vitest';
describe('Calculator', () => {
it('adds two numbers', () => {
expect(1 + 1).toBe(2);
});
it('multiplies two numbers', () => {
expect(2 * 3).toBe(6);
});
});
// 非同期テスト
it('fetches user data', async () => {
const data = await fetchUser('123');
expect(data.name).toBe('Alice');
});
// モック
import { vi } from 'vitest';
it('calls callback', () => {
const callback = vi.fn();
someFunction(callback);
expect(callback).toHaveBeenCalledTimes(1);
});
Reactコンポーネントのテスト:
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import Button from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await userEvent.click(screen.getByText('Click'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
3. プロジェクトのセットアップ
3.1 Vite + React + TypeScriptプロジェクトの作成
初期プロジェクト作成:
# npm create viteを使用
npm create vite@latest frontend -- --template react-ts
# プロジェクトディレクトリに移動
cd frontend
# 依存関係をインストール
npm install
生成されるプロジェクト構造:
frontend/
├── node_modules/
├── public/
│ └── vite.svg
├── src/
│ ├── assets/
│ │ └── react.svg
│ ├── App.css
│ ├── App.tsx # メインコンポーネント
│ ├── index.css
│ └── main.tsx # エントリーポイント
├── .gitignore
├── index.html # HTMLテンプレート
├── package.json
├── tsconfig.json # TypeScript設定
├── tsconfig.node.json
└── vite.config.ts # Vite設定
package.jsonの確認:
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite", // 開発サーバー起動
"build": "tsc -b && vite build", // ビルド
"lint": "eslint .", // Lint実行
"preview": "vite preview" // ビルド結果をプレビュー
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"typescript": "~5.9.3",
"vite": "^7.2.4"
}
}
3.2 Web3ライブラリのインストール
Viem、Wagmi、TanStack Queryのインストール:
npm install viem wagmi @wagmi/core @tanstack/react-query
インストールされるパッケージ:
-
viem: Ethereumライブラリ -
wagmi: React用Web3フック -
@wagmi/core: Wagmiのコア機能 -
@tanstack/react-query: データフェッチング・キャッシュ管理
package.jsonへの追加:
{
"dependencies": {
"@tanstack/react-query": "^5.90.16",
"@wagmi/core": "^3.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"viem": "^2.43.5",
"wagmi": "^3.1.4"
}
}
3.3 Vitestとテストライブラリのインストール
テスト関連パッケージのインストール:
# Vitest本体
npm install -D vitest
# React Testing Library(コンポーネントテスト用)
npm install -D @testing-library/react @testing-library/jest-dom
# jsdom(DOMシミュレーション用)
npm install -D jsdom
インストールされるパッケージ:
-
vitest: テストランナー -
@testing-library/react: Reactコンポーネントテスト -
@testing-library/jest-dom: DOMマッチャー -
jsdom: ブラウザ環境のシミュレーション
package.jsonへのテストスクリプト追加:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest" // テストスクリプト追加
}
}
3.4 Vite設定の更新
vite.config.tsの更新:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true, // グローバルなテストAPI
environment: 'jsdom', // ブラウザ環境をシミュレート
},
})
設定の説明:
-
plugins: [react()]: React用のViteプラグイン -
test.globals: true: describe、it、expectをグローバルで使用可能に -
test.environment: 'jsdom': DOMをシミュレート(Reactコンポーネントテスト用)
4. コントラクトABIの抽出
4.1 FoundryアーティファクトからのABI抽出
FoundryのoutputディレクトリからABIを抽出:
# contractsディレクトリを作成
mkdir -p src/contracts
# Node.jsを使用してABIを抽出(jqが不要)
node -e "
const fs = require('fs');
const myToken = JSON.parse(fs.readFileSync('../out/MyToken.sol/MyToken.json', 'utf8'));
fs.writeFileSync('src/contracts/MyToken.json', JSON.stringify(myToken.abi, null, 2));
"
node -e "
const fs = require('fs');
const myTokenSale = JSON.parse(fs.readFileSync('../out/MyTokenSale.sol/MyTokenSale.json', 'utf8'));
fs.writeFileSync('src/contracts/MyTokenSale.json', JSON.stringify(myTokenSale.abi, null, 2));
"
node -e "
const fs = require('fs');
const kycContract = JSON.parse(fs.readFileSync('../out/KycContract.sol/KycContract.json', 'utf8'));
fs.writeFileSync('src/contracts/KycContract.json', JSON.stringify(kycContract.abi, null, 2));
"
生成されるABIファイルの例(MyToken.json):
[
{
"type": "constructor",
"inputs": [
{
"name": "initialSupply",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "balanceOf",
"inputs": [
{
"name": "account",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
// ... その他の関数
]
4.2 ABIの型定義
TypeScriptでABIを使用する際の型定義:
// as const アサーションで型を保持
import MyTokenABI from './contracts/MyToken.json' assert { type: 'json' };
// または
const MyTokenABI = [...] as const;
// これにより、関数名や引数の型が推論される
5. Wagmi設定ファイルの作成
5.1 wagmi.tsの実装
src/wagmi.tsの作成:
import { http, createConfig } from 'wagmi';
import { localhost } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
export const config = createConfig({
chains: [localhost],
connectors: [injected()],
transports: {
[localhost.id]: http(),
},
});
設定の詳細解説:
1. chains設定:
import { localhost, mainnet, sepolia } from 'wagmi/chains';
// ローカル開発用
chains: [localhost]
// 複数チェーン対応
chains: [mainnet, sepolia, localhost]
利用可能なチェーン:
-
mainnet: Ethereumメインネット -
sepolia: Sepoliaテストネット -
goerli: Goerliテストネット(非推奨) -
polygon: Polygon -
arbitrum: Arbitrum -
optimism: Optimism -
localhost: ローカルノード(Anvil、Hardhat、Ganache)
2. connectors設定:
import { injected, walletConnect, coinbaseWallet } from 'wagmi/connectors';
// MetaMaskなどのブラウザウォレット
connectors: [injected()]
// 複数のコネクター
connectors: [
injected(),
walletConnect({ projectId: 'YOUR_PROJECT_ID' }),
coinbaseWallet({ appName: 'My App' })
]
3. transports設定:
// HTTPトランスポート(デフォルト)
transports: {
[localhost.id]: http(),
[mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
}
// WebSocketトランスポート(リアルタイム更新)
import { webSocket } from 'wagmi';
transports: {
[mainnet.id]: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
}
5.2 コントラクト設定ファイルの作成
src/config/contracts.tsの作成:
import MyTokenABI from '../contracts/MyToken.json';
import MyTokenSaleABI from '../contracts/MyTokenSale.json';
import KycContractABI from '../contracts/KycContract.json';
// Contract addresses - update these with your deployed contract addresses
export const contractAddresses = {
// Anvil local testnet (chainId: 31337)
31337: {
myToken: '0x5FbDB2315678afecb367f032d93F642f64180aa3',
myTokenSale: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512',
kycContract: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0',
},
// Add other networks as needed
} as const;
export const contracts = {
myToken: {
abi: MyTokenABI,
},
myTokenSale: {
abi: MyTokenSaleABI,
},
kycContract: {
abi: KycContractABI,
},
} as const;
設定の詳細解説:
1. contractAddresses:
// チェーンIDごとにアドレスを管理
export const contractAddresses = {
31337: { // Anvil/Hardhat
myToken: '0x...',
},
1: { // Mainnet
myToken: '0x...',
},
11155111: { // Sepolia
myToken: '0x...',
},
} as const;
// 使用例
const chainId = chain?.id || 31337;
const addresses = contractAddresses[chainId];
2. contracts:
// ABIを一元管理
export const contracts = {
myToken: {
abi: MyTokenABI,
},
} as const;
// 使用例
import { contracts } from './config/contracts';
const { data } = useReadContract({
address: '0x...',
abi: contracts.myToken.abi,
functionName: 'balanceOf',
args: [address],
});
as constの重要性:
// ❌ as constなし
export const contracts = {
myToken: { abi: MyTokenABI }
};
// 型: { myToken: { abi: any[] } }
// ✅ as constあり
export const contracts = {
myToken: { abi: MyTokenABI }
} as const;
// 型: { readonly myToken: { readonly abi: readonly [...] } }
// ABIの詳細な型情報が保持される
6. プロバイダーの設定
6.1 main.tsxの更新
src/main.tsxの実装:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'
import { config } from './wagmi'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</WagmiProvider>
</StrictMode>,
)
プロバイダーの階層構造:
StrictMode (React開発モードでの追加チェック)
↓
WagmiProvider (Web3の状態管理)
↓
QueryClientProvider (データキャッシング)
↓
App (アプリケーション本体)
各プロバイダーの役割:
1. StrictMode:
// Reactの潜在的な問題を検出
<StrictMode>
<App />
</StrictMode>
// 開発モードでのみ動作
// - 非推奨APIの警告
// - 副作用の二重実行(バグ検出用)
2. WagmiProvider:
<WagmiProvider config={config}>
{/* Web3の状態がこの配下で利用可能 */}
<App />
</WagmiProvider>
// 提供される機能:
// - アカウント情報
// - コントラクトの読み書き
// - トランザクションの状態
3. QueryClientProvider:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false, // ウィンドウフォーカス時の再取得を無効化
retry: 3, // 失敗時のリトライ回数
},
},
});
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
// 提供される機能:
// - データキャッシング
// - 自動再取得
// - ローディング状態管理
7. メインアプリケーションの実装
7.1 App.tsxの全体像
src/App.tsxの実装:
import { useState, useEffect } from 'react';
import {
useAccount,
useConnect,
useDisconnect,
useReadContract,
useWriteContract,
useWatchContractEvent
} from 'wagmi';
import { parseEther } from 'viem';
import { contracts, contractAddresses } from './config/contracts';
import './App.css';
function App() {
const [kycAddress, setKycAddress] = useState('0x123...');
const [userTokens, setUserTokens] = useState<bigint>(0n);
const { address, isConnected, chain } = useAccount();
const { connect, connectors } = useConnect();
const { disconnect } = useDisconnect();
const { writeContract } = useWriteContract();
// Get contract addresses for current chain
const chainId = chain?.id || 31337;
const addresses = contractAddresses[chainId as keyof typeof contractAddresses];
// Read user token balance
const { data: balance, refetch: refetchBalance } = useReadContract({
address: addresses?.myToken as `0x${string}`,
abi: contracts.myToken.abi,
functionName: 'balanceOf',
args: address ? [address] : undefined,
});
// Update userTokens when balance changes
useEffect(() => {
if (balance !== undefined) {
setUserTokens(balance as bigint);
}
}, [balance]);
// Watch for Transfer events to this address
useWatchContractEvent({
address: addresses?.myToken as `0x${string}`,
abi: contracts.myToken.abi,
eventName: 'Transfer',
args: address ? { to: address } : undefined,
onLogs: () => {
refetchBalance();
},
});
// Handle KYC whitelisting
const handleKycWhitelisting = async () => {
if (!addresses) return;
try {
await writeContract({
address: addresses.kycContract as `0x${string}`,
abi: contracts.kycContract.abi,
functionName: 'setKycCompleted',
args: [kycAddress as `0x${string}`],
});
alert(`KYC for ${kycAddress} is completed`);
} catch (error) {
console.error('KYC whitelisting failed:', error);
alert('KYC whitelisting failed. See console for details.');
}
};
// Handle buying tokens
const handleBuyTokens = async () => {
if (!addresses || !address) return;
try {
await writeContract({
address: addresses.myTokenSale as `0x${string}`,
abi: contracts.myTokenSale.abi,
functionName: 'buyTokens',
args: [address],
value: parseEther('0.000000000000000001'), // 1 wei
});
} catch (error) {
console.error('Token purchase failed:', error);
alert('Token purchase failed. See console for details.');
}
};
if (!isConnected) {
return (
<div className="App">
<h1>StarDucks Cappucino Token Sale</h1>
<p>Please connect your wallet to continue</p>
<div>
{connectors.map((connector) => (
<button
key={connector.id}
onClick={() => connect({ connector })}
type="button"
>
Connect {connector.name}
</button>
))}
</div>
</div>
);
}
return (
<div className="App">
<h1>StarDucks Cappucino Token Sale</h1>
<div style={{ marginBottom: '20px' }}>
<p>Connected: {address}</p>
<button onClick={() => disconnect()} type="button">
Disconnect
</button>
</div>
<p>Get your Tokens today!</p>
<h2>KYC Whitelisting</h2>
<div>
<label>
Address to allow:{' '}
<input
type="text"
name="kycAddress"
value={kycAddress}
onChange={(e) => setKycAddress(e.target.value)}
/>
</label>
<button type="button" onClick={handleKycWhitelisting}>
Add to Whitelist
</button>
</div>
<h2>Buy Tokens</h2>
<p>
If you want to buy tokens, send Wei to this address:{' '}
{addresses?.myTokenSale}
</p>
<p>You currently have: {userTokens.toString()} CAPPU Tokens</p>
<button type="button" onClick={handleBuyTokens}>
Buy more tokens
</button>
</div>
);
}
export default App;
7.2 コードの詳細解説
ステート管理
const [kycAddress, setKycAddress] = useState('0x123...');
const [userTokens, setUserTokens] = useState<bigint>(0n);
useState Hook:
-
kycAddress: KYC承認するアドレス(ユーザー入力) -
userTokens: ユーザーのトークン残高(BigInt型)
BigInt型の使用:
// ❌ 間違い: numberは安全に扱える範囲が限定的
const tokens: number = 1000000000000000000;
// ✅ 正しい: BigIntで大きな数値を正確に扱う
const tokens: bigint = 1000000000000000000n;
// BigIntの演算
const double = tokens * 2n; // BigIntの計算はn suffix必要
const string = tokens.toString(); // 文字列に変換
Wagmi Hooksの使用
1. useAccount - アカウント情報の取得:
const { address, isConnected, chain } = useAccount();
// address: 接続されたウォレットのアドレス(0x...)
// isConnected: 接続状態のboolean
// chain: 接続中のチェーン情報
使用例:
if (!isConnected) {
return <div>Please connect wallet</div>;
}
return (
<div>
<p>Address: {address}</p>
<p>Chain: {chain?.name} (ID: {chain?.id})</p>
</div>
);
2. useConnect - ウォレット接続:
const { connect, connectors, isPending } = useConnect();
// connect: ウォレット接続関数
// connectors: 利用可能なコネクターのリスト
// isPending: 接続処理中かどうか
使用例:
{connectors.map((connector) => (
<button
key={connector.id}
onClick={() => connect({ connector })}
disabled={isPending}
>
{isPending ? 'Connecting...' : `Connect ${connector.name}`}
</button>
))}
3. useReadContract - コントラクト読み取り:
const { data: balance, isLoading, refetch } = useReadContract({
address: addresses?.myToken as `0x${string}`,
abi: contracts.myToken.abi,
functionName: 'balanceOf',
args: address ? [address] : undefined,
});
// data: 読み取った値
// isLoading: ローディング状態
// refetch: 再取得関数
キャッシュと再取得:
// 自動キャッシュされる
const { data } = useReadContract({...});
// 手動で再取得
const { refetch } = useReadContract({...});
await refetch();
// 定期的に再取得
const { data } = useReadContract({
// ...
query: {
refetchInterval: 10000, // 10秒ごと
},
});
4. useWriteContract - コントラクト書き込み:
const { writeContract, isPending, isSuccess } = useWriteContract();
// writeContract: トランザクション送信関数
// isPending: トランザクション処理中
// isSuccess: トランザクション成功
使用例:
const handleBuy = async () => {
try {
await writeContract({
address: '0x...',
abi: contractAbi,
functionName: 'buyTokens',
args: [userAddress],
value: parseEther('1'), // 1 ETH送信
});
} catch (error) {
console.error('Transaction failed:', error);
}
};
5. useWatchContractEvent - イベント監視:
useWatchContractEvent({
address: addresses?.myToken as `0x${string}`,
abi: contracts.myToken.abi,
eventName: 'Transfer',
args: address ? { to: address } : undefined,
onLogs: (logs) => {
console.log('Transfer event detected:', logs);
refetchBalance(); // 残高を再取得
},
});
イベントフィルタリング:
// 特定のアドレス宛てのTransferのみ監視
useWatchContractEvent({
eventName: 'Transfer',
args: { to: myAddress }, // toが一致するイベントのみ
onLogs: (logs) => {
// このアドレス宛てのTransferのみ
},
});
// 複数の条件
useWatchContractEvent({
eventName: 'Transfer',
args: {
from: senderAddress,
to: receiverAddress,
},
onLogs: (logs) => {
// fromとtoが両方一致するイベント
},
});
コントラクト関数の呼び出し
KYCホワイトリストへの追加:
const handleKycWhitelisting = async () => {
if (!addresses) return;
try {
await writeContract({
address: addresses.kycContract as `0x${string}`,
abi: contracts.kycContract.abi,
functionName: 'setKycCompleted',
args: [kycAddress as `0x${string}`],
});
alert(`KYC for ${kycAddress} is completed`);
} catch (error) {
console.error('KYC whitelisting failed:', error);
alert('KYC whitelisting failed. See console for details.');
}
};
処理フロー:
- アドレスの存在確認
- writeContract呼び出し(トランザクション送信)
- ユーザーがウォレットで承認
- トランザクションが確認される
- 成功メッセージ表示
トークン購入:
const handleBuyTokens = async () => {
if (!addresses || !address) return;
try {
await writeContract({
address: addresses.myTokenSale as `0x${string}`,
abi: contracts.myTokenSale.abi,
functionName: 'buyTokens',
args: [address],
value: parseEther('0.000000000000000001'), // 1 wei
});
} catch (error) {
console.error('Token purchase failed:', error);
alert('Token purchase failed. See console for details.');
}
};
valueの指定:
// parseEtherを使用してETHをweiに変換
value: parseEther('1') // 1 ETH
value: parseEther('0.1') // 0.1 ETH
value: parseEther('0.000000000000000001') // 1 wei
// 直接BigIntで指定
value: 1n // 1 wei
value: 1000000000000000000n // 1 ETH (18桁)
条件分岐レンダリング
接続前の画面:
if (!isConnected) {
return (
<div className="App">
<h1>StarDucks Cappucino Token Sale</h1>
<p>Please connect your wallet to continue</p>
<div>
{connectors.map((connector) => (
<button
key={connector.id}
onClick={() => connect({ connector })}
type="button"
>
Connect {connector.name}
</button>
))}
</div>
</div>
);
}
Reactの条件分岐パターン:
// パターン1: Early return
if (!isConnected) {
return <ConnectWallet />;
}
return <MainApp />;
// パターン2: 三項演算子
return isConnected ? <MainApp /> : <ConnectWallet />;
// パターン3: 論理AND演算子
return (
<>
{!isConnected && <ConnectWallet />}
{isConnected && <MainApp />}
</>
);
8. ビルドとテスト
8.1 開発サーバーの起動
開発モードで実行:
npm run dev
出力例:
VITE v7.2.4 ready in 342 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
開発サーバーの特徴:
- 高速なHMR: ファイル変更が即座に反映
- エラー表示: ブラウザにエラーオーバーレイ表示
- TypeScriptチェック: 型エラーをリアルタイム検出
開発ワークフロー:
-
npm run devでサーバー起動 - ブラウザで
http://localhost:5173/を開く - コードを編集
- ブラウザが自動更新(HMR)
8.2 TypeScriptのコンパイル
型チェックの実行:
# TypeScriptコンパイラで型チェック
npx tsc --noEmit
# または、ビルドスクリプトに含まれている
npm run build
型エラーの例:
// ❌ 型エラー
const balance: number = await contract.balanceOf(address);
// Error: Type 'bigint' is not assignable to type 'number'
// ✅ 修正
const balance: bigint = await contract.balanceOf(address);
tsconfig.jsonの主要設定:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
8.3 プロダクションビルド
ビルドの実行:
npm run build
ビルドプロセス:
- TypeScriptコンパイル(型チェック)
- Viteによるバンドル(Rollup)
- 最適化(minify、tree-shaking)
- 静的アセットの処理
ビルド出力例:
> frontend@0.0.0 build
> tsc -b && vite build
vite v7.3.0 building client environment for production...
✓ 1455 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.29 kB
dist/assets/index-COcDBgFa.css 1.38 kB │ gzip: 0.70 kB
dist/assets/ccip-B4MYfB3J.js 4.28 kB │ gzip: 1.87 kB
dist/assets/secp256k1-BSR98dvY.js 27.38 kB │ gzip: 10.73 kB
dist/assets/index-Cf4fRyaE.js 416.06 kB │ gzip: 126.66 kB
✓ built in 2.35s
生成されるdistディレクトリ:
dist/
├── index.html
└── assets/
├── index-Cf4fRyaE.js # メインJavaScript
├── index-COcDBgFa.css # スタイルシート
└── ... # その他のアセット
ビルドの最適化:
- Code Splitting: ページごとにファイル分割
- Tree Shaking: 未使用コードの削除
- Minification: コードの圧縮
- Gzip Compression: gzipサイズの計算
8.4 ビルド結果のプレビュー
プレビューサーバーの起動:
npm run preview
出力例:
➜ Local: http://localhost:4173/
➜ Network: use --host to expose
➜ press h + enter to show help
プレビューの目的:
- プロダクションビルドの動作確認
- バンドルサイズの確認
- パフォーマンステスト
8.5 Vitestによるテスト
テストの実行:
# 全テストを実行
npm run test
# ウォッチモード(変更を監視)
npm run test -- --watch
# カバレッジ計測
npm run test -- --coverage
# UIモード(ブラウザでテスト結果表示)
npm run test -- --ui
テストファイルの配置:
src/
├── App.tsx
├── App.test.tsx # Appのテスト
├── components/
│ ├── Button.tsx
│ └── Button.test.tsx # Buttonのテスト
└── utils/
├── helpers.ts
└── helpers.test.ts # helpersのテスト
サンプルテスト:
// App.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
it('renders title', () => {
render(<App />);
expect(screen.getByText('StarDucks Cappucino Token Sale')).toBeInTheDocument();
});
});
ウォッチモードの利点:
npm run test -- --watch
# ファイルを編集すると自動的に関連テストが実行される
# 高速なフィードバックループ
9. 完成したプロジェクト構造
9.1 ディレクトリ構造
frontend/
├── node_modules/ # 依存パッケージ
├── public/ # 静的アセット
│ └── vite.svg
├── src/
│ ├── assets/ # 画像などのアセット
│ │ └── react.svg
│ ├── config/ # 設定ファイル
│ │ └── contracts.ts # コントラクトABIとアドレス
│ ├── contracts/ # ABIファイル
│ │ ├── MyToken.json
│ │ ├── MyTokenSale.json
│ │ └── KycContract.json
│ ├── App.css # アプリケーションスタイル
│ ├── App.tsx # メインコンポーネント
│ ├── index.css # グローバルスタイル
│ ├── main.tsx # エントリーポイント
│ └── wagmi.ts # Wagmi設定
├── .gitignore
├── index.html # HTMLテンプレート
├── package.json # パッケージ定義
├── package-lock.json # 依存関係ロック
├── tsconfig.json # TypeScript設定
├── tsconfig.node.json # Node用TypeScript設定
└── vite.config.ts # Vite設定
9.2 主要ファイルの役割
| ファイル | 役割 |
|---|---|
src/main.tsx |
アプリケーションのエントリーポイント、プロバイダー設定 |
src/App.tsx |
メインアプリケーションロジック、UI |
src/wagmi.ts |
Wagmi設定(チェーン、コネクター、トランスポート) |
src/config/contracts.ts |
コントラクトABIとアドレスの管理 |
src/contracts/*.json |
Solidityコントラクトから抽出したABI |
vite.config.ts |
Viteのビルド設定、テスト設定 |
tsconfig.json |
TypeScriptコンパイラ設定 |
package.json |
依存関係とスクリプト定義 |
9.3 実装された機能
1. ウォレット接続:
- MetaMaskなどのブラウザウォレットとの接続
- 接続状態の表示
- 切断機能
2. トークン残高表示:
- 接続したアドレスのトークン残高を表示
- Transferイベントを監視して自動更新
- BigInt型での正確な表示
3. KYCホワイトリスト:
- アドレスをKYC承認リストに追加
- オーナーのみが実行可能(コントラクト側で制御)
- トランザクション結果の通知
4. トークン購入:
- ETH(wei)を送信してトークンを購入
- KYC承認済みアドレスのみ購入可能
- エラーハンドリング
5. リアルタイム更新:
- Transferイベントの監視
- 残高の自動再取得
- ブロックチェーンとの同期
10. AppSample.jsとの比較
10.1 技術スタックの違い
| 項目 | AppSample.js(旧) | App.tsx(新) |
|---|---|---|
| 言語 | JavaScript | TypeScript |
| ライブラリ | Web3.js | Viem + Wagmi |
| フレームワーク | React Class | React Hooks |
| ビルドツール | Webpack | Vite |
| 型安全性 | なし | あり |
10.2 コードの比較
クラスコンポーネント vs 関数コンポーネント:
// AppSample.js - クラスコンポーネント
class App extends Component {
state = { loaded: false, userTokens: 0 };
componentDidMount = async () => {
this.web3 = await getWeb3();
this.accounts = await this.web3.eth.getAccounts();
// ...
}
render() {
return <div>...</div>;
}
}
// App.tsx - 関数コンポーネント + Hooks
function App() {
const [userTokens, setUserTokens] = useState<bigint>(0n);
const { address, isConnected } = useAccount();
useEffect(() => {
// ...
}, []);
return <div>...</div>;
}
Web3.js vs Viem + Wagmi:
// AppSample.js - Web3.js
this.tokenInstance = new this.web3.eth.Contract(
MyToken.abi,
MyToken.networks[this.networkId].address
);
let userTokens = await this.tokenInstance.methods
.balanceOf(this.accounts[0])
.call();
// App.tsx - Viem + Wagmi
const { data: balance } = useReadContract({
address: addresses.myToken,
abi: contracts.myToken.abi,
functionName: 'balanceOf',
args: [address],
});
イベントリスニング:
// AppSample.js
listenToTokenTransfer = () => {
this.tokenInstance.events.Transfer({to: this.accounts[0]})
.on("data", this.updateUserTokens);
}
// App.tsx
useWatchContractEvent({
address: addresses.myToken,
abi: contracts.myToken.abi,
eventName: 'Transfer',
args: { to: address },
onLogs: () => refetchBalance(),
});
10.3 新しいスタックの利点
1. 型安全性:
// ✅ TypeScript: コンパイル時にエラー検出
const balance: bigint = await readContract(...);
const invalid: number = balance; // エラー: bigintをnumberに代入不可
// ❌ JavaScript: 実行時にエラー
const balance = await contract.methods.balanceOf(...).call();
const doubled = balance * 2; // 動作するが期待通りでない可能性
2. 優れた開発体験:
// Wagmiは自動補完が効く
const { data } = useReadContract({
functionName: 'balance', // タイポ → エラー
functionName: 'balanceOf', // ✅ 正しい(自動補完)
});
3. パフォーマンス:
- Vite: 高速な起動とHMR
- Viem: 小さいバンドルサイズ
- キャッシング: 自動的なデータキャッシュ
4. モダンなパターン:
- React Hooks: 関数コンポーネント
- 宣言的なAPI: useReadContract、useWriteContract
- 自動状態管理: ローディング、エラー、成功
11. デプロイの準備
11.1 コントラクトアドレスの設定
デプロイ後の設定:
// src/config/contracts.ts
export const contractAddresses = {
// ローカル開発(Anvil)
31337: {
myToken: '0x5FbDB2315678afecb367f032d93F642f64180aa3',
myTokenSale: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512',
kycContract: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0',
},
// Sepoliaテストネット
11155111: {
myToken: '0x...', // デプロイ後のアドレスを設定
myTokenSale: '0x...',
kycContract: '0x...',
},
// メインネット
1: {
myToken: '0x...',
myTokenSale: '0x...',
kycContract: '0x...',
},
} as const;
11.2 環境変数の設定
.env.localの作成:
# フロントエンド用の環境変数
VITE_ALCHEMY_KEY=your_alchemy_key
VITE_WALLETCONNECT_PROJECT_ID=your_project_id
環境変数の使用:
// src/wagmi.ts
import { http } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';
export const config = createConfig({
chains: [mainnet, sepolia],
transports: {
[mainnet.id]: http(`https://eth-mainnet.g.alchemy.com/v2/${import.meta.env.VITE_ALCHEMY_KEY}`),
[sepolia.id]: http(`https://eth-sepolia.g.alchemy.com/v2/${import.meta.env.VITE_ALCHEMY_KEY}`),
},
});
11.3 静的サイトホスティング
Vercelへのデプロイ:
# Vercel CLIのインストール
npm i -g vercel
# デプロイ
vercel --prod
Netlifyへのデプロイ:
# Netlify CLIのインストール
npm i -g netlify-cli
# ビルド
npm run build
# デプロイ
netlify deploy --prod --dir=dist
GitHub Pagesへのデプロイ:
# vite.config.tsにbase設定を追加
export default defineConfig({
base: '/repository-name/',
// ...
});
# gh-pagesパッケージをインストール
npm install -D gh-pages
# package.jsonにスクリプト追加
{
"scripts": {
"deploy": "npm run build && gh-pages -d dist"
}
}
# デプロイ
npm run deploy
12. トラブルシューティング
12.1 よくある問題と解決方法
問題1: ウォレット接続できない
// エラー: No connector provided
// 原因: WagmiProviderが設定されていない
// 解決: main.tsxでWagmiProviderを確認
// ❌ 間違い
<App />
// ✅ 正しい
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</WagmiProvider>
問題2: 型エラー
// エラー: Type 'string | undefined' is not assignable to type '0x${string}'
// 原因: undefinedの可能性がある値を型アサーションなしで使用
// ❌ 間違い
<div>{addresses.myToken}</div>
// ✅ 正しい
<div>{addresses?.myToken}</div>
問題3: BigInt表示エラー
// エラー: Do not know how to serialize a BigInt
// 原因: BigIntを直接JSXに渡している
// ❌ 間違い
<div>Balance: {balance}</div>
// ✅ 正しい
<div>Balance: {balance?.toString()}</div>
問題4: CORS エラー
// エラー: Access to fetch at ... has been blocked by CORS policy
// 原因: ローカルノード(Anvil)がCORSを許可していない
// 解決: Anvilを--hostオプション付きで起動
// anvil --host 0.0.0.0
問題5: Contract ABIエラー
// エラー: ABI is not properly formatted
// 原因: ABIが配列ではなくオブジェクト全体を読み込んでいる
// ❌ 間違い
const abi = MyTokenArtifact; // { abi: [...], bytecode: ... }
// ✅ 正しい
const abi = MyTokenArtifact.abi; // [...]
12.2 デバッグのヒント
ブラウザの開発者ツール:
// Console
console.log('Balance:', balance?.toString());
console.log('Address:', address);
// Networkタブでトランザクションを確認
// JSON-RPC呼び出しを確認
React Developer Tools:
// Chromeエクステンションをインストール
// コンポーネントツリーを確認
// Hooksの状態を確認
Wagmi DevTools:
// 開発環境でのデバッグツール
import { WagmiDevTools } from 'wagmi';
<WagmiProvider config={config}>
<App />
<WagmiDevTools />
</WagmiProvider>
13. まとめ
13.1 実装内容
1月5日に構築したフロントエンド:
- ✅ Vite + React + TypeScriptのプロジェクトセットアップ
- ✅ Viem + Wagmiの統合
- ✅ Vitestのテスト環境構築
- ✅ コントラクトABIの抽出と設定
- ✅ ウォレット接続機能
- ✅ トークン残高表示
- ✅ KYCホワイトリスト機能
- ✅ トークン購入機能
- ✅ イベントリスニング
13.2 技術スタック
使用技術:
- Vite 7.2.4: 高速ビルドツール
- React 19.2.0: UIライブラリ
- TypeScript 5.9.3: 型安全な開発
- Viem 2.43.5: モダンなEthereumライブラリ
- Wagmi 3.1.4: React Web3フック
- Vitest 4.0.16: テストフレームワーク
- TanStack Query 5.90.16: データフェッチング
13.3 学んだこと
モダンWeb3開発のベストプラクティス:
- 型安全性: TypeScriptによるコンパイル時エラー検出
- 宣言的API: React Hooksでシンプルなコード
- パフォーマンス: Viteの高速開発サーバー
- キャッシング: 自動的なデータキャッシュとリフレッチ
- エラーハンドリング: try-catchと型チェック
- モジュラー設計: 設定ファイルの分離
旧スタックからの主な改善点:
- Web3.js → Viem: 軽量化と型安全性
- Class Component → Hooks: シンプルで読みやすい
- Webpack → Vite: 高速な開発体験
- JavaScript → TypeScript: バグの早期発見
13.4 次のステップ
今後の拡張案:
-
ユーザー体験の向上:
- トランザクション待機中のローディング表示
- エラーメッセージの改善
- トースト通知の追加
-
機能追加:
- トークンセールのフェーズ表示
- 購入履歴の表示
- マルチチェーン対応
-
テストの充実:
- コンポーネントテストの追加
- E2Eテスト(Playwright/Cypress)
- モックによるコントラクト呼び出しのテスト
-
デプロイ:
- Vercel/Netlifyへのデプロイ
- CI/CDパイプラインの構築
- 環境変数の管理
-
最適化:
- Code splittingによるバンドルサイズ削減
- 画像最適化
- PWA化
このフロントエンド実装により、モダンなWeb3開発の基礎を習得しました。型安全で保守性の高い、ユーザーフレンドリーなDAppの構築が可能になりました。
Discussion