公開日
ユーザースクリプトを軽くする:他人のページに相乗りする作法
Mastodon の投稿欄を拡張するユーザースクリプトの v3.0.2 では、動作を変えずに無駄な処理を減らした。関係する状態が変わらなければ DOM を触らない、MutationObserver の走査を 1 フレーム 1 回にまとめる。他人のページに相乗りするスクリプトが守るべきことを書く。
はじめに
公開範囲と引用範囲をワンクリックで切り替えるユーザースクリプトは、機能としては一段落した。v3.0.2 では機能を足さず、軽くすることだけをやった。
ユーザースクリプトは他人のページに相乗りして動く。自分の都合で重くしていいわけがない。Mastodon の Web UI はタイムラインが流れるたびに DOM もストアも頻繁に動くので、そのたびにこちらが仕事をしていると、見えないところで負荷が積み上がる。
ストアの通知は全部に来る
このスクリプトは Redux ストアを subscribe して、公開範囲や引用範囲の変化をボタンに反映している。問題は、ストアの通知が関係のないアクションでも来ること。タイムラインの更新も通知も、全部同じ購読者に届く。
v3.0.2 より前は、通知のたびに状態を読んでボタンを更新していた。動きは正しいが、ほとんどの通知ではボタンの表示は変わらないんだよな。
対処は単純で、関係する値だけを連結した文字列を作り、前回と同じなら何もしない。
const key = [s.privacy, s.quotePolicy, s.isEditing, s.quotedId, s.quotedVis].join('|');
if (key === lastKey) return;
lastKey = key;
見ているのは、公開範囲、引用範囲、編集中かどうか、引用先の ID、引用先の公開範囲の 5 つ。ボタンの表示に関わる値はこれで全部で、どれか 1 つでも変わったときだけ DOM を触る。
状態の読み出しも 1 回にした。通知 1 回につき readState() を 1 回だけ呼び、その結果をすべてのボタン列に渡す。投稿欄が複数あっても(デッキのカラムと返信モーダルなど)、ストアを読む回数は増えない。
DOM の走査を 1 フレーム 1 回にまとめる
投稿欄はページ読み込み時にあるとは限らない。デッキのカラム切り替えや返信モーダルで後から現れる。そこで MutationObserver で document.body 以下を監視し、投稿欄が現れたらボタン列を差し込んでいる。
MutationObserver のコールバックは、DOM の変更がまとまって届くとはいえ、タイムラインが流れている間は何度も呼ばれる。そのたびに querySelectorAll で投稿欄を探すのは無駄すぎる。
let scanQueued = false;
new MutationObserver(() => {
if (scanQueued) return;
scanQueued = true;
requestAnimationFrame(() => { scanQueued = false; scan(); });
}).observe(document.body, { childList: true, subtree: true });
変更が来たら requestAnimationFrame で次の描画前に走査を予約し、予約済みなら何もしない。どれだけ変更が連続しても、走査は 1 フレームに 1 回で済む。走査の側でも、ボタン列をすでに差し込んだ投稿欄は冒頭で弾く。
ほかに守っていること
小さなことだが、次も意識している。
- ストアは一度見つけたら保持し、二度と Fiber をたどらない
- 本家の要素は消さず、隠すだけにする
- 本家と同じアクションを使い、自前の状態を持たない
最後の点は軽さにも効く。自前で状態を持つと、本家とのずれを検出するために余計な比較や監視が要る。ストアを正とすれば、やることは「変わったら表示に写す」だけ。
AI と壁打ちして決めたこと
どこが無駄かの洗い出しは AI(Claude Code)に手伝ってもらった。ただし、どこまで削るかはやっぱ自分で決めた。投稿欄が現れる場所は複数あるので、監視の範囲は body のまま狭めず、走査の回数をまとめる方を取った。
効果を数字で測ったわけじゃない。それでも、他人のページに相乗りする以上、「何もしなくていいときは何もしない」をちゃんと徹底しておくのが作法なんだよな。
次回は、このスクリプトを公開するにあたって README に何を書いたかをまとめる予定。