JavaScript
Chrome
chrome-extension
WebExtensions
0

Mouse Dictionary

Chrome拡張の高速な英語辞書ツールをつくりました
https://qiita.com/wtetsu/items/c43232c6c44918e977c9

これ↑を作ったときの工夫とかの話になります。

ブラウザ拡張の開発に関する情報は、ふつうのWebフロントエンドの情報と比較するとなかなか見つかりづらいため、役に立つかもしれないと思いここに書き残しておきます。

ソースコード

https://github.com/wtetsu/mouse-dictionary

おおまかにいうと、ただのWebExtensionsプロジェクトです。が、とくに速度を出すために、ちらほらバッドノウハウ的なものも必要になりました。

基本方針

以下を絶対に守る。

  • とにかく一瞬でルックアップ~表示の更新を完了させる
  • とにかく雑に複数の見出し候補を作成して一度にルックアップする

一瞬さ

どれくらい一瞬かというと、マウスうごかす~小窓の表示更新完了まで、60分の1秒を越えないようにします。また、辞書機能は他サイトの上に追加で表示させるという性質上、60分の1秒を超えなくても、更に早くて負荷が少ないに越したことはない、という感じです。

なお最新版での計測では、テキスト解析~辞書データルックアップ~DOM生成処理まで、実用上数ms以内で完了できているようです。

image.png

雑さ

どれくらい雑かというと、"dealt with it"の上にカーソルを乗せると、dealtを自動的に原型dealに変換し、"deal with"や"deal"もルックアップ候補になります。

image.png

"on my own"の場合は、"on one's own"や"on someone's own"もルックアップ候補になります。

image.png

実際に小窓の説明としてなにが表示されるかは、インポートされている辞書データによって異なります。雑にルックアップした見出し語群の中で、アタリがあったものを優先順位に従って上から表示します。

機能の構成

大まかに2つの機能で構成されています。

  • 辞書参照機能(小窓とその裏の処理)
  • オプション画面

(小窓)
image.png

(オプション画面)
image.png

オプション画面は初期化や動作が少し遅くなったところで問題ではないので、ライブラリ等はとくに遠慮せずに入れています。逆に辞書機能(小窓とその裏の処理)の方は初期化時間も動作速度も最重要な要素のため、ライブラリは極力利用していません(Hogan.jsのみ利用)

主に使ったもの

  • ビルド
    • webpack + BabelとかUglifyとか
  • オプション画面
    • React
  • テンプレート
    • Hogan.js
  • テスト
    • Jest
  • CSS
    • milligram
  • 開発環境
    • VSCode
    • prettier

webpackは、普通のWebのフロントと異なり当初Chromeしか対象にしていなかったので有り難みは限定的かと思ったのですが、結局なにかと大活躍でした。途中からReact使うことにしたり、途中からFirefox対応することにした上にChromeとFirefoxのビルドを分けるハメになったりしたのですが、そのような場合も難なく対応できました。

Reactは、オプション画面を楽に作るために利用しました。カワイイUIコンポーネントライブラリを使いたかったというのも動機でした(結局カラーピッカーしか使っていませんが)

ストレージ

chrome.storageのlocalとsync

  • chrome.storage.local
  • chrome.storage.sync

どちらもキーバリューなのですが、おおまかにという前者はたくさんのデータを格納することができて、後者は容量が限られているものの格納したデータはGoogleアカウント経由で共有することができます。

https://developer.chrome.com/apps/storage

(ちなみにchrome~とありますが、Firefoxでも使えます)

Mouse Dictionaryでは、以下のように使い分けています。

  • localにはインポートした辞書データを保存
  • syncにはユーザ設定や前回の小窓位置などを保存

使い分けの理由は、localにたくさんデータが入っている状態でさらにlocalにデータを追加しようとすると、かなり時間がかかるためです(1件追加するのに数秒かかる)。

一度インポートしたら殆ど変更しないであろう辞書データと比較し、利用中にしばしば更新される可能性があり、かつ小さいデータであるユーザ設定を高速に保存完了できるようsyncに格納することにしたという感じです。バッドノウハウ感はあります。

速度

そんなchrome.storage.localですが、たくさんのデータが入っている状態でも参照は高速です。どのくらい高速化かというと、私の環境では、200万件以上のデータが入った状態で、50件のデータを取り出すのに6msとかで完了できるようです。実用上、一度に50件も取り出す必要はないので、Mouse Dictionaryの目標(1/60秒=約16ms)を考えても十分に高速と言えます。

注意点として、getのAPIがこんな感じなので、50件のデータを引く場合はgetを50回呼ぶのではなく、一回だけ呼ぶようにしましょう。それさえ守れば高速に処理できます。

chrome.storage.local.get(["word1", "word2", "word3"], r => {
  // r.word1 という感じで引いた値を参照できる
});

テキスト参照

前述の通り、辞書データの参照は、見出し語候補が数十個あっても一度に引けば十分な速度が出ることが期待できます。

そのため、Mouse Dictionaryは「多少無駄になってもいいので、ルックアップ候補を雑にたくさん作って一度に引いてアタリだけ表示する」という方針を採用できます。アタリがありそうな候補を慎重に生成するよりも、この雑な方針の方がずっと高速で便利なためです。

ルックアップ候補をつくるというのは、動詞の過去形を原型に戻したり、myをsomeone'sとかone'sにしたりとか、そういうのです。たとえば以下のような感じになります。

  • "dealt with"にカーソルを置く → ルックアップ候補は["dealt with", "dealt", "deal with", "deal"]
  • "on my own"にカーソルを置く → ルックアップ候補は["on my own", "on my", "on", "on one's own", "on one's", "on someone's own", "on someone's"]

当たり前ですが、辞書データの見出しにはふつう"dealt with"とも"on my own"とも書かれていません。"deal with"や"on one's own"なら見出しにある可能性があります。そのため、ルックアップ前に自動的に変換をしています。

image.png

image.png

各ルックアップ候補の優先順位は自明ではないのですが、Mouse Dictionaryは「マウス下にあるテキストそのものを高い優先度とする」ということを基本ルールにしています。よきにはからって変換した方の文字列は、優先度を下げてルックアップします。このルールなら、おかしな順序で説明が表示されると感じる場面はあまりないと思います。

たとえば、dealt withにカーソルを合わせた際に、dealtがdeal withより上に表示されているのは、このルールに従っているためです。

ちなみに不規則動詞は、↓みたいな対応関係を保持して地道に変換しています。

{
  dealt: "deal",
  did: "do",
  done: "do",
  dove: "dive",
  drank: "drink",
  ...
}

やっぱHoganよ

ルックアップして発見した説明文字列からHTMLを生成する際には、テンプレートエンジンを利用しています。

動機:

  • プログラムの見通しが悪くなりがちな適当文字列連結を避けテンプレート化
  • そのテンプレートをユーザ変更可能にすればカスタマイズ機能の提供になる

ユーザ変更可能なので、イタズラできないようにロジックレスな候補を選定しました。

で、速度を計測してみたら(Mouse Dictionaryの用途においては)Hogan.jsが高速で、APIも一度コンパイルした結果を再利用できるようになっていてかつ使いやすかったので、これにしました。もうほぼメンテされていないという点は気になりましたが、とくにセキュリティリスクが報告されているわけでもないので良しとしました。

ちなみに説明テキスト表示部分のテンプレートはこんな感じになっています。これはオプション画面からカスタマイズできるので、多少HTMLの知識があれば、たとえば見出し({{head}})をクリックするとその単語をGoogleで検索する、といったことも可能です。キミだけのMouse Dictionaryを作り上げろ!

<div style="all:initial;">
  {{#words}}
    {{^isShort}}
      {{! 通常の単語 }}
      <span style="font-size:{{headFontSize}};font-weight:bold;color:{{headFontColor}}">{{head}}</span>
      <br/>
      <span style="font-size:{{descFontSize}};color:{{descFontColor}};">
        {{{desc}}}
      </span>
    {{/isShort}}
    {{#isShort}}
      {{! 短い単語 }}
      <span style="font-size:{{headFontSize}};font-weight:bold;color:{{headFontColor}}">{{head}}</span>
      <span style="color:#505050;font-size:x-small;">{{shortDesc}}</span>
    {{/isShort}}
    {{^isLast}}
      <br/><hr style="border:0;border-top:1px solid #E0E0E0;margin:0;height:1px;" />
    {{/isLast}}
  {{/words}}
</div>

複数ブラウザ対応

WebExtensionsはChrome専用ではなく共通規格のようなものなので、FirefoxでもEdgeでも動かすことができます。

...という名目になっているのですが、同じビルドが複数ブラウザでなにもせずに動くとかそんなうまい話があるわけはないと確信していたので、Mouse DictionaryはChrome専用機能として開発していました。その予想は半分合っていて半分間違っていました。

Firefox対応

いくつかプルリクいただいた結果、Firefoxでも動くようになりました。

意外と互換性あるな!という感じで嬉しい誤算でした。ただ、ユーザ設定の保存がいまいち安定できていないので、いまのところFirefoxビルドではユーザ設定は利用できなくしています。

他の落とし穴としては、Firefoxではmanifest.jsonのversionが"0.9.0Beta"みたいな表記も許されるのですが、Chromeだとアルファベットを混ぜることができないというものがあります。

Vivaldi

一切なにもせずにChrome版が動いた。

Edge

Edgeだけなぜかmanifest.jsonのauthorを必須としている。

というかそもそもぜんぜん動かない。対応予定もありません。

ShortCache

一回マウスが通ったテキストと、そこからいろいろ処理して生成したDOMの対応を、短期的にメモリにキャッシュする、ShortCacheという仕組みを動かしています。

これにより、カーソルが同じテキストを複数回通過したときは、ストレージへのアクセスも必要なしに超高速で処理が完了します。

という目的で作った処理だったのですが、そもそもキャッシュなしでも処理が一瞬で完了するので、幸か不幸か期待していたほどの効果はありませんでした。しかし前述の通りMouse Dictionaryの負荷は少なければ少ないほどいいという考えがあるため、この仕組は残しています。

おわりにひとこと

Mouse Dictionaryは「慣れてしまうと、これなしでは英語が読めなくなってしまう」ものではなく、むしろ英語の語彙向上をすごく効率化することができるものだと思っています。わからない語も面倒さなしに辞書がひけるということは、そういう利点があると思います。そういう感じで使っていただけると幸いです。

参考までにですが、私は昔TOEIC280点だったのですが今は900点で、それはMouseoverDictionaryが英語への抵抗を取り払ってくれたことが大きなきっかけになっています。