Read OSS

在终端中使用 Redux:状态管理、中间件与副作用模式

高级

前置知识

  • 第 2 篇:IPC 与 Session 生命周期
  • Redux 中间件基础概念
  • 不可变数据结构模式

在终端中使用 Redux:状态管理、中间件与副作用模式

用 Redux 来管理终端模拟器的状态,是一个不寻常的选择。终端是高吞吐量系统,每一次按键、每一行输出都会触发状态变更。如果只是简单地将 action 派发给 reducer、让 React 重新渲染,对于终端输出来说将是灾难性的性能瓶颈。

Hyper 的解决方案是一条与普通 Redux 应用截然不同的中间件链。它包含出现了 两次 的 thunk、一个完全绕过 React 渲染的 write 中间件(专门针对最热路径进行优化),以及一套自定义的"副作用(effects)"模式——既能将 side effect 与 action 放在一起管理,又保留了插件的拦截能力。

Store 结构:三个切片

Redux store 由三个切片组合而成:

lib/reducers/index.ts

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。addSessionrequestSession 等 action 本质上是接收 dispatchgetState 的函数,这个 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 提升性能

这是整个链路中最关键的中间件:

lib/store/write-middleware.ts

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 注册表的实现非常简洁:

lib/terms.ts

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 放在一起管理:

lib/utils/effects.ts

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 自身的键盘处理之间协调出一套两级键盘系统。