背景
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
交互形式选择
前后端的交互可以有两种形式:
直接将原始输入传给后端。
按了回车后再把输入的内容传给后端。
很明显,第一种方式的实现更简单,查找资料时我看到的大部分也是用了第一种方式。
但是,我所做的项目后端使用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(() => { 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); } });
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': doBackspace(); break; ... }
|
同行删除
基础的删除操作可以这样做:写入一格退格,将光标向左移一位,再写入一位空格,将已输入内容的最后一位字符覆盖,此时光标会自动向右移一位,之后再写入一位退格,将光标左移一位即可。整合起来,就是写入\b \b
。
在删除前需要做一个判断,保证已输入内容不为空时才做删除操作。
代码如下:
1 2 3 4 5 6 7 8 9
| switch (data){ ... case '\x7F': 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': 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': if (currentLine.current.length > 0) { const charWidth = strWidth(currentLine.current.slice(-1)); const cursorX = terminal.buffer.active.cursorX; 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;
|
上面的代码是一点点调试摸索出来的,比较乱,写出来后也还没优化,层级较多,这里用流程图总结一下:
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;
对退格的处理到这里就结束了,这样的处理方法还存在一些不足:
- 代码层级过多,还未优化;
- 只适配了常用中文字符;
- 行末删除后立即写入,再立即删除有小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; } } terminal.write('\x1b[2K\x1b[1F'.repeat(y + 1)); terminal.write('\x1b[u');
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 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('');
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)); 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(() => { const socketUrl = 'ws://xxxxxxx'; 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', '\x1b[OQ', '\x1b[OR', '\x1b[OS', '\x1b[15~', '\x1b[17~', '\x1b[18~', '\x1b[19~', '\x1b[20~', '\x1b[21~', '\x1b[23~', '\x1b[24~', '\x1b[3~', '\x1b[2~', '\x1b[5~', '\x1b[6~', '\x1b[H', '\x1b[67;', ]; 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': 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': case '\x1B\x5B\x41': case '\x1B\x5B\x42': case '\x1B\x5B\x43': case '\x1B\x5B\x44': break;
default: if(specialKeycode.includes(data)) { break; }
terminal.write(data); currentLine.current += data; } });
terminal.onResize((size) =>{ socket.send("%command%resize:"+size.cols+','+size.rows); })
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>
); }
|