公開日

ユーザースクリプトを軽くする:他人のページに相乗りする作法

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 に何を書いたかをまとめる予定。