Chrome拡張機能の開発Tips (2019)

この記事ではChrome拡張機能を書くときに得られた知見をまとめておく。拡張機能を書くにあたっての基本的な知識は持っていることを前提とする。

なお、今回の記事の元となった拡張機能は[Chromeウェブストアから入手可能](https://chrome.google.com/webstore/detail/moly-tab-menu/pniegmhajhkebcalmfalpjjhmgfipibc)で、[ソースコードも公開している](https://github.com/slaypni/moly-tabmenu2)ので、適宜参照していただきたい。

# 開発環境の準備

[parcel](https://github.com/parcel-bundler/parcel)と[parcel-plugin-web-extension](https://github.com/kevincharm/parcel-plugin-web-extension)を使うことで、簡単に拡張機能の開発環境を構築することができる。また、開発時にブラウザで拡張機能を再読込みするためにChrome拡張機能の[Extensions Reloader](https://chrome.google.com/webstore/detail/extensions-reloader/fimgfedafeadlieiabdeeaodndnlbhid)が便利。

parcelのインストールは次のコマンドを叩く。

```sh
yarn -D parcel-bundler parcel-plugin-web-extension
```

例えばTypeScriptとStylusを使う場合、manifest.jsonを次のように書いておくだけでparcelがJSとCSSにビルドしてくれる。

```json
"content_scripts": [
  {
    "matches": ["*://*/*"],
    "js": ["scripts/content.ts"],
    "css": ["styles/content.styl"]
  }
]
```

ビルド時には次のようにmanifest.jsonの場所を指定する。

```sh
yarn parcel build src/manifest.json
```

また次のようなコマンドで、コードの変更を監視して自動でビルドできる。

```sh
yarn parcel watch --no-hmr src/manifest.json
```
 
# バンドルサイズを小さくする

ビルドされるスクリプトのサイズはなるべく抑えたいもの。とくに、各ページに読み込まれるContent Scriptでは、無闇に多くのリソースを消費してしまうことにもなりかねない。

そこで活用できるのがTree Shakingや Scope Hoistingといったバンドラの機能。これらを用いることで、バンドルサイズを小さくして実行速度も向上させることができる。

parcelでは次のようにオプションを付けることで、これらの機能を有効にしたビルドができる。

```sh
yarn parcel build --experimental-scope-hoisting src/manifest.json
```

またTypeScriptを用いている場合、tsconfig.jsonでモジュール関連の設定を適切にしておく必要もある。次のようにES Modulesを有効にしておく。

```json
"target": "es2016",
"module": "es6",
"lib": ["dom", "esnext"],
"moduleResolution": "node"
```

ただし、外部ライブラリがES Moduleでエクスポートしてないとサイズ削減できなかったりもするので注意。

#  Content Scriptから可能な限り早いタイミングで`document.body`を操作する

ブラウザが開いているページに拡張機能が何か表示したい場合などは、Content Scriptから`document.body`を操作することが多いと思う。[本稿を書く元となった拡張機能](https://chrome.google.com/webstore/detail/moly-tab-menu/pniegmhajhkebcalmfalpjjhmgfipibc)でも、タブ一覧をポップアップで画面内に出すために、`document.body`にDOM要素を追加している。

その際に使い心地を良くするため重要だと感じたのが、`document.body`に要素を追加するタイミングだ。`document.body`を操作する場合、次のようにmanifest.jsonで`run_at`を`document_end`とすることで、DOMの読み込みを待ってContent Srciptを走らせるのが一般的な方法とされる。

```json
"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](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)を用いることで、`document.body`が非nullになった直後からDOMを操作することができるのだ。

manifest.jsonでは`run_at`を`document_start`として、

```json
"content_scripts": [
  {
    "matches": ["*://*/*"],
    "js": ["scripts/content.ts"],
    "run_at": "document_start"
  }
]
```

Content Scriptの読込み直後に`MutationObserver`でDOMの変更を監視し、document.bodyが非nullになった直後にアクセスする。

```js
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](https://developer.mozilla.org/ja/docs/Web/Web_Components/Using_shadow_DOM)だ。Shadow DOMにより通常のDOMツリーからは隠され分離されたDOMツリーを作ることができ、Content Scriptが追加する要素はShadow rootの下に追加していく。

```js
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`要素を追加して、そこにスタイルを書く方法である。[本稿を書く元となった拡張機能](https://chrome.google.com/webstore/detail/moly-tab-menu/pniegmhajhkebcalmfalpjjhmgfipibc)ではこの方法を使っている。ただし、Stylusで書きたかったので、ビルドされたCSSをBackground Script経由で読み込んで`style`要素に書き出している。もしくは、Background Scriptを経由せずとも、manifest.jsonで[web_accessible_resources](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/manifest.json/web_accessible_resources)を指定することでContent ScriptからCSSを直接読むことができる。

### 方法2: CSS Shadow Parts

もうひとつの方法は、[CSS Shadow Parts](https://triple-underscore.github.io/css-shadow-parts-ja.html)が定義する`::part()`疑似要素を用いてShadow DOMの要素にスタイルを適用する方法だ。~~この機能は[FireFoxではまだデフォルトで有効になっていない](https://caniuse.com/#feat=mdn-css_selectors_part)ので、拡張機能をFireFoxのアドオンとしても提供したい場合は注意が必要だろう。~~また、スタイルを適用したい各DOM要素に`part`属性をつけないといけないのが個人的には少し手間だと感じた。さらに、Shadow partを適用するShadow DOMはWeb Componentsとして提供されていなければならないが、Chromeは普通は[拡張機能からWeb Componentsを作れない](https://bugs.chromium.org/p/chromium/issues/detail?id=390807)ので、[polyfill](https://github.com/webcomponents/polyfills)を用いて何とか作れるようにする必要もある。

## `all: initial`で親要素からも分離

拡張機能のShadow rootなどに適用するスタイルで`all: initial`を指定しておくのも忘れてはならない。というのも、Shadow DOMはサイトのCSSからは隔離されるが、Shadow rootの親のスタイルの影響は受けるからだ。`all: initial`を指定することで、その影響を消すことができる。

# 拡張機能のAPIでPromiseを使う

`chrome.*`で提供される拡張機能のAPIでは、Promiseを返すメソッドは用意されておらず、非同期な処理はコールバック関数で書くことになる。`async`や`await`を用いた書きかたでAPIを使いたければ、[webextension-polyfill](https://github.com/mozilla/webextension-polyfill)、TypeScriptで書いている場合は[webextension-polyfill-ts](https://github.com/Lusito/webextension-polyfill-ts)を利用するとよい。

# Content ScriptでReactを使う

Reactはとても有用なので、Content Scriptでも使いたいことがあると思う。その際に、[react](https://github.com/facebook/react)のかわりに軽量な互換ライブラリの[preact](https://github.com/preactjs/preact)を使うことで、フットプリントを小さくすることができる。preactはHooksなど最近の機能も実装しており、Reactを使って書かれたコードをそのまま動かすことができる。

preactをTypeScriptと使う場合、tsconfig.jsonには次の設定を加える。詳しくは[本家のドキュメント](https://preactjs.com/guide/v10/getting-started/)も参照してほしい。

```json
"jsx": "react",
"jsxFactory": "h"
```

# 参考URLs

### Bundler

- [Reduce JavaScript Payloads with Tree Shaking  _  Web Fundamentals](https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking)
- [Brief introduction to scope hoisting in Webpack](https://medium.com/webpack/brief-introduction-to-scope-hoisting-in-webpack-8435084c171f)
- [📦 Parcel v1.9.0 — Tree Shaking, 2x faster watcher, and more! 🚀](https://medium.com/@devongovett/parcel-v1-9-0-tree-shaking-2x-faster-watcher-and-more-87f2e1a70f79)

### Mutation Observer

- [Observe changes in DOM using MutationObserver - codeburst](https://codeburst.io/observe-changes-in-dom-using-mutationobserver-9c2705222751)

### CSS Isolation

- [Isolating CSS for Chrome extension - Stack Overflow](https://stackoverflow.com/questions/5145620/isolating-css-for-chrome-extension)
- [javascript - Light DOM style leaking into Shadow DOM - Stack Overflow](https://stackoverflow.com/questions/49709676/light-dom-style-leaking-into-shadow-dom)
- [polymer - Registering a custom element from a chrome extension - Stack Overflow](https://stackoverflow.com/questions/30022350/registering-a-custom-element-from-a-chrome-extension)

### Others

- [Chrome拡張機能をFirefox・Edge(WebExtensions)に移植する際のハマリメモ](https://moondoldo.com/wordpress/?p=1240#Firefoxoptions_pageoptions_ui)