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