代码高亮实现:不用 highlight.js 也能着色

代码高亮看起来神奇,原理其实很简单:按优先级识别几种 token,套上带颜色的 span

一、先认识 token 类型

一段代码里需要区分的东西就这几类:

token例子处理优先级
注释# xxx // xxx最高(注释里的内容不该被识别为关键字)
字符串"xxx" 'xxx'` | 高
数字123 0x1f
变量$var ${var}
关键字if for server
标志--flag -v

二、逐字符扫描

不要试图用正则一把梭——嵌套和转义会让你崩溃。正确做法是从左到右扫描,遇到什么处理什么

js
function highlight(code, lang) {
  var out = "";
  var i = 0;

  while (i < code.length) {
    var ch = code[i];

    // 1. 注释:从注释符到行尾,整段吞掉
    if (ch === "#" || code.substr(i, 2) === "//") {
      var end = code.indexOf("\n", i);
      if (end === -1) end = code.length;
      out += span("comment", code.slice(i, end));
      i = end;
      continue;
    }

    // 2. 字符串:找到配对的结束引号
    if (ch === "\"" || ch === "'") {
      var j = i + 1;
      while (j < code.length && code[j] !== ch) {
        if (code[j] === "\\") j++;   // 跳过转义字符
        j++;
      }
      out += span("string", code.slice(i, j + 1));
      i = j + 1;
      continue;
    }

    // 3. 数字
    if (/[0-9]/.test(ch) && !/\w/.test(code[i - 1] || "")) {
      var m = code.slice(i).match(/^\d+(\.\d+)?/);
      out += span("number", m[0]);
      i += m[0].length;
      continue;
    }

    // 4. 标识符:可能是关键字
    var w = code.slice(i).match(/^[A-Za-z_$][\w$]*/);
    if (w) {
      var word = w[0];
      out += keywords.indexOf(word) >= 0
        ? span("keyword", word)
        : escapeHtml(word);
      i += word.length;
      continue;
    }

    // 5. 其他字符原样输出
    out += escapeHtml(ch);
    i++;
  }
  return out;
}

function span(cls, text) {
  return "<span class=\"tok-" + cls + "\">" + escapeHtml(text) + "</span>";
}

三、关键字表按语言分

js
var KEYWORDS = {
  bash: ["if","then","else","fi","for","while","do","done","case","esac",
         "function","return","export","local","echo","cd","sudo"],

  nginx: ["server","location","listen","server_name","root","index",
          "proxy_pass","upstream","include","ssl_certificate","return",
          "if","set","add_header","try_files"],

  js: ["const","let","var","function","return","if","else","for","while",
       "class","new","await","async","import","export","try","catch"],

  json: ["true","false","null"],

  yaml: ["true","false","null","yes","no"],
};

不用追求完整的关键字表。把常用的一二十个加上,视觉上就已经「像高亮」了

四、YAML/TOML 这类配置文件的键名

配置文件里最有辨识度的是键名,可以单独着色:

js
// 行首的 key: 形式
var m = line.match(/^(\s*)([A-Za-z_][\w.-]*)(\s*:)/);
if (m) {
  out += m[1] + span("variable", m[2]) + m[3];
}

五、与 Markdown 解析器的配合

关键点:高亮在转义之后

js
function codeBlock(code, lang) {
  var body = highlight(code, lang);   // 内部会 escapeHtml
  return "<div class=\"code-block\">"
       +   "<div class=\"code-bar\">"
       +     "<span class=\"code-lang\">" + (lang || "text") + "</span>"
       +     "<button class=\"copy-btn\" data-copy>复制</button>"
       +   "</div>"
       +   "<pre><code>" + body + "</code></pre>"
       + "</div>";
}

这样代码块里的内容不会被二次解析,也不会有 XSS 风险。

六、配色:用 CSS 变量

css
:root {
  --tok-keyword: #cf222e;
  --tok-string:  #0a3069;
  --tok-comment: #6e7781;
  --tok-number:  #0550ae;
  --tok-variable:#953800;
  --code-bg:     #f6f8fa;
}

[data-theme="dark"] {
  --tok-keyword: #ff7b72;
  --tok-string:  #a5d6ff;
  --tok-comment: #8b949e;
  --tok-number:  #79c0ff;
  --tok-variable:#ffa657;
  --code-bg:     #0f151d;
}

.tok-keyword { color: var(--tok-keyword); }
.tok-comment { color: var(--tok-comment); font-style: italic; }

好处是主题切换时代码配色自动跟着变,不用维护两套 CSS 规则。

七、复制到剪贴板

js
btn.addEventListener("click", function () {
  var code = btn.closest(".code-block").querySelector("code").innerText;

  function done() {
    btn.textContent = "已复制";
    setTimeout(() => { btn.textContent = "复制"; }, 1600);
  }

  function fallback() {
    var ta = document.createElement("textarea");
    ta.value = code;
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    document.execCommand("copy");
    document.body.removeChild(ta);
    done();
  }

  // Clipboard API 只在安全上下文(https / localhost)可用
  if (navigator.clipboard && window.isSecureContext) {
    navigator.clipboard.writeText(code).then(done, fallback);
  } else {
    fallback();
  }
});

file://navigator.clipboard 不可用,所以必须有 execCommand 兜底。这是本地可用的站点特别要注意的。

八、要不要用现成方案

方案体积适用
自写~5KB只支持 5~6 种语言,够用
Prism.js~50KB(含语言包)语言多,主题丰富
highlight.js~100KB自动识别语言
Shiki较大,但质量最高用 VS Code 的配色

本站选自写的理由:

  • 只写笔记,涉及的语言就 bash/nginx/yaml/json/js 几种;
  • 零依赖是本站的核心原则;
  • 100KB 的 JS 对一个小站来说太重了。

如果你的博客涉及十几种语言,直接上 Prism 更明智。

九、检查清单

  • 注释、字符串内部不被误识别
  • 转义字符处理正确("a\"b"
  • 输出经过 HTML 转义
  • 深浅色各有配色
  • 复制按钮在非 https 环境也能用

代码高亮是那种「看起来复杂,做起来两小时」的功能。自己写一遍,对文本处理的理解会深一层。