{T}

键盘控制

捕获键盘输入

首先需要理解 Node.js 怎样从终端接收输入,才能实现键盘控制

process.stdin 与 Raw Mode

process.stdin 是一个可读流,代表了进程的标准输入。默认情况下,输入流是按行缓冲的,并且由终端进行处理。意味着只有当用户按下回车键后,程序才能接收到整行输入

为即时响应每一次按键,需要开启 "Raw Mode"(原始模式)

javascript
process.stdin.setRawMode(true)

在原始模式下:

  1. 即时响应:每一次按键(包括 abCtrl+C 等)都会被立刻发送到 process.stdin 流,而无需等待回车
  2. 禁用默认行为:终端的内置行为,如字符回显(在屏幕上显示你输入的字符)和特殊组合键(如 Ctrl+C 退出进程)都会被禁用

readline 模块

虽然 Raw Mode 能接收到原始的按键数据,但这些数据通常是底层的字节流。为方便处理,可以使用 Node.js 内置的 readline 模块来解析这些数据,并触发格式化的 keypress 事件

javascript
import readline from "node:readline"

// readline.emitKeypressEvents 让 process.stdin 开始触发 keypress 事件
readline.emitKeypressEvents(process.stdin)

// process.stdin.setRawMode(true) 让输入立即被程序可见,而不是等待用户按下回车
process.stdin.setRawMode(true)

// 监听 keypress 事件
process.stdin.on("keypress", (str, key) => {
  // ctrl+c 退出,\u0003 代表 Ctrl+C 的组合键
  if (key.sequence === "\u0003") {
    process.exit()
  }

  console.log(str, key)
})

实战:实现滚动列表

安装 typescript

bash
pnpm install typescript @types/node --save-dev
npx tsc --init

修改 tsconfig.json 以适应 Node.js ES Module 项目:

json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  }
}

package.json 中添加 "type": "module"

封装基础 UI 类

先创建 BaseUi 抽象类,封装一些通用的终端操作:src/base-ui.ts

typescript
import ansiEscapes from "ansi-escapes"

/** 表示终端中的位置坐标 */
export interface Position {
  x: number
  y: number
}

/**
 * 终端UI基础抽象类,提供了控制终端输出的基本功能
 *
 * 这个类封装了终端操作的基本方法,包括光标控制、文本输出和终端大小获取等功能
 */
export abstract class BaseUi {
  /** 保存对标准输出流的引用,用于向终端输出内容 */
  private readonly stdout: NodeJS.WriteStream = process.stdout

  /**
   * 在当前光标位置输出文本
   * @param text 要输出的文本内容
   */
  protected print(text: string) {
    // 使用bind确保this指向正确的process.stdout对象
    process.stdout.write.bind(process.stdout)(text)
  }

  /**
   * 将光标移动到指定位置
   * @param position 目标位置坐标
   */
  protected setCursorAt({ x, y }: Position) {
    // 使用ansi-escapes库提供的cursorTo方法移动光标
    this.print(ansiEscapes.cursorTo(x, y))
  }

  /**
   * 在指定位置输出消息
   * @param message 要输出的消息内容
   * @param position 消息输出的位置坐标
   */
  protected printAt(message: string, position: Position) {
    // 先移动光标到目标位置,然后输出消息
    this.setCursorAt(position)
    this.print(message)
  }

  /**
   * 清除指定行的内容
   * @param row 要清除的行号
   */
  protected clearLine(row: number) {
    // 将光标移动到行首,然后使用eraseLine清除整行
    this.printAt(ansiEscapes.eraseLine, { x: 0, y: row })
  }

  /**
   * 获取终端的尺寸信息
   * @returns 返回包含列数和行数的对象
   */
  get terminalSize(): { columns: number; rows: number } {
    return {
      columns: this.stdout.columns, // 终端宽度(字符数)
      rows: this.stdout.rows // 终端高度(行数)
    }
  }

  /** 抽象方法,子类必须实现此方法来渲染UI */
  abstract render(): void
}

滚动列表组件

继承 BaseUi 并实现核心的 ScrollList 组件:src/scroll-list.ts

typescript
import { BaseUi } from "./base-ui.js"
import chalk from "chalk"

/**
 * 可滚动的列表组件,支持键盘上下键导航
 *
 * 继承自BaseUi类,提供了列表项的选择、滚动和渲染功能
 */
export class ScrollList extends BaseUi {
  /** 当前选中的项索引 */
  curSelectIndex = 0
  /** 列表滚动的顶部位置 */
  scrollTop = 0

  /**
   * 创建一个可滚动列表实例
   * @param list 要显示的字符串数组
   */
  constructor(private list: Array<string> = []) {
    super()
    // 初始化时渲染一次列表
    this.render()
  }

  /**
   * 处理键盘输入事件
   * @param name 按键名称(如"up"或"down")
   */
  onKeyInput(name: string) {
    // 只处理上下方向键
    if (name !== "up" && name !== "down") {
      return
    }

    // 根据按键执行相应的操作
    const action: Function = this.KEYS[name]
    action()
    // 重新渲染列表
    this.render()
  }

  /**
   * 按键映射对象,将按键名称映射到对应的处理函数
   */
  private readonly KEYS = {
    up: () => this.cursorUp(),
    down: () => this.cursorDown()
  }

  /** 将光标向上移动一项 */
  cursorUp() {
    this.moveCursor(-1)
  }

  /** 将光标向下移动一项 */
  cursorDown() {
    this.moveCursor(1)
  }

  /**
   * 移动光标并确保索引在有效范围内
   * @param index 移动的步数(正数向下,负数向上)
   */
  private moveCursor(index: number): void {
    // 更新当前选中索引
    this.curSelectIndex += index

    // 确保索引不小于0
    if (this.curSelectIndex < 0) {
      this.curSelectIndex = 0
    }

    // 确保索引不超出列表范围
    if (this.curSelectIndex >= this.list.length) {
      this.curSelectIndex = this.list.length - 1
    }

    // 调整滚动位置以确保选中项可见
    this.fitScroll()
  }

  /**
   * 调整滚动位置,确保当前选中项始终可见
   */
  fitScroll() {
    // 检查是否需要向上滚动
    const shouldScrollUp = this.curSelectIndex < this.scrollTop

    // 检查是否需要向下滚动(减2是为了预留空间给边框)
    const shouldScrollDown = this.curSelectIndex > this.scrollTop + this.terminalSize.rows - 2

    // 向上滚动一行
    if (shouldScrollUp) {
      this.scrollTop -= 1
    }

    // 向下滚动一行
    if (shouldScrollDown) {
      this.scrollTop += 1
    }

    // 清除整个屏幕以便重新渲染
    this.clear()
  }

  /**
   * 清除屏幕上的所有内容
   */
  clear() {
    // 逐行清除屏幕内容
    for (let row = 0; row < this.terminalSize.rows; row++) {
      this.clearLine(row)
    }
  }

  /**
   * 为文本添加蓝色背景,并填充到整行
   * @param text 要添加背景的文本
   * @returns 添加了蓝色背景并填充至整行的文本
   */
  bgRow(text: string) {
    // 使用空格填充剩余的列宽,确保背景覆盖整行
    return chalk.bgBlue(text + " ".repeat(this.terminalSize.columns - text.length))
  }

  /** 渲染列表到终端 */
  render() {
    // 根据当前滚动位置获取可见的列表项
    const visibleList = this.list.slice(this.scrollTop, this.scrollTop + this.terminalSize.rows)

    // 遍历可见列表项并渲染每一项
    visibleList.forEach((item: string, index: number) => {
      const row = index

      // 清除当前行
      this.clearLine(row)

      let content = item

      // 如果当前项是被选中的项,添加蓝色背景
      if (this.curSelectIndex === this.scrollTop + index) {
        content = this.bgRow(content)
      }

      // 在指定位置输出内容
      this.printAt(content, {
        x: 0,
        y: row
      })
    })
  }

  /**
   * 获取当前选中的项
   * @returns 当前选中的列表项字符串
   */
  getSelectedItem(): string {
    return this.list[this.curSelectIndex]
  }
}

实现说明:

  1. curSelectIndexscrollTop:这是实现虚拟滚动的核心。curSeletecIndex 跟踪用户的选择,而 scrollTop 跟踪列表的可视部分的起始位置
  2. fitScroll():此方法确保当用户的选择移出屏幕时,可视区域(由 scrollTop 控制)会相应地滚动,始终保持选中项在屏幕内
  3. render()
    • 首先清空整个终端
    • 然后,仅从完整列表中截取当前可视部分(visibleList
    • 遍历可视列表并将其打印到屏幕上
    • 如果某项是当前选中的项,就使用 chalk 为其添加高亮背景

虚拟滚动原理图:

text
+--------------------------------+
| Terminal Window (Visible Area) |
|                                |
|   Item 5 (scrollTop)           | <---
|   Item 6                       |    |
| > Item 7 (curSeletecIndex)     |    | Visible Height (terminalSize.rows)
|   Item 8                       |    |
|   ...                          | <---
|                                |
+--------------------------------+
      |
      |
+----------------+
| Full List      |
|                |
|   Item 1       |
|   ...          |
|   Item 5       |
|   Item 6       |
|   Item 7       |
|   Item 8       |
|   ...          |
|   Item 20      |
+----------------+

测试滚动列表

bash
npx tsc scroll-list.ts --module ESNext --moduleResolution node

创建入口文件 list-test.ts 测试

typescript
import ansiEscapes from "ansi-escapes"
import { ScrollList } from "./scroll-list.js"
import readline from "node:readline"

// 1. 初始化
readline.emitKeypressEvents(process.stdin)
process.stdin.setRawMode(true)

const listData = [
  "红楼梦",
  "西游记",
  "水浒传",
  "三国演义",
  "儒林外史",
  "金瓶梅",
  "聊斋志异",
  "白鹿原",
  "平凡的世界",
  "围城",
  "活着",
  "百年孤独",
  "红高粱家族",
  "梦里花落知多少",
  "倾城之恋",
  "悲惨世界",
  "哈利波特",
  "霍乱时期的爱情",
  "白夜行",
  "解忧杂货店",
  "挪威的森林",
  "追风筝的人",
  "小王子",
  "飘",
  "麦田里的守望者"
]

const list = new ScrollList(listData)

// 2. 监听键盘事件
process.stdin.on("keypress", (str, key) => {
  // Ctrl+C 退出
  if (key.ctrl && key.name === "c") {
    // 退出前清空屏幕并显示光标
    process.stdout.write(ansiEscapes.clearTerminal)
    process.stdout.write(ansiEscapes.cursorShow)
    process.exit()
  }
  // Enter 键确认选择
  else if (key.name === "return") {
    process.stdout.write(ansiEscapes.clearTerminal)
    process.stdout.write(ansiEscapes.cursorShow)
    console.log(`你选择了: ${list.getSelectedItem()}`)
    process.exit()
  }
  // 其他按键交由 list 处理
  else {
    list.onKeyInput(key.name)
  }
})

// 3. 隐藏光标,增强体验
process.stdout.write(ansiEscapes.cursorHide)

常见问题解答 (Q&A)

Q1: 为什么我的脚本异常退出后,终端显示变得混乱?

A: 这是因为脚本在退出前未能将终端从 Raw Mode 恢复到正常模式。在 Raw Mode 下,终端的许多默认行为(如回车换行、字符回显)都被禁用了。

解决方案:监听进程的 exit 事件,确保在退出前总是能恢复终端状态。

typescript
function cleanup() {
  process.stdout.write(ansiEscapes.clearTerminal)
  process.stdout.write(ansiEscapes.cursorShow)
  process.stdin.setRawMode(false)
}

process.on("exit", cleanup)
// 对于 SIGINT (Ctrl+C),也需要特殊处理
process.on("SIGINT", () => process.exit())

Q2: 如何处理像 Ctrl+SShift+Tab 这样的组合键?

A: keypress 事件回调中的 key 对象包含了 ctrlmetashift 等布尔值属性,可以用来判断组合键。

typescript
process.stdin.on("keypress", (str, key) => {
  if (key.ctrl && key.name === "s") {
    console.log("保存操作被触发!")
  }
  if (key.shift && key.name === "tab") {
    console.log("Shift+Tab 被按下!")
  }
})

Q3: 手动实现和使用 promptsblessed 等库有什么区别?

  • 手动实现:提供了最大的灵活性和控制力,非常适合学习底层原理。但对于复杂的 UI,开发成本高,且需要处理大量边界情况
  • 使用库
    • prompts:专注于提供一系列预设好的、交互式的命令行提示(如文本输入、选择、确认等),易于使用,非常适合构建脚手架工具
    • blessed:一个更重量级的终端界面“组件库”,允许你像开发 GUI 一样,使用盒子、列表、表单等组件来构建复杂的、类似应用程序的 TUI(文本用户界面),pm2 monit 就是用它构建的

对于简单交互,手动实现或使用 prompts 即可。对于需要持久化、复杂布局的界面,blessed 是更好的选择

prompts 库

安装 prompts

bash
pnpm install prompts @types/prompts

prompts 支持多种类型的输入,包括文本、数字、密码、确认、选择等。下面是一个综合示例:

typescript
import prompts from "prompts"

async function main() {
  const questions: prompts.PromptObject[] = [
    {
      type: "text",
      name: "name",
      message: `你的名字`,
      initial: `xiaoye`
    },
    {
      type: "number",
      name: "age",
      message: "你的年龄?",
      validate: (value) => (value < 18 ? `未满 18 岁不能使用` : true)
    },
    {
      type: "password",
      name: "secret",
      message: "设置下密码"
    },
    {
      type: "confirm",
      name: "confirmed",
      message: "确认么?"
    },
    {
      type: "toggle",
      name: "confirmtoggle",
      message: "性别?",
      active: "男",
      inactive: "女"
    },
    {
      type: "select",
      name: "color",
      message: "喜欢的颜色?",
      choices: [
        { title: "Red", description: "这是红色", value: "#ff0000" },
        { title: "Green", description: "这是绿色", value: "#00ff00" },
        { title: "Yellow", value: "#ffff00" },
        { title: "Blue", value: "#0000ff" }
      ]
    },
    {
      type: "multiselect",
      name: "multicolor",
      message: "选择不喜欢的颜色(多选)",
      choices: [
        { title: "Red", description: "这是红色", value: "#ff0000" },
        { title: "Green", value: "#00ff00" },
        { title: "Yellow", value: "#ffff00" },
        { title: "Blue", value: "#0000ff" }
      ]
    },
    {
      type: "date",
      name: "birthday",
      message: `你的生日?`,
      validate: (date) => (date > Date.now() ? `不能设置未来的日期` : true)
    }
  ]

  const answers = await prompts(questions)
  console.log(answers)
}

main()

prompts 函数接收一个问题对象数组,并以 Promise 的形式返回所有答案。这种声明式的 API 非常直观。

实现 prompts

动手实现一简化版的 prompts

设计 Prompt 基类

所有不同类型的 Prompt(如文本、选择)都有一些共同的行为:监听键盘、提交答案、关闭自身。Prompt.ts

  • EventEmitter:继承 EventEmitter,以便在用户提交答案时(按下回车),通过 this.emit('submit', ...) 来通知外部调用者
  • 全局 onKeypress:使用一个全局变量来持有当前的键盘监听器。这可以防止在切换不同 Prompt 时,旧的监听器没有被正确移除而导致重复响应
  • close() 方法:负责清理工作,包括移除监听器、恢复终端模式,并通过 emit 发送最终结果
typescript
import { EventEmitter } from "events"
import * as readline from "node:readline"
import ansiEscapes from "ansi-escapes"

/**
 * 键盘按键接口定义
 * @interface Key
 * @property {string} name 按键名称,如 "return", "escape" 等
 * @property {string} sequence 按键的字符序列,用于特殊按键识别
 */
export interface Key {
  name: string
  sequence: string
}

/** 保存键盘事件处理函数的引用,用于后续移除监听器 */
let onKeypress: (str: string, key: Key) => void

/**
 * 命令行提示基类,提供交互式命令行输入功能。继承自 EventEmitter,支持事件发射
 * @abstract
 * @extends EventEmitter
 */
export abstract class Prompt extends EventEmitter {
  /** 当前输入值 */
  value = ""
  /** readline 接口实例,用于处理命令行输入 */
  rl: readline.Interface

  /**
   * 构造函数,初始化命令行交互环境
   * 设置原始模式以捕获单个按键,并绑定键盘事件
   */
  constructor() {
    super()

    // 启用按键事件发射,让 process.stdin 能够发射 keypress 事件
    readline.emitKeypressEvents(process.stdin)
    // 创建 readline 接口,用于处理命令行输入
    this.rl = readline.createInterface({ input: process.stdin })

    // 设置为原始模式,使输入无缓冲,可以立即获取按键
    process.stdin.setRawMode(true)

    // 绑定按键事件处理函数
    onKeypress = this.onKeypress.bind(this)
    process.stdin.on("keypress", onKeypress)
  }

  /**
   * 抽象方法,处理按键输入
   * 子类必须实现此方法来定义具体的按键处理逻辑
   * @param str 按键对应的字符
   * @param key 按键对象,包含名称和序列信息
   */
  abstract onKeyInput(str: string, key: Key): void

  /**
   * 私有方法,处理键盘按键事件
   * 处理特殊按键(如 Ctrl+C 和回车)并调用子类实现的按键处理方法
   * @param str 按键对应的字符
   * @param key 按键对象,包含名称和序列信息
   */
  private onKeypress(str: string, key: Key) {
    // 检查是否按下 Ctrl+C (序列为 \u0003)
    if (key.sequence === "\u0003") {
      process.exit()
    }

    // 检查是否按下回车键
    if (key.name === "return") {
      this.close()
      return
    }

    // 调用子类实现的按键处理方法
    this?.onKeyInput(str, key)
  }

  /**
   * 关闭命令行交互,恢复正常输入模式
   * 移除事件监听器,关闭 readline 接口,并发射 submit 事件
   */
  close() {
    // 输出换行符,美化命令行显示
    process.stdout.write("\n")

    // 移除键盘事件监听器,防止内存泄漏
    process.stdin.removeListener("keypress", onKeypress)
    // 恢复标准输入模式
    process.stdin.setRawMode(false)

    // 关闭 readline 接口
    this.rl.close()
    // 发射 submit 事件,传递当前值
    this.emit("submit", this.value)
  }
}

实现 TextPrompt

TextPrompt 用于接收单行文本输入。TextPrompt.ts

  • onKeyInput:处理字符输入和退格键,并更新 this.valuethis.cursor
  • render
    1. 清空当前行并将光标移到行首
    2. 拼接并打印提示信息和用户已输入的内容
    3. 最关键的一步:使用 ansiEscapes.cursorTo() 将光标移动到 this.cursor 所记录的正确位置,从而实现在文本中间插入和删除的效果
typescript
import ansiEscapes from "ansi-escapes"
import { Key, Prompt } from "./Prompt.js"
import chalk from "chalk"

/**
 * 文本提示选项接口
 * @interface TextPromptOptions
 * @property {string} type 提示类型,固定为 "text"
 * @property {string} name 提示名称,用于标识此提示
 * @property {string} message 显示给用户的提示信息
 */
export interface TextPromptOptions {
  type: "text"
  name: string
  message: string
}

/**
 * 检查字符是否为不可打印字符
 * @param char 要检查的字符
 * @returns 如果是不可打印字符返回 true,否则返回 false
 */
function isNonPrintableChar(char: string) {
  // 正则表达式匹配控制字符 (ASCII 0-31 和 127)
  return /^[\x00-\x1F\x7F]$/.test(char)
}

/**
 * 文本提示类,继承自 Prompt,用于获取用户文本输入
 * 提供基本的文本输入功能,支持字符输入、删除和光标位置管理
 * @extends Prompt
 */
export class TextPrompt extends Prompt {
  /** 输出流,默认为标准输出 */
  out = process.stdout
  /** 光标位置,表示当前输入光标所在的位置 */
  cursor = 0

  /**
   * 构造函数,初始化文本提示
   * @param options 文本提示选项
   */
  constructor(private options: TextPromptOptions) {
    super()
  }

  /**
   * 处理按键输入,实现基类的抽象方法,处理退格键和普通字符输入
   * @param str 按键对应的字符
   * @param key 按键对象,包含名称和序列信息
   */
  onKeyInput(str: string, key: Key) {
    // 处理退格键
    if (key.name === "backspace") {
      // 减少光标位置
      this.cursor--
      // 截断光标位置后的字符
      this.value = this.value.slice(0, this.cursor)
    }

    // 如果是可打印字符,则添加到输入值中
    if (!isNonPrintableChar(str)) {
      this.value += str
      this.cursor++
    }

    // 重新渲染界面
    this.render()
  }

  /** 渲染用户界面,使用 ANSI 转义序列显示提示信息、用户输入和错误提示 */
  render() {
    // 清除屏幕并将光标移动到左上角
    this.out.write(ansiEscapes.clearScreen)
    this.out.write(ansiEscapes.cursorTo(0, 0))

    // 保存当前光标位置
    this.out.write(ansiEscapes.cursorSavePosition)

    // 显示提示信息和用户输入
    this.out.write(
      [
        chalk.bold(this.options.message),
        chalk.gray("›"),
        " ",
        chalk.blue(this.value)
      ].join("")
    )

    // 保存当前光标位置
    this.out.write(ansiEscapes.cursorSavePosition)

    // 移动到下一行并回到行首,用于显示错误信息或状态提示
    this.out.write(ansiEscapes.cursorDown(1) + ansiEscapes.cursorTo(0))

    // 根据用户输入状态显示不同的提示信息
    if (this.value === "") {
      this.out.write(chalk.red(`请输入${this.options.name}`))
    } else {
      // 如果有输入,清除错误提示行
      this.out.write(ansiEscapes.eraseLine)
    }

    // 恢复之前保存的光标位置
    this.out.write(ansiEscapes.cursorRestorePosition)
  }
}

实现 SelectPrompt

SelectPrompt 用于提供一个选项列表供用户选择。SelectPrompt.ts

  • onKeyInput:通过取模运算 (index + 1) % length 来实现循环选择
  • render:这是一个典型的 TUI 渲染流程:
    1. cursorHidecursorSavePosition:隐藏光标避免闪烁,并保存初始位置
    2. 渲染问题行和当前选中的值
    3. 循环向下移动光标并渲染每个选项,高亮当前选中的项
    4. cursorRestorePosition:将光标恢复到初始位置。这是实现“原地”刷新界面的关键,否则每次渲染都会在终端下方追加内容
typescript
import ansiEscapes from "ansi-escapes"
import { Key, Prompt } from "./Prompt.js"
import chalk from "chalk"

/**
 * 选择提示选项接口
 * @interface SelectPromptOptions
 * @property {string} type 提示类型,固定为 "select"
 * @property {string} name 提示名称,用于标识此提示
 * @property {string} message 显示给用户的提示信息
 * @property {Array<string>} choices 可选择的选项列表
 */
export interface SelectPromptOptions {
  type: "select"
  name: string
  message: string
  choices: Array<string>
}

/**
 * 选择提示类,继承自 Prompt,用于让用户从预设选项中选择一个
 * 支持使用上下箭头键导航选项,并高亮显示当前选中项
 * @extends Prompt
 */
export class SelectPrompt extends Prompt {
  /** 输出流,默认为标准输出 */
  out = process.stdout
  /** 当前选中的选项索引 */
  index = 0

  /**
   * 构造函数,初始化选择提示
   * @param options 选择提示选项
   */
  constructor(private options: SelectPromptOptions) {
    super()
    // 默认选中第一个选项
    this.value = options.choices[0]
  }

  /**
   * 处理按键输入
   * 实现基类的抽象方法,处理上下箭头键以导航选项
   * @param str 按键对应的字符
   * @param key 按键对象,包含名称和序列信息
   */
  onKeyInput(str: string, key: Key) {
    // 只处理上下箭头键,忽略其他按键
    if (key.name !== "up" && key.name !== "down") {
      return
    }

    // 处理向下箭头键
    if (key.name === "down") {
      this.index += 1
      // 如果超过最后一个选项,则回到第一个选项(循环选择)
      if (this.index > this.options.choices.length - 1) {
        this.index = 0
      }
    }

    // 处理向上箭头键
    if (key.name === "up") {
      this.index -= 1
      // 如果低于第一个选项,则跳到最后一个选项(循环选择)
      if (this.index < 0) {
        this.index = this.options.choices.length - 1
      }
    }

    // 更新当前值为选中索引对应的选项
    this.value = this.options.choices[this.index]

    // 重新渲染界面
    this.render()
  }

  /**
   * 渲染用户界面
   * 使用 ANSI 转义序列显示提示信息、选项列表和高亮当前选中项
   */
  render() {
    // 清除屏幕并将光标移动到左上角
    this.out.write(ansiEscapes.clearScreen)
    this.out.write(ansiEscapes.cursorTo(0, 0))

    // 保存当前光标位置
    this.out.write(ansiEscapes.cursorSavePosition)

    // 显示提示信息和当前选中的值
    this.out.write(
      [
        chalk.bold(this.options.message),
        chalk.gray("›"),
        " ",
        chalk.blue(this.value)
      ].join("")
    )

    // 遍历所有选项并显示
    for (let i = 0; i < this.options.choices.length; i++) {
      const choice = this.options.choices[i]

      // 移动到下一行
      this.out.write(ansiEscapes.cursorDown(1))
      // 移动到缩进位置(2个空格)
      this.out.write(ansiEscapes.cursorTo(2))

      // 如果是当前选中的选项,使用箭头图标并高亮显示
      if (this.value === choice) {
        this.out.write(chalk.blue("❯") + " " + choice)
      } else {
        // 非选中项前面加两个空格对齐
        this.out.write("  " + choice)
      }
    }

    // 恢复之前保存的光标位置
    this.out.write(ansiEscapes.cursorRestorePosition)
  }
}

编排多个 Prompt

需要函数来按顺序执行一系列 Prompt。index.ts

这个 prompt 函数通过 for...of 循环,await 每个 runPrompt 的结果,从而实现了问答的串行执行

typescript
import { SelectPrompt, SelectPromptOptions } from "./SelectPrompt.js"
import { TextPromptOptions, TextPrompt } from "./TextPrompt.js"

/** 提示选项联合类型,可以是文本提示选项或选择提示选项 */
export type PromptOptions = TextPromptOptions | SelectPromptOptions

/** 提示类型与对应类的映射表,用于根据提示类型创建对应的提示实例 */
const map: Record<string, any> = {
  text: TextPrompt,
  select: SelectPrompt
}

/**
 * 运行单个提示并返回用户答案
 * 根据提示类型创建相应的提示实例,监听提交事件并返回答案
 * @param question 提示配置选项
 * @returns Promise,解析为用户输入的答案或 null
 */
async function runPrompt(question: PromptOptions) {
  // 根据提示类型获取对应的提示类
  const promptClass = map[question.type]

  // 如果没有对应的提示类,返回 null
  if (!promptClass) {
    return null
  }

  // 创建 Promise 来处理异步用户输入
  return new Promise((resolve) => {
    // 创建提示实例
    const prompt = new promptClass(question)

    // 首次渲染界面
    prompt.render()

    // 监听提交事件,用户按下回车键时触发
    prompt.on("submit", (answer: string) => {
      resolve(answer)
    })
  })
}

/**
 * 顺序执行多个提示并收集所有答案
 * 按照问题数组的顺序依次显示每个提示,收集用户输入
 * @param questions 提示配置选项数组
 * @returns Promise,解析为包含所有答案的对象,键为提示名称,值为用户答案
 */
export async function prompt(questions: PromptOptions[]) {
  // 存储所有答案的对象
  const answers: Record<string, any> = {}

  // 依次处理每个问题
  for (let i = 0; i < questions.length; i++) {
    // 获取当前问题的名称,用作答案对象的键
    const name = questions[i].name

    // 运行提示并等待用户答案
    answers[name] = await runPrompt(questions[i])
  }

  // 返回收集到的所有答案
  return answers
}

测试 test2.ts

python
import { prompt, PromptOptions } from "./index.js"

const questions: PromptOptions[] = [
  {
    message: "你的名字?",
    type: "text",
    name: "name"
  },
  {
    message: "年龄?",
    type: "text",
    name: "age"
  },
  {
    message: "你的班级?",
    type: "select",
    name: "class",
    choices: ["一班", "二班", "三班"]
  }
]

;(async function () {
  const answers = await prompt(questions)
  console.log(answers)
})()

运行项目:

bash
# 编译 ts 文件
npx tsc Prompt.ts TextPrompt.ts SelectPrompt.ts index.ts --module ESNext --moduleResolution node

# 运行,但是之前的ts 文件定义了类型,上面的编译不会输出类型,因此使用下面的提示
node test2.ts

# 编译出 类型提示文件
npx tsc Prompt.ts TextPrompt.ts SelectPrompt.ts index.ts test2.ts --module ESNext --moduleResolution node --declaration --outDir dist

# 进入 dist 目录
node test2.js

常见问题解答 (Q&A)

Q1: 如何为 TextPrompt 添加输入验证?

A: 你可以在 TextPromptclose 方法中添加验证逻辑。如果验证失败,就阻止关闭并显示错误信息。

typescript
// 在 TextPrompt 中
// constructor(private options: TextPromptOptions & { validate?: (input: string) => boolean | string }) { ... }

close() {
    if (this.options.validate) {
        const validationResult = this.options.validate(this.value);
        if (validationResult !== true) {
            // 渲染错误信息,并阻止关闭
            this.renderError(validationResult as string);
            return;
        }
    }
    super.close();
}

Q2: SelectPrompt 的选项太多,一屏显示不下怎么办?

A: 这就需要引入“虚拟滚动”了,原理与上一章实现的 ScrollList 完全相同。需要一个 scrollTop 变量来记录可视区域的起始索引,并在 render 时只渲染可视区域内的选项

Q3: 为什么在 SelectPrompt 中要先 cursorSavePositioncursorRestorePosition

A: 这是为了实现“原地刷新”的效果。如果不这样做,每次 render 都会在终端的最后一行向下继续打印,导致界面被不断推高。正确的流程是:

  1. 保存当前光标位置(通常在问题行的行首)
  2. 向下移动光标,逐行绘制所有选项
  3. 将光标恢复到第 1 步保存的位置 这样,下一次 render 就会覆盖掉上一次渲染的内容,看起来就像界面在原地更新一样