三個文件教你寫一個命令行終端[electron實戰]

前言

Electron很出名,很多人可能瞭解過,知道它是用來開發桌面端的應用,但是一直沒有在項目中實踐過,缺乏練手的實踐項目。

很多開源的命令行終端都是使用Electron來開發的,本文將從零開始手把手的教大家用Electron寫一個命令行終端。

作爲一個完整的實戰項目示例,該終端demo也將集成到Electron開源學習項目electron-playground中,目前這個項目擁有800+ Star⭐️,它最大的特點是所見即所得的演示Electron的各種特性,幫助大家快速學習、上手Electron

大家跟着本文一起來試試Electron吧~

下載試玩

本文命令行終端demo的代碼量很少,總共只有三個文件,註釋也足夠詳細,建議看完後上手體驗一下一個項目運行的細節。

項目演示

clear命令演示

實際上就是將歷史命令行輸出的數組重置爲空數組。

執行失敗箭頭切換

根據子進程close事件,判斷執行是否成功,切換一下圖標。

cd命令

識別cd命令,根據系統添加獲取路徑(pwd/chdir)的命令,再將獲取到的路徑,更改爲最終路徑。

giit提交代碼演示

項目地址

開源地址: electron-terminal-demo

啓動與調試

安裝

npm install

啓動

  1. 通過vscode的調試運行項目,這種形式可以直接在VSCode中進行debugger調試。

  2. 如果不是使用vscode編輯器, 也可以通過使用命令行啓動。

npm run start

目錄

  1. 初始化項目。

  2. 項目目錄結構

  3. Electron啓動入口index-創建窗口

  4. 進程通信類-processMessage。

  5. 窗口html頁面-命令行面板

  6. 命令行面板做了哪些事情

    • 核心方法:child_process.spawn-執行命令行監聽命令行的輸出
    • stderr不能直接識別爲命令行執行錯誤
    • 命令行終端執行命令保存輸出信息的核心代碼
    • html完整代碼
    • 命令行終端的更多細節
  7. 下載試玩

    • 項目演示
    • 項目地址
    • 啓動與調試
  8. 小結

初始化項目

npm init
npm install electron -D

如果Electron安裝不上去,需要添加一個.npmrc文件,來修改Electron的安裝地址,文件內容如下:

registry=https://registry.npm.taobao.org/
electron_mirror=https://npm.taobao.org/mirrors/electron/
chromedriver_cdnurl=https://npm.taobao.org/mirrors/chromedriver

修改一下package.json的入口mainscripts選項, 現在package.json長這樣,很簡潔:

{
  "name": "electron-terminal",
  "version": "1.0.0",
  "main": "./src/index.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^11.1.1"
  }
}

項目目錄結構

我們最終實現的項目將是下面這樣子的,頁面css文件不算的話,我們只需要實現src下面的三個文件即可。

.
├── .vscode // 使用vscode的調試功能啓動項目
├── node_dodules
├── src
│   ├── index.js // Electron啓動入口-創建窗口
│   └── processMessage.js // 主進程和渲染進程通信類-進程通信、監聽時間
│   └── index.html // 窗口html頁面-命令行面板、執行命令並監聽輸出
│   └── index.css // 窗口html的css樣式 這部分不寫
├── package.json
└── .npmrc // 修改npm安裝包的地址
└── .gitignore

Electron啓動入口index-創建窗口

  1. 創建窗口, 賦予窗口直接使用node的能力。
  2. 窗口加載本地html頁面
  3. 加載主線程和渲染進程通信邏輯
// ./src/index.js
const { app, BrowserWindow } = require('electron')
const processMessage = require('./processMessage')

// 創建窗口
function createWindow() {
  // 創建窗口
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true, // 頁面直接使用node的能力 用於引入node模塊 執行命令
    },
  })
  // 加載本地頁面
  win.loadFile('./src/index.html')
  win.webContents.openDevTools() // 打開控制檯
  // 主線程和渲染進程通信
  const ProcessMessage = new processMessage(win)
  ProcessMessage.init()
}

// app ready 創建窗口
app.whenReady().then(createWindow)

進程通信類-processMessage

electron分爲主進程和渲染進程,因爲進程不同,在各種事件發生的對應時機需要相互通知來執行一些功能。

這個類就是用於它們之間的通信的,electron通信這部分封裝的很簡潔了,照着用就可以了。

// ./src/processMessage.js
const { ipcMain } = require('electron')
class ProcessMessage {
  /**
   * 進程通信
   * @param {*} win 創建的窗口
   */
  constructor(win) {
    this.win = win
  }
  init() {
    this.watch()
    this.on()
  }
  // 監聽渲染進程事件通信
  watch() {
    // 頁面準備好了
    ipcMain.on('page-ready', () => {
      this.sendFocus()
    })
  }
  // 監聽窗口、app、等模塊的事件
  on() {
    // 監聽窗口是否聚焦
    this.win.on('focus', () => {
      this.sendFocus(true)
    })
    this.win.on('blur', () => {
      this.sendFocus(false)
    })
  }
  /**
   * 窗口聚焦事件發送
   * @param {*} isActive 是否聚焦
   */
  sendFocus(isActive) {
    // 主線程發送事件給窗口
    this.win.webContents.send('win-focus', isActive)
  }
}
module.exports = ProcessMessage

窗口html頁面-命令行面板

在創建窗口的時候,我們賦予了窗口使用node的能力, 可以在html中直接使用node模塊。

所以我們不需要通過進程通信的方式來執行命令和渲染輸出,可以直接在一個文件裏面完成。

終端的核心在於執行命令,渲染命令行輸出,保存命令行的輸出

這些都在這個文件裏面實現了,代碼行數不到250行。

命令行面板做了哪些事情

  • 頁面: 引入vue、element,css文件來處理頁面

  • template模板-渲染當前命令行執行的輸出以及歷史命令行的執行輸出

  • 核心:執行命令監聽命令行輸出

    • 執行命令並監聽執行命令的輸出,同步渲染輸出。
    • 執行完畢,保存命令行輸出的信息。
    • 渲染歷史命令行輸出。
    • 對一些命令進行特殊處理,比如下面的細節處理。
  • 圍繞執行命令行的細節處理

    • 識別cd,根據系統保存cd路徑
    • 識別clear清空所有輸出。
    • 執行成功與失敗的箭頭圖標展示。
    • 聚焦窗口,聚焦輸入。
    • 命令執行完畢滾動底部。
    • 等等細節。

核心方法:child_process.spawn-執行命令行監聽命令行的輸出

child_process.spawn介紹

spawn是node子進程模塊child_process提供的一個異步方法。

它的作用是執行命令並且可以實時監聽命令行執行的輸出

當我第一次知道這個API的時候,我就感覺這個方法簡直是爲命令行終端量身定做的。

終端的核心也是執行命令行,並且實時輸出命令行執行期間的信息。

下面就來看看它的使用方式。

使用方式

const { spawn } = require('child_process');
const ls = spawn('ls', {
  encoding: 'utf8',
  cwd: process.cwd(), // 執行命令路徑
  shell: true, // 使用shell命令
})

// 監聽標準輸出
ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

// 監聽標準錯誤
ls.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
});

// 子進程關閉事件
ls.on('close', (code) => {
  console.log(`子進程退出,退出碼 ${code}`);
});

api的使用很簡單,但是終端信息的輸出,需要很多細節的處理,比如下面這個。

stderr不能直接識別爲命令行執行錯誤

stderr雖然是標準錯誤輸出,但裏面的信息不全是錯誤的信息,不同的工具會有不同的處理。

對於git來說,有很多命令行操作的輸出信息都輸出在stederr上。

比如git clonegit push等,信息輸出在stederr中,我們不能將其視爲錯誤。

git總是將詳細的狀態信息和進度報告,以及只讀信息,發送給stederr

具體細節可以查看git stderr(錯誤流)探祕等資料。

暫時還不清楚其他工具/命令行也有沒有類似的操作,但是很明顯我們不能將stederr的信息視爲錯誤的信息。

PS: 對於git如果想提供更好的支持,需要根據不同的git命令進行特殊處理,比如對下面clear命令和cd命令的特殊處理。

根據子進程close事件判斷命令行是否執行成功

我們應該檢測close事件的退出碼code, 如果code爲0則表示命令行執行成功,否則即爲失敗。

命令行終端執行命令保存輸出信息的核心代碼

下面這段是命令行面板的核心代碼,我貼一下大家重點看一下,

其他部分都是一些細節、優化體驗、狀態處理這樣的代碼,下面會將完整的html貼上來。

const { spawn } = require('child_process') // 使用node child_process模塊
// 執行命令行
actionCommand() {
  // 處理command命令 
  const command = this.command.trim()
  this.isClear(command)
  if (this.command === '') return
  // 執行命令行
  this.action = true
  this.handleCommand = this.cdCommand(command)
  const ls = spawn(this.handleCommand, {
    encoding: 'utf8',
    cwd: this.path, // 執行命令路徑
    shell: true, // 使用shell命令
  })
  // 監聽命令行執行過程的輸出
  ls.stdout.on('data', (data) => {
    const value = data.toString().trim()
    this.commandMsg.push(value)
    console.log(`stdout: ${value}`)
  })

  ls.stderr.on('data', this.stderrMsgHandle)
  ls.on('close', this.closeCommandAction)
},
// 錯誤或詳細狀態進度報告 比如 git push
stderrMsgHandle(data) {
  console.log(`stderr: ${data}`)
  this.commandMsg.push(`stderr: ${data}`)
},
// 執行完畢 保存信息 更新狀態
closeCommandAction(code) {
  // 保存執行信息
  this.commandArr.push({
    code, // 是否執行成功
    path: this.path, // 執行路徑
    command: this.command, // 執行命令
    commandMsg: this.commandMsg.join('\r'), // 執行信息
  })
  // 清空
  this.updatePath(this.handleCommand, code)
  this.commandFinish()
  console.log(
    `子進程退出,退出碼 ${code}, 運行${code === 0 ? '成功' : '失敗'}`
  )
}

html完整代碼

這裏是html的完整代碼,代碼中有詳細註釋,建議根據上面的命令行面板做了哪些事情,來閱讀源碼。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>極簡electron終端</title>
    <link
      rel="stylesheet"
      href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"
    />
    <script src="https://unpkg.com/vue"></script>
    <!-- 引入element -->
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>
    <!-- css -->
    <link rel="stylesheet" href="./index.css" />
  </head>
  <body>
    <div id="app">
      <div class="main-class">
        <!-- 渲染過往的命令行 -->
        <div v-for="item in commandArr">
          <div class="command-action">
            <!-- 執行成功或者失敗圖標切換 -->
            <i
              :class="['el-icon-right', 'command-action-icon', { 'error-icon': item.code !== 0  }]"
            ></i>
            <!-- 過往執行地址和命令行、信息 -->
            <span class="command-action-path">{{ item.path }} $</span>
            <span class="command-action-contenteditable"
              >{{ item.command }}</span
            >
          </div>
          <div class="output-command">{{ item.commandMsg }}</div>
        </div>
        <!-- 當前輸入的命令行 -->
        <div
          class="command-action command-action-editor"
          @mouseup="timeoutFocusInput"
        >
          <i class="el-icon-right command-action-icon"></i>
          <!-- 執行地址 -->
          <span class="command-action-path">{{ path }} $</span>
          <!-- 命令行輸入 -->
          <span
            :contenteditable="action ? false : 'plaintext-only'"
            class="command-action-contenteditable"
            @input="onDivInput($event)"
            @keydown="keyFn"
          ></span>
        </div>
        <!-- 當前命令行輸出 -->
        <div class="output-command">
          <div v-for="item in commandMsg">{{item}}</div>
        </div>
      </div>
    </div>

    <script>
      const { ipcRenderer } = require('electron')
      const { spawn } = require('child_process')
      const path = require('path')

      var app = new Vue({
        el: '#app',
        data: {
          path: '', // 命令行目錄
          command: '', // 用戶輸入命令
          handleCommand: '', // 經過處理的用戶命令 比如清除首尾空格、添加獲取路徑的命令
          commandMsg: [], // 當前命令信息
          commandArr: [], // 過往命令行輸出保存
          isActive: true, // 終端是否聚焦
          action: false, // 是否正在執行命令
          inputDom: null, // 輸入框dom
          addPath: '', // 不同系統 獲取路徑的命令 mac是pwd window是chdir
        },
        mounted() {
          this.addGetPath()
          this.inputDom = document.querySelector(
            '.command-action-contenteditable'
          )
          this.path = process.cwd() // 初始化路徑
          this.watchFocus()
          ipcRenderer.send('page-ready') // 告訴主進程頁面準備好了
        },
        methods: {
          // 回車執行命令
          keyFn(e) {
            if (e.keyCode == 13) {
              this.actionCommand()
              e.preventDefault()
            }
          },
          // 執行命令
          actionCommand() {
            const command = this.command.trim()
            this.isClear(command)
            if (this.command === '') return
            this.action = true
            this.handleCommand = this.cdCommand(command)
            const ls = spawn(this.handleCommand, {
              encoding: 'utf8',
              cwd: this.path, // 執行命令路徑
              shell: true, // 使用shell命令
            })
            // 監聽命令行執行過程的輸出
            ls.stdout.on('data', (data) => {
              const value = data.toString().trim()
              this.commandMsg.push(value)
              console.log(`stdout: ${value}`)
            })
            // 錯誤或詳細狀態進度報告 比如 git push、 git clone 
            ls.stderr.on('data', (data) => {
              const value = data.toString().trim()
              this.commandMsg.push(`stderr: ${data}`)
              console.log(`stderr: ${data}`)
            })
            // 子進程關閉事件 保存信息 更新狀態
            ls.on('close', this.closeCommandAction) 
          },
          // 執行完畢 保存信息 更新狀態
          closeCommandAction(code) {
            // 保存執行信息
            this.commandArr.push({
              code, // 是否執行成功
              path: this.path, // 執行路徑
              command: this.command, // 執行命令
              commandMsg: this.commandMsg.join('\r'), // 執行信息
            })
            // 清空
            this.updatePath(this.handleCommand, code)
            this.commandFinish()
            console.log(
              `子進程退出,退出碼 ${code}, 運行${code === 0 ? '成功' : '失敗'}`
            )
          },
          // cd命令處理
          cdCommand(command) {
            let pathCommand = ''
            if (this.command.startsWith('cd ')) {
              pathCommand = this.addPath
            } else if (this.command.indexOf(' cd ') !== -1) {
              pathCommand = this.addPath
            }
            return command + pathCommand
            // 目錄自動聯想...等很多細節功能 可以做但沒必要2
          },
          // 清空歷史
          isClear(command) {
            if (command === 'clear') {
              this.commandArr = []
              this.commandFinish()
            }
          },
          // 獲取不同系統下的路徑
          addGetPath() {
            const systemName = getOsInfo()
            if (systemName === 'Mac') {
              this.addPath = ' && pwd'
            } else if (systemName === 'Windows') {
              this.addPath = ' && chdir'
            }
          },
          // 命令執行完畢 重置參數
          commandFinish() {
            this.commandMsg = []
            this.command = ''
            this.inputDom.textContent = ''
            this.action = false
            // 激活編輯器
            this.$nextTick(() => {
              this.focusInput()
              this.scrollBottom()
            })
          },
          // 判斷命令是否添加過addPath
          updatePath(command, code) {
            if (code !== 0) return
            const isPathChange = command.indexOf(this.addPath) !== -1
            if (isPathChange) {
              this.path = this.commandMsg[this.commandMsg.length - 1]
            }
          },
          // 保存輸入的命令行
          onDivInput(e) {
            this.command = e.target.textContent
          },
          // 點擊div
          timeoutFocusInput() {
            setTimeout(() => {
              this.focusInput()
            }, 200)
          },
          // 聚焦輸入
          focusInput() {
            this.inputDom.focus() //解決ff不獲取焦點無法定位問題
            var range = window.getSelection() //創建range
            range.selectAllChildren(this.inputDom) //range 選擇obj下所有子內容
            range.collapseToEnd() //光標移至最後
            this.inputDom.focus()
          },
          // 滾動到底部
          scrollBottom() {
            let dom = document.querySelector('#app')
            dom.scrollTop = dom.scrollHeight // 滾動高度
            dom = null
          },
          // 監聽窗口聚焦、失焦
          watchFocus() {
            ipcRenderer.on('win-focus', (event, message) => {
              this.isActive = message
              if (message) {
                this.focusInput()
              }
            })
          },
        },
      })

      // 獲取操作系統信息
      function getOsInfo() {
        var userAgent = navigator.userAgent.toLowerCase()
        var name = 'Unknown'
        if (userAgent.indexOf('win') > -1) {
          name = 'Windows'
        } else if (userAgent.indexOf('iphone') > -1) {
          name = 'iPhone'
        } else if (userAgent.indexOf('mac') > -1) {
          name = 'Mac'
        } else if (
          userAgent.indexOf('x11') > -1 ||
          userAgent.indexOf('unix') > -1 ||
          userAgent.indexOf('sunname') > -1 ||
          userAgent.indexOf('bsd') > -1
        ) {
          name = 'Unix'
        } else if (userAgent.indexOf('linux') > -1) {
          if (userAgent.indexOf('android') > -1) {
            name = 'Android'
          } else {
            name = 'Linux'
          }
        }
        return name
      }
    </script>
  </body>
</html>

以上就是整個項目的代碼實現,總共只有三個文件。

更多細節

本項目終究是一個簡單的demo,如果想要做成一個完整的開源項目,還需要補充很多細節。

還會有各種各樣奇奇怪怪的需求和需要定製的地方,比如下面這些:

  • command+c終止命令
  • cd目錄自動補全
  • 命令保存上下鍵滑動
  • git等常用功能單獨特殊處理。
  • 輸出信息顏色變化
  • 等等

小結

命令行終端的實現原理就是這樣啦,強烈推薦各位下載體驗一下這個項目,最好單步調試一下,這樣會更熟悉Electron

項目idea誕生於我們團隊開源的另一個開源項目:electron-playground, 目的是爲了讓小夥伴學習electron實戰項目。

electron-playground是用來幫助前端小夥伴們更好、更快的學習和理解前端桌面端技術Electron, 儘量少走彎路。

它通過如下方式讓我們快速學習electron。

  1. 帶有gif示例和可操作的demo的教程文章。
  2. 系統性的整理了Electron相關的api和功能。
  3. 搭配演練場,自己動手嘗試electron的各種特性。

前端進階積累公衆號GitHub、wx:OBkoro1、郵箱:[email protected]

以上2021/01/12

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章