如何搭建一个简易的 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,但是还是有点问题,没有完善好,所以最终还是决定,本篇文章还是着重描写一些输入操作的处理。最后,如果大家对于本篇文章有疑惑,欢迎踊跃发言。

更多

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容