Help us understand the problem. What is going on with this article?

Chrome拡張機能を開発するときに得られた知見を共有する

More than 1 year has passed since last update.

一週間前に、Chromeの拡張機能の開発を開始しました。
正規表現を使ってページ内を検索する拡張機能ですが、昨日ようやく開発に一区切りがついたので、Chromeウェブストアに公開しようと考えています。
今はGoogleの審査待ち状態です。公開されるのが楽しみです。審査通るかな…。
shinsamachi.png
ということで、開発中に得られた知見を共有するために記事を書くことにします。

コンテントスクリプトが効かないページがある

コンテントスクリプトとは、1つのHTMLページに対応する、JavaScriptで書かれたスクリプトのことです。通常、ページ遷移するたびにコンテントスクリプトが実行されますが、一部動作しないページが存在します。Chrome拡張機能の画面Chromeウェブストア - 拡張機能Chromeの設定画面などです。これらのページではコンテントスクリプトは実行されません。

テストしてみます。manifest.jsonとscript.jsを用意します。

manifest.json
{
    "manifest_version": 2,
    "name": "test",
    "version": "0.0.0.1",
    "content_scripts": [{
        "matches": ["<all_urls>"],
        "js": ["script.js"]
    }]
}
script.js
alert("");

この2つのファイルを同じフォルダに入れ、Chrome拡張機能の画面のデベロッパーモードをONにし、【パッケージ化されていない拡張機能を読み込む】で先ほどのフォルダを選択します。すると自作の拡張機能が実行されるようになります。今回の場合、ページ遷移するたびにalert("")が実行されることになります。

ほとんどのページでalert("")は実行されますが、Chromeウェブストア - 拡張機能などを開いてもアラートは表示されません。ということで、コンテントスクリプトが実行されないページがあることがわかりました。

原因は何か?

わかりません…。manifest.jsonに"matches": ["<all_urls>"]と書いているにも関わらず実行されないのはなぜなのか…。

そういった理由から、僕の作った検索ツールも一部ページには対応しておりません。何とか対応したいのですが、ページの情報を取得・操作できるのはコンテントスクリプトだけなんですよね…。他の2つのスクリプトは動作可能なのですが、この2つではページ情報の取得・操作はできません。お手上げ状態です。

DOM操作はすぐに画面に反映されない

これはChrome拡張機能ではなくブラウザの仕様ですが、DOMを操作したからといって、すぐに画面上の見た目が変わるわけではないです。たとえば、ループを使って10個の要素を追加したとします。追加の間隔は1秒とし、この1秒間はひたすらループさせて実行権を手放さないようにします。

<div id="block"></div>
<script>
let busyWait = () => {
  let start = new Date().getTime();
  while (new Date().getTime() - start < 1000);
}
for (let i = 0; i < 10; i++) {
  busyWait();
  let node = document.createTextNode("a");
  document.getElementById("block").appendChild(node);
}
</script>

このスクリプトを実行すると、10秒間画面に何も表示されず、10秒後に突然10個の'a'が表示されます。ここから、script要素内のスクリプトと、DOM操作を画面に反映させるプログラムは、一つの実行権を共有していることがわかります。

DOM操作を画面に反映させるためには、スクリプトが一度実行権を手放す必要があります。次のように記述します。

<div id="block"></div>
<script>
let count = 0;
let id = window.setInterval(() => {
  let node = document.createTextNode("a");
  document.getElementById("block").appendChild(node);
  count++;
  if (count === 10) {
    window.clearInterval(id);
  }
}, 1000);
</script>

このように記述することで、次に'a'が追加されるまでの1秒間、スクリプトは実行権を手放すことができます。

DOMの内容を画面に反映させるのに物凄く時間がかかる

ここからが本題なのですが、DOMの内容を画面に反映させるのにかなりの時間を要します。これに比べると、配列の途中に要素を挿入するのにかかる時間なんて本当に一瞬です(この部分を高速化しようとして徒労に終わりました…)。

どれくらい時間がかかるかを測ってみます。先ほどの2つのスクリプトを少し書き換えます。

一つ目のスクリプト
<div id="block"></div>
<script>
let start = new Date().getTime();
for (let i = 0; i < 1000; i++) {
  let node = document.createTextNode("a");
  document.getElementById("block").appendChild(node);
}
console.log((new Date().getTime() - start) / 1000);
</script>
二つ目のスクリプト
<div id="block"></div>
<script>
let start = new Date().getTime();
let count = 0;
let id = window.setInterval(() => {
  let node = document.createTextNode("a");
  document.getElementById("block").appendChild(node);
  count++;
  if (count === 1000) {
    window.clearInterval(id);
    console.log((new Date().getTime() - start) / 1000);
  }
}, 0);
</script>

それぞれ5回実行してかかった時間の平均は、一つ目が0.002秒、二つ目が4.044秒でした。2000倍以上の差があります。

この2つのスクリプトの違いは、スクリプトが実行権を手放すかどうかだけです。他に違いはありません。

一つ目のスクリプトは1000個の要素を追加するまで実行権を手放さず、二つ目のスクリプトは要素を1つ追加するたびに実行権を手放します。手放した実行権は「DOMの内容を画面に反映させるプログラム」に渡されることになります。

一つ目のスクリプトではこのプログラムが一度だけ実行され、二つ目のスクリプトでは1000回実行されることになります。それが2000倍の差として表れます。いかに「DOMの内容を画面に反映させるプログラム」が時間のかかる処理かがわかります。

このような事実があることはわかりました。これから、この知識をどのように活用するかを考えていこうと思います。

追記

二つ目のスクリプトに4秒以上かかる理由は、setTimeout(..., 0)を連続的に呼び出したときに4ミリ秒の遅延が発生するためでした。DOMの内容を画面に反映させるのに膨大な時間を消費しているわけではありませんでした…。setTimeout()の仕様について、詳しくはこちらの記事にまとめています。

検索過程が見えることの重要性

実行権を手放さないことで大幅な速度改善が見込めることはわかりましたが、だからといって、現実だとそれが最善の選択というわけではないっぽいです。というのも、正規表現による検索は時間のかかるものであり、ページのボリュームと正規表現によっては1秒以上かかることも普通にありえます。そうなると、検索ボタンを押してから1秒以上フリーズすることになります。

1秒以上フリーズしている画面を眺めていても楽しくありませんし、本当に検索されているのか不安になります。だからといって、CSSで簡単なローディングアニメーションを実装したとしても、本当に検索されているのかという不安は解消されません。何か別の処理が行われているという可能性も捨てきれないです。

というわけで、検索過程を見せることが重要になってきます。そして、検索過程を可視化するためにはDOMの操作が必要です。しかし、DOM操作には時間がかかるし…といったジレンマがあります。

ということで今回は、2つの間を取ることにしています。

DOM要素を一つ追加するたびに画面に反映していては時間がかかりすぎてしまい、まったく反映しなかったらフリーズしてしまう。この2つの間というのは2通りあります。

  • チャンクという概念を導入する。DOM要素を一定個数(10個とか100個とか)追加するたびに画面に反映させる
  • FPSという概念を導入する。一定の時間間隔でDOMの内容を画面に反映させる

前者は面白みに欠けます。というのも、今の実装では画面に反映された要素数がリアルタイムで検索バーの右端に表示されるのですが(下の画像の赤枠参照)、一定個数だと増えていく数字も一定なので面白くありません。得られる情報は「10ずつ増えていってるなー」くらいです。一桁目がずっと0なのもあまり面白くないです。
search-bar.png
だからといって、増えていく数字をランダムにしたとしても、なんだかそれも少し違う気がします。数字が更新される間隔が一定ではないので、眺めていても得られるものは少なそうです。

というわけで今回は後者の実装にしました。一定の時間間隔で数字を更新することで、今ちょっとペース遅いなとかそういったことがわかります。数字の増え方もランダムに近くて面白みがあります。今99増えても、次は103増えるといったように。そして、これらの数字は決してランダムではなく、常に一定間隔で更新されるため有用な情報も得られます。

こういったことを考えながら開発しました。けっこう楽しいです。

おわりに

途中から「得られた知見を共有する」というよりはただの開発手記になってしまいました(笑)Qiitaという場にはあまりふさわしくない投稿かも…。

Githubに今回開発した拡張機能のソースコードがあるので、もしよろしければ見てみてください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away