React + ViteでGoogle Chromeの拡張機能を作成する
はじめに
今回作成するものをベースにいくつかの機能を追加した拡張機能を公開・開発中です。
ご興味がありましたら、ぜひお試しください。
作成するもの
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を編集します。
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がプロジェクト直下に生成されます。
{
"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
こちらをお好みの設定に修正します。
今回は以下のようにしました。
{
"$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も修正します
- "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ディレクトリをインポート時に@
で呼び出せるよう設定を追加します。
export default defineConfig({
plugins: [...],
+ resolve: {
+ alias: [
+ {
+ find: '@',
+ replacement: `${__dirname}/src`,
+ },
+ ],
+ },
})
エディタの支援が強力になるようにプロジェクト直下にjsconfig.json
を作成します。
{
"compilerOptions": {
"target": "es2017",
"allowSyntheticDefaultImports": false,
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
},
"exclude": [
"node_modules"
]
}
一旦プロジェクト周辺の設定は以上です。
コンテンツスクリプトを追加
ここから拡張機能の肝となるコンテンツスクリプトを追加していきます。
基本的な手順としてはCRXJSのドキュメントに従います。
まずmanifest.json
にコンテンツスクリプトのファイルを定義します。
{
...
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"src/content/index.jsx"
]
}
],
...
}
manifest.json
に定義したパスにファイルを作成します。
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ファイルも作成します。
export default function BookmarkWindow() {
return (
<div className="window">
<h1>Bookmark Window</h1>
</div>
)
}
#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
を入力した時要素を非表示
+ 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
に権限を追加する必要があります。
{
...
+ "permissions": ["bookmarks"],
...
+ "background": {
+ "service_worker": "src/background/index.js",
+ "type": "module"
+ }
}
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で定義したリスナーにメッセージを送り、ブックマークを渡してもらいます。
+ 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を持っている時は再帰的にコンポーネントを呼び出す。また、子コンポーネントの表示・非表示をステートで管理する。
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} />
}
<div className='window'>
+ <div className='container'>
+ {bookmarks.map((item) => (
+ <Tree key={item.id} item={item} />
+ ))}
+ </div>
</div>
併せて見た目も整えていきます。
+ .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のアイコン
- ブックマークリンクの左にファビコン
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>
)
}
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>
)
}
const FolderRow = ({ item }) => {
...
return (
<>
<div className='folder-row' onClick={toggle} onKeyUp={toggle}>
+ {open ? <FolderOpenIcon /> : <FolderIcon />}
<span>{item.title}</span>
</div>
...
</>
)
}
これでフォルダの左側にアイコンを表示できました。
次にリンクの横にファビコンを表示します。
ファビコンを取得するにはmanifest.json
にfavicon
の権限を明記する必要があります。
"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;
}
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