阶段 3: 交互式问答系统
📌 核心知识点
1. prompts 库简介
prompts 是一个轻量级、美观的命令行交互式问答库。
安装:
bash
pnpm add prompts
基本用法:
javascript
import prompts from 'prompts'
const response = await prompts({
type: 'text',
name: 'username',
message: '请输入用户名:'
})
console.log(response.username) // 用户输入的值
2. 问题对象 (PromptObject) 详解
每个问题是一个对象,包含以下字段:
核心字段:
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
type |
`string | function` | ✅ |
name |
string |
✅ | 答案的字段名 |
message |
`string | function` | ✅ |
initial |
any |
❌ | 默认值 |
常用字段:
| 字段 | 类型 | 说明 | 适用类型 |
|---|---|---|---|
choices |
Array |
选项列表 | select, multiselect |
active |
string |
"是"选项文本 | toggle |
inactive |
string |
"否"选项文本 | toggle |
hint |
string |
提示信息 | 所有类型 |
validate |
function |
验证函数 | 所有类型 |
3. 问题类型 (type) 详解
3.1 text - 文本输入
用途: 获取用户输入的字符串
示例:
javascript
{
type: 'text',
name: 'projectName',
message: '请输入项目名称:',
initial: 'my-app'
}
效果:
perl
? 请输入项目名称: › my-app
使用场景: 项目名、用户名、邮箱等
3.2 number - 数字输入
用途: 获取数字
示例:
javascript
{
type: 'number',
name: 'port',
message: '请输入端口号:',
initial: 3000
}
效果:
yaml
? 请输入端口号: › 3000
3.3 toggle - 开关/确认
用途: 是/否选择
示例:
javascript
{
type: 'toggle',
name: 'shouldOverwrite',
message: '目标文件非空,是否覆盖?',
initial: false,
active: '是',
inactive: '否'
}
效果:
? 目标文件非空,是否覆盖? › 否 / 是
使用场景: 确认操作、开关设置
3.4 select - 单选列表
用途: 从列表中选择一项
示例:
javascript
{
type: 'select',
name: 'templateType',
message: '请选择模板:',
choices: [
{ title: 'base(推荐)', value: { type: 'base' } },
{ title: 'demo(演示)', value: { type: 'demo' } }
],
initial: 0
}
效果:
css
? 请选择模板: › - Use arrow-keys. Return to submit.
❯ base(推荐)
demo(演示)
使用场景: 模板选择、框架选择
3.5 multiselect - 多选列表
用途: 从列表中选择多项
示例:
javascript
{
type: 'multiselect',
name: 'features',
message: '选择需要的功能:',
choices: [
{ title: 'TypeScript', value: 'ts' },
{ title: 'ESLint', value: 'eslint' },
{ title: 'Prettier', value: 'prettier' }
]
}
效果:
? 选择需要的功能: (空格选择,回车确认)
❯ ◉ TypeScript
◯ ESLint
◉ Prettier
4. 动态问题 - type 和 message 可以是函数
这是 prompts 最强大的特性之一!
4.1 动态 type - 条件显示问题
用途: 根据前一个答案决定是否显示当前问题
语法:
javascript
type: (prevValue) => {
// prevValue 是上一个问题的答案
return 条件 ? '问题类型' : null // null 表示跳过
}
create-unibest 实际案例:
javascript
// 文件覆盖问题 - 只在目录非空时显示
{
name: 'shouldOverwrite',
type: (prevValue) => {
// prevValue 是上一个问题的答案 (projectName)
return canSkipEmptying(prevValue) ? null : 'toggle'
},
message: '目标文件非空,是否覆盖?',
initial: false,
active: '是',
inactive: '否'
}
执行流程:
bash
用户输入项目名: "my-app"
↓
type 函数被调用: type("my-app")
↓
检查: canSkipEmptying("my-app")
├─ true → 返回 null → 跳过这个问题
└─ false → 返回 'toggle' → 显示问题
4.2 动态 message - 根据上下文生成问题文本
用途: 根据前面的答案动态生成问题内容
语法:
javascript
message: (prevValue) => {
return `根据 ${prevValue} 生成的问题文本`
}
create-unibest 实际案例:
javascript
{
name: 'shouldOverwrite',
type: (prevValue) => canSkipEmptying(prevValue) ? null : 'toggle',
message: (prevValue) => {
// prevValue 是项目名
const dirForPrompt = prevValue === '.'
? '当前文件'
: `目标文件"${prevValue}"`
return `${dirForPrompt}非空,是否覆盖?`
},
initial: false,
active: '是',
inactive: '否'
}
效果:
arduino
// 如果项目名是 "my-app"
? 目标文件"my-app"非空,是否覆盖? › 否 / 是
// 如果项目名是 "."
? 当前文件非空,是否覆盖? › 否 / 是
5. 验证器 (overwriteChecker) 模式
问题: 用户选择"否"时如何中断流程?
解决方案: 使用隐藏的验证器问题
代码:
javascript
{
name: 'overwriteChecker',
type: (prevValues) => {
// prevValues 是上一个问题的答案
if (prevValues === false) {
// 用户选择了"否",抛出错误中断
throw new Error('操作已取消')
}
return null // 返回 null 不显示问题
}
}
工作原理:
bash
用户选择: 是否覆盖?
├─ 选择"是" (true)
│ ↓
│ type(true) → null → 跳过验证器 → 继续下一个问题
│
└─ 选择"否" (false)
↓
type(false) → 抛出错误 → 整个流程中断
6. create-unibest 问答系统架构
文件结构:
bash
src/question/
├── index.ts # 主入口,组合所有问题
├── name.ts # 项目名问题
├── file.ts # 文件覆盖问题
└── template/
├── index.ts # 模板选择问题
└── templateDate.ts # 模板数据
6.1 主入口 (src/question/index.ts)
代码:
typescript
import prompts from 'prompts'
import { bold, red } from 'kolorist'
import figures from 'prompts/lib/util/figures.js'
import projectName from './name'
import template from './template'
export async function question() {
// 组合所有问题
const questions = [
...projectName(), // 项目名 + 文件覆盖
template(), // 模板选择
]
// 取消处理
const onCancel = () => {
throw new Error(`${red(figures.cross)} ${bold('操作已取消')}`)
}
// 执行问答
const answers = await prompts(questions, { onCancel })
return answers
}
关键点:
- 使用展开运算符
...组合问题数组 onCancel处理用户按 Ctrl+C 的情况await等待用户输入完成- 返回答案对象
6.2 项目名问题 (src/question/name.ts)
代码:
typescript
import type { PromptObject } from 'prompts'
import filePrompt from './file'
export default (): PromptObject<string>[] => {
return [
{
name: 'projectName',
type: 'text',
message: '请输入项目名称:',
initial: 'unibest',
},
...filePrompt(), // 嵌入文件覆盖问题
]
}
特点:
- 返回问题数组(不是单个问题)
- 包含项目名问题 + 文件覆盖问题
- 使用
...filePrompt()展开
6.3 文件覆盖问题 (src/question/file.ts)
完整代码分析:
typescript
import type { PromptObject } from 'prompts'
import { bold, red } from 'kolorist'
import figures from 'prompts/lib/util/figures.js'
import { canSkipEmptying } from '../utils'
export default (targetDir?: string): PromptObject<string>[] => {
return [
// 问题 1: 覆盖确认
{
name: 'shouldOverwrite',
// 🎯 动态 type: 根据目录状态决定是否显示
type: prevValue => (
canSkipEmptying(targetDir ?? prevValue)
? null // 目录空/不存在 → 跳过
: 'toggle' // 目录非空 → 显示 toggle
),
// 🎯 动态 message: 根据目录名生成问题文本
message: (prevValue) => {
const _targetDir = targetDir ?? prevValue
const dirForPrompt = _targetDir === '.'
? '当前文件'
: `目标文件"${_targetDir}"`
return `${dirForPrompt}非空,是否覆盖?`
},
initial: false,
active: '是',
inactive: '否',
},
// 问题 2: 验证器(隐藏)
{
name: 'overwriteChecker',
// 🎯 如果用户选择"否",抛出错误
type: (prevValues) => {
if (prevValues === false)
throw new Error(`${red(figures.cross)} ${bold('操作已取消')}`)
return null // 不显示这个问题
},
},
]
}
执行流程:
bash
场景 1: 目录不存在
projectName = "new-app"
↓
canSkipEmptying("new-app") = true
↓
type(prevValue) 返回 null
↓
跳过 shouldOverwrite 问题
↓
跳过 overwriteChecker 问题
↓
继续模板选择
场景 2: 目录存在且非空,用户选择"是"
projectName = "existing-app"
↓
canSkipEmptying("existing-app") = false
↓
type(prevValue) 返回 'toggle'
↓
显示: ? 目标文件"existing-app"非空,是否覆盖?
↓
用户选择: 是 (true)
↓
overwriteChecker: type(true) 返回 null
↓
继续模板选择
场景 3: 目录存在且非空,用户选择"否"
projectName = "existing-app"
↓
显示: ? 目标文件"existing-app"非空,是否覆盖?
↓
用户选择: 否 (false)
↓
overwriteChecker: type(false) 抛出错误
↓
整个流程中断,程序退出
6.4 模板选择问题 (src/question/template/index.ts)
代码:
typescript
import type { PromptObject } from 'prompts'
import { templateList } from './templateDate'
export default (): PromptObject<string> => {
return {
name: 'templateType',
type: 'select',
message: '请选择 uni-app 模板?',
hint: '使用方向键选择,回车确认',
choices: [
...templateList, // 从配置文件导入
],
initial: 0, // 默认选中第一个
}
}
templateList 数据结构:
typescript
// src/question/template/templateDate.ts
export const templateList: TemplateList[] = [
{
title: `base${green('(推荐)')}`, // 显示文本
description: `${red('(多TAB base项目)')}`, // 描述
value: { // 返回值
type: 'base',
branch: 'base',
url: {
gitee: 'https://gitee.com/codercup/unibest.git',
github: 'https://github.com/codercup/unibest.git',
},
},
},
// ... 更多模板
]
显示效果:
scss
? 请选择 uni-app 模板? › - 使用方向键选择,回车确认
❯ base(推荐) - (多TAB base项目)
demo(演示项目) - (多TAB演示项目)
i18n(多语言) - (多TAB多语言项目)
7. 取消处理 (onCancel)
用途: 处理用户按 Ctrl+C 或 Esc 的情况
语法:
javascript
const onCancel = () => {
// 处理取消逻辑
throw new Error('操作已取消') // 抛出错误
// 或
return true // 返回 true 继续,false 抛出默认错误
}
await prompts(questions, { onCancel })
create-unibest 的实现:
typescript
const onCancel = () => {
throw new Error(`${red(figures.cross)} ${bold('操作已取消')}`)
}
try {
const answers = await prompts(questions, { onCancel })
} catch (cancelled) {
console.log(cancelled.message)
process.exit(1)
}
效果:
? 请输入项目名称: › ^C
✖ 操作已取消
8. 问答结果对象
输入示例:
bash
pnpm create unibest
用户交互:
csharp
? 请输入项目名称: › my-app
? 目标文件"my-app"非空,是否覆盖? › 是
? 请选择 uni-app 模板? › base(推荐)
返回的 answers 对象:
javascript
{
projectName: 'my-app',
shouldOverwrite: true,
templateType: {
type: 'base',
branch: 'base',
url: {
gitee: 'https://gitee.com/codercup/unibest.git',
github: 'https://github.com/codercup/unibest.git'
}
}
}
注意:
overwriteChecker不在结果中(它的 type 返回 null)- 跳过的问题也不在结果中
9. 完整执行流程
ini
用户执行: pnpm create unibest
↓
printBanner()
↓
解析参数: projectName = undefined
↓
走路径 A: 完全交互式
↓
调用 question() 函数
↓
┌─────────────────────────────────┐
│ 问题 1: 请输入项目名称 │
│ 用户输入: "my-app" │
│ answers.projectName = "my-app" │
└─────────────────────────────────┘
↓
检查目录: canSkipEmptying("my-app")
├─ true → 跳过问题 2
└─ false → 显示问题 2
↓
┌─────────────────────────────────┐
│ 问题 2: 是否覆盖? │
│ 用户选择: 是 │
│ answers.shouldOverwrite = true │
└─────────────────────────────────┘
↓
验证器检查: shouldOverwrite === true
↓ 通过
┌─────────────────────────────────┐
│ 问题 3: 选择模板 │
│ 用户选择: base │
│ answers.templateType = {...} │
└─────────────────────────────────┘
↓
返回 answers 对象
↓
开始下载模板...
💡 设计思路总结
1. 智能动态问题
问题: 不是所有情况都需要询问
解决方案:
- 使用函数式
type - 根据上下文动态决定是否显示
好处:
- 减少不必要的问题
- 提升用户体验
- 逻辑清晰
2. 链式验证模式
问题: 用户选择"否"时如何优雅退出?
解决方案:
- 添加隐藏的验证器问题
type返回null不显示- 在验证器中抛出错误
好处:
- 即时反馈
- 流程清晰
- 避免无效操作
3. 问题模块化
架构:
bash
question/
├── index.ts # 组合器
├── name.ts # 项目名模块
├── file.ts # 文件覆盖模块
└── template/ # 模板选择模块
好处:
- 职责分离
- 易于维护
- 可复用
4. 用户友好
细节:
- 提供默认值 (
initial: 'unibest') - 提供提示 (
hint: '使用方向键选择,回车确认') - 彩色高亮 (
green('(推荐)')) - 清晰的描述 (
description: '(多TAB base项目)')
🔧 prompts API 速查表
问题类型
| 类型 | 用途 | 返回值类型 |
|---|---|---|
text |
文本输入 | string |
number |
数字输入 | number |
confirm |
是/否 | boolean |
toggle |
开关 | boolean |
select |
单选 | any |
multiselect |
多选 | any[] |
autocomplete |
自动完成 | any |
常用字段
typescript
{
type: 'text' | 'number' | 'select' | ...,
name: 'fieldName',
message: '问题文本',
initial: 默认值,
choices: [{ title, value }], // select/multiselect
active: '是', // toggle
inactive: '否', // toggle
hint: '提示信息',
validate: (value) => boolean | string,
format: (value) => newValue,
onState: (state) => { ... }
}
动态字段
javascript
// 动态 type
type: (prev) => 条件 ? '类型' : null
// 动态 message
message: (prev) => `根据 ${prev} 生成的消息`
// 验证
validate: (value) => value.length > 0 || '不能为空'
📚 延伸阅读
prompts 官方文档
其他交互式库
✅ 阶段 3 总结
你学到了:
- ✅ prompts 基本用法 - 各种问题类型
- ✅ 动态问题机制 - type 和 message 函数
- ✅ 验证器模式 - 隐藏问题实现流程控制
- ✅ 取消处理 - onCancel 回调
- ✅ 模块化架构 - 问题组织和复用
- ✅ 用户体验优化 - 默认值、提示、彩色输出
关键要点:
| 概念 | 作用 |
|---|---|
type 函数 |
动态决定是否显示问题 |
message 函数 |
根据上下文生成问题文本 |
| 验证器模式 | 隐藏问题 + 错误抛出 = 流程控制 |
onCancel |
处理用户取消操作 |
| 展开运算符 | 组合问题数组 |
准备好进入阶段 4 了吗? 🚀
下一阶段将学习 文件系统操作,揭秘目录遍历、清空、创建的实现!