EN

交互式应用

在 Func 中接入流行的开源终端 UI 库最佳实践。

更新于

func 不替应用选择或封装终端 UI 库。应用根据需要安装第三方库,再把它绑定到当前 Context。本页用多个开源库分别说明不同的接入边界;@clack/prompts 只是其中一个示例,不是默认推荐。

终端应用的主界面和 Prompt 应绑定到本次调用的 stdout;警告、错误与独立的进度展示继续使用 stderr。通过 Context.io 而不是进程级全局 streams 接入,可以保留 invocation 宿主的输出策略、stream 替换和测试隔离。同一个交互 session 中的 renderer 应使用同一个输出 stream,避免光标更新相互竞争。

使用 Ink 渲染终端应用

Ink 是 React renderer。将主 render target 绑定到本次调用的 stdout,同时保留独立的 stderr 通道。关闭 exitOnCtrlCpatchConsole,因为 signal 与应用输出由 func 管理:

TypeScript
import { render } from 'ink'

const { stdin, stdout, stderr } = context.io

const application = render(view, {
  stdin,
  stdout,
  stderr,
  exitOnCtrlC: false,
  patchConsole: false,
})

try {
  await application.waitUntilExit()
} finally {
  application.unmount()
}

所有路径都应 unmount;Command 取消时,再让 Context.signal 提前触发 unmount。可复制的 Ink service 已包含 signal 绑定。

使用 Inquirer 明确 stdin 所有权

Inquirer 将运行时 streams 和 abort signal 与 Prompt 选项分开传入:

TypeScript
import { input } from '@inquirer/prompts'

const name = await input(
  { message: 'Project name' },
  {
    input: context.io.stdin,
    output: context.io.stdout,
    signal: context.signal,
  },
)

Prompt 启动前应检查输入与渲染 stream 都是 TTY。在 CI、Shell 管道或其他非交互环境中,改用命令选项或默认值,并在缺少必要数据时立即报错。不要让同一个 stdin 既承载管道数据,又充当交互按键输入。参见可复制的 Inquirer service

等待 Prompt 只会暂停 Command 的输入,不会阻塞 Node.js 事件循环,但同步任务仍会阻塞输入、signal 处理与所有终端动画。

使用 Ora 渲染进度

Ora 把单个 spinner 渲染到 writable stream。独立 spinner 通常指代任务执行进度,而不是命令主界面,因此建议将 stream 绑定到 stderr,并在 finally 中停止:

TypeScript
import ora from 'ora'

const progress = ora({
  text: 'Publishing',
  stream: context.io.stderr,
  discardStdin: false,
}).start()

try {
  await publish({ signal: context.signal })
  progress.succeed('Published')
} catch (error) {
  progress.fail('Publish failed')
  throw error
} finally {
  progress.stop()
}

Ora 的 discardStdin 会操作进程级 stdin,而不是接收一个自定义 input stream;streams 由 Context 管理时应将其关闭。实际任务自身仍应接收 Context.signal。参见可复制的 Ora service

使用 Chalk 添加样式

Chalk 只生成带样式的字符串,我们可以根据目标 stream 决定颜色能力,将装饰性或诊断性文本写入 stderr,并让结构化 stdout 保持无样式:

TypeScript
import { Chalk } from 'chalk'

const { stdout, stderr } = context.io
const style = new Chalk({ level: 'isTTY' in stderr && stderr.isTTY ? 1 : 0 })
stderr.write(`${style.green('✔')} Published\n`)
stdout.write(`${JSON.stringify(result)}\n`)

自定义或捕获 stream 无法确认终端能力时,颜色等级应默认为 0。参见可复制的 Chalk service

代码片段示例

下面只用 @clack/prompts 给出一个具体的最小实现,并不表示默认推荐。它应由用户选择自行安装:

终端
npm install @clack/prompts
src/services/clack.service.ts
import { text } from '@clack/prompts'
import { Context, Injectable } from 'func'
import type { Readable, Writable } from 'node:stream'

@Injectable()
export class ClackService {
  static IsTTY(stream: Readable | Writable): boolean {
    return 'isTTY' in stream && stream.isTTY === true
  }

  constructor(private readonly context: Context) {}

  projectName() {
    const { stdin, stdout } = this.context.io
    if (!ClackService.IsTTY(stdin) || !ClackService.IsTTY(stdout)) {
      throw new TypeError('Interactive input requires a terminal.')
    }

    return text({
      message: 'Project name',
      input: stdin,
      output: stdout,
      signal: this.context.signal,
    })
  }
}

ClackService 注册到所属 Module 的 providers,再注入 Command。最终 stdout 结果仍由 Command 管理:

src/commands/create.command.ts
import { Command, Context, Handler } from 'func'
import { ClackService } from '../services/clack.service'

@Command('create')
export class CreateCommand {
  constructor(
    private readonly context: Context,
    private readonly prompts: ClackService,
  ) {}

  @Handler()
  async run() {
    const name = await this.prompts.projectName()
    if (typeof name === 'symbol') return

    this.context.io.stdout.write(`${name}\n`)
  }
}

仓库提供了可复制的完整 Clack service,包含 Prompt 绑定、取消辅助方法、TTY 检查和 spinner 清理。底层约定参见输入与输出进程信号测试生态选型