之前在做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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
}
加上这个快速路径,整体又快了三成左右。注意替换顺序:& 必须最先替换,否则会把后面生成的 < 里的 & 又转一遍。
样式
引擎只输出语义标签,颜色由这几个规则决定。整套样式就是一个 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,零依赖。