概要
Electronでドキュメントビューワーを作ったとき「ブラウザみたいにタブがあると便利だな」と思い、electron-tabsというライブラリを入れてタブを実装した。見た目はすぐできたが、使ってみるとフォーカスの順序がおかしい、新しいタブで開けない、キーボードショートカットが他のアプリを壊す、リロードが全タブを吹き飛ばす等々、問題が次々に出てきた。根本的な原因は、このタブが1枚のHTMLページ上でブラウザのタブ挙動をJavaScriptでシミュレーションしているだけだということ。結論として、全アプリからタブを削除することにした。そのまとめ。

そもそもなぜElectronを使っているか
最近Electronを使っている。SlackやVS Codeが使っているアレで、Webアプリをネイティブアプリのように表示できるやつ。
自分の場合、プロジェクトごとにDocusaurusでドキュメントサイトを作っていて、それをElectronで包んでスタンドアロンアプリにしている。モチベーションは単純で、Alfredなどのランチャーから「zmoddoc」と打ってドキュメントを開きたいだけ。ブラウザのタブの中に埋もれさせたくない。
開発コストという意味ではオーバーキルだが、今はAI時代なので、Electronアプリを1つ作るコストはほぼゼロに近い。Web開発者であれば、自分の作ったWebアプリをネイティブアプリにすることがほぼ無料でできるということは覚えておいて良さそう。
タブが欲しくなる
Electronでドキュメントビューワーを最初に作ったとき、自然にこう思った。「ブラウザみたいにタブがあったら便利だな」と。普段ブラウザでWebを見るときにタブを使っているのだから、ドキュメントビューワーにもタブがあるのは自然な発想。
Claude Codeに聞いたらelectron-tabsというライブラリを教えてくれた。ということで実装を頼んだ。
見た目はすぐできた
electron-tabsの導入自体は簡単だった。<tab-group>というWebコンポーネントを置いて、タブの追加・削除のコードを書けば、見た目としてはブラウザのタブバーができる。ダークテーマにして、ドラッグで並べ替えできるようにして、新規タブボタンも付けて。ここまでは順調だった。
使ってみると色々おかしい
実装後に実際使い始めると、色々な問題が出てきた。
タブタイトルが表示されない
electron-tabsはwebviewのページタイトルを自動的に取得してくれない。page-title-updatedイベントを拾い、did-finish-loadイベントでも取得を試み、それでもダメなら250msごとにポーリングして最大5秒間タイトルの取得を試みる、という3段構えのフォールバックが必要だった。タブのタイトルを表示するだけでこの有様。
Cmd+Rがタブではなくアプリ全体をリロードする
Electronメニューの{ role: "reload" }は、タブの中のwebviewではなく、タブを含むメインウィンドウ(tabbed-window.html)をリロードする。つまりCmd+Rを押すと全タブが初期化されてデフォルトURLに戻る。これを直すにはIPCを使ったカスタムリロードハンドラーを実装する必要があった。
// menu.js - role: "reload"を使わずにIPCで送信
{
label: "Reload",
accelerator: "CmdOrCtrl+R",
click: () => sendToFocusedWindow("menu-reload"),
}
// tabbed-window.html - アクティブタブのwebviewだけリロード
ipcRenderer.on("menu-reload", () => {
const activeTab = tabGroup.getActiveTab();
if (activeTab && activeTab.webview) {
activeTab.webview.reload();
}
});Cmd+1〜9がSlackを壊す
タブ切り替えのためにCmd+1、Cmd+2...のショートカットを実装するのだが、webviewにフォーカスがあるとき、通常のメニューアクセラレータやdocument.keydownは効かない。globalShortcutを使う必要がある。
ところがglobalShortcutはシステムワイド。自分のElectronアプリがバックグラウンドにあっても、Cmd+1を横取りする。Slackで1番目のチャンネルに切り替えようとしたら、自分のドキュメントビューワーが反応する。
解決にはウィンドウのフォーカス状態に応じてショートカットを登録・解除する仕組みが必要になった。しかもblur時に100msの遅延を入れないと、自分のアプリ内でウィンドウを切り替えたときにも解除されてしまう。さらにmacOSではウィンドウを全部閉じてもアプリは生き残るので、window-all-closed、before-quit、will-quitの3箇所でクリーンアップしないと、アプリを閉じた後もショートカットが奪われたままになる。
タブの順序がおかしい
getTabs()が返すのはタブの作成順であって表示順ではない。ドラッグで並べ替えた後にCmd+2を押すと、見た目上の2番目ではなく、作成順で2番目のタブがアクティブになる。getTabByPosition()を使わないといけない。
なぜこんなに難しいのか
ここまでの問題を眺めると、ある共通点がある。全部「ブラウザのタブなら当たり前にできること」が、electron-tabsではできないか、大量のワークアラウンドが必要になるということ。
理由はシンプルで、electron-tabsのタブは結局のところ1枚のHTMLページの中でJavaScriptで再現されたタブUIでしかないから。
ブラウザのタブは、OSレベルのプロセスとして独立していて、それぞれが自前のセキュリティサンドボックス、入力フォーカス、ナビゲーション履歴、ライフサイクルを持っている。electron-tabsが提供するのはタブの見た目だけで、そのインフラは一切ない。
つまり、Cmd+T、Cmd+W、Cmd+1〜9、フォーカス管理、タブごとの独立リロード、戻る/進むの履歴──ブラウザのタブに期待する機能の一つ一つを、全部手動で再実装しなければならない。しかもそのたびにElectron固有の制約(webview内でのイベント伝播、globalShortcutのスコープ、IPCの必要性)にぶつかる。
「1枚のHTMLページの上にタブUIを載せている」と考えると、これは要するにシンプルなページ遷移型のWebアプリを、突然複雑なSPAに変えているのと同じ。そりゃ大変なわけだ。
気づいたらタブ対応に時間を使っている
Electronでドキュメントビューワーをいくつか作ったのだが、ふと気づくと、タブ周りの問題対応にかなりの時間を使っていた。本来ドキュメントを見るためのアプリなのに、タブのタイトルが出ない、ショートカットが効かない、リロードがおかしい──そういうタブ起因の問題を直す作業ばかりしている。しかもアプリごとに同じワークアラウンドを展開する必要があって、どう考えても割に合わない時間の使い方だった。
結論: タブを全部消す
ということで、全アプリからタブを削除することにした。
冷静に考えると、ドキュメントビューワーにタブは要らない。1つのDocusaurusサイトを見るだけなのだから、サイドバーナビゲーションで十分。外部リンクはCmd+クリックでデフォルトブラウザに飛ばせばいい。タブが欲しくなったのは「ブラウザにはタブがあるから」という惰性であって、実際の用途を考えれば不要だった。
Electronで「タブを付けたい」と思ったら、それは見た目の簡単さに騙されている可能性が高い。electron-tabsは見た目のタブを提供するだけで、ブラウザのタブが持つインフラは自分で作る必要がある。しかもElectronの制約上、完全な再現は難しい。
シンプルなドキュメントビューワーには、シンプルなシングルウィンドウが正解だった。