Vue.jsを使ってChromeの拡張機能作るぞ！

# 目標
Vue.jsを使って、こんなGoogle Chromeの拡張を作ろうと思います。

- 右クリックで「そのうち読む」というメニューを表示する
- 「そのうち読む」をクリックしたらブックマークに追加する
- ブックマークは30日以内に読まないと消えてしまう（「あとで読む」を溜め込んでしまうあなたに）
- ブックマークは古い順に表示する
- ついでにシャッフル機能をつける

![右クリックメニュー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266558/3a08dc5b-4dae-8dc1-b1df-c355a647f20f.png)
![完成品.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266558/c1ec7a1d-2363-564f-0272-6362fce30e2d.png)
SHUFFLE後の画像です

# 手順
## Dockerの準備
WSLのDocker内に環境を作り、そこで引きこもって開発したいです。環境を用意しましょう。
ローカル環境などでnode.jsが用意されており、@vue/cli, @vue/cli-initを使える方はそちらでもOKです。


```Dockerfile:Dockerfile
FROM node:12
WORKDIR /work
RUN npm install -g @vue/cli @vue/cli-init
```

```yml:docker-compose.yml
version: '3'
services:
  cext:
    build: .
    ports:
      - 9090:9090
    volumes:
      - /c/chrome-extension:/work
      - node_modules_volume:/work/node_modules
    stdin_open: true
    tty: true
    environment:
      - CHOKIDAR_USEPOLLING=true
volumes:
  node_modules_volume:
```

ホストのマウント位置を/mnt/c/ではなく/c/に変えたうえで、ボリュームをマウントしています。
ただし`node_modules`フォルダだけは、ファイル数が莫大で重くなるそうなので、ホストではなくWSL内のボリュームとしました。
また、後述しますが9090ポートをリッスンするようにしています。また、npmがファイルの変更を察知してすぐにビルドし直してくれるように`CHOKIDAR_USEPOLLING=true`という環境変数を追加しておきます。
Dockerの説明は本筋からずれるのでこのへんにしておきます。

## vue-web-extensionsのインストール

Vue.jsを使ってChromeの拡張機能のテンプレートを作ってくれる便利なライブラリがあるのでそれを入れます。
[Kocal/vue\-web\-extension: 🛠️ A Vue CLI 3\+ preset \(previously a Vue CLI 2 boilerplate\) for quickly starting a web extension with Vue, Babel, ESLint and more\!](https://github.com/Kocal/vue-web-extension)

```
vue create --preset kocal/vue-web-extension my-extension
```
```
npm install
```

最初にウィザード的なのを聞かれますが、あまりよく分からず適当に選んでいきました。

```
npm run serve
```
でとりあえず最初のHello Worldがコンパイルできることを確認します。`dist`フォルダにコンパイルされたコードが出力されます。

Chromeを開きアドレスバーに`chrome://extensions/`と打ち込みます。デベロッパーモードにし、`パッケージ化されていない拡張機能を読み込む`のボタンを押して、さっき出力された`dist`フォルダを指定します。拡張機能がインストールされ、インストールされた右上の拡張機能ボタンをクリックすると「Hello world!」というポップアップが表示されます。

![拡張機能.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266558/81f8b007-0e4c-ca02-43bc-6b9a3818b627.png)


## manifest.jsonの記述
基本的には、`src`フォルダの中をいじっていくことになります。基本的に使用するのは、`src/components`フォルダと`src/background.js`です。

まずは`manifest.json`をいじりましょう。とりあえず今必要なところだけをいじっていきます。permissionsを変更していないと何もできません。右クリックメニューとストレージとタブの操作を使用します。その他は使わないので、この三つだけにしましょう。多めにpermissionsをつけると、ストアに公開したときに審査に時間がかかったり、落ちやすくなるそうです。

```json
"permissions": [   
  "contextMenus",
  "storage",
  "tabs"
]
```

また、以下の一行を追加しておきます。これを追加しないと`Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' chrome-extension-resource:".`と怒られてしまうようです。

```json
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
```

## 右クリックメニューを追加する

以下のコードを`background.js`に記述し、右クリックメニューを追加します。

```js:backgrround.js
browser.contextMenus.create({
  id: 'addReadback',
  title: 'そのうち読む',
  type: 'normal',
  contexts: ['all'],
});
```

## ストレージへのブックマークへの追加

では、このボタンを押されたときの処理を記述していきます。

```js:background.js
browser.contextMenus.onClicked.addListener(item => {
  if (item.menuItemId === 'addReadback') {
    const queryOptions = { active: true, currentWindow: true };
    chrome.tabs.query(queryOptions, (tabs) => {
      url = tabs[0].url;
      title = tabs[0].title;
      chrome.storage.local.get({items: []}, result => {
        const items = result.items;
        items.push({url, title, date: getNow()});
        chrome.storage.local.set({ items })
      });
    });
  }
});

const getNow = () => {
  const now = new Date();
  const year = now.getFullYear();
  const month = ('0' + (now.getMonth() + 1)).slice(-2);
  const day = ('0' + now.getDate()).slice(-2);
  const hour = ('0' + now.getHours()).slice(-2);
  const minute = ('0' + now.getMinutes()).slice(-2);
  const second = ('0' + now.getSeconds()).slice(-2);
  return `${year}/${month}/${day} ${hour}:${minute}:${second}`;
}
```

メニューがクリックされたときの処理は以下のように書くらしいです。ちょっと泥臭いですね。

```js
browser.contextMenus.onClicked.addListener(item => {
  if (item.menuItemId === '押されたボタンのID') {
    // なんたらかんたら
  }
```

わりと衝撃的だったのですが、[chrome\.tabs \- Chrome Developers](https://developer.chrome.com/docs/extensions/reference/tabs/)を見る限り、Chrome拡張機能のAPIには、`現在のタブを取得する`専用のAPIが存在せず、以下のようにqueryというメソッドを使用して、コールバックでそのタブを捕まえるみたいです。基本的に拡張機能周りのAPIは非同期処理ばかりです。適宜`await`を使っていくと読みやすくなりそうですが、まあインデントがどんどん深くなっていくことに目をつぶれば、それほど読みにくくはないのでこのまま行きます。

```js
const queryOptions = { active: true, currentWindow: true };
chrome.tabs.query(queryOptions, (tabs) => {
  // なんたらかんたら
})
```

Chromeの拡張機能のストレージには、LocalとSyncの二種類があり、その名の通りSyncはアカウント単位で同期ができ、違う端末でも同じブックマークを見ることができるはずなのですが、1アイテムあたり8KB、アイテム数512個と結構厳しめです。今回の用途で使おうと思うと、不可能ではないのですが、データの持ち方に工夫が必要になり、面倒だったので断念し、Localストレージを使いました。Localのストレージなら制約が少なく結構自由に使えます。
`items`というキーに`{ url, title, date }`のオブジェクトを配列として突っ込んでいく形にします。dateにはDateオブジェクトを突っ込みたかったのですが、なぜかうまく動かなかったので文字列を突っ込みました。Storageに入れる際、シリアライズするときにObjectだとまずいのかもしれないです。JSの日付周り、いい感じのformat関数がないんですねえ……。

ストレージからアイテムを取り出すときの書き方もこれまた独特で、以下のように第一引数に取り出したいkey(とそのkeyが存在しなかった場合のデフォルト値)を指定し、第二引数が例によってコールバックです。

```js
chrome.storage.local.get({items: []}, result => {
  const items = result.items;
```

配列を取り出し配列のケツにアイテムを追加してもう一度storageに格納します。

```js
const items = result.items;
items.push({url, title, date: getNow()});
chrome.storage.local.set({ items })
```

これで右クリックをして、「あとで読む」ブックマークにアイテムを追加することができるようになりました。

## デバッグについて
次にブックマークを見るためのpopupを作っていきたいところですが、その前にデバッグの仕方について書いておきます。拡張機能のJavaScriptは通常のデベロッパーツールを見ていても、何も出てきません。

`background.js`を検証するには、例によっては`chrome://extensions`をクリックして、拡張機能画面を開きこの拡張機能の、`ビューを検証 バックグラウンドページ`をクリックします。そうするとこのスクリプトのデベロッパーツールが開くようになります。
また、今後ポップアップを編集していきますが、それは拡張機能のポップアップボタンを右クリックし「ポップアップを検証」を押すと、ポップアップ画面のデベロッパーツールを開くことができます。

また、`npm run serve`で作成したスクリプトをデバッグ中に`WebSocket connection to 'ws://localhost:9090/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED`というエラーが発生し続けることがあります。これはdevelopモードでビルドしたスクリプトは、常に`9090`ポートを監視し、ファイルに変更があればそれを再読み込みするような処理が組み込まれているためです。なので、`npm run serve`を使用して、開発をするときは常にnpm runを立ち上げっぱなしにしておき、Dockerを使用する場合は前述のように9090ポートをフォワードするようにしておきましょう。

![バックグラウンドデバッグ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266558/00843ad8-1ed4-d000-9e87-fe608ed281d3.png)
バックグラウンドのデバッグはここ

![ポップアップデバッグ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266558/09effb19-cc37-a5ff-5fa9-fb6fe14912e6.png)
ポップアップのデバッグはここ

あと、ESLintを入れているために、ビルド時にめちゃくちゃ怒られます。丁寧にルールを設定するなり、ルールを守って書くなりすればいいんでしょうが、すいません、よく分からないので黙らせます。
ルートフォルダにある`vue.config.js`の`modele.exports = { }`の中に以下の項目を入れます。

```js
chainWebpack: config => {
  config.module.rules.delete('eslint');
}
```



## ポップアップを作成する

Vue.jsの詳細な書き方についてここでは触れませんが、一応軽く触れていきたいと思います。
ポップアップのエンドポイントは`src/popup/App.vue`になるようです。これを次のように編集します。

```vue:App.vue
<template>
  <ItemList></ItemList>
</template>

<script>
import ItemList from '@/components/ItemList.vue'

export default {
  name: 'App',
  components: { ItemList }
}
</script>

<style>
html {
  width: 400px;
  height: 500px;
  overflow-y: scroll;
}
</style>
```

スクロールができるように`overflow-y: scroll`をスタイルにつけておきましょう。

今回は二つのコンポーネントを作成します。ひとつのブックマークを表す`ReadingItem`というコンポーネント、もう一つはそれを束ねる`ItemList`というコンポーネントです。


### 親コンポーネント
`ItemList.vue`は次のようにしました。

```ItemList.vue
<template>
  <div>
    <div class="count"><span class="f-bold">{{ items.length }}</span>件のブックマーク</div>
    <div><button @click="shuffle">SHUFFLE!</button></div>
    <transition-group name="list-item" tag="div">
      <ReadingItem v-for="(item, idx) in items" 
        :key="item.id"
        :url="item.url"
        :title="item.title"
        :date="item.date"
        @remove="remove(idx)">
      </ReadingItem>
    </transition-group>
  </div>
</template>

<script>
import ReadingItem from '@/components/ReadingItem.vue'

export default {
  name: 'item-list',
  components: {ReadingItem},
  data() {
    return { items: [] }
  },
  mounted() {
    const that = this;
    chrome.storage.local.get('items', result => {
      const items = result.items;
      const validItems = items.filter(item => {
        let date = new Date(item.date);
        date.setDate(date.getDate() + 30);
        const today = new Date();
        return today < date;
      });
      chrome.storage.local.set({ items: validItems }, () => {
        const readingItems = validItems
          .sort((a, b) => b.date - a.date)
          .map((item, i) => new Object({ ...item, id: i }));
        that.items = readingItems;
      });
    });
  },
  methods: {
    remove(idx) {
      this.items.splice(idx, 1);
    },
    shuffle() {
      for(let i = this.items.length-1 ; i > 0; i --){
        let j = Math.floor(Math.random() * (i+1));
        let tmp = this.items[i];
        this.$set(this.items, i, this.items[j]);
        this.$set(this.items, j, tmp);
      }
    }
  }
};
</script>

<style scoped>
.count {
  color: #333333;
  text-align: right;
}
.f-bold{
  font-weight: bold;
}
.list-item-leave-active {
  transition: all .5s ease;
  position: absolute;
}
.list-item-leave-to {
  transform: translateX(50px);
  opacity: 0;
}
.list-item-move {
  transition: all .5s ease;
  transition-delay: .1s;
}
</style>
```

このコンポーネントでは`data`として`items`を配列として持ちます。
マウント時の処理を`mounted`関数に定義してきます。ストレージを読みに行き、未読リストを取得します。その際30日を超えていない分のみを`filter`して取得します。

```js
chrome.storage.local.get('items', result => {
  const items = result.items;
  const validItems = items.filter(item => {
    let date = new Date(item.date);
    date.setDate(date.getDate() + 30);
    const today = new Date();
    return today < date;
  });
  // なんたらかんたら
});
```

余談ですが、JavaScriptのsetDate関数は新しいインスタンスを返すのではなく破壊的に書き換えるので要注意です。必ずnewして別のオブジェクトを作ってからsetDateしましょう。

```js
let date = new Date(item.date);
date.setDate(date.getDate() + 30);
```
（`const`ではなく`let`をあえて使って注意喚起）

いったん取得したら、それをストレージに格納し直します。実は、ストレージの`set`関数も第二引数がコールバックになっています。今考えると別にコールバックにこの処理を書かなくても動く気がしますが、まあいいでしょう。

新しいブックマークを配列のケツにつっこむという仕様上、基本的には日付順になっているはずですが、念の為日付で`sort`してます。
また、Vue.jsにおいてリストを作る際は`v-key`の設定が必要です。よってここでidを振っておいてあとで使うことにします。

```js
chrome.storage.local.set({ items: validItems }, () => {
  const readingItems = validItems
    .sort((a, b) => b.date - a.date)
    .map((item, i) => new Object({ ...item, id: i }));
```

最後にitemsをこのコンポーネントに設定しましょう。これでmount時の処理完了です。

```js
that.items = readingItems;
```

`template`のほうを見ていきましょう。

```vue
<template>
  <div>
    <div class="count"><span class="f-bold">{{ items.length }}</span>件のブックマーク</div>
    <div><button @click="shuffle">SHUFFLE!</button></div>
    <transition-group name="list-item" tag="div">
      <ReadingItem v-for="(item, idx) in items" 
        :key="item.id"
        :url="item.url"
        :title="item.title"
        :date="item.date"
        @remove="remove(idx)">
      </ReadingItem>
    </transition-group>
  </div>
</template>
```

未読件数の表示は簡単で`{{ items.length }}`で正しく件数が表示されます。
SHUFFLEボタンは押されたときにshuffleメソッドを呼べるように`@click="shuffle"`属性を追加しています。`@hoge`は`v-on:hoge`のショートハンドです。
また、ボタンを押されたときにリストがアニメーションするように、`<ReadingItem>`要素を`<transition-group>`で囲っています。
アニメーションについてはVue.jsの公式リファレンスを見ながら、見様見真似で作りました。CSSのアニメーション、未だに苦手でよく分かっていません……。

[Enter/Leave とトランジション一覧 — Vue\.js](https://jp.vuejs.org/v2/guide/transitions.html)

`<ReadingItem>`要素は繰り返しレンダリングするものになるので`v-for="(item, idx) in items"`属性を使います。`:key`に配列の要素のidを指定します。`:key="id"`は`v-bind:key="id"`のショートハンドです。このkeyとやらは、Vue.jsが要素が移動したときにその要素を追跡するのに必要らしいです。v-forで得られるidxをkeyにするとうまく追跡できないらしいので、必ず`data`側で何らかの手段で一意に識別できるものを用意しなければなりません。

子コンポーネントには`url, title, date`をデータとして渡したいのでこれらをv-bindで指定しておきます。
また、子コンポーネントからは`remove`というイベントが送られてくるので、このとき`remove`メソッドを呼ぶように、`@remove="remove(idx)"`という属性を追加しておきます。こちらは、単にビュー上で数えたときのその配列のidx番目の要素を削除すればいいだけなので、`id`ではなく`idx`で大丈夫です。

`shuffle`メソッドを作りましょう。

```js
shuffle() {
  for(let i = this.items.length-1 ; i > 0; i--){
    let j = Math.floor(Math.random() * (i+1));
    let tmp = this.items[i];
    this.$set(this.items, i, this.items[j]);
    this.$set(this.items, j, tmp);
   }
}
```
シャッフルのやりかたは`フィッシャー-イェーツのシャッフル`というアルゴリズムがあります。比較的単純なアルゴリズムですが、これも先人がすでに実装してくれているので、どこかから適当に借りてきましょう。
ただ困ったことに、Vue.jsでは配列の要素への代入は検知してくれません、すなわち次のような書き方は無効です。

```js
arr[i] = newVal
```

公式ドキュメントを見るとlodashを使っているみたいですが、わざわざ巨大なライブラリを入れるのは嫌です。

[Enter/Leave とトランジション一覧 — Vue\.js](https://jp.vuejs.org/v2/guide/transitions.html)

オブジェクトのプロパティや配列の要素への再代入は、`Vue.set`または`this.$set`を使います。こうすることでプロパティへの再代入をVue.jsに知らせることができます。これを使いましょう。

```js
this.$set(this.arr, idx, newVal);
```

`remove`関数も定義しましょう。配列の要素の削除はsplice関数を使います。

```js
this.items.splice(idx, 1);
```

アニメーションのCSSも定義しましょう。すいませんあんまよく分かってないです。

```css
.list-item-leave-active {
  transition: all .5s ease;
  position: absolute;
}
.list-item-leave-to {
  transform: translateX(50px);
  opacity: 0;
}
.list-item-move {
  transition: all .5s ease;
  transition-delay: .1s;
}
```

今回要素が増えるときのアニメーションはないので、`list-item-leave-active`と`list-item-leave-to`の二つを定義しておきます。消えるときに0.5sかけて50px右に移動して薄くなって消えていく感じです。
またシャッフルの移動時には、0.5sかけて移動するようにする感じです。

### 子コンポーネント
次に、個々のアイテムを表す`ReadingItem`です。コンポーネントの単語名は二単語以上にしましょう。

```vue:ReadingItem.vue
<template>
  <div class="reading-item" @click="openAndEmitRemove(url)">
    <h2>{{ title }}</h2>
    <div class="url">{{ url }}</div>
    <div class="bar" :style="styleObj"></div>
    <div class="rest">あと{{ rest }}日</div>
  </div>
</template>

<script>
export default {
  name: 'reading-item',
  props: ['title', 'url', 'date'],
  computed: {
    rest() {
      let date = new Date(this.date);
      date.setDate(date.getDate() + 30);
      const now = new Date();
      const diff = (date - now) / (60*60*24*1000);
      return Math.floor(diff);
    },
    styleObj() {
      const percent = this.rest / 30 * 100;
      return {
        width: percent + '%',
        background: this.getBarColor(percent)
      }
    }
  },
  methods: {
    openAndEmitRemove(url) {
      chrome.storage.local.get({items: []}, result => {
        let items = result.items;
        for (const i in items){
          if (items[i].date === this.date && items[i].url === this.url){
            items.splice(i, 1);
            break;
          }
        }
        chrome.storage.local.set({ items });
      })
      chrome.tabs.create({ url , active: false }, tab => {
        setTimeout(() => chrome.tabs.update(tab.id, { active: true }), 800);
      });
      this.$emit('remove');
    },
    getBarColor(percent) {
      if (percent > 50) return "#0000cd";
      if (percent > 30) return "#006400";
      if (percent > 10) return "#dc143c";
      else return "#ff00ff";
    }
  }
};
</script>

<style scoped>
.reading-item:hover{
  cursor: pointer
}
.reading-item {
  border: solid #808080 1px;
  background: #F0F8FF;
  border-radius: 3px;
  padding: 5px;
  margin: 5px;
}
.reading-item h2{
  margin: 5px;
}
.reading-item .bar{
  margin-top: 2px;
  background:red;
  height:8px
}
.reading-item .url{
  width: 90%;
  white-space: nowrap;
  color: darkgray;
  overflow: hidden;
  text-overflow: ellipsis;
}
.reading-item .rest{
  text-align: right;
}
</style>
```

こちらは先程書いたように、親コンポーネントから`title`, `url`, `date`という3つのデータを貰っているので、これを`props`で受け取ります。

```js
props: ['title', 'url', 'date']
```

この要素自体がクリックされたときに、openAndEmitRemoveメソッドを呼ぶように`@click="openAndEmitRemove(url)"`という属性を追加しておきます。

また、ブックマークが自動で削除されるまでの残り日数を表すバーを付けましょう。`<div class="bar" :style="styleObj"></div>`という`div`要素を作り、`:style`属性を追加します。`style`にはObjectを指定することができます。

```js
styleObj() {
  const percent = this.rest / 30 * 100;
  return {
    width: percent + '%',
    background: this.getBarColor(percent)
  }
}
```

最後に`openAndEmitRemove`関数を実装します。

```js
chrome.storage.local.get({items: []}, result => {
  let items = result.items;
  for (const i in items){
    if (items[i].date === this.date && items[i].url === this.url){
      items.splice(i, 1);
      break;
    }
}
chrome.storage.local.set({ items });
```

例によってストレージから全アイテムを引っ張り出し、forで回しながら日付とURLが一致しているやつを見つけたら削除し、ストレージに再セットするという泥臭い処理を書きます。いささかダサすぎで、アイテム突っ込むときに一意に識別できるIDを振っておけばもう少しマシになるかもしれませんが、まあこれはこれでいいでしょう。

次にそのURLを新しいタブを開きましょう。基本的には`tabs.create`で大丈夫なんですが、ポップアップ上でリストのアイテムが消えるところを一瞬見せてから開きたいので、わざと非アクティブで開いて`setTimeout`でタイミングを遅らせてからそのタブをアクティブにしています。

```js
chrome.tabs.create({ url , active: false }, tab => {
   setTimeout(() => chrome.tabs.update(tab.id, { active: true }), 800);
});
```

最後に親コンポーネント`ItemList`にremoveというイベントを伝えています。親にイベントを伝える場合は`$emit`を使います。親コンポーネントでは`@remove=`が定義してあるので、そっちで定義した`remove`関数が走るはずです。

```js
this.$emit('remove');
```

## 完成と公開
次にアイコンを用意しましょう。ファビコンを用意するのは、なかなか面倒なので以下のようなサービスでさくっと作ってしまいましょう。

[Favicon Generator \- Text to Favicon \- favicon\.io](https://favicon.io/favicon-generator/)

16px, 19px, 38px, 48px, 128pxの画像を用意し、`public/icons`配下に置きます。

それができれば、今度はproduction用のビルドを行います。

```
npm run build
```

ビルドされたコードは、これまでと同様`dist`フォルダに出力されます。

せっかく拡張機能を作ったのでChromeウェブストアに公開しましょう。公開にはクレジットカードが必要で、500円程度のデベロッパー登録料が必要です。

`dist`フォルダ以下をzipに圧縮しておきます。

[Chrome ウェブストア \- 拡張機能](https://chrome.google.com/webstore/category/extensions)に移動し、右上の歯車から`デベロッパーダッシュボード`を選択します。`新しいアイテム`ボタンをクリックし、作成したzipフォルダをアップロードします。
必要事項を入力し、`審査のため送信`ボタンを押します。最初は必要事項が足りてないどかなんとかでハネられるかもしれませんが、理由が書いてあるので、それをよく読み修正していきます。審査ができる状態になると、送信することができます。

あとは数日待ちましょう。うまくいけばストアに公開されるはずです。

# 参考文献
[Vue\.js\+TypeScript\+FirebaseでChrome Extensionを作った – rinoguchiブログ](https://rinoguchi.net/2020/10/vue-vuetify-firebase-chrome-extension.html)
[【JavaScript】Docker上でのnpm/yarnの操作を10倍早くする方法 \| ゆとって生きたい。](https://tkkm.tokyo/post-495/)
[vue\-web\-extension を使って Chrome 拡張機能を開発する方法 \- to\-me\-mo\-rrow \- 未来の自分に残すメモ \-](https://r17n.page/2019/09/03/chrome-extension-with-vue-web-extension/)
[javascript \- Can't use Raphael JS to draw a path in a Chrome extension popup because of security policy? \- Stack Overflow](https://stackoverflow.com/questions/14855541/cant-use-raphael-js-to-draw-a-path-in-a-chrome-extension-popup-because-of-secur)
[Chrome拡張機能で右クリックメニューを作る方法 \- Qiita](https://qiita.com/plumfield56/items/e98c247888d82a79c7ea)
[Chrome拡張機能開発　contextMenus（コンテクストメニュー）についてのメモ \- なろう分析記録](https://karupoimou.hatenablog.com/entry/2019/04/22/040546)
[Chromeエクステンションを作ろう：ストレージ編 \- Qiita](https://qiita.com/dhun/items/cf18e43cb0376fcff302)
[chrome\.storage を利用したchromeへのデータ保存 \- Qiita](https://qiita.com/shimutaya/items/e8835d6ce794ef6c73cf)
[javascript \- Store an array with chrome\.storage\.local \- Stack Overflow](https://stackoverflow.com/questions/16605706/store-an-array-with-chrome-storage-local)
[【chrome extensions】Google Chrome拡張機能の作り方② \- デバッグの方法\(console\.log\) \- tweeeetyのぶろぐ的めも](https://www.tweeeety.blog/entry/2015/03/04/231354)
[javascript \- Randomize/shuffle order of elements in Vue\.js on page load \- Stack Overflow](https://stackoverflow.com/questions/60715066/randomize-shuffle-order-of-elements-in-vue-js-on-page-load)