stenciljs-component-development について
TypeScriptベースのWeb Componentsを開発する人向けのStencil.jsコンポーネント開発スキルです。コンポーネント構造、ライフサイクル、レンダリング、状態、イベント、テストと、複数のWebフレームワークで再利用するための実践を扱います。
主な機能
- 01型付きコンポーネントのプロパティと状態
- 02ライフサイクルとプロパティ変更の処理
- 03カスタムイベントとDOMリスナー
- 04JSXレンダリングとコンポーネント構成
- 05ユニットテストとE2Eテスト
- 06Web Component設計の実践
活用シーン
- 01再利用可能なStencil.jsコンポーネントの構築
- 02コンポーネントのプロパティとイベントAPIの設計
- 03コンポーネントの描画と動作のテスト
- 04異なるWebフレームワーク間でのコンポーネント共有
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ corlab-tech/ skills --skill stenciljs-component-developmentインストール後、Claude Code で/stenciljs-component-developmentを入力して読み込まれたか確認します。
詳細
Stencil.js - コンポーネント開発
Stencil.js と TypeScript、Web Components 標準を使用して、スケーラブルでエンタープライズ対応の Web コンポーネントを構築します。Stencil コンポーネントはフレームワークに依存せず、React、Angular、Vue、従来の Web アプリケーションに配布できます。
主な概念
コンポーネントの構造
Stencil コンポーネントは、@Component() でデコレートされた TypeScript クラスです。
import { Component, Prop, State, Event, EventEmitter, Watch, h } from '@stencil/core';
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
@Prop() name: string;
@State() isActive: boolean = false;
render() {
return (
<div>
<p>Hello, {this.name}!</p>
</div>
);
}
}
コンポーネントデコレーターのオプション
@Component({
tag: 'my-component', // Required: Custom element tag name (must contain hyphen)
styleUrl: 'my-component.css', // Single stylesheet
styleUrls: { // Multiple stylesheets for different modes
ios: 'my-component.ios.css',
md: 'my-component.md.css'
},
shadow: true, // Use Shadow DOM (recommended)
scoped: false, // Scoped CSS without Shadow DOM
assetsDirs: ['assets'], // Asset directories to copy
formAssociated: false, // Form-associated custom element
})
デコレーター
@Prop() - コンポーネントのプロパティ
属性として公開されるプロパティです。
// Basic prop
@Prop() name: string;
// With default value
@Prop() size: 'small' | 'medium' | 'large' = 'medium';
// Mutable prop (can be changed internally)
@Prop({ mutable: true }) value: string;
// Reflect to attribute (sync prop changes to DOM attribute)
@Prop({ reflect: true }) active: boolean;
// Attribute name different from property name
@Prop({ attribute: 'data-id' }) dataId: string;
ベストプラクティス:
- プロパティには必ず型を指定します
- 公開 API のドキュメントには JSDoc コメントを使用します
- 適切な場合は、妥当なデフォルト値を設定します
reflect: trueは必要な場合にのみ使用します(パフォーマンスコストがかかります)
@State() - 内部状態
変更時に再レンダリングをトリガーするプライベートな状態です。
@State() isOpen: boolean = false;
@State() items: string[] = [];
// Updating state triggers re-render
this.isOpen = true;
重要:
- State は内部専用であり、属性として公開されません
- 配列やオブジェクトを直接変更しても、再レンダリングはトリガーされません
- 複雑な状態には不変パターンを使用します
@Watch() - プロパティ変更ハンドラー
プロパティまたは状態の変更を監視します。
@Prop() value: string;
@Watch('value')
valueChanged(newValue: string, oldValue: string) {
console.log(`Value changed from ${oldValue} to ${newValue}`);
this.validateValue(newValue);
}
// Place @Watch() immediately after the prop it watches
@Prop() swipeEnabled: boolean = true;
@Watch('swipeEnabled')
swipeEnabledChanged(newSwipeEnabled: boolean, oldSwipeEnabled: boolean) {
this.updateState();
}
@Event() - カスタムイベント
カスタムイベントを発行します。
@Event() itemSelected: EventEmitter<string>;
@Event() formSubmit: EventEmitter<{value: string}>;
handleClick() {
this.itemSelected.emit('item-1');
}
ベストプラクティス:
- 内容がわかりやすいイベント名を使用します
- イベントのペイロードに型を指定します
- JSDoc でイベントの詳細を記述します
@Listen() - イベントリスナー
DOM イベントを監視します。
@Listen('click')
handleClick(event: MouseEvent) {
console.log('Component clicked', event);
}
@Listen('scroll', { target: 'window' })
handleScroll(event: Event) {
console.log('Window scrolled');
}
@Listen('resize', { target: 'window', passive: true })
handleResize() {
this.updateDimensions();
}
@Element() - ホスト要素への参照
ホスト要素への参照です。
@Element() el: HTMLElement;
componentDidLoad() {
console.log('Host element:', this.el);
this.el.classList.add('loaded');
}
@Method() - パブリックメソッド
パブリックな非同期メソッドを公開します。
@Method()
async open(): Promise<void> {
this.isOpen = true;
}
@Method()
async getValue(): Promise<string> {
return this.value;
}
重要:
- すべてのパブリックメソッドは非同期である必要があります
- 公開 API には JSDoc でドキュメントを記述します
ライフサイクルメソッド
実行順に並べたライフサイクルメソッドです。
export class MyComponent {
// 1. Called when component is connected to DOM (can be called multiple times)
connectedCallback() {
console.log('Component connected to DOM');
}
// 2. Called once before first render (good for async data loading)
componentWillLoad() {
console.log('Component will load');
// Can return Promise to delay first render
}
// 3. Called once after first render
componentDidLoad() {
console.log('Component loaded');
}
// 4. Called before every render (after first)
componentWillRender() {
console.log('Component will render');
}
// 5. Called after every render (after first)
componentDidRender() {
console.log('Component rendered');
}
// 6. Called when component will update (not on first render)
componentWillUpdate() {
console.log('Component will update');
}
// 7. Called after component updates (not on first render)
componentDidUpdate() {
console.log('Component updated');
}
// 8. Determine if component should re-render
componentShouldUpdate(newVal: any, oldVal: any, propName: string): boolean {
return newVal !== oldVal;
}
// 9. Called when disconnected from DOM (can be called multiple times)
disconnectedCallback() {
console.log('Component disconnected');
}
}
ライフサイクルのベストプラクティス:
- 非同期のデータ取得には
componentWillLoad()を使用します - DOM 操作には
componentDidLoad()を使用します - 要素がアタッチされるたびに実行するロジックには
connectedCallback()を使用します disconnectedCallback()でクリーンアップします(リスナーの削除、タイマーのクリア)
JSX とレンダリング
render() メソッド
render() メソッドは JSX を返します。
import { h } from '@stencil/core';
render() {
return (
<div class="container">
<h1>Hello, {this.name}!</h1>
{this.isActive && <p>Active!</p>}
<button onClick={() => this.handleClick()}>
Click me
</button>
</div>
);
}
ホスト要素
<Host> を使用して、ホスト要素に属性を設定します。
import { Host } from '@stencil/core';
render() {
return (
<Host
class={{
'is-active': this.isActive,
'is-disabled': this.disabled
}}
aria-label={this.label}
>
<slot></slot>
</Host>
);
}
スロット
コンテンツの投影にはスロットを使用します。
render() {
return (
<div>
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> {/* Default slot */}
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
);
}
条件付きレンダリング
render() {
return (
<div>
{this.loading ? (
<div class="spinner">Loading...</div>
) : (
<div class="content">{this.content}</div>
)}
{this.error && <div class="error">{this.error}</div>}
</div>
);
}
リスト
render() {
return (
<ul>
{this.items.map(item => (
<li key={item.id}>
{item.name}
</li>
))}
</ul>
);
}
ファイル構成と命名
ディレクトリ構成
コンポーネントごとにディレクトリを分けます。
src/
├── components/
│ ├── my-button/
│ │ ├── my-button.tsx # Component implementation
│ │ ├── my-button.css # Styles
│ │ ├── my-button.spec.ts # Unit tests
│ │ ├── my-button.e2e.ts # E2E tests
│ │ └── readme.md # Component documentation
│ ├── my-card/
│ │ ├── my-card.tsx
│ │ ├── my-card.ios.css # iOS-specific styles
│ │ ├── my-card.md.css # Material Design styles
│ │ └── my-card.css # Base styles
命名規則
- タグ名:ハイフンを含むケバブケース(必須):
my-component、app-header - コンポーネントクラス:パスカルケース:
MyComponent、AppHeader - ファイル名:タグ名に合わせます:
my-component.tsx - プロパティ:キャメルケース:
firstName、isActive - イベント:キャメルケース:
itemSelected、formSubmit - CSS クラス:ケバブケース:
button-primary、is-active
コードの構成
コンポーネントクラス内では、次の順序に従います。
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
// 1. Own Properties (private, not decorated)
private internalValue: number;
someText = 'default';
// 2. Reference to host element
@Element() el: HTMLElement;
// 3. State() variables (alphabetical)
@State() isValidated: boolean;
@State() status = 0;
// 4. Public Property API - @Prop() (alphabetical)
@Prop() content: string;
@Prop() enabled: boolean;
@Prop() type = 'default';
// 5. Prop lifecycle events (@Watch) - immediately after related @Prop
@Prop() value: string;
@Watch('value')
valueChanged(newValue: string, oldValue: string) {
this.validate(newValue);
}
// 6. Events section (alphabetical)
@Event() itemSelected: EventEmitter<string>;
@Event() statusChanged: EventEmitter<number>;
// 7. Component lifecycle events (in natural order)
connectedCallback() {}
disconnectedCallback() {}
componentWillLoad() {}
componentDidLoad() {}
componentShouldUpdate() {}
componentWillRender() {}
componentDidRender() {}
componentWillUpdate() {}
componentDidUpdate() {}
// 8. Listeners
@Listen('click')
onClick(event: MouseEvent) {
console.log('Clicked');
}
// 9. Public methods API (alphabetical)
@Method()
async open(): Promise<void> {
this.isOpen = true;
}
@Method()
async close(): Promise<void> {
this.isOpen = false;
}
// 10. Local methods (private business logic)
private validate(value: string): boolean {
return value.length > 0;
}
private updateState() {
// Internal logic
}
// 11. render() function (always last)
render() {
return (
<Host>
<div class="container">
<slot></slot>
</div>
</Host>
);
}
}
ベストプラクティス
1. TypeScript を厳格に使用する
// Good - Fully typed
@Prop() items: Array<{id: string; name: string}>;
@State() count: number = 0;
// Bad - Any types
@Prop() items: any;
@State() count;
2. 不変な状態更新
// Bad - Direct mutation won't trigger re-render
this.items.push(newItem);
// Good - Create new array
this.items = [...this.items, newItem];
// Bad - Direct object mutation
this.user.name = 'New Name';
// Good - Create new object
this.user = { ...this.user, name: 'New Name' };
3. 可能な限り Shadow DOM を使用する
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true, // Encapsulation and style isolation
})
4. 公開 API をドキュメント化する
/**
* The button component for user interactions
*/
@Component({
tag: 'my-button',
})
export class MyButton {
/**
* The button label text
*/
@Prop() label: string;
/**
* Emitted when the button is clicked
*/
@Event() buttonClick: EventEmitter<void>;
/**
* Opens the button's associated menu
*/
@Method()
async openMenu(): Promise<void> {
// ...
}
}
5. 非同期処理を適切に扱う
async componentWillLoad() {
try {
this.data = await this.fetchData();
} catch (error) {
console.error('Failed to load data:', error);
this.error = 'Failed to load';
}
}
6. リソースをクリーンアップする
private intervalId: number;
componentDidLoad() {
this.intervalId = window.setInterval(() => {
this.updateTime();
}, 1000);
}
disconnectedCallback() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
7. 単純なケースでは関数コンポーネントを使用する
import { h, FunctionalComponent } from '@stencil/core';
interface IconProps {
name: string;
size?: number;
}
export const Icon: FunctionalComponent<IconProps> = ({ name, size = 24 }) => (
<svg width={size} height={size}>
<use xlinkHref={`#icon-${name}`} />
</svg>
);
アンチパターン
❌ プロパティを変更しない
// Bad
@Prop() value: string;
handleChange() {
this.value = 'new value'; // Error if not mutable
}
// Good
@Prop({ mutable: true }) value: string;
// Or better: emit event and let parent handle
@Event() valueChange: EventEmitter<string>;
handleChange() {
this.valueChange.emit('new value');
}
❌ 初期化にコンストラクターを使用しない
// Bad
constructor() {
this.data = this.fetchData(); // Won't work properly
}
// Good
async componentWillLoad() {
this.data = await this.fetchData();
}
❌ render() 内で DOM にアクセスしない
// Bad
render() {
const width = this.el.offsetWidth; // Can cause issues
return <div style={{ width: `${width}px` }}></div>;
}
// Good
componentDidLoad() {
this.width = this.el.offsetWidth;
}
render() {
return <div style={{ width: `${this.width}px` }}></div>;
}
❌ リスト内のキーを忘れない
// Bad
render() {
return (
<ul>
{this.items.map(item => <li>{item.name}</li>)}
</ul>
);
}
// Good
render() {
return (
<ul>
{this.items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
❌ ハンドラーに render() 内でアロー関数を使用しない
// Bad - Creates new function on every render
render() {
return <button onClick={() => this.handleClick()}>Click</button>;
}
// Good - Bind in class or use arrow function property
private handleClick = () => {
console.log('Clicked');
}
render() {
return <button onClick={this.handleClick}>Click</button>;
}
テスト
単体テスト
import { newSpecPage } from '@stencil/core/testing';
import { MyComponent } from './my-component';
describe('my-component', () => {
it('renders', async () => {
const page = await newSpecPage({
components: [MyComponent],
html: `<my-component></my-component>`,
});
expect(page.root).toEqualHtml(`
<my-component>
<mock:shadow-root>
<div>
Hello, World!
</div>
</mock:shadow-root>
</my-component>
`);
});
it('renders with props', async () => {
const page = await newSpecPage({
components: [MyComponent],
html: `<my-component name="Stencil"></my-component>`,
});
expect(page.root).toEqualHtml(`
<my-component name="Stencil">
<mock:shadow-root>
<div>
Hello, Stencil!
</div>
</mock:shadow-root>
</my-component>
`);
});
});
E2E テスト
import { newE2EPage } from '@stencil/core/testing';
describe('my-component', () => {
it('renders', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');
const element = await page.find('my-component');
expect(element).toHaveClass('hydrated');
});
it('emits event on click', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');
const itemSelected = await page.spyOnEvent('itemSelected');
const button = await page.find('my-component >>> button');
await button.click();
expect(itemSelected).toHaveReceivedEvent();
});
});
よくあるパターン
フォーム関連付けコンポーネント
@Component({
tag: 'my-input',
formAssociated: true,
})
export class MyInput {
@Element() el: HTMLElement;
private internals: ElementInternals;
componentWillLoad() {
this.internals = (this.el as any).attachInternals();
}
@Prop() value: string = '';
@Watch('value')
valueChanged(newValue: string) {
this.internals.setFormValue(newValue);
}
}
読み込み状態
@State() loading: boolean = false;
@State() error: string | null = null;
@State() data: any = null;
async componentWillLoad() {
await this.loadData();
}
private async loadData() {
this.loading = true;
this.error = null;
try {
this.data = await fetch('/api/data').then(r => r.json());
} catch (e) {
this.error = 'Failed to load data';
} finally {
this.loading = false;
}
}
render() {
if (this.loading) return <div>Loading...</div>;
if (this.error) return <div class="error">{this.error}</div>;
return <div>{JSON.stringify(this.data)}</div>;
}
制御コンポーネントと非制御コンポーネント
// Controlled - value managed by parent
@Prop() value: string;
@Event() valueChange: EventEmitter<string>;
handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.valueChange.emit(value);
}
render() {
return <input value={this.value} onInput={e => this.handleInput(e)} />;
}
// Uncontrolled - internal state
@State() internalValue: string = '';
handleInput(event: Event) {
this.internalValue = (event.target as HTMLInputElement).value;
}
render() {
return <input value={this.internalValue} onInput={e => this.handleInput(e)} />;
}
パフォーマンスのヒント
- 不要なレンダリングを防ぐには
componentShouldUpdate()を使用します - render() 内で複雑な計算を行わないようにします
- 負荷の高い計算には
memoizeを使用します import()でコンポーネントを遅延読み込みします- スタイルのカプセル化には Shadow DOM を使用します
- 親からのプロパティ変更を最小限に抑えます
- リストにはイベント委譲を使用します