在現在這個講「大數據、雲計算」的時代,筆者相信很多前端同行們都被投入到了這種大數據後臺產品的開發當中。數據是海量的,條件也是複雜的。爲了讓用戶更加方便準確地建立到他們想要的數據報表,這類後臺產品每每有着特殊的表單輸入組件設計,好比面向數據專業人士的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結合到一塊兒)。
最後感謝閱讀到這裏的你,你的每一次點贊都是我前進的動力哦。