この記事は「構造パスで作るシンプルなフロントエンドフレームワーク(Structive)」Advent Calendarの16日目です。
Structiveについて詳しくはこちらより
前回のおさらい
Day 15では、SFC(シングルファイルコンポーネント)の設計思想を学びました。今日は、SFCがどのようにWeb標準のWeb Componentsとして実装されるか、その仕組みとライフサイクルを詳しく見ていきます。
Web Componentsとは?
Web Componentsは、ブラウザ標準のコンポーネント技術です。フレームワークに依存せず、ネイティブなHTMLとして動作します。
主要な技術
Web Componentsは3つの標準技術で構成されます:
-
Custom Elements(カスタム要素)
- 独自のHTML要素を定義
-
<my-component>のように使える
-
Shadow DOM
- スタイルとDOMのカプセル化
- 外部からの影響を遮断
-
HTML Templates
- 再利用可能なHTMLの断片
-
<template>タグ
コンポーネントの登録
このフレームワークでは、Import MapsとEasyLoader/AutoLoaderを使ってコンポーネントを登録します。
セットアップの3ステップ
1. SFCファイルを作成
<!-- product-card.st.html -->
<template>
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>${{ product.price|fix,2 }}</p>
</div>
</template>
<style>
.product-card {
border: 1px solid #ccc;
padding: 1em;
border-radius: 8px;
}
</style>
<script type="module">
export default class {
product = {
name: "Laptop",
price: 999.99
};
}
</script>
2. Import Mapsで登録
エントリーHTMLで、Import Mapsを使ってコンポーネントを登録します:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>My App</title>
<!-- Import Maps: コンポーネントのパスを定義 -->
<script type="importmap">
{
"imports": {
"structive": "/path/to/structive.esm.js",
"@components/product-card": "./product-card.st.html",
"@components/user-profile": "./components/user-profile.st.html",
"@components/shopping-cart": "./components/shopping-cart.st.html"
}
}
</script>
<!-- EasyLoader/AutoLoaderを読み込み -->
<script type="module" src="path/to/EasyLoaders/components.js"></script>
</head>
<body>
<!-- コンポーネントを使用 -->
<product-card></product-card>
<user-profile></user-profile>
<shopping-cart></shopping-cart>
</body>
</html>
3. Loader の選択
フレームワークは2種類のローダーを提供します:
EasyLoader:
<!-- Shadow DOMを使う(デフォルト) -->
<script type="module" src="EasyLoaders/components.js"></script>
<!-- Shadow DOMを使わない -->
<script type="module" src="EasyLoaders/components--disable-shadow-dom.js"></script>
AutoLoader:
<!-- Shadow DOMを使う(デフォルト) -->
<script type="module" src="AutoLoaders/components.js"></script>
<!-- Shadow DOMを使わない -->
<script type="module" src="AutoLoaders/components--disable-shadow-dom.js"></script>
違い:
- EasyLoader: Import Mapsでstructiveの行の記述が不要
- AutoLoader: Import Mapsでstructiveの行の記述が必要
Import Mapsの命名規則
Import Mapsのキーは@components/プレフィックスを使います:
{
"imports": {
// ✅ 正しい形式
"@components/タグ名": "./パス/ファイル名.st.html",
// 例
"@components/product-card": "./product-card.st.html",
"@components/user-profile": "./components/user-profile.st.html"
}
}
ポイント:
- キー:
@components/タグ名(ハイフンを含むタグ名) - 値: SFCファイルへのパス
カスタム要素名のルール
カスタム要素名は必ずハイフン(-)を含む必要があります(HTML標準):
❌ productcard (ハイフンなし)
❌ ProductCard (大文字)
✅ product-card (ハイフン付き、小文字)
✅ user-profile (OK)
✅ shopping-cart (OK)
実践例:タイマーコンポーネント
完全な例を見てみましょう。
ディレクトリ構造
project/
├── index.html
├── timer-widget.st.html
└── EasyLoaders/
└── components.js
timer-widget.st.html
<template>
<div class="timer-widget">
<h3>タイマー</h3>
<div class="display">
<span class="time">{{ formattedTime }}</span>
</div>
<div class="controls">
<button data-bind="onclick:start; disabled:isRunning">
開始
</button>
<button data-bind="onclick:stop; disabled:isNotRunning">
停止
</button>
<button data-bind="onclick:reset">
リセット
</button>
</div>
<p class="info">経過時間: {{ elapsedSeconds }}秒</p>
</div>
</template>
<style>
.timer-widget {
padding: 2em;
border: 2px solid #333;
border-radius: 8px;
text-align: center;
max-width: 300px;
}
.display {
margin: 1em 0;
padding: 1em;
background: #f5f5f5;
border-radius: 4px;
}
.time {
font-size: 2em;
font-weight: bold;
font-family: monospace;
}
.controls {
display: flex;
gap: 0.5em;
margin-bottom: 1em;
}
.controls button {
flex: 1;
padding: 0.75em;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info {
color: #666;
font-size: 0.9em;
margin: 0;
}
</style>
<script type="module">
export default class {
elapsedSeconds = 0;
isRunning = false;
timer = null;
get isNotRunning() {
return !this.isRunning;
}
get formattedTime() {
const hours = Math.floor(this.elapsedSeconds / 3600);
const minutes = Math.floor((this.elapsedSeconds % 3600) / 60);
const seconds = this.elapsedSeconds % 60;
return [hours, minutes, seconds]
.map(n => String(n).padStart(2, '0'))
.join(':');
}
// コンポーネントがDOMに追加されたとき
$connectedCallback() {
console.log("Timer widget mounted");
}
// コンポーネントがDOMから削除されたとき
$disconnectedCallback() {
console.log("Timer widget unmounted");
// タイマーのクリーンアップ
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.timer = setInterval(() => {
// 非同期的なコールバックから状態を更新する場合、$invokeから呼び出す
this.$invoke(() => {
this.elapsedSeconds += 1;
});
}, 1000);
}
stop() {
if (!this.isRunning) return;
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
reset() {
this.stop();
this.elapsedSeconds = 0;
}
}
</script>
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Timer Widget Demo</title>
<!-- Import Maps: コンポーネントを登録 -->
<script type="importmap">
{
"imports": {
"@components/timer-widget": "./timer-widget.st.html"
}
}
</script>
<!-- EasyLoaderを読み込み -->
<script type="module" src="EasyLoaders/components.js"></script>
</head>
<body>
<h1>タイマーデモ</h1>
<!-- コンポーネントを使用 -->
<timer-widget></timer-widget>
<button onclick="toggleTimer()">タイマーの表示/非表示</button>
<script>
function toggleTimer() {
const timer = document.querySelector('timer-widget');
if (timer) {
timer.remove(); // disconnectedCallbackが呼ばれる
} else {
const newTimer = document.createElement('timer-widget');
document.body.appendChild(newTimer); // connectedCallbackが呼ばれる
}
}
</script>
</body>
</html>
ライフサイクル
Web Componentsには、コンポーネントの状態変化に応じて呼ばれるライフサイクルメソッドがあります。
このフレームワークでの使い方
$プレフィックスを付けたメソッドでライフサイクルをフックします。
export default class {
// 状態
data = null;
// DOMに追加されたとき
$connectedCallback() {
console.log("Component connected to DOM");
// 初期化処理
this.loadData();
}
// DOMから削除されたとき
$disconnectedCallback() {
console.log("Component disconnected from DOM");
// クリーンアップ処理
this.cleanup();
}
}
対応関係:
-
$connectedCallback→ Web標準のconnectedCallback -
$disconnectedCallback→ Web標準のdisconnectedCallback
ライフサイクルの使用パターン
1. リソースのクリーンアップ
export default class {
timer = null;
websocket = null;
$connectedCallback() {
// WebSocketを開く
this.websocket = new WebSocket('ws://example.com');
this.websocket.onmessage = (event) => {
this.$invoke(() => {
this.handleMessage(event.data);
})
};
}
$disconnectedCallback() {
// WebSocketを閉じる
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
}
}
2. イベントリスナーの登録/解除
export default class {
handleResize = () => {
this.$invoke(() => {
this.windowWidth = window.innerWidth;
});
};
windowWidth = window.innerWidth;
$connectedCallback() {
// イベントリスナーを登録
window.addEventListener('resize', this.handleResize);
}
$disconnectedCallback() {
// イベントリスナーを解除
window.removeEventListener('resize', this.handleResize);
}
}
3. データの取得
export default class {
data = null;
isLoading = false;
error = null;
async $connectedCallback() {
this.isLoading = true;
try {
const response = await fetch('/api/data');
this.data = await response.json();
} catch (err) {
this.error = err.message;
} finally {
this.isLoading = false;
}
}
}
<template>
{{ if:isLoading }}
<p>読み込み中...</p>
{{ elseif:error|truthy }}
<p class="error">エラー: {{ error }}</p>
{{ elseif:data|truthy }}
<div>{{ data.content }}</div>
{{ endif: }}
</template>
複数コンポーネントの登録
実際のアプリケーションでは、複数のコンポーネントを使います。
ディレクトリ構造
project/
├── index.html
├── components/
│ ├── product-card.st.html
│ ├── user-profile.st.html
│ └── shopping-cart.st.html
└── EasyLoaders/
└── components.js
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Shopping App</title>
<script type="importmap">
{
"imports": {
"@components/product-card": "./components/product-card.st.html",
"@components/user-profile": "./components/user-profile.st.html",
"@components/shopping-cart": "./components/shopping-cart.st.html"
}
}
</script>
<script type="module" src="EasyLoaders/components.js"></script>
</head>
<body>
<user-profile></user-profile>
<div class="product-list">
<product-card></product-card>
<product-card></product-card>
<product-card></product-card>
</div>
<shopping-cart></shopping-cart>
</body>
</html>
重要なポイント:
- Import Mapsで全コンポーネントを登録
-
@components/プレフィックスを統一 - EasyLoaderが自動的に読み込んで登録
ライフサイクルのベストプラクティス
1. 常にクリーンアップ
// ✅ 良い例
$connectedCallback() {
this.timer = setInterval(() => { ... }, 1000);
}
$disconnectedCallback() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
// ❌ 悪い例(メモリリーク)
$connectedCallback() {
setInterval(() => { ... }, 1000); // クリーンアップできない
}
2. 非同期処理の制御
export default class {
isMounted = false;
$connectedCallback() {
this.isMounted = true;
this.loadData();
}
$disconnectedCallback() {
this.isMounted = false;
}
async loadData() {
const data = await fetch('/api/data');
// コンポーネントがまだマウントされているかチェック
if (this.isMounted) {
this.data = await data.json();
}
}
}
3. イベントリスナーの参照を保持
// ✅ 良い例
export default class {
handleClick = () => { ... };
$connectedCallback() {
document.addEventListener('click', this.handleClick);
}
$disconnectedCallback() {
document.removeEventListener('click', this.handleClick);
}
}
まとめ
今日は、Web Componentsとの統合を学びました:
コンポーネントの登録:
- Import Mapsで
@components/タグ名として登録 - EasyLoaderまたはAutoLoaderを使用
- カスタム要素名は必ずハイフン付き
Loaderの選択:
- EasyLoader: 明示的な登録、最適なパフォーマンス
- AutoLoader: 自動検出、素早い開発
ライフサイクル:
-
$connectedCallback: DOMに追加されたとき -
$disconnectedCallback: DOMから削除されたとき
ベストプラクティス:
- 常にクリーンアップする
- 非同期処理を制御する
- イベントリスナーの参照を保持する
次回予告:
明日は、「親子コンポーネント間のデータ受け渡し」を学びます。状態の部分委譲パターン(state.子パス:親パス)と、疎結合な親子関係の構築方法を解説します。
次回: Day 17「親子コンポーネント間のデータ受け渡し」
Web Componentsの登録やライフサイクルについて質問があれば、コメントでぜひ!