これは何
個人開発中に経験した、chrome 拡張機能開発における教訓でございます。
Manifest のバージョンは MV3 です。
V3 特有の話と、よく嵌りやすい点についてまとめました。
icon が表示されないときは
参考:https://developer.chrome.com/docs/extensions/reference/action/#icon
次を確認してみてください。
-
png
を提供しているか
pbg
以外の拡張子、例えばsvg
とかは無視されます
次の 3 つのサイズ(DIP: デバイスに依存しないピクセル)を提供しているか
- 128 * 128
インストール中および Chrome ウェブストアで使用されます
- 48 * 48
拡張機能管理ページ(chrome://extensions)で使用される 48x48 アイコンも提供する必要があります
- 16 * 16
拡張機能のページのファビコンとして使用する 16x16 アイコンを指定することもできます。
16x16 アイコンは、実験的な拡張情報バー機能にも表示されます。
Chrome は完全に一致するものが見つからない場合、画像に合わせて拡大縮小するように調整してくれるみたいなので
完全なサイズを提供する必要はない模様。
MV3 Service Worker の特徴
参考:
background
は MV3 でservice worker
へ移行しました。
service worker
の特徴として
-
service worker
は使われていない時にアンロードされて、必要になったときだけロードされる -
service worker
は DOM にアクセスできない
MV2 までのbackground page
は(アンロードされないという意味で)永続的な独立環境でしたが、
service worker
はアイドル時にアンロードされて、
リスンしているイベントがあった場合だけ再ロードされるという違いがあります。
この特徴によって気を付けないといけないことがあります。
注意 1. service worker のイベントリスナはトップレベルに同期的に記述すること
理由は必ずイベントが発生したときに chrome API のリスナが真っ先に実行されるようにするためです
どういうことかというと、
リスナの発生条件を「イベントが発生したから」以外にしてはならないという意味です
// 公式のコードそのままですが...
// background.js
chrome.storage.local.get(["badgeText"], ({ badgeText }) => {
chrome.action.setBadgeText({ text: badgeText });
// Listener is registered asynchronously
// This is NOT guaranteed to work in Manifest V3/service workers! Don't do this!
chrome.action.onClicked.addListener(handleActionClick);
});
上記のようにイベントリスナをネストさせて、発火条件を「イベントが発生したから」という条件以外にすると
再ロードされたときに非同期にイベントリスナが登録されて、イベントを逃してしまう可能性があります
また同様に、
イベントが発生したときにすぐに発火すべきはイベントリスナなので
background script ファイルの最初のほうは、
余計なコードが実行されないようにイベントリスナはトップに書いておくべきです
注意 2. service worker で変数を保存するなら必ずchrome.storage
API で保存すること
service worker は頻繁にアンロードと再ロードが繰り返されます
その間 background script は変数を保持してくれません
たとえばbackground.js
でファイル内のグローバル変数の値を変更したとしても
一旦再ロードされればその値は存在しないのです
この重要な事実は、公式情報をよく確認してから開発するか開発中偶然経験しないかぎり十分見落とす可能性があります
(拡張機能を background がアンロードされるまで放置しないといけなくて、場合によってはなかなかアンロードされない時もあるから)
開発中まったく気づかず、そのまま拡張機能をリリースするところまで来る可能性はないとはいえません
ということで
公式に書いてあるとおり、chrome.storage
を使って変数を保持することになります
background はアンロードでうっかり変数を失うのを防ぐために、
変数の呼び出しのたびにこのchrome.storage
を使っていちいち保存・取り出しを行わないといけません
例:
// background.ts
// KEY_LOCALSTORAGEというkeyでローカルストレージにT型のデータを保存しています
const KEY_LOCALSTORAGE: string = "some_awesome_local_storage_name";
const state: <T> = (function () {
const _getLocalStorage = async function (key): Promise<T> {
return new Promise((resolve, reject) => {
chrome.storage.local.get(key, (s: T): void => {
if (chrome.runtime.lastError) reject(chrome.runtime.lastError);
resolve(s);
});
});
};
return {
set: async (prop: {
[Property in keyof T]?: T[Property];
}): Promise<void> => {
try {
const s: T = await _getLocalStorage(KEY_LOCALSTORAGE);
const newState = {
...s[KEY_LOCALSTORAGE],
...prop,
};
await chrome.storage.local.set({
[KEY_LOCALSTORAGE]: newState,
});
} catch (err) {
// ...
}
},
get: async (): Promise<T> => {
try {
const s: T = await _getLocalStorage(KEY_LOCALSTORAGE);
return { ...s[KEY_LOCALSTORAGE] };
} catch (err) {
// ...
}
}
};
})();
// background内では下のように呼び出してつかいます
await state.set({hoge: 'hoge'});
// 10秒待つ間にservice workerがアンロードされたとして
setTimeout(function() {
state.get().then((t: T) => {
// 変数が保存出来ているのを確認できます
console.log(t); // {hoge: 'hoge'}
})
}, 10000);
他、background を service worker として使う場合の注意点はこちらの公式情報を参考にしてください
アンロードを検知する方法はないのか?
あるようです(使ったことない)。
chrome.runtime.onSuspend
を利用する。
アンロードされる直前にイベントページに送信されます。これにより、拡張機能にクリーンアップを実行する機会が与えられます。
先のコードのような、毎回ちまちまストレージに保存する方法から、
アンロード時に一気に保存する方法をとってみます
// background.ts
interface State {
hoge: string;
num: number;
}
// 起動中はstorageを使わずこのstateに保存された値を使うとして
const state: State = {
hoge: null,
num: null
};
const KEY_LOCALSTORAGE: string = "some_awesome_local_storage_name";
// アンロード検知したらstateに保存されている値をstorageに保存する
chrome.runtime.onSuspend.addListener((): void => {
chrome.storage.local.set({
[KEY_LOCALSTORAGE]: state,
});
})
ということでアンロード時に保存はできます。
ただし、
chrome API には再ロードされたことに対してトリガーされるイベントはないです。
service worker は発生したイベントのリスナが登録されてあるときにのみ再ロードされるだけだからです。
つまり、アンロードされたときに保存した最新の値を、再ロード時に使うにはストレージから取得したいけれど
再ロードに関するイベントがないから再ロードを検知することができないのです。
となると、結局必要な変数を取得するには必要な時に直接ストレージから取得するか、
今回の方法を守るとするならば、すべての変数を利用するイベントリスナにchrome.storage.local.get
の呼出しを義務付けることになります。
ただしアンロードさせない方法はあります
こちらで紹介されています通り、
拡張機能の content script や popup とchrome.runtime.connect()
で接続されているときはしばらくアンロードされない仕様を利用して、
chrome.runtime.onDisconnect()
で切断検知したらすかさず再接続させて service worker の稼働状態を保つ方法のようです
これをするくらいなら MV2 で開発したほうがいいと思います。
chrome.tabs.query
で windowId
を option で指定するな
今フォーカスしているウィンドウのアクティブなタブを取得したいとき、
chrome API のtabs.query
を使って取得することになります。
その際、「どの window でどのタブなんですか?」という情報をoption
としてtabs.query
に渡します。
このoption
にはwindowId
というプロパティを含めることができますが、
このwindowId
を指定してはなりません(この場合)。
なぜなのか
windowId を取得する方法として次のメソッドを使うことになります。
chrome.windows.getCurrentId()
chrome.windows.getLastFocused()
これらのメソッドで取得できるwindowId
は、
必ずと言っていいほど、最後に生成されたウィンドウの ID を取得します。
なので
たとえば拡張機能を展開中のタブを含むウィンドウとは別に、あとから新しいウィンドウを生成したときに、
その新しいウィンドウで拡張機能を展開しているわけではないのにchrome.windows.getCurrentId()
またはchrome.windows.getLastFocused()
は
この新しいウィンドウを表すwindowId
を返してしまうのです。
chrome API では頻繁にtabId
を求められるのですが
このままだと新しいウィンドウを生成した瞬間にtabs.query
がとんちんかんなTab[]
を取得してしまい
拡張機能が機能しなくなってしまうのです。
特に開発中は chrome の DevTools を別窓なんかで開いていたりするので
この開発者ツールの窓の ID なんかも加わってきてさぁ大変です。
解決策
今フォーカスしているウィンドウのアクティブなタブを取得したいときは、
tab.query
に次のオプションを渡すとよい
{
active: true, // 表示中のタブを指定する
lastFocusedWindow: true, // 最後にフォーカスしたwindowを指定できる
currentWindow: true // 現在のwindowを指定できる
}
lastFocusedWindow
とcurrentWindow
はどちらかだけでもいい。
windowId
がとんちんかんになる実験はこちらです。
message-passing で sendResponse()を非同期に完了させたいならばchrome.runtime.onMessage.addListener()
のコールバックは必ずtrue
を返すこと
というのは公式に書いてあるので当然かもしれませんが本当はここで言いたいのは文法の話です。
TypeScript 的にいうと、
chrome.runtime.onMessage.addListener()
のコールバックは
-
正しい:
(): boolean => { return true }
-
誤り:
async (): Promise<boolean> => {return true;}
非同期処理を含むからといってついついコールバック関数をasync
関数にしてしまうと
非同期処理が無視されて、sendResponse()
が非同期に返されるのを待たずに
送信先が存在しませんという旨のruntime.lastError
が起きてしまいます。
このエラーが起きると「なんでコールバック関数でreturn true
したのに非同期処理にならないんだ」と迷宮入りしてしまいます。
つまりsendResponse
を非同期に返したいときは次の通りにしなくてはなりません。
-
chrome.runtime.onMessage.addListener()
のコールバックは同期関数を渡さなくてはならない -
chrome.runtime.onMessage.addListener()
のコールバックはtrue
を返さなくてはならない -
chrome.runtime.onMessage.addListener()
のコールバック内で非同期処理をしたいならプロミスチェーンか即時関数内部でasync/await
を使わなくてはならない
よって次の通りに書くべきです
// message-passingでやり取りするオブジェクトの型
interface iMessage {
// ...
}
chrome.runtime.onMessage.addListener(
// 1. 同期関数をcallbackとして渡す
(
message: iMessage,
sender,
sendResponse: (response: iMessage) => void
): boolean => {
const { order, from } = message;
const response: iMessage = {
from: "content_script",
to: from,
};
// orderの各処理には非同期処理が含まれるとして...
if (order && order.length) {
// 3-1. 非同期処理を書きたいときはプロミスチェーンを使うか...
if (order.includes(orders.reset)) {
handlerOfReset()
.then(() => {
sendResponse({
...response
complete: true
});
})
}
// 3-2. IIFEでasync関数を囲う
if (order.includes(orderNames.turnOff)) {
(async function() {
const result: boolean = await handlerOfTurnOff();
if(result) sendResponse({
...response,
complete: true
})
})()
}
}
// 2. sendResponse()が非同期に実行されるのを許可するために
// `true`を返す
return true;
}
);
これで非同期的にsendResponse
が返されるまで通信が途切れない。
popup の state は background script で管理すること
chrome 拡張のpopup
は開かれるたびに、web ページのリロード同様に、毎回リフレッシュされます
なので例えば POPUP を React で生成しているようなとき、
POPUP 再表示後の state は以前の保存内容を記録していません
POPUP が表示されるたびに state は毎回初期値になります。
なので popup は state の値に依存して、その表示内容が変化するようなときは、
background script に state の値を保存してもらうことになります。
以下は私が POPUP を React で実装したときに特に問題なく動いたやり方です。
useEffect()
を使って background script から必要な情報を取得しています。
注意点として、useEffect()
のコールバック関数は async 関数は使えません
// popup.tsx
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
const Popup = (): JSX.Element => {
const [hoge, setHoge] = useState<boolean>(false);
const [fuga, setFuga] = useState<boolean>(false);
// 表示されたときの初期値だけ取得するので
// 第二引数には空の配列を渡します
// sendMessagePromiseというのは拡張機能間のmessage passing 関数ということで
// とにかくbackground scriptにメッセージを送信します
useEffect(() => {
sendMessagePromise({
from: extensionNames.popup,
to: extensionNames.background,
order: [orderNames.sendStatus],
}).then((res: iResponse) => {
const { hogeStatus, fugaStatus } = res.state;
setHoge(hogeStatus);
setFuga(fugaStatus);
});
}, []);
//...
}
[余談]chrome.windowsのwindowId取得実験
chrome.windows
のメソッドが実際にはどのwindowId
を取得するのか
それを確認する実験を行いました。
実験内容
POPUP を表示させた時に POPUP は background script へ message passing し
background script は下記コードのwindowIdSurvey()
を実行する。
以下の状況でwindowIdSurvey()
内の各出力が異なる windowId を出すのか確認する
-
検証1:ブラウザのウィンドウが 1 つだけの時
windowId はすべて同じになるはず -
検証2:ブラウザのウィンドウが 2 つの時、もとあるウィンドウをフォーカスしたままその POPUP を表示させる
windowId は最後にフォーカスしたウィンドウになるはず -
検証3:あとから生成したウィンドウをフォーカスしている最中に、もとあるウィンドウの方の POPUP を表示させる
windowId はあとから生成したウィンドウの id になるはず -
検証4:あとから生成したウィンドウをフォーカスしている最中に、そちらのウィンドウの方の POPUP を表示させる
windowId はあとから生成したウィンドウの id になるはず
const windowIdSurvey = funciton() {
chrome.tabs.query(
{ active: true, currentWindow: true, lastFocusedWindow: true },
function (tabs) {
console.log("windowId by tabs.query() with the option: ");
console.log(tabs[0].windowId);
}
);
chrome.windows.getLastFocused({}, (w) => {
console.log('windowId by getLastFocused():');
console.log(w.id);
});
chrome.windows.getCurrent({}, (w) => {
console.log('windowId by getCurrent():');
console.log(w.id);
});
}
実験結果
# 検証1: 当然ウィンドウが一つしかないから同じwindowIdになる
windowId by tabs.query() with option
1
windowId by getLastFocused():
1
windowId by getCurrent():
1
# 検証2: chrome.windowsメソッドのほうは
# フォーカスしていないにもかかわらずあとから生成したウィンドウのIDを出力した
windowId by tabs.query() with option
1
windowId by getLastFocused():
101
windowId by getCurrent():
101
# 検証3: 検証2と同じ結果になった
windowId by tabs.query() with option
1
windowId by getLastFocused():
101
windowId by getCurrent():
101
# 検証4: 想定通り
windowId by tabs.query() with option
101
windowId by getLastFocused():
101
windowId by getCurrent():
101
以上の結果から、chrome.windows
の2つのメソッドは期待したwindowId
を取得しないことが分かりました。
このように、なぜだか不明ですが、chrome.widnows.getCurrent()
もchrome.widnows.getLastFocused()
も
必ず最後に生成したウィンドウの id を返します。
最後にフォーカスしていたかも現在のウィンドウであるかどうかは全く関係ありません。
一方chrome.tabs.query
のオプションに{ active: true, currentWindow: true, lastFocusedWindow: true }
を
渡せば必ず最後にフォーカスしたウィンドウの id を取得できます。
公式はどうすればどのウィンドウの id を取得できるのか、どのウィンドウのタブを取得できるのかチュートリアルでも出してくれればいいのですが、
残念ながら誤解を招くメソッドの説明をするだけでありました。
最後に
以上の教訓は私が実際に chrome 拡張機能を開発する際に立ちはだかった障害に対して調べたあれこれです。
認識など間違いがあるかもしれませんので、
chrome 拡張機能の開発に強い人は是非ご指摘いただきたいです。
またこんな記事でもこれから chrome 拡張機能の開発をする方の助けになれれば幸いです。