本文由QQ音樂前端團隊發表
前段時間做了一個非常有意思的模擬終端的展示頁:http://ursb.me/terminal/(沒有做移動端適配,請在PC端訪問),這個頁面非常有意思,它可以作為個人部落格系統或者給 Linux 初學者學習終端命令,現分享給大家~
開源地址:airingursb/terminal
0x01 樣式
開啟頁面效果如下圖所示:
其實這裡的樣式就直接 Copy 了自己 Mac 上 Terminal 的介面,當然介面上的引數都是自己寫的,表示窮人沒有錢買這麼高配的電腦…
注:截圖裡面的 logo 是通過archey列印出來的,mac直接輸入 brew install archey 即可安裝。
命令輸入其實只用了一個 input
標籤實現的:
<span class="prefix">[<span id=`usr`>usr</span>@<span class="host">ursb.me</span> <span id="pos">~</span>]% </span>
<input type="text" class="input-text">
當然,原始的樣式太醜了,肯定要對 input
標籤做美化:
.input-text {
display: inline-block;
background-color: transparent;
border: none;
-moz-appearance: none;
-webkit-appearance: none;
outline: 0;
box-sizing: border-box;
font-size: 17px;
font-family: Monaco, Cutive Mono, Courier New, Consolas, monospace;
font-weight: 700;
color: #fff;
width: 300px;
padding-block-end: 0
}
雖然是在瀏覽器訪問,但畢竟我們要模擬終端的效果,因此對滑鼠的樣式最好也修改一下:
* {
cursor: text;
}
0x02 渲染邏輯
每次列印新的內容其實是一個在之前 html 的基礎上拼接新的內容再重新繪製的過程。渲染時機是使用者按下Enter鍵,因此需要監聽keydown事件;渲染函式是mainFunc,傳入使用者輸入的內容和使用者當前的目錄,後者是全域性變數,在很多命令中都需要判斷使用者當前的位置。
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>Nice to Meet U : )<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
每次渲染之後記得加個滾動動畫,讓瀏覽器儘可能真實地模擬終端的行為。
$(document).bind(`keydown`, function (b) {
e_input.focus()
if (b.keyCode === 13) {
e_main.html($(`#main`).html())
e_html.animate({ scrollTop: $(document).height() }, 0)
mainFunc(e_input.val(), nowPosition)
hisCommand.push(e_input.val())
isInHis = 0
e_input.val(``)
}
// Ctrl + U 清空輸入快捷鍵
if (b.keyCode === 85 && b.ctrlKey === true) {
e_input.val(``)
e_input.focus()
}
})
同時,還實現了一個快捷鍵 Ctrl + U 清空當前輸入,有其他的快捷鍵讀者也可以這樣類似去實現。
0x03 help
我們知道,Linix 命令的規範是 command[Options...]
,以防有使用者不瞭解,首先,我實現了一個最簡單的 help
命令字。效果如下:
直接看程式碼,這是直接列印的內容,實現起來非常簡單。
switch (command) {
case `help`:
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>` + `[sudo ]command[ Options...]<br/>You can use following commands:<br/><br/>cd<br/>ls<br/>cat<br/>clear<br/>help<br/>exit<br/><br/>Besides, there are some hidden commands, try to find them!<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
break
}
其中 command
取 input 標籤第一個空格前的元素即可:
command = input.split(` `)[0]
既然知道了怎麼取命令字,那各種列印型別的命令字都是可以自己作為小彩蛋實現~ 這裡就不一一舉例了,讀者可以閱讀原始碼自行了解。
0x04 clear
clear
是清空控制檯,實現起來非常簡單,根據我們的渲染邏輯,直接清空外層div中的內容即可。
case `clear`:
e_main.html(``)
e_html.animate({ scrollTop: $(document).height() }, 0)
break
既然是部落格系統,總不能全部的內容都放在前端頁面的程式碼上進行渲染,固定的 help
命令或者簡單的列印命令是這樣做是可以的。但如果我們的目錄結構變動了,或者想寫一篇新文章,或者修改檔案的內容,那則需要我們大幅度去修改靜態 html 檔案的程式碼,這顯然是不現實的。
本系統還配套實現了相應的後臺,服務端的作用是用來讀取存放在服務端的目錄和檔案內容,並提供對應的介面以便將資料返回給前端。
伺服器儲存的檔案層級如下:
接下來,來看幾個稍有難度的功能吧。
0x05 ls
ls
命令用來顯示目標列表,在 Linux 中是使用率較高的命令。 ls
命令的輸出資訊可以進行彩色加亮顯示,以分割槽不同型別的檔案。
因此,我們的實現該功能的三個重點是:
- 獲取使用者當前的位置
- 獲取當前位置下的所有檔案和目錄
- 需要區分出檔案和目錄,以便區分樣式
對於第一點,在 mainFunc
中的第二引數是必傳的,它是我們精心維護的一個全域性變數(在 cd
命令中進行維護)。
對於第二點,我們在後端提供了一個介面:
router.get(`/ls`, (req, res) => {
let { dir } = req.query
glob(`src/file${dir}**`, {}, (err, files) => {
if (dir === `/`) {
files = files.map(i => i.replace(`src/file/`, ``))
files = files.filter(i => !i.includes(`/`)) // 過濾掉二級目錄
} else {
// 如果不在根目錄,則替換掉當前目錄
dir = dir.substring(1)
files = files.map(i => i.replace(`src/file/`, ``).replace(dir, ``))
files = files.filter(i => !i.includes(`/`) && !i.includes(dir.substring(0, dir.length - 1))) // 過濾掉二級目錄和當前目錄
}
return res.jsonp({ code: 0, data: files.map(i => i.replace(`src/file/`, ``).replace(dir, ``)) })
})
})
檔案遍歷這裡我們用到了第三方的開源庫glob。如果使用者在主目錄,我們需要過濾掉二級目錄下的檔案,因為ls只能看到本目錄下的內容;如果使用者在其他目錄,我們還需要過濾掉當前目錄,因為glob返回的資料包含有當前目錄的名字。
之後,前端直接呼叫就好:
case `ls`:
// dir: /dir/
$.ajax({
url: host + `/ls`,
data: { dir: position.replace(`~`, ``) + `/` },
dataType: `jsonp`,
success: (res) => {
if (res.code === 0) {
let data = res.data.map(i => {
if (!i.includes(`.`)) {
// 目錄
i = `<span class="ls-dir">${i}</span>`
}
return i
})
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>` + data.join(` `) + `<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
}
}
})
break
前端這裡我們根據是否檔名中是否具有`.`來區分是目錄和檔案的,給目錄加上新的樣式。但我們這樣區分其實並不嚴謹,因為目錄名其實也可以具備`.`,目錄本質上也是一個檔案。嚴謹的方法應該根據系統的 ls-l
命令判斷,我們要實現的部落格系統沒有這麼複雜,因此就簡單根據`.`判斷也是適用的。
實現效果如下:
0x06 cd
服務端提供介面,pos為使用者當前的位置,dir是使用者想要切換的相對路徑。需要注意的是,這裡過濾了檔案,因為cd命令後面的引數只能接目錄;同時這裡並沒有過濾掉二級目錄,因為cd命令後續接的是目錄的路徑,有可能是深層級的。對於目錄不存在的情況,只需要返回一個錯誤碼和提示即可。
router.get(`/cd`, (req, res) => {
let { pos, dir } = req.query
glob(`src/file${pos}**`, {}, (err, files) => {
pos = pos.substring(1)
files = files.filter(i => !i.includes(`.`)) // 過濾掉檔案
files = files.map(i => i.replace(`src/file/`, ``).replace(pos, ``))
dir = dir.substring(0, dir.length - 1)
if (files.indexOf(dir) === -1) {
// 目錄不存在
return res.jsonp({ code: 404, message: `cd: no such file or directory: ` + dir })
} else {
return res.jsonp({ code: 0 })
}
})
})
前端直接呼叫就好,但是這裡要區分幾種情況:
- 回退到主目錄:cd || cd ~ || cd ~/
- 切換到其他目錄
- 切換到絕對路徑的其他層級:cd ~/dir
- 切換為相對路徑的更深層級:cd dir || cd ./dir || cd ../dir || cd .. || cd ../ || cd ../../
- 使用者在主目錄:cd ~/dir || cd ./dir || cd dir
- 使用者在其他目錄:cd .. || cd ../ || cd ../dir || cd dir || cd ./dir
對於情境1,實現比較簡單,直接將當前目錄切回`~`即可。
if (!input.split(` `)[1] || input.split(` `)[1] === `~` || input.split(` `)[1] === `~/`) {
// 回退到主目錄:cd || cd ~ || cd ~/
nowPosition = `~`
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
e_pos.html(nowPosition)
}
對於情境2之所以還判斷是否在主目錄,是因為解析規則不一樣。其實也可以做個相容合併成一種情況。由於程式碼比較長,這裡只列出最複雜的情境2.2.2的程式碼:
let pos = `/` + nowPosition.replace(`~/`, ``) + `/`
let backCount = input.split(` `)[1].match(/..//g) && input.split(` `)[1].match(/..//g).length || 0
pos = nowPosition.split(`/`) // [~, blog, img]
nowPosition = pos.slice(0, pos.length - backCount) // [~, blog]
nowPosition = nowPosition.join(`/`) // ~/blog
pos = `/` + nowPosition.replace(`~`, ``).replace(`/`, ``) + `/`
dir = dir + `/`
dir = dir.startsWith(`./`) && dir.substring(1) || dir // 適配:cd ./dir
$.ajax({
url: host + `/cd`,
data: { dir, pos },
dataType: `jsonp`,
success: (res) => {
if (res.code === 0) {
nowPosition = `~` + pos.substring(1) + dir.substring(0, dir.length - 1) // ~/blog/img
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
e_pos.html(nowPosition)
} else if (res.code === 404) {
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>` + res.message + `<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
}
}
})
核心環節是計算回退層數,並根據回退層數判斷出回退後的路徑應該是什麼。回退層數用正則匹配出路徑中`../`的數量即可,而路徑計算則通過陣列和字串的相互轉換可以輕易實現。
效果如下:
0x07 cat
cat 命令的實現和 cd 基本一致,只需要將目錄處理換成檔案處理即可。
服務端提供介面:
router.get(`/cat`, (req, res) => {
let { filename, dir } = req.query
// 多級目錄拼接: 位於 ~/blog/img, cat banner/menu.md
dir = (dir + filename).split(`/`)
filename = dir.pop() // 丟棄最後一級,其肯定是檔案
dir = dir.join(`/`) + `/`
glob(`src/file${dir}*.md`, {}, (err, files) => {
dir = dir.substring(1)
files = files.map(i => i.replace(`src/file/`, ``).replace(dir, ``))
filename = filename.replace(`./`, ``)
if (files.indexOf(filename) === -1) {
return res.jsonp({ code: 404, message: `cat: no such file or directory: ` + filename })
} else {
fs.readFile(`src/file/${dir}/${filename}`, `utf-8`, (err, data) => {
return res.jsonp({ code: 0, data })
})
}
})
})
這裡的目錄拼接計算放在了服務端完成,和之前的拼接方法基本一樣,因為與 cd 命令不同,這裡 nowPosition 不會發生改變,所以可放在服務端計算。
若檔案存在,讀取檔案內容返回即可;檔案不存在,則返回一個錯誤碼和提示。
與 cd 不同的是, cat 更加簡單,前端不需要區分那麼多種情況了,直接呼叫就好。因為我們不需要再維護 nowPosition 去計算當前路徑,glob 支援相對路徑。
case `cat`:
file = input.split(` `)[1]
$.ajax({
url: host + `/cat`,
data: { filename: file, dir: position.replace(`~`, ``) + `/` },
dataType: `jsonp`,
success: (res) => {
if (res.code === 0) {
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>` + res.data.replace(/
/g, `<br/>`) + `<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
} else if (res.code === 404) {
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + position + `]% ` + input + `<br/>` + res.message + `<br/>`)
e_html.animate({ scrollTop: $(document).height() }, 0)
}
}
})
break
實現效果如下:
0x08 自動補全
熟悉命令列的童鞋應該都知道命令列的效率其實大部分情況都比圖形介面快得多,最主要的一點是因為命令列工具支援 Tab 自動補全命令,這使得使用者只需短短几個字元就可以敲出一大串命令。如此使用且基礎的功能,我們當然也是需要實現的。
所謂自動補全,前提必然是系統知道補全之後的完整內容是啥。我們的模擬終端暫時只是檔案和目錄的讀取操作,所以自動補全的前提是,系統儲存有完整的目錄和檔案。
這裡用兩個全域性變數來分別儲存目錄和檔案的資料就好,在頁面一開啟時呼叫:
$(document).ready(() => {
// 初始化目錄和檔案
$.ajax({
url: host + `/list`,
data: { dir: `/` },
dataType: `jsonp`,
success: (res) => {
if (res.code === 0) {
directory = res.data.directory
directory.shift(); // 去掉第一個 ~
files = res.data.files
}
}
})
})
服務端介面實現如下:
router.get(`/list`, (req, res) => {
// 用於獲取所有目錄和所有檔案
let { dir } = req.query
glob(`src/file${dir}**`, {}, (err, files) => {
if (dir === `/`) {
files = files.map(i => i.replace(`src/file/`, ``))
}
files[0] = `~` // 初始化主目錄
let directory = files.filter(i => !i.includes(`.`)) // 過濾掉檔案
files = files.filter(i => i.includes(`.`)) // 只保留檔案
// 檔案根據層級排序(預設為首字母排序),以便前端實現最短層級優先匹配
files = files.sort((a, b) => {
let deapA = a.match(///g) && a.match(///g).length || 0
let deapB = b.match(///g) && b.match(///g).length || 0
return deapA - deapB
})
return res.jsonp({ code: 0, data: {directory, files }})
})
})
額,註釋寫的比較詳盡,看註釋就好了…最後得到的兩個陣列結構如下:
需要注意的是,對於目錄而言,我們用的是預設的字元表的順序排序的,因為 cd 到某目錄的自動補全,應該遵循最短路徑匹配;而對於檔案而言,我們根據層級深度拍排序的,因為 cat 某檔案,是根據最淺路徑匹配的,即應優先匹配當前目錄下的檔案。
前端需要監聽 Tab 鍵的 keydown 事件:
if (b.keyCode === 9) {
pressTab(e_input.val())
b.preventDefault()
e_html.animate({ scrollTop: $(document).height() }, 0)
e_input.focus()
}
對於pressTab函式,分成了三類情況(因為我們實現的帶引數的命令只有cat和cd):
- 補全命令
- 補全 cat 命令後的引數
- 補全 cd 命令後的引數
情況1的實現有點蠢萌蠢萌的:
command = input.split(` `)[0]
if (command === `l`) e_input.val(`ls`)
if (command === `c`) {
e_main.html($(`#main`).html() + `[<span id="usr">` + usrName + `</span>@<span class="host">ursb.me</span> ` + nowPosition + `]% ` + input + `<br/>cat cd claer<br/>`)
}
if (command === `ca`) e_input.val(`cat`)
if (command === `cl` || command === `cle` || command === `clea`) e_input.val(`clea`)
對於情況2,cat 命令自動補全只適配檔案,即適配我們全域性變數files裡面的元素,需要注意的是處理好字首`./`的情況。直接貼程式碼了:
if (input.split(` `)[1] && command === `cat`) {
file = input.split(` `)[1]
let pos = nowPosition.replace(`~`, ``).replace(`/`, ``) // 去除主目錄的 ~ 和其他目錄的 ~/ 字首
let prefix = ``
if (file.startsWith(`./`)) {
prefix = `./`
file = file.replace(`./`, ``)
}
if (nowPosition === `~`) {
files.every(i => {
if (i.startsWith(pos + file)) {
e_input.val(`cat ` + prefix + i)
return false
}
return true
})
} else {
pos = pos + `/`
files.every(i => {
if (i.startsWith(pos + file)) {
e_input.val(`cat ` + prefix + i.replace(pos, ``))
return false
}
return true
})
}
}
對於情況3,實現和情況2基本一致,但是 cd 命令自動補全只適配目錄,即配我們全域性變數directory 裡面的元素。由於篇幅問題,且此處實現和以上程式碼基本重複,就不貼了。
0x09 歷史命令
Linux 的終端按上下方向鍵可以翻閱使用者歷史輸入的命令,這也是一個很重要很基礎的功能,所以我們來實現一下。
先來幾個全域性變數,以便儲存使用者輸入的歷史命令。
let hisCommand = [] // 歷史命令
let cour = 0 // 指標
let isInHis = 0 // 是否為當前輸入的命令,0是,1否
isInHis 變數用於判斷輸入內容是否在歷史記錄裡,即使用者輸入了內容哪怕沒有按回車,按了上鍵之後再按下鍵也依然可以復現剛才自己輸入的內容,不至於清空。(在按回車之後,isInHis = 0)
在監聽keydown事件繫結的時候新增上下方向鍵的監聽:
if (b.keyCode === 38) historyCmd(`up`)
if (b.keyCode === 40) historyCmd(`down`)
historyCmd 函式接受的引數則表明使用者的翻閱順序,是前一條還是後一條。
let historyCmd = (k) => {
$(`body,html`).animate({ scrollTop: $(document).height() }, 0)
if (k !== `up` || isInHis) {
if (k === `up` && isInHis) {
if (cour >= 1) {
cour--
e_input.val(hisCommand[cour])
}
}
if (k === `down` && isInHis) {
if (cour + 1 <= hisCommand.length - 1) {
cour++
$(".input-text").val(hisCommand[cour])
} else if (cour + 1 === hisCommand.length) {
$(".input-text").val(inputCache)
}
}
} else {
inputCache = e_input.val()
e_input.val(hisCommand[hisCommand.length - 1])
cour = hisCommand.length - 1
isInHis = 1
}
}
程式碼實現比較簡單,根據上下鍵移動陣列的指標即可。
本程式碼已開源(airingursb/terminal),有興趣的小夥伴可以提交 PR,讓我們一起把模擬終端做的更好~
此文已由作者授權騰訊雲+社群釋出,更多原文請點選
搜尋關注公眾號「雲加社群」,第一時間獲取技術乾貨,關注後回覆1024 送你一份技術課程大禮包!