还在手写 if-else 响应按键?我用 React-Ink 搭配键盘引擎解决了

Keyboard-engine

上一篇介绍了我写的键盘引擎的设计思路,这篇我们聚焦于API与代码

首先你可以安装已经用React-ink封装好了引擎的完整库使用(本文章将使用React-ink进行讲解)

bash 复制代码
npm install ink-cartridge

ink-cartridge 内置了 @cartridge-engine/keyboard-engine,React 这套适配层ink-cartridge本身已经封装好了。但是如果你用的是其他框架,你可以安装@cartridge-engine/keyboard-engine 直接接入你正在使用的框架,正确接入后,你的应用会获得一套完整的键盘系统。

10 秒跑起来

tsx 复制代码
import { KeyboardProvider, useKeyboard } from 'ink-cartridge';
import { useInput } from 'ink';

function App() {
  return (
    <KeyboardProvider>
      <MyScreen />
    </KeyboardProvider>
  );
}

function MyScreen() {
  const { boundKeyboard } = useKeyboard();

  useEffect(() => {
    return boundKeyboard(['q'], () => process.exit(0));
  }, []);

  return <Text>按 q 退出</Text>;
}

KeyboardProvider 会创建一个引擎实例并接入 React 生命周期。useKeyboard 取出当前层级的方法。boundKeyboard 返回一个 unbind 函数,直接丢给 useEffect 的 return 就自动管理生命周期------组件卸载的时候绑定自动清理(如果不清理引擎默认你这个键会冒泡到下一层)。

1. boundKeyboard:核心方法,按什么键干什么事

基本用法

tsx 复制代码
const { boundKeyboard } = useKeyboard();

useEffect(() => {
  // 单键
  const unbind1 = boundKeyboard(['ctrl+s'], () => saveFile());

  // 多键触发同一个回调
  const unbind2 = boundKeyboard(['q', 'escape'], () => back());

  // 可以用 * 匹配任意按键
  const unbind3 = boundKeyboard(['*'], (input) => handleAnyKey(input));

  return () => { unbind1(); unbind2(); unbind3(); };
}, []);

每个页面只管自己的绑定,切走就自动解绑,不会污染别的页面。

onlyThis:只在栈顶生效

你的列表页绑了 j/k 导航,设置页也绑了 j/k 做别的事。两个页面叠在一起的时候,引擎按管线优先级裁决。但如果列表页加了 onlyThis: true,别的页面叠上来时列表页的 j/k 自动失活。

tsx 复制代码
function ListScreen() {
  const { boundKeyboard } = useKeyboard();

  useEffect(() => {
    return boundKeyboard(['j'], () => moveDown(), { onlyThis: true });
  }, []);
  // ...
}

times:按多少次才触发

tsx 复制代码
// 按两次 q 才退出
boundKeyboard(['q'], () => process.exit(0), { times: 2 });

// observer 能拿到还剩几次
boundKeyboard(['space'], () => superMove(), {
  times: 3,
  observer: (remaining) => console.log(`还剩 ${remaining} 次`),
});

once:一次性绑定,"按任意键继续"那种

tsx 复制代码
boundKeyboard(['*'], () => nextScreen(), { once: true });

// 同时times可以与once进行配合
boundKeyboard(['a'], () => nextScreen(), { once: true, times: 2})
// 此时现在的情况就是按了两次之后 这个键不再响应 相当于自动销毁了 
// 而与不带once的区别则是 按键再循环了一次之后并不会自动销毁 会继续响应

触发后自动解绑,回调里抛异常也不影响解绑。

when:条件绑定

一个布尔值管多个绑定的开关:

tsx 复制代码
const [isEditing, setEditing] = useState(false);

useEffect(() => {
  return boundKeyboard(['j'], () => moveDown(), { when: () => !isEditing });
}, [isEditing]);

isEditing 变了之后下次按键时条件就会重新评估,不需要手动 bind/unbind。

同时我们也支持条件命名:

tsx 复制代码
// 不用条件系统:每个绑定各自判断,同一个条件写好几遍
boundKeyboard(['j'], () => moveDown(), { when: () => !isEditing });
boundKeyboard(['k'], () => moveUp(), { when: () => !isEditing });
boundKeyboard(['/'], () => startSearch(), { when: () => !isEditing });
// isEditing 的逻辑散落在三处,改起来要命

// 用条件系统:注册一次,到处引用
addCondition('isEditing', false);

boundKeyboard(['j'], () => moveDown(), { when: 'isEditing' });
boundKeyboard(['k'], () => moveUp(), { when: 'isEditing' });
boundKeyboard(['/'], () => startSearch(), { when: 'isEditing' });

// 一切换,三个绑定同时变
setCondition('isEditing', true);

mode:比 when 更干净的多模式切换

tsx 复制代码
<KeyboardProvider modes={['normal', 'insert']} defaultMode="normal">
  <Editor />
</KeyboardProvider>

function Editor() {
  const { boundKeyboard, setMode } = useKeyboard();

  useEffect(() => {
    const unbinds = [
      boundKeyboard(['j'], () => moveDown(), { mode: 'normal' }),
      boundKeyboard(['k'], () => moveUp(), { mode: 'normal' }),
      boundKeyboard(['*'], (input) => insertChar(input), { mode: 'insert' }),
      // escape 不指定 mode,所有模式都生效
      boundKeyboard(['escape'], () => setMode('normal')),
    ];
    return () => unbinds.forEach(u => u());
  }, []);

  return (
    <Box>
      <Text>当前模式: normal --- 按 i 进入编辑</Text>
    </Box>
  );
}

这里仿照了vim 但更加轻量

2. penetration 和 stop:控制事件怎么流

penetration:把键"放生",让它透到下面的层

搜索框浮在列表页上面,你希望字母被搜索框吃掉,但 j/k/n/p 继续导航列表。a-f 这种字符键让搜索框处理,列表导航键穿透:

tsx 复制代码
function SearchOverlay() {
  const { boundKeyboard, penetration } = useKeyboard();

  useEffect(() => {
    // j/k 和 n/p 穿透,下面的列表页继续响应
    const undoPenetration = penetration(['j', 'k', 'n', 'p']);
    const unbind = boundKeyboard(['escape'], () => closeSearch());

    return () => { undoPenetration(); unbind(); };
  }, []);
  // ...
}

还可以加条件------只在搜索框为空时才穿透:

tsx 复制代码
penetration(['j', 'k'], { when: () => query.length === 0 });

也可以限制到某个焦点目标:

tsx 复制代码
penetration(['j', 'k'], { focusId: 'searchInput' });

stop:阻断传播,不要再往下走了

tsx 复制代码
// 这些键在当前层处理完就停了,不往下传
stop(['escape', 'return']);

// 按 action ID 阻断------哪怕某天把 ctrl+s 改成了 cmd+s,stop 依然有效
stop(['save'], { stopAction: true });

// 条件阻断:有未保存的修改时才阻断,没修改就放行
stop(['escape'], { when: () => hasUnsavedChanges });

2.5. 浮层:多个弹窗同时在线,互不抢键盘

浮层不像模态框那么霸道------它默认不拦截任何按键,所有活跃的浮层并行接收同一个键盘事件,然后事件继续往下流到全局键和页面栈。

scss 复制代码
function DropdownOverlay() {
  const { boundKeyboard } = useKeyboard();


  useEffect(() => {
    const unbinds = [      boundKeyboard(['j'], () => moveDown()),
      boundKeyboard(['k'], () => moveUp()),
      boundKeyboard(['enter'], () => selectItem()),
      boundKeyboard(['escape'], () => closeDropdown()),
    ];
    return () => unbinds.forEach(u => u());
  }, []);
  // ...
}

同一个按键,多个浮层怎么分? 浮层阶段是广播------j 按键事件同时发给所有活跃浮层。如果搜索框和下拉菜单同时在屏幕上,两个都收到 j,各自处理各自的。都处理完之后事件继续往下走。浮层之间不互相干扰,也不吃事件。

怕浮层和下面的页面打架?penetrationstop 控制流向就够了------浮层上你只声明自己关心的键,不关心的键要么穿透要么就是不绑定,页面依旧响应。

3. 全局快捷键:globalKeys / globalSequence

boundKeyboard 是跟着页面走的,切走就没了。全局快捷键是应用生命周期的。

tsx 复制代码
function App() {
  const { globalKeys } = useKeyboard();

  useEffect(() => {
    globalKeys([
      {
        key: 'ctrl+c',
        operate: () => process.exit(0),
        affectOverlay: true,  // 有浮层时也生效
      },
      {
        key: 'escape',
        operate: () => closeTopOverlay(),
        category: '*',         // 所有页面都能用
      },
      {
        key: 'f1',
        operate: () => openHelp(),
        category: [Menu, Game], // 只在 Menu 或 Game 页面生效
      },
    ]);
  }, []);
}

affectOverlay 解决什么问题

一个典型的坑:全局绑定 escape 为"返回上一页"。弹窗弹出来的时候用户按 escape,应该关弹窗还是返回上一页?

  • affectOverlay: true --- 弹窗弹出来时这个键依然全应用生效(Ctrl+C 退出,任何情况都要能用)
  • affectOverlay: false(默认)--- 有弹窗时弹窗优先,弹窗没处理才轮到全局键

cover:允许页面覆盖吗

tsx 复制代码
globalKeys([{
  key: 'h',
  operate: () => showHelp(),
  cover: false,  // 页面不能覆盖这个键,强行覆盖会抛异常
}]);

globalSequence:全局多键序列

tsx 复制代码
globalSequence([
  {
    keys: ['g', 'g'],
    operate: () => scrollToTop(),
    timeout: 500,
  },
  {
    keys: ['ctrl+w', 'q'],
    operate: () => process.exit(0),
    exclusive: true,   // 只认这个序列,按错不触发别的
  },
]);

查一下有没有序列在等待中,UI 可以提示:

tsx 复制代码
const { thereGlobalQueueWaiting, getGlobalPendingSequence } = useKeyboard();

// 简单版
if (thereGlobalQueueWaiting()) {
  return <Text dimColor>等待序列下一键...</Text>;
}

// 详细版------比如在状态栏显示
const pending = getGlobalPendingSequence();
if (pending) {
  console.log(`等待第 ${pending.nextIndex + 1}/${pending.sequences.length} 个键`);
}

4. boundSequence:页面级别的多键序列

和 globalSequence 一样的机制,不过是跟着页面生命周期走的:

tsx 复制代码
function MyScreen() {
  const { boundSequence } = useKeyboard();

  useEffect(() => {
    // Ctrl+K 然后 Ctrl+C
    const unbind1 = boundSequence(['ctrl+k', 'ctrl+c'], () => console.log('触发'));

    // 自定义超时(默认 500ms)
    const unbind2 = boundSequence(['j', 'k'], () => escapeInsert(), { timeout: 300 });

    // exclusive 模式:按错了继续等,不回退到单键绑定
    const unbind3 = boundSequence(['ctrl+b', 'd'], () => detach(), { exclusive: true });

    return () => { unbind1(); unbind2(); unbind3(); };
  }, []);
}

序列也有 action 注册机制,多个页面共用一套序列定义:

tsx 复制代码
const { defineSequenceAction, boundSequence } = useKeyboard();

defineSequenceAction([
  { sequenceActionId: 'quit', action: () => process.exit(0), keys: ['q', 'q'] },
]);

boundSequence('quit');  // 直接用 actionId

5. 焦点系统

终端没有 autofocus,焦点全你自己管。引擎提供了一个集成的焦点方案:

tsx 复制代码
function LoginForm() {
  const { focusSet } = useKeyboard();

  useEffect(() => {
    focusSet('username');
    // 除了手动设置焦点,引擎本身处理了tab键和shift+tab的焦点轮转
  }, []);

  return (
    <Box flexDirection="column">
      <TextInput value={username} onChange={setUsername} focusId="username" />
      <TextInput value={password} onChange={setPassword} focusId="password" mask="*" />
    </Box>
  );
}

绑定也可以挂在焦点上------只在密码框获得焦点时才响应:

tsx 复制代码
boundKeyboard(['enter'], () => togglePasswordVisible(), { focusId: 'password' });

6. 操作注册(Action System)

多个页面共享同一组行为的时候,把行为和按键解耦:

tsx 复制代码
const { defineShortcutAction, boundKeyboard, addAction, modifyAction } = useKeyboard();

// 注册操作
defineShortcutAction([
  { actionId: 'save', action: () => saveFile() },
  { actionId: 'quit', action: () => process.exit(0) },
  { actionId: 'undo', action: () => undoLastAction() },
]);

// 用操作名来绑定,按键和操作名解耦
boundKeyboard(['ctrl+s'], 'save');
boundKeyboard(['ctrl+q'], 'quit');
boundKeyboard(['ctrl+z'], 'undo');

// 给操作加预设键
addAction({ actionId: 'redo', action: () => redo(), keys: ['ctrl+y'] });
boundKeyboard('redo');

// 动态改预设键(用户改了快捷键配置)
modifyAction('save', ['ctrl+s', 'cmd+s']);

操作注册本身不绑定键,只是起一个中间层。这个设计的真正好处体现在你让用户自定义快捷键 的时候------所有键位都集中在操作层,不需要满代码翻 boundKeyboard 调用。

7. Modal:模态框 + allowModal + useModalMissListener

模态框打开后,默认拦截所有按键 。这个设计是故意为之的------确认对话框弹出来的时候,按 escape 必须是关闭对话框,而不是在背后偷偷退出了程序。

tsx 复制代码
function ConfirmDialog({ onConfirm, onCancel }) {
  const { boundKeyboard, allowModal } = useKeyboard();

  useEffect(() => {
    const unbinds = [
      boundKeyboard(['y'], () => onConfirm()),
      boundKeyboard(['n'], () => onCancel()),
    ];
    // 放行 escape,让它穿透模态框触发全局关闭逻辑
    const undoAllow = allowModal(['escape']);

    return () => { unbinds.forEach(u => u()); undoAllow(); };
  }, []);
  //...
}

useModalMissListener:监听"没人处理的键"

模态框里按了一个没有绑定的键,默认就是被拦截了。你可以用这个来在 UI 上提示用户:

tsx 复制代码
function SearchModal() {
  const { useModalMissListener } = useKeyboard();
  const [hint, setHint] = useState('');

  useEffect(() => {
    return useModalMissListener((evt) => {
      if (evt.miss) {
        setHint(`没有绑定: ${evt.eventNames.join(', ')}. 试试 / 搜索, escape 退出`);
      }
    });
  }, []);

  return (
    <Box flexDirection="column">
      <Text>{hint}</Text>
      {/* ... */}
    </Box>
  );
}

8. 条件系统(Conditions)

有一些状态不是模式,但影响同一批绑定。比如"是否正在编辑""是否已经保存"。这里用布尔值到处传很烦,条件系统统一管理这些值:

tsx 复制代码
const { addCondition, setCondition, boundKeyboard, globalKeys } = useKeyboard();

// 注册条件
addCondition('isEditing', false);
addCondition('hasUnsavedChanges', false);

// 绑定里直接写条件名 ------ 字符串引用,不用传函数
useEffect(() => {
  const unbinds = [
    boundKeyboard(['j'], () => moveDown(), { when: 'isEditing' }),
    boundKeyboard(['ctrl+s'], () => save(), { when: 'hasUnsavedChanges' }),
  ];
  return () => unbinds.forEach(u => u());
}, []);

// 全局键也能用
useEffect(() => {
  globalKeys([{ key: 'escape', operate: () => closePanel(), when: 'isEditing' }]);
}, []);

// 后面直接切条件,所有引用它的绑定自动切换
setCondition('isEditing', true);
setCondition('hasUnsavedChanges', false);

还有个方便的------when 可以同时支持函数和字符串,灵活的用函数,稳定的用字符串。

9. 自定义处理器(Custom Processors)

7 阶段管线是默认配置,但你可以往里插自己的处理器。比如插一个 key logger:

tsx 复制代码
function App() {
  const { addProcessor } = useKeyboard();

  useEffect(() => {
    addProcessor(
      {
        id: 'key-logger',
        process: (ctx) => {
          // 对开发者:F12 打开 DevTool 用的
          console.log(`[key] 当前模式:${ctx.currentMode} 按键:${ctx.eventNames.join(',')}`);
          return false;  // 始终不消费,事件继续
        },
      },
      { after: 'modal' },
    );
  }, []);
}

也可以删掉或者重置:

tsx 复制代码
const { removeProcessor, resetProcessors, getProcessors } = useKeyboard();

removeProcessor('key-logger');
resetProcessors();   // 一键恢复默认 7 阶段管线
console.log(getProcessors().map(p => p.id));
// ['modal', 'global-sequence-overlay', 'global-key-overlay', ...]

10. 这引擎本身是可以单拎出来用的

所有上面说的功能------管线、焦点、序列、模式、全局键、自定义处理器------都不只是 React 的。它们是一个纯 TypeScript 类的状态机 只不过ink-cartridge对他进行了适配并进行了封装:

ts 复制代码
import { KeyboardEngine } from '@cartridge-engine/keyboard-engine';

const engine = new KeyboardEngine({
  normalizeKeyNames: (input, key) => {
    // 你的框架的 key 对象 → 标准化名字符串
    if (key.ctrl && key.name) return [`ctrl+${key.name}`];
    return [key.name ?? input];
  },
});

ink-cartridge 只是用 React hooks 把它包了起来,包了三样东西:

  1. 一个 <KeyboardProvider>(创建引擎实例、接 Ink 的 useInput、每帧 sync)
  2. 一套 hooks(useKeyboardboundKeyboardpenetration......)
  3. 一套屏幕树系统(和引擎的管线配合,自动管理 overlay/modal 层级)

即使是框架换了,引擎不用换。你的键盘逻辑不会因为哪天从 Ink 迁到别处而报废。

最后

这篇文章并没有把更多高级特性讲完,这些仅仅只是基础,更多的特性还得看看仓库和源码 使用这个引擎或者直接使用ink-cartridge可以为你减去许多负担 但这个框架本身也有一定的使用难度,他对使用者有一定的心智负担和反直觉设计,但我们一定会努力让这个轮子跑的更稳

另外 这篇文章使用的React-ink来讲例子 如果是其他框架 可以仿照ink-cartridge的做法进行接入

我们非常欢迎提issue(不管是bug报告 还是文档等问题 还是新功能要求等 我们都非常欢迎)

欢迎提任何PR

  • ink-cartridge: npm | GitHub
  • @cartridge-engine/keyboard-engine: npm
相关推荐
Excel效率人3 小时前
飞书自动回复机器人开发:@机器人自助查询物流信息
机器人·node.js·飞书
先吃饱再说21 小时前
从“地狱”到“楼梯”再到“同步写法”:Node.js 异步的二十年与Node内置fs模块详解
node.js
知识收割者21 小时前
Nodejs也能写Agent - 10.LangChain篇 - 初识LangChain
node.js
HjhIron1 天前
Node.js 内置模块之 path 与 fs:从路径拼接到异步演进
node.js·promise
触底反弹1 天前
🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!
javascript·后端·node.js
sugar__salt1 天前
Node.js path 与 fs 核心模块完全指南 —— 从路径处理到异步流程控制
javascript·node.js
屑曦晨1 天前
npm 全局包迁移指南:从系统目录到用户目录
npm·node.js
moMo1 天前
Node.js 文件操作:path 路径与 fs 异步流程
node.js
拾年2751 天前
Node.js 异步进化论:从 path 路径拼接到 fs 文件读取,我终于理解了回调地狱的救赎之路
前端·node.js