如何搭建一個簡易的 Web Terminal(一)

前言

在介紹本篇文章的時候,先說一下本篇文章的一些背景。筆者是基於公司的基礎建設哆啦 A 夢(Doraemon)一些功能背景寫的這篇文章,不了解、有興趣的同學可以去 袋鼠雲 的 github 下面了解一下百寶箱哆啦 A 夢。 在哆啦 A 夢中可以配置代理,我們在配置中心的配置詳情下,可以找到主機對應的 nginx 配置文件或者其他文件,可以在這裡對其進行編輯,但是這個功能模塊下的 Execute shell 其實只是一個輸入框,這給使用者會造成一種,這個輸入框是一個 Web Terminal 的假象。因此,為了解決這個問題,我們打算做一個簡易版的 Web Terminal 去解決這個問題。筆者就是在這個背景之下開始了對於 Web Terminal 的調研,寫下了這篇文章。

file

本篇文章取名如何搭建一個簡易的 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 對象的尺寸與它的父元素的尺寸匹配。

file

以上是 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’ 是終端的特殊字符,分別表示為光標向左移一位和擦除當前光標到行末的字符,特殊字符因為筆者了解也不是很多,就不展開說明了。其中,在文本末尾直接進行輸入則拼接字符寫入文本,如果在非末尾的位置輸入字符,則主要過程如下

file

講解之前先說一下這個 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
...

其中,在文本末尾直接進行輸入則刪除該光標位置字符,如果在非末尾的位置進行刪除字符文本操作,則主要過程如下

file

假設現在有 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
}
...

其中主要的步驟如下

file

相對於其他,向上或向下按鍵就是將之前存儲的字符拿出來,先全部刪除,再進行寫入。

向左/向右操作
// 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,目前只是添加了終端的輸入操作,我們最終的目的還是需要讓它能夠登陸到服務器上面

設想中的最後實現的效果應該是這樣的

file

筆者也對與當前的代碼進行了 socket.io 的接入,哆啦 A 夢的話是基於 egg 的這個框架的,可以使用這個 egg.socket.io 建立 socket 通信,筆者在這裡列了一下大概的步驟,但是準備作為本文的補充,會在下一篇文章中完善。

總結

首先,這個終端寫到這裡並沒寫完,由於時間的原因,暫未寫完。上面也列了一些待完善的點,筆者也會在後面添加本文的第二或第三篇,陸續陸續的補充完善。筆者在這個星期也嘗試了接入 socket,但是還是有點問題,沒有完善好,所以最終還是決定,本篇文章還是着重描寫一些輸入操作的處理。最後,如果大家對於本篇文章有疑惑,歡迎踴躍發言。

更多