在现在这个讲“大数据、云计算”的时代,笔者相信很多前端同行们都被投入到了这种大数据后台产品的开发当中。数据是海量的,条件也是复杂的。为了让用户更加方便准确地建立到他们想要的数据报表,这类后台产品每每有着特殊的表单输入组件设计,好比面向数据专业人士的sql代码编辑器组件与面向普通用户的公式编辑器组件。css
笔者最近有幸参与了一款数据后台产品的研发,其中就涉及到一个只有四则运算的公式编辑器组件的开发。经过调研市面上一些竞品与相关框架的实现,笔者决定从零开始独立实现一个这样的编辑器。通过断断续续三个月的开发,这个编辑器成型。笔者从中也积累的很多经验,因而写下此文向各位同行分享,但愿之后能帮到你们。html
首先咱们须要明确一下,这个编辑器的需求是什么。前端
对笔者的后端们来讲,他们有本身的一套解析工具,所以他们对这个编辑器的需求很简单,只要把用户的输入传递给他们便可,他们能分析出表达式是否合法,变量名是否正确。因此这方面比较简单,就算是直接给个input输入框给用户输入,拿到值传给后端,后端也是不介意的。vue
对笔者的产品来讲,他的需求就三个:node
笔者做为前端工程师,提升用户交互体验是前端工程师的核心职能之一,所以笔者在产品需求的基础上再加上一条:react
综合上面四个需求,就能够把技术方案肯定下来了:web
这套方案中,明眼人就能够看出难点有两个,第一个是AST语法树解析,对于非计算机科班出身的笔者来讲并不是易事;第二个就是代码编辑器式的富文本方案开发,须要吃透浏览器的Selection和Range这两个API。sql
好在现在网络上学习资源及其丰富,编译原理的课程文章并不难找;且笔者之前开发过一些简单的富文本编辑器,对于Selection和Range的使用还算熟悉。chrome
这是最后笔者鼓捣出来的效果 typescript
笔者参考了掘金上的一篇文章《四则运算表达式如何转换成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的对象引用机制是很容易将程序送入火葬场,可是架不住写起来简单粗暴爽啊。
代码很丑,可是跑得起来呀。
富文本绝对是个大坑,慎入。
对于展现来讲,由于经过上面的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对象的朋友能够戳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的位置便可。
如今这个富文本展现层已经有了输入框的样子,在视觉上,笔者认为已经足以达到欺骗用户的目的了。
基本上,一个公式编辑器就这样成形了,其中AST和富文本是笔者以为最有挑战性的部分。其余的,像是语法提示,当解析出ast和token以后,根据光标所在位置的token的类型、内容,就能够作相似列表查询的功能,这个我详细各位搞后台的朋友们应该是写得很是熟悉了的,而后用popover这个库,把语法提示弹层一样也是挂在虚拟光标上就能够了。像是错误提示,其实看上面笔者提供的动图你们也能够看到错误提示功能是添加上去了的,在Token里面添加一个error
类型的Token,在词法分析的时候先简单查一遍,语法分析的时候一样也是查一遍,一旦不符合组合规则的,把Token改了error,而后添加错误信息,至于后面对于错误的处理以及ui的展现,相信朋友们也是轻车熟路了。像是特殊富文本节点处理,在上文也有所说起...
后面若是有时间的话,笔者还想尝试将用户输入转为latex,而后用MathML把公式画出来贴到报表上(若是业务须要的话)。或者像是这个富文本处理,也能够用在不少有趣的地方,好比简谱编辑器,好比MD富文本编辑器(把富文本编辑器和Markdown结合到一块儿)。
最后感谢阅读到这里的你,你的每一次点赞都是我前进的动力哦。