32
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-05-24

目標

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

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

右クリックメニュー.png
完成品.png
SHUFFLE後の画像です

手順

Dockerの準備

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

Dockerfile
FROM node:12
WORKDIR /work
RUN npm install -g @vue/cli @vue/cli-init
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!

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

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

npm run serve

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

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

拡張機能.png

manifest.jsonの記述

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

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

"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:".と怒られてしまうようです。

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

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

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

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

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

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

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}`;
}

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

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

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

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が存在しなかった場合のデフォルト値)を指定し、第二引数が例によってコールバックです。

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

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

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
バックグラウンドのデバッグはここ

ポップアップデバッグ.png
ポップアップのデバッグはここ

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

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

ポップアップを作成する

Vue.jsの詳細な書き方についてここでは触れませんが、一応軽く触れていきたいと思います。
ポップアップのエンドポイントはsrc/popup/App.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して取得します。

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しましょう。

let date = new Date(item.date);
date.setDate(date.getDate() + 30);

constではなくletをあえて使って注意喚起)

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

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

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時の処理完了です。

that.items = readingItems;

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

<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"属性を追加しています。@hogev-on:hogeのショートハンドです。
また、ボタンを押されたときにリストがアニメーションするように、<ReadingItem>要素を<transition-group>で囲っています。
アニメーションについてはVue.jsの公式リファレンスを見ながら、見様見真似で作りました。CSSのアニメーション、未だに苦手でよく分かっていません……。

Enter/Leave とトランジション一覧 — Vue.js

<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メソッドを作りましょう。

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では配列の要素への代入は検知してくれません、すなわち次のような書き方は無効です。

arr[i] = newVal

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

Enter/Leave とトランジション一覧 — Vue.js

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

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

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

this.items.splice(idx, 1);

アニメーションの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-activelist-item-leave-toの二つを定義しておきます。消えるときに0.5sかけて50px右に移動して薄くなって消えていく感じです。
またシャッフルの移動時には、0.5sかけて移動するようにする感じです。

子コンポーネント

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

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で受け取ります。

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

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

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

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

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

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でタイミングを遅らせてからそのタブをアクティブにしています。

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

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

this.$emit('remove');

完成と公開

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

Favicon Generator - Text to Favicon - favicon.io

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

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

npm run build

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

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

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

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

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

参考文献

Vue.js+TypeScript+FirebaseでChrome Extensionを作った – rinoguchiブログ
【JavaScript】Docker上でのnpm/yarnの操作を10倍早くする方法 | ゆとって生きたい。
vue-web-extension を使って Chrome 拡張機能を開発する方法 - to-me-mo-rrow - 未来の自分に残すメモ -
javascript - Can't use Raphael JS to draw a path in a Chrome extension popup because of security policy? - Stack Overflow
Chrome拡張機能で右クリックメニューを作る方法 - Qiita
Chrome拡張機能開発 contextMenus(コンテクストメニュー)についてのメモ - なろう分析記録
Chromeエクステンションを作ろう:ストレージ編 - Qiita
chrome.storage を利用したchromeへのデータ保存 - Qiita
javascript - Store an array with chrome.storage.local - Stack Overflow
【chrome extensions】Google Chrome拡張機能の作り方② - デバッグの方法(console.log) - tweeeetyのぶろぐ的めも
javascript - Randomize/shuffle order of elements in Vue.js on page load - Stack Overflow

32
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?