第4章:基于正则的语法高亮器

说实话,正则表达式这东西,我刚入行时觉得又爱又恨。爱的是它一行代码能顶别人几十行,恨的是调试起来简直要命。但做语法高亮,正则绝对是最趁手的工具。

这一章,我们就手写一个基于正则的语法高亮器。不依赖任何第三方库,纯原生JavaScript搞定。

4.1 高亮函数的核心设计

先想清楚我们要做什么。输入是一段代码字符串,输出是带标签的HTML。每个标签包裹着不同类型的语法单元,比如关键字、字符串、注释。

我个人的习惯是,先搭一个骨架函数:

function highlight(code) {
  // 1. 转义HTML特殊字符
  // 2. 按规则匹配并替换
  // 3. 返回带span的HTML
}

为什么要先转义?你想想看,如果代码里有个<script>,直接塞进HTML里,浏览器会把它当成标签解析。这坑我踩过,输出页面直接乱掉。

转义函数很简单:

function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

4.2 匹配关键字

关键字匹配是最直观的。比如JavaScript里的functionvarifelse这些。

正则怎么写?

const KEYWORDS = /\b(function|var|let|const|if|else|return|for|while)\b/g;

注意那个\b,表示单词边界。没有它,functionality里的function也会被匹配到。我曾经在项目里漏掉这个,结果高亮出来的代码花花绿绿的,完全没法看。

替换的时候,我们给匹配到的关键字包上

code = code.replace(KEYWORDS, '<span class="keyword">$1</span>');
小技巧: 关键字列表可以做成配置项,方便扩展不同语言。我一般会维护一个对象,key是语言名,value是关键字数组。

4.3 匹配字符串

字符串匹配稍微复杂点。单引号、双引号、模板字符串都得考虑。

先处理双引号字符串:

const STRING_DOUBLE = /"([^"\\]*(\\.[^"\\]*)*)"/g;

这个正则看着有点吓人,其实逻辑很简单:

  • " 匹配开头引号
  • [^"\\]* 匹配非引号、非反斜杠的字符
  • (\\.[^"\\]*)* 匹配转义序列,比如 \"\\
  • 最后 " 匹配结尾引号

单引号同理,把双引号换成单引号就行:

const STRING_SINGLE = /'([^'\\]*(\\.[^'\\]*)*)'/g;

替换时注意顺序。我建议先处理字符串,再处理关键字。为什么?因为字符串里的关键字不应该被高亮。比如:

var str = "function is a keyword";

这里的function是字符串内容,不是关键字。如果先匹配关键字,它就会被错误高亮。

注意: 正则匹配顺序很重要!先匹配注释和字符串,再匹配关键字,可以避免很多误伤。

4.4 匹配注释

注释分单行和多行两种。

单行注释:

const LINE_COMMENT = /\/\/.*/g;

多行注释:

const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;

这里用了[\s\S]来匹配任意字符,包括换行。问号表示非贪婪匹配,避免匹配到两个注释之间的内容。

嗯,这里要注意:多行注释的正则如果写错了,可能导致整个文件高亮失败。我遇到过最离谱的一次,是正则写成了贪婪模式,结果一个文件里两个注释之间的所有代码都被当成注释了。

4.5 组装完整的高亮函数

现在我们把所有部分拼起来:

function highlight(code) {
  // 先转义
  let html = escapeHtml(code);
  
  // 匹配顺序:注释 > 字符串 > 关键字
  html = html
    .replace(BLOCK_COMMENT, '<span class="comment">$&</span>')
    .replace(LINE_COMMENT, '<span class="comment">$&</span>')
    .replace(STRING_DOUBLE, '<span class="string">$&</span>')
    .replace(STRING_SINGLE, '<span class="string">$&</span>')
    .replace(KEYWORDS, '<span class="keyword">$1</span>');
  
  return html;
}

注意替换顺序。注释优先,然后是字符串,最后是关键字。这样能最大程度避免误匹配。

4.6 生成带span标签的HTML

有了高亮函数,生成HTML就简单了。把代码放进<pre><code>里,再调用highlight

const code = `function hello() {
  // 打印问候语
  var msg = "Hello, World!";
  console.log(msg);
}`;

document.getElementById('output').innerHTML = 
  '<pre><code>' + highlight(code) + '</code></pre>';

配上CSS样式,效果就出来了:

.keyword { color: #d73a49; font-weight: bold; }
.string { color: #032f62; }
.comment { color: #6a737d; font-style: italic; }

4.7 知识体系总览

下面这张图,帮你理清整个高亮器的核心逻辑:

原始代码字符串 escapeHtml() 转义特殊字符 正则匹配流程(顺序重要!) ① 匹配注释 ② 匹配字符串 ③ 匹配关键字 注释 > 字符串 > 关键字,避免字符串内的关键字被误高亮 每个匹配项用 <span class="xxx"> 包裹 带 span 标签的 HTML 代码

4.8 实际项目中的坑

讲几个我踩过的坑:

  • 正则回溯问题: 复杂的正则可能导致性能问题。比如匹配多行注释时,如果文件很大,非贪婪匹配也可能很慢。我建议对超大文件做分段处理。
  • 嵌套注释: 有些语言支持嵌套注释,比如/* /* */ */。正则很难处理这种情况,需要写解析器。对于简单高亮器,我一般忽略嵌套。
  • 模板字符串: JavaScript的模板字符串里可以嵌套表达式,比如`Hello ${name}`。这超出了正则的能力范围,需要更复杂的解析。
核心要点: 基于正则的语法高亮器,优点是简单、轻量、快速实现。缺点是处理复杂语法时力不从心。对于教学工具、小型项目完全够用。大型IDE还是得用词法分析器。

好了,这一章的内容就到这里。代码示例都在上面,你可以直接复制运行。下一章我们会聊更高级的解析方式,敬请期待。


专注资料整理