跟着AI速度cli源码三-交互问答系统

阶段 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
}

关键点:

  1. 使用展开运算符 ... 组合问题数组
  2. onCancel 处理用户按 Ctrl+C 的情况
  3. await 等待用户输入完成
  4. 返回答案对象

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. 用户友好

细节:

  1. 提供默认值 (initial: 'unibest')
  2. 提供提示 (hint: '使用方向键选择,回车确认')
  3. 彩色高亮 (green('(推荐)'))
  4. 清晰的描述 (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 总结

你学到了:

  1. prompts 基本用法 - 各种问题类型
  2. 动态问题机制 - type 和 message 函数
  3. 验证器模式 - 隐藏问题实现流程控制
  4. 取消处理 - onCancel 回调
  5. 模块化架构 - 问题组织和复用
  6. 用户体验优化 - 默认值、提示、彩色输出

关键要点:

概念 作用
type 函数 动态决定是否显示问题
message 函数 根据上下文生成问题文本
验证器模式 隐藏问题 + 错误抛出 = 流程控制
onCancel 处理用户取消操作
展开运算符 组合问题数组

准备好进入阶段 4 了吗? 🚀

下一阶段将学习 文件系统操作,揭秘目录遍历、清空、创建的实现!

相关推荐
BBB努力学习程序设计2 小时前
用Bootstrap一天搞定响应式网站:前端小白的救命稻草
前端·html
用户0136087566882 小时前
前端支持的主要数据类型及其使用方式
前端
代码搬运媛2 小时前
SOLID 原则在前端的应用
前端
lecepin2 小时前
AI Coding 资讯 2025-11-17
前端
孟祥_成都3 小时前
下一代组件的奥义在此!headless 组件构建思想探索!
前端·设计模式·架构
灰太狼大王灬3 小时前
Telegram 自动打包上传机器人 通过 Telegram 消息触发项目的自动打包和上传。
前端·机器人
4***14903 小时前
SpringSecurity登录成功后跳转问题
前端
小徐敲java3 小时前
window使用phpStudy在nginx部署前端测试
运维·前端·nginx
Winslei3 小时前
【hvigor专栏】OpenHarmony应用开发-hvigor插件之动态修改应用hap文件名
前端