🦁

React + ViteでGoogle Chromeの拡張機能を作成する

2024/05/28に公開

はじめに

今回作成するものをベースにいくつかの機能を追加した拡張機能を公開・開発中です。
ご興味がありましたら、ぜひお試しください。

bookmari filer - Chrome Web Store

作成するもの

chrome上でaを2回押した時、ブックマークが階層状に表示されるポップアップを作成していきます。

プロジェクトのセットアップ

 % pnpm create vite@latest
✔ Project name: … bookmark-extension
✔ Select a framework: › React
✔ Select a variant: › JavaScript

プロジェクト直下に移動して以下のコマンドを実行します。

 % pnpm add -D @crxjs/vite-plugin@beta

vite.config.jsを編集します。

vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+ import { crx } from '@crxjs/vite-plugin'
+ import manifest from './manifest.json'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
+    crx({ manifest }),
  ],
})

manifest.jsonを作成します。

{
  "manifest_version": 3,
  "name": "bookmark extension",
  "version": "1.0.0",
  "action": { "default_popup": "index.html" }
}
% pnpm run dev

拡張機能>デベロッパーモードをON>パッケージ化されていない拡張機能を読み込むを押下
プロジェクト直下のdistを選択します。

読み込まれた拡張機能をクリックするとポップアップが出現します。

ここまでのコード

おまけ

プロジェクト関連の設定を整えます。

formatter・linterにeslintではなくbiomeを利用してみます。

pnpm rm -D eslint \
eslint-plugin-react \
eslint-plugin-react-hooks \
eslint-plugin-react-refresh \
&& pnpm add --save-dev --save-exact @biomejs/biome

biomeの設定ファイルを作成します。

pnpm biome init

biome.jsonがプロジェクト直下に生成されます。

biome.json
{
	"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
	"organizeImports": {
		"enabled": true
	},
	"linter": {
		"enabled": true,
		"rules": {
			"recommended": true
		}
	}
}

こちらをお好みの設定に修正します。
今回は以下のようにしました。

biome.json
{
  "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true
    }
  },
  "formatter": {
    "indentStyle": "space",
    "indentWidth": 2,
    "lineEnding": "lf"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "jsxQuoteStyle": "single",
      "trailingComma": "es5",
      "semicolons": "asNeeded"
    }
  }
}

package.jsonのscriptsも修正します

package.json
-   "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+   "lint": "biome check --apply src",

これでpnpm run lintでformat, lintの両方が行われるようになりました。

エディタの支援が強力になるように@types/chromeを導入しておきます。

pnpm add -D @types/chrome

また、srcディレクトリをインポート時に@で呼び出せるよう設定を追加します。

vite.config.js
export default defineConfig({
  plugins: [...],
+ resolve: {
+   alias: [
+     {
+       find: '@',
+       replacement: `${__dirname}/src`,
+     },
+   ],
+ },
})

エディタの支援が強力になるようにプロジェクト直下にjsconfig.jsonを作成します。

jsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "allowSyntheticDefaultImports": false,
    "baseUrl": "./",
    "paths": {
      "@/*": [
        "src/*"
      ]
    }
  },
  "exclude": [
    "node_modules"
  ]
}

一旦プロジェクト周辺の設定は以上です。

ここまでのコード

コンテンツスクリプトを追加

ここから拡張機能の肝となるコンテンツスクリプトを追加していきます。

基本的な手順としてはCRXJSのドキュメントに従います。

まずmanifest.jsonにコンテンツスクリプトのファイルを定義します。

manifest.json
{
  ...
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "src/content/index.jsx"
      ]
    }
  ],
  ...
}

manifest.jsonに定義したパスにファイルを作成します。

src/content/index.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import BookmarkWindow from './components/bookmark-window'
import './index.css'

const root = document.createElement('div')
root.id = 'bookmark-extension-root'
document.body.appendChild(root)

ReactDOM.createRoot(root).render(
  <React.StrictMode>
    <BookmarkWindow />
  </React.StrictMode>
)

src/content/index.jsxから呼び出すbookmark-window
content scriptで表示するページのcssファイルも作成します。

src/content/components/bookmark-window.jsx
export default function BookmarkWindow() {
  return (
    <div className="window">
      <h1>Bookmark Window</h1>
    </div>
  )
}
src/content/index.css
#bookmark-extension-root {
  position: absolute;
  top: 0;
  z-index: 999999999;

  .window {
    height: 20vh;
    background: white;
    color: black;
  }
}

ここで画面を確認すると以下のように表示されます。

ここまでのコード

特定のキー入力でコンテントスクリプトの表示・非表示を切り替え

現時点ではコンテンツスクリプトで表示している要素がでっぱなしなので、キー入力で表示・非表示を切り替えられるようにします。
具体的に以下のような挙動になるようにしていきたいと思います。

  • aを2回連続して入力でコンテンツスクリプト要素を表示
  • aが1回入力された後、1000msaの入力が無かった場合、1回目のa入力は無かったことにする。
  • escを入力した時要素を非表示
src/content/components/bookmark-window.jsx
+ import { useEffect, useRef, useState } from 'react'

export default function BookmarkWindow() {
+ const [display, setDisplay] = useState(false)
+ const keyCount = useRef(0)

+ useEffect(() => {
+   let timerId
+   const handler = (e) => {
+     if (e.key === 'a') {
+       keyCount.current += 1
+       timerId = setTimeout(() => {
+         keyCount.current = 0
+       }, 1000)
+     }
+     if (e.key === 'Escape') {
+       setDisplay(false)
+       keyCount.current = 0
+     }
+     if (keyCount.current === 2) {
+       setDisplay(true)
+     }
+   }

+   window.addEventListener('keydown', handler)
+   return () => {
+     window.removeEventListener('keydown', handler)
+     if (timerId) {
+       clearTimeout(timerId)
+     }
+   }
+ }, [])

+ if (!display) {
+   return null
+ }

  return (
    <div className='window'>
      <h1>Bookmark Window</h1>
    </div>
  )
}

これでコンテントスクリプト要素の表示非表示を切り替えられるようになりました。

ここまでのコード

ブックマークを取得

コンテントスクリプトで表示するブックマークを取得します。
ブックマークはbackgroundで取得しそれをコンテンツスクリプトに渡す必要があります。

また、ブックマーク読み取りのためにmanifest.jsonに権限を追加する必要があります。

manifest.json
{
  ...
+ "permissions": ["bookmarks"],
  ...
+ "background": {
+   "service_worker": "src/background/index.js",
+   "type": "module"
+ }
}

src/background/index.jsを作成し、以下の処理を記述します。

  • ブックマークを取得
  • コンテンツスクリプトからメッセージを受け取り、取得したブックマークを返すリスナー定義
  • ブックマーク取得する関数を呼び出すリスナー定義
src/background/index.js
let tree = []
function getBookmarks() {
  tree = []
  chrome.bookmarks.getTree((treeNode) => {
    for (const node of treeNode) {
      for (const child of node.children) {
        tree.push(child)
      }
    }
  })
}

chrome.runtime.onMessage.addListener((req, _, res) => {
  if (req.type === 'bookmarks') {
    res({ tree })
  }
})

chrome.runtime.onInstalled.addListener(getBookmarks)
chrome.bookmarks.onCreated.addListener(getBookmarks)
chrome.bookmarks.onRemoved.addListener(getBookmarks)
chrome.bookmarks.onChanged.addListener(getBookmarks)
chrome.bookmarks.onMoved.addListener(getBookmarks)

コンテンツスクリプトからbackgroundで定義したリスナーにメッセージを送り、ブックマークを渡してもらいます。

src/content/components/bookmark-window.jsx
+ const [bookmarks, setBookmarks] = useState([])
+ useEffect(() => {
+   const getBookmarks = async () => {
+     const res = await chrome.runtime.sendMessage({ type: 'bookmarks' })
+     setBookmarks(res.tree)
+   }
+   getBookmarks()
+ }, [])

+ console.log('bookmarks: ', bookmarks)
  // (2) [{…}, {…}]

console.logでブックマークを含む配列を表示できたら完了です。

ここまでのコード

ブックマークをツリー状に表示

content scriptで取得したブックマークをツリー状に表示します。
再帰的にコンポーネントを呼び出すことでこれを実現します。

取得したブックマークは以下のような形になっています。

bookmarks
[
    {
        "children": [
            {
                "children": [
                    {
                        "children": [
                            {
                                "dateAdded": 1716681883242,
                                "id": "8",
                                "index": 0,
                                "parentId": "7",
                                "title": "Qiita",
                                "url": "https://qiita.com/"
                            }
                        ],
                        "dateAdded": 1716681873438,
                        "dateGroupModified": 1716681888310,
                        "id": "7",
                        "index": 0,
                        "parentId": "6",
                        "title": "フォルダ"
                    }
                ],
                "dateAdded": 1716681861490,
                "dateGroupModified": 1716682583503,
                "id": "6",
                "index": 0,
                "parentId": "1",
                "title": "フォルダ"
            },
            {
                "children": [
                    {
                        "dateAdded": 1716681696830,
                        "dateLastUsed": 1716683776048,
                        "id": "5",
                        "index": 0,
                        "parentId": "9",
                        "title": "Zenn|エンジニアのための情報共有コミュニティ",
                        "url": "https://zenn.dev/"
                    }
                ],
                "dateAdded": 1716682761236,
                "dateGroupModified": 1716683777095,
                "id": "9",
                "index": 1,
                "parentId": "1",
                "title": "ddd"
            }
        ],
        "dateAdded": 1716681619944,
        "dateGroupModified": 1716683466876,
        "id": "1",
        "index": 0,
        "parentId": "0",
        "title": "ブックマーク バー"
    },
    {
        "children": [],
        "dateAdded": 1716681619944,
        "dateGroupModified": 1716681696830,
        "id": "2",
        "index": 1,
        "parentId": "0",
        "title": "その他のブックマーク"
    }
]

フォルダーはchildrenを持っており、ブックマークはchidrenを持たずにurlを持っています。
したがって以下のようなコンポーネントを作成していきます。

  • childrenを持っていなかった時はリンクを表示するコンポーネントを返す。
  • childrenを持っている時は再帰的にコンポーネントを呼び出す。また、子コンポーネントの表示・非表示をステートで管理する。
src/content/components/tree.jsx
import { useState } from 'react'

const LinkRow = ({ item }) => {
  return (
    <div className='link-row'>
      <a href={item.url}>
        <span>{item.title}</span>
      </a>
    </div>
  )
}

const FolderRow = ({ item }) => {
  const [open, setOpen] = useState(false)
  const toggle = () => {
    setOpen((old) => !old)
  }

  return (
    <>
      <div className='folder-row' onClick={toggle} onKeyUp={toggle}>
        <span>{item.title}</span>
      </div>
      {open &&
        item.children.map((child) => <Tree key={child.id} item={child} />)}
    </>
  )
}

export default function Tree({ item }) {
  if (!item?.children) {
    return <LinkRow item={item} />
  }
  return <FolderRow item={item} />

}
src/content/components/bookmark-window.jsx
    <div className='window'>
+     <div className='container'>
+       {bookmarks.map((item) => (
+         <Tree key={item.id} item={item} />
+       ))}
+     </div>
    </div>

併せて見た目も整えていきます。

src/content/index.css
+ .window {
+   display: flex;
+   position: fixed;
+   top: 0;
+   left: 0;
+   margin-top: 10px;
+   margin-bottom: 10px;
+   margin-left: 5px;
+   background: #dfe2ec;
+   width: 35%;
+   height: 90%;
+   color: #000;
+   padding: 20px;
+   border-radius: 30px;
+ }

+ .container {
+   width: 100%;
+   background: #fff;
+   overflow: auto;
+   padding: 10px;
+   border-radius: 10px;
+ }

+ .folder-row {
+   border-bottom: 1px solid #ccc;
+   cursor: pointer;
+   display: flex;
+   align-items: center;
+   gap: 0.5rem;
+   height: 32px;
+   padding-left: 0.5rem;
+   margin-right: 0.5rem;
+   font-size: 14px;
+ }

+ .link-row {
+   display: flex;
+   align-items: center;
+   gap: 0.5rem;
+   font-size: 12px;
+   border-bottom: 1px solid #ccc;
+   color: inherit;
+   text-decoration: none;
+   height: 32px;
+   padding-left: 0.5rem;
+   margin-right: 0.5rem;
+ }

以下のような見た目になります。

ここまでのコード

アイコン・ファビコンの表示

以下を表示していきます。

  • フォルダの左にSVGのアイコン
  • ブックマークリンクの左にファビコン
src/content/components/folder-icon.jsx
export default function FolderIcon() {
  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      width='16'
      height='16'
      fill='#000000'
      viewBox='0 0 256 256'
    >
      <title>folder</title>
      <path d='M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z' />
    </svg>
  )
}
src/content/components/folder-open-icon.jsx
export default function FolderOpenIcon() {
  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      width='16'
      height='16'
      fill='#000000'
      viewBox='0 0 256 256'
    >
      <title>folder-open</title>
      <path d='M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208h0a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Zm112,136H43.1l26.67-80H232Z' />
    </svg>
  )
}
src/content/components/tree.jsx
const FolderRow = ({ item }) => {
  ...
  return (
    <>
      <div className='folder-row' onClick={toggle} onKeyUp={toggle}>
+       {open ? <FolderOpenIcon /> : <FolderIcon />}
        <span>{item.title}</span>
      </div>
      ...
    </>
  )
}

これでフォルダの左側にアイコンを表示できました。

次にリンクの横にファビコンを表示します。
ファビコンを取得するにはmanifest.jsonfaviconの権限を明記する必要があります。

manifest.json
  "permissions": [
    "bookmarks",
+   "favicon"
  ],

その上で以下の関数を作成し、<img />にて呼び出します。

export function getFavicon(u) {
  const url = new URL(chrome.runtime.getURL('/_favicon/'))
  url.searchParams.set('pageUrl', u)
  url.searchParams.set('size', '64')
  return url.toString()
}
    <a href={item.url} className='link-row'>
+     <img src={getFavicon(item.url)} alt='' style={{ width: 16 }} />
      <span>{item.title}</span>
    </a>

これでファビコンを表示できました。

ここまでのコード

見た目を整える

最後に見た目を整えていきます。

  • hover時に列の色を変える
  • 階層が深くなった時、左の余白を大きくする
  .folder-row:hover, .link-row:hover {
    background: #f0f0f0;
  }
src/content/components/tree.jsx
export default function Tree({ item, depth = 0 }) {
  const childDepth = depth + 10
  if (!item?.children) {
    return <LinkRow item={item} depth={childDepth} />
  }
  return <FolderRow item={item} depth={childDepth} />
}
const FolderRow = ({ item, depth }) => {
  const [open, setOpen] = useState(false)
  const toggle = () => {
    setOpen((old) => !old)
  }

  return (
    <>
      <div
        className='folder-row'
        onClick={toggle}
        onKeyUp={toggle}
        style={{
          paddingLeft: depth,
        }}
      >
        {open ? <FolderOpenIcon /> : <FolderIcon />}
        <span>{item.title}</span>
      </div>
      {open &&
        item.children.map((child) => (
          <Tree key={child.id} item={child} depth={depth} />
        ))}
    </>
  )
}
const LinkRow = ({ item, depth }) => {
  return (
    <a
      href={item.url}
      className='link-row'
      style={{
        paddingLeft: depth,
      }}
    >
      <img src={getFavicon(item.url)} alt='' style={{ width: 16 }} />
      <span>{item.title}</span>
    </a>
  )
}

こちらで完成です!読んでいただきありがとうございました。

最終的なコード

Discussion