キカガク プラットフォームブログ

株式会社キカガクのプラットフォームブログです。エンジニアやデザイナー、プロダクトマネージャーなどが記事を書いています。

「Plasmo Framework」で俺得 Chrome 拡張機能をサクッと作ってみる

はじめに

はじめまして。キカガクプラットフォーム部の ずんだ です。

最近個人開発で Chrome 拡張機能を作っていて、Plasmo Framework というライブラリを使ってみたところ超絶便利だったので、布教したいです。

想定読者

  • React を使ったことがある人

Plasmo とは

公式ドキュメント
GitHub

Plasmo Framework とは、Chrome 拡張機能を作るためのフレームワークです。

Chrome 拡張を一から作ろうとすると、決まったファイルをあれこれ手動で作成したり、別途ライブラリを入れたり、変更を反映させるためにいちいち読み込みし直したりで結構メンドウだったりするのですが、
このフレームワークを使うことで React + TypeScript + ホットリロード対応の Chrome 拡張開発環境をコマンド一発で作ることができます!

本当はテストとか自動デプロイとかもサポートされているようなのですが、今回はサクッと自分用の拡張機能を作るところまでやってみます。

やってみる

環境構築

※ pnpm 推奨らしいです
プロジェクトを作成します。

# プロジェクト作成
pnpm create plasmo

なにやらいろいろ聞かれます。特にこだわりがなければ Enter 連打で OK です。

しばらく待つと、先ほど聞かれたExtension name と同名のディレクトリができています。

ひとまず開発サーバーを立ち上げてみます。

# できたディレクトリに移動
cd amuse-slim-blobfish/

# 開発サーバーを立ち上げ
pnpm run dev

次に、Chrome に読み込ませてみます。

Chrome を開き、アドレスバーに chrome://extensions/ と入力し Enter。
画面右上の「デベロッパーモード」を ON にします。
「パッケージ化されていない拡張機能を読み込む」から、build 内にできているディレクトリ(chrome-xxx-dev)を選択します。

無事読み込まれると、拡張機能として認識してくれます。

ブラウザ右上の拡張機能ボタン(ジグソーパズルのアイコン)から、今追加した拡張機能をピンどめして、アイコンをクリックしてみましょう。

…なんか細くない?
まぁともかく、これで開発準備は整いました!

Hello World

このアイコンをクリックしたときに出てくる画面は「ポップアップ」といい、popup.tsx で制御しています。
試しに以下のように書き換えてみましょう!

import { useState } from "react"

function IndexPopup() {
  const [data, setData] = useState(0)

  return (
    <div
      style={{
        width: "200px", // 細くならないように幅を指定してあげる
      }}>
      <h1>Hello! {data}</h1>
      <button onClick={() => setData((prev) => prev + 1)}>+</button>
      <button onClick={() => setData((prev) => prev - 1)}>-</button>
    </div>
  )
}

export default IndexPopup

ホットリロードが効いているので、コードを書き換えると勝手に反映してくれてますね。便利〜
(たまーに反映されないことがあるみたいです。1回閉じて開くと反映されてたりします)

React の機能も問題なく動作しているので、「+」ボタンを押すことで表示されている数が増え、「-」ボタンを押すと減っていきます。

なにか作ってみる

せっかくなので、簡単な拡張機能をサクッと作ってみます!

最近開発環境と本番環境を行き来することが多くて、開発環境を見ているつもりで本番環境を見てた…ということが起きがちなので、
本番環境の URL のときだけ警告を出すような拡張機能を作ってみようと思います!

まずポップアップを作ってみます。
本番環境の URL の一部を入力、保存できるようにします。

ポップアップが保持している変数の値は閉じると消えてしまいます。
データを保存するには、@plasmohq/storageを使います。

# @plasmohq/storage を追加でインストール
pnpm install @plasmohq/storage

こんな感じで、データの保存と取得ができます。

import { Storage } from "@plasmohq/storage"

const storage = new Storage()

// 保存
await storage.set("test", "てすと")

// 取得
storage.get("test").then((res) => console.log(res)) // てすと

それではポップアップを作っていきます。

function IndexPopup() {
  const [terms, setTerms] = useState<string[]>([])
  const [inputValue, setInputValue] = useState<string>("")
  const storage = new Storage()

  // コンポーネントマウント時にリストを取得
  useEffect(() => {
    storage.get<string[]>("terms").then((res) => setTerms(res ?? []))
  }, [])

  // リストが変更されたら保存
  useEffect(() => {
    storage.set("terms", terms)
  }, [terms])

  // 項目を削除
  const deleteUrl = (index: number) => {
    setTerms(terms.filter((_, i) => i !== index))
  }

  // 配列を重複なしで返す
  const uniq = (array: string[]) => [...new Set(array)]

  return (
    <div
      style={{
        width: "300px"
      }}>
      <p>🚨本番環境URL一覧(部分一致)🚨</p>
      <div
        style={{
          height: "100px",
          border: "1px solid #ccc",
          overflowY: "auto",
          overflowX: "hidden"
        }}>
        <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
          {terms.map((term, i) => (
            <li key={term} style={{ padding: "8px 4px" }}>
              {term}
              <button
                type="button"
                style={{ float: "right" }}
                onClick={() => deleteUrl(i)}>
                x
              </button>
            </li>
          ))}
        </ul>
      </div>

      <div style={{marginTop: "8px"}}>
        <label htmlFor="term">対象文字列: </label>
        <input
          id="term"
          type="text"
          autoFocus
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyUp={(e) => {
            if (e.key === "Enter") {
              setTerms(uniq([...terms, inputValue]))
              setInputValue("")
            }
          }}
        />
      </div>
    </div>
  )
}

export default IndexPopup

開いてみましょう。

フォームに文字を入力し、Enter で保存してみます。
すると保存した文字がポップアップを閉じても保持されていますね!

次にコンテンツスクリプトを作っていきます。
コンテンツスクリプトを使うことで、今開いているページに対してスクリプトの実行や DOM の操作などができます。

Plasmo Framework には ContentScript UI という機能があり、React コンポーネントをそのままコンテンツスクリプトとしてマウントすることができます。
CSS スタイルの干渉も気にする必要がなくなるので、こちらを使うのがオススメです。

使い方は簡単で、ルートにcontent.tsxを作成して export default するだけです。
試しに簡単な ContentScript UI を作ってみます。

const TestButton = () => {
    return <button>テスト</button>
}
   
export default TestButton

適当な Web ページを開いてみると、左上にボタンが出ているのが分かるかと思います。

たったこれだけで Web ページに自作のコンポーネントを出すことができます!

それでは本番環境に警告を出す拡張の ContentScript UI を作っていきます。

const WarningProd = () => {
  const [terms, setTerms] = useState<string[]>([])
  const [isTarget, setIsTarget] = useState<boolean>(false)

  const storage = new Storage()

  // URLが条件に一致しているかチェック
  useEffect(() => {
    const checkUrl = () => {
      for (const term of terms) {
        if (location.href.includes(term)) {
          setIsTarget(true)
          return
        }
      }
    }
    checkUrl()
  }, [terms])

  // コンポーネントマウント時にリストを取得
  useEffect(() => {
    storage.get<string[]>("terms").then((res) => setTerms(res ?? []))
  }, [])

  return isTarget ? (
    <div
      style={{
        position: "absolute",
        top: "0",
        left: "0",
        width: `${window.innerWidth}px`,
        height: "20px",
        backgroundColor: "#F44366",
        zIndex: 9999,
        color: "#fff",
        textAlign: "center",
        fontSize: "13px",
        fontWeight: "bold"
      }}>
      これは本番環境です!ご安全に!
    </div>
  ) : (
    <> </>
  )
}

export default WarningProd

これで、開いているページが保存した文字列と部分一致したら警告がでるはずです!
試しに弊社サービスであるkikagaku.aiを登録してみます。

ページを表示してみると…

警告が表示されました!

最後にビルドしておきます。

# ビルド
pnpm run build

完了すると /build ディレクトリに chrome-xxx-prod というディレクトリができているので、同様の手順で Chrome に読み込ませれば OK です!

おわりに

Plasmo Framework を使って、簡単な Chrome 拡張機能をサクッと作ってみました。

Chrome 拡張は Web アプリなどを作るよりも全然手軽に作れちゃうので、あまり時間が取れない個人開発に向いているなぁと思いました。
成果が画面上にすぐ反映できるので、モチベーションも続きやすいですしね。
これまであまり個人開発してこなかった方も、ぜひ試してみてください〜

以上、最後まで読んでいただきありがとうございました!