「新しいタブ」を開くためのブラウザ拡張機能において、「起動の速さ」はすべてに優先する最重要機能だ。
ユーザーが Ctrl + T(Macなら Cmd + T)を押したとき、画面が一瞬でも待たされたら、どれほどUIが美しくてもユーザーは苛立ち、やがてデフォルトの真っ白なタブへと戻ってしまう。
自作のダッシュボード拡張機能「ZenithTab」にカレンダー、習慣トラッカー、カウントダウン、スマート回答と次々に機能を追加していった v1.9.0 のころ、Viteのプロダクションビルド(npm run build)を実行したターミナルに、黄色い警告メッセージが表示されるようになった(Issue #40)。
dist/assets/newtab-q2gKU5gX.js 822.04 kB │ gzip: 253.59 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking
822KBの巨大な単一JavaScriptチャンク。
React本体、Lucideアイコン集、XMLパーサー、QRコード生成、グリッドエンジン、そして4つのダイアログが1つのファイルに凝縮され、新しいタブを開くたびにこの重厚なスクリプトをブラウザがパースして実行していた。
Web サイトとは違う「拡張機能のバンドルサイズ」の意味
先に押さえておきたいのは、拡張機能のバンドル最適化は Web サイトのそれと目的が違うということだ。
拡張機能のファイルはローカルディスク(拡張機能のインストールフォルダ)から読み込まれる。ネットワークのダウンロード時間はゼロであり、gzip 後のサイズはほとんど意味を持たない。
それでも 822KB が問題になるのは、タブを開くたびに JavaScript エンジンが そのバイト列をパースし、コンパイルし、実行する からだ。V8 はコンパイル結果をコードキャッシュに保存して2回目以降を速くしてくれるが、そのキャッシュはファイル単位だ。アプリ側のコードを1行直してリリースすると、同じファイルに同居している React や react-grid-layout のキャッシュまで巻き添えで無効になる。
つまり、拡張機能でチャンクを分ける目的は「初回ダウンロードを減らす」ではなく、「毎回パースする塊を小さくする」「変わらないライブラリのコンパイル済みコードを更新をまたいでキャッシュさせる」 の2つだ。vite.config.ts のコメントにもそう書いてある。
犯人の特定:なぜ800KBまで膨らんだのか
ビルド出力とソースの import 関係を追うと、巨大なファイルの中身は大きく2種類に分かれた。
-
変化頻度の低いサードパーティライブラリ:
react/react-dom、react-grid-layout(と依存するreact-draggable/react-resizable)、lucide-react、fast-xml-parser、qrcode。 -
開くまで不要なダイアログ: 設定パネル(
SettingsPanel.tsx、当時 1,235 行で最大のコンポーネント)、ウィジェット追加カタログ、ウィジェット設定モーダル、アプリドロワー。ユーザーが歯車やボタンを押すまで絶対に表示されない。
これらがすべて初期読み込みのメインファイルに同期インポート(Static Import)されていたため、ブラウザは画面を表示する前にすべてのコードを読み解かなければならなかった。
施策1:React.lazy と Suspense によるダイアログの遅延読み込み
ファーストビューに不要な4つのダイアログを、動的インポートへと切り替えた(src/newtab.tsx)。
import React, { Suspense, lazy, useEffect } from 'react';
// ダイアログは大きく(SettingsPanel は最大のコンポーネント)、
// ページの描画には不要なので、初めて開いたときに読み込む。
const SettingsPanel = lazy(() => import('./components/layout/SettingsPanel').then((m) => ({ default: m.SettingsPanel })));
const AddWidgetModal = lazy(() => import('./components/layout/AddWidgetModal').then((m) => ({ default: m.AddWidgetModal })));
const WidgetConfigModal = lazy(() => import('./components/layout/WidgetConfigModal').then((m) => ({ default: m.WidgetConfigModal })));
const AppDrawerModal = lazy(() => import('./components/layout/AppDrawerModal').then((m) => ({ default: m.AppDrawerModal })));
export const App: React.FC = () => {
const activeSettingsModal = useDashboardStore((s) => s.activeSettingsModal);
const isAppDrawerOpen = useDashboardStore((s) => s.isAppDrawerOpen);
// ...
return (
<div className="min-h-screen flex flex-col relative">
<WallpaperBackground />
<Header />
<PageSwitcher />
<main className="flex-1 flex flex-col">
<GridContainer />
</main>
<Dock />
{/* ダイアログは開いている間だけマウントし、コードは初回に取得される */}
<Suspense fallback={null}>
{activeSettingsModal === 'settings' && <SettingsPanel />}
{activeSettingsModal === 'addWidget' && <AddWidgetModal />}
{activeSettingsModal === 'editWidget' && <WidgetConfigModal />}
{isAppDrawerOpen && <AppDrawerModal />}
</Suspense>
<UndoToast />
</div>
);
};
lazy() は default エクスポートを要求するので、名前付きエクスポートを .then((m) => ({ default: m.X })) で包み直す。Suspense の fallback は null にした。読み込み元がローカルディスクなので、実測ではスピナーを描く暇もなく開く。
ここで重要な設計判断がもう1つある。ダイアログは「非表示にする」のではなく「開いている間だけマウントする」。条件付きレンダリングにすることで、閉じている間はコンポーネントツリーにも存在せず、ストアの購読も走らない。
ウィジェットの設定フォームは分けなかった
ウィジェットごとの設定フォーム(ClockConfig.tsx など)を lazy 化する案も検討したが、見送った。フォームはウィジェットレジストリ(registry.tsx)に本体コンポーネントと一緒に登録されており、レジストリはグリッド描画にも必要なので初期チャンクに含まれる。フォーム群の合計は数十KBで、レジストリを2つに割って循環参照のリスクを取るほどの価値はないと判断した。「分割できるものをすべて分割する」のではなく、パース量と構造の単純さのバランスで決める。
施策2:Vite(Rollup)の manualChunks によるベンダー分割
次に、vite.config.ts でRollupのバンドル生成設定を調整し、サードパーティライブラリをベンダーチャンクへと分割した。
// vite.config.ts
export default defineConfig({
plugins: [react(), crx({ manifest })],
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: { newtab: path.resolve(__dirname, 'newtab.html') },
output: {
// 新しいタブはディスクから読まれるので、ダウンロード量の話ではない:
// 滅多に変わらないライブラリを別チャンクに出しておくと、アプリ側のチャンクが
// 小さくなってパースが速くなり、ベンダーチャンクのコンパイル済みコードは
// 更新をまたいで Chrome にキャッシュされる。
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
const normalized = id.replace(/\\/g, '/'); // Windows のパス区切りを揃える
if (/\/node_modules\/(react|react-dom|scheduler)\//.test(normalized)) return 'vendor-react';
if (id.includes('react-grid-layout') || id.includes('react-draggable') || id.includes('react-resizable')) return 'vendor-grid';
if (id.includes('lucide-react')) return 'vendor-icons';
if (id.includes('fast-xml-parser')) return 'vendor-xml';
if (normalized.includes('/qrcode/')) return 'vendor-qrcode';
return 'vendor';
},
},
},
},
});
細かいが、id.replace(/\\/g, '/') は Windows で開発しているときに効く。Rollup が渡すモジュール ID は OS のパス区切りをそのまま含むので、/node_modules/react/ のような正規表現は Windows では \node_modules\react\ にマッチしない。CI(Linux)と手元(Windows)で分割結果が変わる、という地味に嫌なバグを避けるための1行だ。
react と react-dom と scheduler を同じチャンクに置くのも意図的だ。この3つは常に一緒に更新されるので、分けてもキャッシュ効率は上がらず、チャンク間の import が増えるだけになる。
結果
この設定により、単一の 822KB だった出力は、次のチャンク群へと分かれた(v1.10.4、vite build の実測値)。
| チャンク | サイズ | gzip | 読み込み |
|---|---|---|---|
newtab-*.js(アプリ本体) |
391.9 KB | 126.5 KB | 起動時 |
vendor-react-*.js |
194.4 KB | 60.7 KB | 起動時 |
vendor-*.js(その他: zustand など) |
77.9 KB | 26.3 KB | 起動時 |
vendor-grid-*.js |
68.7 KB | 20.4 KB | 起動時 |
vendor-icons-*.js |
65.7 KB | 14.8 KB | 起動時 |
SettingsPanel-*.js |
34.6 KB | 7.4 KB | 設定を開いたとき |
vendor-xml-*.js |
27.9 KB | 9.2 KB | 起動時(RSS ウィジェットが読む) |
vendor-qrcode-*.js |
24.3 KB | 9.5 KB | 起動時(QR ウィジェットが読む) |
AppDrawerModal-*.js / AddWidgetModal-*.js / WidgetConfigModal-*.js
|
1.5〜6 KB | — | 開いたとき |
Vite の警告は消えた。アプリ本体のチャンクは 822KB から 392KB へ、更新のたびに再コンパイルされる部分が半分以下になり、残りはライブラリとしてキャッシュが効く。
正直に書くと、起動時にパースされる合計は依然として 800KB 前後ある。ライブラリのコードが減ったわけではなく、置き場所が変わっただけだ。それでも「1リリースごとに毎回コンパイルし直す量」と「Vite が警告する巨大ファイル」は解消された。ここから先——アプリ本体 392KB をさらに削る——は、ウィジェットごとの遅延読み込みや不要な依存の見直しという、次の段階の話になる。
施策3:Lucide Icons は名前付きインポートで
意外な盲点になりがちなのが、アイコンライブラリ lucide-react の扱いだ。
import * as Icons from 'lucide-react' や、設定画面で「ユーザーが自由なアイコンを選べるピッカー」を作ろうとして全アイコンを一括インポートすると、1,500 個を超えるアイコンSVGコードがバンドルにねじ込まれる。
ZenithTabでは、ウィジェットやUIで実際に使用しているアイコンだけを明示的な名前付きインポート(Named Import)に限定している。
// registry.tsx — 必要なアイコンだけを厳密にインポート
import { Bookmark, CalendarDays, CheckSquare, Clock, CloudSun, FileText, Globe, Flame, History, Hourglass, Newspaper, QrCode, Search, Timer } from 'lucide-react';
ESModuleのツリーシェイキング(Tree Shaking)が機能し、使われていないアイコンはビルド成果物から落ちる。それでも vendor-icons が 65KB あるのは、ドックのアイコン選択やウィジェット内の各所で結果的に相当数のアイコンを使っているからで、これは「使っている分」だ。
計測の前に:何を測るべきか
この種の記事では「FCP が 240ms から 65ms に」といった数字を出したくなる。だが拡張機能の新しいタブでは、ネットワークを介さない・キャッシュの状態で大きく変わる・数十ミリ秒の差はマシン依存、という理由で、単発の計測値は再現性に乏しい。
ZenithTab で確認しているのは次の2点だ。
-
npm run buildで 500KB 警告が出ないこと(CI でもビルドを通し、続くnpm run verifyでマニフェストが指すファイルがすべて出力されているかを検証する) -
npm run test:e2eで ダイアログの開閉と初回描画に退行がないこと(lazy化でダイアログが開かなくなる、という事故を E2E が捕まえる)
数字で語るなら、Chrome DevTools の Performance パネルで「新しいタブを開いてから #zenith-root に最初の子が入るまで」を、キャッシュを消した状態と温めた状態の両方で複数回測る。そうして初めて、比較に耐える値になる。
「拡張機能だからこそ」バンドル構成にこだわる理由
Webサイトであれば、CDN や HTTP キャッシュがバンドルサイズの肥大化をある程度ごまかしてくれる。
しかし、ブラウザの新しいタブを置き換える拡張機能は、ユーザーが一日に何十回、何百回と開く最も身近な道具だ。ダウンロードは無くても、パースとコンパイルは毎回ある。
- ファーストビューに不要なダイアログは
React.lazyで切り離し、開いている間だけマウントする -
manualChunksで「変わらないライブラリ」と「よく変わるアプリ本体」を分け、キャッシュを効かせる - アイコンやパーサーは名前付きインポートでツリーシェイキングを効かせる
- 分割の効果は「警告が消えたか」「E2E が通るか」で継続的に確かめる
これらの王道のフロントエンドチューニングを、拡張機能の特性に合わせて愚直に積み重ねること。
それが、機能の豊富さと軽快な起動を両立させるための、地味だが確実な道だ。
さいごに:ZenithTabについて
本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。
ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。
- 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
- 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
- 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備
Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!
- 🌐 Chrome ウェブストアでインストール:
ZenithTab - Chrome ウェブストア - 🐙 GitHub リポジトリ(完全オープンソース):
miyabiver39/ZenithTab
ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!
