0x01 前言
在如今这个讲“大数据、云计算”的时代,笔者相信不少前端同行们都被投入到了这种大数据后台产品的开发当中。数据是海量的,条件也是复杂的。为了让用户更加方便准确地创建到他们想要的数据报表,这类后台产品往往有着特殊的表单输入组件设计,比如面向数据专业人士的sql代码编辑器组件与面向普通用户的公式编辑器组件。
笔者最近有幸参与了一款数据后台产品的研发,其中就涉及到一个只有四则运算的公式编辑器组件的开发。通过调研市面上一些竞品与相关框架的实现,笔者决定从零开始独立实现一个这样的编辑器。经过断断续续三个月的开发,这个编辑器成型。笔者从中也积累的不少经验,于是写下此文向各位同行分享,希望以后能帮到大家。
0x02 确认需求
首先我们需要明确一下,这个编辑器的需求是什么。
对笔者的后端们来说,他们有自己的一套解析工具,因此他们对这个编辑器的需求很简单,只要把用户的输入传递给他们即可,他们能分析出表达式是否合法,变量名是否正确。所以这方面比较简单,就算是直接给个input输入框给用户输入,拿到值传给后端,后端也是不介意的。
对笔者的产品来说,他的需求就三个:
- 输入时要向专业的代码编辑器那样,要有输入提示。
- 输入后要区分出变量与非变量,要对变量进行特殊展示。
- 兼容mac safari
笔者作为前端工程师,提高用户交互体验是前端工程师的核心职能之一,因此笔者在产品需求的基础上再加上一条:
- 除了有输入提示,还要在用户输入错误时给予友好的错误提示。
0x03 技术方案
综合上面四个需求,就可以把技术方案确定下来了:
- 采用web worker多线程,在多线程里面将用户的输入解析成语法树。可以避免解析时阻塞主线程,导致输入时出现卡顿感。
- AST语法树解析,鉴于笔者所研发的产品中,允许用户输入奇奇怪怪的字符来当变量名,如“xxx@ddd”或“#dd#”之类的,而业界很多解析库不允许这种字符当作变量。况且这个编辑器的规则相对简单,只有四则运算和括号运算,没必要引入一个完整的程序解析库来增大包体积,因此笔者决定手写解析。
- 模仿Monaco Editor,采用代码编辑器式的富文本方案。
这套方案中,明眼人就可以看出难点有两个,第一个是AST语法树解析,对于非计算机科班出身的笔者来说并非易事;第二个就是代码编辑器式的富文本方案开发,需要吃透浏览器的Selection和Range这两个API。
好在如今网络上学习资源及其丰富,编译原理的课程文章并不难找;且笔者以前开发过一些简单的富文本编辑器,对于Selection和Range的使用还算熟悉。
这是最后笔者鼓捣出来的效果
0x04 AST语法树解析
笔者参考了掘金上的一篇文章《四则运算表达式如何转换成AST》。这篇文章写得非常通俗易懂,跟着作者的思路走,可以很容易把一串字符串解析成一棵语法树。一样的,笔者也是将这个解析过程分成两个步骤,词法解析和语法树构建。
词法解析
词法分析是一个比较简单的步骤,原理很简单,就是遍历每一个字符,判断字符的类型,然后输出为Token到一个列表里。在这里,笔者根据业务需要,把Token分为了数字符、运算符、括号符、空格符、错误符、字符。
先简单声明常量,用常量代替字符串做标识的好处在于,当你不小心写错了vscode可以帮你提示,而字符串不行:
const T_ERROR = 1;
const T_EMPTY = 0;
const T_SPACE = 2;
const T_NUMBER = 10;
const T_INT = 11;
const T_FLOAT = 12;
const T_DOT = 20;
const T_OPERATOR = 30;
const T_OPERATOR_ADD = 31;
const T_OPERATOR_SUB = 32;
const T_OPERATOR_MUL = 33;
const T_OPERATOR_DIV = 34;
const T_OPERATOR_REM = 35;
const T_BRACKET = 40;
const T_BRACKET_START = 41;
const T_BRACKET_END = 42;
const T_WORD = 50;
const T_WORD_ROOT = 51;
const T_WORD_PROP = 52;
使用一个字符状态机类来处理这些字符的状态:
class CharState{
type = T_EMPTY; // 类型
sub = T_EMPTY; // 子类型
start = -1; // 状态机的包含字符的起始位置
end = -1; // 状态机的包含字符的结束位置
/**
* @desc 初始化,确定状态机类型
* @param prev - 上一个字符状态机
* @param char - 当前单个字符
* @param i - 当前字符的索引
*/
constructor(prev:CharState,char:string,i:number){}
/**
* @desc 读取下一个字符,如果下一个字符符合连接规则,返回true,否则返回false
*/
join(char:string,i:number):boolean{
switch (this.type) {
case T_NUMBER:
return this.numberJoin(char, i);
case T_DOT:
return this.dotJoin(char, i);
case T_OPERATOR:
return this.operatorJoin(char, i);
case T_SPACE:
return this.spaceJoin(char, i);
case T_WORD:
return this.wordJoin(char, i);
}
return false;
}
// 各种状态机的连接规则,这里就不展开了
numberJoin(char:string,i:number):boolean{}
dotJoin(char:string,i:number):boolean{}
operatorJoin(char:string,i:number):boolean{}
spaceJoin(char:string,i:number):boolean{}
wordJoin(char:string,i:number):boolean{}
}
状态机的连接规则,指的是把下一个字符拼接进状态机之后,状态机要如何变化的逻辑。比如说,用户输入了a1
,虽然单个字符分析,程序将会得到[字符,数字]
的一个列表,但显然不是笔者想要的,笔者想要的是程序将a1
这个组合判断为字符
,因此可以确定了一个连接规则,如果一个字符状态机跟一个数字类型的字符拼接在一起,那么字符状态机的类型依旧是字符。
然后直接跑一遍用户的输入就可以了:
function parse(code:string=""):CharState[]{
let list:CharState[]=[];
for (let i = 0; i < code.length; i++) {
let char = code[i];
let last = list[list.length-1];
if (last && last.join(char, i)) {
continue;
}
list.push(new CharState(last, char, i));
}
return list
}
parse("w.w + 11.1*ww.w");
这是笔者简单做的一个gif图,可以比较直观地看这个过程:
完全可以采用单个状态机来输出,只不过笔者图方便,把状态机直接当成Token塞入列表。
语法树构建
参考文章里,采用了栈这个数据结构来构建语法树,笔者多次尝试,奈何水平太次,写起来觉得难了。因此直接采用二叉树暴力构建,毕竟大家都是树结构,搞起来相对简单点(当然代码也不好看不优雅)
一样的,先确定运算符优先级:
const PRIORITY_MAP = {
[T_NUMBER]: 0,
[T_WORD]: 0,
[T_OPERATOR_ADD]: 2,
[T_OPERATOR_SUB]: 2,
[T_OPERATOR_MUL]: 3,
[T_OPERATOR_DIV]: 3,
[T_OPERATOR_REM]: 3,
[T_DOT]: 4,
[T_BRACKET_END]: 5
};
然后声明一下节点结构与工具方法:
interface SyntaxNode{
type: number
left: SyntaxNode
right: SyntaxNode
parent: SyntaxNode
}
const root:SyntaxNode={
type:T_EMPTY,
parent: null,
left: null, // last
right: null, // child
}
function clearRoot() {
root.left = null;
root.right = null;
}
function getRootLast():SyntaxNode{
return root.left;
}
function setRootLast(node:SyntaxNode) {
root.left = node;
}
function getRootChild(){
return root.right;
}
function setNode(parent:SyntaxNode, node:SyntaxNode, isLeft:boolean) {
if (isLeft) {
parent.left = node;
} else {
parent.right = node;
}
node.parent = parent;
}
我们构建的语法树将会放在root节点的右节点,而左节点保存着最后一个被操作的节点的引用。为了防止left/right两个节点记混了,直接写个工具函数,显式地获取、设置这两个节点。parent保存着对父节点的引用,没错,就是用来找爸爸的,主要是针对运算符比较时使用的。整个构建过程的最基础的就是这个setNode函数了,承担着所有的节点的增加与替换功能。
当然,真实的节点还会携带更多的信息,为了突出结构,其他信息省略。
构建过程如同上面gif图所示。以乘法运算为例子,处理函数如下:
function handleMul(node:SyntaxNode, token:CharState):boolean {
let last = getRootLast();
if (![T_OPERATOR_MUL, T_OPERATOR_REM, T_OPERATOR_DIV].includes(last.type)) {
return false;
}
if (!last.right) {
// 数、字 直接塞入右叶
if ([T_NUMBER, T_WORD].includes(node.type)) {
setNode(last, node);
return true;
}
// 减号 左括号也是塞入右叶,不过需要注意要把last指针指向它们
if ([T_BRACKET_START, T_OPERATOR_SUB].includes(node.type)) {
setNode(last, node);
setRootLast(node);
return true;
}
// 非法组合
setTokenError(root, node, token);
return true;
}
if ([T_NUMBER, T_WORD, T_BRACKET_START].includes(node.type)) {
// 非法组合
setTokenError(root, node, token);
return true;
}
// 反括号
if (node.type === T_BRACKET_END) {
handleBraEnd(node, token);
return true;
}
// 运算符比较
compare(node, token, last);
return true;
}
很粗暴,函数进来直接判断节点的值,如果是不是乘法类型(乘法、除法、求余),直接返回false让调用者知道新节点不是乘法类型的节点。然后跟参考文章一样的规则,如果右边节点为空,只要是数字节点或者字符节点,直接就塞入进右边节点。为了处理1*-1
和1*(-1)
这种情况,括号和减号也一样先塞入进去,等下一步再看看构建是否会有问题。
如果左右节点都满了,证明我们刚刚完成了一个n*n
的结构,此时一个合法的公式接下来可以输入的字符只有运算符和反括号,不是这些字符直接抛错。反括号和运算符号有不同的处理规则,对于运算符的比较,规则如下:
function compare(node:SyntaxNode, token:CharState, last:SyntaxNode) {
let lastPriority = PRIORITY_MAP[last.type];
let nodePriority = PRIORITY_MAP[node.type];
/**
* 优先级大的在右节点
* ...
* \
* last
* / \
* ... node <-rootLast
* / \
* last.right ?
*/
if (nodePriority > lastPriority) {
setNode(node, last.right, true);
setNode(last, node);
setRootLast(root, node);
return true;
}
/**
* 找到一个优先级大于或等于的节点,替换后放node的左节点
* parent
* / \
* ... node <-rootLast
* / \
* parent.right ?
* / \
* ... last
*/
let parent = findParentsWhile(last, p => {
if (p.type === T_BRACKET_START) {
return true;
}
if (!PRIORITY_MAP[p.type]) {
return true;
}
if (PRIORITY_MAP[p.type] > lastPriority) {
return true;
}
});
if (!parent) {
token.type = T_ERROR;
token.msg = ERR_UNEXPECTED_MAP[node.type];
clearRoot();
return true;
}
last = parent.right;
setNode(node, last, true);// 设置左节点
setNode(parent, node); // 设置替换到右节点
setRootLast(node);
return true;
}
如果node的运算优先级大于last的运算优先级,则做一次替换,断开原来last的右边节点,把它挂到node的左边。然后将node挂到last的右边,最后将last指针指向node。
如果node的运算优先级小于last的运算优先级,则要从last一层层往上找,找到一个运算等级大于last的父节点或者括号节点,然后对这个父节点做一次替换,断掉父级的右节点,把node挂上去,再把原来的右节点挂到node的左边,最后同样将last指针指向node。
其他类型的处理也是类似的。这个过程有的绕,不过可以试试自己在纸上画一下这个过程,会清晰很多。
最后直接跑一下语法解析出来的Token列表就可以了:
list.forEach((item:CharState, i:number) => {
if (item.type === T_SPACE) {
return;
}
if (item.type === T_ERROR) {
handleEOF(root, list);
clearRoot(root);
return;
}
item.prev = "";
let node = createNode(item, i);
if (!getRootLast(root)) {
initRootChild(node, item);
return;
}
if (handleNum(node, item)) return;
if (handleWord(node, item)) return;
if (handleDot(node, item)) return;
if (handleAdd(node, item)) return;
if (handleMul(node, item)) return;
if (handleBra(node, item)) return;
});
handleEOF(root, list);
这里不得不吐槽下,虽然js的对象引用机制是很容易将程序送入火葬场,但是架不住写起来简单粗暴爽啊。
代码很丑,但是跑得起来呀。
0x05 富文本处理
富文本绝对是个大坑,慎入。
对于展示来说,因为通过上面的AST语法树解析后,我们已经可以拿到Token列表,把Token列表转成html输出到页面上,再通过css控制一下各个Token的样式,富文本展示就完成了。
理论上笔者可以直接在div上开启contenteditable属性,让div变成一个富文本框,然后光标之类的会有浏览器自己处理。但是现实情况是,mac safari跟chrome的在设置光标位置的表现上完全不一致。
于是笔者参考Monaco Editor这些代码编辑器的做法,将展示层和输入层拆分开。去掉那些杂七杂八的功能,一个简单的代码编辑器的结构关系如下图:
其实这就是一个非常简单的输入 -> 输出
结构,无论是vue还是react官网给的案例都会有这种例子,比如一个input框,一边输入,下面一个p标签把你输入的内容逆序展示。代码编辑器的道理也是一样的,通过语法解析后,得到一个数组,然后用v-for
或者map
把数组渲染到ui层上。
<div>
<div class="content">
<!--数组渲染到富文本层-->
<span class="is-number">100</span>
<span class="is-operator">+</span>
<span class="is-number">100.01</span>
...
</div>
<div class="cursor"></div>
<input />
</div>
困难的是,你要隐藏掉输入框,还要欺骗用户,让用户误以为富文本展示层才是输入框。所以,我们需要花费一些功夫来使富文本展示层具有输入框的效果。
虚拟光标
最基本的效果,输入框有光标,但富文本展示层没有,所以需要搞一个假的光标,这里称之为“虚拟光标”。
这个光标可以用任何元素来模拟,笔者就直接简单地使用了一个div标签,给它加个绝对定位,通过css属性的left/top
来控制它的位置,通过属性height
来控制它的高度,给它固定2px的宽度,再写一个css animation让它1秒闪一次,就有输入框光标那味儿了。
注意的是,这个虚拟光标是浮动在富文本展示层上面的,它跟富文本展示层是同级的。语法解析出来的数组顺序是怎么样的,富文本展示层的子元素顺序就是怎么样的,虚拟光标不会插入到里面,这样可以减轻渲染逻辑的编写。
Range定位
不熟悉浏览器Range对象的朋友可以戳Web API接口参考-Range
浏览器对于光标处理有一个特性,当页面上任何一个文本位置被点击时,无论这个文本能不能被编辑,总会产生一个Range对象。这个Range对象可以通过下面代码获取:
let selection = window.getSelection();
let range = selection.getRangeAt(0)
而Range对象有四个非常关键的属性:
inteface Range{
// 光标起始节点
startContainer:Node
// 光标相对起始节点的偏移量
startOffset:number
// 光标终结节点
endContainer:Node
// 光标相对终结节点的偏移量
endOffset:number
}
通常情况下,起始和终结节点往往是文本节点,也就是TextNode
。如果用户只是点击了文本,那么startContainer
和endContainer
是同个节点。如果用户是框选了文本,那么endContainer
节点就是鼠标点击的节点。结合偏移量,我们便可以准确地获得了鼠标点击的位置是在富文本的哪段第几个字符。通过HTMLInputElement.setSelectionRange
方法,我们可以在input框激活时指定光标开始的位置。
let startNode:Node = range.startContainer;
let endNode:Node = range.endContainer;
let startOffset:number = range.startOffset;
let endOffset:number = range.endOffset;
// 拿文本节点的父节点
if (startNode.nodeType === 3) {
startNode = startNode.parentNode;
}
if (endNode.nodeType === 3) {
endNode = endNode.parentNode;
}
// 找到纯文本坐标
let start = startOffset + startNode.start;
input.setSelectionRange(start, start);
input.focus()
还记得上文解析语法的时候那个Token对象吗?里面的start和end属性被笔者挂在dom节点属性里了,因此通过访问富文本节点的start属性就可以知道这段节点是从纯文本的哪个位置开始。
其次,Range
跟HTMLElement
对象一样,也有一个getBoundingClientRect
方法。用过这个getBoundingClientRect
方法的朋友应该知道,它是一个获取元素包围盒尺寸位置的一个方法。也就是说,点击发生时,Range光标在页面的相对位置,浏览器也是可以给你计算到的。
所以可以直接的用数值计算的方式,设置虚拟光标的left top height
属性。
反过来,如果Input标签里的光标发生了变化,也可以通过设置Range来达到更新虚拟光标位置的目的。
updateRange:{
let start:number = input.selectionStart;
let end:number = input.selectionEnd;
let i:number = tokens.findIndex(item:Token=>{
return end<=item.end
});
let node:Node = content.children[i];
if (!node) {
break updateRange
}
let content:Node = node.firstChild;
if (content.nodeType !== 3) {
content = node.querySelector("[data-content]");
if (!content) {
break updateRange
}
content = content.firstChild;
}
let token:Token = tokens[i];
range.setStart(content, end - node.start);
range.collapse(true);
}
通过HTMLInputElement.selectionStart
属性,可以获取当前输入框里光标的起始位置。然后到Token数组里找到对应的索引,拿到索引后再取出富文本里对应的子节点的文本节点,注意,Range对象的setStart
方法是用来设置起始光标的,它的第一个参数是Node
类型,是你要选中的html节点,第二个参数是number
类型,是这个光标在第一个参数html节点里的偏移量。当第一个参数不是文本节点,那么偏移量不能超过该节点的子节点数量,否则报错。而当第一个参数时文本节点时,偏移量超出文本长度才会报错。为了能正确设置Range的位置,我们需要确保第一个参数必须是文本节点。
所以笔者使用了个小技巧,如果取出的富文本节点,它的第一个子节点不是文本,那么就去找带有data-content
属性的节点的子节点,这个子节点一定是个文本节点。这样做,既可以满足特殊功能的富文本节点,又不会影响Range的设置
<div class="content">
<!--有特殊功能的富文本节点-->
<div class="custom-richtext-node">
<button>https://</button>
<span data-content>www</span>
<button>与光标无关的按钮</button>
</div>
<!--普通富文本节点-->
<span class="is-dot">.</span>
<span class="is-word">ddddd</span>
...
</div>
设置完Range对象后,又可以通过获取Range包围盒的方式去更新虚拟光标的位置。这样,我们就已经为输入框和虚拟光标建立起了一个“双向绑定”,虚拟光标位置变动(指富文本展示层被点击)可以更新输入框的光标位置,输入框的光标改变能更新虚拟光标位置。
不过遗憾的是,浏览器并没有为输入框提供一个光标变动的事件,我们没法监听光标发生改变,只能通过input事件和键盘事件变相监听光标变动。
会慢一拍,但是隐藏掉输入框的话,就感觉不出来了。
输入框隐藏
一开始,笔者直接对输入框进行了透明度隐藏,并且对Monaco Editor设置1px大小隐藏的做法感到疑惑,而且Monaco Editor还动态更新输入框的位置,更加令笔者不解。直到笔者有一次唤起了输入法
原来如此,不愧是微软,考虑得真的是全面。输入法的位置实际上是根据输入框真实光标位置定位的,当输入框一直定死不动时,输入法得位置也不会跟着动。
既然要欺骗用户,那就要贯彻到底,输入法的问题需要解决掉。
前人种树,后人乘凉,直接把Monaco的方案抄过来。把Input设置为1px隐藏,然后设置虚拟光标的时候,也更新Input的位置即可。
现在这个富文本展示层已经有了输入框的样子,在视觉上,笔者认为已经足以达到欺骗用户的目的了。
0x06 最后
基本上,一个公式编辑器就这样成形了,其中AST和富文本是笔者觉得最有挑战性的部分。其他的,像是语法提示,当解析出ast和token之后,根据光标所在位置的token的类型、内容,就可以做类似列表查询的功能,这个我详细各位搞后台的朋友们应该是写得非常熟悉了的,然后用popover这个库,把语法提示弹层同样也是挂在虚拟光标上就可以了。像是错误提示,其实看上面笔者提供的动图大家也可以看到错误提示功能是添加上去了的,在Token里面添加一个error
类型的Token,在词法分析的时候先简单查一遍,语法分析的时候同样也是查一遍,一旦不符合组合规则的,把Token改了error,然后添加错误信息,至于后面对于错误的处理以及ui的展示,相信朋友们也是轻车熟路了。像是特殊富文本节点处理,在上文也有所提及...
后面如果有时间的话,笔者还想尝试将用户输入转为latex,然后用MathML把公式画出来贴到报表上(如果业务需要的话)。或者像是这个富文本处理,也可以用在很多有趣的地方,比如简谱编辑器,比如MD富文本编辑器(把富文本编辑器和Markdown结合到一起)。
最后感谢阅读到这里的你,你的每一次点赞都是我前进的动力哦。