SVG内でカスタムタグを使った完全カプセル化
HTMLコンテンツを、SVG内で完全にカプセル化する必要があった。
外部リソースは使わない。全て自己完結。
Web Componentsで実現した。
なぜ作ったか
midori252まで、3D空間にHTMLを表示する実装を進めてきた。
でも、問題があった。
外部CSSや外部JSを参照すると、SVG変換が複雑になる。
セキュリティ的にも不安。
全て内包したかった。
写真展示では、オフライン環境でも動く必要がある。
外部リソースに依存しない設計が必須だった。
カスタムタグによるカプセル化
Web Componentsの定義
class GalleryComponent extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
/* スタイルを内包 */
.gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
padding: 20px;
}
.photo {
width: 100%;
border-radius: 8px;
}
</style>
<div class="gallery">
<img class="photo" src="photo1.jpg" />
<img class="photo" src="photo2.jpg" />
<img class="photo" src="photo3.jpg" />
</div>
<script>
// JavaScriptも内包
document.querySelectorAll('.photo').forEach(img => {
img.addEventListener('click', () => {
console.log('Photo clicked');
});
});
</script>
`;
}
}
customElements.define('gallery-panel', GalleryComponent);
スタイルもスクリプトも、全て内包。
外部ファイルは不要。
SVG内への配置
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="800">
<foreignObject x="0" y="0" width="1000" height="800">
<gallery-panel></gallery-panel>
</foreignObject>
</svg>
カスタムタグとして配置。
シンプル。
静的コンテンツの制約
外部リソースの禁止
画像も、BASE64で埋め込む。
const images = {
photo1: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...',
photo2: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...',
};
class GalleryComponent extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div class="gallery">
<img src="${images.photo1}" />
<img src="${images.photo2}" />
</div>
`;
}
}
全てがコンポーネント内に含まれる。
自己完結。
動的読み込みの排除
外部APIも呼ばない。
全てのデータを事前に用意。
const data = {
title: 'ギャラリー',
items: [
{ name: '写真1', date: '2024-01-01' },
{ name: '写真2', date: '2024-01-02' },
]
};
class InfoComponent extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div class="info">
<h2>${data.title}</h2>
${data.items.map(item => `
<div class="item">
<p>${item.name}</p>
<p>${item.date}</p>
</div>
`).join('')}
</div>
`;
}
}
静的。でも、柔軟。
データを変えれば、表示も変わる。
コンポーネント間の通信
カスタムイベント
コンポーネント同士で通信。
class SelectorComponent extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button id="selectBtn">選択</button>
`;
this.querySelector('#selectBtn').addEventListener('click', () => {
const event = new CustomEvent('item-selected', {
detail: { id: 123 },
bubbles: true
});
this.dispatchEvent(event);
});
}
}
class DisplayComponent extends HTMLElement {
connectedCallback() {
this.innerHTML = `<div class="display">待機中...</div>`;
document.addEventListener('item-selected', (e) => {
this.querySelector('.display').textContent =
`選択されました: ${e.detail.id}`;
});
}
}
疎結合。
各コンポーネントが独立している。
状態管理
シンプルなストアパターン。
const store = {
state: {
selectedId: null,
items: []
},
listeners: [],
setState(newState) {
this.state = { ...this.state, ...newState };
this.listeners.forEach(listener => listener(this.state));
},
subscribe(listener) {
this.listeners.push(listener);
}
};
全コンポーネントが、同じ状態を参照。
同期が保たれる。
ハマったポイント
SVG内でのscriptタグ
最初、scriptタグがSVG変換時に無視された。
理由は、foreignObject内のscriptは、即座に実行されないこと。
回避策: connectedCallback()内でscriptを手動実行。
connectedCallback() {
this.innerHTML = `
<div class="content">...</div>
`;
// スクリプトを手動で実行
const script = document.createElement('script');
script.textContent = `
// 初期化処理
console.log('Component initialized');
`;
this.appendChild(script);
}
これで、正しく動くようになった。
BASE64のサイズ制限
画像をBASE64にすると、サイズが膨らむ。
1MBの画像が、1.3MBになる。
10枚で13MB。
負荷が高かった。
対策: 画像を事前に圧縮。
// 圧縮後のBASE64
const compressedImage = await compressImageToBase64(originalImage, 0.7);
品質70%で圧縮。
サイズが半分になった。
カスタムイベントのバブリング
イベントがSVGの外に伝播しない。
foreignObject内で止まる。
対策: parentNodeを遡って、手動でイベントを伝播。
bubbleEvent(event) {
let current = this.parentNode;
while (current) {
current.dispatchEvent(new CustomEvent(event.type, {
detail: event.detail
}));
current = current.parentNode;
}
}
これで、3Dシーン全体にイベントが届く。
パフォーマンス測定
コンポーネント数別に、初期化時間を測定した。
コンポーネント1個
初期化: 50ms
描画: 30fps
軽い。
コンポーネント5個
初期化: 200ms
描画: 25fps
許容範囲。
コンポーネント10個
初期化: 500ms
描画: 20fps
やや負荷が高い。
でも、実用的。
試してみた結果
写真ギャラリー
20枚の写真を、カスタムタグで表示。
<gallery-panel photos="20"></gallery-panel>
シンプルな記述。
中身は複雑だが、使う側は簡単。
インタラクティブUI
ボタン、スライダー、チェックボックス。
全てカスタムタグ。
<control-panel>
<slider-control name="brightness" min="0" max="100"></slider-control>
<button-control name="apply"></button-control>
</control-panel>
再利用可能。
データ表示
表形式のデータ。
<data-table
columns="Name,Age,City"
rows="Alice,25,Tokyo;Bob,30,Osaka">
</data-table>
属性で指定。
中でパースして表示。
使ってみて
実際に完全カプセル化を試してみて、メリットとデメリットが分かった。
メリット:
- 外部依存なし(オフラインでも動く)
- セキュリティが高い(外部スクリプト実行なし)
- ポータブル(SVGファイル1つで完結)
デメリット:
- BASE64で画像サイズが増える
- 外部APIが使えない
- 初期化が若干重い
ポイントは以下の3つ:
- カスタムタグで完全カプセル化(スタイル・スクリプト内包)
- 静的コンテンツに徹する(外部リソース禁止)
- カスタムイベントで疎結合(独立性維持)
次のmidori254で、さらに動画統合を追加した。
同じような自己完結型コンポーネントを作っている方の参考になれば嬉しいです。
まとめ
今回は、SVG内でカスタムタグを使った完全カプセル化を実現しました。
ポイントは以下の4つ:
- Web Componentsで自己完結(外部リソース不要)
- BASE64で画像埋め込み(オフライン動作可能)
- カスタムイベントで通信(コンポーネント間疎結合)
- シンプルなストアパターン(状態管理一元化)
完全に独立したコンポーネントシステムが完成した。
これが、midori254以降の基盤になった。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。