22
9

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 1 year has passed since last update.

[MV3] Chrome Extension 開発の教訓

Last updated at Posted at 2022-04-24

これは何

個人開発中に経験した、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の特徴として

  1. service workerは使われていない時にアンロードされて、必要になったときだけロードされる
  2. 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.querywindowId を 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を指定できる
}

lastFocusedWindowcurrentWindowはどちらかだけでもいい。

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 拡張機能の開発をする方の助けになれれば幸いです。

22
9
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
22
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?