mcpField
V
vscode-webview-expert

vscode-webview-expert

github.com/s-hiraoku開発2249 回インストール8 週間前に更新
リポジトリ

vscode-webview-expert について

VS Code WebView Expertは、WebViewを扱うVS Code拡張開発者向けの実装スキルです。CSP、拡張機能とWebView間のメッセージ通信、テーマ対応、デバッグを含め、セキュリティ、状態管理、ライフサイクル、性能を扱います。

主な機能

  1. 01WebViewパネルとサイドバー表示の作成
  2. 02コンテンツセキュリティポリシー(CSP)の設定
  3. 03拡張機能とWebView間のメッセージ設計
  4. 04WebViewの状態と永続化の管理
  5. 05リソース読み込みと大規模リスト描画の最適化
  6. 06テーマ連携とデバッグへの対応

活用シーン

  1. 01VS Code拡張機能にWebViewパネルを実装
  2. 02CSPによるリソース読み込み問題の調査
  3. 03拡張機能とWebView間のリクエスト・レスポンス通信の構築
  4. 04大規模リスト表示の描画性能改善

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/s-hiraoku/vscode-sidebar-terminal --skill vscode-webview-expert

インストール後、Claude Code で/vscode-webview-expertを入力して読み込まれたか確認します。

詳細

VS Code WebView エキスパート

概要

このスキルを使用すると、VS Code WebView の機能をエキスパートレベルで実装できます。VS Code 拡張機能に固有の WebView セキュリティ要件、通信パターン、状態管理、パフォーマンス最適化手法に関する包括的な知識を提供します。

このスキルを使用する場面

  • 新しい WebView パネルまたはビューを作成する
  • Content Security Policy(CSP)を実装する
  • 拡張機能と WebView 間の通信プロトコルを設計する
  • WebView の状態と永続化を管理する
  • WebView のライフサイクルイベントを処理する
  • WebView のレンダリングパフォーマンスを最適化する
  • WebView 関連の問題をデバッグする
  • WebView を使用したカスタムエディターを実装する

WebView の基礎

WebView パネルの作成
import * as vscode from 'vscode';

class WebViewManager {
  private panel: vscode.WebviewPanel | undefined;

  show(context: vscode.ExtensionContext): void {
    if (this.panel) {
      this.panel.reveal();
      return;
    }

    this.panel = vscode.window.createWebviewPanel(
      'myWebview',           // viewType - unique identifier
      'My WebView',          // title
      vscode.ViewColumn.One, // column to show in
      {
        enableScripts: true,
        retainContextWhenHidden: true,  // Keep state when hidden
        localResourceRoots: [
          vscode.Uri.joinPath(context.extensionUri, 'media'),
          vscode.Uri.joinPath(context.extensionUri, 'dist')
        ]
      }
    );

    this.panel.webview.html = this.getHtmlContent(
      this.panel.webview,
      context.extensionUri
    );

    // Handle disposal
    this.panel.onDidDispose(() => {
      this.panel = undefined;
    });
  }
}
サイドバーの WebView(TreeView の代替)
class SidebarWebViewProvider implements vscode.WebviewViewProvider {
  private view?: vscode.WebviewView;

  constructor(private readonly extensionUri: vscode.Uri) {}

  resolveWebviewView(
    webviewView: vscode.WebviewView,
    context: vscode.WebviewViewResolveContext,
    token: vscode.CancellationToken
  ): void {
    this.view = webviewView;

    webviewView.webview.options = {
      enableScripts: true,
      localResourceRoots: [this.extensionUri]
    };

    webviewView.webview.html = this.getHtmlContent(webviewView.webview);

    // Handle visibility changes
    webviewView.onDidChangeVisibility(() => {
      if (webviewView.visible) {
        this.refresh();
      }
    });
  }
}

// Register in package.json
/*
"contributes": {
  "views": {
    "explorer": [{
      "type": "webview",
      "id": "myWebviewView",
      "name": "My View"
    }]
  }
}
*/

セキュリティ:Content Security Policy

CSP の実装(重要)
function getHtmlContent(
  webview: vscode.Webview,
  extensionUri: vscode.Uri
): string {
  // Generate unique nonce for scripts
  const nonce = getNonce();

  // Get resource URIs
  const styleUri = webview.asWebviewUri(
    vscode.Uri.joinPath(extensionUri, 'media', 'style.css')
  );
  const scriptUri = webview.asWebviewUri(
    vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js')
  );

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="Content-Security-Policy" content="
    default-src 'none';
    style-src ${webview.cspSource} 'unsafe-inline';
    script-src 'nonce-${nonce}';
    img-src ${webview.cspSource} https: data:;
    font-src ${webview.cspSource};
    connect-src https:;
  ">
  <link href="${styleUri}" rel="stylesheet">
  <title>My WebView</title>
</head>
<body>
  <div id="app"></div>
  <script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}

function getNonce(): string {
  let text = '';
  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  for (let i = 0; i < 32; i++) {
    text += possible.charAt(Math.floor(Math.random() * possible.length));
  }
  return text;
}
CSP ディレクティブのリファレンス
ディレクティブ目的推奨値
default-src他のディレクティブのフォールバック'none'
script-srcJavaScript のソース'nonce-${nonce}'
style-srcスタイルシートのソース${webview.cspSource} 'unsafe-inline'
img-src画像のソース${webview.cspSource} https: data:
font-srcフォントのソース${webview.cspSource}
connect-srcXHR/Fetch の接続先https: または特定のオリジン
frame-srciframe のソース'none'(必要な場合を除く)
よくある CSP の問題と解決策
// Issue: Inline styles not working
// Solution: Add 'unsafe-inline' to style-src (acceptable for styles)
style-src ${webview.cspSource} 'unsafe-inline';

// Issue: External images not loading
// Solution: Add https: to img-src
img-src ${webview.cspSource} https: data:;

// Issue: Web fonts not loading
// Solution: Ensure font-src includes cspSource
font-src ${webview.cspSource} https://fonts.gstatic.com;

// Issue: Fetch/XHR blocked
// Solution: Add connect-src with allowed origins
connect-src https://api.example.com;

拡張機能と WebView 間の通信

メッセージプロトコルの設計
// Shared message types (use in both Extension and WebView)
interface Message {
  type: string;
  payload?: unknown;
  id?: string;  // For request-response pattern
}

// Extension → WebView messages
type ExtensionMessage =
  | { type: 'init'; payload: { config: Config; state: State } }
  | { type: 'update'; payload: { data: Data } }
  | { type: 'theme-changed'; payload: { theme: 'light' | 'dark' } }
  | { type: 'response'; id: string; payload: unknown; error?: string };

// WebView → Extension messages
type WebViewMessage =
  | { type: 'ready' }
  | { type: 'action'; payload: { action: string; data: unknown } }
  | { type: 'request'; id: string; payload: { method: string; args: unknown[] } }
  | { type: 'error'; payload: { message: string; stack?: string } };
拡張機能側:メッセージ処理
class WebViewMessageHandler {
  private pendingRequests = new Map<string, {
    resolve: (value: unknown) => void;
    reject: (error: Error) => void;
    timeout: NodeJS.Timeout;
  }>();

  constructor(private panel: vscode.WebviewPanel) {
    this.setupMessageHandler();
  }

  private setupMessageHandler(): void {
    this.panel.webview.onDidReceiveMessage(
      async (message: WebViewMessage) => {
        try {
          await this.handleMessage(message);
        } catch (error) {
          console.error('Message handling error:', error);
          this.sendError(error as Error);
        }
      }
    );
  }

  private async handleMessage(message: WebViewMessage): Promise<void> {
    switch (message.type) {
      case 'ready':
        await this.onWebViewReady();
        break;

      case 'action':
        await this.handleAction(message.payload);
        break;

      case 'request':
        await this.handleRequest(message.id!, message.payload);
        break;

      case 'error':
        console.error('WebView error:', message.payload);
        break;
    }
  }

  private async onWebViewReady(): Promise<void> {
    // Send initial state when WebView is ready
    this.send({
      type: 'init',
      payload: {
        config: await this.getConfig(),
        state: await this.getState()
      }
    });
  }

  private async handleRequest(id: string, payload: any): Promise<void> {
    try {
      const result = await this.executeMethod(payload.method, payload.args);
      this.send({ type: 'response', id, payload: result });
    } catch (error) {
      this.send({
        type: 'response',
        id,
        payload: null,
        error: (error as Error).message
      });
    }
  }

  send(message: ExtensionMessage): void {
    this.panel.webview.postMessage(message);
  }

  private sendError(error: Error): void {
    this.send({
      type: 'response',
      id: 'error',
      payload: null,
      error: error.message
    });
  }
}
WebView 側:メッセージ処理
// In WebView JavaScript
declare const acquireVsCodeApi: () => {
  postMessage(message: unknown): void;
  getState(): unknown;
  setState(state: unknown): void;
};

class VSCodeBridge {
  private vscode = acquireVsCodeApi();
  private pendingRequests = new Map<string, {
    resolve: (value: unknown) => void;
    reject: (error: Error) => void;
  }>();
  private ready = false;
  private messageQueue: unknown[] = [];

  constructor() {
    this.setupMessageListener();
    this.notifyReady();
  }

  private setupMessageListener(): void {
    window.addEventListener('message', (event) => {
      const message = event.data as ExtensionMessage;
      this.handleMessage(message);
    });
  }

  private handleMessage(message: ExtensionMessage): void {
    switch (message.type) {
      case 'init':
        this.ready = true;
        this.flushMessageQueue();
        this.onInit(message.payload);
        break;

      case 'update':
        this.onUpdate(message.payload);
        break;

      case 'theme-changed':
        this.onThemeChanged(message.payload.theme);
        break;

      case 'response':
        this.handleResponse(message);
        break;
    }
  }

  private handleResponse(message: ExtensionMessage & { type: 'response' }): void {
    const pending = this.pendingRequests.get(message.id!);
    if (pending) {
      this.pendingRequests.delete(message.id!);
      if (message.error) {
        pending.reject(new Error(message.error));
      } else {
        pending.resolve(message.payload);
      }
    }
  }

  // Request-response pattern
  async request<T>(method: string, ...args: unknown[]): Promise<T> {
    const id = crypto.randomUUID();

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        this.pendingRequests.delete(id);
        reject(new Error(`Request timeout: ${method}`));
      }, 10000);

      this.pendingRequests.set(id, {
        resolve: (value) => {
          clearTimeout(timeout);
          resolve(value as T);
        },
        reject: (error) => {
          clearTimeout(timeout);
          reject(error);
        }
      });

      this.send({ type: 'request', id, payload: { method, args } });
    });
  }

  send(message: WebViewMessage): void {
    if (!this.ready && message.type !== 'ready') {
      this.messageQueue.push(message);
      return;
    }
    this.vscode.postMessage(message);
  }

  private flushMessageQueue(): void {
    while (this.messageQueue.length > 0) {
      this.vscode.postMessage(this.messageQueue.shift());
    }
  }

  private notifyReady(): void {
    this.vscode.postMessage({ type: 'ready' });
  }

  // State persistence
  getState<T>(): T | undefined {
    return this.vscode.getState() as T | undefined;
  }

  setState<T>(state: T): void {
    this.vscode.setState(state);
  }

  // Override these in subclass
  protected onInit(payload: { config: any; state: any }): void {}
  protected onUpdate(payload: { data: any }): void {}
  protected onThemeChanged(theme: 'light' | 'dark'): void {}
}

状態管理

WebView の状態の永続化
// Simple state (survives hide/show, lost on reload)
class SimpleStateManager {
  private vscode = acquireVsCodeApi();

  save<T>(state: T): void {
    this.vscode.setState(state);
  }

  load<T>(): T | undefined {
    return this.vscode.getState() as T | undefined;
  }
}

// Full persistence (survives VS Code restart)
class PersistentStateManager {
  constructor(
    private context: vscode.ExtensionContext,
    private key: string
  ) {}

  async save<T>(state: T): Promise<void> {
    await this.context.globalState.update(this.key, state);
  }

  load<T>(): T | undefined {
    return this.context.globalState.get<T>(this.key);
  }
}

// WebView Serializer for panel restoration
class WebViewSerializer implements vscode.WebviewPanelSerializer {
  constructor(private manager: WebViewManager) {}

  async deserializeWebviewPanel(
    panel: vscode.WebviewPanel,
    state: unknown
  ): Promise<void> {
    // Restore panel with saved state
    this.manager.restorePanel(panel, state);
  }
}

// Register serializer
vscode.window.registerWebviewPanelSerializer('myWebview', new WebViewSerializer(manager));
状態同期のパターン
class StateSynchronizer<T> {
  private state: T;
  private webview: vscode.Webview;
  private context: vscode.ExtensionContext;
  private saveDebouncer: NodeJS.Timeout | undefined;

  constructor(
    webview: vscode.Webview,
    context: vscode.ExtensionContext,
    initialState: T
  ) {
    this.webview = webview;
    this.context = context;
    this.state = this.loadState() ?? initialState;
  }

  private loadState(): T | undefined {
    return this.context.workspaceState.get<T>('webviewState');
  }

  private async persistState(): Promise<void> {
    await this.context.workspaceState.update('webviewState', this.state);
  }

  update(partial: Partial<T>): void {
    this.state = { ...this.state, ...partial };

    // Notify WebView
    this.webview.postMessage({
      type: 'state-update',
      payload: this.state
    });

    // Debounced persistence
    if (this.saveDebouncer) {
      clearTimeout(this.saveDebouncer);
    }
    this.saveDebouncer = setTimeout(() => {
      this.persistState();
    }, 1000);
  }

  getState(): T {
    return this.state;
  }
}

パフォーマンスの最適化

リソースの遅延読み込み
function getHtmlContent(webview: vscode.Webview, extensionUri: vscode.Uri): string {
  const nonce = getNonce();

  return `<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="Content-Security-Policy" content="...">
  
  <style>
    body { font-family: var(--vscode-font-family); }
    .loading { display: flex; justify-content: center; }
  </style>
</head>
<body>
  <div id="app">
    <div class="loading">Loading...</div>
  </div>

  
  <link rel="preload" href="${styleUri}" as="style" onload="this.rel='stylesheet'">

  
  <script nonce="${nonce}" src="${scriptUri}" defer></script>
</body>
</html>`;
}
メッセージのバッチ処理
class MessageBatcher {
  private queue: Message[] = [];
  private flushTimeout: NodeJS.Timeout | undefined;
  private readonly flushInterval = 16; // ~60fps

  constructor(private webview: vscode.Webview) {}

  send(message: Message): void {
    this.queue.push(message);
    this.scheduleFlush();
  }

  private scheduleFlush(): void {
    if (!this.flushTimeout) {
      this.flushTimeout = setTimeout(() => {
        this.flush();
      }, this.flushInterval);
    }
  }

  private flush(): void {
    if (this.queue.length === 0) return;

    // Send batch message
    this.webview.postMessage({
      type: 'batch',
      messages: this.queue
    });

    this.queue = [];
    this.flushTimeout = undefined;
  }

  dispose(): void {
    if (this.flushTimeout) {
      clearTimeout(this.flushTimeout);
      this.flush(); // Send remaining messages
    }
  }
}
大規模なリストの仮想スクロール
// WebView side implementation
class VirtualList {
  private container: HTMLElement;
  private itemHeight = 24;
  private visibleItems = 50;
  private items: unknown[] = [];

  constructor(container: HTMLElement) {
    this.container = container;
    this.setupScrollHandler();
  }

  setItems(items: unknown[]): void {
    this.items = items;
    this.render();
  }

  private setupScrollHandler(): void {
    this.container.addEventListener('scroll', () => {
      requestAnimationFrame(() => this.render());
    });
  }

  private render(): void {
    const scrollTop = this.container.scrollTop;
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    const endIndex = Math.min(
      startIndex + this.visibleItems,
      this.items.length
    );

    // Only render visible items
    const visibleItems = this.items.slice(startIndex, endIndex);

    // Update DOM with padding for scroll position
    this.container.innerHTML = `
      <div style="height: ${startIndex * this.itemHeight}px"></div>
      ${visibleItems.map(item => this.renderItem(item)).join('')}
      <div style="height: ${(this.items.length - endIndex) * this.itemHeight}px"></div>
    `;
  }

  private renderItem(item: unknown): string {
    return `<div class="item" style="height: ${this.itemHeight}px">${item}</div>`;
  }
}

テーマの統合

VS Code のテーマ変数
/* Use VS Code CSS variables for consistent theming */
body {
  background-color: var(--vscode-editor-background);
  color: var(--vscode-editor-foreground);
  font-family: var(--vscode-font-family);
  font-size: var(--vscode-font-size);
}

.button {
  background-color: var(--vscode-button-background);
  color: var(--vscode-button-foreground);
  border: none;
  padding: 4px 12px;
  cursor: pointer;
}

.button:hover {
  background-color: var(--vscode-button-hoverBackground);
}

.input {
  background-color: var(--vscode-input-background);
  color: var(--vscode-input-foreground);
  border: 1px solid var(--vscode-input-border);
  padding: 4px 8px;
}

.input:focus {
  outline: 1px solid var(--vscode-focusBorder);
}

.error {
  color: var(--vscode-errorForeground);
  background-color: var(--vscode-inputValidation-errorBackground);
  border: 1px solid var(--vscode-inputValidation-errorBorder);
}

.panel {
  background-color: var(--vscode-panel-background);
  border: 1px solid var(--vscode-panel-border);
}
テーマ変更の検出
// Extension side
function watchThemeChanges(webview: vscode.Webview): vscode.Disposable {
  return vscode.window.onDidChangeActiveColorTheme((theme) => {
    webview.postMessage({
      type: 'theme-changed',
      payload: {
        kind: theme.kind, // 1=Light, 2=Dark, 3=HighContrast
        theme: theme.kind === vscode.ColorThemeKind.Dark ? 'dark' : 'light'
      }
    });
  });
}

// WebView side
window.addEventListener('message', (event) => {
  if (event.data.type === 'theme-changed') {
    document.body.dataset.theme = event.data.payload.theme;
  }
});

WebView のデバッグ

開発者ツールへのアクセス
// Command to open WebView DevTools
vscode.commands.registerCommand('myExt.openWebviewDevTools', () => {
  vscode.commands.executeCommand('workbench.action.webview.openDeveloperTools');
});
デバッグログ
// WebView side - comprehensive error handling
window.onerror = (message, source, lineno, colno, error) => {
  vscode.postMessage({
    type: 'error',
    payload: {
      message: String(message),
      source,
      lineno,
      colno,
      stack: error?.stack
    }
  });
};

window.addEventListener('unhandledrejection', (event) => {
  vscode.postMessage({
    type: 'error',
    payload: {
      message: 'Unhandled Promise rejection',
      reason: String(event.reason),
      stack: event.reason?.stack
    }
  });
});

// Debug logging utility
const debug = {
  log: (...args: unknown[]) => {
    console.log('[WebView]', ...args);
    vscode.postMessage({
      type: 'debug',
      payload: { level: 'log', args: args.map(String) }
    });
  },
  error: (...args: unknown[]) => {
    console.error('[WebView]', ...args);
    vscode.postMessage({
      type: 'debug',
      payload: { level: 'error', args: args.map(String) }
    });
  }
};

よくあるパターン

シングルトンパネルのパターン
class SingletonWebViewManager {
  private static instance: SingletonWebViewManager;
  private panel: vscode.WebviewPanel | undefined;

  private constructor() {}

  static getInstance(): SingletonWebViewManager {
    if (!SingletonWebViewManager.instance) {
      SingletonWebViewManager.instance = new SingletonWebViewManager();
    }
    return SingletonWebViewManager.instance;
  }

  show(context: vscode.ExtensionContext): void {
    if (this.panel) {
      this.panel.reveal();
      return;
    }

    this.panel = vscode.window.createWebviewPanel(/* ... */);

    this.panel.onDidDispose(() => {
      this.panel = undefined;
    });
  }

  dispose(): void {
    this.panel?.dispose();
  }
}
複数パネルのパターン
class MultiPanelManager {
  private panels = new Map<string, vscode.WebviewPanel>();

  create(id: string, context: vscode.ExtensionContext): vscode.WebviewPanel {
    if (this.panels.has(id)) {
      const existing = this.panels.get(id)!;
      existing.reveal();
      return existing;
    }

    const panel = vscode.window.createWebviewPanel(
      'myWebview',
      `Panel ${id}`,
      vscode.ViewColumn.One,
      { enableScripts: true }
    );

    panel.onDidDispose(() => {
      this.panels.delete(id);
    });

    this.panels.set(id, panel);
    return panel;
  }

  get(id: string): vscode.WebviewPanel | undefined {
    return this.panels.get(id);
  }

  disposeAll(): void {
    this.panels.forEach(panel => panel.dispose());
    this.panels.clear();
  }
}

リソース

詳細なリファレンスドキュメント:

  • references/csp-reference.md - CSP ディレクティブの完全なリファレンス
  • references/message-patterns.md - 高度なメッセージパッシングのパターン
  • references/theming-guide.md - VS Code のテーマ統合ガイド

コメント