本站有两个格式化工具:JSON 格式化校验和 SQL 格式化美化。它们看起来是同一类东西,实现却几乎没有共同点——一个把活全交给宿主环境,另一个从字符开始手写。这篇文章讲清楚为什么,顺便记录一个把浏览器标签页直接冻结的死循环 bug。
1. JSON:宿主已经有一个符合规范的解析器
JSON 格式化的核心只有两行:
const parsed = JSON.parse(raw);
const formatted = JSON.stringify(parsed, null, indent);
JSON.parse 是引擎内置的、经过 RFC 8259 完整验证的 C++ 实现,JSON.stringify 的第三个参数就是缩进。自己写一个 JSON tokenizer 不会更快、不会更正确,只会多出一份需要维护的规范符合性风险。能复用宿主的时候就别自己写——这条原则和“不引入第三方依赖”并不冲突:宿主 API 不会被 unpublish,也不会挂在别人的 CDN 上。
那手写的部分是什么?是引擎不提供的那些。
错误定位:把引擎报错反查成行列号
JSON.parse 抛出的 SyntaxError 消息格式各引擎不统一,而且同一引擎不同版本还会变。V8 现在报的是 Expected property name or '}' in JSON at position 1 (line 1 column 2),早期版本只有 position;SpiderMonkey 一直只给行列。所以定位逻辑写成两级降级:
function getErrorPosition(errorMsg: string, text: string): { line?: number; col?: number } {
const posMatch = errorMsg.match(/position\s+(\d+)/i);
if (posMatch) {
const pos = parseInt(posMatch[1], 10);
const lines = text.slice(0, pos).split('\n');
return { line: lines.length, col: lines[lines.length - 1].length + 1 };
}
const lineColMatch = errorMsg.match(/line\s+(\d+)\s+column\s+(\d+)/i);
if (lineColMatch) {
return { line: parseInt(lineColMatch[1], 10), col: parseInt(lineColMatch[2], 10) };
}
return {};
}
两条正则各覆盖一族引擎,都不匹配就退化成“只报消息不报位置”——引擎的报错文案不是 API,不能假定它永远长这样,所以拿不到位置时工具仍要能正常给出错误提示。把字符偏移换算成行列号则是一行:切到出错位置、按换行分割,段数就是行号,最后一段的长度 + 1 就是列号。
代价:格式化不是“重排”,而是“解析后重新序列化”
复用宿主解析器有一个必须说清楚的语义边界。JSON.parse → JSON.stringify 走了一趟值,原文的写法在这一趟里被规范化了:
| 输入 | 输出 | 原因 |
|---|---|---|
1e3 |
1000 |
数字先变成 IEEE 754 double,再按最短往返形式打印 |
0.1e1 |
1 |
同上 |
{"a":1,"a":2} |
{"a":2} |
重复键只留最后一个,前面的值静默丢弃 |
"\u0041"(Unicode 转义) |
"A" |
转义序列在解析阶段已被解码,写法无法还原 |
12345678901234567890 |
12345678901234567000 |
超过 2⁵³ 的整数精度丢失 |
前四条只是形态变化,最后一条是真实的数据损失。处理区块链交易号、雪花 ID、Twitter/X 的 id_str 这类大整数 JSON 时,任何基于 JSON.parse 的格式化器都会静默改值——这也正是这些 API 都额外提供一个字符串版字段的原因。要保住原始字面量,就必须放弃宿主解析器、手写一个只做重排不做求值的 tokenizer。本站的 JSON 工具选择了前者,所以这条限制在文档里明说。
2. SQL:没有宿主解析器,也不需要 AST
SQL 这边没得选,只能手写。但“手写”有两个层次,选错了工程量差一个数量级:
- 完整 parser + AST:需要覆盖方言。MySQL 的反引号、PostgreSQL 的
::强制转换与$$函数体、T-SQL 的方括号、SQLite 的宽松语法、各家不同的窗口函数与 CTE 写法——真要做全,是几千行的工程,而且每个方言更新都得跟。 - 词法分词 + 局部规则:格式化要回答的问题其实只有三个——这个 token 前面要不要换行、缩进几级、要不要大写。这些全是局部判断,看当前 token 的类别和括号深度就够了,不需要知道它在语法树上的位置。
格式化属于第二类。所以这个实现只做到分词,没有 AST:
type SqlToken = { type: 'str' | 'comment' | 'word' | 'punct' | 'op' | 'ws'; val: string };
六个类别就是全部的类型系统。反过来说,能力上限也就锁定在这里了:不能做列对齐、不能重排子句顺序、不能识别 CTE 的作用域——那些确实需要 AST。明确知道自己不做什么,比假装什么都能做要有用。
3. 分词器:六个分支,和一条铁律
铁律是:字符串字面量和注释必须原样带出,后续任何一个 pass 都不许改写它们的内容。 这一条决定了整个架构——它把“什么是代码、什么是用户数据”的判断集中到分词这一步做完,下游只看 token 类别,永远不会再面对原始字符。
// String literal '...', "..." or `...`
if (c === "'" || c === '"' || c === '`') {
let j = i + 1;
while (j < n && text[j] !== c) {
if (text[j] === '\\') j++;
j++;
}
j = Math.min(j + 1, n);
tokens.push({ type: 'str', val: text.slice(i, j) });
i = j;
continue;
}
两个细节:
if (text[j] === '\\') j++处理反斜杠转义,让'it\'s'不会在中间被切断。j = Math.min(j + 1, n)是未闭合引号的兜底。工具是边输入边格式化的(onInput直接调doFormat(2)),用户打到where name = 'ab时字符串必然没闭合。分词器这时候绝不能抛异常,把剩下的全部当成一个字符串 token 就是最合理的处理。
4. 那个把标签页冻结的死循环
分词器的最后一个分支是“扫一个词”:从当前位置往前走,直到撞上分隔符。
let j = i;
while (j < n && !/[\s(),;'"`\-/]/.test(text[j])) j++;
tokens.push({ type: 'word', val: text.slice(i, j) });
i = j;
分隔符集合里有 - 和 /。它们本该在上面的分支里被消费掉——-- 是行注释,/* 是块注释。但只有成对出现时才会。单独一个 - 或 / 走到这里,while 的第一次判断就为假,j 停在 i,于是:
j === i → val = '' → push 一个空 token → i = j 原地不动 → 下一轮完全一样
无限循环,而且每轮往数组里 push 一个空对象。 主线程被占满,标签页失去响应,内存持续上涨直到崩溃。
触发条件有多普通?
select price - discount as net from items -- 减法
select a/b from t -- 除法
where qty > -1 -- 负数字面量
任何一句带减法、除法或负数的 SQL 都会冻结页面。而示例 SQL 里恰好没有这三样,所以工具“看起来”一直是好的。
修复只有三行——零推进时把这个字符当成单字符运算符:
// Word. A lone delimiter that no branch above claimed — subtraction,
// division, a stray backtick — leaves j === i, which would spin this
// loop forever; emit it as a one-character operator instead.
let j = i;
while (j < n && !/[\s(),;'"`\-/]/.test(text[j])) j++;
if (j === i) {
tokens.push({ type: 'op', val: c });
i++;
continue;
}
真正值得记住的是那条通用规则:while 驱动的分词器里,每一条分支都必须保证至少消费一个字符。 一个能“匹配零长度”的分支就是一个死循环。这比语法覆盖度重要得多——覆盖不全只是格式化得不够漂亮,零推进是整个页面挂掉。
5. 压缩为什么也必须走分词器
同一个文件里的“单行压缩”原本是四条链式正则,看着干净利落:
export function minifySql(sql: string): string {
return sql
.replace(/--.*$/gm, '') // remove line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // remove block comments
.replace(/\s+/g, ' ') // collapse whitespace
.replace(/\s*([(),;=])\s*/g, '$1 ') // clean around punctuation
.trim();
}
它错在一个根本的地方:正则不知道自己扫到的字符是代码还是数据。 而“这个逗号是分隔符还是字符串里的一个字”恰恰是上下文问题。
select * from t where tag = 'a,b--c'
- 第一条规则看到
--,把--c'整段删掉,剩下tag = 'a,b——引号都不闭合了,输出的 SQL 语法非法。 - 第四条规则看到逗号,把它改写成
,,'a,b'变成'a, b'——用户的数据被静默篡改。
第二种更危险:不报错、不崩溃,输出看起来还挺整齐,拿去执行就查不到数据了,而且没人会怀疑格式化工具。
修好的办法不是给正则打补丁(不存在能“跳过字符串”的正则),而是让压缩复用格式化那套分词器——字符串和注释在分词阶段就已经被识别出来了:
for (const token of tokenizeSql(sql.trim())) {
switch (token.type) {
case 'comment':
skipWs = false;
break; // 注释整个丢掉
case 'ws':
if (!skipWs && out && !out.endsWith(' ')) out += ' ';
skipWs = false;
break;
case 'punct':
// "(" 后不留空隙,")" 前去空白,"," 与 ";" 后补一个空格
…
default:
out += token.val; // word / str / op 原样输出
skipWs = false;
}
}
default 分支那一行就是铁律的兑现:字符串字面量走的是“原样拼接”这条路,压缩过程根本没有能力碰它的内容。'a,b--c' 于是逐字节存活,而注释被删得干干净净。
这就是写分词器的真正回报——不是为了“更专业”,而是因为一旦把代码和数据分开了一次,后面每个 pass 都自动是安全的。
6. 缩进状态机:三档关键字 + 一个括号深度
分词之后的重排只有两个状态变量:当前缩进级数,和括号深度。关键字分三档,每档一种排版行为:
| 档位 | 成员(节选) | 行为 |
|---|---|---|
| MAJOR | SELECT / FROM / WHERE / GROUP BY / ORDER BY / HAVING / LIMIT / UNION ALL / INSERT INTO | 独占一行,之后的内容缩进一级 |
| SUB | LEFT JOIN / INNER JOIN / ON / AND / OR / WHEN / THEN / ELSE | 换行、缩进一级,内容跟在同一行 |
| OTHER | AS / DISTINCT / IN / LIKE / BETWEEN / COUNT / SUM / COALESCE | 只做大写,不影响排版 |
if (isMajor) {
if (!atLineStart) newLine();
currentIndent = parenDepth;
append(wordUpper);
currentIndent = parenDepth + 1;
newLine();
} else if (isSub) {
if (!atLineStart) newLine();
currentIndent = parenDepth + 1;
append(wordUpper);
append(' ');
} else if (isOther) {
append(wordUpper);
}
关键是缩进全部相对于 parenDepth,而不是写死的 0 和 1。子查询里的 SELECT/FROM/WHERE 因此会嵌在它自己的括号下面,而不是回到第 0 列——否则一个 from (select … ) 读起来像是两条不相干的语句拼在一起。括号 token 只做两件事:
} else if (token.val === '(') {
// no space of our own — the preceding ws token, if there was one,
// already added it. Keeps `count(x)` tight and `from (select …)` open.
append('(');
parenDepth++;
currentIndent = parenDepth;
} else if (token.val === ')') {
parenDepth = Math.max(0, parenDepth - 1);
currentIndent = parenDepth;
append(')');
append('(') 而不是 append(' (') 是个小而重要的选择:左括号不自带空格,要不要空格完全由前面那个 ws token 有没有出现决定。于是 count(o.id) 保持紧凑,from (select …) 保留间隔——一个规则同时管对了函数调用和子查询,不需要判断“这是不是函数名”。Math.max(0, …) 则是防括号不配对:用户少打一个 ),缩进不会变成负数。
多词子句用一次前瞻处理:
const nextToken = tokens[idx + 1]?.type === 'ws' ? tokens[idx + 2] : null;
if (nextToken?.type === 'word') {
const twoWords = `${token.val.toUpperCase()} ${nextToken.val.toUpperCase()}`;
if (ALL_KEYWORDS_SORTED.includes(twoWords)) { … }
}
先拼两个词查一次表,命中就把两个 token 一起吃掉(idx 前进 2),没命中才按单词处理。两词优先于一词就是这里的最长匹配——GROUP BY 不会先被识别成 GROUP,UNION ALL 不会退化成 UNION。
实际效果
-- 输入
select * from (select id from users where id > 5) u where u.id < 10
-- 输出
SELECT
*
FROM
(
SELECT
id
FROM
users
WHERE
id > 5) u
WHERE
u.id < 10
子查询的三个子句跟着括号一起缩进了一级,外层的 WHERE 回到第 0 列——这就是 parenDepth 在起作用。
7. 它做不到什么
一个格式化工具最容易失信的方式,是让人以为它能做它做不到的事。所以把边界列清楚:
- 不校验语法。 它不是 linter,输入
select from where照样输出排版好的select from where。真正的语法检查需要 parser,而 parser 要绑方言。 - 不拆无空格的表达式。
a=b因为=不在分隔符集合里,整体成为一个 word token,原样输出为a=b,不会补成a = b。写坏不会,但也美化不了。 - 列不各占一行。
select a, b, c的三列排在同一行,逗号后只补一个空格。要做“每列一行 + 逗号前置”这类风格,得引入更多状态。 - 不做列对齐、不重排子句、不识别 CTE 作用域。 这些是 AST 的活。
- 关键字表是固定的。 表里没有的方言关键字(窗口函数的
OVER、PARTITION BY,PostgreSQL 的ILIKE等)会被当成普通标识符原样输出——不会被改坏,只是不参与排版和大写。 - 压缩不改大小写。 压缩的目标是体积,不是风格,所以关键字保持原样。
JSON 侧对应的边界已经在第 1 节说了:数字与转义会被规范化,超过 2⁵³ 的整数会精度丢失。
这些限制里没有一条会改坏输入——这是分词器架构给的保证,也是划定边界时唯一真正重要的标准:做不到可以接受,静默改数据不行。
8. 在线试试
- SQL 格式化美化:本文拆解的这份实现,2/4 空格缩进、关键字自动大写、单行压缩;
- JSON 格式化校验:复用宿主解析器 + 手写错误定位,报到具体行列;
- XML 格式化 与 CSS 格式化:同一套分词思路在另外两种语法上的应用;
- JWT 解码:解出的 header 与 payload 正是用 JSON 那条路径重新排版的。
所有工具 100% 在你的浏览器本地完成,没有任何数据会发送给外部服务器——格式化 SQL 时这一点尤其重要,生产库的表结构和查询条件不该经过第三方服务器。