glowglow:语言无关的语法高亮器是怎么实现的

之前在做lingo-reader网页端阅读器的时候,会涉及到将书里面的代码块高亮的问题,但当时看了一下highlight.js shiki,前者包太大,没办法tree-shaking,后者倒是可以,但在将要使用shiki的时候,发现又需要将所有语言的包都引入进来,也还是太大,不想引入这么大的一个包。然后就有了做一个轻量的代码高亮库的想法。

最简单的highlighter可以使用正则表达式实现,也就是将一些关键字、通用的表达式等使用正则替换,但这种做法高亮出来的代码不太容易阅读,很容易看出其只是做了一种很粗糙的处理。虽然很多情况下,给代码增加一些颜色可以帮助我们更快速的阅读代码,但是通过正则表达式的方式做这件事情还是没有办法达到我的需求。

于是后来想着还是需要使用编译的方式来做一个简版的highlighter。

最开始想做这个事情的时候,也是搜了一下现在有没有相关的实现,于是就搜到了nue-glow。nue-glow内部虽然也主要是使用正则表达式来实现,但是其css-first的做法还是启发了我,于是我就直接把这件事情交给了claude+deepseek-flash。

他两个小时给我干出来了一个可用的版本,改bug也改的很快。然后顺带着又让他写了一篇教程,我看着可读性、深度还是足够的。如果你觉得不行,也可以让大模型来看一下glowglow的代码,这样应该就够了。

以现在agent+一个比较好用的大模型的迭代速度,可以很快的跑出来一个mvp,并且也可以极大的加快mvp到正式产品的过程。在平时的研发过程中,我们将很大一段时间都浪费在了同步上下文的过程中,放在对产品的研究上的时间没那么多,也就是说现有的产品研发流程不足以充分释放agent+大模型的能力,以后的研发体系肯定还会有大的变革。

改bug、跑benchmark、写博客等这一会做的事情花了我快十块钱,按照以前的计费,应该两块钱都花不到。

凉白开。

Install

shell 复制代码
pnpm install glowglow

npm:https://www.npmjs.com/package/glowglow

项目地址:https://github.com/hhk-png/glowglow

要做一个什么样的高亮器

语法高亮通常的做法是给每种语言准备一套语法定义。highlight.js 带了近 200 种语言,Shiki 直接搬来了 VS Code 的 TextMate 语法,两者都很好用。但如果你的场景是"页面上偶尔出现一段代码,什么语言都有",代价就不太划算:要么引入一个几百 KB 的包,要么按需加载一个个语言文件,还要为动态出现的代码块决定加载哪几个。

而实际上,绝大多数代码片段里真正需要被着色区分的东西,翻来覆去就那几类:注释、字符串、数字、关键字、标识符、标签。这六类东西,不管在 Python 还是 Rust 里,长得都差不多。

这篇文章要做的,就是一个不猜语言、也不需要语言的高亮器:把代码扫一遍,把六类内容映射到六个现成的 HTML 语义标签上。

内容 标签 默认色(可覆盖)
关键字 <strong> --glow-accent-color
标识符 <b> --glow-primary-color
字符串、数字 <em> --glow-secondary-color
注释 <sup> --glow-comment-color
装饰器 / 注解 <label> --glow-special-color
运算符、括号 <i> --glow-char-color

用现成的语义标签而不是自定义 class,好处是颜色全交给使用方的 CSS:库只管"这段是什么",不管"它长什么样"。换肤就是覆盖几个变量的事:

css 复制代码
pre {
  --glow-accent-color: #d73a49;   /* 关键字 */
  --glow-primary-color: #6f42c1;  /* 标识符 */
}

目标定下来之后,整件事就是一条三段的流水线:

复制代码
源码
 │
 ├─ ① 分词    一遍扫描 → 原子 token(连续、不重叠、完整覆盖输入)
 │             [{ kind: 'word', start: 0, end: 5 }, ...]
 │
 ├─ ② 分类    按上下文给 token 补上颜色标签
 │             [{ kind: 'word', start: 0, end: 5, tag: 'strong' }, ...]
 │
 └─ ③ 渲染    按行切片、转义、包裹
                '<code><strong>const</strong> ...</code>'

最终产出长这样:

js 复制代码
glow('const x = "hi"')
// <code><strong>const</strong> <b>x</b> <i>=</i> <em>"</em><em>hi</em><em>"</em></code>

下面按这三段讲,每一段都给能直接改编的代码。

第一步:分词

数据结构

先定 token 的形状。只需要三样东西:类型、以及它在源码里的区间。

ts 复制代码
type Kind = 'comment' | 'str' | 'word' | 'num' | 'op' | 'decor' | 'ws'

interface Token {
  kind: Kind
  start: number   // 闭区间起点
  end: number     // 开区间终点,src.slice(start, end) 就是这段文本
}

用 [start, end) 的区间而不是直接存字符串,有两个好处:一是切片是零拷贝的视图,二是偏移量可以直接用来做位置映射(后面渲染按行切片就靠它)。

扫描骨架

核心是一个游标 i 加一个大循环。每轮根据当前字符 决定怎么做,然后要么把 i 推到一个新位置,要么前进一个字符。写清楚这个骨架,剩下的都是往里填分支:

ts 复制代码
export function lex(src: string): Token[] {
  const tokens: Token[] = []
  const len = src.length

  const emit = (kind: Kind, start: number, end: number) => {
    if (end > start)
      tokens.push({ kind, start, end })
  }

  const stack: Frame[] = []
  const top = () => stack[stack.length - 1]

  let i = 0
  while (i < len) {
    // 如果当前在字符串里,走另一套扫描逻辑(下一节)
    if (top()?.kind === 'str') { /* ... */ continue }

    const c = src[i]

    // 一条条往下匹配,命中即处理并 continue
    // 空白 → 行注释 → 块注释 → 哈希注释 → 字符串 → 数字 → 标识符 → 运算符
    // ...
  }
  return tokens
}

注意 emit 里的 end > start 判断:空 token 永远不产出。这条小规矩能省掉后面很多边界判断。

帧栈:处理跨行与嵌套

大部分 token 可以就地判定,看一眼当前字符就能切开。但有一类东西天生要跨越这些边界:

js 复制代码
const s = `共 ${count} 项,其中 ${list.map(x => `${x.name}`).join('、')}`

模板字符串里有插值,插值里又有嵌套的模板字符串。要在一遍线性扫描里处理这种嵌套,最自然的做法是压栈。帧只有两种:

ts 复制代码
interface StrFrame {
  kind: 'str'
  delim: string    // '`' | '"' | "'" | '"""' | "'''"
  mark: 'dollar' | 'brace' | 'none'   // 插值语法:${} / {} / 无
  multi: boolean   // 是否允许跨行(反引号、三引号)
}
interface InterpFrame {
  kind: 'interp'
  depth: number    // 1 表示插值自己的括号已经消费掉
}

进入字符串时压一个 str 帧,之后大循环就切换到"字符串内部"的扫描模式 ------ 这个模式只做四件事,按顺序判断:

ts 复制代码
while (i < len) {
  const c = src[i]

  // ① 反斜杠转义:连同下一个字符一起吞掉(也能吞掉转义换行)
  if (c === '\\') { i = Math.min(len, i + 2); continue }

  // ② 裸换行结束单行字符串(引号没闭合)
  if (c === '\n' && !frame.multi) { emit('str', chunkStart, i); stack.pop(); break }

  // ③ 插值开始:${ 或 {,压入插值帧,把控制权交回正常代码模式
  if (frame.mark === 'dollar' && c === '$' && src[i + 1] === '{') {
    emit('str', chunkStart, i)
    emit('op', i, i + 2)                  // 把 ${ 本身作为一个运算符 token
    stack.push({ kind: 'interp', depth: 1 })
    i += 2
    break
  }

  // ④ 遇到闭合分隔符(要区分单引号和三引号)
  if (c === frame.delim[0]) {
    if (frame.delim.length === 1 || src.startsWith(frame.delim, i)) {
      emit('str', chunkStart, i)          // 分隔符之前的字面量
      emit('str', i, i + frame.delim.length)  // 闭合分隔符本身
      i += frame.delim.length
      stack.pop()
      break
    }
    i++   // 三引号串内部出现的单个引号,不算闭合
    continue
  }

  i++
}

插值帧的弹栈要处理深度,因为插值里完全可能有对象字面量:

ts 复制代码
if (STRUCTURAL.has(c)) {          // STRUCTURAL = ()[]{}
  const t = top()
  if (c === '{') {
    if (t?.kind === 'interp') t.depth++          // 插值里的 { 只是普通括号
  } else if (c === '}') {
    if (t?.kind === 'interp') {
      if (t.depth > 1) t.depth--                 // 还没到插值自己的那个 }
      else stack.pop()                           // 插值结束,回到字符串模式
    }
  }
  emit('op', i, i + 1)
  i++
}

有了这套栈,跨行块注释、三引号字符串、嵌套模板就都能在一遍扫描里处理完,不需要回溯,也不需要正则。

逐类判定规则

真正花时间的是把每种标记的判定条件想清楚。这部分没什么巧劲,只能一条条列。关键在于每个分支都要回答两个问题:什么时候触发?扫到哪里为止?

类型 触发条件 扫描到
ws 空白字符 连续空白结束
comment // 行尾
comment /* */(找不到就到输入末尾)
comment <!-- -->(同上)
comment # 后面是空白 / ! / 行尾 行尾
comment --[[ 配对的 ]]
comment -- 且前后都是空白 行尾
str ````` / " / ' / 三引号 配对分隔符(见上节)
num 数字,或 . 后面跟数字 见下
word 标识符首字符 标识符字符结束
decor @ 后面跟标识符,且 @ 不紧跟标识符字符 标识符结束
op 运算符字符 运算符 run 结束(见下)

几条最容易踩坑的,展开说:

-- 什么时候是注释。 SQL、Lua、Haskell 里 -- 是行注释,但在 JS/Rust 里 a-- 是自减、--x 是前缀自减。判据是前后都得是空白:

ts 复制代码
if (c === '-' && src[i + 1] === '-'
  && (prevC === '' || isWs(prevC))          // 前面是行首或空白
  && (i + 2 >= len || isWs(src[i + 2]))) {  // 后面也是空白或行尾
  // ... 吃到行尾
}

这样 x = 1 -- 注释 是注释,x = a-- 和 --x 保持运算符。

# 什么时候是注释。 CSS 的颜色、C 的预处理指令都用 #,所以只有后面跟着空白、!、或行尾时才算注释:

ts 复制代码
if (c === '#' && (i + 1 >= len || isWs(src[i + 1]) || src[i + 1] === '!')) { ... }

#fff、#include、#my-id 于是都保持代码。

@ 什么时候是装饰器。 @sealed 是装饰器,email@example.com 不是。判据是 @ 不能紧跟在标识符字符后面:

ts 复制代码
if (c === '@' && !(i > 0 && ID_PART.test(src[i - 1]))) {
  if (isIdStartAt(src, i + 1)) { /* ... 吃成一个 decor */ }
  else { emit('op', i, i + 1) }   // 孤立的 @ 当运算符
}

字符串前缀。 Python 的 f"..."、C# 的 $"..."、以及 r"...",识别规则是:引号前面有 1~2 个字母、字母都在 r/f/b/u 里(或者是 $),并且这个前缀必须从词首开始 。最后那条限制很关键,否则 10u32$"x" 这种"Rust 数字后缀紧接着 C# 插值字符串"的写法会误判------$ 前面是 2,不是词首。

前缀还决定了插值语法:$"..." 和含 f/F 的前缀按 {} 插值,r"..."、b'...' 不插值。

数字。 要处理的形态比想象的多,按顺序扫:

ts 复制代码
function scanNumber(src: string, start: number): number {
  const len = src.length
  let i = start
  const c = src[i]

  // ① 进制前缀 0x / 0X / 0b / 0o,之后吃 [0-9a-zA-Z_]
  const nx = src[i + 1]
  if (c === '0' && nx !== undefined && 'xXbBoO'.includes(nx)) {
    i += 2
    while (i < len && /[0-9a-zA-Z_]/.test(src[i])) i++
    return i
  }

  // ② 前导小数点 .5
  if (c === '.') i++
  while (i < len && (isDigit(src[i]) || src[i] === '_')) i++

  // ③ 小数部分:注意后面的点不能是 ..(那是范围运算符)
  if (src[i] === '.' && src[i + 1] !== '.') {
    i++
    while (i < len && (isDigit(src[i]) || src[i] === '_')) i++
  }

  // ④ 指数:e / E,可带正负号,但后面必须真的跟数字
  if (src[i] === 'e' || src[i] === 'E') {
    let j = i + 1
    if (src[j] === '+' || src[j] === '-') j++
    if (isDigit(src[j])) {
      i = j
      while (i < len && (isDigit(src[i]) || src[i] === '_')) i++
    }
  }

  // ⑤ 字母后缀(1n、10u32),但不能无限长,否则会把后面的标识符吃掉
  let p = i
  while (p < len && isAsciiLetter(src[p])) p++
  if (p > i && p - i <= 6) {
    while (p < len && (isDigit(src[p]) || src[p] === '_')) p++
    i = p
  }
  return i
}

第 ⑤ 步的"最多 6 个字母"是个刻意的限制:10u32 要整体算一个数字,但 10abcdefgh 不能。写到这里有个真实踩过的坑值得提一句------最初判断进制前缀写成了 'xXbBoO'.indexOf(src[i + 1] || '') !== -1,本意是"后面没字符就当空串",但 indexOf('') 返回 0 而不是 -1。结果是当 0 是输入最后一个字符时,它被当成进制前缀,索引直接冲出字符串末尾,产出了一个越界的 token。判断"下一个字符是否存在"要显式写 nx !== undefined,不要靠空串兜底。

运算符要合并,但不能合并过头。 ===、=>、?.、:: 应该是一个整体,所以要把连续的运算符字符合并成一个 token。但有三种情况必须打断:

ts 复制代码
const OP_RUN = new Set('=+-*/%!<>&|^~?:.;,')

let j = i
while (j < len && OP_RUN.has(src[j])) {
  const ch = src[j]
  // ① 遇到注释开头要停,否则 // 和 /* 会被吞进运算符
  if (ch === '/' && (src[j + 1] === '/' || src[j + 1] === '*')) break
  // ② 遇到 HTML 注释开头要停
  if (ch === '<' && src.startsWith('<!--', j)) break
  // ③ 不能把 < 和后面的 / 粘在一起 ------ </div> 必须切成三段,
  //    后面才能识别出闭合标签
  if (ch === '/' && j - 1 >= i && src[j - 1] === '<') break
  j++
}
emit('op', i, j)

第 ③ 条是整段里最不显然的:它让 < 和 / 分开,而 / 和 > 保持合并(/> 自闭合标签要靠它)。

必须守住的约定

到这里分词就写完了,但有一条约定必须从一开始就守住:

token 流必须连续、不重叠、完整覆盖输入。

也就是源码的每一个字符恰好属于一个 token。这条性质贯穿整个设计,渲染那一节会看到它有多重要------它决定了渲染能不能简单地按行切片。

守不住它,症状是"文本悄悄少了一截",这类故障最难察觉。所以值得写一条测试直接把它钉死:

ts 复制代码
// 拿一批有代表性的源码,逐个断言
const toks = lex(src)
expect(toks[0].start).toBe(0)
expect(toks[toks.length - 1].end).toBe(src.length)
for (let k = 1; k < toks.length; k++)
  expect(toks[k].start).toBe(toks[k - 1].end)   // 首尾相接

更好的做法是用随机输入去撞:固定种子的伪随机生成器造两万个随机源码(字符素材刻意挑 <、/、引号、反引号、${、"""、0x 这些最容易踩边界的组合),断言同一件事。这样任何一个新加的分支如果破坏了覆盖性,立刻会被抓出来。

第二步:分类

分词之后拿到的还是一堆无意义的原子 ------ 一个 word 既可能是关键字也可能只是变量名。第二步按上下文给它们补上颜色标签。

ts 复制代码
interface ClassifiedToken extends Token {
  tag: string | null      // null 表示原样输出,不包裹
}

大部分映射是直接查表:

ts 复制代码
switch (t.kind) {
  case 'comment': tag = 'sup'; break
  case 'str':     tag = 'em'; break      // 例外见下面的引号键
  case 'num':     tag = 'em'; break
  case 'decor':   tag = 'label'; break
  case 'op':      tag = 'i'; break
  case 'ws':      break                  // 不包裹
  case 'word':    /* 需要上下文,见下 */ break
}

麻烦全在 word 和 str 这两类上。

关键词表与点号守卫

因为没有语言提示,关键词表只能是跨语言的并集------338 个词,涵盖 JS/TS、Python、Java、C/C++、C#、Go、Rust、Ruby、PHP、Swift、Kotlin、Shell、SQL。任何一个在这些语言里是保留字的标识符都会被着色。

取舍很明确:宁可误报,不可漏报。一个恰好是某语言保留字的普通变量名会被误染色,但漏掉一个真的关键字会让代码读起来更糟。

为了让误报少一点,有一条非常有效的守卫:跟在 . 后面的标识符不算关键字。

ts 复制代码
const prev = t.start > 0 ? src[t.start - 1] : ''
tag = isKeyword(w) && prev !== '.' ? 'strong' : 'b'

因为 obj.type、str.match、arr.new 这类属性访问极常见,而它们恰好撞上关键词表。有了这一条,type、match、new 在点号后面就会保持普通标识符的颜色。

认出标签:候选区域 + 三条判据

这是整个项目里最麻烦的一块。看这两段:

ts 复制代码
const v = Foo<T>          // T 是类型参数,不该被当成标签
if (a < b && c)           // 比较运算,也不是标签
html 复制代码
<div class="x">hi</div>
<img src="a.png" />
<MyComp a={1}>text</MyComp>

它们的字符构成完全一样:<、名字、>。分词器也不管这些区别,都会给出 op:"<" word:"Foo" op:">"。区分它们只能靠结构。

分两步走。第一步是找出所有候选区域 :扫描 token 流,遇到 < 就看紧跟其后的是不是一个名字(开标签),或者是 / 加名字(闭标签):

ts 复制代码
for (let idx = 0; idx < n; idx++) {
  const t = toks[idx]
  if (t.kind !== 'op' || text(t) !== '<') continue

  const j = nextNW(idx)                     // 跳过后面的空白
  if (j < 0) continue
  const jt = toks[j]

  let nameIdx = -1, open = false, closer = false
  if (jt.kind === 'word' && jt.start === t.end) {
    open = true; nameIdx = j                 // <div
  } else if (jt.kind === 'op' && text(jt) === '/' && jt.start === t.end) {
    const w = nextNW(j)
    if (w >= 0 && toks[w].kind === 'word' && toks[w].start === jt.end) {
      closer = true; nameIdx = w             // </div
    }
  }
  if (nameIdx < 0) continue

  // 之后从这里向后找结束的 '>',途中遇到结构性字符就判定这个候选无效
}

jt.start === t.end 这个判断不能省 ------ 它确保 < 和名字紧挨着 ,中间没有空格。否则 a < b 里的 < b 也会被当作开标签候选。

第二步是判断候选是不是真标签。三条判据,满足任意一条即可:

ts 复制代码
const isTag = (r: Region): boolean => {
  const name = text(toks[r.nameIdx]).toLowerCase()
  if (HTML_TAGS.has(name)) return true      // ① 已知的 HTML 元素名
  if (paired.has(name)) return true         // ② 开闭标签成对出现
  return r.open && r.selfClose              // ③ 自闭合
}

第 ② 条的 paired 是预先算出来的:把所有开标签的名字和闭标签的名字各收一个集合,取交集。只要 <Foo> 和 </Foo> 都出现过,Foo 就被认定为自定义组件。第 ③ 条的依据是:TS 里根本不存在"标识符后面跟 / >"这种写法,所以自闭合一定意味着标签。

输入 判定 理由
<div class="x"> 标签 ① HTML 元素名
<img src="a.png" /> 标签 ③ 自闭合
<MyComp a={1}>text</MyComp> 标签 ② 成对出现
<Foo>(单独) 不是 三条都不满足
Foo<T> 不是 三条都不满足
a < b 不是 根本没有 > 来结尾

扫描结束标记时还有一个关键设计:遇到结构性字符就整个否掉。

ts 复制代码
for (let m = nameIdx + 1; m < n; m++) {
  const mt = toks[m]
  if (mt.kind === 'ws') continue
  const s = text(mt)
  if (mt.kind === 'op') {
    if (s === '>') { term = m; break }
    if (s === '/>') { selfClose = true; term = m; break }
    // 另一个 <、或者括号方括号,都说明这不是标签
    if (s.includes('<') || /[()[\]]/.test(s)) { invalid = true; break }
    continue        // 属性运算符(= : . ? -)是正常的,继续找
  }
  if (mt.kind === 'word' || mt.kind === 'str' || mt.kind === 'num') continue
  invalid = true; break
}

这一步让 a < b && x <= y 彻底出局(扫描时会先撞上 <= 里的 <),也顺便吃掉了 <div (x)> 这种语法错误的输入。

标签内外:角色与散文

确定一个区域真的是标签之后,还要给它内部的 token 分配角色:

ts 复制代码
for (const r of decided) {
  role[r.nameIdx] = 'name'                    // 标签名 → <strong>
  if (r.open) {
    for (let m = r.nameIdx + 1; m < r.to; m++) {
      if (toks[m].kind === 'word' && !role[m])
        role[m] = 'attr'                      // 属性名 → <b>
    }
  }
}

剩下的位置角色为 null,最后按"是关键字吗"来定。

然后是"散文规则"。在真正的标记文档里,两个标签之间的东西是人写的正文:

html 复制代码
<p>Hello world</p>

Hello world 如果被染成标识符颜色,整个文档会很花。所以对每一对相邻的已确认标签,检查它们之间的空隙:

ts 复制代码
// decided 里是已确认的标签区域,每项记着首尾的 token 下标
for (let g = 0; g < decided.length - 1; g++) {
  const a = decided[g], b = decided[g + 1]

  let prose = true
  for (let m = a.to + 1; m < b.from; m++) {
    const mt = toks[m]
    // 出现代码特征字符(= ; < > ( ) [ ])就不再是散文
    if (mt.kind === 'op' && CODEISH_OP.test(text(mt))) { prose = false; break }
    if (mt.kind === 'word' || mt.kind === 'ws' || mt.kind === 'str' || mt.kind === 'num') continue
    prose = false; break
  }
  if (prose) {
    for (let m = a.to + 1; m < b.from; m++)
      if (toks[m].kind === 'word') role[m] = 'text'   // text 角色 → 不染色
  }
}

这个判断在 JSX 里正好派上用场:

jsx 复制代码
<Comp a={x} b={y} />

标签之间的 {x}、{y} 是代码不是散文 ------ 而括号属于代码特征字符,所以这段空隙不会被当成散文,里面的标识符照常染色。

引号键

最后一条小而实用的规则。JSON 里键和值都是字符串,但视觉上应该区分:

json 复制代码
{ "name": "ana", "n": 1 }

"name" 是键,"ana" 是值。判据是后面跟着 :,前面是 { 或 ,:

ts 复制代码
// 一个字符串在分词后往往是三个 token:开引号、内容、闭引号。
// 先把整串收成一个区间 [i, j],再往两边看
let j = i
while (toks[j + 1]?.kind === 'str' && toks[j + 1].start === toks[j].end) j++

let k = j + 1
while (k < n && toks[k].kind === 'ws') k++        // 跳到后面的非空白
let p = i - 1
while (p >= 0 && toks[p].kind === 'ws') p--       // 跳到前面的非空白

const hasColon = k < n && toks[k].kind === 'op' && text(toks[k]) === ':'
const afterObject = p >= 0 && toks[p].kind === 'op'
  && (text(toks[p]) === '{' || text(toks[p]) === ',')

if (hasColon && afterObject) { /* 把 [i, j] 标记为键 → <b> */ }
i = j   // 整串已经处理完,跳过它

注意 let j = i 之后那个循环:它是为了让 "name" 这种被切成三个 token 的字符串被当成一个整体处理。

这个规则同时能正确处理对象字面量和 Python 字典。而因为要求前面必须是 { 或 ,,三元表达式里的 cond ? "a" : "b" 不会被误判。

第三步:渲染

到这一步每个 token 都有了标签,剩下的事就是把它们拼成 HTML。渲染是按行 做的:算出每一行在源码里的 [start, end) 区间,把落在这一行里的 token 切片、转义、包上标签。

ts 复制代码
function renderLine(src, toks, from, ls, le) {
  const out = []
  for (let i = from; i < toks.length; i++) {
    const t = toks[i]
    if (t.start >= le) break              // 越过本行了
    const s = Math.max(ls, t.start)       // 跨行的 token 在本行内的部分
    const e = Math.min(le, t.end)
    if (s < e) {
      const inner = src.slice(s, e)
      out.push(t.tag ? `<${t.tag}>${esc(inner)}</${t.tag}>` : esc(inner))
    }
  }
  return out.join('')
}

Math.max / Math.min 这两行处理跨行的 token(块注释、三引号字符串),把它在每一行里的那一段单独渲染------这正是"多行块注释在每行都显示为注释色"的实现方式。

这里就体现出第一步那条约定的价值了。因为 token 连续覆盖输入,每一行的每一个字符都必然属于某个 token,行内不可能出现空隙,行尾也不会有残留。所以渲染函数不需要维护游标去填补空隙,也不需要处理"行的最后一段没有 token 覆盖"这种情况。反过来说,如果分词时没守住覆盖性,这里就必须加一堆兜底逻辑。

必须线性:前进游标

上面那个 from 参数是后加的。最早的写法是每渲染一行都从 token 数组的头部开始遍历,跳过前面那些不属于本行的 token:

ts 复制代码
for (const t of toks) {
  if (t.end <= ls) continue        // 属于前面的行,跳过
  if (t.start >= le) break
  ...
}

逻辑完全正确,小输入也很快------100 行的代码块只要 1.2 毫秒。但复杂度是 O(行数 × token 数):L 行、T 个 token,总工作量是 L×T。

行数 耗时 每行耗时
100 1.2ms 12.3µs
1000 12.5ms 12.5µs
5000 360ms 72µs
10000 4.9 秒 493µs
20000 21.7 秒 1083µs

同一份代码,前 1000 行用 12 毫秒,后 1000 行用 250 毫秒------每行的成本随着文件变大而持续上涨,这就是平方级复杂度的特征。

修复利用了另一个有序性:行是递增的,token 也是递增的。所以本行用到的 token 区间,起点一定不早于上一行的起点。只要维护一个只前进不回退的游标:

ts 复制代码
let from = 0
for (const line of lines) {
  const ls = offset, le = ls + line.length
  while (from < toks.length && toks[from].end <= ls) from++   // 只前进,不回退
  const rendered = renderLine(src, toks, from, ls, le)
  offset = le + 1     // 跳过换行符
}

整趟遍历就从 O(L×T) 变成 O(L+T)。同样两万行:21.7 秒 → 93 毫秒,每行成本降到平稳的 4~5µs,不再随规模增长。

这个坑值得单独记一笔,因为它是最难发现的一类问题 :逻辑正确、测试全过、小输入下性能无可挑剔,代价只在输入变大时显现。而真实页面里的代码块通常都很小。写这类循环的时候,先问一句"内层循环每次真的需要从头开始吗",往往就能避开。

转义

最后是 HTML 转义。这里有个小技巧:绝大多数 token(标识符、空白)根本没有需要转义的字符,不必为它们付出转换成本。

ts 复制代码
function esc(str: string): string {
  if (!/[&<>]/.test(str)) return str      // 一次扫描:没有特殊字符就直接返回
  return str.replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

加上这个快速路径,整体又快了三成左右。注意替换顺序:& 必须最先替换,否则会把后面生成的 &lt; 里的 & 又转一遍。

样式

引擎只输出语义标签,颜色由这几个规则决定。整套样式就是一个 pre 作用域里的规则:

css 复制代码
pre {
  background-color: var(--glow-bg-color, #20293A);
  color: var(--glow-base-color, #a2aab1);
  padding: var(--glow-padding, 1.5em);
  counter-reset: line-counter 0;
  font-family: monospace;
  overflow-x: auto;

  /* 清掉标签自带的字形,让只有颜色承载语义 */
  * { font-weight: 400; font-style: inherit; text-decoration: inherit }
  b, strong { font-weight: 400 }   /* 见下方说明 */

  b      { color: var(--glow-primary-color, #7dd3fc) }    /* 标识符 */
  em     { color: var(--glow-secondary-color, #f472b6) }  /* 字符串、数字 */
  strong { color: var(--glow-accent-color, #419fff) }     /* 关键字 */
  i      { color: var(--glow-char-color, #64748b) }       /* 运算符 */
  label  { color: var(--glow-special-color, #fff) }       /* 装饰器 */
  sup {
    color: var(--glow-comment-color, #6f7a7d);
    font-style: italic;
    font-size: inherit;
    vertical-align: inherit;
    position: static; top: 0;      /* 抵消 normalize.css 对 sup 的抬升 */
  }
}

那个单独的 b, strong { font-weight: 400 } 不是重复。通配符不贡献特异度 ,pre * 的特异度和 normalize.css 里 b, strong { font-weight: bolder } 完全相同,都是 (0,0,1)。两个规则打平,谁赢就取决于样式表的加载顺序------打包后 normalize 的 chunk 排在后面,于是 normalize 赢,所有关键字都变成粗体。加上元素选择器把它提到 (0,0,2) 才能稳定生效。

行号靠 CSS 计数器,每行一个 span:

css 复制代码
.glow-line {
  counter-increment: line-counter 1;
  &:before {
    content: counter(line-counter);
    display: inline-block;
    text-align: right;
    width: 2.5em;
    padding-right: 1em;
    margin-right: 1em;
    color: var(--glow-counter-color, #475569);
  }
}

这里最关键的是那个类名。 如果图省事写成 pre span,那么不开行号功能时,使用方代码里任何 span(比如书页的锚点 <span id="page_46">)都会被塞上行号。把样式限定在"只有启用行号时才产出的那个类"上,才能保证不误伤别人的标签。

这是一个通用教训:库的样式表每多管一点闲事,使用方就得多写一条覆盖。

这个方案的边界

只讲做到了什么,读起来会不放心。下面这些是这个方案的固有边界,不是待修的 bug。

正则字面量会被当成除法。 分词器不知道语言,/ab+/g 里的 / 只能按运算符处理:

js 复制代码
glow('const re = /ab+/g')
// <code><strong>const</strong> <b>re</b> <i>=</i> <i>/</i><b>ab</b><i>+/</i><b>g</b></code>

要正确处理它,就必须知道"上一个 token 是操作数还是运算符"才能判断 / 是除法还是正则开头------那已经是有语义的分析了,超出了这个方案的定位。

冷门的块注释语法不识别。 /* */、<!-- -->、Lua 的 --[[ ]] 都支持,但 OCaml 的 (* *) 和 Haskell 的 {- -} 不行:

js 复制代码
glow('(* comment *)')
// <code><i>(</i><i>*</i> <b>comment</b> <i>*</i><i>)</i></code>

原因是 (* 在 C 系语言里是"左括号 + 解引用",{ 在 JS 里是对象字面量的开头------把它们当成块注释开头,会在这两个更常见的场景下制造错误。有冲突时,选常见的那个。

关键字表是并集,所以存在误报。 一个标识符只要在某个语言里是保留字就会被着色:

js 复制代码
glow('const open = true')
// <code><strong>const</strong> <strong>open</strong> <i>=</i> <strong>true</strong></code>

open 在 JS 里是普通变量名,但它是 Java、Kotlin、Swift 的保留字,所以被染成了关键字色。点号守卫能挡住 obj.open 这种最常见的场景,挡不住独立出现的。

它不做语法分析。 引擎不构建语法树,函数名、类名、变量名在它眼里都是"标识符",不会因为"这是个函数"而有不同颜色。这是刻意的取舍:一旦开始做语义判断,就要引入语言知识,也就回到了"每种语言一套定义"的老路上。

把这些局限和它的体积放在一起看,选择就清楚了:这个方案适合"代码出现得零散、语言混杂、体积敏感"的场景。如果页面通篇是同一门语言、又需要精确到函数名和方法名的着色,用 Shiki 或 highlight.js 更合适。

小结

把上面三段拼起来,一个能用的高亮器大概是三百行核心代码。真正花时间的不是"怎么识别关键字",而是两类事情:

一是边界条件。 -- 什么时候是注释、@ 什么时候是装饰器、< 什么时候是标签、点号后面跟的 / 是除法还是正则------每个标记都有一堆"看起来像但其实不是"的邻近情况,只能一条条列出来想清楚。"语言无关"省下的不是规则本身,而是规则的重复:不是每种语言一套,而是每种边界一条。

二是把隐含的约定变成会失败的测试。 token 连续覆盖输入、渲染不增不减字符,这两条约定支撑着整个实现,但只要没人验证,它们就只是注释里的几句话------而一旦被破坏,症状是"文本悄悄少了一截"这种最难察觉的故障。

glowglow 就是这套东西的完整实现,核心不到一千行,压缩后 6.8 KB,零依赖。

项目地址:https://github.com/hhk-png/glowglow

相关推荐
java_nnnn1 小时前
JavaEE进阶-HTML基础认识
前端·java-ee·html
鬼手点金2 小时前
Claude Code示范案例-常用快捷命令
java·服务器·前端·计算机视觉·前向传播
CopyCode2 小时前
我排查了一下午,发现项目打包体积翻倍的元凶是它
前端·性能优化
u0111026752 小时前
Vue 3 实现图片裁剪框:拖动、缩放与固定宽高比
前端·javascript·vue.js
汉堡大王95273 小时前
一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表
前端·后端·github
小小善后师3 小时前
用 Canvas + AI 实现登录页的 Logo 粒子动画
前端·vue.js
Amos_Web3 小时前
Rspack 源码解析(十六):多类型资源如何进入 Compilation.assets
前端·rust·前端框架
GAMC3 小时前
chrome-devtools-mcp:让 AI 编码助手真正"看见"浏览器
前端·人工智能
deli0073 小时前
高尔顿板:把球一颗颗丢下去,为什么最后总堆成一座钟形山
前端