この記事ではChrome拡張機能を書くときに得られた知見をまとめておく。拡張機能を書くにあたっての基本的な知識は持っていることを前提とする。
なお、今回の記事の元となった拡張機能はChromeウェブストアから入手可能で、ソースコードも公開しているので、適宜参照していただきたい。
開発環境の準備
parcelとparcel-plugin-web-extensionを使うことで、簡単に拡張機能の開発環境を構築することができる。また、開発時にブラウザで拡張機能を再読込みするためにChrome拡張機能のExtensions Reloaderが便利。
parcelのインストールは次のコマンドを叩く。
yarn -D parcel-bundler parcel-plugin-web-extension
例えばTypeScriptとStylusを使う場合、manifest.jsonを次のように書いておくだけでparcelがJSとCSSにビルドしてくれる。
"content_scripts": [
{
"matches": ["*://*/*"],
"js": ["scripts/content.ts"],
"css": ["styles/content.styl"]
}
]
ビルド時には次のようにmanifest.jsonの場所を指定する。
yarn parcel build src/manifest.json
また次のようなコマンドで、コードの変更を監視して自動でビルドできる。
yarn parcel watch --no-hmr src/manifest.json
バンドルサイズを小さくする
ビルドされるスクリプトのサイズはなるべく抑えたいもの。とくに、各ページに読み込まれるContent Scriptでは、無闇に多くのリソースを消費してしまうことにもなりかねない。
そこで活用できるのがTree Shakingや Scope Hoistingといったバンドラの機能。これらを用いることで、バンドルサイズを小さくして実行速度も向上させることができる。
parcelでは次のようにオプションを付けることで、これらの機能を有効にしたビルドができる。
yarn parcel build --experimental-scope-hoisting src/manifest.json
またTypeScriptを用いている場合、tsconfig.jsonでモジュール関連の設定を適切にしておく必要もある。次のようにES Modulesを有効にしておく。
"target": "es2016",
"module": "es6",
"lib": ["dom", "esnext"],
"moduleResolution": "node"
ただし、外部ライブラリがES Moduleでエクスポートしてないとサイズ削減できなかったりもするので注意。
Content Scriptから可能な限り早いタイミングでdocument.body
を操作する
ブラウザが開いているページに拡張機能が何か表示したい場合などは、Content Scriptからdocument.body
を操作することが多いと思う。本稿を書く元となった拡張機能でも、タブ一覧をポップアップで画面内に出すために、document.body
にDOM要素を追加している。
その際に使い心地を良くするため重要だと感じたのが、document.body
に要素を追加するタイミングだ。document.body
を操作する場合、次のようにmanifest.jsonでrun_at
をdocument_end
とすることで、DOMの読み込みを待ってContent Srciptを走らせるのが一般的な方法とされる。
"content_scripts": [
{
"matches": ["*://*/*"],
"js": ["scripts/content.ts"],
"run_at": "document_end"
}
]
この方法の問題は、サイトによってはDOMの読み込みまで数秒かかり、それまでContent Scriptが実行されないという点だ。この問題を解決するには、run_at
をdocument_start
にすればよい。しかしそうすると、Content Scriptが走りだしたタイミングではdocument.body
がnull
のことがあるという新たな問題が生じる。そこで、すぐにContent Scriptを読み込み、Mutation Observerを用いることで、document.body
が非nullになった直後からDOMを操作することができるのだ。
manifest.jsonではrun_at
をdocument_start
として、
"content_scripts": [
{
"matches": ["*://*/*"],
"js": ["scripts/content.ts"],
"run_at": "document_start"
}
]
Content Scriptの読込み直後にMutationObserver
でDOMの変更を監視し、document.bodyが非nullになった直後にアクセスする。
const insertApp = () => {
// manipulate document.body!
};
if (document.body != null) {
insertApp();
} else {
const observer = new MutationObserver(() => {
if (document.body != null) {
observer.disconnect();
insertApp();
}
});
observer.observe(document, {
attributes: false,
attributeOldValue: false,
characterData: false,
characterDataOldValue: false,
childList: true,
subtree: true
});
document.addEventListener(
"readystatechange",
event => {
switch (event.target.readyState) {
case "interactive":
case "complete":
insertApp();
}
},
{ capture: true }
);
}
拡張機能をWebサイトのCSSから分離する
Content ScriptでDOMに追加した要素には、Webサイトに読み込まれているCSSのスタイルが適用される。これはDOMに要素を追加している以上は当然なのだが、拡張機能の独自のUIを表示したい場合には、WebサイトのCSSが影響して開発者の意図とは違う見た目になってしまうことがあり問題となる。
Shadow DOM
Content Scriptが追加する要素がWebサイトのCSSの影響を受けないようにするために活用できるのがShadow DOMだ。Shadow DOMにより通常のDOMツリーからは隠され分離されたDOMツリーを作ることができ、Content Scriptが追加する要素はShadow rootの下に追加していく。
const element = document.createElement("div");
element.attachShadow({ mode: "open" });
element.id = "myawesomeapp";
document.body.prepend(element);
// Append DOM elements to element.shadowRoot
Shadow DOMとして追加した要素に拡張機能のCSSを適用させるには、二通りの方法がある。
方法1: style
要素に書く
ひとつはShadow rootの下にstyle
要素を追加して、そこにスタイルを書く方法である。本稿を書く元となった拡張機能ではこの方法を使っている。ただし、Stylusで書きたかったので、ビルドされたCSSをBackground Script経由で読み込んでstyle
要素に書き出している。もしくは、Background Scriptを経由せずとも、manifest.jsonでweb_accessible_resourcesを指定することでContent ScriptからCSSを直接読むことができる。
方法2: CSS Shadow Parts
もうひとつの方法は、CSS Shadow Partsが定義する::part()
疑似要素を用いてShadow DOMの要素にスタイルを適用する方法だ。~~この機能はFireFoxではまだデフォルトで有効になっていないので、拡張機能をFireFoxのアドオンとしても提供したい場合は注意が必要だろう。~~また、スタイルを適用したい各DOM要素にpart
属性をつけないといけないのが個人的には少し手間だと感じた。さらに、Shadow partを適用するShadow DOMはWeb Componentsとして提供されていなければならないが、Chromeは普通は拡張機能からWeb Componentsを作れないので、polyfillを用いて何とか作れるようにする必要もある。
all: initial
で親要素からも分離
拡張機能のShadow rootなどに適用するスタイルでall: initial
を指定しておくのも忘れてはならない。というのも、Shadow DOMはサイトのCSSからは隔離されるが、Shadow rootの親のスタイルの影響は受けるからだ。all: initial
を指定することで、その影響を消すことができる。
拡張機能のAPIでPromiseを使う
chrome.*
で提供される拡張機能のAPIでは、Promiseを返すメソッドは用意されておらず、非同期な処理はコールバック関数で書くことになる。async
やawait
を用いた書きかたでAPIを使いたければ、webextension-polyfill、TypeScriptで書いている場合はwebextension-polyfill-tsを利用するとよい。
Content ScriptでReactを使う
Reactはとても有用なので、Content Scriptでも使いたいことがあると思う。その際に、reactのかわりに軽量な互換ライブラリのpreactを使うことで、フットプリントを小さくすることができる。preactはHooksなど最近の機能も実装しており、Reactを使って書かれたコードをそのまま動かすことができる。
preactをTypeScriptと使う場合、tsconfig.jsonには次の設定を加える。詳しくは本家のドキュメントも参照してほしい。
"jsx": "react",
"jsxFactory": "h"
参考URLs
Bundler
- Reduce JavaScript Payloads with Tree Shaking _ Web Fundamentals
- Brief introduction to scope hoisting in Webpack
- 📦 Parcel v1.9.0 — Tree Shaking, 2x faster watcher, and more! 🚀
Mutation Observer
CSS Isolation
- Isolating CSS for Chrome extension - Stack Overflow
- javascript - Light DOM style leaking into Shadow DOM - Stack Overflow
- polymer - Registering a custom element from a chrome extension - Stack Overflow