前言
最近公司有一个在 web 上调用终端的需求。我们使用的技术栈 react + ts,调研了一下决定用 xterm 这个库来实现。做的过程中踩了一些坑,稍作整理分享一下。
看一下最终的效果
错误理解重灾区
在开始之前,你是否遇到了这些问题:
- 一片空白
- 编辑器最前面出现了一个框
- 无法删除,删除就报错
- 光标莫名跑到最前方,覆盖最前方的字符
- 很奇怪的换行,在中间部分折断了
- 接收到的消息没法正常处理和展示
- 不能输入数字
- ...
所以必须要说清楚几个错误点,否则要折腾很久
一、引入组件就开干
要额外引入css...否则你会看到莫名其妙的样式
二 、直接将其当做一个 input 来 交互
绝大部分同学看到这个终端需求的第一个想法一定是:
- 找一个库,它提供一个类似输入框一样的组件,只是稍作封装
- 这个输入框负责输入、记录、回车后 ws 发送消息
- ws 接收到消息时显示响应
但实际上完全不是!!!差的非常远,官方也不推荐这种使用方案。如果按这种方案做,会遇到相当多的问题,说多了都是泪。
正确的做法应该是:
- 用户按下键盘,不管按什么都立刻传给后端,也就是每按一下就传到后端一下
- 后端使用 pty,并且也直接传到对应服务器
- 绑定 ws 的 onmessage,后端向前端推送消息时,立刻更新终端
这样就 ok 了,不需要考虑什么删除、换行、组合键等等一系列操作。如果按这种思路,能躲开大多数的坑。
如果迫不得已需要按刚才那种错误观点来做,可以参考这篇文章,需要做很多额外处理,非必须情况还是不推荐的juejin.cn/post/708156...
三、将其当做普通的标签来调长宽大小
一旦按这种想法,你会发现诡异的换行,莫名的覆盖,删除可以删掉前面的提示等等一系列坑。
如果想调长宽的样式,必须要后端同时来支持。 这可太新鲜了,最开始完全没想到。
最大的几个坑就是这里,搞清楚以后就容易了。
步骤
- 引入
js
npm i @xterm/xterm
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
- 放一个容器供注入
html
<div ref={terminalDomRef}></div>
- 初始化终端
js
const terminalDomRef = useRef<HTMLDivElement>(null);
const initTerminal = () => {
if (termRef.current) {
termRef.current.dispose();
}
termRef.current = new Terminal(options);
if (terminalDomRef.current) {
termRef.current.open(terminalDomRef.current);
termRef.current.loadAddon(fitAddon.current);
}
}
useEffect(() => {
initTerminal();
}, [])
到这一步就可以看到完整的终端了,不过只是终端的 dom,我们还需要考虑连接 websocket 部分。
这一部分有个插件,但是我最开始没发现,所以没有使用,
- 初始化 websocket
js
const initSocket = async () => {
try {
// 这里我做了封装,具体情况具体改造,只要连接上就行
wsRef.current = await wss('xxxx'});
wsRef.current.onopen = () => {
setLogStatus('success');
resetTerminal();
};
wsRef.current.onmessage = (event) => {
if (!termRef.current) return;
try {
// 对象/数字,数字没法输入的同学可以看这里的逻辑
const data = JSON.parse(event.data);
termRef.current.write(data.output || String(data));
} catch {
// 字符串
termRef.current.write(event.data);
}
};
} catch (error) {
Message.error('连接终端失败');
}
};
useEffect(() => {
initSocket();
})
此时,只要后端按部就班,那你的终端已经可以正常读写了。但我们还需要考虑一下样式问题,当该终端的长宽发生变化时,页面会变的非常诡异,主要原因是后端的行数、列数没有匹配上,所以我们修改长宽时,要计算出对应的行数、列数并告知后端。
js
const fitAddon = useRef<FitAddon>();
// initTerminal 时
{
...
fitAddon.current = new FitAddon();
termRef.current.loadAddon(fitAddon.current);
...
}
const resize = debounce ( function onResize () { fitAddon. current ?. fit (); const { cols } = termRef. current !; const windowHeight = window . innerHeight ; const rows = Math . floor (windowHeight / 19 ); // 这个需要自己看下自己每行多高 termRef. current ?. resize (cols, rows); wsRef. current ?. send ( JSON . stringify ({ type : 'resize' , cols, rows })); }, 500 );
useEffect(() => {
initTerminal();
window.addEventListener('resize', resize);
return () => {
if (termRef.current) {
termRef.current.dispose();
}
window.removeEventListener('resize', resize);
};
}, []);
至此,大功告成。
其他
做的过程中主要参考了这两篇文章