Edited at

Mouse Dictionaryの技術的な話


Mouse Dictionary

Chrome拡張の高速な英語辞書ツールをつくりました

https://qiita.com/wtetsu/items/c43232c6c44918e977c9

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

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


ソースコード

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

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


基本方針

以下を絶対に守る。



  • とにかく一瞬でルックアップ~表示の更新を完了させる


  • とにかく雑に複数の見出し候補を作成して一度にルックアップする


一瞬さ

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

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


雑さ

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

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

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


機能の構成

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


  • 辞書参照機能(小窓とその裏の処理)

  • オプション画面

(小窓)

(オプション画面)

オプション画面は初期化や動作が少し遅くなったところで問題ではないので、ライブラリ等はとくに遠慮せずに入れています。逆に辞書機能(小窓とその裏の処理)の方は初期化時間も動作速度も最重要な要素のため、ライブラリは極力利用していません(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"なら見出しにある可能性があります。そのため、ルックアップ前に自動的に変換をしています。

各ルックアップ候補の優先順位は自明ではないのですが、Mouse Dictionaryは以下のルールで作っています。


  • マウス下にあるテキストそのものを高い優先度とする

  • 単語が長い表現の方を高い優先度とする

よきにはからって変換した方の文字列は、優先度を下げてルックアップします。その上で、長い表現このルールなら、おかしな順序で説明が表示されると感じる場面はあまりないと思います。

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

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

{

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


日本語

英語を引くときをほど気は利いていないと思いますがいちおう日本語もいけます。

「多く」から「多く」「多い」「多」と、活用もまあ扱えます。これはdeinjaを利用して実現しています。まあこれも私がつくったやつですが…。(いい感じの既存ライブラリが見つからなかった)


HTML生成


やっぱHoganよ

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

動機:


  • プログラムの見通しが悪くなりがちな適当文字列連結を避けテンプレート化

  • そのテンプレートをユーザ変更可能にすればカスタマイズ機能の提供になる

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

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

ちなみに説明テキスト表示部分のテンプレートはこんな感じになっています。これはオプション画面からカスタマイズできるので、多少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>

イメージ検索や類語検索に飛ばす例:

https://github.com/wtetsu/mouse-dictionary/wiki/HTML-templates


複数ブラウザ対応

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

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


Firefox対応

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

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

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


Vivaldi/Opera/Brave

特別なことは一切なにもせずにChrome版が動いた。


Edge

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

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

※ChromiumベースのEdgeでは問題なく動きます


Safari

Safari拡張は互換性ぜんぜんない模様。対応予定はないです。

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


その他


プレビュー画面

Reactでつくったオプション画面も、軽快に動くように工夫しています。

テンプレートを編集するとリアルタイムでプレビューが変更されます。ただ、キーボードを高速に叩いた時など、1キーごとにプレビューを更新するのは無駄なので、高頻度で変更した場合は、そのうちの最後の一回に更新が入るようになっています。これはdebounceで実現しています。


Cross-extension messaging

iframe対応のために、Cross-extension messagingを活用しています。詳細はこちら


resize/draggable

小窓を移動したりリサイズしたりする仕組みです。仕組みと行っても既存の軽くていい感じのライブラリは見つからなかったので、完全に自前で実装しています。自前で書いた分、自分の好きなように挙動をつくることができました。実は端っこをダブルクリックでワープする機能とかもあります。


ShortCache

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

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

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


おわりにひとこと

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

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