こんにちは! 株式会社iimonに所属しているエンジニアのひがです!
これまで業務でWebフレームワークやライブラリに触る機会は多かったのですが、ふとした瞬間に「ブラウザ拡張機能開発のFWってどんなものがあるのだろうか?」と疑問に思い、この機に調べて実際に使ってみました🙌
是非みていっていただけると嬉しいです!
はじめに
今回はPlasmo Frameworkというブラウザ拡張機能専用のFWで遊んでみます!
公式トップ: https://www.plasmo.com/
ドキュメント: https://docs.plasmo.com/framework
ドキュメントの冒頭に
ハッカーによるハッカーのための、バッテリー満載のブラウザ拡張SDKです。 設定ファイルやブラウザ拡張機能構築の奇妙な特殊性に悩まされることなく、製品を構築することができます。
と書かれています!(英語訳ちょっと怪しいかもですが、、)
なんかすごそうですね!
また、Plasmoの特徴として
・React + TypescriptをFirst Classでサポート
・Declarative Development
・Content ScriptsによるUI
・Tabページ
・ライブリロード + React HMR
・.env*ファイル
・Storage API
・Messaging API
・Remote code bundling(GAなど)
・複数ブラウザやmanifestのターゲティング
・BPPによる自動デプロイ
・OptionalでSvelte、Vueなどをサポート
など挙げられています。盛りだくさんですね!
また、比較的新しいOSSのため、ドキュメントが基本英語だったりバージョンが若かったりします。
(Latestがv0.90.2(2025/3/18時点)でした)
https://github.com/PlasmoHQ/plasmo/releases?page=1
すべての機能を触るのはちょっと厳しかったので、今回は下記あたりで遊んでみようと思います。
・Browser Extension Pages(popup)
・Tab Pages
・Content Scripts UI
・Background Service Worker
・Messaging API
・Storage API
・env
それでは、みてみましょう!
Let's play!
まずはプロジェクトの作成ですが、Plasmoはpnpmを激しく推奨しているので、今回はpnpmを使うことにします!
(現に、npmでやろうとすると構築内容が変わってしまいスムーズにいかなかったので諦めました、、)
ちなみに、nodeのバージョンもv23.10.0と新しめで行っています!
(22系とかだと謎のパッケージ依存エラーなど出てしまったので、手取り早くバージョンを上げました)
pnpm create plasmo --with-tailwindcss
レイアウトまわりを簡単に整えたかったので、Tailwind css
もセットで構築します。
ちなみに、plasmoは色々なサービスと合わせてプロジェクトを構築することができるみたいです🙌
https://docs.plasmo.com/quickstarts
プロジェクトを作成しようとすると諸々質問が来るので、適当に答えます。
無事にプロジェクトが作成されました🎉
hello-plasmo
初期のディレクトリ構成は下記のようになっています。
ここでmanifest.jsonがないことに疑問を持った人もいるかと思います。
実はPlasmoはmanifestを用意する必要がなく、ソースファイルとコードからエクスポートした構成に基づいて内部でマニフェストが作成されるみたいです!(まるでNext.jsがファイルシステムとコンポーネントベースでルーティングやSSGを抽象化するかのように、とドキュメントで紹介されてました)
とはいえ、細かい内容で設定したい場合もあるかと思いますので、その場合はpackage.jsonでoverrideできるみたいです。
さっそくプロジェクトへ移動して起動してみましょう。
pnpm dev
起動するとbuild/chrome-mv3-dev配下にビルドされた資源ができます。
これをブラウザで読み取っていきましょう!
今回はChromeで試していきます。
拡張機能の管理ページで「パッケージ化されていない拡張機能を読み込む」から先ほどのビルド資源を読み込みます。
すると読み込まれた拡張機能が出来上がっているかと思います。
右下のボタンが活性化されていると、無事に起動しています!
また、左下の詳細ボタンからツールバーの固定ができますので、やっておきましょう。
ツールバーに固定するとアイコンが現れるかと思いますので、そのアイコンをクリックするとポップアップが出てくると思います。
デフォルトでCounterが実装されてますね!
これがソースコードでいうところのsrc/popup.tsxの内容になります。
ちなみにpopupは最も一般的な拡張機能のページとのことです。
docs.plasmo.com
さて、準備も整いましたので、色々いじっていきましょう。
今回修正/追加したコードを下記で一覧化しておきますので、興味のある方はお目通しください。
popup.tsx
import { useCallback, useEffect, useRef, useState } from "react" import { sendToBackground } from "@plasmohq/messaging" import { Search } from "~features/search" import { Todo } from "~features/todo" import "~style.css" const timer = () => { const [count, setCount] = useState<number>(0) const callback = useCallback(() => { sendToBackground({ name: "timer", body: { type: "popup", action: "add" } }) setCount(count + 1) }, [count]) const callbackRef = useRef(callback) useEffect(() => { callbackRef.current = callback }, [count]) useEffect(() => { const timerId = setInterval(() => { callbackRef.current() }, 1000) return () => clearInterval(timerId) }, []) } function IndexPopup() { timer() return ( <div className="w-[300px] p-4"> <h2 className="w-full mb-2"> <div className="text-center text-lg italic text-slate-500"> Hello Plasmo </div> </h2> <Todo /> <Search /> </div> ) } export default IndexPopup
features/todo.tsx
import { useEffect, useState } from "react" import { Storage } from "@plasmohq/storage" type TodoItem = { id: number title: string done: boolean } const todoStorageKey = "plasmo-todo" export const Todo = () => { const storage = new Storage() useEffect(() => { const init = async () => { const todo = (await storage.get(todoStorageKey)) as TodoItem[] if (!todo) return setTodoList(todo) } init() }, []) const [inputTodo, setInputTodo] = useState<string>("") const [todoList, setTodoList] = useState<TodoItem[]>([]) // やること追加 const addTodo = async (e) => { e.preventDefault() if (!inputTodo) return const maxId = todoList.length ? Math.max(...todoList.map((item) => item.id)) : 0 const props: TodoItem = { id: maxId + 1, title: inputTodo, done: false } await storage.set(todoStorageKey, [...todoList, props]) setTodoList([...todoList, props]) } // やること追加 const doneTodo = async (e, id: number) => { e.preventDefault() const updateList = todoList.map((todo) => { if (todo.id === id) { todo.done = !todo.done } return todo }) await storage.set(todoStorageKey, updateList) setTodoList(updateList) } // やること削除 const deleteTodo = async (e, id: number) => { e.preventDefault() if (!id) return const filteredTodo = todoList.filter((item) => item.id !== id) await storage.set(todoStorageKey, filteredTodo) setTodoList(filteredTodo) } return ( <> <h3 className="mt-2 font-bold">やることリスト</h3> <div className="flex gap-2 my-1"> <input className="pl-2 border border-slate-200 rounded-md w-full" placeholder="Type here..." type="text" value={inputTodo} onChange={(e) => setInputTodo(e.target.value)} /> <button className="bg-green-500 hover:bg-green-700 px-2 text-white rounded-md" onClick={(e) => addTodo(e)}> add </button> </div> {todoList.map((todo) => ( <div key={todo.id} className="flex justify-between my-1"> <div className="flex gap-2"> <input checked={todo.done} type="checkbox" onChange={(e) => doneTodo(e, todo.id)} /> <div className={todo.done ? "line-through" : ""}>{todo.title}</div> </div> <button className="justify-end bg-red-500 hover:bg-red-700 px-2 text-white rounded-sm" onClick={(e) => deleteTodo(e, todo.id)}> delete </button> </div> ))} </> ) }
features/search.tsx
export const Search = () => { return ( <> <h3 className="mt-2 font-bold">探索</h3> <a className="text-sky-500 underline" href={process.env.PLASMO_PUBLIC_PLASMO_TOP_URL} target="_blank"> Plasmo Top </a> </> ) }
contents/plasmo-top.tsx
import cssText from "data-text:~style.css" import type { PlasmoCSConfig } from "plasmo" import { useCallback, useEffect, useRef, useState } from "react" import { sendToBackground } from "@plasmohq/messaging" import "~style.css" export const config: PlasmoCSConfig = { matches: ["$PLASMO_PUBLIC_PLASMO_TOP_URL"] } export const getStyle = (): HTMLStyleElement => { const baseFontSize = 16 let updatedCssText = cssText.replaceAll(":root", ":host(plasmo-csui)") const remRegex = /([\d.]+)rem/g updatedCssText = updatedCssText.replace(remRegex, (match, remValue) => { const pixelsValue = parseFloat(remValue) * baseFontSize return `${pixelsValue}px` }) const styleElement = document.createElement("style") styleElement.textContent = updatedCssText return styleElement } const PlasmoOverlay = () => { const [count, setCount] = useState<number>(3) const callback = useCallback(() => { sendToBackground({ name: "timer", body: { type: "plasmoTop", action: "add" } }) if (!count) { window.location.href = "https://docs.plasmo.com/" return } setCount(count - 1) }, [count]) const callbackRef = useRef(callback) useEffect(() => { callbackRef.current = callback }, [count]) useEffect(() => { const timerId = setInterval(() => { callbackRef.current() }, 1000) return () => clearInterval(timerId) }, []) return ( <> <div className="bg-amber-200 w-[200px] p-4 fixed top-[140px] left-[50px]"> <div>Plasmoを探索</div> <div>3秒後に移動します</div> {count ? count : "here we go !"} </div> </> ) } export default PlasmoOverlay
contents/plasmo-doc.tsx
import cssText from "data-text:~style.css" import type { PlasmoCSConfig } from "plasmo" import { useCallback, useEffect, useRef, useState } from "react" import { sendToBackground } from "@plasmohq/messaging" import "~style.css" export const config: PlasmoCSConfig = { matches: ["$PLASMO_PUBLIC_PLASMO_DOC_URL"] } export const getStyle = (): HTMLStyleElement => { const baseFontSize = 16 let updatedCssText = cssText.replaceAll(":root", ":host(plasmo-csui)") const remRegex = /([\d.]+)rem/g updatedCssText = updatedCssText.replace(remRegex, (match, remValue) => { const pixelsValue = parseFloat(remValue) * baseFontSize return `${pixelsValue}px` }) const styleElement = document.createElement("style") styleElement.textContent = updatedCssText return styleElement } const timer = () => { const [count, setCount] = useState<number>(0) const callback = useCallback(() => { sendToBackground({ name: "timer", body: { type: "plasmoDoc", action: "add" } }) setCount(count + 1) }, [count]) const callbackRef = useRef(callback) useEffect(() => { callbackRef.current = callback }, [count]) useEffect(() => { const timerId = setInterval(() => { callbackRef.current() }, 1000) return () => clearInterval(timerId) }, []) } const PlasmoOverlay = () => { timer() return ( <> <div className="bg-amber-200 w-[200px] p-4 fixed top-[140px] right-[150px]"> <div className="text-center">Complete 🎉</div> <div className="text-sm">Thanks for your time.</div> </div> </> ) } export default PlasmoOverlay
background/messages/timer.ts
import type { PlasmoMessaging } from "@plasmohq/messaging" let popupTime: number = 0 let plasmoTopTime: number = 0 let plasmoDocTime: number = 0 const handler: PlasmoMessaging.MessageHandler = async (req, res) => { const body = req.body if (body?.type === "popup" && body?.action === "add") { popupTime++ return } if (body?.type === "plasmoTop" && body?.action === "add") { plasmoTopTime++ return } if (body?.type === "plasmoDoc" && body?.action === "add") { plasmoDocTime++ return } if (body?.action === "reset") { popupTime = 0 plasmoTopTime = 0 plasmoDocTime = 0 return } const message = { popupTime: popupTime, plasmoTopTime: plasmoTopTime, plasmoDocTime: plasmoDocTime } res.send(message) } export default handler
tabs/analysis.tsx
import { useCallback, useEffect, useRef, useState } from "react" import { sendToBackground } from "@plasmohq/messaging" import "~style.css" export type Times = { popupTime: number plasmoTopTime: number plasmoDocTime: number } function Analysis() { const [count, setCount] = useState(0) const [times, setTimes] = useState<Times>({ popupTime: 0, plasmoTopTime: 0, plasmoDocTime: 0 }) const callback = useCallback(async () => { const res = (await sendToBackground({ name: "timer", body: { type: "analysis", action: "get" } })) as Times setTimes(res) setCount(count + 1) }, [count]) const callbackRef = useRef(callback) useEffect(() => { callbackRef.current = callback }, [count]) useEffect(() => { const timerId = setInterval(() => { callbackRef.current() }, 1000) return () => clearInterval(timerId) }, []) const resetTimer = () => { sendToBackground({ name: "timer", body: { action: "reset" } }) } return ( <> <div className="p-2"> <h2 className="font-bold">Hello Plasmo Analysis</h2> <div className="p-2"> <div>popup滞在時間: {times.popupTime}</div> <div>Plasmo Topページ滞在時間: {times.plasmoTopTime}</div> <div>Plasmo Docページ滞在時間: {times.plasmoDocTime}</div> <button className="mt-2 justify-end bg-red-500 hover:bg-red-700 px-2 text-white rounded-sm" onClick={() => resetTimer()}> タイマーリセット </button> </div> </div> </> ) } export default Analysis
tailwind.config.js(デフォルト生成ではPrefixがついてくるので、消します)
/** @type {import('tailwindcss').Config} */ module.exports = { content: ["./src/**/*.{tsx,html}"], darkMode: "media" }
上記のコードを当てはめると、最終的に下記のような構成になります。
では挙動を見ていきましょう。
まず、先ほどのようにアイコンをクリックすると、下記のようなポップアップが表示されます。
良い感じですね!ちゃんとTailwind cssも効いています!
Todoの方も問題なく追加できています。Reactも問題なく動いていますね!
ここでポップアップを閉じて再度開いてみると、先ほど登録したTodoが状態を維持していることがわかります。
今回はStorageAPIを利用し、ブラウザのストレージにデータを保持することで永続化してみました!
docs.plasmo.com
ちなみにストレージの中身はService Workerから確認することができます!
次に探索エリアをみていきましょう。
こちらは機能としてはシンプルに指定したURLへ遷移するものですが、ポイントが二つあります。
1. 指定するURLはenvに記載されているものを取得
2. 遷移先はContent Script UIが発火するURLで指定
まぁ、要するに簡単な動作確認ですね!
.env
ファイルでは下記のような値をセットしています。
.env
PLASMO_PUBLIC_PLASMO_TOP_URL=https://www.plasmo.com/ PLASMO_PUBLIC_PLASMO_DOC_URL=https://docs.plasmo.com/*
※ ちなみに、.env.production
のように環境ごとに設定ファイルを用意し読み込ませることもできます
docs.plasmo.com
また、Content Script UIの表示はcontentsディレクトリ配下にある内容で指定しています。
今回はPlasmoのトップページとドキュメントで指定しました。
挙動の確認に戻りますが、ポップアップのPlasmo TopリンクをクリックするとPlasmoのトップページへ遷移し、そこでContent Script UIが発火、3秒後にPlasmoのドキュメントページへ遷移するようにしています(忙しないですね!)
ドキュメントへ遷移するとドキュメントのContent Script UIが発火し、完了メッセージが表示されます。
ここで強調しておきたいこととして、ページ単位ごとに別々のContent Script UIを手軽に発火させることができます!
(ちなみに、ドキュメントの方はワイルドカードを使っているので、配下のページであれば常に完了メッセージが表示されます)
docs.plasmo.com
さて、いよいよ楽しい遊びも終わりの時間が近づいてきましたが、最後にタブページをみてみましょう。
Plasmoの大きな特徴の一つとして、独自のタブページを作成することができます。
docs.plasmo.com
src/tabsディレクトリにファイルを作成することで、そのファイル名をパスとしたページが作成されます。
今回だとanalysis.tsxで作成していますので、下記のURLからタブページを開くことができます!
chrome-extension://<id>/tabs/analysis.html
ちなみに、
上記のURLからページを開くと下記のようなページが表示されるかと思います。
何やら滞在時間が表示されてますね!
実はソースコードを見てもらうとわかるのですが、popupやContent Script UIを開いた際にbackgroundへmessage送信で滞在時間を送っていたのです!
ちなみにbackgroundはChromeで拡張機能を起動している限り、裏でずっと動き続けています。
そのため、タブを移動したりしてもChromeや拡張機能を閉じない限りずっと値を保持することができます。
その特性とMessage APIを掛け合わせると、色々なタブでのユーザーアクションを収集・取得することができるのですね〜。
なんか怖いですね!
docs.plasmo.com
docs.plasmo.com
今回はここまでとしますが、まだまだ触ってない機能もたくさんありますので、興味のある方は是非遊んでみてください!
ちなみに
ここまではPlasmoの実装面についてお話してきましたが、PlasmoにはIteroというブラウザ拡張機能用のクラウドサービスがあります。
そちらを利用することで開発フローに大きな恩恵を与えてくれるみたいです!
docs.plasmo.com
たとえばIteroのTestBedを使うと下記のようなメリットがあるとのことです。
・拡張機能をテストするために、Zipにまとめてドラッグ&ドロップする必要はなし
・ブラウザがアップデートを承認するのを待つ必要なし
・gitとシームレスに連携可能
・ベータテスター用にワンクリックでインストール可能
・自動アップデートがテスターにプッシュ
実際に触ってないので所感になりますが、ベータテスタ用のクラウドに拡張機能をアップロードすることで、テスターが自由に動作確認することができるよ、git連携することでアップロードも自動化できるよ、みたいな感じでした。
また、Publisherを使うことで本番環境へのデプロイもGUIベースで楽にできるみたいです。
docs.plasmo.com
ただし、Plasmoの有料化に伴い、この辺りはお金を払わないとデプロイ回数の制限など制約がついてしまうみたいです!
(残念。。)
まとめ
今回はじめて拡張機能のFWを触ってみて、拡張機能自体への理解も深まり、すごく勉強になりました!
手軽に拡張機能開発できる環境も整ってきていて、拡張機能自体ができることのポテンシャルも高いと感じましたので、今後もキャッチアップしつつ、アイディアも出していけたらもっと楽しめそうです。
さいごに
現在弊社ではエンジニアを募集しています!
この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!
iimon採用サイト / Wantedly / Green