Mar 08, 2024
Tree Style Tab 4.0でのパフォーマンス改善に至るまでの17年の歴史を振り返る
先だって、Firefox用の縦型&ツリー表示タブバーアドオンであるTree Style Tab(以下、TST)のバージョン4.0をリリースしました。 このバージョンではサイドバーパネルの設計を大きく変更し、タブの数が多い場面での動作パフォーマンス(消費メモリー、CPU消費、体感速度)が大幅に向上しました。 参考値として、作者環境(Windows 11、Firefox 122.0.1、タブの数536個)で、Firefoxを起動しセッションを復元した直後、TSTも初期化完了した時点でのabout:memoryで計測したTST関連リソースの消費メモリー量は以下の通りとなっていました。
TST 3.9.22 | TST 4.0 | TST 3.9.22→TST 4.0の消費メモリー削減割合 | |
---|---|---|---|
メインプロセス | 10.87MB | 6.62MB | 39.1%減 |
拡張機能プロセス | 143.92MB | 83.35MB | 42.1%減 |
自分の主観的には、タブを開いた時やツリー開閉時などのもたつきも軽減され、体感的な快適さは大きく向上した印象があります。 タブの数が数千個に及ぶような状況や、Firefoxのプロセスが長期間生存する状況では、メモリー消費量の点でも体感的な速度の点でも、もっと顕著に効果が表れるのではないかと思います。
この改善のために、今バージョンでは前の版に比べて、CSSでのカスタマイズやヘルパーアドオンとの互換性が一部損なわれています。 既知のヘルパーアドオンについては問題無さそうなことを一通り確認済みですが、僕の把握していない物は動かなくなってるかもしれません。
なお、後述しますが、今回のTSTの改善はWaterfoxプロジェクトからの支援によって実現されました。 この場を借りて、プロジェクト主催のAlexさんに感謝の言葉を述べさせて頂きます。 改めて、ありがとうございます!
パフォーマンス改善のためにやったことの要旨
何が変わったかというと、端的に言えば「UIを仮想スクロールに切り替えた」の一言に尽きます。
TST 3.xまでのバージョンでは、Firefox上で開かれているタブに1対1で対応するHTML要素をDOMツリー上に保持していて、折りたたまれているタブや非表示のタブの分も、DOMノードが常に存在していました。 TST 4.0からは、最大でもサイドバーの高さの3倍(表示中の物に加え、前後1ページ分ずつのスクロール待機用)までしかDOMノードを保持せず、描画範囲から遠く離れたタブはDOMツリー上には存在しないようになっています。
DOMインスペクターを横に置いてスクロールしてみると、実際にDOMノードがダイナミックに置き換わっているのを見て取れます。
TST 3.xまでの版では、DOMノードの数の多さに起因する以下のようなパフォーマンス低下が発生しやすい状態でした。
- 表示されていないタブのアイコン画像のdata: URL(画像データがBase64の文字列になっている)が無駄にRAMを消費していた。
- サイドバーの開閉時の初期化処理時間短縮のために、構築済みのサイドバーの内容(HTMLのソース文字列)をキャッシュとして保存していたが、タブの数が多くなるとそのキャッシュもどんどん大きくなって、RAMの使用量やJSON文字列へのシリアライズに要する時間、ストレージのI/Oなど色々な面でFirefox全体の動作にまで悪影響を及ぼしていた。
- タブの数=DOMノードの数が多いと、CSSのセレクターにマッチする要素の探索や再描画にかかる時間も比例して増大していたっぽい。
- 総じて、画面に実際に表示される機会がほとんどない物のために、多くのリソースが無駄に費やされていた。
TST 4.0以降では仮想スクロール化の恩恵により、保持する物理的なDOMノードの数が大きく減ったため、このようなことが起こりにくくなっています。
ちなみに、僕が把握している範囲のTSTと同様の用途のアドオンを調べてみた限りでは、仮想スクロール方式を導入している例はTSTの他にはまだ無いようでした。 ただ、これはTSTが先進的というよりは、こういうテクニックでも使わないとまともな性能が出ない程度にTSTの基本的な設計が宜しくないだけで、他のアドオンはこういう技術に頼る必要が無い、と言った方が多分正しいのだと思われます。
そもそも、「仮想スクロール」は先進的どころか枯れた技術の部類です。 TSTに導入すれば確実にパフォ-マンスが向上するので、何年も前からやりたいとは思っていました。 なのに、なぜ今までやれなかったのか。 TSTの歴史的に、仮想スクロールと相性が悪い設計になってしまっていたのと、仮想スクロールに関して僕が大きな誤解をしていたのとが、その理由です。
仮想スクロールを導入しにくい設計から、導入しやすい設計への変更の歴史
データモデルとUIの分離という大前提
「JavaScript 仮想スクロール」で検索して上位に出てくる記事を見ると分かりますが、仮想スクロールが使われるのは、大量のデータを表で見るような場面が多いようです。 表形式でのデータ表示のためのJSライブラリを色々紹介しているjspreadsheetsでも、ライブラリ紹介のページに「Virtual Scroll」とか「Virtual DOM」といったキーワードがよく挙がっています。 僕自身も会社の業務上で、「大きな企業で全社規模のアドレス帳を組織構造を伴って共有したい」というご要望からThunderbird用のアドレス帳アドオンを3年ほど前に作成した際に、jspreadsheetsで紹介されていたライブラリの1つだったTabulatorを使いました。 最近のバージョンのThunderbirdも、スレッドペイン(メールの件名や宛先が表形式で列挙されている画面)がJSでの仮想スクロール実装になっています
これらの例で共通するのは、「一定の形式の大量のデータが先にあって、それを一覧で見るためのビューとして表(スプレッドシート)がある」ということです。 仮想スクロールは、そのような場面での高パフォーマンスなUIの実現方法として、それこそ何十年も前から使われてきました。 Excelのような表計算アプリは言わずもがなですし、FirefoxでもC++による仮想スクロール実装(XUL tree)が20年以上前からブックマーク・履歴の管理画面やサイドバーで使われています。 Thunderbirdも、JSでの仮想スクロール実装に切り替わるまではXUL treeを使っていました。
ちなみに、最も高速な例では(少なくともXUL treeでは確実に、恐らくExcelのような表計算アプリも昔のバージョンは)、データを表示するための枠だけあらかじめ決まった数だけ用意しておき、スクロール位置に応じて枠に流し込む内容だけを切り替えて、非常に高いパフォーマンスを実現しています。
特に工夫していなければ、スクロール位置が行単位でしか変わらない(0.1行分とか0.2行とかの単位では動かせない、スムーズスクロールができない)という制約が生じるので、このような実装は見た目ですぐに判別がつきます。
本来なら、WebExtensions APIベースの拡張機能では、仮想スクロールのUIを簡単に作れるはずです。
というのも、WebExtensions APIでは個々のタブの情報をtabs.query()
などのAPIを介して、tabs.Tab
という形のオブジェクト、一種のデータモデルとして得る形になっており、そのデータモデルを操作するUIをどのようにして用意するかは、アドオン作者の自由に任されているからです。
ブックマークや履歴、Thunderbirdにおけるメールやアドレス帳などを仮想スクロールで表示できるなら、同じ要領でタブも仮想スクロールで表示できて当然というわけです(そして、TST 4.0ではまさにそうしています)。
じゃあ最初からそうしてればよかったじゃんという話なんですが、TST 3.xまでのバージョンでは、それこそバージョン0.1や、前身となったTabbrowser Extensions(以下、TBE)の頃からの設計思想をずっと引きずり続けていたために、データとUIを切り離しきれずににいました。 この背景事情として、まず先に、Firefox自体のUIがどのように実装されている(た)かを説明します。
FirefoxのUI部分は、データモデルとUIが一体になっている(た)
FirefoxのUIはそれ自体がXML/HTMLとCSSとJSで記述されていて、アドレスバーやツールバーボタン、タブなどの画面上のUIウィジェットは、それぞれがHTMLまたはXULの要素としてDOMツリー上のノードになっています。
ここで重要なのは、FirefoxのUIレイヤーにおいては「データモデルとウィジェットが一体となっている」「XUL要素自体がウィジェットとデータモデルの両方の役割を兼ねている」ということです。
これは、Firefoxの前身のMozillaブラウザー(後のSeamonkey)が、今のElectronのような地位の「Webベースアプリケーション開発基盤」をも目指していたことの影響と言えるでしょう。 XML要素を書くだけで高機能なウィジェットを自由に組み合わせられれば、アプリケーションの開発は(少なくとも初期開発段階は)容易になります。 現代のWebアプリでは、HTML要素を置くだけでカラーピッカーでの色選択やカレンダーからの日付選択といった機能を持つウィジェットとして動作しますが、XULはそれをもっと推し進めたような物でした。
当時のFirefoxのUIは、「切り替え可能なタブ(<tab>
)」や「自動スクロール可能なボックス(<arrowscrollbox>
)」などの基本ウィジェットを組み合わせて、「タブを開くよう要求されたら<tabbrowser>
配下の<tabs>
(タブバー)に<tab>
を、<tabpanel>
(タブに対応するコンテンツ部分のコンテナー要素)に<browser>
(<iframe>
の強化版)をそれぞれ生成して追加する」といったことを行い、タブブラウザーとしての振る舞いを実現していました。
また、XUL要素のウィジェットの振る舞いの一部は、以下のようにCSSでの工夫によっても実現されていました。
<tabs>
(タブバー)にタブが増えたときはoverflow:hidden
を適用し、溢れた部分を非表示にしつつ内容をスクロール可能にする。- ピン留めしたタブは、
position:fixed
で通常フローから切り離して左端に寄せる。- 複数のピン留めしたタブは、それぞれに異なる
margin-left
を設定して表示位置をずらす。 - ピン留めしたタブが他のタブに重ならないよう、ピン留めされなかった他の通常タブは、親要素の
padding-left
で右側に寄せる。
- 複数のピン留めしたタブは、それぞれに異なる
今でも、FirefoxのUIの振る舞いを変えるカスタマイズ方法手法として userChrome.css
が使われる事がありますが、CSSでブラウザーの振る舞いまで変えられるのは、Firefoxがこのような設計となっているためです。
当時のTBEやTST 0.xのような「XULアドオン」は、Firefox自身のJSの名前空間に自前のJSコードを読み込ませて、Firefox内部のJSの関数やDOMノードが持つメソッドを置き換えたり、XUL要素に適用するCSSの指定を上書きしたりしていました。 TST 0.xの「タブのツリー表示機能」も、以下のようなことをして実現していました。
<tab>
の親要素の属性値を変更し、<tab>
を並べる方向を水平方向から垂直方向に切り替える(今でいうと、Flexboxのflex-direction
をrow
からcolumn
に変更することに相当)。- ツリーの親子関係の情報を、
<tab>
の属性値やDOMノードのカスタムプロパティで保持する。 - ツリーの親子関係のインデントの深さを、
<tab>
のmargin-left
で示す。 - ツリー構造の情報に基づいてタブを特定する際は、
querySelector()
(CSSセレクター)やDOM3 XPath(XPath式)で<tab>
を探す。
「Web技術の簡単な知識があれば、アプリケーションを開発したり改造したりカスタマイズしたりできる」のが、この当時からのFirefoxの特徴でした。
しかし、この設計には「データモデルとウィジェットが一体であるために、ウィジェットの一般的な振る舞いを超えたことをやりにくい」という欠点もあります。
先程「ピン留めしたタブはCSSでの工夫で実現されている」旨を述べましたが、これは別の観点から言えば、「<tab>
を束ねる親要素は<tabs>
でなくてはならず、スクロールさせたくないピン留めタブだけを別の親要素の配下に移動して分けることはできない、という制約があるので、CSSを駆使してそれらしい振る舞いを無理矢理実現するしかなかった」という話でもあります。
また、このような設計は仮想スクロールの導入を難しくする一因にもなります。
<tab>
はデータモデルを兼ねているため、DOMツリーから気軽に削除するわけにはいかず、「スクロール位置に合わせてウィジェットを動的に追加・削除する」仮想スクロールとの相性がすこぶる悪いのです。
<tab>
へのアクセス方法を制限し、DOMノードをDOMツリーから切り離した後もデータモデルとして参照し続けられるよう慎重に設計を行えば、そのようなことも実現可能かもしれません。
ただ、DOMツリーから切り離した<tab>
はデータモデルとしては無駄が多く、メモリーを多く消費してしまいますし、イベントの伝搬やノードの検索といったDOMならではの便利機能がほぼ使えないため、敢えてそのようにする意義はあまりないと考えられます。
TST 3.xまでのバージョンで仮想スクロールを導入できずにいたのは、FirefoxのUIのこのような制約が、TSTのUIにも意図せず引き継がれてしまっていたからだと言えます。
(なお、ここで述べた「ウィジェットとデータモデルが一体化された設計」は、最近のFirefoxでは少しずつ改められつつあるようで、それまではXUL要素のウィジェットだった物が、純粋なJavaScriptのオブジェクトとして実装し直されていたりします。Firefox 123時点では、<tab>
は現在もXUL要素のままですが、<tabbrowser>
という要素はDOMツリー上にはもはや存在しておらず、当時のそれとインターフェース的な互換性を持つgBrowser
というオブジェクトによって置き換えられています。)
TST 2.0からTST 3.xまでの間での設計の変遷
XULアドオンだったTST 0.xは、前述したFirefox自身のUIの設計からくる制約をモロに受けざるを得ませんでしたが、WebExtensionsアドオンではそのような制約はありません。
WebExtensionsでは、タブを操作する際はAPI経由でtabs.Tab
のオブジェクトを取得しますが、これはFirefoxのUIウィジェットそのものではなく、その時点の<tab>
の状態のスナップショットに過ぎません。
このtabs.Tab
というデータモデルをどのようなUIで表現するかは、完全にアドオン作者の自由です。
今思えば、このWebExtensionsへの移行時が、仮想スクロール導入の最大のチャンスでした。
ですが、当時(2017年)の僕は発想がまだまだXULアドオン時代の考え方=Firefox本体の設計の在り方に囚われていて、そんなことは思いつきもしませんでした。
WebExtensions移行の顛末記では「従来提供していたユーザー体験の再現を目指して、WebExtensionsベースで新しくアドオンを開発し直す」と書いていましたが、実際の所は、「WebExtensions APIらしいやり方でソフトウェアをゼロから開発し直した」わけではありませんでした。
詳細はTST 3.0リリース時の振り返りに書いていますが、TST 2.0時点では、Firefoxのタブの状態を取得したりタブの状態を指定したりするためのインターフェースとしてWebExtensions APIを使用してはいたものの、それ以外の部分は、Firefoxの<tabs>
代わりの<ul class="tabs">
や<tab>
代わりの<li class="tab-item">
など、FirefoxがXULで実装していた「データモデルと一体化したウィジェット」をHTMLで再現する形で実装していました。
なので、ツリー構造に基づいて子タブを特定したり兄弟タブを特定したりするときはquerySelector()
やDOM3 XPathを使っていましたし、ピン留めされたタブも、position:fixed
を使ったCSSでのハックで実現していました。
2019年のTST 3.0での大規模改修では、分散型・協調動作型の設計を中央集権型の設計に改めると同時に、データモデルとしてのtabs.Tab
をウィジェットとしてのHTMLから引き剥がして、なるべくtabs.Tab
の状態のまま取り扱うようにしました。
しかしながら、この時点においても、UI部分であるHTMLの構造についてはほぼ変更が無く、UIウィジェットがデータモデルを兼ねていた頃の構造をほぼそのまま維持していました。
これは、「データモデルの取り扱い」と「UIの構造」の両方を一度に変更して収拾が付かなくなってしまうことを避けるための保守的・防衛的な判断という側面もありましたが、根本的にはやはり、僕自身の「発想がXULアドオン時代の実装の仕方に囚われていた」部分が大きかったのだと思います。
(なんせ、GUIを伴うアプリケーションの開発経験がほぼ無かったところから、データモデルとウィジェットが渾然一体となったXULアドオンの開発を手がけるようになり、そのまま活動領域を変えずに18年以上も開発を続けていたのですから……)
また、データモデルの機能の大部分はtabs.Tab
に切り離せたものの、タブの表示位置や大きさといった情報は依然としてDOMノードがなければ得られず、タブのウィジェットであるHTML要素をDOMツリーから気軽には削除できない、中途半端な状態となっていました。
フレームワークへの載せ替えに伴うリスクとコスト
データモデルの機能の大部分をウィジェットから切り離したことにより、技術的にはTST 3.0の時点で、UIを自前のHTMLからReactなどの既製フレームワークに載せ替えることも可能な状況となっていました。 既製の著名フレームワークであれば、仮想スクロール機能が最初から含まれていたり、そうでなくても既にプラグインやライブラリーが存在していたりしますし、データモデルをウィジェットに反映するコストを最小化する「仮想DOM」などの技術を含む、技術Webアプリ開発の最前線で多くの人によって揉まれた既製フレームワークなら、個人の我流で作ったUIよりも性能がよくなることにも期待できるでしょう。
しかし、実際にはTST 3.0時点では、フレームワークへの載せ替えは成されませんでした。 「自分自身が著名フレームワークの素人で、使い方が分からなかったから」という理由もありますが、それ以前に、フレームワークの「後載せ」は非現実的と考えたからです。
そもそもフレームワークとは、「そのフレームワークが用意した道に沿って進めば、スタートからゴールまで迷わずに辿り着ける」という性質の物です。採用する場合の理想的な進め方は
- データモデルを用意する前の時点で採用フレームワークを決定しておき、
- フレームワークが想定する構造でデータモデルを用意して、
- フレームワークが用意している機能を活用してUIを作る。
といった要領で、僕のあまり多くない経験上、フレームワークの敷いた道から外れる部分が増えれば増えるほど泥沼化してしまいやすいと考えています。 然るにTSTは、
- どのフレームワークを採用するか、何も考えずに作ってきた。
- TST 3.0時点でのウィジェットからの引き剥がし時点で、我流のデータモデルの作り方をしてしまっており、どのフレームワークも想定していないであろうデータモデルになっている。
- 「フレームワークでどういう見た目・どういう振る舞いの物を作れるか」ではなく「Firefox本体のUIの見た目・振る舞いをいかに違和感なく再現したUIを作るか」という方向で考えて作っているため、フレームワークが用意する見た目・振る舞いを使うつもりが薄い。
と、泥沼化しやすい要素しかありません。 「TST 3.0として作成した物を一旦すべて捨てて、データモデルの設計からすべてフレームワーク前提でやり直せばいい」と言ってしまえばそれまでですが、TST 2.0での基盤の載せ替え、TST 3.0での大規模な改設計を行った後の僕には、そこまでの大仕事をやる気力は残っていませんでした。
それに、元々の出発点に立ち返れば、TSTは「自分が使うために作っている」アドオンです。 何千個という、自分ではまず開かないような極端に大量のタブを開いたときの性能を改善するために、積極的にすべてをなげうつ動機は僕にはないのです。
「仮想スクロールの導入まであとひと息」という所までは来ていたのに、最後のステップでつまずいたまま、気付けば5年が過ぎていました。
仮想スクロールの導入(ついに)
Waterfoxプロジェクトによる支援
事態が動いたきっかけは、Waterfoxプロジェクト(というか主催のAlexさん)から「Waterfoxへの縦型タブ導入に協力して欲しい」という趣旨の依頼を頂いたことでした。
最初は僕個人宛にオファーを頂いたのですが、少なくない金額が絡むであろう国際取引を個人でやるのは不安がありましたし、漫画連載の合間を縫ってだと充分な時間を確保できない恐れもあり、業務時間で腰を据えて取り組めないかと自分の所属会社と先方の両方に相談したところ、無事に会社の案件として受注し、その担当者として作業に取り組む運びとなりました。
そして、予算(工数)と期間の範囲内で行える現実的な方法として「今あるTSTを出発点にする」のを有力な案として挙げた際に、ここまでですでに述べたDOMノードの多さに起因する性能問題を棚上げしたまま成果物を納品することには、一人の技術者としてためらいを感じました。 作るなら、UIは仮想スクロールを前提とした物を作るのが大前提だと思いましたし、うまくいけば、その成果を今あるTSTにも取り込めるかもしれません。 そう考えて「TSTベースにしつつ、UIは仮想スクロールで作り直す」という案を先方に提案したところ、ありがたいことに、これも無事にご快諾頂けました。
この記事はあくまでTST側の改良点について説明する趣旨なので、このあたりの事は別途、会社のブログに詳細を書きます(2024年3月13日追記:書きました)。 ともかく、取引先にも所属会社にも恵まれているということを強く実感した次第でした。
誤解の解消
そうして始めたUIの改善作業でしたが、当初は「既成フレームワークの上で縦型タブのUIをゼロから作り直す」ことを考えていたものの、改めて仮想スクロールの実装を学び直すために検索で見つけたBuild your Own Virtual Scrollという記事を読んでいるうちに、「UIを全面的に作り直さなくても、案外、今の実装に少し手を加えるだけで仮想スクロールを導入できそうかも……?」と考えが変わってきました。
そもそも、なぜ僕が「フレームワークを使った全面的な作り直しが必要だ」と思っていたかというと、「仮想スクロールとは、HTML要素を『使い回す』物である」と思い込んでいたからでした。
僕が仮想スクロールの概念を知ったのは、過去の案件でSencha TouchというJSフレームワークを使ってTwitterのような「画面を無限にスクロール可能なWebアプリ」を実装しようとした時でした。 その当時の技術解説を読んだ僕は、無限スクロール(仮想スクロールの一種と言える)を「エスカレーターのステップのように、スクロール対象の項目のHTML要素が画面外に隠れた時点で要素の表示位置を反対側に移動して、同じ要素に別の内容を流し込んで再表示する。これにより、HTML要素の生成というハイコストな処理を最初の一回だけに留める」技術だと受け取りました。
自分の頭でそんな物を実装しきれる自信は全く無く、それ故にフレームワークに頼らざるを得ない、と考えていた訳です。
しかし、前出の記事には「HTML要素の使い回し」についての記述は一切なく、単に項目が画面内に入るタイミングでHTML要素を生成することと、画面外に出るタイミングでHTML要素を削除することだけが書かれていました。
どうやら、「HTML要素の使い回しまでやる」というのは仮想スクロールの必須要件ではないようだと、この時やっと気付いたのでした。
客観的に見れば、「そんな簡単なことになぜ今まで気付いていなかったのか? 勉強不足すぎでは?」と呆れかえるところだとは思います。 各月化したとはいえ解説漫画の連載の〆切に追われ、それ以外の余暇の時間にも色々とやりたいこと・やらなくてはいけないことがあり、という状況で、過去の誤解から仮想スクロールを実装が難しい物だと思い込んでいた自分には、重い腰を上げられずにいました。 「そのような停滞を脱し、業務として腰を据えて調査と開発に取り組む機会をもらえた」ということが、今回の「Waterfox案件」の一番ありがたい点だった、と僕は思っています。
実装
HTML要素を使い回す必要がないのなら、TST 3.0の時点でデータモデルとウィジェット(HTML要素)の切り離しは進んでいたので、後は
- タブが描画対象になるかどうかの条件(完全に折り畳まれたタブは描画しないが、畳むアニメーション効果の適用中は描画し続ける、など)を明確化する。
- HTML要素の生成タイミングを「タブが開かれた時(
tabs.onCreated
)」ではなく「スクロール位置から求めた描画範囲に入った時」に変える。 - HTML要素の削除タイミングを「タブが閉じられた時」ではなく「スクロール位置から求めた描画範囲の外に出た時」に変える。
- タブに起こった変更をHTML要素に反映する処理について、HTML要素が未生成なら処理をスキップするようにする。
- ピン留めされたタブは、描画対象のタブの算出の邪魔になるので、完全に別のコンテナー要素配下に移動してしまう。(タブの前後関係などはすでにJSのオブジェクトだけで判断するようになっていたため、ピン留めされたタブのDOMノードを通常のタブのDOMノードと同じコンテナー要素内に置いておく必然性は、もはやなくなっていた。)
といった変更だけで仮想スクロールを実現できます。 そうして目算が立ったところで作業を開始し、実際に2日ほどで、仮想スクロールが動く状態に到達できました。
工夫した点としては、バックグラウンドページ側とサイドバー側との間でのタブの同期の乱れを解消するために導入し、それ以後も度々使ってきた、diffのアルゴリズムの利用が挙げられます。
今回採用した単純な仮想スクロールの実装では、スクロール位置に応じて「描画範囲に入ったのでHTML要素を生成・描画する」「描画範囲から出たのでHTML要素を削除する」処理が必要です。 単にスクロールするだけなら、HTML要素群の先頭・末尾だけを調べて判断すればいいのですが、TSTでは「ツリーの開閉」という要素があります。 一連のHTML要素群の中間でもDOM要素の生成と削除が必要になるため、ナイーブに実装すると、表示範囲内だけとはいえ毎回タブを全件走査しなくてはいけません。
そこでTSTでは、描画処理をなるべく高速にするために、以下のことを行っています。
- 各タブの状態(折りたたまれているかどうか、閉じられている最中かどうか、ピン留めされているかどうか、など)が変化したときに、そのタブが「あとは描画範囲に入ったことを確認できさえすれば、HTML要素を生成・描画する対象になる」かどうかを見て、内部的に保持している「描画対象候補のタブ」の一覧を更新する。
- スクロール位置が変化したら、「新たなスクロール位置」「タブ1つあたりの高さ」「ビューポートの大きさ」をヒントに「描画範囲の最初のタブ」と「描画範囲の最後のタブ」を特定し、描画対象候補のタブ一覧から始点~終点間のタブを抽出して、「今回の描画対象のタブの配列」を用意する。
- 「前回HTML要素を生成したタブのidの配列」と「今回の描画対象のタブのidの配列」の差分を求める。
- 求めた差分情報に基づいて、HTML要素の削除と生成を行う。
- 「今回の描画対象のタブの配列」を、次回と比較するための「前回HTML要素を生成したタブのidの配列」として保持する。
このような「差分を検出してDOMツリーの変更を最小限に留める」やり方は、仮想DOMアーキテクチャの特長の1つだと聞いています。 著名なフレームワークはこういう事をいい感じにやってくれるのでしょうが、先述した通り、そのようなフレームワークを効果的に使うには、データモデルの用意からUIのデザインまでフレームワークに合わせて作り直す必要があります(多分)。 今回は既存のデータモデルやUIを可能な限りそのまま使うために、差分更新という要素技術をピンポイントで適用したよ、というのがここで言いたかったことでした。
こうして、タブの数が何千個あってもUIの構築は短時間で終わるようになりました。 そのため、「構築済みのHTMLをそのまま保存しておく」方針だったサイドバーコンテンツのキャッシュ機能は、役目を終えたと判断し完全に廃止しました。
副次的な恩恵としての最適化
仮想スクロールの導入でボトルネックが解消されたことにより、当初は想定していなかった副次的な改善もいくつかありました。
- まず、「表示のボトルネックを解消したのにまだ遅い」という形で、それ以外の箇所の最適化の余地が炙り出されました。 ブラウザーツールボックスでFirefox込みのパフォーマンスプロファイルを取ってみて、引っかかった箇所をあちこち改めたところ、当初はそれほど手を入れるつもりがなかったバックグラウンドページ側にも色々と変更が入りました。
- ヘルパーアドオンがあると極端に遅くなる場合があることも分かりました。 これは、ヘルパーアドオンへのメッセージ送出時に行っていた、ツリー構造の情報を送出先アドオンの権限ごとに合わせる処理のオーバーヘッドのせいでした。 可読性重視で実装したことでオーバーヘッドが大きくなっていたため、可読性の点では後退する変更が必要でした。
- アニメーション効果がコマ落ちせず適用されるくらいに高速になった結果、今まではコマ落ちの影に隠れて観測できなかった「別々の箇所で同じ物を触って似たようなことをしていて、片方がもう片方の効果を打ち消していた」不具合もいくつか表面化しました。 これも、衝突していたコードを整理して都度修正しました。
UI要素を愚直に生成していたときは「どうせこんなクソ設計だから、パフォーマンスが上がらないのは仕方ないでしょ」という言い訳を自分の中でできてしまっていたために、それ以外の問題点までまとめて視界の外に追いやってしまっていました。 自分を甘やかさないためにも、ソフトウェア開発においてはやはり「速さは正義」は真理なのだな……と改めて実感した次第です。
タブの「貼り付け」機能
また、これは仮想スクロールと直接の関係はないのですが、アクティブなタブがスクロール範囲外にあるときの使い勝手を向上する新機能として、「タブをタブバーの端に貼り付ける」機能も追加しました。
元々僕は、開いているタブが多いときにアクティブなタブの位置を見失ってしまいやすく、それでわざわざTST Active Tab on Scroll Barというヘルパーアドオンまで作っていたほどなのですが、そもそも何故そんな機能を欲していたのかの動機を改めて考えてみて、
- 今見ているタブのタイトルとURLを(アドオンの機能を使って)コピーするために、アクティブなタブの上でコンテキストメニューを開きたい(が、タブがスクロールアウトしてしまっていて見失った)
- 今見ているタブを読み終わったので閉じたい(が、タブがスクロールアウトしてしまっているし、左手が塞がっていてキーボードショートカットをすぐには入力できない)
ということがストレスだったのだと気付きました。つまり、アクティブなタブが「画面内にいないことがある」のが問題だったわけです。
現状においても一応、タブをピン留めしておけばタブを常に視界に留めておけるのですが、「タブの位置や振る舞いはFirefoxのタブと同期させる」方針のFirefoxでは、ピン留めしたタブはツリーから切り離されてしまいます。 「ツリー構造は維持したい」「でも、タブを視界に留めておきたい」という2つの要求を同時に満たすには、別の解決策が必要です。
そこで思い出したのがCSSのposition:sticky
だったのですが、実際に試してみたところ、これはやりたいことを実現するのには残念ながら不向きでした。
position:sticky
を使うにはコンテナー要素をoverflow:hidden
ではなくcontain:paint
に設定する必要があり、他に表示したかった要素が表示されなくなってしまったからです。
よって最終的には、タブが描画範囲外から外れたらHTML要素をタブバーの上下端に重ねた別のコンテナー配下に移し、描画範囲に戻ってきたらHTML要素を元の位置の方に戻す、という物理的なDOMツリーの編集で実装することにしました。 当初はアクティブなタブのみを自動的に貼り付ける動作にしていましたが、この実装方法の副次的なメリットとして、複数個のタブも簡単に配置できることから、コンテキストメニューでの操作などで任意のタブをピン留めと同様の間隔で「タブバー端に貼り付け」られるようにもしました。 (逆に、アクティブなタブを自動的に貼り付ける動作については、他にも自動的に貼り付けたい対象のタブが色々出てきたため、最終的なリリース時点では本体から機能を削除し、ヘルパーアドオンで実現することにしました。)
なお、通常のタブ群から貼り付け状態にしたタブのHTML要素を単純に取り除くと、そのタブより後のタブの表示位置がずれてスクロール位置の修正が必要になってしまいます。そこで今回は、取り除いたHTML要素の位置に代わりのスペーサー要素を挿入して、スクロール位置を修正しなくてもよくなる実装としました。 このように、タブ以外の任意の種類のHTML要素をタブのコンテナー要素内に挿入できるようになったのも、データモデルとウィジェットの分離を推し進めたことの副次的なメリットです。
TSTというプロジェクトでは「ツリーに関係無い機能はなるべく入れない、Firefox本体のタブバーにない機能は入れない」というポリシーを定めているため、本来であればこのような機能はTST本体に入れないのが自然です。 ただ、この一連のことを実現可能にするAPIの仕様化は困難(仮にAPI経由で行えるようにできても、必要な実装の9割以上をTST本体側に置くことになりそう)と思えました。 一応、「APIではどうしてもできないことは本体でやる」ということもプロジェクトポリシーの1つとして決めてはいるので、今回は妥協し、TST本体の新機能として組み込むことにした、という判断です。
漸進的・段階的に、やれることをやるしかない
以上が、TST 4.0での改良のあらましとなります。
先述の通り、TSTは2.0でのWebExtensionsへの移行時に一度、クリーンで理想的な設計の高性能なソフトウェアへ生まれ変わるチャンスを逃しました。 その後の段階的な改修を経て、TSTは3.0で「あるべき設計」の方向性に大きく梶を切り、4.0でようやく、あるべき形に最接近したと思います。 今回の再改修の機会を提供してくださったAlexさんに、改めて感謝します。
そういう経緯で、過去の実装を可能な限り引き継いでいるため、最初から理想的な設計で作られた物に比べると、あちこちにいびつな部分があるのは否めません。 設計方針自体はまあまあマシになったものの、モジュール同士が有機的に結合しすぎて他の人には手出し不可能な魔窟になっていると言われれば、その通りと認めるところです。 このような状態を解消できたときか、あるいは、Manifest V3での「永続化されないバックグラウンドページ」を前提にした実装へ更新できたときが、バージョン番号を5.0に繰り上げるタイミングになりそうな気がしています。
するべき時にするべきことをできていればよかった(TST 2.0を作る前にもっとソフトウェア設計の知見を貯めておくべきだった、TST 2.0時点で誰の目から見てもベストな設計にするべきだった)のは間違いありませんが、TST 3.0の時にも書いた通り、残念ながら当時の僕では、途中で自分の脳のキャパシティを超えてしまい、投げ出してしまっただろうと思います。
作るべき物ではなく、ないと困る物を作る。 作るべき能力のある人ではなく、作るしかない状況にいる人が作る。 その結果、持っているべき知見を持たない、作者にふさわしくない人が作者となって、出来の良くないソフトウェアが生まれてしまう。 ありがちなジレンマです。
とは言うものの、何であれ動く物が今ここにあるのが事実です。 TSTを長く維持していると、「生き残るとは、手段を選ばずいぎたなく生きることだ」と身を以て思い知ります。 TSTの初版公開からもう17年目。 Firefoxが生き続けている限りは、僕もTSTの開発を継続し続けるのだろうと思います。
wikieditish message: Ready to edit this entry.