本記事の目的
表題の通り、Webview を用いた VSCode Extension の作り方について書いていますが、
VSCode Extension の作り方については以下の公式ドキュメントを読めば分かります。
また、Webview についてもきちんと解説があります。
本記事はほぼ自分用のメモでして、作ってみた結果いくつか注意点等ありましたので、コードをテンプレ化してみた感じです。
テンプレコード
本記事のコードは↑にあります。次の手順でデバッグ実行できます。
-
VSCode 上で
⌘ + Shift + P
を押してコマンドパレットを開きます -
パレットから
Debug: Start Debugging
を選択します. すると新しい Extension 実行用の VSCode が起動します -
Extension 実行用の VSCode 上で
⌘ + Shift + P
を押してコマンドパレットを開きます -
パレットから
example-vscode-extension-webview: Hello World
を選択します
ディレクトリ構造
ほぼ公式チュートリアルの構造と同じですが、一部都合に併せて改変しています。
.
├── out
│ └── extension.js
│
├── src
│ ├── command
│ │ ├── _app.ts
│ │ ├── _utils.ts
│ │ └── *.ts
│ └── extension.ts
│
├── media
│ ├── cat.gif
│ ├── events.js
│ └── style.css
│
├── eslint.config.mjs
├── tsconfig.json
└── package.json
Directory | 説明 |
---|---|
out/* |
npm run compile すると、ここに tsc コンパイルの結果が出力されます。 |
src/extensions.ts |
VSCode Extension のエントリコードです。 |
src/command/* |
webview ⇔ Extension 間の複雑性を縮減するために型化したコードです。 webview 側から呼べる API の定義をしています。 |
media/* |
src/extension.ts コードで定義した Webview (html) で参照しているリソース類の置き場です。e.g. <link> , <script> , <img> ... |
注意・工夫点
tsconfig.json
{
"compilerOptions": {
- "module": "Node16", // or "CommonJS"
+ "module": "CommonJS",
"target": "ES2022",
"outDir": "out",
"lib": [
"ES2022"
],
"sourceMap": true,
"rootDir": "src",
"strict": true, /* enable all strict type-checking options */
/* Additional Checks */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
+ "esModuleInterop": true
}
}
大事な点ですが、VSCode Extension 自体は package.json
/ tsconfig.json
の設定で CommonJS プロジェクトとして設定する必要があります。
2024/11 現在、プロジェクトを ESM として設定する と動きません!
これを踏まえて tsconfig.json
の "module": "..."
には2つの選択肢があります。
module | 説明 |
---|---|
Node16 | VSCode Extension の標準はこれで今後一般的に推奨されます。 しかし一部の npm modules (e.g. @langchain) では tsc コンパイル時に後述するエラーが発生してしまいます。 |
CommonJS | 昔からの挙動をとる互換のための設定で、最近では Node16 に置き換えられつつあります。がしかし前述の Node16 の問題は、こちらを設定すると解消されます。現時点では VSCode Extension は CommonJS module しか参照できないので特に問題はない筈... |
NPM で公開されている一部の commonjs / ESM 両対応の module では package.json
の "exports":
の設定誤りによって tsc
コンパイル時に次のエラーが発生します。
error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls;
however, the referenced file is an ECMAScript module and cannot be imported with 'require'.
また、"module": "CommonJS"
を選択する際は、明示的に "esModuleInterop": true
を設定する必要があります。
これを設定しないと import fs from 'fs'
の様な default import でコンパイルエラーが発生してしまいます。
"module": "Node16"
の場合は default =true
となるので、VSCode Extension の初期コードでは消されているようです。
eslint.config.mjs
VSCode Extension の default コードは古い書き方になっているため、2024/11 現在で推奨されている flat config & typescript-eslint のものに書き換えています。
src/extension.ts
このファイルが VSCode Extension のエントリコードです。
Extension の初期化・登録と、Webview html の生成を行っています。
ここではチュートリアルコードのまま、コード中に文字リテラルとして Webview html を直接記述しています。
リソース類 (.gif
, .css
, .js
) は後述する media/*
ディレクトリにおいてあり、src=
属性でパス参照をします。
html ヘッダでは <meta http-equiv="Content-Security-Policy" ... />
を設定しており、javascript インラインコード等は禁止しています。
import * as vscode from 'vscode'
import { commandRouter } from './command/_app'
export function activate(context: vscode.ExtensionContext) {
// ...省略...
}
function getWebviewContent(cspSource: string, gif: vscode.Uri, script: vscode.Uri, css: vscode.Uri) {
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'; img-src ${cspSource} https:; script-src ${cspSource}; style-src ${cspSource};"
/>
<title>Cat Coding</title>
<link rel="stylesheet" href="${css.toString()}">
</head>
<body>
<img src="${gif.toString()}" width="300" />
<button id="findfiles">List files</button>
<textarea id="files"></textarea>
<button id="createfile">Create "hello.txt"</button>
<script src="${script.toString()}"></script>
</body>
</html>`
}
src/command/*.ts
こちらは完全に独自のファイルです。 Webview (html) から Extension 宛に呼べる API の定義をしています。
tRPC / zod のインターフェースを真似てみました。
import { z } from 'zod'
import { createCommandRouter, CommandFactory } from './_utils'
import type { SendResponse, Command } from './_utils'
import { findFiles } from './findfiles'
import { createFile } from './createfile'
export const commandRouter = createCommandRouter(getHandlers)
function getHandlers(sendResponse: SendResponse): Command[] {
return [
// 'findfiles' API 定義.
CommandFactory
.command('findfiles')
.input(z.object({
dir: z.string(),
}))
.query(async (message) => { // .input(zod) で定義した型の message が来る.
// process.
const files = await findFiles(message)
// send response.
sendResponse({
command: message.command,
result: files,
})
}),
// 'createfile' API 定義.
CommandFactory
.command('createfile')
.input(z.object({}))
.query(async (message) => {
await createFile(message)
}),
]
}
※ Webview 本来の仕組み
VSCode Extension では、Webview (html) からは vscode.postMessage() で、逆に Extension からは webview.postMessage() でメッセージ送信できます。
設計として一貫性を持たせないと、コードが複雑化し簡単に循環参照に陥りそうなので本 API 定義コードを書きました。
media/*
html から参照する公開リソースの置き場です。
公式チュートリアルに従い media/*
としていますが、他フレームワークではよく public/*
と命名されることが多いですね。
ここで特に重要なのは media/event.js
で、このファイルは Webview (html) に <script src="..."></script>
埋め込みで参照されています。
この javascript ファイルでは主に Webview (html) の DOM Event 処理をします。
1. メッセージ送信 (Webview → Extension)
Webview (html) 上のボタンが押された際の処理をコーディングしています。
vscode.postMessage()
で Extension 側で定義した API を呼んでいます。
const vscode = acquireVsCodeApi() // eslint-disable-line no-undef
const buttonFindfiles = document.getElementById('findfiles')
const buttonCreatefile = document.getElementById('createfile')
// send findfiles command.
buttonFindfiles.addEventListener('click', () => {
vscode.postMessage({
command: 'findfiles',
params: {
dir: '.',
},
})
})
// send createfile command.
buttonCreatefile.addEventListener('click', () => {
vscode.postMessage({
command: 'createfile',
params: {},
})
})
2. メッセージ受信 (Extension → Webview)
逆にこちらのコードでは Extension 側からのメッセージ応答を受信しています。
const textarea = document.getElementById('files')
// Handle the message inside the webview
window.addEventListener('message', event => {
const message = event.data
// define event handlers.
const eventHandlers = {
findfiles: () => {
textarea.value = message.result.join('\n')
},
}
// call handler.
eventHandlers[message.command]()
})