在终端中使用 Redux:状态管理、中间件与副作用模式
前置知识
- ›第 2 篇:IPC 与 Session 生命周期
- ›Redux 中间件基础概念
- ›不可变数据结构模式
在终端中使用 Redux:状态管理、中间件与副作用模式
用 Redux 来管理终端模拟器的状态,是一个不寻常的选择。终端是高吞吐量系统,每一次按键、每一行输出都会触发状态变更。如果只是简单地将 action 派发给 reducer、让 React 重新渲染,对于终端输出来说将是灾难性的性能瓶颈。
Hyper 的解决方案是一条与普通 Redux 应用截然不同的中间件链。它包含出现了 两次 的 thunk、一个完全绕过 React 渲染的 write 中间件(专门针对最热路径进行优化),以及一套自定义的"副作用(effects)"模式——既能将 side effect 与 action 放在一起管理,又保留了插件的拦截能力。
Store 结构:三个切片
Redux store 由三个切片组合而成:
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 为键的活跃终端 session 集合。每个 session 记录标题、尺寸、清屏状态、搜索状态和 Shell 信息。termGroups— 分屏面板的树形结构。这是架构上最有趣的切片,我们将在后文详细介绍。
三个切片都使用了 seamless-immutable——这个库会在开发模式下对对象进行深度冻结,以便及早发现意外的状态变更。每个 reducer 还会被 decorateReducer 包装,以支持插件扩展状态处理逻辑——这部分内容将在第 5 篇中展开。
中间件链详解
中间件管道是 Hyper Redux 架构的核心:
lib/store/configure-store.dev.ts#L16
applyMiddleware(thunk, plugins.middleware, thunk, writeMiddleware, effects)
五个中间件,从左到右依次执行。让我们来看看每一个的作用,以及顺序为何如此重要:
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 自身的 thunk action creator。addSession、requestSession 等 action 本质上是接收 dispatch 和 getState 的函数,这个 thunk 会将它们解析为普通的 action 对象,再传递给插件中间件。
plugins.middleware — 插件提供的中间件在此执行。此时应用自身的 thunk 已被解析,但 action 尚未进入 store。这是插件的拦截点:插件可以修改、延迟或丢弃任意 action。该中间件会将所有插件中间件顺序串联:
lib/utils/plugins.ts#L579-L583
thunk₂ — 第二个 thunk 的存在是为了处理插件 生成的新 thunk action。当插件中间件派发的是一个函数而非普通对象时,这个 thunk 负责将其解析。没有它,插件生成的 thunk 就会直接报错。
writeMiddleware — 性能关键的中间件,我们将在下一节详细分析。
effects — 在 reducer 处理完 action 之后,执行 action 上附加的 side effect 函数。
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 action 到达时(每批终端输出都会触发),write 中间件会直接从全局注册表中取出对应的 xterm.js Terminal 实例并写入数据,完全绕开 React 的渲染周期。
如果没有这个中间件会发生什么?终端数据将经历:reducer → store 更新 → React reconciliation → 组件重渲染 → 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"]
提示: action 依然会通过
next(action)传递给 reducer。session reducer 在收到SESSION_PTY_DATA时会更新lastActivity时间戳,用于标记哪个标签页有近期活动。所以 React 确实 会重新渲染——但只是标签页上的活动指示器,而非终端画布本身。
副作用(Effects)模式
Hyper 的 effects 中间件提供了一种简洁而强大的模式,用于将 side effect 与 action 放在一起管理:
const effectsMiddleware: Middleware = () => (next) => (action) => {
const ret = next(action);
if (action.effect) {
action.effect();
delete action.effect;
}
return ret;
};
该中间件在 next(action) 之后 运行,也就是说 reducer 已经处理完 action。接着它检查 action 对象上是否有 effect() 函数,有则执行。下面是来自 session action 的一个实际例子:
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 并阻止其到达 reducer(通过不调用 next()),对应的 effect 也不会执行。这让插件能够干净地同时拦截状态变更 及其关联的 side effect。
lib/actions/sessions.ts#L53-L69 中的 addSessionData action 展示了一种嵌套派发模式:SESSION_ADD_DATA 的 effect 会派发 SESSION_PTY_DATA,后者正是触发 write 中间件的 action。这种两步派发确保插件可以在两个层面拦截数据流——在记录元数据之前,以及在写入终端之前。
Term Groups:不可变分屏树
termGroups reducer 使用 seamless-immutable 将 Hyper 的分屏 UI 建模为树形结构:
lib/reducers/term-groups.ts#L13-L29
每个 ITermGroup 节点包含:
uid— 唯一标识符sessionUid— 若为叶节点,表示其包含的终端 session(父节点为 null)parentUid— 父节点引用(根节点为 null)direction— 分割方向:'HORIZONTAL'或'VERTICAL'(叶节点为 null)sizes— 子节点的比例尺寸数组(null 表示等分)children— 子 group 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 辅助函数用于折叠只有一个子节点的父节点。关闭某个分屏后,如果父节点只剩一个子节点,父节点将被移除,子节点直接取代其在树中的位置,避免不必要的嵌套层级。
RPC 事件与 Redux 派发的连接
IPC 事件与 Redux 状态变更之间的桥接逻辑位于 lib/index.tsx#L72-L234。大约 30 个 RPC 事件处理器各自对应一个 Redux action 的派发:
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 前缀机制),然后再派发 action:
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 store 只负责保存 状态——还需要有机制将状态转化为屏幕上的像素。下一篇文章将介绍 Hyper 如何用 React 组件封装 xterm.js、如何实现 WebGL/Canvas 渲染器的自动降级选择,以及如何在 Mousetrap 与 xterm 自身的键盘处理之间协调出一套两级键盘系统。