上一节,我们利用词法解析器加上观察者模式,实现了代码语句的抽取关键字功能,对于给定代码:
<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节点:
<@H_502_30@text></@H_502_30@text>
然后我们把关键字let构造一个含有span标签的节点:
<@H_502_30@span style="color:green">let</@H_502_30@span>
第一个let关键字的结束位置是4,第二个关键字let的起始位置是15,因此我们把4到14之间的字符合在一起构造成一个text节点:
<text> five = 5; </text>
然后把第二个关键字单独构建成一个含有span标签的节点:
<@H_502_30@span style="color:green">let</@H_502_30@span>
第二个let关键字的结束位置是18,第三个关键字let的起始位置是28,所以我们把18到27之间的字符合在一起形成一个text节点:
<text> six = 6; </text>
然后把第三个关键字let单独构建成一个含有span标签的节点:
<@H_502_30@span style="color:green">let</@H_502_30@span>
第三个关键字let的结束位置为31,于是我们把32开始到字符串末尾之间的字符合成一个text节点:
<text> seven = 7;</text>
接着我们把上面新生成的节点调用DOM API insertBefore全部插入到div节点之下:
<@H_502_30@div>
<@H_502_30@text></@H_502_30@text>
<@H_502_30@span style="color:green">let</@H_502_30@span>
<@H_502_30@text> five = 5; </@H_502_30@text>
<@H_502_30@span style="color:green">let</@H_502_30@span>
<@H_502_30@text> six = 6; </@H_502_30@text>
<@H_502_30@span style="color:green">let</@H_502_30@span>
<@H_502_30@text> seven = 7;</@H_502_30@text>
<@H_502_30@text>let five = 5; let six = 6; let seven = 7;</@H_502_30@text>
</@H_502_30@div>
最后我们再把最后一个text节点给删除,得到下面的HTML代码就具备了关键字高亮效果:
<@H_502_30@div>
<@H_502_30@text></@H_502_30@text>
<@H_502_30@span style="color:green">let</@H_502_30@span>
<@H_502_30@text> five = 5; </@H_502_30@text>
<@H_502_30@span style="color:green">let</@H_502_30@span>
<@H_502_30@text> six = 6; </@H_502_30@text>
<@H_502_30@span style="color:green">let</@H_502_30@span>
<@H_502_30@text> seven = 7;</@H_502_30@text>
</@H_502_30@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 - @H_502_30@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”的前后分别加上标签
<@H_502_30@span style="color:green"></@H_502_30@span>
一旦内部HTML代码发生改变后,附带的一个效果是,光标会返回到字符串的开头去,如果每次实现关键字高亮时,光标总是从当前输入位置返回到开头,那对用户来说是不堪忍受的,因此我们使用rangy组件来保证内部HTML代码改变后,光标能够回到原来所在的位置,所以代码:
var bookmark = undefined
if (evt.key !== 'Enter') {
bookmark = rangy.getSelection().getBookmark(this.divInstance)
}
其作用是先记录当前光标所在的位置。后面对应代码:
@H_502_30@if (evt.key !== 'Enter') { @H_502_30@rangy.@H_502_30@getSelection().@H_502_30@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> </text><text>else</text>
此时第二个关键字”else”还没有高亮,我们实现关键字高亮的策略是查找所有关键字字符串,并把他们包裹在”span”标签中,如果不事先把已经存在的span标签删除的话,那么就会出现一个关键字间套多个span标签的情况,于是上面的HTML代码在完成关键字高亮流程后会变成:
```
<@H_502_30@span class="keyword" sytle="color:green"><@H_502_30@span class="keyword" sytle="color:green">if</@H_502_30@span></@H_502_30@span><@H_502_30@text> </@H_502_30@text><@H_502_30@span class="keyword" sytle="color:green">else</@H_502_30@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代码:
<@H_502_30@div>
<@H_502_30@div>
<@H_502_30@div><@H_502_30@text>let</@H_502_30@text></@H_502_30@div>
</@H_502_30@div>
</@H_502_30@div>
上面HTML代码中,div有三层箭头,其中只有最里面的div是含有字符串的,也就是最里面的div它的data属性才不是空。changeNode会先找到最外层的div节点,然后通过childNodes找到第二层div节点,然后再次递归找到最里面第三层的div节点,这时候找到的div节点,它的data属性才包含了可供处理的有效字符串。
至此,整个即时性关键字语法高亮的算法逻辑和实现过程就解析完毕了,如果配合视频,理解起来会更容易一些。
关键字即时高亮是一种技术难度不小的功能点,如果你用搜索引擎查找的话,你会发现有一个专门的插件叫Prim是专门用来实现这个功能的。原本我也想直接使用这个插件实现高亮功能,这样省事,但考虑到技术能力的真正提高,是需要足够的编码和思考设计才能得以实现,因此就自己从头到尾做一次。如果谁能够从头到尾跟着完成这个功能点,那么他的数据结构和算法能力,设计模式能力,DOM 树状模型的深入理解能力,都会得到相当程度的提升。
当前关键字高亮算法存在一个大问题是效率低,每当用户输入一个字符,所有的代码就都得全部进行词法解析,然后再把整个内部html改造一遍,如果编辑框中的代码很多的话,这么做是很浪费资源的,一个改进办法是,当用户输入时,我们把用户输入的所在行拿出来解析就好,没必要把编辑框里所有内容都拿出来解析。
更多技术信息,包括操作系统,编译器,面试算法,机器学习,人工智能,请关照我的公众号: