はじめに
ブラウザを操作するとき、私はVimiumをよく使っています。
例えば、ページをスクロールするときにマウスホイールへ手を伸ばさなくても、
j
k
で上下に移動できます。
リンクを開きたい場合も、Vimiumでは f を押すと、画面上のリンクやボタンに短い文字列が表示されます。
その文字を入力すると、マウスでクリックしなくても対象を開けます。
使っていると自然ですが、よく考えると不思議です。
- 普通のWebページなのに、なぜ
jやkでスクロールできるのか -
fを押したとき、どうやってクリック可能な要素を見つけているのか - タブを閉じたり履歴を移動したりする操作は、WebページのJavaScriptだけで実現できるのか
この記事では、VimiumのソースコードとManifestを見ながら、ブラウザをVimのように操作できる仕組みを整理します。
Vimiumとは
Vimiumは、Vimの操作感をブラウザへ持ち込むためのブラウザ拡張です。
代表的な操作には次のようなものがあります。
j 下へスクロール
k 上へスクロール
h 左へスクロール
l 右へスクロール
f リンクヒントを表示
F リンクを新しいタブで開く
H 戻る
L 進む
J 左のタブへ移動
K 右のタブへ移動
x タブを閉じる
t 新しいタブを開く
/ ページ内検索
? ヘルプを表示
マウスを使わなくても、多くのブラウザ操作をキーボードだけで行えるようになります。
まず普通のWebページだけでは実現できない
例えばWebページ上のJavaScriptでも、キーボードイベントを取得することはできます。
document.addEventListener("keydown", (event) => {
console.log(event.key);
});
この仕組みだけでも、
jが押された
kが押された
fが押された
といったことは検出できます。
そのため、スクロールだけなら次のようなJavaScriptでも実現できます。
document.addEventListener("keydown", (event) => {
if (event.key === "j") {
window.scrollBy(0, 100);
}
});
しかしVimiumは、単なるWebページ上のJavaScriptではありません。
ブラウザ拡張として動いています。
これが重要です。
VimiumのManifestを見る
現在のVimiumはManifest V3のブラウザ拡張です。
manifest.jsonを見ると、大きく二つの実行場所が定義されています。
content_scripts
background service worker
VimiumのManifestには、例えば次のようなcontent scriptが登録されています。
content_scripts/link_hints.js
content_scripts/scroller.js
content_scripts/mode_insert.js
content_scripts/mode_find.js
content_scripts/mode_visual.js
content_scripts/mode_normal.js
content_scripts/vimium_frontend.js
さらにbackground側には、
background_scripts/main.js
がservice workerとして登録されています。
content scriptとは
content scriptは、ブラウザ拡張がWebページの中で実行するJavaScriptです。
イメージとしては次のようになります。
Chrome
├── Webページ
│ ├── HTML
│ ├── CSS
│ ├── ページ本来のJavaScript
│ └── Vimiumのcontent script
│
└── Vimiumのbackground service worker
Vimiumはcontent scriptを使うことで、現在表示しているページのDOMを調べたり、キーボード入力を受け取ったりできます。
ただし、content scriptとページ本来のJavaScriptは同じ変数や関数を直接共有しているわけではありません。Chromeでは通常、content scriptはページとDOMを共有しつつ、JavaScriptの実行環境は分離されたisolated worldで動きます。
この分離があるため、VimiumはページのDOMを操作できても、ページのJavaScriptの内部状態をそのまま読むことはできません。ページ側と値をやり取りする必要がある場合は、DOMイベントや window.postMessage などを使います。
つまり、
j
k
f
/
などを押したときの処理の多くは、ページへ注入されたcontent scriptから始まります。
jやkでスクロールできる仕組み
基本的な考え方はシンプルです。
実際のVimiumにはモードやスクロール対象の判定、連続入力、スムーズスクロールなどがあるため、単純な window.scrollBy より複雑です。
実装では、まずページ全体のスクロール要素を次の優先順位で決めます。
const getScrollingElement = () =>
getSpecialScrollingElement() || document.scrollingElement || document.body;
ただし、j を押したときに常にこの要素をスクロールするわけではありません。Vimiumは現在の対象要素から親へたどり、実際にスクロールできる要素を探します。
const findScrollableElement = function (element, direction, amount, factor) {
while (
(element !== getScrollingElement()) && !isScrollableElement(element, direction, amount, factor)
) {
element = DomUtils.getContainingElement(element) || getScrollingElement();
}
return element;
};
doesScroll()では、scrollHeightだけで判断せず、対象を1px動かして元へ戻すことで本当に動くかを確認しています。入れ子のスクロール領域があるページで、意図しない親やページ全体を動かしにくくするための処理です。
しかし基本的には、
キーボードイベントを受け取る
↓
現在のモードを確認する
↓
対応するコマンドを決める
↓
ページをスクロールする
という流れです。
なぜ文字入力中はjを押してもスクロールしないのか
もしVimiumが常に j を奪ってしまうと、フォームへ文字を入力できません。
例えば、
<input type="text">
<textarea></textarea>
へ入力しているときには、通常の文字入力を優先する必要があります。
そのためVimiumにはNormal Mode以外にも、入力中の操作を扱うためのInsert Modeなどがあります。
考え方はVimにかなり近いです。
Normal Mode
↓
Vimiumのショートカットとして解釈
Insert Mode
↓
Webページへの文字入力として解釈
Vimiumのソースにも、
mode_normal.js
mode_insert.js
mode_find.js
mode_visual.js
のように、モードごとに処理が分割されています。
ブラウザ上でVim風操作を成立させるために、キーそのものだけでなく「現在どのモードなのか」も管理しているわけです。
fを押すとリンクに文字が出る仕組み
Vimiumで特に特徴的なのがLink Hintsです。
f を押すと、
A
S
DF
JK
のような短い文字列が、リンクやボタンの上に表示されます。
これはブラウザに元から存在する機能ではありません。
Vimium自身がDOMを調べて生成しています。
大まかな流れは次のようになります。
つまり、見えているリンクへブラウザが自動で番号を付けているわけではありません。
Vimium側が、
DOMを走査
↓
操作可能な要素を抽出
↓
画面内の位置を取得
↓
Hintを生成
↓
対象の近くへオーバーレイ表示
しています。
Link HintsはDOM要素と表示位置をセットで管理する
Link Hintsの実装では、候補を単なるリンクの配列として持ちません。LocalHintは、対象のDOM要素、表示位置、リンクテキストなどをまとめて保持します。
class LocalHint {
element;
image;
rect;
linkText;
}
rectはHintを重ねる位置の計算に使われます。実際にHintを描画するときは、この矩形のleftとtopを使ってマーカー要素を配置します。
候補の取得も、概念図だけで終わりません。現在のframeではLocalHints.getLocalHints()でローカル候補を集め、他のframeへ渡す情報へ変換しています。
this.localHints = LocalHints.getLocalHints(requireHref);
this.localHintDescriptors = this.localHints.map(({ linkText }, localIndex) => (
new HintDescriptor({ frameId, localIndex, linkText })
));
iframeの中にあるリンクも候補へ集約する
Webページには、別のHTML文書を埋め込むiframeがあります。例えば、埋め込み動画、広告、外部サービスの画面、社内ツールの一部などです。iframe内のDOMは親ページとは別のdocumentなので、親ページだけをquerySelectorAll()しても、その中のリンクやボタンは取得できません。
VimiumのManifestではcontent scriptにall_frames: trueを指定しています。つまり、対象ページだけでなくiframeにもcontent scriptを注入します。
{
"content_scripts": [{
"all_frames": true,
"match_about_blank": true
}]
}
各frameはローカルの候補を集めます。HintDescriptorには候補を所有するframeIdと、そのframe内の候補番号であるlocalIndexが含まれます。backgroundを介して各frameの記述子を集め、すべてのframeで同じ候補一覧と入力状態を共有するため、iframe内のリンクも一つのLink Hints操作として選べます。
ただし、ブラウザや拡張機能の権限によりcontent scriptを注入できないframe、すでに消えたframe、画面が小さすぎるframeなどは候補になりません。iframe対応は「すべての埋め込み先を無条件に操作できる」という意味ではありません。
Hintはページを書き換えて表示している
リンクヒントは、Webサイト本来のHTMLに含まれているものではありません。
Vimiumのcontent scriptがHint用のUIをページへ追加して表示しています。
イメージとしては、
<a href="/users">Users</a>
<!-- Vimiumが追加するイメージ -->
<span class="vimiumHintMarker">AF</span>
のような状態です。
実際の実装はもっと複雑ですが、考え方としては「ブラウザ拡張がページの上に独自UIを重ねている」と理解すると分かりやすいです。
これはVimiumだけでなく、多くのブラウザ拡張で使われる方法です。
タブ操作はcontent scriptだけではできない
一方、
新しいタブを作る
タブを閉じる
別のタブへ移動する
履歴を取得する
ブックマークを検索する
といった操作は、単なるWebページ上のJavaScriptから自由に実行できません。
もし普通のWebサイトが勝手に他のタブを閉じたり、ブラウザの履歴をすべて読めたりすると危険だからです。
そこでブラウザ拡張には専用のExtension APIがあります。
VimiumのManifestを見ると、例えば次の権限があります。
tabs
bookmarks
history
storage
sessions
webNavigation
これらを使うことで、通常のWebページには許されていないブラウザ操作ができます。
backgroundのmain.jsには、タブを選択・移動する実装もあります。
async function selectSpecificTab(request) {
const tab = await chrome.tabs.get(request.id);
await chrome.windows.update(tab.windowId, { focused: true });
await chrome.tabs.update(request.id, { active: true });
}
return chrome.tabs.move(tab.id, { index: tabs[moveIndex].index });
このように、ページ内のDOM操作とは別に、タブの前面化や並べ替えはbackground service workerからExtension APIへ依頼します。
ここで「履歴」には二つの意味があります。現在のタブで H や L を押して戻る・進む処理は、Vimiumのcontent scriptがページの history.go() を呼んで行います。一方で、履歴全体を検索する機能や、別タブの作成・削除・切り替えには、拡張機能の権限とAPIが必要です。
content scriptとbackgroundの役割分担
大まかには次のように分かれています。
content script
├── キーボード入力
├── DOM探索
├── スクロール
├── Link Hints
├── ページ内検索
└── ページ上のUI表示
background service worker
├── タブ操作
├── 履歴全体の検索
├── ブックマーク
├── セッション
└── Browser Extension APIを使う処理
例えば x でタブを閉じる場合、概念的には次のような流れになります。
ページを操作する処理と、ブラウザそのものを操作する処理を分けているわけです。
なお、Manifest V3のbackground service workerは常駐プロセスではありません。イベントを処理するときに起動し、アイドル状態になると停止します。そのため、拡張機能は必要な状態を storage などへ保存し、次に起動したときに復元できるようにします。
なぜChromeの設定画面ではVimiumが効かないことがあるのか
Vimiumを使っていると、
chrome://settings
chrome://extensions
Chrome Web Store
など、一部のページではショートカットが効かないことがあります。
これはVimiumの不具合とは限りません。
ブラウザはセキュリティ上の理由から、拡張機能がcontent scriptを注入できないページを持っています。
通常のWebページなら、
https://example.com
↓
content scriptを注入できる
一方、ブラウザ内部の特権ページでは、
chrome://...
↓
拡張機能から自由に操作できない
という制約があります。
ブラウザ拡張は強力ですが、ブラウザ全体を無制限に操作できるわけではありません。
キーボードだけで操作できると作業へ集中しやすい
リンクを開く、タブを閉じる、履歴を戻る、ページ内を検索するといった操作をキーボードだけで続けられると、マウスへ手を伸ばす回数が減ります。慣れると、画面上の操作を自分で直接動かしているような全能感があります。
ブラウザで調査、実装、レビューを往復するときは、小さな操作を何度も繰り返します。よく使う操作がキーへまとまっていると、視線と手の移動が減り、作業の流れを保ちやすくなります。
ブラウザ拡張として導入しやすい
VimiumはChromeやEdgeなどの拡張機能として追加し、必要ならキーマッピングを少し調整するだけで始められます。ブラウザ操作をキーボード中心へ寄せたい人へ紹介しやすい点も利点です。
ブラウザ拡張として見るとVimiumは面白い教材になる
普段は、
ブラウザをVimっぽく操作するための拡張
として使っています。
しかし仕組みを見ると、ブラウザ拡張の基本的な構成がかなり詰まっています。
keydownイベント
DOM探索
DOMへのUI追加
モード管理
content script
background service worker
message passing
Extension API
Manifest V3
特に f のLink Hintsは、
WebページのDOMを解析する
↓
対象を抽出する
↓
座標を調べる
↓
独自UIを重ねる
↓
キーボード入力と対象を対応付ける
という処理なので、ブラウザ拡張がWebページに対して何をできるのかを理解しやすい機能です。
まとめ
Vimiumは、content scriptでキーボード入力とDOMを扱い、タブ操作のようにブラウザ権限が必要な処理はbackground service workerへ依頼します。f のLink Hintsでは、DOMから操作候補を探し、Hintを表示して、入力された文字に対応する要素を操作します。
普段使うショートカットの裏側には、content script、background service worker、DOM、Extension APIというブラウザ拡張の基本的な仕組みがあります。Vimiumは、その境界を理解する教材としても面白い拡張機能です。