我写了一个框架无关的终端键盘引擎,顺便把它在 React 里用了起来
起因:终端键盘事件的混乱程度
做终端应用,Ink 是个非常不错的选择 React 组件直接渲染到终端,useInput 处理键盘事件 写几个if else即可。
然后需求开始膨胀。页面从一页变五页,加个模态弹窗,再来个全局快捷键,Tab 切换焦点要自己写状态机......
这时候你的 useInput 变成了这样:
tsx
useInput((input, key) => {
if (key.escape) { /* 关弹窗?还是返回上一页?还是退出?取决于当前在哪 */ }
if (key.tab) { /* 手动算下一个焦点元素 */ }
if (key.return) { /* 是提交表单?还是确认对话框?还是触发搜索? */ }
if (input === 'j') { /* 列表下移 */ }
if (input === 'k') { /* 列表上移 */ }
// ... 查了下,还有 200 行的 if-else
});
问题不在于代码多,而在于冲突无法避免。 模态框应该拦截所有按键,全局快捷键应该在任何页面生效,某个组件的 j/k 导航不应该干扰到另一个组件的 j/k。这些规则用一坨条件判断是很难理清的。
于是我写了 一个 7 阶段管线键盘引擎,从 Ink 里拆了出来,做成框架无关的独立 npm 包。然后基于它,又写了一整套 React/Ink 库对接这个引擎。
键盘引擎:7 步裁决,谁该响应
核心思想很简单:每个层级只管自己关心的键,管线决定谁赢。
markdown
按键按下
│
▼
0. Modal 模态框独占 --- 不放行就全拦截
1. GlobalSeq 全局序列键(覆盖浮层)
2. GlobalKeys 全局单键(覆盖浮层)
3. Overlay 浮层并行广播
4. GlobalSeq 全局序列键(不覆盖浮层)
5. GlobalKeys 全局单键(不覆盖浮层)
6. Screen Stack 页面栈从上往下遍历,首个命中消费
每一阶段都是独立的处理器。返回 true 就消费事件,后面不再处理。唯一的例外是第 3 阶段(Overlay),始终返回 false,让管线继续往下走。
这套机制覆盖了实际项目中几类典型的冲突场景:
场景一:模态框优先。 确认对话框打开时,按 escape 应该关闭对话框,而不是退出当前页面。Modal 阶段(优先级最高)吃掉了 escape,后面的页面栈根本收不到。
场景二:全局快捷键 > 页面快捷键。 Ctrl+C 应该在任何地方都能退出。把它挂在 GlobalKeys 里,比所有页面绑定都优先。
场景三:同键不同页,互不干扰。 菜单页按 q 退出,设置页按 q 返回上一页------每个页面只管自己的 boundKeyboard,离开页面就自动解绑,不需要写"当前在第几页"的判断。
场景四:浮层穿透。 搜索框弹出时,列表页的 j/k 导航应该暂时被覆盖。让搜索框作为一个 Overlay 挂载,它比页面栈优先。
引擎本身是框架无关的
这是我觉得最值得提的设计决策------整个键盘引擎不依赖任何 React API
它就是一个纯 TypeScript 类的状态机:
ts
class KeyboardEngine {
sync(state) // 框架把状态同步进来
processKey(key) // 框架把按键事件抛进来
// 引擎内部走 7 阶段管线,返回结果
}
这意味着你能用它在任何地方(理论上任何js运行时都可以接入):
bash
npm install @cartridge-engine/keyboard-engine
ts
// Vue 3
import { KeyboardEngine } from '@cartridge-engine/keyboard-engine';
const engine = new KeyboardEngine();
// 用 Vue 的 onKeydown 驱动它
// Svelte
import { KeyboardEngine } from '@cartridge-engine/keyboard-engine';
const engine = new KeyboardEngine();
// 用 svelte:window on:keydown 驱动它
// 甚至纯 Node.js(blessed、blessed-contrib、neo-blessed)
import { KeyboardEngine } from '@cartridge-engine/keyboard-engine';
const engine = new KeyboardEngine();
process.stdin.on('keypress', (ch, key) => engine.processKey(key));
引擎负责决策,框架负责渲染。 这是它和市面上所有终端键盘方案最大的不同:大多数方案是直接绑死在某个框架上的。
而且 @cartridge-engine/keyboard-engine 已经是独立 npm 包,有自己的版本号和生命周期,不和 React/Ink 捆绑。
引擎之上:ink-cartridge
引擎有了,React/Ink 的适配层就是顺理成章的事了。
ink-cartridge 本质上做了两件事:
- 用
KeyboardProvider把引擎接入了 React 生命周期 - 用 hooks(
boundKeyboard、penetration、stop、globalKeys等)暴露了管线的 API
围绕键盘引擎,又搭了一整套终端应用的基础设施:
屏幕系统 树形页面导航。 每个组件注册为一个屏幕,形成父子树。skip() 进入页面,back() 返回上级,gotoScreen() 跨分支跳转。Overlay 和 Modal 是独立的浮层通道,导航自动清理(当然这个库还提供了持久化的浮层和模态框 意味着即使页面切换这些东西还会悬浮在屏幕上 而且通过与引擎配合 当页面切换后这些持久化的东西的键盘响应会被自动接管防止意外发生)。
tsx
registerComponent(Menu, {});
registerComponent(Settings, {}, { parent: Menu });
registerComponent(About, {}, { parent: Menu });
// 树: Menu
// / \
// Settings About
焦点系统 每个可交互组件给个 focusId,Tab/Shift+Tab 自动循环,同一个页面内各组件不会抢键盘焦点。
关于组件 文本输入、多选、搜索栏、进度条、标签页、折叠面板、表单验证......所有交互组件都集成了焦点系统。
另外:键盘引擎决定了下限,但屏幕系统和框架调用方的使用也同样重要,ink-cartridge之所以可以无缝接入引擎,是因为他本身也提供了一个恰当合适的Screen管理系统
Monorepo 结构
整个仓库是 monorepo:
css
ink-cartridge/
├── src/ ← ink-cartridge 本体(React/Ink 组件 + 适配层)
│ ├── keyboard/ ← KeyboardProvider、hooks
│ ├── screen/ ← 屏幕系统
│ ├── components/ ← 17 个组件
│ └── ...
├── src/keyboard-engine/ ← @cartridge-engine/keyboard-engine(框架无关)
└── docs/ ← 全套 API 文档
每次发布,各自推送npm
项目对AI的态度
如今AI已经非常强大了,他可以将开发效率提升好几倍,我很推荐人机合作的开发模式,但不意味着无限的包容AI产出的东西,AI可以为项目写文档,注释,但核心逻辑必须由人理解,审查
最后
这个项目是我在实际写终端应用时被痛出来的。如果你也在做终端方向的东西,被键盘事件搞烦了,或者只是好奇怎么把复杂的输入模型抽象干净,欢迎来看看。
- 仓库: github.com/BAIGAOa/ink...
- 键盘引擎 npm: @cartridge-engine/keyboard-engine
- 仓库 npm: ink-cartridge
- 欢迎提Issue,pr
感谢大家