前言
在介紹本篇文章的時候,先說一下本篇文章的一些背景。筆者是基於公司的基礎建設哆啦 A 夢(Doraemon)一些功能背景寫的這篇文章,不瞭解、有興趣的同學可以去 袋鼠雲 的 github 下面瞭解一下百寶箱哆啦 A 夢。 在哆啦 A 夢中可以配置代理,我們在配置中心的配置詳情下,可以找到主機對應的 nginx 配置檔案或者其他檔案,可以在這裡對其進行編輯,但是這個功能模組下的 Execute shell 其實只是一個輸入框,這給使用者會造成一種,這個輸入框是一個 Web Terminal 的假象。因此,為了解決這個問題,我們打算做一個簡易版的 Web Terminal 去解決這個問題。筆者就是在這個背景之下開始了對於 Web Terminal 的調研,寫下了這篇文章。
本篇文章取名如何搭建一個簡易的 Web Terminal,主要還是會圍繞這個主題,結合哆啦 A 夢去進行述說,逐步衍生出涉及到的點,筆者思考的一些點。當然,實現 Web Terminal 的方式可能有很多種,筆者也在調研過程中,同時,本篇文章寫的時間也比較倉促,涉及到的點也比較多,如若本文有不對之處,歡迎同學指出,筆者一定及時改正。
Xterm.js
首先,我們需要一個元件幫助我們快速的搭建起來 Web Terminal 的基本框架,它就是--Xterm.js。那麼 Xterm.js 是什麼呢,官方的解釋如下
Xterm.js 是一個用 TypeScript 編寫的前端元件,它可以讓應用程式在瀏覽器中為使用者帶來功能齊全的終端。它被 VS Code、Hyper 和 Theia 等流行專案使用。
因為本篇文章主要還是圍繞著搭建一個 Web Terminal,所以涉及到 Xterm.js 的詳細的 API 就不介紹了,只簡單介紹一下基本的 API,大家現在只用知道它是一個元件,我們需要使用到它,有興趣的同學可以點選 官方文件 進行閱讀。
基本 API
- Terminal
建構函式,可生成 Terminal 例項
import { Terminal } from 'xterm';
const term = new Terminal();
- onKey、onData
Terminal 例項上監聽輸入事件的函式
- write
Terminal 例項上寫入文字的方法
- loadAddon
Terminal 例項上載入外掛的方法
- attach 、fit 外掛
fit 外掛可以適配調整 Terminal 的大小,使得其適配 Terminal 的父元素
attach 外掛提供了將終端附加到 WebSocket 流的方法,以下是官網使用的例子
import { Terminal } from 'xterm';
import { AttachAddon } from 'xterm-addon-attach';
const term = new Terminal();
const socket = new WebSocket('wss://docker.example.com/containers/mycontainerid/attach/ws');
const attachAddon = new AttachAddon(socket);
// Attach the socket to term
term.loadAddon(attachAddon);
基本使用
作為一個元件,我們需要先了解一下他的基本使用,如何能夠快速的搭建起來 Web Terminal 的基本框架。以下使用哆啦 A 夢的程式碼為例
1、首先第一步是安裝 Xterm
npm install xterm / yarn add xterm
2、使用 xterm 生成 Terminal 例項物件,將其掛載到 dom 元素上
// webTerminal.tsx
import React, { useEffect, useState } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import Loading from '@/components/loading'
import './style.scss';
import 'xterm/css/xterm.css'
const WebTerminal: React.FC = () => {
const [terminal, setTerminal] = useState(null)
const initTerminal = () => {
const prefix = 'admin $ '
const fitAddon = new FitAddon()
const terminal: any = new Terminal({ cursorBlink: true })
terminal.open(document.getElementById('terminal-container'))
// terminal 的尺寸與父元素匹配
terminal.loadAddon(fitAddon)
fitAddon.fit()
terminal.writeln('\x1b[1;1;32mwellcom to web terminal!\x1b[0m')
terminal.write(prefix)
setTerminal(terminal)
}
useEffect(() => { initTerminal() }, [])
return (
<Loading>
<div id="terminal-container" className='c-webTerminal__container'></div>
</Loading>
)
}
export default WebTerminal
// style.scss
.c-webTerminal__container {
width: 600px;
height: 350px;
}
如下圖所示,我們就此可以得到一個 Web Terminal 的架子。在上面的程式碼中,我們需要引入 xterm-addon-fit 模組,使用其將生成的 terminal 物件的尺寸與它的父元素的尺寸匹配。
以上是 xterm 最基本的使用,當在這個時候,我們就有生成的這個 terminal 的例項,但是如果要實現一個 Web terminal 的話,這還遠遠不夠,接下來我們需要逐步的為其添磚加瓦。
輸入操作
當我們嘗試輸入的時候,有的同學應該發現了,這個架子並不能輸入欄位,我們還需要增加 terminal 例項物件對輸入操作的處理。下面介紹一下輸入操作的處理,對這個 Terminal 的輸入操作的處理的思路也很簡單,就是我們需要對剛剛生成的這個 Terminal 例項新增監聽事件,當捕捉到有鍵盤的輸入操作的時候,根據輸入的值對應不同的數字進行處理。
由於時間比較的倉促,我們就大致寫一些比較常見的操作進行處理,比如最基本字母或數字的輸入,刪除操作,游標上下左右操作的處理。
基本輸入
首先是最基本的輸入操作,程式碼如下
// webTerminal.tsx
...
const WebTerminal: React.FC = () => {
const [terminal, setTerminal] = useState(null)
const prefix = 'admin $ '
let inputText = '' // 輸入字元
const onKeyAction = () => {
terminal.onKey(e => {
const { key, domEvent } = e
const { keyCode, altKey, altGraphKey, ctrlKey, metaKey } = domEvent
const printAble = !(altKey || altGraphKey || ctrlKey || metaKey) // 禁止相關按鍵
const totalOffsetLength = inputText.length + prefix.length // 總偏移量
const currentOffsetLength = terminal._core.buffer.x // 當前x偏移量
switch(keyCode) {
...
default:
if (!printAble) break
if (totalOffsetLength >= terminal.cols) break
if (currentOffsetLength >= totalOffsetLength) {
terminal.write(key)
inputText += key
break
}
const cursorOffSetLength = getCursorOffsetLength(totalOffsetLength - currentOffsetLength, '\x1b[D')
terminal.write('\x1b[?K' + `${key}${inputText.slice(currentOffsetLength - prefix.length)}`) // 在當前的座標寫上 key 和座標後面的字元
terminal.write(cursorOffSetLength) // 移動停留在當前位置的游標
inputText = inputText.slice(0, currentOffsetLength) + key + inputText.slice(totalOffsetLength - currentOffsetLength)
}
})
}
useEffect(() => {
if (terminal) {
onKeyAction()
}
}, [terminal])
...
...
}
// const.ts
export const TERMINAL_INPUT_KEY = {
BACK: 8, // 退格刪除鍵
ENTER: 13, // Enter鍵
UP: 38, // 方向盤上鍵
DOWN: 40, // 方向盤鍵
LEFT: 37, // 方向盤左鍵
RIGHT: 39 // 方向盤右鍵
}
其中,程式碼中的 '\x1b[D' 和 '\x1b[?K' 是終端的特殊字元,分別表示為游標向左移一位和擦除當前游標到行末的字元,特殊字元因為筆者瞭解也不是很多,就不展開說明了。其中,在文字末尾直接進行輸入則拼接字元寫入文字,如果在非末尾的位置輸入字元,則主要過程如下
講解之前先說一下這個 currentOffsetLength,也就是 terminal._core.buffer.x 這個的取值,當我們從左往右的時候他是從 0 開始增加,當我們從右往左的時候,他是在原有基礎上+1,在逐次遞減,遞減到 0,用來標記當前游標的位置
假設現在輸入的字元有兩個字元,游標在第三位,主要發生有一下步驟:
1、游標移到第二位,按下鍵盤輸入字元 s
2、刪除游標位置到字元末尾的字元
3、將輸入的字元與原有字元文字的游標位置到行末的字元拼接寫入
4、將游標移到原有的輸入位置
刪除操作
// webTerminal.tsx
...
const getCursorOffsetLength = (offsetLength: number, subString: string = '') => {
let cursorOffsetLength = ''
for (let offset = 0; offset < offsetLength; offset++) {
cursorOffsetLength += subString
}
return cursorOffsetLength
}
...
case TERMINAL_INPUT_KEY.BACK:
if (currentOffsetLength > prefix.length) {
const cursorOffSetLength = getCursorOffsetLength(totalOffsetLength - currentOffsetLength, '\x1b[D') // 保留原來游標位置
terminal._core.buffer.x = currentOffsetLength - 1
terminal.write('\x1b[?K' + inputText.slice(currentOffsetLength-prefix.length))
terminal.write(cursorOffSetLength)
inputText = `${inputText.slice(0, currentOffsetLength - prefix.length - 1)}${inputText.slice(currentOffsetLength - prefix.length)}`
}
break
...
其中,在文字末尾直接進行輸入則刪除該游標位置字元,如果在非末尾的位置進行刪除字元文字操作,則主要過程如下
假設現在有 abc 三個字元,其中游標在第二個位置,當其進行刪除操作的時候,過程如下:
1、游標移到第二位,按下鍵盤刪除字元
2、清除當前的游標位置到末尾的字元
3、根據偏移量拼接剩餘字元
3、將游標移到原有的輸入位置
回車操作
// webTerminal.tsx
...
let inputText = ''
let currentIndex = 0
let inputTextList = []
const handleInputText = () => {
terminal.write('\r\n')
if (!inputText.trim()) {
terminal.prompt()
return
}
if (inputTextList.indexOf(inputText) === -1) {
inputTextList.push(inputText)
currentIndex = inputTextList.length
}
terminal.prompt()
}
...
case TERMINAL_INPUT_KEY.ENTER:
handleInputText()
inputText = ''
break
...
按下Enter鍵後,需要將輸入的字元文字存入陣列中,記錄當前文字位置,以便後續利用
向上/向下操作
// webTerminal.tsx
...
case TERMINAL_INPUT_KEY.UP: {
if (!inputTextList[currentIndex - 1]) break
const offsetLength = getCursorOffsetLength(inputText.length, '\x1b[D')
inputText = inputTextList[currentIndex - 1]
terminal.write(offsetLength + '\x1b[?K' )
terminal.write(inputTextList[currentIndex - 1])
terminal._core.buffer.x = totalOffsetLength
currentIndex--
break
}
...
其中主要的步驟如下
相對於其他,向上或向下按鍵就是將之前儲存的字元拿出來,先全部刪除,再進行寫入。
向左/向右操作
// webTerminal.tsx
...
case TERMINAL_INPUT_KEY.LEFT:
if (currentOffsetLength > prefix.length) {
terminal.write(key) // '\x1b[D'
}
break
case TERMINAL_INPUT_KEY.RIGHT:
if (currentOffsetLength < totalOffsetLength) {
terminal.write(key) // '\x1b[C'
}
break
...
待完善的點
1、接入 websocket,實現服務端和客戶端之間的通訊
2、接入 ssh,目前只是新增了終端的輸入操作,我們最終的目的還是需要讓它能夠登陸到伺服器上面
設想中的最後實現的效果應該是這樣的
筆者也對與當前的程式碼進行了 socket.io 的接入,哆啦 A 夢的話是基於 egg 的這個框架的,可以使用這個 egg.socket.io 建立 socket 通訊,筆者在這裡列了一下大概的步驟,但是準備作為本文的補充,會在下一篇文章中完善。
總結
首先,這個終端寫到這裡並沒寫完,由於時間的原因,暫未寫完。上面也列了一些待完善的點,筆者也會在後面新增本文的第二或第三篇,陸續陸續的補充完善。筆者在這個星期也嘗試了接入 socket,但是還是有點問題,沒有完善好,所以最終還是決定,本篇文章還是著重描寫一些輸入操作的處理。最後,如果大家對於本篇文章有疑惑,歡迎踴躍發言。