本稿は innei.in/posts/tech/lobehub-performance-dx-optimization の分析です。原文では ChatGPT/LLM クライアントである LobeHub を例に、フロントエンドコンポーネント、Electron のメインプロセス、Next.js の開発体験など、複数のレイヤーで行われた一連の最適化を紹介しています。本稿では原文の記述を繰り返さず、散在する具体的な変更を、ほかのプロジェクトにも移植できるエンジニアリングのフレームワーク、4 つのコスト × コストを移す場所としてまとめます。
関連ノート
- [[笔记/web/Java/JVM 原理、性能调优与故障排查]]:JVM チューニングの考え方は本稿と近く、いずれも「高頻度で発生する小さなコストを均し、全体の負担を下げる」ことを重視しています。
1. まず直感をつくる:インフラの小さなコストはコンポーネント数で拡大する
LobeHub のこの一連の最適化には、共通する前提があります。1 回の操作では 0.1 ms は安くなくても、コンポーネント数を掛けると話が変わるのです。
記事には、次のような大まかな計算が示されています。
0.1 ms × 500 个组件 = 50 ms
スムーズな描画に必要な 1 フレームの予算は 16.7 ms です。インフラの微小な処理が「各コンポーネントの描画で毎回実行される」ようになったら、100〜200 個のコンポーネントと掛け合わさったとき、「気にならない」から「明確な引っかかり」に変わります。
この直感はとても重要です。LobeHub が最適化したのは、一般ユーザーには全く目立たないものだった 이유がここにあります。
<Flexbox>コンポーネント関数自体の実行useStyles()Hook の呼び出し- Tooltip の浮層の初期化
- 通常は表示されない Modal が React ツリー内でマウントされている Hook
いずれもインフラ層の小さな処理です。どれか 1 つを最適化しただけで、あるページが 1000 ms から 10 ms になるわけではありません。しかしippiには、数百個のコンポーネントをマウントするアプリの動作が明らかにスムーズになります。
ここからの具体的な変更は、すべて次のように考えられています。この 0.1 ms の小さな処理を「毎回発生」から「一度だけ発生」へ移す、あるいは「本当に必要なときに発生」へ移すのです。
3. react-layout-kit を置き換える:CSS-in-JS のランタイムをモジュールロードへ移す
react-layout-kit の Flexbox は、ただのレイアウトコンポーネントのように見えますが、antd-style / Emotion の仕組みの中では、内部で次の処理を行う必要があります。
props の組み合わせには 5 種類あります。
どの組み合わせも異なる CSS を生成する可能性があります。
キャッシュにヒットしても、その前にはコンポーネント関数の実行、スタイル引数の構築、オブジェクトの割り当て、シリアライズ、Hash の計算、キャッシュ検索が必要です。開発環境ではさらにデバッグ情報とソースマップを保存するため、メモリの問題が際立ちます。
新しい方式:すべてのインスタンスで CSS を共有
LobeHub では内部向けに Flex を自作し、API はそのままに、下層を固定クラスへ切り替えました。
コンポーネントは props を CSS 変数へマッピングするだけになります。
結果はこのようになります。
省けるコストは次のとおりです。
- gap の値ごとに新しい Hash クラス名を生成しなくてよい
<style>へルールを挿入し続ける必要がない- Emotion Cache を検索しなくてよい
代わりに増えるコストは次のとおりです。
- 描画のたびに小さな
cssVarsオブジェクトを生成する classNameを組み立てる
後者は前者に比べて明らかに安くなります。記事によれば、コンポーネント単位のオーバーヘッドは約 0.1 ms ですが、深刻なのは、数百個のコンポーネント × 0.1 ms のほうです。
CSS 変数のデフォルト値を各 Flex に書く理由
CSS カスタムプロパティは、デフォルトでは継承されます。たとえば次のようになります。
子要素が --lobe-flex-gap を明示的に定義していない場合、親要素の 16px を継承し、サブレイアウトに余分な間隔が入ります。
そこで内部 CSS では、すべての .lobe-flex でデフォルト値をリセットしています。
このデフォルト値の層は、避けられないコストです。「CSS 変数でコンポーネントのスタイルを制御する」类似于コードを読むときは、各階層でデフォルト値がリセットされているかを、そのまま確認できます。
5. ホーム画面の常駐:React Activity と「アクセス後のキャッシュ」
LobeHub のホーム画面(DesktopHomeLayout)には、次の要素が多数組み込まれています。
- サイドバー
- 最近の履歴
- 入力エリア
- スキル一覧
- 複数のモーダルへの導線
- 多数の store selector
- データ復元のロジック
旧来のルート構造は、概ね次の&Eyth Hell had. もっと自然に:
旧来のルート構造は、概ね次の通りでした。
設定画面からホームへ戻ると、完全なアンマウント・再構築の流れを通ります。
記事での測定では、DesktopHomeLayout へ戻る所要時間が 500 ms に迫りました。
変更:ホーム画面をルート分岐の外へ移す
変更後の構造は、概ね次の通りです。
index route がホームコンポーネントを個別に生成しなくなりました。
ホーム画面では、 internally 根据路径判断:
Activity の mode="hidden" 時の挙動は次のとおりです。
display: noneで内容を隠す- コンポーネントの React State を保持する
- 対応する DOM を保持する
- コンポーネントの Effect とサブスクリプションをクリーンアップする
- 非表示の間も、新しい props に低優先度で応答する可能性がある
- 再表示時に元の状態を復元し、Effect を再生成する
次のように考えられます。
Fiber、State、DOM:保留
Effect、订阅:清理
视觉:display: none
戻り時の主な処理は、次のようになります。
hidden → visible
↓
恢复 Effect
↓
浏览器重新展示已有 DOM
DOM ツリー全体を再構築する必要はなくなります。
hasActivated:アクセス後のキャッシュであり、起動時のプリレンダリングではない
実際のコードには、次の処理が追加されています。
ロジックは次の通りです。
用户从未访问首页 → 不创建首页
用户第一次访问首页 → 创建首页
用户以后离开首页 → 隐藏但保留
これは「アクセス後のキャッシュ」であり、「アプリ起動時に無条件にプリレンダリングする」ではありません。そう Broadway。用户が直接 /settings を開いた場合にも、重いホーム画面がバックグラウンドでマウントされ、起動時間とメモリ使用量が増えてしまいます。
絶対配置とそのコスト
常駐するホーム画面のコンテナでは、次を使っています。
ホーム画面は通常のルート出力に含まれなくなったため、他のページのドキュメントフローに参加しないようにする必要があります。表示中はメイン内容を覆い、非表示時は Activity によって非表示になります。
この最適化のコストは、常駐による占用です。
- Fiber のメモリ
- DOM のメモリ
- コンポーネントの State
- キャッシュデータ
- ブラウザのレイアウトオブジェクト
向いている場面は次のとおりです。
- 高頻度で戻るホーム画面
- エディター
- チャットセッション
- 入力状態を保持したいページ
向いていない場面は次のとおりです。
- めったに戻らないページ
- DOM が特に巨大なレポート
<video>/<audio>/<iframe>が大量に Included ページ
<video>、<audio>、<iframe> は非表示にしても DOM が残り、それぞれの動作を実行し続ける可能性があります。Effect のクリーンアップ関数で明示的に停止するか、リソースを解放してください。
7. Tooltip のシングルトン化:1 つの浮層を共有する
旧 Tooltip の各インスタンスは、次を持つ可能性がありました。
useFloatingによる位置決めロジック- マウスの enter / leave タイマー
- Focus と Hover のインタラクション Hook
- Portal
- Arrow の位置決め
- Open State
- Motion アニメーション
ResizeObserver- Scroll リスナー
- 位置を自動更新するロジック
ページに 200 個のアイコンがあり、それぞれ Tooltip で包んでいる場合を考えます。
Tooltip が一度も開いていなくても、各インスタンスのトリガーと Hook は生成されます。記事内の TooltipFloating は約 200 行で、Floating UI、debounce、AnimatePresence、動的な transformOrigin、コンテンツ切替アニメーション、矢印ロジック、Portal、レイアウトアニメーションを含みます。
シングルトン Tooltip の構造
各 Tooltip が登録するのは、次の情報だけです。
触发节点
title
placement
回调
Group 全体で、実際の浮層を 1 つ共有します。
一个 Root
一个 Portal
一个 Floating Content
一个 Arrow
一套定位状态
あるトリガーにマウスが入ると、現在の payload だけを更新します。
浮層は新しい anchor の位置へ移動します。各ボタンが完全な浮層コンポーネントツリーを作る必要はありません。
実際の実装は Base UI の <BaseTooltip.Root handle={handle}> をベースに、Group Context で handle を共有しています。通常の Tooltip は Group 内にあると共有モードに切り替わります。制御型 Tooltip や、defaultOpen、明示的な standalone を持つ Tooltip は個別の実装を維持します。
Base UI の方が完全な UI ライブラリより最適化しやすい理由
Base UI は Headless コンポーネントで、主に次を提供します。
- アクセシビリティ
- インタラクションの状態管理
- 位置決め
- キーボード操作
- ARIA
- 基本的な Portal
スタイルとアニメーションはプロジェクト側で制御します。完全なコンポーネントライブラリ相比较ると、以下を負担しなくて済みます。
- 互換レイヤー
- テーマのラップ
- 複数層の Context
- デフォルトの DOM
- デフォルトのアニメーション
- スタイル注入のロジック
より正確な表現は「Ant Design が遅く、Base UI が速い」ではなく、「LobeHub は Base UI を基盤に、より小さなコンポーネントツリーと共有浮層を実装できる」です。基礎ライブラリを置き換える価値があるかは、自分のプロジェクトでこのパターンをどれだけ使うか次第です。
9. Electron のバンドルサイズと、ネイティブモジュールで重要な 3 つのこと
Electron のメインプロセスは、もともと次のような依存を直接参照していました。
ビルドツールがすべての依存を externalize すると、成果物には require('some-package') だけが残ります。実行時には、完全版の node_modules/some-package を持っていなければなりません。node_modules 全体には、次のようなものが含まれます。
- 使用していないファイル
- README
- 型宣言
- ソースマップ
- 複数のモジュール形式
- テストコード
- オプションリソース
- 重複した依存
通常の JS 依存をバンドルする
最適化後の electron-vite は、すべての依存を externalize しなくなりました。通常の JavaScript パッケージは Rollup が取り込みます。
dist/main/index.js
dist/preload/index.js
Rollup が同時に次を行います。
- Tree Shaking
- 重複の削除
- 圧縮
- 依存のインライン化
- 参照されていないコードの削除
electron-builder の設定は次のとおりです。
node_modules 全体を除き、保持すべきネイティブ依存だけ戻します。
ネイティブモジュールは一緒にバンドルしない
たとえば次のようなパッケージがあります。
better-sqlite3
sharp
node-mac-permissions
これらのパッケージには .node ファイルが含まれることがあります。これはネイティブの動的ライブラリで、JavaScript ではありません。Node.js が読み込むには、実際のファイルパスが必要です。
そのため、ネイティブモジュールは次の 3 つを満たす必要があります。
Rollup ではバンドルしない
生成結果には require('node-mac-permissions') が残ります。
electron-builder でインストールパッケージに含める
ASAR から展開する
最終的なファイルは app.asar.unpacked/node_modules/... に配置されます。
プロジェクトでは、ネイティブモジュールの package.json を再帰的に読み取り、その推移的依存も追加します。そうしないと、ネイティブモジュール自体は存在しても、起動時に子依存の不足で失敗する可能性があります。
3 つのリストは一致していなければなりません。
external 列表 = builder include 列表 = asarUnpack 列表
1 つでも欠けていれば、「ビルドは成功しても、実行時は失敗する」ことになります。これは Electron のパッケージングで初心者最容易出线坑です。
Electron の言語リソースを削除する
Electron Framework には、Chromium のローカライズリソースが同梱されています。
en.lproj
zh_CN.lproj
ja.lproj
fr.lproj
これらは Chromium のネイティブ UI リソースであり、アプリ独自の i18n JSON ではありません。
ビルド完了後の afterPack Hook は、次のパスを走査します。
Electron Framework.framework/
Versions/A/Resources/*.lproj
英語に関係するディレクトリだけを残します。
すべて削除することはできません。Electron Framework には、基本的なローカライズリソースも必要だからです。
11. IPC の型安全性:文字列から Controller レジストリへ
従来の書き方は次のとおりです。
綴りが食い違っても TS では検出できません。チャネル名、引数、戻り値、メインプロセスの実装、レンダープロセスからの呼び出しが、それぞれ独立しています。メインプロセスのメソッドをリファクタリングしても、呼び出し側はコンパイルを通ったあと、実行時にエラーになる可能性があります。
Controller + デコレーター
メインプロセスは次のように変更されました。
チャネル名は windows.minimizeWindow と自動導出されます。Controller は controllerIpcConstructors に一元登録され、実行時の IPC Handler のインストールと、TypeScript によるサービス型の抽出の両方で使われます。
レンダープロセスでは Proxy を使う
ensureElectronIpc() では JavaScript Proxy を使ってプロパティアクセスを収集できます。
TypeScript の型は Controller レジストリから得られ、次を推論できます。
- 利用可能な Controller
- 利用可能なメソッド
- 引数
- 戻り値
ただし、TS の型はコンパイル時にしか存在しません。IPC はプロセス境界をまたぐ処理で、レンダープロセスが攻撃される可能性があります。そのため、メインプロセスでは実行時の引数検証が必要であり、TS だけに依存してはいけません。
13. Next.js から Vite へ:フロントエンドとバックエンドのビルド責務を分割
LobeHub のfowl方は、単純に Next.js を削除することではありません。責務を分割しています。
純粋な UI の開発時は Vite だけを起動し、完全な Next.js App Router、RSC、サーバー側のバンドル、ルート解析のフローは起動しません。
大規模な Next.js プロジェクトで開発時のメモリ消費が大きくなる理由
App Router のプロジェクトでは、開発中に次を同時に維持している可能性があります。
- クライアントモジュールのグラフ
- サーバーモジュールのグラフ
- RSC モジュールのグラフ
- ルートツリー
- Server Action
- Route Handler
- ミドルウェア
- Hot Module Replacement の状態
- ソースマップ
- ビルドキャッシュ
同じモジュールには、クライアント版、サーバー版、RSC 参照版が存在することもあります。Vite の純粋な SPA モードでは、主にブラウザ向けの ESM グラフだけを保持し、リクエストに応じてモジュールを変換します。UI だけを開発する 경우、プロセスモデルはずっと単純になります。
記事にある Next.js: 10 GB+、Vite: 约 1 GB は LobeHub プロジェクト自身での実験結果であり、すべてのプロジェクトで必ず 10 倍の差になるわけではありません。プロジェクト規模、プラグイン、ソースマップ、依存ツリー、Node.js のバージョン heck influencing result。
移行にかかるコスト
移行後は、次を 다시 handled。
- App Router から React Router へのルートマッピング
- SSR と SEO
- サーバー設定のフロントエンドへの注入
- 認証とルートガード
- Next API のプロキシ
- Vite と Next の 2 つの開発サーバー
- 静的アセットのパス
- Electron と Web のビルド差
- RSC コンポーネントの境界
これはアーキテクチャレベルの DX 最適化であり、next dev を vite に変えるだけのことではありません。今の Next.js がQJ不舒服 如果 smooth、未必 worth move。
参考
[1] LobeHub 性能と DX 最適化. innei. https://innei.in/posts/tech/lobehub-performance-dx-optimization
[2] antd-style createStaticStyles. antd-style. https://github.com/ant-design/antd-style
[3] Base UI. MUI. https://base-ui.com/
[4] React Activity (Offscreen コンポーネント). React. https://react.dev/blog/2025/04/23/react-19-updates
[5] class-variance-authority. https://github.com/joebell.co.uk/cva
[6] electron-vite. https://electron-vite.org/
[7] electron-builder. https://www.electron.build/