用 React 写 CLI 是什么体验?—— Ink 框架深度解析与实战

4 阅读6分钟

用 React 写 CLI 是什么体验?—— Ink 框架深度解析与实战

Claude Code 源码泄露技术解析系列 · 第 3 篇
探索终端 UI 的组件化革命,用声明式思维构建现代 CLI 应用


引言

当你看到 Claude Code 的终端界面时,可能会惊讶地发现:它用 React 来写 CLI

┌─────────────────────────────────────────┐
│  Claude Code CLI                        │
├─────────────────────────────────────────┤
│  ⠋ Thinking...                          │
│                                         │
│  > 帮我分析这个项目的结构               │
│                                         │
│  📁 src/                                │
│  📁 tests/                              │
│  📄 package.json                        │
│                                         │
│  [1] 继续分析  [2] 查看文件  [3] 退出   │
└─────────────────────────────────────────┘

这不是传统的命令行界面,而是一个用 React 组件构建的交互式终端应用。本文将深入解析 Ink 框架,学习如何用组件化思维构建现代 CLI。

本文你将学到

  • 终端 UI 的演进历史(curses → blessed → Ink)
  • React 组件模型在终端的适配原理
  • Ink 核心 API 详解(Text, Box, useInput, useApp)
  • 实战:构建一个带进度条、表格、交互的 CLI
  • 性能优化与渲染策略

一、终端 UI 的演进历史

1.1 第一代:curses/ncurses(1980s)

// C 语言,命令式 API
#include <ncurses.h>

int main() {
    initscr();
    mvprintw(0, 0, "Hello World");
    refresh();
    getch();
    endwin();
    return 0;
}

特点:直接操作终端光标位置,性能高但代码难以维护。

1.2 第二代:blessed/blessed-contrib(2010s)

// JavaScript,回调式 API
const blessed = require('blessed');

const screen = blessed.screen();
const box = blessed.box({
  top: 0,
  left: 0,
  width: '50%',
  height: '50%',
  content: 'Hello World',
  border: { type: 'line' }
});

screen.append(box);
screen.render();

特点:声明式配置,但仍基于命令式更新。

1.3 第三代:Ink(2019+)

// React + TypeScript,组件式 API
import { render, Text, Box } from 'ink';

function Hello({ name }) {
  return (
    <Box borderStyle="round">
      <Text color="green">Hello, {name}!</Text>
    </Box>
  );
}

render(<Hello name="World" />);

特点:完全组件化,利用 React 的虚拟 DOM 和 Hooks 生态。


二、React 组件模型在终端的适配

2.1 核心挑战

终端与浏览器的本质差异:

特性浏览器终端
渲染目标DOM 树字符网格
坐标系统连续像素离散字符位置
样式系统CSSANSI 转义码
事件系统丰富事件有限键盘输入

2.2 Ink 的解决方案

Ink 通过以下机制适配 React 到终端:

React Component
    ↓
Reconciliation (React Fiber)
    ↓
Ink Elements (Box, Text, etc.)
    ↓
Yoga Layout Engine (Flexbox)
    ↓
ANSI Escape Sequences
    ↓
Terminal Output

2.3 虚拟终端缓冲

Ink 维护一个虚拟终端缓冲区,只在变化时输出差异:

// 伪代码:Ink 的渲染优化
class TerminalRenderer {
  private previousFrame: Frame;
  private currentFrame: Frame;
  
  render(component: ReactElement) {
    this.currentFrame = this.computeFrame(component);
    const diff = this.computeDiff(this.previousFrame, this.currentFrame);
    this.writeDiff(diff);
    this.previousFrame = this.currentFrame;
  }
}

三、Ink 核心 API 详解

3.1 基础组件

Box - 布局容器
import { Box, Text } from 'ink';

function Layout() {
  return (
    <Box flexDirection="column">
      <Box padding={1} backgroundColor="blue">
        <Text color="white">Header</Text>
      </Box>
      <Box flexGrow={1} padding={1}>
        <Text>Content</Text>
      </Box>
      <Box padding={1} borderStyle="round">
        <Text>Footer</Text>
      </Box>
    </Box>
  );
}

常用属性

  • flexDirection: 'row' | 'column'
  • justifyContent: 'flex-start' | 'center' | 'flex-end' | 'space-between'
  • alignItems: 'flex-start' | 'center' | 'flex-end'
  • padding, margin: number | { top, right, bottom, left }
  • borderStyle: 'single' | 'double' | 'round' | 'bold'
Text - 文本渲染
import { Text } from 'ink';

function StyledText() {
  return (
    <>
      <Text color="red">红色文本</Text>
      <Text backgroundColor="yellow" color="black">高亮文本</Text>
      <Text bold>粗体文本</Text>
      <Text italic>斜体文本</Text>
      <Text underline>下划线文本</Text>
      <Text dimColor>暗淡文本</Text>
      <Text wrap="wrap">自动换行的长文本...</Text>
      <Text truncate={20}>被截断的长文本</Text>
    </>
  );
}

3.2 Hooks

useInput - 处理键盘输入
import { useInput } from 'ink';

function Interactive() {
  useInput((input, key) => {
    if (input === 'q') {
      // 退出
      process.exit(0);
    }
    
    if (key.leftArrow) {
      // 左箭头
      console.log('Left');
    }
    
    if (key.ctrl && input === 'c') {
      // Ctrl+C
      process.exit(0);
    }
  });
  
  return <Text>按 q 退出,使用箭头键导航</Text>;
}
useApp - 访问应用上下文
import { useApp, useInput } from 'ink';

function AppWithExit() {
  const { exit } = useApp();
  
  useInput((input) => {
    if (input === 'q') {
      exit();
    }
  });
  
  return <Text>按 q 退出应用</Text>;
}
useState + useInput = 交互状态
import { useState, useInput } from 'ink';

function Counter() {
  const [count, setCount] = useState(0);
  
  useInput((input) => {
    if (input === '+') setCount(c => c + 1);
    if (input === '-') setCount(c => Math.max(0, c - 1));
  });
  
  return <Text>计数:{count} (按 + 增加,- 减少)</Text>;
}

3.3 高级组件

Spinner - 加载指示器
import { useInterval } from 'ink-use-interval';

function LoadingSpinner() {
  const [frame, setFrame] = useState(0);
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
  
  useInterval(() => {
    setFrame(f => (f + 1) % frames.length);
  }, 80);
  
  return (
    <Text>
      <Text color="cyan">{frames[frame]}</Text>
      <Text> 加载中...</Text>
    </Text>
  );
}
ProgressBar - 进度条
function ProgressBar({ value, max = 100 }) {
  const percentage = Math.round((value / max) * 100);
  const filled = Math.round((percentage / 100) * 20);
  const empty = 20 - filled;
  
  return (
    <Text>
      <Text backgroundColor="green">
        {'█'.repeat(filled)}
      </Text>
      <Text backgroundColor="gray">
        {'░'.repeat(empty)}
      </Text>
      <Text> {percentage}%</Text>
    </Text>
  );
}

四、实战:构建交互式 CLI

4.1 项目初始化

# 创建项目
mkdir my-ink-cli && cd my-ink-cli
npm init -y

# 安装依赖
npm install ink react
npm install -D @types/react typescript bun-types

# 配置 TypeScript
cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true
  }
}
EOF

4.2 完整示例:文件浏览器 CLI

#!/usr/bin/env bun
// src/file-browser.tsx
import { render, Box, Text, useInput, useApp } from 'ink';
import { useState, useEffect } from 'react';
import { readdir, stat } from 'fs/promises';
import { join, basename } from 'path';

interface FileEntry {
  name: string;
  path: string;
  isDirectory: boolean;
  size?: number;
}

interface Props {
  initialPath?: string;
}

function FileBrowser({ initialPath = '.' }: Props) {
  const { exit } = useApp();
  const [currentPath, setCurrentPath] = useState(initialPath);
  const [files, setFiles] = useState<FileEntry[]>([]);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [error, setError] = useState<string | null>(null);
  const [history, setHistory] = useState<string[]>([]);

  // 加载目录内容
  useEffect(() => {
    async function loadFiles() {
      try {
        const entries = await readdir(currentPath, { withFileTypes: true });
        const fileEntries: FileEntry[] = [];
        
        // 添加父目录选项
        if (currentPath !== '/' && currentPath !== '.') {
          fileEntries.push({
            name: '..',
            path: join(currentPath, '..'),
            isDirectory: true
          });
        }
        
        // 排序:目录在前,文件在后
        const sorted = entries.sort((a, b) => {
          if (a.isDirectory === b.isDirectory) return a.name.localeCompare(b.name);
          return a.isDirectory ? -1 : 1;
        });
        
        for (const entry of sorted) {
          const fullPath = join(currentPath, entry.name);
          let size: number | undefined;
          
          if (!entry.isDirectory) {
            try {
              const s = await stat(fullPath);
              size = s.size;
            } catch {}
          }
          
          fileEntries.push({
            name: entry.name,
            path: fullPath,
            isDirectory: entry.isDirectory,
            size
          });
        }
        
        setFiles(fileEntries);
        setSelectedIndex(0);
        setError(null);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error');
      }
    }
    
    loadFiles();
  }, [currentPath]);

  // 处理键盘输入
  useInput((input, key) => {
    if (input === 'q' || (key.ctrl && input === 'c')) {
      exit();
      return;
    }
    
    if (key.upArrow) {
      setSelectedIndex(i => Math.max(0, i - 1));
    } else if (key.downArrow) {
      setSelectedIndex(i => Math.min(files.length - 1, i + 1));
    } else if (input === '\n' || key.return) {
      const selected = files[selectedIndex];
      if (selected) {
        if (selected.name === '..') {
          setHistory(h => [...h, currentPath]);
          setCurrentPath(selected.path);
        } else if (selected.isDirectory) {
          setHistory(h => [...h, currentPath]);
          setCurrentPath(selected.path);
        }
      }
    } else if (input === 'b' && history.length > 0) {
      const prev = history.pop();
      if (prev) {
        setHistory([...history]);
        setCurrentPath(prev);
      }
    }
  });

  // 格式化文件大小
  function formatSize(bytes?: number): string {
    if (bytes === undefined) return '';
    if (bytes < 1024) return `${bytes}B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
    return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
  }

  return (
    <Box flexDirection="column">
      {/* 标题栏 */}
      <Box paddingY={1} backgroundColor="blue">
        <Text color="white" bold>
          📁 {basename(currentPath) || currentPath}
        </Text>
      </Box>
      
      {/* 路径导航 */}
      <Box paddingY={1}>
        <Text dimColor>路径:{currentPath}</Text>
        {history.length > 0 && (
          <Text dimColor> (按 b 返回)</Text>
        )}
      </Box>
      
      {/* 错误信息 */}
      {error && (
        <Box paddingY={1} backgroundColor="red">
          <Text color="white">❌ {error}</Text>
        </Box>
      )}
      
      {/* 文件列表 */}
      <Box flexDirection="column">
        {files.length === 0 ? (
          <Text dimColor>空目录</Text>
        ) : (
          files.map((file, index) => (
            <Box
              key={file.path}
              paddingX={1}
              backgroundColor={index === selectedIndex ? 'blue' : undefined}
            >
              <Text color={index === selectedIndex ? 'white' : undefined}>
                {index === selectedIndex ? '❯ ' : '  '}
                {file.isDirectory ? '📁 ' : '📄 '}
                {file.name}
                {file.size !== undefined && (
                  <Text dimColor> ({formatSize(file.size)})</Text>
                )}
              </Text>
            </Box>
          ))
        )}
      </Box>
      
      {/* 帮助信息 */}
      <Box paddingY={1} borderTop={1}>
        <Text dimColor>
          ↑↓ 导航  Enter 进入  b 返回  q 退出
        </Text>
      </Box>
    </Box>
  );
}

// 渲染应用
const { waitUntilExit } = render(<FileBrowser initialPath={process.argv[2] || '.'} />);
await waitUntilExit();

4.3 运行效果

$ bun run src/file-browser.tsx /path/to/browse

┌─────────────────────────────────────────┐
│ 📁 browse                               │
├─────────────────────────────────────────┤
│ 路径:/path/to/browse (按 b 返回)        │
│                                         │
│   ❯ 📁 ..                               │
│     📁 src/                             │
│     📁 tests/                           │
│     📄 package.json (2.3K)              │
│     📄 README.md (5.1K)                 │
│                                         │
│ ─────────────────────────────────────── │
│ ↑↓ 导航  Enter 进入  b 返回  q 退出      │
└─────────────────────────────────────────┘

五、性能优化与渲染策略

5.1 避免不必要的重渲染

// ❌ 不好:每次状态变化都重新计算
function BadExample({ items }) {
  const [count, setCount] = useState(0);
  const sorted = items.sort(); // 每次都排序
  
  return <Text>{sorted.length}</Text>;
}

// ✅ 好:使用 useMemo
function GoodExample({ items }) {
  const [count, setCount] = useState(0);
  const sorted = useMemo(() => items.sort(), [items]);
  
  return <Text>{sorted.length}</Text>;
}

5.2 静态内容优化

import { Static } from 'ink';

function LogViewer({ logs }) {
  return (
    <>
      {/* 静态内容:输出后不再更新 */}
      <Static>
        {logs.map((log, i) => (
          <Text key={i}>{log}</Text>
        ))}
      </Static>
      
      {/* 动态内容:持续更新 */}
      <Text>当前处理:{currentFile}</Text>
    </>
  );
}

5.3 节流与防抖

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  // 防抖搜索
  useEffect(() => {
    const timer = setTimeout(async () => {
      if (query) {
        const r = await search(query);
        setResults(r);
      }
    }, 300);
    
    return () => clearTimeout(timer);
  }, [query]);
  
  return <Text>搜索:{query}</Text>;
}

六、Claude Code 中的 Ink 应用

6.1 消息流式显示

Claude Code 使用 Ink 实现流式消息渲染:

// 简化版 Claude Code 消息组件
function StreamingMessage({ content, isComplete }) {
  return (
    <Box flexDirection="column">
      <Text color="cyan">
        {isComplete ? '✅' : '⏳'}
        {' '}Claude:
      </Text>
      <Box paddingX={2}>
        <Text wrap="wrap">{content}</Text>
        {!isComplete && <Text></Text>}
      </Box>
    </Box>
  );
}

6.2 工具执行状态

function ToolExecution({ tool, status, output }) {
  const statusIcons = {
    pending: '⏳',
    running: '🔄',
    success: '✅',
    error: '❌'
  };
  
  return (
    <Box flexDirection="column" marginY={1}>
      <Text>
        {statusIcons[status]} {tool.name}
        {status === 'running' && '...'}
      </Text>
      {output && (
        <Box paddingX={2} borderStyle="round">
          <Text dimColor>{output}</Text>
        </Box>
      )}
    </Box>
  );
}

6.3 多智能体状态展示

function SwarmStatus({ agents }) {
  return (
    <Box flexDirection="column" borderStyle="round" padding={1}>
      <Text bold>🐝 Agent Swarm 状态</Text>
      {agents.map(agent => (
        <Box key={agent.id} paddingX={1}>
          <Text>
            {agent.status === 'working' ? '🔄' : '⏸️'}
            {' '}{agent.name}: {agent.task}
          </Text>
        </Box>
      ))}
    </Box>
  );
}

七、最佳实践与避坑指南

7.1 推荐实践

使用 TypeScript

// 明确定义 Props 类型
interface Props {
  title: string;
  count?: number;
  onExit?: () => void;
}

function MyComponent({ title, count = 0, onExit }: Props) {
  // ...
}

分离业务逻辑与 UI

// hooks/useFileList.ts
export function useFileList(path: string) {
  const [files, setFiles] = useState<FileEntry[]>([]);
  // 业务逻辑...
  return { files, loading, error };
}

// components/FileList.tsx
function FileList({ path }: { path: string }) {
  const { files, loading, error } = useFileList(path);
  // 只负责渲染
  return <Text>{files.length} files</Text>;
}

使用 ink-testing-library 测试

// __tests__/counter.test.tsx
import { render } from 'ink-testing-library';
import { Counter } from '../src/counter';

test('counter increments', () => {
  const { lastFrame } = render(<Counter />);
  expect(lastFrame()).toContain('计数:0');
});

7.2 常见陷阱

避免在 render 中执行副作用

// ❌ 错误
function Bad() {
  const data = fs.readFileSync('file.txt'); // 每次渲染都读取
  return <Text>{data}</Text>;
}

// ✅ 正确
function Good() {
  const [data, setData] = useState('');
  useEffect(() => {
    fs.readFile('file.txt').then(setData);
  }, []);
  return <Text>{data}</Text>;
}

八、总结

核心要点回顾

特性传统 CLIInk (React CLI)
代码组织命令式过程组件化
状态管理全局变量React State
布局系统手动计算Flexbox
测试困难ink-testing-library
生态有限完整 React 生态

Ink 适合的场景

  • ✅ 需要复杂交互的 CLI 工具
  • ✅ 团队已有 React 经验
  • ✅ 需要丰富 UI 组件(表格、进度条、表单)
  • ✅ 长期维护的生产级 CLI

可能不适合的场景

  • ⚠️ 简单的单命令工具
  • ⚠️ 对启动速度极度敏感(Ink 有 React 开销)
  • ⚠️ 团队不熟悉 React

延伸学习资源


系列导航


下篇预告:设计一个可扩展的工具系统 —— 从 Claude Code 的 40+ 工具学习架构模式,包括工具接口设计、权限门控、沙箱隔离等核心概念。


免责声明:本文仅用于教育和研究目的。所有代码示例为原创。