概要
この記事ではNext.jsをベースに、Chrome拡張を作成した際の方法を紹介します。
背景
私は普段日本語の文章を書く機会が多く、よく言葉選びに迷ってしまいます。その際、できるだけ正しい言葉の意味を知るために、辞書を引く癖を徹底づけるようにしています。
(技術者が一次情報である公式ドキュメントやGithubのコードを確認するべきというのと同じですね)
ただ、毎回辞書サイトを開くのも少し面倒なので、少しでも楽をするために拡張機能を作りました。せっかくなので、書き慣れているNext.js + TypeScriptの組み合わせを使っています。
補足
のんびり制作を進めていたら、ちょうど先日、同じReactベースのchrome拡張に関する分かりやすい記事を2つも発見しました。
ある程度内容が被っているので、最初に記載しておきます。
https://zenn.dev/tokku5552/articles/how-to-make-chrome-extension
https://zenn.dev/mayo_dev/articles/chrome-extension-with-nextjs
つくったもの
使い方
イメージとしては、Google翻訳の拡張機能です。
意味を調べたい単語をスクロールで選択し、右上のアイコンをクリックします。すると、入力フォームに選択した単語が入っているので、虫眼鏡ボタンを押してください。単語の意味や解説が、ポップアップの下に表示されます。
制作メモ
ディレクトリ構造
├── public(画像ファイルやデフォルトのHTML)
│ ├── images
│ │ ├── ...
│ ├── manifest.json
│ └── popup.html
├── src(Next.jsで生成されたファイル群は基本ここ)
│ ├── background.ts
│ ├── content.ts
│ ├── hooks
│ │ └── ...
│ ├── options.ts
│ └── pages
│ └── popup.tsx
├── extensions(ビルドの出力先、このディレクトリをChromeに読み込ませる)
├── ...
content.ts
chrome拡張では、現在表示しているページのDOM操作ができます。
今回は、selectionchange
を使用し、テキストの選択が変更された際にイベントを発火させています。
中身は非常にシンプルで、選択したテキストをchromeのローカルストレージに都度保存しているだけです。
document.addEventListener('selectionchange', () => {
const selection = document.getSelection();
if (!selection) return;
const selectionString = selection.toString();
chrome.storage.local.set({ selectionString });
});
webpack.config.js
Chrome拡張はJavaScriptで動くため、TSXやTypeScriptのコードをトランスパイルする必要があります。
私の場合、webpackを用いて、上述の content.ts
をはじめとしたTypeScriptのコードをJavascriptに変換しています。
const webpack = require('webpack');
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const srcDir = path.join(__dirname, 'src');
module.exports = {
entry: {
popup: path.join(srcDir, 'pages/popup.tsx'),
options: path.join(srcDir, 'options.ts'),
background: path.join(srcDir, 'background.ts'),
content: path.join(srcDir, 'content.ts'),
},
output: {
path: path.join(__dirname, './extensions'),
filename: '[name].js',
},
optimization: {
splitChunks: {
name: 'vendor',
chunks(chunk) {
return chunk.name !== 'background';
},
},
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
plugins: [
new CopyPlugin({
patterns: [{ from: '.', to: '.', context: 'public' }],
options: {},
}),
],
mode: 'production',
};
manifest.json
全てのChrome拡張には必ずmanifest.jsonが存在します。
詳しくは公式サイトに記載されていますが、代表的なもので言うと、拡張機能の名前や使用する権限、使用するスクリプトを設定できます。
ここでは、拡張機能のアイコンをクリックした時に開くポップアップの設定や、ローカルストレージを使用するための設定を記載しています。
{
"name": "National dictionary",
"version": "1.0.0",
"manifest_version": 3,
"description": "Look up the National dictionary",
"background": {
"service_worker": "background.js"
},
"permissions": ["storage"], //必要な権限はここで設定します。
"icons": {
"16": "images/icon16.png",
"48": "images/icon48.png",
"128": "images/icon128.png"
},
"action": {
"default_icon": "images/icon128.png",
"default_popup": "popup.html"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"]
}
]
}
popup.tsx
ロジックはほとんどこのファイルにまとめています。
特徴的な部分としては、useEffectでローカルストレージの変更を監視している部分かと思います。
拡張機能のアイコンをクリックした時の挙動をスクリプトで設定できればいいのですが、その挙動を行うaction.onClicked
はdefault_popup
を設定していると動かないため、この手段を用いることにしました。
(参考: https://developer.chrome.com/docs/extensions/reference/action/#popup)
ただ、今改めてドキュメントを読み直していると、ストレージのonChanged
イベントを設定できるようなので、もっと良いやり方がある気がします。
また、私だけかもしれませんが、通常_app.tsx
に記載するReactDOM.render
の部分をこのファイルに記載する必要があると気が付かず、しばらくハマってしまっていました。
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import type { NextPage } from 'next';
import { ChakraProvider } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { useBudouX } from '../hooks/useBudouX';
import { Box, FormControl, Input, IconButton, Text } from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
const Popup: NextPage = () => {
const [storageValue, setStorageValue] = useState('');
const [result, setResult] = useState('');
type FormData = {
word: string;
};
const { register, setValue, handleSubmit } = useForm<FormData>();
const onSubmit = handleSubmit(async (data) => {
const result = await searchDictionary(data['word']);
setResult(result);
});
const { parse } = useBudouX();
const searchDictionary = async (word: string) => {
const url = `https://www.weblio.jp/content/${word}`;
const kijiElements = await fetch(url)
.then((response) => response.text())
.then((text) => new DOMParser().parseFromString(text, 'text/html'))
.then((document) => document.getElementsByClassName('kiji') as HTMLCollectionOf<HTMLElement>);
if (kijiElements.length != 0) {
const children = kijiElements[0].children as HTMLCollectionOf<HTMLElement>;
return children[1].innerText;
} else {
return `用語解説で「${word}」に一致する見出し語は見つかりませんでした。`;
}
};
//ローカルストレージは常に監視して、変更があった時だけuseEffectを発火させる
chrome.storage.local.get('selectionString', ({ selectionString }) => {
setStorageValue(selectionString);
});
useEffect(() => {
setValue('word', storageValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageValue]);
return (
<Box w={400} p={4}>
<form onSubmit={onSubmit}>
<FormControl>
<Box display="flex" alignItems="baseline">
<Input size="xs" w={150} {...register('word')} />
<IconButton
size="xs"
ml="2"
colorScheme="blue"
aria-label="Search database"
icon={<SearchIcon />}
type="submit"
>
</IconButton>
</Box>
</FormControl>
</form>
<Box mt="2">
<Text color="gray.500">{parse(result)}</Text>
</Box>
</Box>
);
};
ReactDOM.render(
<React.StrictMode>
<ChakraProvider>
<Popup />
</ChakraProvider>
</React.StrictMode>,
document.getElementById('root')
);
最後に
上記二つの記事をはじめ、様々なChrome拡張の解説記事のおかげで、ほとんどつまづくことなく開発を進められました。(強いていうのであれば、最初はTailwindを使おうと思ったのですが、webpackの設定で苦戦して諦めてしまいました...)
ちなみに、このChrome拡張は半日ほどで審査が通ったのですが、調子に乗って説明を端折った二つ目の拡張機能を審査に出したところ、10分ほどで説明不足とリジェクトされてしまいました。
皆さんも説明はしっかり書きましょう(今回の教訓
ここまで読んでくださりありがとうございました。