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)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,784评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,745评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,702评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,229评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,245评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,376评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,798评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,471评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,655评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,485评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,535评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,235评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,793评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,863评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,096评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,654评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,233评论 2 341

推荐阅读更多精彩内容

  • 求关注,求粉丝
    纯莹一一北原莹子阅读 242评论 0 1
  • 我是一个可怕的守财奴 我的小鱼包里总是一不小心装满小东西 它们曾经的主人 我一个不落的记着 就等过节回家 清明节劳...
    磁轨炮阅读 315评论 1 1
  • 拿到这可爱的盒子,发现很适合孩子们使用,外观看起来像个文具盒十分惹人喜爱。盒子用起来以后想在盒子上安装一些软件,所...
    ckyyouknow阅读 339评论 3 4