前言
Electron很出名,很多人可能了解過,知道它是用來開發(fā)桌面端的應(yīng)用,但是一直沒有在項目中實踐過,缺乏練手的實踐項目。
很多開源的命令行終端都是使用Electron來開發(fā)的,本文將從零開始手把手的教大家用Electron寫一個命令行終端。
作為一個完整的實戰(zhàn)項目示例,該終端demo也將集成到Electron開源學(xué)習(xí)項目electron-playground中,目前這個項目擁有800+ Star??,它最大的特點是所見即所得的演示Electron的各種特性,幫助大家快速學(xué)習(xí)、上手Electron。
大家跟著本文一起來試試Electron吧~
終端效果
開源地址: electron-terminal-demo
giit提交代碼演示
目錄
初始化項目。
項目目錄結(jié)構(gòu)
Electron啟動入口index-創(chuàng)建窗口
進程通信類-processMessage。
窗口html頁面-命令行面板
-
命令行面板做了哪些事情
- 核心方法:child_process.spawn-執(zhí)行命令行監(jiān)聽命令行的輸出
- stderr不能直接識別為命令行執(zhí)行錯誤
- 命令行終端執(zhí)行命令保存輸出信息的核心代碼
- html完整代碼
- 命令行終端的更多細(xì)節(jié)
-
下載試玩
- 項目演示
- 項目地址
- 啟動與調(diào)試
小結(jié)
初始化項目
npm init
npm install electron -D
如果Electron安裝不上去,需要添加一個.npmrc文件,來修改Electron的安裝地址,文件內(nèi)容如下:
registry=https://registry.npm.taobao.org/
electron_mirror=https://npm.taobao.org/mirrors/electron/
chromedriver_cdnurl=https://npm.taobao.org/mirrors/chromedriver
修改一下package.json的入口main和scripts選項, 現(xiàn)在package.json長這樣,很簡潔:
{
"name": "electron-terminal",
"version": "1.0.0",
"main": "./src/index.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^11.1.1"
}
}
項目目錄結(jié)構(gòu)
我們最終實現(xiàn)的項目將是下面這樣子的,頁面css文件不算的話,我們只需要實現(xiàn)src下面的三個文件即可。
.
├── .vscode // 使用vscode的調(diào)試功能啟動項目
├── node_dodules
├── src
│ ├── index.js // Electron啟動入口-創(chuàng)建窗口
│ └── processMessage.js // 主進程和渲染進程通信類-進程通信、監(jiān)聽時間
│ └── index.html // 窗口html頁面-命令行面板、執(zhí)行命令并監(jiān)聽輸出
│ └── index.css // 窗口html的css樣式 這部分不寫
├── package.json
└── .npmrc // 修改npm安裝包的地址
└── .gitignore
Electron啟動入口index-創(chuàng)建窗口
- 創(chuàng)建窗口, 賦予窗口直接使用node的能力。
- 窗口加載本地html頁面
- 加載主線程和渲染進程通信邏輯
// ./src/index.js
const { app, BrowserWindow } = require('electron')
const processMessage = require('./processMessage')
// 創(chuàng)建窗口
function createWindow() {
// 創(chuàng)建窗口
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true, // 頁面直接使用node的能力 用于引入node模塊 執(zhí)行命令
},
})
// 加載本地頁面
win.loadFile('./src/index.html')
win.webContents.openDevTools() // 打開控制臺
// 主線程和渲染進程通信
const ProcessMessage = new processMessage(win)
ProcessMessage.init()
}
// app ready 創(chuàng)建窗口
app.whenReady().then(createWindow)
進程通信類-processMessage
electron分為主進程和渲染進程,因為進程不同,在各種事件發(fā)生的對應(yīng)時機需要相互通知來執(zhí)行一些功能。
這個類就是用于它們之間的通信的,electron通信這部分封裝的很簡潔了,照著用就可以了。
// ./src/processMessage.js
const { ipcMain } = require('electron')
class ProcessMessage {
/**
* 進程通信
* @param {*} win 創(chuàng)建的窗口
*/
constructor(win) {
this.win = win
}
init() {
this.watch()
this.on()
}
// 監(jiān)聽渲染進程事件通信
watch() {
// 頁面準(zhǔn)備好了
ipcMain.on('page-ready', () => {
this.sendFocus()
})
}
// 監(jiān)聽窗口、app、等模塊的事件
on() {
// 監(jiān)聽窗口是否聚焦
this.win.on('focus', () => {
this.sendFocus(true)
})
this.win.on('blur', () => {
this.sendFocus(false)
})
}
/**
* 窗口聚焦事件發(fā)送
* @param {*} isActive 是否聚焦
*/
sendFocus(isActive) {
// 主線程發(fā)送事件給窗口
this.win.webContents.send('win-focus', isActive)
}
}
module.exports = ProcessMessage
窗口html頁面-命令行面板
在創(chuàng)建窗口的時候,我們賦予了窗口使用node的能力, 可以在html中直接使用node模塊。
所以我們不需要通過進程通信的方式來執(zhí)行命令和渲染輸出,可以直接在一個文件里面完成。
終端的核心在于執(zhí)行命令,渲染命令行輸出,保存命令行的輸出。
這些都在這個文件里面實現(xiàn)了,代碼行數(shù)不到250行。
命令行面板做了哪些事情
頁面: 引入vue、element,css文件來處理頁面
template模板-渲染當(dāng)前命令行執(zhí)行的輸出以及歷史命令行的執(zhí)行輸出
-
核心:執(zhí)行命令監(jiān)聽命令行輸出
- 執(zhí)行命令并監(jiān)聽執(zhí)行命令的輸出,同步渲染輸出。
- 執(zhí)行完畢,保存命令行輸出的信息。
- 渲染歷史命令行輸出。
- 對一些命令進行特殊處理,比如下面的細(xì)節(jié)處理。
-
圍繞執(zhí)行命令行的細(xì)節(jié)處理
- 識別cd,根據(jù)系統(tǒng)保存cd路徑
- 識別clear清空所有輸出。
- 執(zhí)行成功與失敗的箭頭圖標(biāo)展示。
- 聚焦窗口,聚焦輸入。
- 命令執(zhí)行完畢滾動底部。
- 等等細(xì)節(jié)。
核心方法:child_process.spawn-執(zhí)行命令行監(jiān)聽命令行的輸出
child_process.spawn介紹
spawn是node子進程模塊child_process提供的一個異步方法。
它的作用是執(zhí)行命令并且可以實時監(jiān)聽命令行執(zhí)行的輸出。
當(dāng)我第一次知道這個API的時候,我就感覺這個方法簡直是為命令行終端量身定做的。
終端的核心也是執(zhí)行命令行,并且實時輸出命令行執(zhí)行期間的信息。
下面就來看看它的使用方式。
使用方式
const { spawn } = require('child_process');
const ls = spawn('ls', {
encoding: 'utf8',
cwd: process.cwd(), // 執(zhí)行命令路徑
shell: true, // 使用shell命令
})
// 監(jiān)聽標(biāo)準(zhǔn)輸出
ls.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
// 監(jiān)聽標(biāo)準(zhǔn)錯誤
ls.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
// 子進程關(guān)閉事件
ls.on('close', (code) => {
console.log(`子進程退出,退出碼 ${code}`);
});
api的使用很簡單,但是終端信息的輸出,需要很多細(xì)節(jié)的處理,比如下面這個。
stderr不能直接識別為命令行執(zhí)行錯誤
stderr雖然是標(biāo)準(zhǔn)錯誤輸出,但里面的信息不全是錯誤的信息,不同的工具會有不同的處理。
對于git來說,有很多命令行操作的輸出信息都輸出在stederr上。
比如git clone、git push等,信息輸出在stederr中,我們不能將其視為錯誤。
git總是將詳細(xì)的狀態(tài)信息和進度報告,以及只讀信息,發(fā)送給stederr。
具體細(xì)節(jié)可以查看git stderr(錯誤流)探秘等資料。
暫時還不清楚其他工具/命令行也有沒有類似的操作,但是很明顯我們不能將stederr的信息視為錯誤的信息。
PS: 對于git如果想提供更好的支持,需要根據(jù)不同的git命令進行特殊處理,比如對下面clear命令和cd命令的特殊處理。
根據(jù)子進程close事件判斷命令行是否執(zhí)行成功
我們應(yīng)該檢測close事件的退出碼code, 如果code為0則表示命令行執(zhí)行成功,否則即為失敗。
命令行終端執(zhí)行命令保存輸出信息的核心代碼
下面這段是命令行面板的核心代碼,我貼一下大家重點看一下,
其他部分都是一些細(xì)節(jié)、優(yōu)化體驗、狀態(tài)處理這樣的代碼,下面會將完整的html貼上來。
const { spawn } = require('child_process') // 使用node child_process模塊
// 執(zhí)行命令行
actionCommand() {
// 處理command命令
const command = this.command.trim()
this.isClear(command)
if (this.command === '') return
// 執(zhí)行命令行
this.action = true
this.handleCommand = this.cdCommand(command)
const ls = spawn(this.handleCommand, {
encoding: 'utf8',
cwd: this.path, // 執(zhí)行命令路徑
shell: true, // 使用shell命令
})
// 監(jiān)聽命令行執(zhí)行過程的輸出
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)
},
// 錯誤或詳細(xì)狀態(tài)進度報告 比如 git push
stderrMsgHandle(data) {
console.log(`stderr: ${data}`)
this.commandMsg.push(`stderr: ${data}`)
},
// 執(zhí)行完畢 保存信息 更新狀態(tài)
closeCommandAction(code) {
// 保存執(zhí)行信息
this.commandArr.push({
code, // 是否執(zhí)行成功
path: this.path, // 執(zhí)行路徑
command: this.command, // 執(zhí)行命令
commandMsg: this.commandMsg.join('\r'), // 執(zhí)行信息
})
// 清空
this.updatePath(this.handleCommand, code)
this.commandFinish()
console.log(
`子進程退出,退出碼 ${code}, 運行${code === 0 ? '成功' : '失敗'}`
)
}
html完整代碼
這里是html的完整代碼,代碼中有詳細(xì)注釋,建議根據(jù)上面的命令行面板做了哪些事情,來閱讀源碼。
<!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"
/>
<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">
<!-- 執(zhí)行成功或者失敗圖標(biāo)切換 -->
<i
:class="['el-icon-right', 'command-action-icon', { 'error-icon': item.code !== 0 }]"
></i>
<!-- 過往執(zhí)行地址和命令行、信息 -->
<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>
<!-- 當(dāng)前輸入的命令行 -->
<div
class="command-action command-action-editor"
@mouseup="timeoutFocusInput"
>
<i class="el-icon-right command-action-icon"></i>
<!-- 執(zhí)行地址 -->
<span class="command-action-path">{{ path }} $</span>
<!-- 命令行輸入 -->
<span
:contenteditable="action ? false : 'plaintext-only'"
class="command-action-contenteditable"
@input="onDivInput($event)"
@keydown="keyFn"
></span>
</div>
<!-- 當(dāng)前命令行輸出 -->
<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: '', // 經(jīng)過處理的用戶命令 比如清除首尾空格、添加獲取路徑的命令
commandMsg: [], // 當(dāng)前命令信息
commandArr: [], // 過往命令行輸出保存
isActive: true, // 終端是否聚焦
action: false, // 是否正在執(zhí)行命令
inputDom: null, // 輸入框dom
addPath: '', // 不同系統(tǒng) 獲取路徑的命令 mac是pwd window是chdir
},
mounted() {
this.addGetPath()
this.inputDom = document.querySelector(
'.command-action-contenteditable'
)
this.path = process.cwd() // 初始化路徑
this.watchFocus()
ipcRenderer.send('page-ready') // 告訴主進程頁面準(zhǔn)備好了
},
methods: {
// 回車執(zhí)行命令
keyFn(e) {
if (e.keyCode == 13) {
this.actionCommand()
e.preventDefault()
}
},
// 執(zhí)行命令
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, // 執(zhí)行命令路徑
shell: true, // 使用shell命令
})
// 監(jiān)聽命令行執(zhí)行過程的輸出
ls.stdout.on('data', (data) => {
const value = data.toString().trim()
this.commandMsg.push(value)
console.log(`stdout: ${value}`)
})
// 錯誤或詳細(xì)狀態(tài)進度報告 比如 git push、 git clone
ls.stderr.on('data', (data) => {
const value = data.toString().trim()
this.commandMsg.push(`stderr: ${data}`)
console.log(`stderr: ${data}`)
})
// 子進程關(guān)閉事件 保存信息 更新狀態(tài)
ls.on('close', this.closeCommandAction)
},
// 執(zhí)行完畢 保存信息 更新狀態(tài)
closeCommandAction(code) {
// 保存執(zhí)行信息
this.commandArr.push({
code, // 是否執(zhí)行成功
path: this.path, // 執(zhí)行路徑
command: this.command, // 執(zhí)行命令
commandMsg: this.commandMsg.join('\r'), // 執(zhí)行信息
})
// 清空
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
// 目錄自動聯(lián)想...等很多細(xì)節(jié)功能 可以做但沒必要2
},
// 清空歷史
isClear(command) {
if (command === 'clear') {
this.commandArr = []
this.commandFinish()
}
},
// 獲取不同系統(tǒng)下的路徑
addGetPath() {
const systemName = getOsInfo()
if (systemName === 'Mac') {
this.addPath = ' && pwd'
} else if (systemName === 'Windows') {
this.addPath = ' && chdir'
}
},
// 命令執(zhí)行完畢 重置參數(shù)
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() //創(chuàng)建range
range.selectAllChildren(this.inputDom) //range 選擇obj下所有子內(nèi)容
range.collapseToEnd() //光標(biāo)移至最后
this.inputDom.focus()
},
// 滾動到底部
scrollBottom() {
let dom = document.querySelector('#app')
dom.scrollTop = dom.scrollHeight // 滾動高度
dom = null
},
// 監(jiān)聽窗口聚焦、失焦
watchFocus() {
ipcRenderer.on('win-focus', (event, message) => {
this.isActive = message
if (message) {
this.focusInput()
}
})
},
},
})
// 獲取操作系統(tǒng)信息
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>
以上就是整個項目的代碼實現(xiàn),總共只有三個文件。
更多細(xì)節(jié)
本項目終究是一個簡單的demo,如果想要做成一個完整的開源項目,還需要補充很多細(xì)節(jié)。
還會有各種各樣奇奇怪怪的需求和需要定制的地方,比如下面這些:
-
command+c終止命令 -
cd目錄自動補全 - 命令保存上下鍵滑動
- git等常用功能單獨特殊處理。
- 輸出信息顏色變化
- 等等
下載試玩
即使這個終端demo的代碼量很少,注釋足夠詳細(xì),但還是需要上手體驗一下一個Electron項目運行的細(xì)節(jié)。
項目演示
clear命令演示
實際上就是將歷史命令行輸出的數(shù)組重置為空數(shù)組。
執(zhí)行失敗箭頭切換
根據(jù)子進程close事件,判斷執(zhí)行是否成功,切換一下圖標(biāo)。
cd命令
識別cd命令,根據(jù)系統(tǒng)添加獲取路徑(pwd/chdir)的命令,再將獲取到的路徑,更改為最終路徑。
giit提交代碼演示
項目地址
開源地址: electron-terminal-demo
啟動與調(diào)試
安裝
npm install
啟動
-
通過vscode的調(diào)試運行項目,這種形式可以直接在VSCode中進行debugger調(diào)試。
image 如果不是使用vscode編輯器, 也可以通過使用命令行啟動。
npm run start
小結(jié)
命令行終端的實現(xiàn)原理就是這樣啦,強烈推薦各位下載體驗一下這個項目,最好單步調(diào)試一下,這樣會更熟悉Electron。
項目idea誕生于我們團隊開源的另一個開源項目:electron-playground, 目的是為了讓小伙伴學(xué)習(xí)electron實戰(zhàn)項目。
electron-playground是用來幫助前端小伙伴們更好、更快的學(xué)習(xí)和理解前端桌面端技術(shù)Electron, 盡量少走彎路。
它通過如下方式讓我們快速學(xué)習(xí)electron。
- 帶有g(shù)if示例和可操作的demo的教程文章。
- 系統(tǒng)性的整理了Electron相關(guān)的api和功能。
- 搭配演練場,自己動手嘗試electron的各種特性。
前端進階積累、公眾號、GitHub、wx:OBkoro1、郵箱:obkoro1@foxmail.com
以上2021/01/12