前言
在介紹本篇文章的時候,先說一下本篇文章的一些背景。筆者是基於公司的基礎建設哆啦 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, // 回車鍵
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
...
按下回車鍵后,需要將輸入的字符文本存入數組中,記錄當前文本位置,以便后續利用
向上/向下操作
// 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,但是還是有點問題,沒有完善好,所以最終還是決定,本篇文章還是着重描寫一些輸入操作的處理。最后,如果大家對於本篇文章有疑惑,歡迎踴躍發言。