他居然把 React 組件跑在命令行終端窗口里面!
也許你之前聽說過前端組件代碼可以運行在瀏覽器,運行在移動端 App 里面,甚至可以直接在各種設備當中,但你有沒有見過: 前端組件直接跑在命令行窗口里面,讓前端代碼構建出終端窗口的 GUI 界面和交互邏輯?
今天, 給大家分享一個非常有意思的開源項目: ink。它的作用就是將 React 組件渲染在終端窗口中,呈現出最后的命令行界面。
本文偏重實戰,前面會帶大家熟悉基本使用,然后會做一個基于實際場景的實戰項目。
上手初體驗
剛開始上手時,推薦使用官方的腳手架創建項目,省時省心。
- npx create-ink-app --typescript
然后運行這樣一段代碼:
- import React, { useState, useEffect } from 'react'
- import { render, Text} from 'ink'
- const Counter = () => {
- const [count, setCount] = useState(0)
- useEffect(() => {
- const timer = setInterval(() => {
- setCount(count => ++count)
- }, 100)
- return () => {
- clearInterval(timer)
- }
- })
- return (
- <Text color="green">
- {count} tests passed
- </Text>
- )
- }
- render(<Counter />);
會出現如下的界面:

并且數字一直遞增!demo 雖小,但足以說明問題:
- 首先,這些文本輸出都不是直接 console 出來的,而是通過 React 組件渲染出來的。
- React 組件的狀態管理以及hooks 邏輯放到命令行的 GUI 當中仍然是生效的。
也就是說,前端的能力以及擴展到了命令行窗口當中了,這無疑是一項非常可怕的能力。著名的文檔生成工具Gatsby,包管理工具yarn2都使用了這項能力來完成終端 GUI 的搭建。
命令行工具項目實戰
可能大家剛剛了解到這個工具,知道它的用途,但對于具體如何使用還是比較陌生。接下來讓我們以一個實際的例子來進行實戰,快速熟悉。代碼倉庫已經上傳到 git,大家可以這個地址下面 fork 代碼: https://github.com/sanyuan0704/ink-copy-command。
下面我們就來從頭到尾開發這個項目。
項目背景
首先說一說項目的產生背景,在一個 TS 的業務項目當中,我們曾經碰到了一個問題:由于production模式下面,我們是采用先 tsc,拿到 js 產物代碼,再用webpack打包這些產物。
但構建的時候直接報錯了,原因就是 tsc 無法將 ts(x) 以外的資源文件移動到產物目錄,以至于 webpack 在對于產物進行打包的時候,發現有些資源文件根本找不到!比如以前有這樣一張圖片的路徑是這樣—— src/asset/1.png,但這些在產物目錄dist卻沒還有,因此 webpack 在打包 dist 目錄下的代碼時,會發現這張圖片不存在,于是報錯了。
解決思路
那如何來解決呢?
很顯然,我們很難去擴展 tsc 的能力,現在最好的方式就是寫個腳本手動將src下面的所有資源文件一一拷貝到dist目錄,這樣就能解決資源無法找到的問題。
一、拷貝文件邏輯
確定了解決思路之后,我們寫下這樣一段 ts 代碼:
- import { join, parse } from "path";
- import { fdir } from 'fdir';
- import fse from 'fs-extra'
- const staticFiles = await new fdir()
- .withFullPaths()
- // 過濾掉 node_modules、ts、tsx
- .filter(
- (p) =>
- !p.includes('node_modules') &&
- !p.endsWith('.ts') &&
- !p.endsWith('.tsx')
- )
- // 搜索 src 目錄
- .crawl(srcPath)
- .withPromise() as string[]
- await Promise.all(staticFiles.map(file => {
- const targetFilePath = file.replace(srcPath, distPath);
- // 創建目錄并拷貝文件
- return fse.mkdirp(parse(targetFilePath).dir)
- .then(() => fse.copyFile(file, distPath))
- );
- }))
代碼使用了fdir這個庫才搜索文件,非常好用的一個庫,寫法上也很優雅,推薦大家使用。
我們執行這段邏輯,成功將資源文件轉移到到了產物目錄中。
問題是解決掉了,但我們能不能封裝一下這個邏輯,讓它能夠更方便地在其它項目當中復用,甚至直接提供給其他人復用呢?
接著,我想到了命令行工具。
二、命令行 GUI 搭建
接著我們使用 ink,也就是用 React 組件的方式來搭建命令行 GUI,根組件代碼如下:
- // index.tsx 引入代碼省略
- interface AppProps {
- fileConsumer: FileCopyConsumer
- }
- const ACTIVE_TAB_NAME = {
- STATE: "執行狀態",
- LOG: "執行日志"
- }
- const App: FC<AppProps> = ({ fileConsumer }) => {
- const [activeTab, setActiveTab] = useState<string>(ACTIVE_TAB_NAME.STATE);
- const handleTabChange = (name) => {
- setActiveTab(name)
- }
- const WELCOME_TEXT = dedent`
- 歡迎來到 \`ink-copy\` 控制臺!功能概覽如下(按 **Tab** 切換):
- `
- return <>
- <FullScreen>
- <Box>
- <Markdown>{WELCOME_TEXT}</Markdown>
- </Box>
- <Tabs onChange={handleTabChange}>
- <Tab name={ACTIVE_TAB_NAME.STATE}>{ACTIVE_TAB_NAME.STATE}</Tab>
- <Tab name={ACTIVE_TAB_NAME.LOG}>{ACTIVE_TAB_NAME.LOG}</Tab>
- </Tabs>
- <Box>
- <Box display={ activeTab === ACTIVE_TAB_NAME.STATE ? 'flex': 'none'}>
- <State />
- </Box>
- <Box display={ activeTab === ACTIVE_TAB_NAME.LOG ? 'flex': 'none'}>
- <Log />
- </Box>
- </Box>
- </FullScreen>
- </>
- };
- export default App;
可以看到,主要包含兩大組件: State和Log,分別對應兩個 Tab 欄。具體的代碼大家去參考倉庫即可,下面放出效果圖:

三. GUI 如何實時展示業務狀態?
現在問題就來了,文件操作的邏輯開發完了,GUI 界面也搭建好了。那么現在如何將兩者結合起來呢,也就是 GUI 如何實時地展示文件操作的狀態呢?
對此,我們需要引入第三方,來進行這兩個模塊的通信。具體來講,我們在文件操作的邏輯中維護一個 EventBus 對象,然后在 React 組件當中,通過 Context 的方式傳入這個 EventBus。從而完成 UI 和文件操作模塊的通信。
現在我們開發一下這個 EventBus 對象,也就是下面的FileCopyConsumer:
- export interface EventData {
- kind: string;
- payload: any;
- }
- export class FileCopyConsumer {
- private callbacks: Function[];
- constructor() {
- this.callbacks = []
- }
- // 供 React 組件綁定回調
- onEvent(fn: Function) {
- this.callbacks.push(fn);
- }
- // 文件操作完成后調用
- onDone(event: EventData) {
- this.callbacks.forEach(callback => callback(event))
- }
- }
接著在文件操作模塊和 UI 模塊當中,都需要做響應的適配,首先看看文件操作模塊,我們做一下封裝。
- export class FileOperator {
- fileConsumer: FileCopyConsumer;
- srcPath: string;
- targetPath: string;
- constructor(srcPath ?: string, targetPath ?: string) {
- // 初始化 EventBus 對象
- this.fileConsumer = new FileCopyConsumer();
- this.srcPath = srcPath ?? join(process.cwd(), 'src');
- this.targetPath = targetPath ?? join(process.cwd(), 'dist');
- }
- async copyFiles() {
- // 存儲 log 信息
- const stats = [];
- // 在 src 中搜索文件
- const staticFiles = ...
- await Promise.all(staticFiles.map(file => {
- // ...
- // 存儲 log
- .then(() => stats.push(`Copied file from [${file}] to [${targetFilePath}]`));
- }))
- // 調用 onDone
- this.fileConsumer.onDone({
- kind: "finish",
- payload: stats
- })
- }
- }
然后在初始化 FileOperator之后,將 fileConsumer通過 React Context 傳入到組件當中,這樣組件就能訪問到fileConsumer,進而可以進行回調函數的綁定,代碼演示如下:
- // 組件當中拿到 fileConsumer & 綁定回調
- export const State: FC<{}> = () => {
- const context = useContext(Context);
- const [finish, setFinish] = useState(false);
- context?.fileConsumer.onEvent((data: EventData) => {
- // 下面的邏輯在文件拷貝完成后執行
- if (data.kind === 'finish') {
- setTimeout(() => {
- setFinish(true)
- }, 2000)
- }
- })
- return
- //(JSX代碼)
- }
這樣,我們就成功地將 UI 和文件操作邏輯串聯了起來。當然,篇幅所限,還有一些代碼并沒有展示出來,完整的代碼都在 git 倉庫當中。希望大家能 fork 下來好好體會一下整個項目的設計。
總體來說,React 組件代碼能夠跑在命令行終端,確實是一件激動人心的事情,給前端釋放了更多想象的空間。本文對于這個能力的使用也只是冰山一角,更多使用姿勢等待你去解鎖,趕緊去玩一玩吧!