iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Plasmo Frameworkにご挨拶してきた件について

こんにちは! 株式会社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できるみたいです。

docs.plasmo.com

さっそくプロジェクトへ移動して起動してみましょう。

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

ちなみに、の部分には拡張機能のIDを入れる必要があります。

上記の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とシームレスに連携可能
・ベータテスター用にワンクリックでインストール可能
・自動アップデートがテスターにプッシュ

docs.plasmo.com

実際に触ってないので所感になりますが、ベータテスタ用のクラウド拡張機能をアップロードすることで、テスターが自由に動作確認することができるよ、git連携することでアップロードも自動化できるよ、みたいな感じでした。
また、Publisherを使うことで本番環境へのデプロイもGUIベースで楽にできるみたいです。

docs.plasmo.com

ただし、Plasmoの有料化に伴い、この辺りはお金を払わないとデプロイ回数の制限など制約がついてしまうみたいです! (残念。。)

まとめ

今回はじめて拡張機能のFWを触ってみて、拡張機能自体への理解も深まり、すごく勉強になりました!
手軽に拡張機能開発できる環境も整ってきていて、拡張機能自体ができることのポテンシャルも高いと感じましたので、今後もキャッチアップしつつ、アイディアも出していけたらもっと楽しめそうです。

さいごに

現在弊社ではエンジニアを募集しています!
この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!

iimon採用サイト / Wantedly / Green

【参考サイト】 www.plasmo.com docs.plasmo.com tech.kikagaku.co.jp