天天看點

Reactjs開發自制程式設計語言Monkey的編譯器:使用元件的state機制實作螢幕取詞

上一節我們完成了文法關鍵字高亮的功能。基本思路是,每當使用者在編輯控件中輸入字元時,元件就把控件裡的代碼送出給詞法解析器,解析器分析出代碼中關鍵字字元串的起始和結束位置,然後為每一個關鍵字字元串間套一個span标簽,同時把span标簽的字型屬性設定成綠色,于是被span标簽包裹的關鍵字就可以顯示出綠色高亮了。

然而這種做法存在一個嚴重問題,就在于如果每輸入一個字元,解析器就得把所有代碼重新解析一遍,如果目前代碼量很大,那麼這種辦法效率就相當低下。這裡我們先解決這個問題。事實上,當使用者輸入代碼時,受到影響的隻不過是目前所輸入那行,其他行是沒有變化的,是以我們隻需要擷取使用者目前輸入那一行代碼,單就這一行代碼進行詞法解析,然後就這一行重新實作文法高亮,那麼整體效率就能夠得到極大的提升了。

由此,我們目前面臨一個難題是,如何得到目前正在輸入的那一行資訊。我們的編輯控件是一個div元件,一開始,元件中沒有任何内容,如果我們向它輸入一行字元串”let g = 0;”,那麼div元件下的html内容如下:

<div>
  <text>let g = 0</text>
</div>
           

如果接着我們按下Enter鍵,換一行後,再輸入字元串”let f = 1;”,那麼此時div控件裡面的html會變成如下格式:

<div>
  <text>let g = 0</text>
  <div><text>let f = 1;</text></div>
</div>
           

新的一行則包裹在另一個div标簽中,我們可以利用這個特性,實作将滑鼠所在的那行資訊抽取出來。每當有輸入到div控件時,我們就檢測目前所在的text節點,它是否包含一在一個span節點中,如果沒有,那麼我們就為其添加一個span節點,當我們想要抽取某一行的資訊時,我們就找到對應的span節點,把該節點包裹的資訊拿出來就可以了,例如上面的html代碼,我們需要改造成如下形式:

(為了簡便,我們暫時忽略關鍵字應該包裹在一個span标簽裡)

<div>
  <span class="LineSpan line0"><text>let g = 0</text></span>
  <div><span class="LineSpan line1"><text>let f = 1;</text></span></div>
</div>
           

這樣一來,當我們想要獲得第一行的字元串,我們隻要查找屬性含有line0的span元素,從該元素的子節點中就可以得到第一行的内容。于是獲得滑鼠所在行字元串的代碼實作如下:

getCaretLineNode() {
        var sel = document.getSelection()
        //得到光标所在行的node對象
        var nd = sel.anchorNode
        //檢視其父節點是否是span,如果不是,
        //我們插入一個span節點用來表示光标所在的行
        var elements = document.getElementsByClassName(this.lineSpanNode)
        for (var i = ; i < elements.length; i++) {
            var element = elements[i]
            if (element.contains(nd)) {
                while (element.classList.length > ) {
                    element.classList.remove(element.classList.item())
                }
                element.classList.add(this.lineSpanNode)
                element.classList.add(this.lineNodeClass + i)
                return element
            }
        }

        //計算一下目前光标所在節點的前面有多少個div節點,
        //前面的div節點數就是光标所在節點的行數
        var divElements = this.divInstance.childNodes;
        var l = ;
        for (var i = ; i < divElements.length; i++) {
            if (divElements[i].contains(nd)) {
                l = i;
                break;
            }
        }

        var spanNode = document.createElement('span')
        spanNode.classList.add(this.lineSpanNode)
        spanNode.classList.add(this.lineNodeClass + l)
        nd.parentNode.replaceChild(spanNode, nd)
        spanNode.appendChild(nd)
        return spanNode
    }
           

document.getSelection() 獲得目前光标閃爍時所在的節點,也就是代碼中的nd, 接着我們找出所有含有屬性為”LineSpan”的span節點,其中this.lineSpanNode對應的就是字元串”LineSpan”,接着對每一個span元素,看看它的子元素是否包含光标所在的元素nd,如果包含了,那表明目前行已經成功添加了span父節點,同時計算目前元素前面的span節點有幾個,進而得出當光标在第幾行,因為每一行所在行數其實是動态可變的,如果目前行是第3行,我們在上一行按回車,然後添加一行,那麼原來的第3行就得變成第4行,代碼最開始的for循環就是要檢測這種情況。

如果目前光标所在元素沒有一個對應的span父節點,那麼我們就得為目前行增加一個span父節點,此時我們先找出所有div節點,每一個div節點意味着一行,通過計算包含目前光标節點的div節點前面有幾個div節點,我們就可以确定目前光标在第幾行。接着我們構造一個新的span節點,并為該節點添加相應的class屬性,然後把目前光标所在節點當做span節點的子節點添加到DOM中。

接下來修改onDivContentChane函數,當每次有按鍵輸入時,我們不再将所有代碼送出給詞法解析器去分析,而是把光标所在行的字元串抽出來,送出給解析器去分析,這樣效率就可以大大提升了,代碼修改如下:

onDivContentChane(evt) {
....
var currentLine = this.getCaretLineNode()
....
this.changeNode(currentLine)
....
}
           

接下來,我們要完成一個特性是實作螢幕取詞功能,如果你使用VS或Eclipse進行單步代碼調試時,你把滑鼠挪動到某個變量字元串上,那麼IDE會彈出一個視窗,給你顯示出滑鼠所在變量的值或相關資訊。此外不少翻譯軟體,當你把滑鼠挪動到某個單詞上時,界面會在滑鼠旁邊彈出一個視窗,顯示該單詞的中文解釋,這種功能就叫做滑鼠取詞,完成後,我們頁面效果如下:

Reactjs開發自制程式設計語言Monkey的編譯器:使用元件的state機制實作螢幕取詞

當我們把滑鼠挪動到變量f上時,在滑鼠旁邊彈出一個視窗,裡面顯示的是f這個變量對應的token資訊。右邊彈出的視窗是由bootstrap元件popover來實作的。實作這個功能的基本思路如下:

1, 解析代碼,确定代碼中類型為IDENTIFIER字元串的起始和結束位置。

2, 在根據起始和結束位置,我們給該字元串添加一個span父節點

3, 把目前變量字元串對應的token對象和添加的span父節點對象關聯起來。

4,相應span節點的mouseenter 和 mouseleave消息.

5,一旦滑鼠挪動到字元串上時,span節點的mouseenter事件觸發,我們響應該事件時,彈出popover視窗,一旦滑鼠離開我們就關閉popover視窗。

第一步的實作與我們前面實作的關鍵字高亮算法是一樣的,隻不過有些環節需要處理。一種情況是,目前輸入行不含關鍵字時,例如:

上面代碼行對應的html代碼如下:

<text>five = ; six = ; seven = ;</text>
           

我們用詞法解析器解析改行代碼,得到三個變量five , six , seven的起始和結束位置,通過這些位置給他們插入span标簽:

如果目前代碼行包含關鍵字的話,那就得特殊處理,例如語句:

let five = ; let six = ; let seven = 
           

它對應的html代碼為:

由于關鍵字高亮時,程式會把夾在關鍵字中的代碼切割成若幹部分,就像上面那樣,這種情況,我們就需要把上面各個text标簽包裹的字元串送出給詞法解析器去解析,于是相關代碼修改如下:

changeNode(n) {
      var f = n.childNodes; 
      for(var c in f) {
        this.changeNode(f[c]);
      }
      if (n.data) {
        this.lastBegin = 
        n.keyWordCount = 
        //change here
        n.identifierCount = 
        var lexer = new MonkeyLexer(n.data)
        this.lexer = lexer
        lexer.setLexingOberver(this, n)
        lexer.lexing()
      } 
    }

notifyTokenCreation(token, elementNode, begin, end) {
        // change here
        var e = {}
        e.node = elementNode
        e.begin = begin
        e.end = end
        e.token = token

        if (this.keyWords[token.getLiteral()] !== undefined) {
            elementNode.keyWordCount++;
            this.keyWordElementArray.push(e)
        }

        if (elementNode.keyWordCount ==  && token.getType() === this.lexer.IDENTIFIER) {
            elementNode.identifierCount++
            this.identifierElementArray.push(e)
        }
    }
           

每當解析器解析到一個token時,代碼會檢測目前token類型是否是IDENTIFIER,如果是,并且目前代碼不包含關鍵字,也就是elementNode.keyWordCount == 0, 那麼就把目前解析得到的相關資訊壓入數組identifierElementArray。在給關鍵字添加span标簽時,我們會把夾在關鍵字中的其他代碼字元串單獨建立成一個text節點,這些text節點中很可能包含了IDENTIFIER類型的變量,于是我們需要把這些節點送出給解析器去分析,是以代碼修改如下:

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)
  //change here
  this.textNodeArray.push(textNode)
  ....
}

hightLightSyntax() {
  var i
  //change here
  this.textNodeArray = []
  for (i = ; i < this.keyWordElementArray.length; i++) {
     ....
     if (this.currentElement.keyWordCount === ) {
     if (this.currentElement.keyWordCount === ) {
         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)
         // change here
        // 解析最後一個節點,這樣可以為關鍵字後面的變量字元串設立popover控件
         this.textNodeArray.push(lastNode)
         parent.removeChild(this.currentElement)
  }
  ....
}
           

上面代碼是原來用于解析關鍵字并實作高亮效果的,我們增加一些新代碼,目的就是把關鍵字解析時,夾在關鍵字中的代碼送出給詞法解析器解析,并識别出其中的表示變量的字元串,把這些字元串及其對應的token收集到數組textNodeArray中,這些資訊收集完畢後,我們就可以實作螢幕取詞功能了,代碼如下:

addPopoverSpanToIdentifier(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.onmouseenter = (this.handleIdentifierOnMouseOver).bind(this)
        span.onmouseleave = (this.handleIdentifierOnMouseOut).bind(this)
        span.classList.add(this.identifierClass)
        span.appendChild(document.createTextNode(token.getLiteral()))
        span.token = token
        parentNode.insertBefore(span, elementNode)
        this.lastBegin = end - 
        elementNode.identifierCount--
    }

    addPopoverByIdentifierArray() {
        //該函數的邏輯跟hightLightSyntax一摸一樣
        for (var i = ; i < this.identifierElementArray.length; i++) {
            //用 span 将每一個變量包裹起來,這樣滑鼠挪上去時就可以彈出popover控件
            var e = this.identifierElementArray[i]
            this.currentElement = e.node
            //找到每個IDENTIFIER類型字元串的起始和末尾,給他們添加span标簽
            this.addPopoverSpanToIdentifier(e.token, e.node, 
            e.begin, e.end)

            if (this.currentElement.identifierCount === ) {
                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.identifierElementArray = []
    }
    preparePopoverForIdentifers() {
        if (this.textNodeArray.length > ) {
            for (var i = ; i < this.textNodeArray.length; i++) {
                //将text 節點中的文本送出給詞法解析器抽取IDENTIFIER
                this.changeNode(this.textNodeArray[i])
                //為解析出的IDENTIFIER字元串添加滑鼠取詞功能
                this.addPopoverByIdentifierArray()
            }
            this.textNodeArray = []
        } else {
            this.addPopoverByIdentifierArray()
        }

    }
           

上面代碼的邏輯與實作關鍵詞高亮幾乎是一模一樣的。都是把相應字元串抽出來,給它用一個span标簽給包裹上,同時我們添加對span标簽兩種事件的響應,一個是mouseenter消息,也就是當滑鼠挪動到span标簽時産生的事件,靈感是mouseleave,也就是滑鼠挪開span标簽時,我們需要響應的事件。于是當mouseenter發生時,我們就可以在滑鼠旁邊彈出popover控件,當mouseleave發送時,我們就把popover控件給關閉掉,這樣一來我們就可以實作螢幕取詞的效果了。

Reactjs開發自制程式設計語言Monkey的編譯器:使用元件的state機制實作螢幕取詞

現在我們看看上面的popover控件是如何彈出的,由于它是boostrap提供的控件,是以我們在元件的render()函數中需要把它添加進來:

render() {
        let textAreaStyle = {
            height: ,
            border: "1px solid black"
        };
        //change here
        return (
            <div>
              <div style={textAreaStyle} 
              onKeyUp={this.onDivContentChane.bind(this)}
              ref = {(ref) => {this.divInstance = ref}}
              contentEditable>
              </div>

               <bootstrap.Popover placement = {this.state.popoverStyle.placement}
               positionLeft = {this.state.popoverStyle.positionLeft}
               positionTop = {this.state.popoverStyle.positionTop}
               title = {this.state.popoverStyle.title}
                >
                  {this.state.popoverStyle.content}
                </bootstrap.Popover>
            </div>
            );
    }
           

注意看,代碼中用到一個對象叫this.state,這是reactjs元件一個相當重要的内置成分,它與上節我們提到的props屬性相當。單頁應用開發有一個難點就在于如何讓程式底層資料與外在界面的展示實作實時關聯。比如說我在程式底層有一個資料叫counter, 它的值是1,在頁面上就可以把這個值顯示出來。如果程式運作時,counter 的值變成了2,在變化的那一刻頁面上顯示的資訊也要立刻變成2,這種底層資料和外層UI的實時關聯是是以web架構都必須解決的問題,reactjs解決這個難題依賴的就是state内置變量。

大家看上面代碼,popover控件的很多屬性是跟state内部的變量綁定起來的,例如:

positionTop = {this.state.popoverStyle.positionTop}
           

也就是popover控件顯示時的高度跟state變量裡面的popoverStyle.positionTop這個變量綁定一起了。這樣就産生了一種關聯效果,如果this.state.popoverStyle.positionTop的值是10,那麼popover控件在頁面上顯示時,它的高度是10px處,如果我們在代碼中改變this.state.popoverStyle.positionTop的值,使他變成20,這個改動就會裡面反應到頁面顯示上,也就是popover控件的窗體會自動下架10個機關,在高度為20px的位置上顯示。這種關聯性能極大的降低我們開發程式的難度,更詳細的講解和代碼調試示範過程,請點選連結。

在元件啟動時,我們先把popover窗體挪動到界面之外,讓使用者看不到它的存在,一旦使用者把滑鼠挪動到某個變量字元串上時,包裹着變量字元串的span它會觸發mouseenter事件,在響應該事件時,我們得到滑鼠目前所在的位置,然後把popover控件挪動到滑鼠旁邊,并把popover控件中的資訊顯示成變量對應的token,相關代碼如下:

constructor(props) {
  super(props)
  ....
  // change here
  this.identifierElementArray = []
  this.textNodeArray = []
  this.lineNodeClass = 'line'
  this.lineSpanNode = 'LineSpan'
  this.identifierClass = "Identifier"
  this.spanToTokenMap = {}
  this.initPopoverControl()
}

initPopoverControl() {
  this.state = {}
  this.state.popoverStyle = {}
  this.state.popoverStyle.placement = "right"
  this.state.popoverStyle.positionLeft = -
  this.state.popoverStyle.positionTop = -
  this.state.popoverStyle.content = ""
  this.setState(this.state)
}

 // change here
 handleIdentifierOnMouseOver(e) {
        e.currentTarget.isOver = true
        var token = e.currentTarget.token
        this.state.popoverStyle.positionLeft = e.clientX + 
        this.state.popoverStyle.positionTop = e.currentTarget.offsetTop - e.currentTarget.offsetHeight
        this.state.popoverStyle.title = "Syntax"
        this.state.popoverStyle.content = "name:" + token.getLiteral() + "\nType:" + token.getType()
        + "\nLine:" + e.target.parentNode.classList[]
        this.setState(this.state)
    }

  handleIdentifierOnMouseOut(e) { 
     this.initPopoverControl()
  }
           

在元件初始化時,我們先調用initPopoverControl()函數,該函數是對this.state.popoverStyle對象的初始化,設定為相關内容後,這裡一定要注意,修改完state變量的内容後,一定要調用setState函數,把修改後的state對象送出給reactjs架構。

我們前面說過,元件的state對象是内置的,它用來把底層資料跟外層UI綁定起來,如果它改變了,外層UI會根據改變後的底層資料進行顯示,但代碼内部改變state變量的内容後,必須調用setState函數通知reactjs架構,這樣架構才能及時幫我們更新與底層資料綁定的UI展示。

在上面代碼中,我們把popover控件的placement, positionLeft, positionTop三個屬性跟state對象中的state.popoverStyle.placement, state.popoverStyle.positionLeft, state.popoverStyle.positionRight綁定起來,state變量部分的資料變動後,通過setState()送出給架構,那麼popover 控件的相關屬性就會自動改變,進而控件窗體會在頁面上根據資料的改變而作相應的變動。

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

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

Reactjs開發自制程式設計語言Monkey的編譯器:使用元件的state機制實作螢幕取詞