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>?</text><span class="keyword" sytle="color:green">else</span>

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

<text>if</text><text>?</text><text>else</text>


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

<text>if?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屬性才包含了可供處理的有效字符串。

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

[更詳細的講解和代碼調試演示過程,請點擊鏈接](http://study.163.com/provider-search?keyword=Coding%E8%BF%AA%E6%96%AF%E5%B0%BC)

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

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

更多技術信息,包括操作系統,編譯器,面試算法,機器學習,人工智能,請關照我的公眾號:
![這里寫圖片描述](http://upload-images.jianshu.io/upload_images/2849961-d584a529f20a04da?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 求關注,求粉絲
    純瑩一一北原瑩子閱讀 269評論 0 1
  • 我是一個可怕的守財奴 我的小魚包里總是一不小心裝滿小東西 它們曾經的主人 我一個不落的記著 就等過節回家 清明節勞...
    磁軌炮閱讀 342評論 1 1
  • 拿到這可愛的盒子,發現很適合孩子們使用,外觀看起來像個文具盒十分惹人喜愛。盒子用起來以后想在盒子上安裝一些軟件,所...
    ckyyouknow閱讀 369評論 3 4