web模擬終端博客系統

本文由QQ音樂前端團隊發表javascript

前段時間作了一個很是有意思的模擬終端的展現頁:ursb.me/terminal/(沒… Linux 初學者學習終端命令,現分享給你們~html

開源地址:airingursb/terminal前端

0x01 樣式

打開頁面效果以下圖所示:java

img

其實這裏的樣式就直接 Copy 了本身 Mac 上 Terminal 的界面,固然界面上的參數都是本身寫的,表示窮人沒有錢買這麼高配的電腦…web

注:截圖裏面的 logo 是經過archey打印出來的,mac直接輸入 brew install archey 便可安裝。ajax

命令輸入其實只用了一個 input標籤實現的:json

<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 的基礎上拼接新的內容再從新繪製的過程。渲染時機是用戶按下回車鍵,所以須要監聽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命令字。效果以下:

img

直接看代碼,這是直接打印的內容,實現起來很是簡單。

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 文件的代碼,這顯然是不現實的。

本系統還配套實現了相應的後臺,服務端的做用是用來讀取存放在服務端的目錄和文件內容,並提供對應的接口以便將數據返回給前端。

服務器存儲的文件層級以下:

img

接下來,來看幾個稍有難度的功能吧。

0x05 ls

ls命令用來顯示目標列表,在 Linux 中是使用率較高的命令。 ls命令的輸出信息能夠進行彩色加亮顯示,以分區不一樣類型的文件。

所以,咱們的實現該功能的三個重點是:

  1. 獲取用戶當前的位置
  2. 獲取當前位置下的全部文件和目錄
  3. 須要區分出文件和目錄,以便區分樣式

對於第一點,在 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('&nbsp;&nbsp;') + '<br/>')
        e_html.animate({ scrollTop: $(document).height() }, 0)
      }
    }
  })
  break
複製代碼

前端這裏咱們根據是否文件名中是否具備'.'來區分是目錄和文件的,給目錄加上新的樣式。但咱們這樣區分其實並不嚴謹,由於目錄名其實也能夠具有'.',目錄本質上也是一個文件。嚴謹的方法應該根據系統的 ls-l命令判斷,咱們要實現的博客系統沒有這麼複雜,所以就簡單根據'.'判斷也是適用的。

實現效果以下:

img

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 })
    }
  })
})
複製代碼

前端直接調用就好,可是這裏要區分幾種狀況:

  1. 回退到主目錄:cd || cd ~ || cd ~/
  2. 切換到其餘目錄
    1. 切換到絕對路徑的其餘層級:cd ~/dir
    2. 切換爲相對路徑的更深層級:cd dir || cd ./dir || cd ../dir || cd .. || cd ../ || cd ../../
    3. 用戶在主目錄:cd ~/dir || cd ./dir || cd dir
    4. 用戶在其餘目錄: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)
      }
    }
})
複製代碼

核心環節是計算回退層數,並根據回退層數判斷出回退後的路徑應該是什麼。回退層數用正則匹配出路徑中'../'的數量便可,而路徑計算則經過數組和字符串的相互轉換能夠輕易實現。

效果以下:

img

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(/\n/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
複製代碼

實現效果以下:

img

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 }})
  })
})
複製代碼

額,註釋寫的比較詳盡,看註釋就行了…最後獲得的兩個數組結構以下:

img

img

須要注意的是,對於目錄而言,咱們用的是默認的字符表的順序排序的,由於 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):

  1. 補全命令
  2. 補全 cat 命令後的參數
  3. 補全 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&nbsp;&nbsp;cd&nbsp;&nbsp;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 送你一份技術課程大禮包!

相關文章
相關標籤/搜索