Next.jsから始めるChrome拡張

## 概要

この記事ではNext.jsをベースに、Chrome拡張を作成した際の方法を紹介します。

## 背景

私は普段日本語の文章を書く機会が多く、よく言葉選びに迷ってしまいます。その際、できるだけ正しい言葉の意味を知るために、辞書を引く癖を徹底づけるようにしています。

(技術者が一次情報である公式ドキュメントやGithubのコードを確認するべきというのと同じですね)

ただ、毎回辞書サイトを開くのも少し面倒なので、少しでも楽をするために拡張機能を作りました。せっかくなので、書き慣れているNext.js + TypeScriptの組み合わせを使っています。

### 補足

のんびり制作を進めていたら、ちょうど先日、同じReactベースのchrome拡張に関する分かりやすい記事を2つも発見しました。

ある程度内容が被っているので、最初に記載しておきます。

[https://zenn.dev/tokku5552/articles/how-to-make-chrome-extension](https://zenn.dev/tokku5552/articles/how-to-make-chrome-extension)

[https://zenn.dev/mayo_dev/articles/chrome-extension-with-nextjs](https://zenn.dev/mayo_dev/articles/chrome-extension-with-nextjs)

## つくったもの

https://github.com/faronan/chrome-extension

https://chrome.google.com/webstore/detail/national-dictionary/apgpapibkjnjaooiinhfhibmkhidkcae

## 使い方

イメージとしては、Google翻訳の拡張機能です。

意味を調べたい単語をスクロールで選択し、右上のアイコンをクリックします。すると、入力フォームに選択した単語が入っているので、虫眼鏡ボタンを押してください。単語の意味や解説が、ポップアップの下に表示されます。

(12/2追記: デモ画像を載せ忘れておりました)
![143685943-c1576e29-bfb3-4db8-8fdd-9c438a2ab195.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478011/4b1a9838-c15a-57a7-e987-960d8e08d15c.gif)

## 制作メモ

### ディレクトリ構造

```markdown
├── 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のローカルストレージに都度保存しているだけです。

```tsx
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に変換しています。

```jsx
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**が存在します。

詳しくは[公式サイト](https://developer.chrome.com/docs/extensions/mv3/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](https://developer.chrome.com/docs/extensions/reference/action/#popup))

ただ、今改めてドキュメントを読み直していると、ストレージの`onChanged` イベントを設定できるようなので、もっと良いやり方がある気がします。

また、私だけかもしれませんが、通常`_app.tsx`に記載する`ReactDOM.render`の部分をこのファイルに記載する必要があると気が付かず、しばらくハマってしまっていました。

```tsx
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分ほどで説明不足とリジェクトされてしまいました。

皆さんも説明はしっかり書きましょう(今回の教訓

ここまで読んでくださりありがとうございました。

## 参考記事

- [https://qiita.com/ErgoFriend/items/365bc9b0bec2c23d595e](https://qiita.com/ErgoFriend/items/365bc9b0bec2c23d595e)
- [https://qiita.com/sakaimo/items/5e41d6b2ad8d7ee04b12](https://qiita.com/sakaimo/items/5e41d6b2ad8d7ee04b12)
- [https://qiita.com/yunity29/items/120187ca48dbf03c75da](https://qiita.com/yunity29/items/120187ca48dbf03c75da)
- [https://zenn.dev/minojiro/articles/772ba9be722255](https://zenn.dev/minojiro/articles/772ba9be722255)
- [https://zenn.dev/cti1650/articles/81016618ec5a87](https://zenn.dev/cti1650/articles/81016618ec5a87)