键盘控制
捕获键盘输入
首先需要理解 Node.js 怎样从终端接收输入,才能实现键盘控制
process.stdin 与 Raw Mode
process.stdin 是一个可读流,代表了进程的标准输入。默认情况下,输入流是按行缓冲的,并且由终端进行处理。意味着只有当用户按下回车键后,程序才能接收到整行输入
为即时响应每一次按键,需要开启 "Raw Mode"(原始模式)
process.stdin.setRawMode(true)在原始模式下:
- 即时响应:每一次按键(包括
a、b、Ctrl+C等)都会被立刻发送到process.stdin流,而无需等待回车 - 禁用默认行为:终端的内置行为,如字符回显(在屏幕上显示你输入的字符)和特殊组合键(如
Ctrl+C退出进程)都会被禁用
readline 模块
虽然 Raw Mode 能接收到原始的按键数据,但这些数据通常是底层的字节流。为方便处理,可以使用 Node.js 内置的 readline 模块来解析这些数据,并触发格式化的 keypress 事件
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
pnpm install typescript @types/node --save-dev
npx tsc --init修改 tsconfig.json 以适应 Node.js ES Module 项目:
{
"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
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
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]
}
}实现说明:
curSelectIndex和scrollTop:这是实现虚拟滚动的核心。curSeletecIndex跟踪用户的选择,而scrollTop跟踪列表的可视部分的起始位置fitScroll():此方法确保当用户的选择移出屏幕时,可视区域(由scrollTop控制)会相应地滚动,始终保持选中项在屏幕内render():- 首先清空整个终端
- 然后,仅从完整列表中截取当前可视部分(
visibleList) - 遍历可视列表并将其打印到屏幕上
- 如果某项是当前选中的项,就使用
chalk为其添加高亮背景
虚拟滚动原理图:
+--------------------------------+
| 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 |
+----------------+测试滚动列表
npx tsc scroll-list.ts --module ESNext --moduleResolution node创建入口文件 list-test.ts 测试
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 事件,确保在退出前总是能恢复终端状态。
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+S 或 Shift+Tab 这样的组合键?
A: keypress 事件回调中的 key 对象包含了 ctrl、meta 和 shift 等布尔值属性,可以用来判断组合键。
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: 手动实现和使用 prompts、blessed 等库有什么区别?
- 手动实现:提供了最大的灵活性和控制力,非常适合学习底层原理。但对于复杂的 UI,开发成本高,且需要处理大量边界情况
- 使用库:
prompts:专注于提供一系列预设好的、交互式的命令行提示(如文本输入、选择、确认等),易于使用,非常适合构建脚手架工具blessed:一个更重量级的终端界面“组件库”,允许你像开发 GUI 一样,使用盒子、列表、表单等组件来构建复杂的、类似应用程序的 TUI(文本用户界面),pm2 monit就是用它构建的
对于简单交互,手动实现或使用 prompts 即可。对于需要持久化、复杂布局的界面,blessed 是更好的选择
prompts 库
安装 prompts:
pnpm install prompts @types/promptsprompts 支持多种类型的输入,包括文本、数字、密码、确认、选择等。下面是一个综合示例:
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发送最终结果
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.value和this.cursor。render:- 清空当前行并将光标移到行首
- 拼接并打印提示信息和用户已输入的内容
- 最关键的一步:使用
ansiEscapes.cursorTo()将光标移动到this.cursor所记录的正确位置,从而实现在文本中间插入和删除的效果
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 渲染流程:cursorHide和cursorSavePosition:隐藏光标避免闪烁,并保存初始位置- 渲染问题行和当前选中的值
- 循环向下移动光标并渲染每个选项,高亮当前选中的项
cursorRestorePosition:将光标恢复到初始位置。这是实现“原地”刷新界面的关键,否则每次渲染都会在终端下方追加内容
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的结果,从而实现了问答的串行执行
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
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)
})()运行项目:
# 编译 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: 你可以在 TextPrompt 的 close 方法中添加验证逻辑。如果验证失败,就阻止关闭并显示错误信息。
// 在 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 中要先 cursorSavePosition 再 cursorRestorePosition?
A: 这是为了实现“原地刷新”的效果。如果不这样做,每次 render 都会在终端的最后一行向下继续打印,导致界面被不断推高。正确的流程是:
- 保存当前光标位置(通常在问题行的行首)
- 向下移动光标,逐行绘制所有选项
- 将光标恢复到第 1 步保存的位置
这样,下一次
render就会覆盖掉上一次渲染的内容,看起来就像界面在原地更新一样