之前写过一个“rexskz.info in terminal”,打算用伪 Web terminal 来展示自己的博客,但是后来发现简直是太简陋了,于是萌生了重写的念头。经过一段时间,居然写完了。这篇文章就是分析一下里面用到的一些技术。虽然这个终端还是很简陋,对命令的支持仅限于 command arg0 arg1 ...
的格式,但是它支持自动补全、颜色,也有文件系统和变量,所以还是比之前的稍微高级那么一点的。
按照惯例,先上图:
CSS 部分
首先,一个 Web terminal 要长的像 Terminal 才行,因此写好 CSS 还是很重要的。这儿的 CSS 难点主要是输入的效果和选中文字的颜色。
Rex.sh 中输入文字的效果是模拟出来的,但是需要输入的话,最简单的方法是使用 <input>
了。我将 <input>
标签尽可能隐藏起来,然后用
CSS 动画添加了一个闪烁的光标:
/* 闪烁光标部分 */
@keyframes cursor-blink {
0% { text-decoration: underline; }
49% { text-decoration: underline; }
50% { text-decoration: none; }
99% { text-decoration: none; }
}
/* 隐藏 input 标签 */
.sh-input-txt {
opacity: 0;
width: 1px;
}
剩下的部分应该都没有什么大问题,只需要注意对齐、行高以及不要出现滚动条就行了。
JavaScript 部分
这部分才是本文的重点。下面就一条条来说吧。
Rexsh 对象的内容
为了确保每个 Shell 都是完全独立的,我将 Shell 用到的内容都存在了对象中,包括:
id: 对象的 id,也用来标记 HTML 元素
sh: HTML 元素
env: Shell 的环境变量,是一组键值对
commands: 可用的命令列表
completions: 可用的补全列表
completionState: 补全的状态(补全命令 or 按照输完的命令补全参数)
filesystem: 文件系统,一个独立的类,下文会讲到
history: 输入过的命令
在这儿提一下,我知道例如 history
可以存到 ~/.bash_history
,commands
可以在 env.PATH
下面找,但是考虑到这是个伪终端,因此就不用在意那么多了,尤其是 commands
,下面有个方法 addCommand
可以让你添加自定义命令,而无需在 env.PATH
下面添加文件。
其中的一部分属性可以用函数修改,例如 addCommand
、setEnvs
、addFiles
、addCompletions
。
执行命令
这是一个 Shell 最核心的内容了。首先先将命令 push 到 history
中,然后开始分离各个参数。由于实现的时候直接使用了 argv = cmd.trim().split(' ')
而不是从左往右扫描,没有考虑到转义字符,因此只要带了空格,就会被认定为另一个参数,例如 cd Another\ Folder
其实是三个参数。
接下来是环境变量。由于一条命令可能是 VAR=xxx command
,也可能是 VAR=xxx
,前者是不会修改实际的环境变量,而后者会,因此将现有的环境变量克隆一份:envp = clone(this.env)
。如果 argv[0]
带有等号,说明这是环境变量赋值,于是修改 envp
。如果没有下文了,说明这确实修改了实际的环境变量,因此将 envp
再克隆回去。
然后才是执行程序。首先通过 commands
判断一下程序是否存在并且 typeof this.commands[cmd] == 'function'
(因为这是 JavaScript 啊),如果是,就构造程序执行时的三个参数:argc
、argv
、envp
。由于环境变量在刚才已经全部被 shift
出去了,因此直接传入 argv.length
、argv
、envp
就可以了。
用户输入区域的显示
刚才在 CSS 部分提到,用来输入的 <input>
标签是被隐藏的,其实是一个 <span>
标签在显示内容。举一个简单的例子,假设光标停留在右边的 a
下方:[email protected]$ 123asd456
那么其实这一行的 HTML 结构是这样的:
<div class="sh-input-area">
<span class="sh-input-display">[email protected]$ 123</span>
<span class="sh-input-blink">a</span>
<span class="sh-input-over">sd456</span>
<input type="text" class="sh-input-txt">
</div>
然后配合上 CSS,就大功告成了。记得 Shell 是要强制断行的,因此不要忘了用 word-break: break-all
。
至于如何同步,可以这样写:
const syncWithInput = () => setTimeout(() => {
const pos = input.selectionStart;
const pre = input.value.substr(0, pos) || '';
const cur = input.value[pos] || ' ';
const post = input.value.substr(pos + 1, 255) || '';
display.innerText = this.env['PS1'] + pre;
blink.innerText = cur;
over.innerText = post;
over.scrollIntoView();
}, 10);
首先,最左边的提示符其实是环境变量中的 PS1
(可以去看一下 Shell 的常用变量),然后我要获取 <input>
标签中的光标位置,经过适当的剪裁,才能刚好凑出刚才 HTML 中那三个 <span>
的内容。至于为什么要等待十毫秒,因为浏览器的绘制跟不上 JavaScript 的执行。其实等待零毫秒也可以,只要是异步的都可以。此外还要监听一个事件:当 <input>
失去焦点的时候要及时 focus
回来。
快捷键
这一部分其实是最好讲的了,由于焦点始终在 <input>
上面,因此直接监听即可。这儿支持的快捷键有:Ctrl+A
(跳到行首)、Ctrl+E
(跳到行尾)、Ctrl+C
(结束当前输入)、上下方向键(调出历史记录)、Enter
(执行命令)、Tab
(自动补全,也是下面要说的)。
文件系统
是一个 Object,键为空,值为一个文件。文件有四个属性:name
(文件名)、type
(“drwxr--r--”这类,严格来讲应该叫“类型和权限”)、size
(文件大小)、children
(文件夹专用,下面是一个由文件组成的数组),这样就组成了一个树形结构。
路径标准化
首先使用 path = path.split('/')
来分离路径,如果是相对路径,那么第一项肯定不为空,于是用当前的 cwd
将其变为绝对路径。然后使用如下方法去除路径中多余的 .
和 ..
(path
此时刚好是一个栈):
let result = [];
for (const item of path) {
if (item == '.') {
// 什么也不做
} else if (item == '..') {
if (result.length > 1) {
result.pop();
} else {
// 根目录的上级目录还是根目录,所以什么也不做
}
} else {
result.push(item);
}
}
然后将 result
拼接起来,做一些细节处理就行了。
转义
转义分为几个部分,要依次执行:
- 将
$xxx
转译为变量(假设不存在单引号、双引号、撇号、反斜杠) - 将
<
转译为<
,以免显示时出现问题 - 转义
\033[XXm
这类修改前景色和背景色的地方
第一部分好说,直接上正则:
str = str.replace(/\$([a-z_][\w_]*|[0-9#@\*\$!]|{[^\s]+})/gi, function (m) {
return envp[m.substr(1, 255)] || '';
});
第二部分也好说,一个 replace
就搞定。
麻烦的是第三个。因为在 Shell 的规定中,XX
有好几种状态,一种是 30~39 控制前景,一种是 40~49 控制背景,还有就是控制下划线、闪烁之类的,甚至还有 \033[4;32;43m
这种奇怪的复合状态。同一种状态中,后者覆盖前者,例如 \033[32;33m
最后只会保留 33;但如果出现了 \033[0m
,则需要全部重置。当然我们可以写一个 calcType
函数来轻松计算某个数字属于哪种状态:
const calcType = x => {
if (30 <= x && x <= 37) return 30;
if (40 <= x && x <= 47) return 40;
else return x;
};
我们用正则 /\\033\[([\d;]+)m/g
将这类文本提取出来,假设放到了变量 m
中,然后对 m
进行去头去尾并分割的操作:props = m.replace(/(^\\033\[|m$)/g, '').split(';')
,这样 props
数组就记录了依次出现的所有数字。然后记录当前所有出现的状态(假设存在变量 validProps
中),每出现一个标记,就将 validProps
变量中对应的状态修改;然后使用各种 <span clas="sh-033-XX sh-033-XX">
将文字包起来就可以了。
说起来好像很麻烦,直接上代码好了:
let validProps = {};
str = str.replace(/\\033\[([\d;]+)m/g, function (m) {
let result = '';
const props = m.replace(/(^\\033\[|m$)/g, '').split(';');
for (const prop of props) {
validProps[calcType(prop)] = prop;
}
if (validProps[0]) {
// 如果有 0,则清空当前的类型
validProps = {};
result = '</span><span>';
} else {
// 根据当前的类型拼接 className
let str033 = '';
for (const prop in validProps) {
str033 += ` sh-033-${validProps[prop]}`;
}
result = `</span><span class="${str033}">`;
}
return result;
});
// 在最外面包一层,因为刚才生成的 str 开头没有 <span>,结尾没有 </span>
str = `<span>${str}</span>`;
看看效果,还是没什么问题的:
自动补全
首先,我需要先根据当前的输入,整理出一个 List,因为这涉及到补全命令(l -> ls
)还是补全参数(/e -> /etc
)。这个只需要特判即可,不属于这部分要讲的内容。当然,我们要补全的输入,是用户输入中左边最近一个空格的下一个字符开始,到当前光标处结尾的一个单词。我们假设这个 List 中的每一个单词都以当前的单词为前缀(若不是,请先对 List 使用一下 filter
)。
一个好用的 Shell,自动补全应该分为两类:按一下 Tab 后尽可能无歧义往后补全、按两下 Tab 后显示一个补全列表。
无歧义补全
既然所有单词(假设为 options
)都是输入单词(假设为 t
)的前缀了,那补全的最大长度就是所有单词的最长公共前缀。一个比较暴力(O(len*N))的算法如下:
// 枚举长度,暂且先取第一项的长度为最大长度
for (let i = t.length; i <= options[0].length; i++) {
// 枚举串
for (let j = 0; j < options.length; j++) {
// 有一个串刚好完整匹配,这个串只有那么长了,所以没必要再往后了
if (options[j].length == i) {
t.push(options[j]);
return t.join(' ') + ((cmd && options.length == 1) ? ' ' : '');
}
// 在第 i 位有了区别,说明前 i - 1 位是好的,那就补全到前 i - 1 位
if (j > 0 && options[j][i] != options[j - 1][i]) {
t.push(options[j].substr(0, i));
return t.join(' ');
}
}
}
然后直接用补全的单词替换掉当前的单词即可。
可能的补全列表
这就需要用到最长公共子串(LCS)算法了。对于 options
中的每个元素,计算它与 t
的最长公共子串,只有超过了 t
的一半,或者 t
本身就是个空串,这个元素才可以被输出。如果你对 LCS 不太了解,建议先从其它地方学一下,这是一个很好玩的动态规划算法,它的核心公式如下:
如果你了解了 LCS,就请看下面的代码吧:
// options 是 List 中所有元素的集合
// 这里按照“是 t 的前缀”和“不是 t 的前缀”分开
// 如果是,则直接输出在“补全列表”中
// 如果不是,则输出在“可能的输入”中
const nonPre = options.filter(item => item.indexOf(last) != 0);
const preResult = options.filter(item => item.indexOf(last) == 0);
let lcsResult = [];
for (const item of nonPre) {
// 计算 item 和 last 的最长公共子串
let f = [];
for (var i = 0; i < item.length; i++) {
f[i] = [];
for (var j = 0; j < last.length; j++) {
if (i == 0 || j == 0) {
f[i][j] = 0;
} else if (item[i] == last[j]) {
f[i][j] = f[i - 1][j - 1] + 1;
} else {
f[i][j] = Math.max(f[i - 1][j], f[i][j - 1]);
}
}
}
const word = item;
const ans = f[item.length - 1][last.length - 1];
// 足够匹配,或者原串为空,这个解才可以被采纳
if (ans >= last.length / 2 || str == '') {
lcsResult.push({ word, ans });
}
}
lcsResult = lcsResult.sort((x, y) => y.ans - x.ans).map(item => item.word);
return {
preMatch: preResult,
lcsMatch: lcsResult,
};
然后注意一下显示的细节就可以了,当前单词无需替换。
其它工具
定位文件(一个递归查找)、输出(作为命令函数中的输出语句),都是些小玩意。
这个项目只是我自己写着玩的,不确定后续还会不会维护,如果真的有别人用上了再说吧……