上一节我们完成了语法关键字高亮的功能。基本思路是,每当用户在编辑控件中输入字符时,组件就把控件里的代码提交给词法解析器,解析器分析出代码中关键字字符串的起始和结束位置,然后为每一个关键字字符串间套一个span标签,同时把span标签的字体属性设置成绿色,于是被span标签包裹的关键字就可以显示出绿色高亮了。
然而这种做法存在一个严重问题,就在于如果每输入一个字符,解析器就得把所有代码重新解析一遍,如果当前代码量很大,那么这种办法效率就相当低下。这里我们先解决这个问题。事实上,当用户输入代码时,受到影响的只不过是当前所输入那行,其他行是没有变化的,因此我们只需要获取用户当前输入那一行代码,单就这一行代码进行词法解析,然后就这一行重新实现语法高亮,那么整体效率就能够得到极大的提升了。
由此,我们当前面临一个难题是,如何得到当前正在输入的那一行信息。我们的编辑控件是一个div组件,一开始,组件中没有任何内容,如果我们向它输入一行字符串”let g = 0;”,那么div组件下的html内容如下:
<div>
<text>let g = 0</text>
</div>
如果接着我们按下回车键,换一行后,再输入字符串”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 = 0; i < elements.length; i++) {
var element = elements[i]
if (element.contains(nd)) {
while (element.classList.length > 0) {
element.classList.remove(element.classList.item(0))
}
element.classList.add(this.lineSpanNode)
element.classList.add(this.lineNodeClass + i)
return element
}
}
//计算一下当前光标所在节点的前面有多少个div节点,
//前面的div节点数就是光标所在节点的行数
var divElements = this.divInstance.childNodes;
var l = 0;
for (var i = 0; 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会弹出一个窗口,给你显示出鼠标所在变量的值或相关信息。此外不少翻译软件,当你把鼠标挪动到某个单词上时,界面会在鼠标旁边弹出一个窗口,显示该单词的中文解释,这种功能就叫做鼠标取词,完成后,我们页面效果如下:
当我们把鼠标挪动到变量f上时,在鼠标旁边弹出一个窗口,里面显示的是f这个变量对应的token信息。右边弹出的窗口是由bootstrap组件popover来实现的。实现这个功能的基本思路如下:
1, 解析代码,确定代码中类型为IDENTIFIER字符串的起始和结束位置。 2, 在根据起始和结束位置,我们给该字符串添加一个span父节点 3, 把当前变量字符串对应的token对象和添加的span父节点对象关联起来。 4,相应span节点的mouseenter 和 mouseleave消息. 5,一旦鼠标挪动到字符串上时,span节点的mouseenter事件触发,我们响应该事件时,弹出popover窗口,一旦鼠标离开我们就关闭popover窗口。
第一步的实现与我们前面实现的关键字高亮算法是一样的,只不过有些环节需要处理。一种情况是,当前输入行不含关键字时,例如:
five = 5; six = 6; seven = 7;
上面代码行对应的html代码如下:
<text>five = 5; six = 6; seven = 7;</text>
我们用词法解析器解析改行代码,得到三个变量five , six , seven的起始和结束位置,通过这些位置给他们插入span标签:
<text><span class="Identifier">five</span> = 5; <span class="Identifier">six</span> = 6; <span class="Identifier">seven</span> = 7;</text>
如果当前代码行包含关键字的话,那就得特殊处理,例如语句:
let five = 5; let six = 6; let seven = 7
它对应的html代码为:
<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标签包裹的字符串提交给词法解析器去解析,于是相关代码修改如下:
changeNode(n) {
var f = n.childNodes;
for(var c in f) {
this.changeNode(f[c]);
}
if (n.data) {
this.lastBegin = 0
n.keyWordCount = 0
//change here
n.identifierCount = 0
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 == 0 && 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 = 0; i < this.keyWordElementArray.length; i++) {
....
if (this.currentElement.keyWordCount === 0) {
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)
// 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 - 1
elementNode.identifierCount--
}
addPopoverByIdentifierArray() {
//该函数的逻辑跟hightLightSyntax一摸一样
for (var i = 0; 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 === 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.identifierElementArray = []
}
preparePopoverForIdentifers() {
if (this.textNodeArray.length > 0) {
for (var i = 0; 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控件给关闭掉,这样一来我们就可以实现屏幕取词的效果了。
现在我们看看上面的popover控件是如何弹出的,由于它是boostrap提供的控件,因此我们在组件的render()函数中需要把它添加进来:
render() {
let textAreaStyle = {
height: 480,
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 = -100
this.state.popoverStyle.positionTop = -100
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 + 5
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[1]
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 控件的相关属性就会自动改变,从而控件窗体会在页面上根据数据的改变而作相应的变动。