前言

在介绍本篇文章的时候,先说一下本篇文章的一些背景。笔者是基于公司的根底建设哆啦 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 termterm.loadAddon(attachAddon);

根本应用

作为一个组件,咱们须要先理解一下他的根本应用,如何可能疾速的搭建起来 Web Terminal 的根本框架。以下应用哆啦 A 梦的代码为例

1、首先第一步是装置 Xterm

npm install xterm / yarn add xterm

2、应用 xterm 生成 Terminal 实例对象,将其挂载到 dom 元素上

// webTerminal.tsximport 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.tsexport 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 = 0let 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'    }    breakcase 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,然而还是有点问题,没有欠缺好,所以最终还是决定,本篇文章还是着重描写一些输出操作的解决。最初,如果大家对于本篇文章有纳闷,欢送踊跃发言。

更多

  • 官网文档:https://xtermjs.org/
  • Socket.IO 文档:https://eggjs.org/zh-cn/tutorials/socketio.html
  • 终端特殊字符:https://blog.csdn.net/sunjiajiang/article/details/8513215