CLI
开源项目: ink它的作用就是将 React 组件渲染在终端窗口中,呈现出最后的命令行界面。

上手初体验

刚开始上手时,推荐使用官方的脚手架创建项目,省时省心。

  1. npx create-ink-app --typescript

然后运行这样一段代码:

  1. import React, { useState, useEffect } from 'react'
  2. import { render, Text} from 'ink'
  3. const Counter = () => {
  4. const [count, setCount] = useState(0)
  5. useEffect(() => {
  6. const timer = setInterval(() => {
  7. setCount(count => ++count)
  8. }, 100)
  9. return () => {
  10. clearInterval(timer)
  11. }
  12. })
  13. return (
  14. <Text color="green">
  15. {count} tests passed
  16. </Text>
  17. )
  18. }
  19. render(<Counter />);

会出现如下的界面:
React 开发 CLI 工具 - 图1
并且数字一直递增:

  1. 首先,这些文本输出都不是直接 console 出来的,而是通过 React 组件渲染出来的。
  2. React 组件的状态管理以及hooks 逻辑放到命令行的 GUI 当中仍然是生效的。

也就是说,前端的能力以及扩展到了命令行窗口当中了,这无疑是一项非常可怕的能力。著名的文档生成工具Gatsby,包管理工具yarn2都使用了这项能力来完成终端 GUI 的搭建。

命令行工具项目实战

项目背景

首先说一说项目的产生背景,在一个 TS 的业务项目当中,碰到了一个问题:由于production模式下面,是采用先 tsc,拿到 js 产物代码,再用webpack打包这些产物。
但构建的时候直接报错了,原因就是 tsc 无法将 ts(x) 以外的资源文件移动到产物目录,以至于 webpack 在对于产物进行打包的时候,发现有些资源文件根本找不到!比如以前有这样一张图片的路径是这样—— src/asset/1.png,但这些在产物目录dist却没还有,因此 webpack 在打包 dist 目录下的代码时,会发现这张图片不存在,于是报错了。

解决思路

很显然,自己很难去扩展 tsc 的能力,现在最好的方式就是写个脚本手动将src下面的所有资源文件一一拷贝到dist目录,这样就能解决资源无法找到的问题。

一、拷贝文件逻辑

确定了解决思路之后,写下这样一段 ts 代码:

  1. import { join, parse } from "path";
  2. import { fdir } from 'fdir';
  3. import fse from 'fs-extra'
  4. const staticFiles = await new fdir()
  5. .withFullPaths()
  6. // 过滤掉 node_modules、ts、tsx
  7. .filter(
  8. (p) =>
  9. !p.includes('node_modules') &&
  10. !p.endsWith('.ts') &&
  11. !p.endsWith('.tsx')
  12. )
  13. // 搜索 src 目录
  14. .crawl(srcPath)
  15. .withPromise() as string[]
  16. await Promise.all(staticFiles.map(file => {
  17. const targetFilePath = file.replace(srcPath, distPath);
  18. // 创建目录并拷贝文件
  19. return fse.mkdirp(parse(targetFilePath).dir)
  20. .then(() => fse.copyFile(file, distPath))
  21. );
  22. }))

代码使用了fdir这个库才搜索文件,非常好用的一个库,写法上也很优雅,推荐使用。
执行这段逻辑,成功将资源文件转移到到了产物目录中。
问题是解决掉了,但能不能封装一下这个逻辑,让它能够更方便地在其它项目当中复用,甚至直接提供给其他人复用呢?

二、命令行 GUI 搭建

接着使用 ink,也就是用 React 组件的方式来搭建命令行 GUI,根组件代码如下:

  1. // index.tsx 引入代码省略
  2. interface AppProps {
  3. fileConsumer: FileCopyConsumer
  4. }
  5. const ACTIVE_TAB_NAME = {
  6. STATE: "执行状态",
  7. LOG: "执行日志"
  8. }
  9. const App: FC<AppProps> = ({ fileConsumer }) => {
  10. const [activeTab, setActiveTab] = useState<string>(ACTIVE_TAB_NAME.STATE);
  11. const handleTabChange = (name) => {
  12. setActiveTab(name)
  13. }
  14. const WELCOME_TEXT = dedent`
  15. 欢迎来到 \`ink-copy\` 控制台!功能概览如下(按 **Tab** 切换):
  16. `
  17. return <>
  18. <FullScreen>
  19. <Box>
  20. <Markdown>{WELCOME_TEXT}</Markdown>
  21. </Box>
  22. <Tabs onChange={handleTabChange}>
  23. <Tab name={ACTIVE_TAB_NAME.STATE}>{ACTIVE_TAB_NAME.STATE}</Tab>
  24. <Tab name={ACTIVE_TAB_NAME.LOG}>{ACTIVE_TAB_NAME.LOG}</Tab>
  25. </Tabs>
  26. <Box>
  27. <Box display={ activeTab === ACTIVE_TAB_NAME.STATE ? 'flex': 'none'}>
  28. <State />
  29. </Box>
  30. <Box display={ activeTab === ACTIVE_TAB_NAME.LOG ? 'flex': 'none'}>
  31. <Log />
  32. </Box>
  33. </Box>
  34. </FullScreen>
  35. </>
  36. };
  37. export default App;

可以看到,主要包含两大组件: StateLog,分别对应两个 Tab 栏。
React 开发 CLI 工具 - 图2React 开发 CLI 工具 - 图3

3. GUI 如何实时展示业务状态?

文件操作的逻辑开发完了,GUI 界面也搭建好了。那么现在如何将两者结合起来呢,也就是 GUI 如何实时地展示文件操作的状态呢?
对此,需要引入第三方,来进行这两个模块的通信。具体来讲,在文件操作的逻辑中维护一个 EventBus 对象,然后在 React 组件当中,通过 Context 的方式传入这个 EventBus。从而完成 UI 和文件操作模块的通信。
现在开发一下这个 EventBus 对象,也就是下面的FileCopyConsumer:

  1. export interface EventData {
  2. kind: string;
  3. payload: any;
  4. }
  5. export class FileCopyConsumer {
  6. private callbacks: Function[];
  7. constructor() {
  8. this.callbacks = []
  9. }
  10. // 供 React 组件绑定回调
  11. onEvent(fn: Function) {
  12. this.callbacks.push(fn);
  13. }
  14. // 文件操作完成后调用
  15. onDone(event: EventData) {
  16. this.callbacks.forEach(callback => callback(event))
  17. }
  18. }

接着在文件操作模块和 UI 模块当中,都需要做响应的适配,首先看看文件操作模块,做一下封装。

  1. export class FileOperator {
  2. fileConsumer: FileCopyConsumer;
  3. srcPath: string;
  4. targetPath: string;
  5. constructor(srcPath ?: string, targetPath ?: string) {
  6. // 初始化 EventBus 对象
  7. this.fileConsumer = new FileCopyConsumer();
  8. this.srcPath = srcPath ?? join(process.cwd(), 'src');
  9. this.targetPath = targetPath ?? join(process.cwd(), 'dist');
  10. }
  11. async copyFiles() {
  12. // 存储 log 信息
  13. const stats = [];
  14. // 在 src 中搜索文件
  15. const staticFiles = ...
  16. await Promise.all(staticFiles.map(file => {
  17. // ...
  18. // 存储 log
  19. .then(() => stats.push(`Copied file from [${file}] to [${targetFilePath}]`));
  20. }))
  21. // 调用 onDone
  22. this.fileConsumer.onDone({
  23. kind: "finish",
  24. payload: stats
  25. })
  26. }
  27. }

然后在初始化 FileOperator之后,将 fileConsumer通过 React Context 传入到组件当中,这样组件就能访问到fileConsumer,进而可以进行回调函数的绑定,代码演示如下:

  1. // 组件当中拿到 fileConsumer & 绑定回调
  2. export const State: FC<{}> = () => {
  3. const context = useContext(Context);
  4. const [finish, setFinish] = useState(false);
  5. context?.fileConsumer.onEvent((data: EventData) => {
  6. // 下面的逻辑在文件拷贝完成后执行
  7. if (data.kind === 'finish') {
  8. setTimeout(() => {
  9. setFinish(true)
  10. }, 2000)
  11. }
  12. })
  13. return
  14. //(JSX代码)
  15. }

这样就成功地将 UI 和文件操作逻辑串联了起来。