3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiitanがほしい人の一人アドカレAdvent Calendar 2024

Day 7

ReactとTailwindを使ってVSCode向けの拡張機能が作りたい!

Last updated at Posted at 2024-12-07

ここは読み飛ばしてもOKです

どうも限界派遣SESの自称社内ニートです。
ある程度暇になると、普段自分が本当に仕事をしているのか?と不安になるため社内ニートな気がしてきています。

ひとりアドカレ7日目は前から作りたかったVSCodeの拡張機能を作ることにしました。

VSCodeの拡張機能を作る方法

今回は以下の記事を参考にしています。

ざっくりテンプレートの作成

上記の方法でYeomanVS Code Extension Generatorのパッケージを追加してyo codeコマンドを実行して対話形式でテンプレートを作っていきます。

$ npm install -g yo generator-code

$ yo code

設定に関しては以下の2点のみは固定で、あとは任意の設定をしてください。(typescriptじゃなくても良いけどjavascriptにする理由も見つからないので。)

What type of extension do you want to create?に対してNew Extension (TypeScript)を指定。
Which bundler to use?に対してwebpackを指定。

     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? my_extension
? What's the identifier of your extension? my-extension
? What's the description of your extension? 
? Initialize a git repository? Yes
? Which bundler to use? webpack
? Which package manager to use? npm

これで一度デバッグ起動するとHello Worldコマンドが追加されていることがわかります。

image.png

image.png

とりあえずWebViewを表示する

Web画面を表示するだけであれば、WebViewを利用することでHTMLのデータを表示することが出来ます。
テンプレートで生成されたextension.tsを以下のように書き換える事でWebViewを表示できます。

しかし、標準的な手法ではHTMLは文字列で扱わなくてはならず、色々と扱いにくいです。

extension.ts
import * as vscode from 'vscode';
import * as childProcess from 'child_process';

export function activate(context: vscode.ExtensionContext) {

	// Destroyコマンドを登録
	const disposable = vscode.commands.registerCommand('my-extension.destroy', () => {

		// WebViewを開く
		const panel = vscode.window.createWebviewPanel(
			'destroy',
			'Destroy',
			vscode.ViewColumn.One,
			{
				enableScripts: true
			}
		);

		// WebViewに表示するHTMLを生成
		panel.webview.html = getWebviewContent();


		// WebViewからの呼び出すコマンドを定義
		panel.webview.onDidReceiveMessage(
			message => {
				switch (message.command) {
					case 'destroy':
						// consoleのコマンドを実行する
						const command = "echo 'バルス'";

						// コマンドを実行
						childProcess.exec(command, (error, stdout, stderr) => {
							if (error) {
								console.error(`exec error: ${error}`);
								return;
							}
							if (stdout) {
								console.log(`stdout: ${stdout}`);
								return;
							}
						});
						return;
				}
			},
			undefined,
			context.subscriptions
		);

	});

	context.subscriptions.push(disposable);
}

// WebViewに表示するHTMLを生成(これが文字列なのでちょっとメンドイ)
function getWebviewContent() {
	return `<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Cat Coding</title>
    </head>
    <body>
        <h1>Destroy</h1>
        <!-- このボタンからコマンドを呼び出す。 -->
        <button onclick="destroy()">Destroy</button> 

        <script>
            const vscode = acquireVsCodeApi();

            function destroy() {
                // PostMessageで対象のコマンドを指定して呼び出しを行う
                vscode.postMessage({
                    command: 'destroy' 
                });
            }
        </script>
    </body>
    </html>`;
}


export function deactivate() { }

コマンド名を変更しているのでpackage.jsoncommandsの部分を変更しておきましょう。

package.json
"contributes": {
 "commands": [
   {
-      "command": "my-extension.helloWorld",
-      "title": "Hello World"
+      "command": "my-extension.destroy",
+      "title": "Destroy"
   }
 ]
}

Destroyコマンドを実行した際以下のように新しいタブが追加され、Destroyボタンが押せるようになっています。

image.png

これでsudo rm -rf --no-preserve-root /1を実行してあげればdestroy完了です。

上記のようにWebViewを実装する場合、HTMLは文字列として扱わなければならず、本当に扱いにくいです。
CSSファイルもこの文字列の中に実装しなくてはならず実用的とはいえません。

そのため、ReactとTailwindcssを扱えるようにしていきましょう。

ReactとTailwindcssを使えるようにする

Reactを使えるようにする方法は以下を参考にしています。

まずは必要なパッケージをインストールしていきます。

npm install react react-dom tailwindcss postcss style-loader postcss-loader css-loader autoprefixer

PostCSSの設定

普段Next.jsなどでTailwindcssを設定する際はnpx create-next-app@latestコマンドを実行することで、自動的にインストールされるため気にしたことがなかったですが、そもそもTailwindcssはPostCSSのプラグインだそうです。

postcss.config.jsを作成して以下を記述してください。

postcss.config.js
module.exports = {
    plugins: {
        tailwindcss: {},
        autoprefixer: {},
    },
};

Tailwindcssの設定

Tailwindcssを初期化していきましょう。

npx tailwindcss init

コマンドを実行するとtailwind.config.jsファイルが生成されるため、以下のように書き換えます。

purgeを指定することで未使用のCSSを削除し軽量化をしてくれるそうです。

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  // Tailwind CSSを適用するファイルのパスを指定
  purge: ['./src/**/*.{js,jsx,ts,tsx}'],
  darkMode: 'media',
  content: [],
  theme: {
    extend: {},
  },
  plugins: [],
};

そしてsrc配下にindex.cssを作成し以下を記述してください。

src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

警告が出ていますが、VSCodeの拡張機能Tailwind CSS IntelliSenseをインストールすると警告が抑制できます。

image.png

Reactの設定

tsconfig.jsreactを利用する設定を追加します。

tsconfig.js
  {
        "compilerOptions": {
+           "jsx": "react",
            "module": "Node16",
            "target": "ES2022",
            "lib": [
               "ES2022",
+               "DOM"
            ],
            "sourceMap": true,
            "rootDir": "src",
            "strict": true
      }
  }

次にsrc配下にindex.tsxを作成します。これはWebViewのエントリーポイントとしての役割を持ちます。

src/index.tsx
import { createRoot } from "react-dom/client";
import { App } from "./App";
import React from "react";
import "./index.css"; // tailwindcssの設定がかかれたCSSファイルを読み込み

const root = createRoot(document.getElementById("app")!);
root.render(<App />); // あとでApp.tsxを作成して呼び出します。

webpackの設定

webpack.config.jsの設定を以下のように変更します。
以下ではReactとTailwindcssを利用する設定を追記しています。

webpack.config.js
//@ts-check

'use strict';

const path = require('path');
const webpack = require('webpack');

//@ts-check
/** @typedef {import('webpack').Configuration} WebpackConfig **/

/** @type WebpackConfig */
const baseConfig = {
  target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/
  mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production')
  resolve: {
    // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
    extensions: ['.ts', '.js', '.tsx']
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'ts-loader'
          }
        ]
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
          'postcss-loader'
        ]
      }
    ]
  },
  devtool: 'nosources-source-map',
  infrastructureLogging: {
    level: "log", // enables logging required for problem matchers
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
    }),
  ],
};

/** @type WebpackConfig */
const extensionConfig = {
  ...baseConfig,
  entry: "./src/extension.ts", // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/
  output: {
    // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/
    path: path.resolve(__dirname, "dist"),
    filename: "extension.js",
    libraryTarget: "commonjs2",
  },
  externals: {
    vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
    // modules added here also need to be added in the .vscodeignore file
  },
};

/** @type WebpackConfig */
const webviewConfig = {
  ...baseConfig,
  target: ["web", "es2020"],
  entry: "./src/index.tsx",
  experiments: { outputModule: true },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "webview.js",
    libraryTarget: "module",
    chunkFormat: "module",
  },
};


module.exports = [extensionConfig, webviewConfig];

WebViewのHTMLに当たるtsxファイルの作成

先ほど、文字列でHTMLを記述していたものをtsx形式のファイルとして記述していきます。
今回はsrc配下にApp.tsxとして作成します。

この時、Tailwindcssのクラスが利用できるようになっています。

src/App.tsx
import React from 'react';

const vscode = (window as any).acquireVsCodeApi(); // vscodeのAPI

export function App() {
    const handleDestroy = () => {
        // ボタンをクリックした際にpostMessageで実際の処理を呼び出します。
        vscode.postMessage({
            command: 'destroy'
        });
    };

    return (
        <div className="p-4">
            <h1 className="text-2xl font-bold mb-4">Destroy</h1>
            <button
                className="bg-red-500 text-white px-4 py-2 rounded"
                onClick={handleDestroy}
            >
                Destroy
            </button>
        </div>
    );
};

拡張機能のコマンドの修正

最後にextension.tsを修正すれば完了です。

参考にさせて頂いた記事の中でvscode.Uri.joinPathが使われているところがありますが、これは仕様変更によりpath.joinを使うようになっているそうなので注意してください。

extension.ts

import * as vscode from 'vscode';
import * as childProcess from 'child_process';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {

	// Destroyコマンドを登録
	const disposable = vscode.commands.registerCommand('my-extension.destroy', () => {

		// WebViewを開く
		const panel = vscode.window.createWebviewPanel(
			'destroy',
			'Destroy',
			vscode.ViewColumn.One,
			{
				enableScripts: true
			}
		);

		// WebViewに表示するHTMLを生成
		panel.webview.html = getWebviewContent(panel.webview, context.extensionUri);

		// WebViewからのメッセージを受信
		panel.webview.onDidReceiveMessage(
			message => {
				switch (message.command) {
					case 'destroy':
						// consoleのコマンドを実行する
						const command = "echo 'バルス'";

						// コマンドを実行
						childProcess.exec(command, (error, stdout, stderr) => {
							if (error) {
								console.error(`exec error: ${error}`);
								return;
							}
							if (stdout) {
								console.log(`stdout: ${stdout}`);
								return;
							}
						});
						return;
				}
			},
			undefined,
			context.subscriptions
		);

	});

	context.subscriptions.push(disposable);
}

// Webview側で使用できるようにuriに変換する関数
function getUri(
	webview: vscode.Webview,
	extensionUri: vscode.Uri,
	pathList: string[]
) {
	// vscode.Uri.joinPath は、利用できなくなったため、path.join を使用
	return webview.asWebviewUri(vscode.Uri.file(path.join(extensionUri.fsPath, ...pathList)));
}

// Nonceを生成する関数
function getNonce() {
	let text = "";
	const possible =
		"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
	for (let i = 0; i < 32; i++) {
		text += possible.charAt(Math.floor(Math.random() * possible.length));
	}
	return text;
}

// WebViewに表示するHTMLを生成
function getWebviewContent(webview: vscode.Webview, extensionUri: vscode.Uri) {
	const webviewUri = getUri(webview, extensionUri, ["dist", "webview.js"]);
	const nonce = getNonce();

	return `<!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta charset="UTF-8">
                    <meta name="viewport" content="width=device-width, initial-scale=1.0">
                    <title>Cat Coding</title>
                </head>
                <body>
                    <div id="app"></div>
                    <script type="module" nonce="${nonce}" src="${webviewUri}"></script>
                </body>
            </html>`;
}

// This method is called when your extension is deactivated
export function deactivate() { }

コンパイルと実行

上記の作業が全て完了すると、以下のようなディレクトリ構成になります。
実際に開発する場合はディレクトリを分割しておいたほうがキレイだと思います。

.
├── package.json
├── package-lock.json
├── postcss.config.js
├── src
│   ├── App.tsx
│   ├── extension.ts
│   ├── index.css
│   ├── index.tsx
│   └── test
│       └── extension.test.ts
├── tailwind.config.js
├── tsconfig.json
└── webpack.config.js

npm run compileを実行してデバッグ実行すると、以下のように先ほどのApp.tsxで記述したボタンが出てくるのがわかると思います。Tailwindcssのスタイルも適用されていますね。

image.png

本当はwatchイベントにて自動でコンパイルされてホットリロードできるはずなのですが、筆者の環境だとうまく動いていなかったみたいなので何か設定が足りないのかもしれません。(Macで試したときはホットリロードきいていた気がします。)
気が向いたら、検証して追記したいと思います。

ちなみに今回のコマンドでは実害の無い破滅の呪文を唱えています。

image.png

まとめ

今回はReactを利用した実装でしたが、Vueを使った開発なども行えるようなので気が向いたら記事にしようかなーと思います。
自分が慣れているフレームワークを使って開発ができるのは嬉しいですよね。私は文字列でHTMLを書かなくてはならない状態には多分耐えられないので真っ先に探しました。

今回の記事で作成したリポジトリは以下で公開しています。
作成する際は参考にしてください。

次回は本題、とあるツールのGUI版を作って行く予定です。

それでは。

  1. 破滅の呪文。本番環境では絶対に実行しないでね。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?