mcpField
S
stenciljs-component-development

stenciljs-component-development

github.com/corlab-tech開発086 回インストール17 週間前に更新
リポジトリ

stenciljs-component-development について

TypeScriptベースのWeb Componentsを開発する人向けのStencil.jsコンポーネント開発スキルです。コンポーネント構造、ライフサイクル、レンダリング、状態、イベント、テストと、複数のWebフレームワークで再利用するための実践を扱います。

主な機能

  1. 01型付きコンポーネントのプロパティと状態
  2. 02ライフサイクルとプロパティ変更の処理
  3. 03カスタムイベントとDOMリスナー
  4. 04JSXレンダリングとコンポーネント構成
  5. 05ユニットテストとE2Eテスト
  6. 06Web Component設計の実践

活用シーン

  1. 01再利用可能なStencil.jsコンポーネントの構築
  2. 02コンポーネントのプロパティとイベントAPIの設計
  3. 03コンポーネントの描画と動作のテスト
  4. 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)} />;
}

パフォーマンスのヒント

  1. 不要なレンダリングを防ぐには componentShouldUpdate() を使用します
  2. render() 内で複雑な計算を行わないようにします
  3. 負荷の高い計算には memoize を使用します
  4. import() でコンポーネントを遅延読み込みします
  5. スタイルのカプセル化には Shadow DOM を使用します
  6. 親からのプロパティ変更を最小限に抑えます
  7. リストにはイベント委譲を使用します

Related Resources

コメント