ターミナルの中の Redux:状態管理、ミドルウェア、そして Effects パターン
前提知識
- ›第2回:IPC とセッションライフサイクル
- ›Redux ミドルウェアの基本概念
- ›イミュータブルなデータ構造パターン
ターミナルの中の Redux:状態管理、ミドルウェア、そして Effects パターン
ターミナルエミュレーターの状態管理に Redux を使うというのは、少し変わった選択です。ターミナルはスループットの高いシステムで、キーストロークのたびに、出力行のたびに状態変化が発生します。Redux の素朴な使い方 — アクションをディスパッチしてリデューサーを通し、React に再レンダリングさせる — をそのまま適用すると、ターミナル出力においては壊滅的な遅さになるでしょう。
Hyper はこの問題を、一般的な Redux アプリケーションでは見かけないミドルウェアチェーンで解決しています。チェーンには thunk が 2回 登場し、最もホットなコードパスでは React レンダリングを完全にスキップする write ミドルウェアがあり、さらにプラグインによるインターセプトポイントを保ちながら副作用をアクションと同居させる独自の "effects" パターンも存在します。
ストアの形:3 つのスライス
Redux ストアは 3 つのスライスから構成されています。
classDiagram
class HyperState {
+ui: uiState
+sessions: sessionState
+termGroups: ITermState
}
class uiState {
+fontFamily: string
+fontSize: number
+colors: ColorMap
+cursorColor: string
+css: string
+termCSS: string
+backgroundColor: string
+foregroundColor: string
+maximized: boolean
+fullScreen: boolean
+bellSound: string
+notifications: Notification[]
}
class sessionState {
+sessions: Record~string, session~
+activeUid: string | null
}
class ITermState {
+termGroups: Record~string, ITermGroup~
+activeSessions: Record~string, string~
+activeRootGroup: string | null
}
HyperState --> uiState
HyperState --> sessionState
HyperState --> ITermState
ui— フォント設定、カラー、カーソルスタイル、カスタム CSS、ウィンドウ状態(最大化・フルスクリーン)、ベル設定、通知メッセージなど、見た目に関わるすべての情報を管理します。いわばターミナルの「テーマ」です。sessions— UUID をキーとしたアクティブなターミナルセッションを管理します。各セッションのタイトル、サイズ、クリア状態、検索状態、シェル情報などを保持しています。termGroups— 分割ペインのツリー構造を管理します。3 つのスライスの中で最もアーキテクチャ的に興味深い部分であり、後ほど詳しく見ていきます。
3 つのスライスすべてに seamless-immutable が使われています。これは開発モードでオブジェクトを深くフリーズし、意図しないミューテーションを検出するライブラリです。また各リデューサーは decorateReducer でラップされており、プラグインが状態処理を拡張できるようになっています。こちらについては第 5 回で詳しく解説します。
ミドルウェアチェーンの解説
ミドルウェアのパイプラインは、Hyper の Redux アーキテクチャの中核です。
lib/store/configure-store.dev.ts#L16
applyMiddleware(thunk, plugins.middleware, thunk, writeMiddleware, effects)
5 つのミドルウェアが左から右へと適用されます。それぞれの役割と順序の意味を追っていきましょう。
flowchart LR
A["Action dispatched"] --> B["thunk₁"]
B --> C["plugins.middleware"]
C --> D["thunk₂"]
D --> E["writeMiddleware"]
E --> F["effects"]
F --> G["Reducers"]
style B fill:#e1f5fe
style C fill:#fff3e0
style D fill:#e1f5fe
style E fill:#ffebee
style F fill:#e8f5e9
thunk₁ — 最初の thunk は、Hyper 自身のサンク化されたアクションクリエーターを処理します。addSession や requestSession のようなアクションは、dispatch と getState を受け取る関数です。この thunk がそれらをプレーンなアクションオブジェクトに解決してから、プラグインミドルウェアへと渡します。
plugins.middleware — プラグインが提供するミドルウェアはここで実行されます。アプリ側の thunk が解決された後、かつアクションがストアに到達する前というタイミングです。これがインターセプションポイントです。プラグインはここで任意のアクションを変更・遅延・破棄できます。すべてのプラグインミドルウェアが順番にチェーンされています。
lib/utils/plugins.ts#L579-L583
thunk₂ — 2 つ目の thunk は、プラグインが 新たなサンク化されたアクションを生成する 可能性があるために存在します。プラグインのミドルウェアがプレーンオブジェクトではなく関数をディスパッチした場合、この 2 つ目の thunk がそれを解決します。これがないと、プラグインが生成したサンクはクラッシュします。
writeMiddleware — パフォーマンスに直結する重要なミドルウェアです。後ほど詳しく解説します。
effects — リデューサーが処理を終えた 後 に、アクションに付属した副作用関数を実行します。
Write ミドルウェア:パフォーマンスのために React をバイパスする
チェーンの中で最も重要なミドルウェアです。
const writeMiddleware: Middleware = () => (next) => (action) => {
if (action.type === 'SESSION_PTY_DATA') {
const term = terms[action.uid];
if (term) {
term.term.write(action.data);
}
}
next(action);
};
SESSION_PTY_DATA アクション(ターミナル出力のバッチごとに発生します)が届くと、write ミドルウェアはグローバルなレジストリから xterm.js の Terminal インスタンスを直接取得してデータを書き込みます。React のレンダリングサイクルを完全に迂回する仕組みです。
このミドルウェアがない場合を考えてみましょう。ターミナルデータはリデューサー → ストア更新 → React リコンシリエーション → コンポーネント再レンダリング → xterm.write() という経路をたどります。本質的に命令的な操作に対して、少なくとも 3 つの不要なステップが加わります。write ミドルウェアはこれをミドルウェア → xterm.write() のみに短縮します。
グローバルな terms レジストリはシンプルそのものです。
const terms: Record<string, Term | null> = {};
Term コンポーネントは componentDidMount でこのオブジェクトに自身を登録し、componentWillUnmount で登録を解除します。write ミドルウェアはこのレジストリの主要な利用者です。
flowchart TD
A["SESSION_PTY_DATA action"] --> B["writeMiddleware"]
B --> C{"terms[uid] exists?"}
C -->|Yes| D["term.term.write(data)"]
C -->|No| E[Skip]
D --> F["next(action)"]
E --> F
F --> G["Reducers update lastActivity timestamp"]
G --> H["React re-renders tab title, NOT terminal content"]
ポイント: アクションは
next(action)を通じてリデューサーにも届きます。セッションリデューサーはSESSION_PTY_DATAを受け取るとlastActivityタイムスタンプを更新し、どのタブに最近のアクティビティがあるかを示すために使用されます。つまり React は再レンダリングを 行う のですが、それはタブインジケーターのみです。ターミナルのキャンバス自体は再レンダリングされません。
Effects パターン
Hyper の effects ミドルウェアは、副作用をアクションと同居させるためのシンプルかつ強力なパターンを提供します。
const effectsMiddleware: Middleware = () => (next) => (action) => {
const ret = next(action);
if (action.effect) {
action.effect();
delete action.effect;
}
return ret;
};
このミドルウェアは next(action) の 後 に動作します。つまりリデューサーがすでにアクションを処理し終えたタイミングです。その後、アクションオブジェクトに effect() 関数が付いていれば実行します。セッションアクションの実例を見てみましょう。
lib/actions/sessions.ts#L40-L51
export function requestSession(profile) {
return (dispatch, getState) => {
dispatch({
type: SESSION_REQUEST,
effect: () => {
const {ui} = getState();
const {cwd} = ui;
rpc.emit('new', {cwd, profile});
}
});
};
}
なぜ thunk の中で rpc.emit を直接呼ばないのでしょうか?答えはプラグインにあります。effects ミドルウェアはチェーン内でプラグインミドルウェアの 後 に位置しています。プラグインが SESSION_REQUEST をインターセプトして next() を呼ばなかった場合、effect も実行されません。これにより、プラグインは状態変化 と それに紐づく副作用の両方を、明確な形でインターセプトできます。
lib/actions/sessions.ts#L53-L69 の addSessionData アクションはネストされたディスパッチパターンを示しています。SESSION_ADD_DATA の effect が SESSION_PTY_DATA をディスパッチし、それが write ミドルウェアを起動します。この 2 段階のディスパッチにより、プラグインはメタデータが記録される前と、ターミナルに書き込まれる前という 2 つの層でデータフローをインターセプトできます。
termGroups:イミュータブルな分割ペインツリー
termGroups リデューサーは、seamless-immutable を用いてツリー構造で Hyper の分割ペイン UI をモデル化しています。
lib/reducers/term-groups.ts#L13-L29
各 ITermGroup ノードは次のプロパティを持ちます。
uid— 一意な識別子sessionUid— リーフノードの場合、そこに含まれるターミナルセッション(親ノードの場合は null)parentUid— 親ノードへの参照(ルートの場合は null)direction— 分割方向:'HORIZONTAL'または'VERTICAL'(リーフの場合は null)sizes— 子ノードの比率サイズの配列(null の場合は均等配分)children— 子グループの UID の配列
graph TD
R["Root Group<br/>direction: VERTICAL<br/>sizes: [0.5, 0.5]"]
L["Left Group<br/>sessionUid: 'abc123'"]
P["Right Parent<br/>direction: HORIZONTAL<br/>sizes: [0.5, 0.5]"]
T["Top Group<br/>sessionUid: 'def456'"]
B["Bottom Group<br/>sessionUid: 'ghi789'"]
R --> L
R --> P
P --> T
P --> B
ユーザーがペインを分割した際のツリー変換は splitGroup 関数が担います。分割方向が親ノードの方向と一致するかどうかによって、既存の親にシブリングを追加するか、新しい親ノードを作成するかをアルゴリズムが判断します。
サイズの再調整は比率ベースで行われます。挿入時は insertRebalance が新しいペインのスペースを比率に応じて分配します。大きなペインほど絶対的に多くのスペースを譲る形です。削除時は removalRebalance が解放されたスペースを残りのシブリングに均等に分配します。
replaceParent ヘルパーは、子が 1 つしかない親ノードを整理します。分割を閉じた結果として親ノードが子を 1 つしか持たなくなった場合、その親を取り除き、子がツリー上の親の位置を引き継ぎます。これにより、不要なネストを防いでいます。
RPC から Redux へのディスパッチ配線
IPC イベントと Redux の状態変化を橋渡しするコードは lib/index.tsx#L72-L234 にあります。約 30 個の RPC イベントハンドラーがそれぞれ対応する Redux アクションをディスパッチします。
flowchart LR
subgraph "RPC Events (from Main)"
A["'session data'"]
B["'session add'"]
C["'session exit'"]
D["'split request horizontal'"]
E["'increase fontSize req'"]
F["'move left req'"]
end
subgraph "Redux Actions"
G["SESSION_PTY_DATA"]
H["SESSION_ADD"]
I["TERM_GROUP_EXIT"]
J["REQUEST_HORIZONTAL_SPLIT"]
K["UI_FONT_SIZE_INCREASE"]
L["UI_MOVE_LEFT"]
end
A --> G
B --> H
C --> I
D --> J
E --> K
F --> L
'session data' ハンドラーには注目すべき最適化があります。文字列の先頭 36 文字から UUID を取り出してからディスパッチしています(第 2 回で紹介した DataBatcher の UID 付加処理を思い出してください)。
rpc.on('session data', (d: string) => {
const uid = d.slice(0, 36);
const data = d.slice(36);
store_.dispatch(sessionActions.addSessionData(uid, data));
});
これはゼロコピーの解析です。一般的なケースで JSON のデシリアライズもオブジェクトのアロケーションも発生しません。
次回予告
Redux を通じたデータフローと、ミドルウェアチェーンがターミナルレンダリングを高速に保つ仕組みを見てきました。しかし Redux ストアが持つのは 状態 だけです。その状態をピクセルに変換する仕組みが別途必要です。次回は、Hyper が xterm.js を React コンポーネントとしてどのようにラップしているかを見ていきます。WebGL/Canvas レンダラーの選択と自動フォールバック、Mousetrap と xterm 独自のキーハンドリングによる 2 層のキーボードシステムについても詳しく解説します。