天天看點

Reactjs開發自制程式設計語言Monkey的編譯器:高能技術幹貨之文法高亮2

上一節,我們利用詞法解析器加上觀察者模式,實作了代碼語句的抽取關鍵字功能,對于給定代碼:

<div><text>let five = 5; let six = 6; let seven = 7;</text></div>           

MonkeyCompilerEditer把div節點裡面的内容送出給MonkeyLexer,然後通過回調函數notifyTokenCreation獲得了關鍵字對應的token對象,以及關鍵字字元串的起始和結束位置,并把相關資訊存儲到隊列keyWordElementArray。例如上面的語句送出給MonkeyLexer後,編輯器對象的notifyTokenCreation會被調用若幹次,同時三個關鍵字”let”對應的字元串起始和結束位置會被記錄下來,這些位置将會用來對代碼語句進行切分。

第一個關鍵字let的起始位置是0,于是我們把語句從開始到關鍵字起始位置之間的内容抽取出來,構造一個text節點,由于第一個關鍵字的起始位置就是語句的起始位置,是以我們先構造一個空的text節點:

<text></text>           

然後我們把關鍵字let構造一個含有span标簽的節點:

<span style="color:green">let</span>           

第一個let關鍵字的結束位置是4,第二個關鍵字let的起始位置是15,是以我們把4到14之間的字元合在一起構造成一個text節點:

<text> five = 5; </text>           

然後把第二個關鍵字單獨建構成一個含有span标簽的節點:

<span style="color:green">let</span>           

第二個let關鍵字的結束位置是18,第三個關鍵字let的起始位置是28,是以我們把18到27之間的字元合在一起形成一個text節點:

<text> six = 6; </text>           

然後把第三個關鍵字let單獨建構成一個含有span标簽的節點:

<span style="color:green">let</span>           

第三個關鍵字let的結束位置為31,于是我們把32開始到字元串末尾之間的字元合成一個text節點:

<text> seven = 7;</text>           

接着我們把上面新生成的節點調用DOM API insertBefore全部插入到div節點之下:

<div>
<text></text>
<span style="color:green">let</span>
<text> five = 5; </text>
<span style="color:green">let</span>
<text> six = 6; </text>
<span style="color:green">let</span>
<text> seven = 7;</text>
<text>let five = 5; let six = 6; let seven = 7;</text>
</div>           

最後我們再把最後一個text節點給删除,得到下面的html代碼就具備了關鍵字高亮效果:

<div>
<text></text>
<span style="color:green">let</span>
<text> five = 5; </text>
<span style="color:green">let</span>
<text> six = 6; </text>
<span style="color:green">let</span>
<text> seven = 7;</text>
</div>           

我們看看上面算法的代碼實作,在MonkeyCompilerEditer.js中,添加如下代碼:

hightLightKeyWord(token, elementNode, begin, end) {
        var strBefore = elementNode.data.substr(this.lastBegin, 
                         begin - this.lastBegin)
        strBefore = this.changeSpaceToNBSP(strBefore)

        var textNode = document.createTextNode(strBefore)
        var parentNode = elementNode.parentNode
        parentNode.insertBefore(textNode, elementNode)


        var span = document.createElement('span')
        span.style.color = 'green'
        span.classList.add(this.keyWordClass)
        span.appendChild(document.createTextNode(token.getLiteral()))
        parentNode.insertBefore(span, elementNode)

        this.lastBegin = end - 1

        elementNode.keyWordCount--
        console.log(this.divInstance.innerHTML)
    }

changeSpaceToNBSP(str) {
        var s = ""
        for (var i = 0; i < str.length; i++) {
            if (str[i] === ' ') {
                s += '\u00a0'
            }
            else {
                s += str[i]
            }
        }

        return s;
    }
hightLightSyntax() {
        var i
        for (i = 0; i < this.keyWordElementArray.length; i++) {
            var e = this.keyWordElementArray[i]
            this.currentElement = e.node
            this.hightLightKeyWord(e.token, e.node, 
            e.begin, e.end)

            if (this.currentElement.keyWordCount === 0) {
                var end = this.currentElement.data.length
                var lastText = this.currentElement.data.substr(this.lastBegin, 
                                end)
                lastText = this.changeSpaceToNBSP(lastText)
                var parent = this.currentElement.parentNode
                var lastNode = document.createTextNode(lastText)
                parent.insertBefore(lastNode, this.currentElement)
                parent.removeChild(this.currentElement)
            }
        }
        this.keyWordElementArray = []
    }           

我們先看最後一個函數hightLightSyntax,它的if (this.currentElement.keyWordCount === 0)判斷裡面的代碼做的操作就是我們前面算法的最後一步,把最後一個text節點從div中删除。在for循環中,它從keyWordArray中取出回調函數存入的關鍵字資訊,然後調用hightLightKeyWord函數,這個函數的作用就是前面描述算法步驟中,根據關鍵字的起始和結束位置切割代碼字元串,并生成不同節點的過程。

我們看看hightLightKeyWord函數的實作邏輯。傳進來的參數begin代表關鍵字字元串的起始位置,end代表關鍵字字元串的結束位置。this.lastBegin一開始初始化為0,用來表示代碼字元串的起始位置。

var strBefore = elementNode.data.substr(this.lastBegin, 
                         begin - this.lastBegin)
strBefore = this.changeSpaceToNBSP(strBefore)

var textNode = document.createTextNode(strBefore)
var parentNode = elementNode.parentNode
parentNode.insertBefore(textNode, elementNode)           

上面代碼作用是,把關鍵字起始位置之前的所有字元抽出來形成一個字元串strBefore,然後調用DOM API createTextNode建構一個text節點,然後再插入div節點作為它的子節點。這裡有個函數需要強調就是changeSpaceToNBSP,當用字元串建構text節點時,如果字元串中有空格,那麼建構處理的text節點,裡面的字元串會自動把空格删掉,例如字元串:

five = 5;

如果建構text節點的話,中間兩個空格會被删掉,變成:

<text>five=5;</text>           

這樣一來,字元再跟原有顯示就跟原來不一樣了,為了保持字元串的原有樣貌,我們必須保留白格,處理這個問題的辦法是,把空格轉換成UNICODE空格編碼’\u00a0’,這樣當頁面顯示字元串時,當浏覽器讀取到編碼’\u00a0’,它就知道這裡是個空格,是以把字元串顯示在頁面上時,原有空格就會得以保留。

var span = document.createElement('span')
span.style.color = 'green'
span.classList.add(this.keyWordClass)
span.appendChild(document.createTextNode(token.getLiteral()))
parentNode.insertBefore(span, elementNode)           

上面這部分代碼的作用是為關鍵字字元串添加span标簽,使得它在頁面上展示時呈現出高亮的綠色。

this.lastBegin = end - 1
elementNode.keyWordCount--           

上面代碼作用是把lastBegin設定成目前字元串的結束位置減去1,那麼處理下個關鍵字字元串時,就可以把目前字元串結尾直到下一個關鍵字開始位置之間的字元集合起來形成一個字元串,以便生成下一個text節點。

上面代碼邏輯不好了解,請參看視訊中的代碼解讀和調試過程來加深了解:

更詳細的講解和代碼調試示範過程,請點選連結

由于文法高亮是即時顯示的,對于關鍵字”let”, 當使用者敲下前兩個字元”le”時,字元串還是黑色,一旦第三個字元’t’敲下之後,整個字元串就需要立馬轉換成綠色,為了即時性,我們必須在使用者每次敲擊鍵盤後,就立馬解析目前代碼,實作關鍵字高亮,是以我們需要在代碼中監聽鍵盤點選事件,于是需要繼續添加如下代碼,在MonkeyCompilerEditer.js中:

onDivContentChane(evt) {
        if (evt.key === 'Enter' || evt.key === " ") {
            return
        }

        var bookmark = undefined
        if (evt.key !== 'Enter') {
            bookmark = rangy.getSelection().getBookmark(this.divInstance)
        }

        var spans = document.getElementsByClassName(this.keyWordClass);
        while (spans.length) {
            var p = spans[0].parentNode;
            var t = document.createTextNode(spans[0].innerText)
            p.insertBefore(t, spans[0])
            p.removeChild(spans[0])
        }

        //把所有相鄰的text node 合并成一個
        this.divInstance.normalize();
        this.changeNode(this.divInstance)
        this.hightLightSyntax()

        if (evt.key !== 'Enter') {
            rangy.getSelection().moveToBookmark(bookmark)
        }

    }

    render() {
        let textAreaStyle = {
            height: 480,
            border: "1px solid black"
        };

        return (
            <div style={textAreaStyle} 
            onKeyUp={this.onDivContentChane.bind(this)}
            ref = {(ref) => {this.divInstance = ref}}
            contentEditable>
            </div>
            );
    }           

在render函數傳回的jsx中,我們在div控件中添加了onKeyUp消息的響應,一旦使用者點選鍵盤後,元件的onDivContentChane就會被調用。在onDivContentChane中,它先判斷目前使用者按下哪些按鍵,如果是回車或是空格,那麼直接傳回。在該函數中,使用到了一個外部控件叫rangy,這是google開發的一個元件,它的作用是記錄目前光标所在位置。我們實作文法高亮,其實是通過改變頁面的html代碼結構實作的。但這會帶來一個問題,假設使用者在編輯框裡敲下三個字元”let”, 此時光标會在字元t的後面閃爍,當實作高亮時,我們會在html中,給字元串”let”的前後分别加上标簽

<span style="color:green"></span>           

一旦内部html代碼發生改變後,附帶的一個效果是,光标會傳回到字元串的開頭去,如果每次實作關鍵字高亮時,光标總是從目前輸入位置傳回到開頭,那對使用者來說是不堪忍受的,是以我們使用rangy元件來保證内部html代碼改變後,光标能夠回到原來所在的位置,是以代碼:

var bookmark = undefined
        if (evt.key !== 'Enter') {
            bookmark = rangy.getSelection().getBookmark(this.divInstance)
        }           

其作用是先記錄目前光标所在的位置。後面對應代碼:

if (evt.key !== 'Enter') {
    rangy.getSelection().moveToBookmark(bookmark)
}           

它的作用是,當實作文法高亮後,把光标傳回到原來所在的位置。rangy元件的擷取可以在目前項目路徑下,通過控制台執行下面指令:

npm install rangy           

接着看餘下的代碼:

var spans = document.getElementsByClassName(this.keyWordClass);
while (spans.length) {
        var p = spans[0].parentNode;
        var t = document.createTextNode(spans[0].innerText)
        p.insertBefore(t, spans[0])
        p.removeChild(spans[0])
}           

this.keyWordClass 被初始化為字元串”keyword”,上面代碼的作用是,找到所有class屬性為”keyword”的節點。我們每次在關鍵字前添加span節點時,都會給這個節點賦予一個class屬性叫”keyword”,例如:

<span class="keyword" sytle="color:green">if</span>           

上面代碼把所有帶有”keyword”屬性的span節點找出來,并把這些節點删除掉。這麼做是因為,當使用者敲下第二個關鍵字時,第一個關鍵字就已經是高亮狀态了,假設第一個關鍵字是”if”, 第二個關鍵字是else, 那麼目前html代碼如下:

<span class="keyword" sytle="color:green">if</span><text>&nbsp;</text><text>else</text>           

此時第二個關鍵字”else”還沒有高亮,我們實作關鍵字高亮的政策是查找所有關鍵字字元串,并把他們包裹在”span”标簽中, 如果不事先把已經存在的span标簽删除的話,那麼就會出現一個關鍵字間套多個span标簽的情況,于是上面的html代碼在完成關鍵字高亮流程後會變成:

```
<span class="keyword" sytle="color:green"><span class="keyword" sytle="color:green">if</span></span><text>&nbsp;</text><span class="keyword" sytle="color:green">else</span>           

于是第一個關鍵字if就包含在兩個span标簽中,這是不必要的。是以代碼片段中的while把所有已經存在的span标簽去除掉,把html轉換成隻包含text标簽,于是例子中的html代碼經過while這段代碼的處理後變成如下情況:

<text>if</text><text>&nbsp;</text><text>else</text>           

接着的語句this.divInstance.normalize() 把所有相連的text節點合成一個,于是上面的html代碼就變成:

<text>if&nbsp;else</text>           

接着調用this.changeNode(this.divInstance)就開始了使用詞法解析器抽取關鍵字的流程,changeNode函數需要分析一下。

changeNode(n) {
      var f = n.childNodes; 
      for(var c in f) {
        this.changeNode(f[c]);
      }
      if (n.data) {
        console.log(n.parentNode.innerHTML)
        this.lastBegin = 0
        n.keyWordCount = 0;
        var lexer = new MonkeyLexer(n.data)
        lexer.setLexingOberver(this, n)
        lexer.lexing()
      } 
    }           

它包含着遞歸調用的邏輯,n是父節點,通過n.childNodes找到所有子節點,然後分别對每個子節點調用changeNode函數,直到某個子節點的data屬性不為空為止,先看下面這段html代碼:

<div>
  <div>
    <div><text>let</text></div>
  </div>
</div>           

上面html代碼中,div有三層箭頭,其中隻有最裡面的div是含有字元串的,也就是最裡面的div它的data屬性才不是空。changeNode會先找到最外層的div節點,然後通過childNodes找到第二層div節點,然後再次遞歸找到最裡面第三層的div節點,這時候找到的div節點,它的data屬性才包含了可供處理的有效字元串。

至此,整個即時性關鍵字文法高亮的算法邏輯和實作過程就解析完畢了,如果配合視訊,了解起來會更容易一些。

更詳細的講解和代碼調試示範過程,請點選連結

關鍵字即時高亮是一種技術難度不小的功能點,如果你用搜尋引擎查找的話,你會發現有一個專門的插件叫Prim是專門用來實作這個功能的。原本我也想直接使用這個插件實作高亮功能,這樣省事,但考慮到技術能力的真正提高,是需要足夠的編碼和思考設計才能得以實作,是以就自己從頭到尾做一次。如果誰能夠從頭到尾跟着完成這個功能點,那麼他的資料結構和算法能力,設計模式能力,DOM 樹狀模型的深入了解能力,都會得到相當程度的提升。

目前關鍵字高亮算法存在一個大問題是效率低,每當使用者輸入一個字元,所有的代碼就都得全部進行詞法解析,然後再把整個内部html改造一遍,如果編輯框中的代碼很多的話,這麼做是很浪費資源的,一個改進辦法是,當使用者輸入時,我們把使用者輸入的所在行拿出來解析就好,沒必要把編輯框裡所有内容都拿出來解析。

更多技術資訊,包括作業系統,編譯器,面試算法,機器學習,人工智能,請關照我的公衆号:

Reactjs開發自制程式設計語言Monkey的編譯器:高能技術幹貨之文法高亮2

繼續閱讀