💥

技術書典15 「JSで作るいまどきのブラウザ拡張」のアンチ記事

2023/11/30に公開

技術書典に行ってきました(読み飛ばしてください)

先日、池袋で開催された技術書典15に参加してきました。技術書典初参加です。

その前の日はMisskeyのFFと秋葉原でエンカしていました。なんやかんやあって上野に泊まることになり、そのまま東方秋季例大祭と技術書典に参戦した次第です。

午前中に例大祭に行った都合で午後からの入場だったのですが、様々な分野の本が多くて非常に楽しかったです。

流行っているフロントエンドに関する本から、機械学習の数学的内容に言及したものなど多岐にわたるものがあり見ているだけでも非常に楽しかったです。

個人的に面白かったのは「Node.jsでデバイスドライバを書く」という本と、令和にPC-98で開発する手法の本です。こういう非常にニッチな需要の本があるのがとても楽しいです。

なぜこんな記事を書くのか

技術書典の1か月ほど前から、Chrome拡張機能の開発を行っていました。

そんな中技術書典でブラウザ拡張機能を作る本「JSで作るいまどきのブラウザ拡張」を発見し、作者様と「1か月前に知りたかった…」というお話をさせていただきました。

そして作者様から「じゃあこの本のアンチ記事書いてみなよ」と言われました。私は最初非常に驚いたのですが、作者様に言われてしまっては仕方がありません。

本を購入させていただき、隅々まで読んだうえでアンチ記事を書いていこうと思います。

よかったところ

本書のリンクはこちらです。

https://techbookfest.org/product/kbzK73N5HjUjLPkZdaAAey?productVariantID=wR014uLSAzARLc3HSRgd5G

これからたくさん本書に対して細かい間違いやより良い開発方法などを書いていくのですが、ただ悪口ばかり書いてしまっては本書の価値をわかってもらえないかもしれません。

この「JSで作るいまどきのブラウザ拡張」は初心者がブラウザ拡張機能の開発を始めるのにちょうどよい出発点になります。

かくいう私も9月に初めてmanifest.jsonを書いた初心者なのですが、当初はFirefoxとChromeの違いやManifest V2とV3の違いなど、様々な面で情報が錯綜しており非常に苦しめられました。

この本は最新のV3に準拠しつつFirefoxとChromeに両対応しており、各コンポーネントの概念やデバッグの方法を丁寧かつ簡潔にまとめてあります。インターネット上にある無駄に長い公式ドキュメントやV2の情報に迷わされることもありません。

また、インターネット上のほとんどの初心者向けの記事がJSファイルを直書きしてHello Worldして終わりであり、実際にプロダクトを継続的に開発する際のディレクトリ構造やバンドラなどの構成にまで触れているものはありません。

(現時点で具体的なコードは公開されていませんが)バンドラを導入してモダンな拡張機能開発のための最小限かつ実用的なディレクトリ構造を公開しているため非常に参考になります。

また、デバッグ手法については私の知らなかった情報も数多くあり現在活用させていただいております。(クリーンなブラウザプロファイルでデバッグする等)

アンチになる前に

この記事のタイトルがアンチ記事なので、反論というか詳細を追記することが非常に多くなってしまっています。この本が初心者を対象にしているが故にあえて掲載していない情報なのかもしれません。重箱の隅を楊枝でほじくるような記事になってしまっているかもしれませんが、本当に申し訳ございません。

ということで今から私はこの本のアンチになっていこうと思います!!!

誤字脱字

まずは私の発見した誤字脱字の一覧です。(技術書典15で購入した紙版を参考にしています。)

24ページ「isolation以外が」

途中で文章が切れてしまっているのでこの後に何を言いたかったのか推測するしかないのですが、バックグラウンドスクリプトとコンテントスクリプトでの名前空間の差異に言及したセクションですので、おそらくメッセージに関することを書きたかったのだろうと推測します。

メッセージはManifest V3で完全に隔離されたブラウザ拡張機能を構成する複数の名前空間を接続するための連絡手段です。

Electronにも似たようなものがあった気がしますが、メッセージは送信側からchrome.runtime.sendMessageを実行すると、受信側のスクリプトでchrome.runtime.onMessageイベントが発火し、sendMessageに渡された情報をコールバック関数で受け取るというものです。

私はまだ文字列の送受信にしか使用していませんが、オブジェクトを送信することも可能です。

https://developer.chrome.com/docs/extensions/mv3/messaging/

14ページ「popupキーをmanifestに追加することで」

「popupキーをmanifestに追加することで、オプションページを表示することができる。」

とありますが、正しくは「options_pageキーをmanifestに追加することで」だと推測します。

ちなみにoptions_pageキーというのも古い情報であるため間違っているが、こちらは後述する。

29ページ 「なおmanifest.json」

この部分も途中で途切れています。残念ながらこの続きを推測することはできませんでした。

また、その直下の「WarekiConvで用いる最低限のプロジェクト構成」も途中であると思われます。

30ページ 「こだわりがなければ」

おそらくこの後に「不要です。」の一言が追加されると予測します。

誤解のありそうな記載

8ページ「WebExtensionsの内部構造」

この図は間違っていないのですが、ラベルやoption、popupでのJSの扱いの説明がありません。

まず、popupやoptions内で実行されるJavaScriptはContent Scriptと動作が異なります。

上の写真は私が開発している拡張機能のオプションページでchrome.tabsを出力した結果です。非常に大きいオブジェクトが帰ってきていることがわかります。

一方でContent Scriptでchrome.tabsにアクセスすると、ただのundefinedが帰ってきます。

掲載されている図では、PopupやOptionsから各ページのDOM等を操作する場合に、一旦backgroud.jsと通信したうえでContent Scriptと通信するかのように書かれていますが、実際はそうではありません。

optionsやpopupのJSはService Workerであり、background.jsを経由せず直接Content Scriptと連携することが可能です。

また、icon-buttonという独立したスクリプトが存在するかのように書かれていますが、実際にはicon-buttonはpopupやoption、background.jsといったService WorkerによってAPI経由でコールバック関数によって制御されるため、「icon-buttonの挙動用のスクリプト」というのは存在しません。(こちらは矢印の種類をしっかりとみると確認できます。)

この典は23ページのisolated worldと公式ドキュメントを参照すると、より適切なデータの分類が理解できると思います。

10ページ 「WebExtensionsファイル構成と動作を図に起こすとmanifestの置き場所がなかったりする」

WebExtensionsファイル構成では、すべての頂点に君臨するのがmanifest.jsonである。

また、データの移動という観点ではService Workerからアクセス可能なAPIchrome.runtime.getManifest()を通して取得可能であるため、background.jsに最も近いのではないかと考える。

どちらにせよmanifest.jsonはすべての動作と構造を決定するファイルであることは言うまでもない。

個人的に追記したいこと

14ページ manifest.js

"options_page": "option_ui/option_ui_page.html"

optionsページはoptions_page以外にもoptions_uiという方法で指定することもできます。

options_uioptions_pageの上位互換であり、2種類の設定画面の種類を指定できます。

24ページ 「WebExtensionsのクリーンアップ」

本稿ではContent Scriptによって操作されたDOMの変更は拡張機能の削除や無効化を行っても元に戻ることはないということが説明されています。

最も簡単なクリーンアップの方法はリロードすることです。「タブを再読込してしまえば良いだけではある」とある通り、再読み込みを行うとDOMが再構築されるため、その際に拡張機能が操作を行わなければDOMはオリジナルのままになります。

実装時の注意点としては、Service Workerはページのリロードを行えないことが挙げられます。

リロードに必要なLocationインターフェースはHTML DOM APIの一種のためService Worker内では未定義になってしまいます。リロードをoptionやpopupから行うにはメッセージをタブに送信する必要があります。

Service Workerからタブへのメッセージングは逆方向と違って権限や方法が少々面倒であるため注意して下さい。(適切な実装は後述します)

25ページ 「Storage(API)によるデータの永続保持」

型安全について

Storage APIは拡張機能においてデータを永続的に保存する数少ない手段の一つです。

しかしこのStorage APIはJavaScriptを前提に設計されているため返り値にany型が指定されています。

実際にブラウザ拡張機能の開発を行う際にはTypeScriptを用いるべきであり、ジェネリクスを用いて型付けされたStorage APIのラッパーであるfregante/webext-storageを利用するべきであると考えています。

そもそも本書ではTypeScriptについて触れられていないのが非常に残念である。

getの躓きやすい点について

Storage APIのgetは多少面倒な仕様になっています。

画像のようにchrome.storage.local.get("hoge")を実行した際、Promiseから最終的に返却される値は、hogeの値ではなくhogeのキーと値がセットになったオブジェクトです。

この仕様を忘れてコーディングを行うと、以下のようなことが起こりえます。

undefined-keyには値が設定されていないため、chrome.storage.local.get("undefined-key")のPromiseは空のオブジェクトを返却します。

その後の三項演算子ではvalueOfUndefinedKeyが存在するかどうかを判別しているのですが、仮に未定義のキーであっても空のオブジェクトは定義されているので、正常に比較できません。

おそらく最も簡単な比較方法はObject.keysを用いることです。

console.log(`値が設定されていま${Object.keys(valueOfUndefinedKey).length !== 0 ? "す" : "せん" }`)

26ページ 「インストール後にオプションページを表示する」

この実装は簡単に見えて多少面倒です。以下にサンプルコードを提示しておきます。

chrome.runtime.onInstalled.addListener(async () => {
    // オプションページを開く
    const isInstalled = await chrome.storage.local.get("installed");
    if (isInstalled.installed.toString() == "true") return;
    chrome.runtime.openOptionsPage();

    // ストレージにインストール済みフラグを立てる
    // onInstalledは初回インストールだけではなくアップデート時にも呼び出される
    // フラグを設定しないとアップデート時にもオプションページが開かれてしまう
    chrome.storage.local.set({
        installed: true,
    });
});

26ページ 「permission(使用権限の宣言)」

(28ページの非同期メッセージ通信の内容を前提としています)

「すべてのウェブサイトに対してコードの実行を許可する」と「現在アクティブなタブでのみコードの実行を許可する」の中間を実装することもできます。

中間というのは「Content Scriptで指定されたウェブサイトすべてでコードを実行する」です。

(この方法は後述する動的なContent Scriptの挿入に対応していません。)

まず、manifest.jsonで対象にするウェブサイトの数だけContent Scriptを定義します。

その後、各Content ScriptにメッセージのListenerを設定してください。

ここからが肝で、background.jsでmanifest.jsonの値からウェブサイトの一覧を取得して、一致するタブ全てにメッセージを送ります。

export const sendMsgToAllTab = <T>(msg: T) => {
    if (!isServiceWorkerScript()) return;
    const urls = getUrlsFromManifest();
    browser.tabs
        .query({
            url: [...urls, `chrome-extension://${browser.runtime.id}/*`],
        })
        .then((tabs) => {
            tabs.forEach((tab) => {
                try {
                    browser.tabs.sendMessage(tab.id!, msg);
                } catch (e) {
                    console.error(e);
                }
            });
        });
};

export const isServiceWorkerScript = () => {
    return location.protocol === "chrome-extension:";
};

export const getUrlsFromManifest = () => {
    const manifest = browser.runtime.getManifest();
    const content_scripts = manifest.content_scripts;
    if (!content_scripts) return [];

    // (string | undefined)[] => string[]
    return content_scripts
        .map((c) => {
            return c.matches;
        })

        .flat()
        .filter((c): c is string => typeof c === "string");
};

この実装によりall_urlsを用いることなく複数のウェブサイトでメッセージの送受信が可能になります。

28ページ 「Promise/コールバックの採用が異なる」

FirefoxとChromeで一部APIの仕様が異なっています。メッセージングはその最たるものであり、クロスブラウザな拡張機能の開発の上で苦労すると思います。

Fiefoxの開発元であるMozillaはその差異を吸収するPolyfillを公開しています。

https://github.com/mozilla/webextension-polyfill

このpolyfillはFirefoxのAPIをChromeに合わせることでChromeからFirefoxへの移植を容易にするものです。

拡張機能の新規開発の際にはこのPolyFillを最初から導入しておくと色々と楽だと思います。

(41~42ページで言及されていました。失礼しました。)

29ページ プロジェクト構成

第5章の内容は、本書中にサンプルコードが書かれていないため実際に何のバンドラを用いたのかがわかりません。

以下に、よく利用されるバンドラであるWebpackを用いたChrome拡張機能の開発方法へのリンクを掲載しますので参考にしてください。

https://qiita.com/hamachi4708/items/cf7282cc3874220ad399

https://tm-progapp.hatenablog.com/entry/2022/05/21/150850

また、私が開発している拡張機能もReact + Tailwind CSS + Webpack + TSで開発されています。

https://github.com/GunmaRamens/gaming-gundai

本書の作者による拡張機能ではバンドラを独自に書いているようです。

https://github.com/MichinariNukazawa/DanTagCopy_diffusion_tags_clipboard_webextension/blob/main/scripts/build.js

38ページ 「ストア表示用スクリーンショット(chrome)」

スクリーンショットはサイズが厳密に指定されています。

画像編集ソフトで編集しても良いですが、最も簡単なスクリーンショットの作成方法は開発者ツールを用いることです。

開発者ツールでサイズを1280x800に設定したうえで開発者ツールのスクリーンショット機能を用いることで簡単に撮影できます。

43ページ 「innerHTML」

プロジェクト内でinnerHTML等の危険なAPIを避けることを強制するESLintのルールがMozillaによって公開されています。

https://github.com/mozilla/eslint-plugin-no-unsanitized

まだ自分が理解できてないこと

22ページ Content Scriptの動的読み込み

この部分は本書で初めて知ったことです。今までmanifest.jsonでしかContent Scriptを定義できないと思い込んでいたので、この部分は今後勉強していこうと思います。

終わり

やはり自分がある程度知っていると思いこんでいる分野でも、詳細を厳密に調査しようとすると意外と理解できてないことが多い事に気づきました。Content Scriptの動的読み込みや、完全なデバッグのためのクリーンなプロファイル、Firefoxでの自動リロードなどの開発手法は今後の開発に取り組んでいこうと思います。

改めて、素晴らしい本を書いくださったdaisy bellさんに感謝しつつ、アンチ記事の締めとさせていただこうと思います。最後まで読んでいただきありがとうございました。

Discussion