背景

5月份时做的一个项目中要实现一个仿真终端,实现交互,于是找到了xterm.js这个库,用来实现前端的终端样式。

网上查找到的资料大多不太完整,故在此总结记录下自己实现的过程。

官网:xtermjs.org/

(吐槽一下官方文档,连个搜索都没有,接口参数啥的直接甩个github源码链接,真用到时不如直接翻源码)

其他参考文章:

https://juejin.cn/post/7081565139187138590

https://blog.csdn.net/weixin_42136785/article/details/120082568

https://juejin.cn/post/7012153822600495117

https://juejin.cn/post/7051525307227963405

交互形式选择

前后端的交互可以有两种形式:

  1. 直接将原始输入传给后端。

  2. 按了回车后再把输入的内容传给后端。

很明显,第一种方式的实现更简单,查找资料时我看到的大部分也是用了第一种方式。

但是,我所做的项目后端使用jsch构建SSH连接,功能需求为只会存在一个实际的连接,可能会有多个用户同时使用这一连接的情况。因此,这里使用的策略是全部输入完之后按回车才将输入内容发送到后端。

基础使用

xterm.js 结合 React 函数组件,使用Websocket通讯。我这里使用的xterm.js版本是5.1.0。

导入依赖:

1
2
3
import 'xterm/css/xterm.css';
import {Terminal} from 'xterm';
import {FitAddon} from 'xterm-addon-fit'; //终端自适应父元素大小插件

变量:

1
const currentLine = useRef(''); //存储当前用户输入的内容

构建终端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
useEffect(() => {
//定义Websocket连接
const socketUrl = "ws://xxxxx";
const socket = new WebSocket(socketUrl);
//定义终端
const terminal = new Terminal({
cursorStyle: 'underline',
cursorBlink: true,
theme: {
foreground: '#dddddd',
cursor: 'gray'
},
windowsMode: true,
})
//终端自适应父元素大小
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
//将终端载入页面
const terminalDom = document.getElementById('terminal-container');
terminal.open(terminalDom as HTMLElement);
fitAddon.fit();

//定义终端回调方法
terminal.onData((data) => {
switch (data){
case '\r':
case '\n':
//回车,发送
if (currentLine.current.trim().length > 0) {
socket.send(currentLine.current); //发送
currentLine.current = ''; //输入清空
}
break;
default:
//否则将数据添加到记录已输入内容的变量中,并显示在终端上
currentLine.current += data;
terminal.write(data);
}
});

//定义websocket回调方法
socket.onopen = () => {
terminal.write('Welcome to terminal\r\n>!');
};
socket.onclose = () => {
insertBeforeInput(terminal, '\r\nConnection closed. Bye!');
terminal.blur();
};
socket.onerror = () => {
insertBeforeInput(terminal, '\r\nSomething errors.');
};
socket.onmessage = (e: MessageEvent<any>) => {
terminal.write(e);
};

terminal.focus();

return () => {
socket.close();
terminal.dispose();
};
}, []);

xterm.js 只实现了样式,对于终端的操作是需要自己实现的。如上面代码中的对回车的检测是通过判断输入数据中的\n来实现的。接下来,最重要的就是对终端回调函数onData的编写,来响应各种输入,实现功能需求。

退格操作

匹配退格

在terminal的onData回调函数中添加匹配退格输入的case。

1
2
3
4
5
6
7
switch (data){
...
case '\x7F': //backspace
doBackspace();
break;
...
}

同行删除

基础的删除操作可以这样做:写入一格退格,将光标向左移一位,再写入一位空格,将已输入内容的最后一位字符覆盖,此时光标会自动向右移一位,之后再写入一位退格,将光标左移一位即可。整合起来,就是写入\b \b

在删除前需要做一个判断,保证已输入内容不为空时才做删除操作。

代码如下:

1
2
3
4
5
6
7
8
9
switch (data){
...
case '\x7F': //backspace
if(currentLine.current.length > 0) {
terminal.write('\b \b');
}
break;
...
}

中文处理

中文在终端界面上会占据2个字符的宽度,此时再用上面的方法处理退格会出现问题。具体表现为,每次退格删除2位宽度的字符时,光标都会少退1位宽度,退格2次就会出现不能正常清除待删除字符的情况。

为了解决这个问题,首先要能够正确将中文字符从从输入的字符中区别出来。可以使用下面这个方法辨识中文字符:

1
2
3
if(/^[\u4e00-\u9fa5]/.test(ch)) {
//该字符是中文字符
}

\u4e00-\u9fa5是常用中文字符的Unicode码表示,通过判断字符是否在这一集合中来辨别是否为中文字符。

这里可以写一个判断字符占据位数的函数,方便以后做通用处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
const strWidth = (str: string) => {
if(!str) return 0;
let strLen = 0;
for(let i=0; i<str.length; i++) {
if(/^[\u4e00-\u9fa5]/.test(str[i])) {
strLen = strLen + 2;
}
else {
strLen ++;
}
}
return strLen;
};

于是处理退格的代码就变为:

1
2
3
4
5
6
case '\x7F': //backspace
if (currentLine.current.length > 0) { //如果已输入内容不为空,则可以执行删除操作
const charWidth = strWidth(currentLine.current.slice(-1)); //计算最后一个字符占据终端的宽度
terminal.write('\b \b'.repeat(charWidth));
}
break;

换行问题

当输入内容已经超过了一行时,最后一行的删除还能正常运行,但是最后一行删完之后,再按下退格键,会发现无法回到上一行进行删除,因为\b是不能自动返回上一行的。

这种情况处理起来比较麻烦,我也是结合其他一些文章,摸索了很久才研究出来一种可用的方法,这里就直接贴代码加注释解释了,仅做参考。

:代码中terminal.buffer.active.cursorX 是新版本的获取光标x坐标的方法,其他文章中所写的terminal._core.buffer.x方法在我使用的这个版本(5.1.0)中已经无法使用,应该是已经弃用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
case '\x7F': //backspace
if (currentLine.current.length > 0) { //如果已输入内容不为空,则可以执行删除操作
const charWidth = strWidth(currentLine.current.slice(-1)); //计算占据终端的宽度
const cursorX = terminal.buffer.active.cursorX; //获取光标在终端上的X坐标
if(cursorX === 0) { //为零代表光标在行首,需要回到上一行再进行删除
//回退到上一行的行尾
terminal.write('\x1B\x5B\x41'); //光标回到上一行行首
terminal.write('\x1B\x5B\x43'.repeat(terminal.cols)); //光标移动到行尾
terminal.write(' '); //写入一个空格
//如果字符宽度等于1,那么写入一个空格后光标位置(该行最末尾)将变为空,且光标仍保持在行尾,
//无需更多操作。
//如果字符宽度大于1,那么写入一个空格后还需要二次处理。
if(charWidth > 1) {
//首先计算已输入内容末尾字符在终端上的的X坐标
let width = 1; //因为自定义了一个宽为1的输入提示符'>',所以初始化为1
for(let i = 0; i < currentLine.current.length; i++) { //逐字符计算
width += strWidth(currentLine.current[i]);
if(width === terminal.cols) { //刚好满一行就重置为0
width = 0;
}
else if(width > terminal.cols){ //超过一行的宽度就重置为2(终端剩余宽度不足字符宽度时自动换行)
width = 2;
}
}
// 情况1:如果最终X坐标为0,说明原字符串刚好可以占满终端的整行
// 填充一个空格后2宽的字符都变为空,光标需要再退一格
if(width === 0) {
terminal.write('\b');
}
// 情况2:否则最终X坐标为(终端宽度-1),差一位才占满整行(原因是下一位字符宽度为2触发了自动换行)
// 填充的空格没能覆盖到需要删除的字符,需要退格填充空格一次覆盖覆盖字符,再退格2次移动光标
else {
terminal.write('\b \b\b');
}
}
}
else { //光标不在行首,则直接写入字符宽度同等次数的'\b \b',覆盖原字符即可
terminal.write('\b \b'.repeat(charWidth));
}
//删去已输入内容的末尾一位
currentLine.current = currentLine.current.slice(0, currentLine.current.length - 1);
}
//如果什么已输入内容为空,则什么也不做
break;

上面的代码是一点点调试摸索出来的,比较乱,写出来后也还没优化,层级较多,这里用流程图总结一下:

graph TD;
START(开始)-->A{已输入内容是否为空?};
A--yes-->B[不做任何操作];
B-->END(结束);
A--no-->C{光标是否在行首?};
C--yes-->D[光标移动到上一行行末];
C--no-->K["写入'\b空\b'*(字符宽度)次覆盖末尾字符"];
K-->END;
D-->E[写入一位空格];
E-->F{已输入内容的最后一个字符
宽度是否大于1}; F--no-->END; F--yes-->G["计算已输入内容末尾字符在终端上的的X坐标"]; G-->H{X坐标为0?}; H--yes-->I[刚好占满一行,
光标再退一位]; H--no-->J[差一位才占满整行,
退一位写入空格, 再退2位移动光标]; I-->END; J-->END;

对退格的处理到这里就结束了,这样的处理方法还存在一些不足:

  1. 代码层级过多,还未优化;
  2. 只适配了常用中文字符;
  3. 行末删除后立即写入,再立即删除有小bug,但不影响使用。

输入内容被输入覆盖问题

前面提到存在多用户使用同一个连接的情况,当一个用户正在输入时,连接发来了信息,比如说另一个用户向连接发送了信息,而信息经过处理后会输出内容在所有用户的终端上,就会导致正在输入命令的用户的输入内容被覆盖。这个问题该怎么解决呢?

这里,我的解决方案是当后端发送给前端需要显示在终端上的内容时,先清除用户当前已输入的内容(只在界面上删除),然后在终端上输出后端发送过来的内容,再将已输入内容回显到终端上。

graph LR
A[计算已输入内容的行数y]-->B[将这y行清空];
B-->C[移动光标到已显示内容的末尾];
C-->D[写入待输出信息];
D-->F[写回已输入内容];

这里可以自定义一个函数,功能为将data插入到已输入内容的前面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const insertBeforeInput = (terminal: Terminal, data: string) => {
//清除已输入内容
//首先计算已输入内容总共有多少行
let y = 0;
let width = 1;
for(let i = 0; i < currentLine.current.length; i++){
width += strWidth(currentLine.current[i]);
if(width >= terminal.cols){
y++;
width = width === terminal.cols ? 0 : 2;
}
}
//清除这y行内容,并移动光标
terminal.write('\x1b[2K\x1b[1F'.repeat(y + 1)); //(clear row and up) * y
terminal.write('\x1b[u'); //回退光标到保存位置

//写入data到终端上
terminal.write(data);
terminal.write('\x1b[s'); //保存光标位置

//将输入的内容写回终端
terminal.write('\r\n>'+currentLine.current);
}

上面的'\x1b['开头的代码是ANSI转义字符,具体含义可以自行使用搜索引擎查找。

这样,只要将调用terminal.write(data)改为调用insertBeforeInput(terminal, data)就好了。

终端输出大量内容时出现的错位问题

描述

当后端连接一次性接收到来自连接的大量信息,再将这些信息转发给前端后,终端显示就会出现文本错位、覆盖之前的内容的问题。

其实这个问题具体在什么情况下会出现我也不是特别明确,只是在有大量内容输出到终端界面上时观察到了这一问题,少量信息交互没复现过错位和覆盖之前内容。

原因

出现这个问题的原因是后端是使用的jsch,默认的pty列是80,行是24,交互内容中包含了这部分信息,而这些信息会被xterm.js自动解析。如果前端设置的终端大小和后端设置的不一致,就会出现上述问题。

解决

知道了问题所在,只要将前端设置的大小传给后端,让后端去设置就好了。

这里我用了一个偷懒的做法,直接自定一个格式让后端匹配,改成 JSON 格式交互更符合规范。

前端修改一下Websocket的回调函数onOpen就行:

1
2
3
4
socket.current.onopen = () => {
terminal.write('Welcome to terminal!\r\n>');
socket.send("%command%resize:"+terminal.cols+','+terminal.rows);
};

如果前端的终端大小可变,可以在terminal的回调函数onResize里也加上给Websocket连接发送改变pty大小命令的代码。

后端将参数解析出来后调用channelShell.setPtySize方法设置终端大小即可。

遗存问题

每次建立一个连接都设置一次pty大小,意味着多用户使用同一连接的情况下同一时间只有最后建立websocket连接的用户或最后改变自己终端大小的用户才能得到适应自己终端大小的连接。另一方面,我在后端将最近的信息存在了一个大小固定的队列中,这样每次用户连接后都能得到最近的历史信息,而这些信息中包含终端大小的信息,直接发送到前端显示在不匹配大小的终端上也会出现错位的问题,如果允许频繁修改后端pty大小的话就会使得错位问题更严重,这显然不是我想要的。

我想将信息中包含了pty大小的内容删除,但是尝试了寻找信息中的格式代码,没能找到。又看了另一个同样使用xterm.js且也是多用户使用同一个连接的开源应用MCSM,也没有解决这个问题。最后索性将终端大小定死,界面大小不足终端大小时就加滚动条。虽然不够优雅,但最终效果也还不错。

待优化

  1. 光标移动与插入。(默认的移动不能直接使用,我直接覆盖了方向键的输入,但是自定义的移动和插入还没做)

  2. 历史命令记录。(比较想要的一个功能,还没做)

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
import {useEffect, useRef} from 'react';
import 'xterm/css/xterm.css';
import {Terminal} from 'xterm';
import {FitAddon} from 'xterm-addon-fit';

export default function MyTerminal(): JSX.Element {
//当前输入的内容
const currentLine = useRef('');

//获取字符占据终端的宽度大小,中文字符将占据2位
const strWidth = (str: string) => {
if(!str) return 0;
let strLen = 0;
for(let i = 0; i < str.length; i++){
if(/^[\u4e00-\u9fa5]/.test(str[i])){
strLen = strLen + 2;
}else{
strLen++;
}
}
return strLen;
};

const insertBeforeInput = (terminal: Terminal, data: string) => {
//清除已输入内容
let y = 0;
let width = 1;
for(let i = 0; i < currentLine.current.length; i++){
width += strWidth(currentLine.current[i]);
if(width >= terminal.cols){
y++;
width = width === terminal.cols ? 0 : 2;
}
}
terminal.write('\x1b[2K\x1b[1F'.repeat(y + 1)); //(clear row and up) * y
terminal.write('\x1b[u'); //回退光标到保存位置

terminal.write(data);
terminal.write('\x1b[s'); //保存光标位置

//将输入的内容写回终端
terminal.write('\r\n\x1b[40;33m>\x1b[0m\x1b[33m'+currentLine.current);
}

//初始执行一次,初始化终端
useEffect(() => {
//websocket连接地址
const socketUrl = 'ws://xxxxxxx';
//websocket连接
const socket = new WebSocket(socketUrl);
const terminal = new Terminal({
cursorStyle: 'underline',
cursorBlink: true,
theme: {
foreground: '#dddddd',
cursor: 'gray'
},
windowsMode: true,
})
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
const terminalDom = document.getElementById('terminal-container');
terminal.open(terminalDom as HTMLElement);
fitAddon.fit();

//定义终端回调方法
const specialKeycode = [
'\x1b[OP', //f1-f12
'\x1b[OQ',
'\x1b[OR',
'\x1b[OS',
'\x1b[15~',
'\x1b[17~',
'\x1b[18~',
'\x1b[19~',
'\x1b[20~',
'\x1b[21~',
'\x1b[23~',
'\x1b[24~',
'\x1b[3~', //del
'\x1b[2~', //ins
'\x1b[5~', //pageUp
'\x1b[6~', //pageDown
'\x1b[H', //home
'\x1b[67;', //ctrl + c
];
terminal.onData((data) => {
const cursorX = terminal.buffer.active.cursorX;

switch (data){
case '\r':
case '\n':
if (currentLine.current.trim().length > 0) {
socket.send(currentLine.current);
currentLine.current = '';
}
break;
case '\x7F': //backspace
if (currentLine.current.length > 0) {
const charWidth = strWidth(currentLine.current.slice(-1));
if(cursorX === 0){
//回退到上一行的行尾
terminal.write('\x1B\x5B\x41');
terminal.write('\x1B\x5B\x43'.repeat(terminal.cols));
terminal.write(' ');
if(charWidth > 1){
let width = 1;
for(let i = 0; i < currentLine.current.length; i++){
width += strWidth(currentLine.current[i]);
if(width === terminal.cols) {
width = 0;
}
else if(width > terminal.cols){
width = 2;
}
}
if(width === 0){
terminal.write('\b');
}
else{
terminal.write('\b \b\b');
}

}
}
else {
terminal.write('\b \b'.repeat(charWidth));
}
currentLine.current = currentLine.current.slice(0, currentLine.current.length - 1);
}
break;
case '\t': //tab
case '\x1B\x5B\x41': // up arrow.
case '\x1B\x5B\x42': // down arrow.
case '\x1B\x5B\x43': // right arrow.
case '\x1B\x5B\x44': // left arrow.
break;

default:
if(specialKeycode.includes(data)) {
break; //特殊字符,暂不处理
}

terminal.write(data);
currentLine.current += data;
}
});

terminal.onResize((size) =>{
socket.send("%command%resize:"+size.cols+','+size.rows);
})

//定义websocket回调方法
socket.onopen = () => {
terminal.write('\x1b[1;36mWelcome to terminal!\x1b[0m\r\n\x1b[s\x1b[40;33m>\x1b[0m\x1b[33m');
socket.send("%command%resize:"+terminal.cols+','+terminal.rows);
};
socket.onclose = () => {
insertBeforeInput(terminal, '\r\n\x1b[1;31mConnection closed. Bye!\x1b[0');
terminal.blur();
};
socket.onerror = () => {
insertBeforeInput(terminal, '\r\n\x1b[1;31mSomething errors.\x1b[0');
};
socket.onmessage = (e: MessageEvent<any>) => {
insertBeforeInput(terminal, e.data);
};

terminal.focus();

return () => {
socket.close();
terminal.dispose();
};
}, []);

return (
<div>
<div style={{height: 550, width: '100%', overflowX: "auto", overflowY: "hidden"}}>
<div
id="terminal-container"
style={{ height: 550, minWidth: 1280}}
/>
</div>
</div>

);
}