他居然把 React 組件跑在命令行終端窗口里面!
也許你之前聽(tīng)說(shuō)過(guò)前端組件代碼可以運(yùn)行在瀏覽器,運(yùn)行在移動(dòng)端 App 里面,甚至可以直接在各種設(shè)備當(dāng)中,但你有沒(méi)有見(jiàn)過(guò): 前端組件直接跑在命令行窗口里面,讓前端代碼構(gòu)建出終端窗口的 GUI 界面和交互邏輯?
今天, 給大家分享一個(gè)非常有意思的開(kāi)源項(xiàng)目: ink。它的作用就是將 React 組件渲染在終端窗口中,呈現(xiàn)出最后的命令行界面。
本文偏重實(shí)戰(zhàn),前面會(huì)帶大家熟悉基本使用,然后會(huì)做一個(gè)基于實(shí)際場(chǎng)景的實(shí)戰(zhàn)項(xiàng)目。
上手初體驗(yàn)
剛開(kāi)始上手時(shí),推薦使用官方的腳手架創(chuàng)建項(xiàng)目,省時(shí)省心。
- npx create-ink-app --typescript
然后運(yùn)行這樣一段代碼:
- 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 />);
會(huì)出現(xiàn)如下的界面:

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

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