目標
Vue.jsを使って、こんなGoogle Chromeの拡張を作ろうと思います。
- 右クリックで「そのうち読む」というメニューを表示する
- 「そのうち読む」をクリックしたらブックマークに追加する
- ブックマークは30日以内に読まないと消えてしまう(「あとで読む」を溜め込んでしまうあなたに)
- ブックマークは古い順に表示する
- ついでにシャッフル機能をつける
手順
Dockerの準備
WSLのDocker内に環境を作り、そこで引きこもって開発したいです。環境を用意しましょう。
ローカル環境などでnode.jsが用意されており、@vue/cli, @vue/cli-initを使える方はそちらでもOKです。
FROM node:12
WORKDIR /work
RUN npm install -g @vue/cli @vue/cli-init
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!」というポップアップが表示されます。
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
に記述し、右クリックメニューを追加します。
browser.contextMenus.create({
id: 'addReadback',
title: 'そのうち読む',
type: 'normal',
contexts: ['all'],
});
ストレージへのブックマークへの追加
では、このボタンを押されたときの処理を記述していきます。
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ポートをフォワードするようにしておきましょう。
あと、ESLintを入れているために、ビルド時にめちゃくちゃ怒られます。丁寧にルールを設定するなり、ルールを守って書くなりすればいいんでしょうが、すいません、よく分からないので黙らせます。
ルートフォルダにあるvue.config.js
のmodele.exports = { }
の中に以下の項目を入れます。
chainWebpack: config => {
config.module.rules.delete('eslint');
}
ポップアップを作成する
Vue.jsの詳細な書き方についてここでは触れませんが、一応軽く触れていきたいと思います。
ポップアップのエンドポイントはsrc/popup/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
は次のようにしました。
<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"
属性を追加しています。@hoge
はv-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-active
とlist-item-leave-to
の二つを定義しておきます。消えるときに0.5sかけて50px右に移動して薄くなって消えていく感じです。
またシャッフルの移動時には、0.5sかけて移動するようにする感じです。
子コンポーネント
次に、個々のアイテムを表すReadingItem
です。コンポーネントの単語名は二単語以上にしましょう。
<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