R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

Rex.sh:一个伪 Web terminal

注意:本文发布于 2428 天前,文章中的一些内容可能已经过时。

之前写过一个“rexskz.info in terminal”,打算用伪 Web terminal 来展示自己的博客,但是后来发现简直是太简陋了,于是萌生了重写的念头。经过一段时间,居然写完了。这篇文章就是分析一下里面用到的一些技术。虽然这个终端还是很简陋,对命令的支持仅限于 command arg0 arg1 ... 的格式,但是它支持自动补全、颜色,也有文件系统和变量,所以还是比之前的稍微高级那么一点的。

按照惯例,先上图:

0

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_historycommands 可以在 env.PATH 下面找,但是考虑到这是个伪终端,因此就不用在意那么多了,尤其是 commands,下面有个方法 addCommand 可以让你添加自定义命令,而无需在 env.PATH 下面添加文件。

其中的一部分属性可以用函数修改,例如 addCommandsetEnvsaddFilesaddCompletions

执行命令

这是一个 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 啊),如果是,就构造程序执行时的三个参数:argcargvenvp。由于环境变量在刚才已经全部被 shift 出去了,因此直接传入 argv.lengthargvenvp 就可以了。

用户输入区域的显示

刚才在 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 拼接起来,做一些细节处理就行了。

转义

转义分为几个部分,要依次执行:

  1. $xxx 转译为变量(假设不存在单引号、双引号、撇号、反斜杠)
  2. < 转译为 &lt;,以免显示时出现问题
  3. 转义 \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>`;

看看效果,还是没什么问题的:

1

自动补全

首先,我需要先根据当前的输入,整理出一个 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 不太了解,建议先从其它地方学一下,这是一个很好玩的动态规划算法,它的核心公式如下:

2

如果你了解了 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,
};

然后注意一下显示的细节就可以了,当前单词无需替换。

其它工具

定位文件(一个递归查找)、输出(作为命令函数中的输出语句),都是些小玩意。


这个项目只是我自己写着玩的,不确定后续还会不会维护,如果真的有别人用上了再说吧……

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3078 天