この記事は、CYBOZU SUMMER BLOG FES '24 (Frontend Stage) DAY 2の記事です。
本記事では、ブラウザ拡張機能開発を加速させる、個人的に注目な3つの拡張機能開発フレームワーク・ツール(WXT、Plasmo、Extension.js)を紹介します。
サンプル拡張機能の実装を通して、それぞれの特徴、セットアップ方法、実際の開発フローを見ていきます。お好みの拡張機能開発ツールが見つかれば嬉しいです。
WXTは、Viteベースのブラウザ拡張フレームワークです。次のような特徴を持っています(トップページから抜粋)。
Plasmoは、Parcelベースのオールインワンなブラウザ拡張フレームワークです。次のような特徴を持っています(トップページから抜粋)。
Plasmoに関しての詳細な説明は、次の記事が参考になります。
https://zenn.dev/nado1001/articles/plasmo-browser-extension
Extension.jsは、実用性と迅速なプロトタイピングを念頭に設計された拡張機能開発ツールです。次のような特徴を持っています(トップページから抜粋)。
カウンター横の取得するボタンを押すと、現在のカウント数をidに持つポケモンがポップアップに表示されます。カウンターはWebページ上に表示されており、ポップアップは拡張機能のToolbar Buttonを押すと表示されます。
拡張機能のフロー図[1]
完成品のコードはこちらです。全部全く同じ挙動をします。
https://github.com/k1tikurisu/browser-extension-tools
https://wxt.dev/get-started/installation.html
対話形式でセットアップします。テンプレートは、vanilla
、vue
、react
、solid
、svelte
、から選ぶことができます。パッケージマネージャはnpm
、pnpm
、yarn
、bun
から選べます。
npx wxt@latest init.✔ Choose a template › react✔ Package Manager ›npm
https://wxt.dev/guide/directory-structure/output.html
.├── assets├── entrypoints│ ├── background.ts# Background scripts│ ├── content.ts# Content scripts│ └── popup/# Popups├── package.json├── public└── wxt.config.ts# 設定ファイル(manifestなど)
WXTでは、entrypoints
ディレクトリにファイルを追加することでエントリーポイントを作成します。background
や、*.content
、popup/
など、一部のファイル名やパターンは特別で、マニフェストの生成に影響します。詳しくは、Entrypoints Directory guideを参照してください。リストにないファイルはマニフェストには追加されませんが、実行時にアクセスしたり、読み込むことは可能です。
dev
コマンドで拡張機能込みの開発用ブラウザが起動し、開発を開始できます。
wxt.config.ts
のmanifest
プロパティに追加で与えたい権限などを記載します。
import{ defineConfig}from'wxt';exportdefaultdefineConfig({ modules:['@wxt-dev/module-react'], manifest:{// ここに書く}});
今回は特にありません。
https://wxt.dev/guide/key-concepts/content-script-ui.html#content-script-ui
ReactでUIを構築したいので、entrypoints/content.ts
をentrypoints/content/index.tsx
に変更します。
importReactDOMfrom'react-dom/client';importAppfrom'./App.tsx';exportdefaultdefineContentScript({ matches:['*://*/*'], cssInjectionMode:'ui',// Content scriptsのCSSがWebページに影響を与えないようにするasyncmain(ctx){// Content scriptsがロードされたタイミングで実行されるconst ui=awaitcreateShadowRootUi(ctx,{ name:'wxt-react-example', position:'inline', anchor:'body',// Elementを直に指定することも可能 append:'first',onMount:(container)=>{// UIのマウント時に実行されるコールバックconst wrapper=document.createElement('div'); container.append(wrapper);const root=ReactDOM.createRoot(wrapper); root.render(<App/>);return{ root, wrapper};},onRemove:(elements)=>{// UIがWebページから削除される時に実行されるコールバック elements?.root.unmount(); elements?.wrapper.remove();},}); ui.mount();// onMountが実行される},});
defineContentScript
のみをエクスポートします。defineContentScript
には、マニフェストのオプションと、main
関数を定義します。main
関数は、Content scriptsのロード時に実行されます。
UIの構成には、createShadowRootUi
が利用できます。内部的にShadowRootが使われており、記述したCSSはWebページに影響を与えません。UIのmount
やunmount
等のライフサイクルを自分で書くところが特徴です。
また、defineContentScript
や、createShadowRootUi
などは、Nuxt風の自動インポート機能により明示的にインポートする必要はありません。
カウンターのReactコンポーネントです。useState
でカウント数を保持し、runtime.sendMessage
を使って、Background scriptsにカウント数を送信しています。
import{ useState}from'react';import'./App.css';constApp=()=>{const[count, setCount]=useState(1);return(<div><p>カウント数{count}</p><buttontype="button"onClick={()=>setCount((count)=> count+1)}> カウント</button><buttontype="button"onClick={()=>{ chrome.runtime.sendMessage({ type:'count', id: count,});}}> 取得する</button></div>);};exportdefaultApp;
https://wxt.dev/guide/directory-structure/entrypoints/background.html
exportdefaultdefineBackground({asyncmain(){handlePokemonRequest()}});
defineBackground
のみをエクスポートします。defineBackground
は、defineContentScript
同様に、マニフェストのオプションとmain
関数を定義します。main
関数は、Background scriptsのロード時に実行されます。
runtime.onMessage
でContent scriptsからのメッセージを受信します。
Content scriptsが送信したメッセージtype
と一致していたら、PokéAPIにリクエストを送信し、レスポンスをruntime.sendMessage
でPopupに送ります。
functionhandlePokemonRequest(){ chrome.runtime.onMessage.addListener(async(message)=>{if(message.type==='count'){const url=`https://pokeapi.co/api/v2/pokemon/${message.id}`;try{const response=awaitfetch(url);const data=await response.json(); chrome.runtime.sendMessage({ type:'poke', image: data.sprites.front_default, name: data.name,});}catch(error){console.error('Error fetching poke:', error);}}});};
https://wxt.dev/guide/directory-structure/entrypoints/popup.html
entrypoints/popup.html
またはentrypoints/popup/index.html
はPopupsとして解釈されます。
<!doctypehtml><htmllang="en"><head><metacharset="UTF-8"/><metaname="viewport"content="width=device-width, initial-scale=1.0"/><title>Default Popup Title</title><metaname="manifest.type"content="browser_action"/></head><body><divid="root"></div><scripttype="module"src="./main.tsx"></script></body></html>
React要素をレンダリングするためのroot
ノードを定義し、main.tsx
を読み込みます。
main.tsx
で、root
ノードの中にReact要素をレンダリングします。
importReactDOMfrom'react-dom/client';importAppfrom'./App';const root=ReactDOM.createRoot(document.getElementById('root')!);root.render(<App/>);
runtime.onMessage
でBackground scriptsからのメッセージを受信し、中身に応じてポケモンを描画しています。
import{ useEffect, useState}from'react';import'./App.css';interfacePokeMessage{ type:string; image:string; name:string;}constPopup=()=>{const[pokeData, setPokeData]=useState<{ image:string; name:string}>({ image:'', name:'',});useEffect(()=>{consthandleMessage=(message:PokeMessage)=>{if(message.type==='poke'){setPokeData({ image: message.image, name: message.name});}};if(!chrome.runtime.onMessage.hasListener(handleMessage)){ chrome.runtime.onMessage.addListener(handleMessage);}return()=>{ chrome.runtime.onMessage.removeListener(handleMessage);};},[]);return(<divclassName="container"><imgalt={pokeData.name}src={pokeData.image}className="image"/><spanclassName="name">{pokeData.name}</span></div>);};exportdefaultPopup;
entrypoints
でエクスポートする関数もインターフェースが揃っていてわかりやすいですmount
やunmount
等のライフサイクルを自分で書くのは個人的には嬉しいと思いました。ユーザのインタラクションに応じてUIを表示するなど、複雑な要件でも直感的に記述できそうですdev
コマンドで拡張機能込みの開発用ブラウザが起動するのはかなり体験が良かったです。コンポーネント作成中のHMRも安定していましたhttps://docs.plasmo.com/framework#getting-started
create
コマンドで雛形を作成できます。
# srcディレクトリ配下にソースコードを配置するnpm create plasmo --with-src# Messaging APIを扱うため別途インストールするnpm i @plasmohq/messaging
.├── README.md├── assets├── src│ ├── background.ts# Background scripts│ ├── contents# Content scripts│ │ └── plasmo.ts│ ├── popup.tsx# Popups│ └── style.css└── package.json
Content scripts、Background scriptsや、PopupsなどのExtension Pagesは、すべて予約されたファイル名で作成する必要があります。詳しくはPlasmo Frameworkの該当する項を参照してください。
dev
コマンドで開発用サーバーが起動します。生成されたbuild/chrome-mv3-dev
を、開発用の拡張機能として読み込むことで、開発を開始できます。
package.json
のmanifest
プロパティに追加で与えたい権限などを記載します。
{"manifest":{// ここに書く}}
今回は特にありません。
https://docs.plasmo.com/framework/content-scripts-ui
contents/
もしくは、content.tsx
を配置することで、ReactでUIを構築できます。
importstyleTextfrom'data-text:./content.css';importtype{PlasmoCSConfig,PlasmoGetInlineAnchor,PlasmoGetStyle}from'plasmo';import{App}from'./App';// マニフェストのオプションexportconst config:PlasmoCSConfig={ matches:['*://*/*'],};// スタイルexportconst getStyle:PlasmoGetStyle=()=>{const style=document.createElement('style'); style.textContent= styleText;return style;};// Content scriptsで描画するUIの位置(アンカー)を指定exportconst getInlineAnchor:PlasmoGetInlineAnchor=()=>document.body;// レンダリングするReact要素constIndex=()=><App/>;exportdefaultIndex;
config
にはマニフェストのオプション、getStyle
にはスタイル、getXxAnchor
にはアンカーをそれぞれ定義し、エクスポートします。基本的にはReact要素をエクスポートするだけでUIを描画できるところが特徴です。その他のオプションやライフサイクルは、Life Cycle of Plasmo CSUIを参照してください。
Plasmoも内部的にShadowRootが使われており、記述したCSSはWebページに影響を与えません。
contents/index.tsx
でレンダリングしている<App />
では、取得するボタン押下時にPlasmoのMessaging APIである、sendToBackground
を使用しています。
// 取得ボタン押下時に↓を実行awaitsendToBackground({ name:'count', body:{ id: count,},});
sendToBackground
では、name
とbody
を指定して、Background scriptsにメッセージを送信でき、返り値でレスポンスを受け取ります。詳しいAPIの対応関係はTL;DR | Messaging APIを参照してください
カウンターのReactコンポーネントです。
今回はPopupsでレスポンスを受け取るため、sendToBackground
の返り値は受け取りません。
import{ sendToBackground}from'@plasmohq/messaging';import{ useState}from'react';exportconstApp=()=>{const[count, setCount]=useState(1);return(<div><p>カウント数{count}</p><buttontype="button"onClick={()=>setCount((count)=> count+1)}> カウント</button> <button type="button" onClick={async()=>{awaitsendToBackground({ name:'count', body:{ id: count,},});}} > 取得する</button></div>);};
https://docs.plasmo.com/framework/background-service-worker
PlasmoのMessaging APIを利用すると、runtime.onMessage
を使わずにファイルベースでメッセージを受信することができます。
今回は、count
というname
でカウント数を受け取るため、background/messages/count.ts
という場所にファイルを作ります。
importtype{ PlasmoMessaging}from'@plasmohq/messaging';const handler: PlasmoMessaging.MessageHandler<{ id:string}>=async(req, res)=>{// 受信後の処理を書く};exportdefault handler;
req.body
にリクエストの中身が入っています。送信元にレスポンスを返す場合は、res.send()
を使うことができます。req.body
の型は、MessageHandler
の型引数に明示する必要があります。[2]
今回は、送信元ではなくPopupsにポケモンの情報を送りたいので、runtime.sendMessage
を使用しています。
importtype{ PlasmoMessaging}from'@plasmohq/messaging';const handler: PlasmoMessaging.MessageHandler<{ id:string}>=async(req, _res)=>{const url=`https://pokeapi.co/api/v2/pokemon/${req.body.id}`;try{const response=awaitfetch(url);const data=await response.json(); chrome.runtime.sendMessage({ type:'poke', image: data.sprites.front_default, name: data.name,});}catch(error){console.error('Error fetching poke:', error);}};exportdefault handler;```
https://docs.plasmo.com/framework/ext-pages#adding-a-popup-page
popup.tsx
またはpopup/index.tsx
で描画したいコンポーネントをエクスポートするとPopupsとして解釈されます。
import'./popup.css';import{ useEffect, useState}from'react';// Reactコンポーネントをdefault exportするfunctionIndexPopup(){// ポケモンの描画処理}exportdefaultIndexPopup;
WXTのApp
コンポーネントと全く同じです。PopupsでもPlasmoのMessaging APIが使用可能ですが、Background scriptsでruntime.sendMessage
を使用しているため、runtime.onMessage
で受信しています。
import{ useEffect, useState}from'react';import'./index.css';interfacePokeMessage{ type:string; image:string; name:string;}constPopup=()=>{const[pokeData, setPokeData]=useState<{ image:string; name:string}>({ image:'', name:'',});useEffect(()=>{consthandleMessage=(message:PokeMessage)=>{if(message.type==='poke'){setPokeData({ image: message.image, name: message.name});}};if(!chrome.runtime.onMessage.hasListener(handleMessage)){ chrome.runtime.onMessage.addListener(handleMessage);}return()=>{ chrome.runtime.onMessage.removeListener(handleMessage);};},[]);return(<divclassName="container"><imgalt={pokeData.name}src={pokeData.image}className="image"/><spanclassName="name">{pokeData.name}</span></div>);};exportdefaultPopup;
https://extension.js.org/n/getting-started/get-started-immediately/
デフォルトでTailwind CSSで構成されます。
# react-typescriptテンプレートを使用npx extension create.--template=react-typescript
.├── background.ts# Background scripts├── content# Content scripts│ ├── ContentApp.tsx│ ├── base.css│ ├── content.css│ └── content.tsx├── extension-env.d.ts├── manifest.json├── package.json└── public
フレームワークではないため、予約されたファイル名等はありません。
dev
コマンドで拡張機能込みの開発用ブラウザが立ち上がり、開発を開始できます。
manifest.json
に書きます。
{"manifest_version":3,"version":"1.0","name":"extension-js","description":"","background":{"service_worker":"./background.ts"},"action":{"default_title":"Default Popup Title","default_popup":"popup/index.html"},"content_scripts":[{"matches":["*://*/*"],"js":["./content/main.tsx"]}],"host_permissions":["*://*/*","http://localhost/*"],"icons":{"16":"public/icon/icon_16.png","48":"public/icon/icon_48.png"}}
Popups、Background scripts、Content scriptsそれぞれへのビルド前のパスを記載します。
特にフレームワーク特有のルール等はありません。ほとんどこれまでのコードのコピペです。詳細な実装は下記を参照してください。
https://github.com/k1tikurisu/browser-extension-tools/tree/main/extensions/extension-js
dev
コマンドで開発用ブラウザが起動するのはとても体験が良いです今回は、拡張機能を開発するためのフレームワーク・ツールの紹介と、実際にWXT、Plasmo、Extension.jsで拡張機能を実装しました。
開発者体験は全部良かったです。コンポーネントの実装自体が大きく変わるわけではないので、移行はそんなに大変ではないかなと思いました。(PlasmoのAPIをヘビーに使っている場合は微妙かも)
フレームワークを使用する際は慎重に検討する必要があると思いますが、PoC作成等では気にせずガンガン使って加速させると良さそうです。
最後に、マニフェストを自動生成するフレームワークを使用する際は、ビルド後のマニフェストに目を通しておくと安心です。想定外の権限が付与されていることがあります。例として、Plasmoは@plasmohq/storage
を依存関係に追加すると、使用していなくてもstorageの権限が付与されます。
下記の記事が参考になります。
https://dev.classmethod.jp/articles/eetann-chrome-extension-by-crxjs/
WXTの開発者である@aklinker1氏が作成した、WXTの前身となるツールです。
https://vite-plugin-web-extension.aklinker1.io/