split(',') 成功了。第二行有四列。
输入是一份三列的表:
a,b,c
1,2,"3,4"
split(',') 按行切,第二行得到 ["1", "2", "\"3", "4\""]——四个元素。而这份数据的正确解析是三个字段:1、2、3,4。
split 不会报错。它给出的四个字段每个都长得像合法字段,于是下游拿到的是“第二列是 2,第三列是 “3,第四列是 4”“。整张表从这里开始每一列都往后错一位,而每一行都通得过长度检查——因为错误发生在切分那一步,切完之后长度就已经是 4 了。
这份 CSV 工具实现的是 RFC 4180 的状态机。它只做一件事:跟踪“我现在是在引号里还是引号外”。规则只有几条,但每一条都能关掉一类静默失败。
1. 引号状态机:field === '' 这道闸门
核心实现:
export function parseCsv(text: string): string[][] | null {
const rows: string[][] = []; let row: string[] = []; let field = '';
let inQuotes = false; let i = 0;
const s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
while (i < s.length) {
const ch = s[i];
if (inQuotes) {
if (ch === '"') { if (s[i + 1] === '"') { field += '"'; i += 2; continue; } inQuotes = false; }
else field += ch;
} else if (ch === '"' && field === '') inQuotes = true;
else if (ch === ',') { row.push(field); field = ''; }
else if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else field += ch;
i++;
}
if (inQuotes) return null;
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
if (rows.length > 1 && rows[rows.length - 1].every((c) => c === '') && rows[rows.length - 1].length === 1) rows.pop();
return rows;
}
二十来行,没有一个依赖。三处值得逐条讲。
inQuotes 分支里的双引号。 引号内遇到 ",先看下一个字符是不是也是 ":是,就塞进一个真实的 " 并跳两格;不是,就退出引号。这就是 RFC 4180 的转义规则——双引号表示一个字面引号。
field === '' 这道闸门。 引号外遇到 ",只有当前字段为空时才打开引号。这一句是整个状态机的关键,实测:
| 输入 | 输出 |
|---|---|
"lead",b\n1,2 |
[ [ "lead", "b" ], [ "1", "2" ] ] |
a"b,c\n1,2 |
[ [ "a\"b", "c" ], [ "1", "2" ] ] |
1"a,2\n3,4 |
[ [ "1\"a", "2" ], [ "3", "4" ] ] |
"",b\n1,2 |
[ [ "", "b" ], [ "1", "2" ] ] |
第一行是标准情况:引号在字段开头,正常开合。第三、四行是闸门在起作用——a"b 里引号出现在字段已经有内容之后,所以不当成开引号,而是当成一个普通字符塞进字段。
第四行是空字段:"" 的第二个引号到达时 field 恰好是空的,于是被当成关闭引号——结果是一个空字符串字段。这个边界恰好成立,因为空引号字段的语义就是“一个明确为空的字段”,跟“没有字段”要区分开。
逗号与换行只在引号外生效。 看 else if 链的归属:, 和 \n 的分支都在 if (inQuotes) 之外的 else if 上。所以引号内的逗号和换行直接走 else field += ch,原样进入字段。实测:
"a,b",c\n"1,2",3 => [ [ "a,b", "c" ], [ "1,2", "3" ] ]
"multi\nline",b\n1,2 => [ [ "multi\nline", "b" ], [ "1", "2" ] ]
这就是 split(',') 永远做不到的事——逗号可以属于数据。RFC 4180 的全部复杂度就集中在这一句话里。
2. 双引号转义与嵌套引号
把转义规则单独拿出来看,因为它有两层。
解析方向:引号内 "" → 一个字面 "。
序列化方向:一个字面 " → ""。
实测解析:
"she said ""hi""",b\n1,2 => [ [ "she said \"hi\"", "b" ], [ "1", "2" ] ]
原文档里的字段是 she said "hi"。它在 CSV 里写成 "she said ""hi"""——外层一对引号包住字段,内部两个引号各自翻倍。
序列化方向的实测(csvCell):
| 值 | 输出 |
|---|---|
a,b |
"a,b" |
"q" |
"""q""" |
plain |
plain |
multi\nline |
"multi\nline" |
第二行读一下:值是三个字符 "q"。序列化时先翻倍成 ""q"",再包上外层引号,得到 """q"""。Excel 读到 """q""",剥掉外层一对,剩下 ""q"",再按翻倍规则还原成 "q"。三层引号各干一件事。
csvCell 的完整实现:
const CSV_FORMULA_FIRST = /^[=+@\t]/;
const CSV_NUMERIC = /^[+-]?[\d.]+$/;
function csvCell(v: string): string {
const literal = CSV_FORMULA_FIRST.test(v) && !CSV_NUMERIC.test(v);
const safe = literal ? `'${v}` : v;
return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
}
注意包引号的条件是 [",\n]——逗号、引号、换行三个字符。这就是 RFC 4180 规定的“必须加引号”的完整集合。少了任何一个,前面那一节的状态机就会解析错。
3. 未闭合引号返回 null:拒绝,不猜
循环结束后有一行:
if (inQuotes) return null;
实测:
"unterminated,b\n1,2 => null
注意 null 不是“解析出空表”,不是“忽略后面的内容”,也不是抛出带位置的异常——它是一个专门的哨兵值,整个函数签名是 string[][] | null。调用方被迫处理它。
这条规则和 YAML/TOML 那篇里的修法是同一个:把“猜一个值”换成“报一条错”。未闭合引号是 CSV 里最典型的一类损坏——文件被截断、编码错乱、生成脚本中途崩了。如果解析器在这种情况下“尽量返回点什么”,下游拿到的是一份列数不齐的半张表,而错因(文件损坏)完全不可见。
null 的代价是调用方要写 if (rows === null)。这个代价比“返回一份看起来合法的数据”便宜得多。
4. 尾随换行:两道闸,各自拦一种情况
CSV 文件末尾通常有一个换行。这会让解析器在最后一行之后“多看一步”。实测:
| 输入 | 输出 |
|---|---|
a,b\n1,2 |
[ [ "a", "b" ], [ "1", "2" ] ] |
a,b\n1,2\n |
[ [ "a", "b" ], [ "1", "2" ] ] |
a,b\n1,2\n\n |
[ [ "a", "b" ], [ "1", "2" ] ] |
a\n1\n2\n |
[ [ "a" ], [ "1" ], [ "2" ] ] |
三种输入,同一个输出。这靠的是两行看起来无关的代码。
第一道闸是循环结束后的补行:
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
最后一个 \n 会把当前行推出去,然后重置 row = []、field = ''。此时已经没有更多字符了,所以这个 if 条件不成立,不会再多推一个空行。这道闸拦住的是“文件以一个换行结尾”的情况。
第二道闸是收尾的 pop:
if (rows.length > 1 && rows[rows.length - 1].every((c) => c === '') && rows[rows.length - 1].length === 1) rows.pop();
它只在最后一行恰好是一个空字段时才弹掉。三个条件缺一不可:
rows.length > 1 —— 保证不会把整张表弹空。这就是为什么 'a,b,c\n'(只有表头)不会被误删成空表:实测它返回 [ [ "a", "b", "c" ] ],而不是 []。
every((c) => c === '') —— 整行都是空字段。
length === 1 —— 而且只有一个字段。一条真正的空行在 CSV 里是单个空字段,不是一排空字段。如果最后一行是 a,b\nc,d\n,,\n,那个 ,, 是三列空值——那是数据,不是空行,不能删。
两道闸合起来覆盖两种坏情况:末尾单个换行(第一道)、末尾双换行(第二道)。
5. 列数不齐:谁负责检查
parseCsv 不检查列数。实测:
a,b\n1,2,3 => [ [ "a", "b" ], [ "1", "2", "3" ] ]
a,b\nc => [ [ "a", "b" ], [ "c" ] ]
两行三列、一行两列一行一列,parseCsv 都原样返回。它只负责切分,不负责校验。
检查在 csvToJson 那一层:
| 输入 | 结果 |
|---|---|
a,b\n1,2\n1\n |
Row 3 has 1 fields, expected 2. |
a,a\n1,2\n |
Duplicate header "a". |
a,b\n |
CSV needs a header row plus at least one data row. |
name\nhi |
[ { "name": "hi" } ] |
三条错误,都是精确的:哪一行、几个字段、期望几个;哪个表头重了;或者表结构本身不成立(只有表头没有数据)。
这种分层是有意的。parseCsv 的消费者不只 csvToJson——直接拿 rows: string[][] 用的代码可能就是要一份“原样切分”的结果(比如 CSV 格式化器,它要保留用户的坏数据,只是重新排版)。把校验塞进解析器,会强迫所有这些消费者去处理它们不需要的错误。
但反过来,csvToJson 必须查——因为“用第一行的键去取错列的值”是典型的静默失败:{ a: "1", b: "3" } 看起来完全合法,而真实的 b 值是 2。所以列数不齐必须在进入键值映射之前被拦下来。
最后一条值得注意:name\nhi 这种没有换行结尾的单列表也能正常解析成 [ { "name": "hi" } ]。所以 csvToJson 的表头+数据要求不依赖尾随换行。
6. CSV 注入:为什么 +5 不防护,+cmd| 会
写 CSV 比读 CSV 多一件事:单元格可能被电子表格软件当成公式执行。
实测 csvCell 对危险开头的处理:
| 值 | 输出 |
|---|---|
=SUM(A1,A2) |
'=SUM(A1,A2) |
=5+5 |
'=5+5 |
@x |
'@x |
\t=cmd |
'\t=cmd |
-5 |
-5 |
+5 |
+5 |
5 |
5 |
前四个被加上单引号前缀,后三个原样输出。
Excel 把以 = - + @ 开头的单元格当公式解释,制表符前缀同样会被处理(Excel 会先剥掉前导空白再判定)。加一个 ' 前缀是 Excel 自己的“标记为文本”约定——引号显示在公式栏里,但单元格内容是真文本,不执行。
那为什么 +5 不防护?看两个正则的交集:
const CSV_FORMULA_FIRST = /^[=+@\t]/;
const CSV_NUMERIC = /^[+-]?[\d.]+$/;
const literal = CSV_FORMULA_FIRST.test(v) && !CSV_NUMERIC.test(v);
+5 匹配第一个正则(以 + 开头),也匹配第二个(纯数字带符号),所以 literal 为 false,不防护。
这个例外是精确的,不是遗漏。关键在于第二个正则只允许数字和点。+cmd|'/C calc' 这种真正危险的载荷会失败 CSV_NUMERIC(里面有 c、m、d、|、/),于是 literal 为 true,被防护。而 +5 通过 CSV_NUMERIC,意味着它是一个不可能携带命令的字符串——它以 + 开头这件事对 Excel 来说只会评估成数字 5。
所以这个例外的边界画得刚好:“以公式起始符开头,但整体不是纯数字” 才防护。纯数字的那个例外换来的是 -5 和 +5 在 Excel 里仍然显示为数字,而不是文本——这是有意的可读性取舍,代价是这两个单元格会被 Excel 求值(结果仍是同一个数,不会执行代码、不会发网络请求)。
如果为了“绝对不放过”把 CSV_NUMERIC 删掉,那 -5 会变成 '-5,Excel 里显示成文本 -5——数据对了,但整列的数字列变成文本列,排序和求和都坏了。这个代价对一份财务报表来说不小。
7. 转义顺序:先加前缀,再包引号
csvCell 里的顺序不是随手写的:
const safe = literal ? `'${v}` : v;
return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
先加 ' 前缀,然后才判断要不要包引号。
为什么顺序重要:Excel 的文本标记要求 ' 是单元格值的第一个字符。假如先包引号再加前缀,得到的会是 '"=SUM(A1)"'——引号在外面,前缀在最前。这在 CSV 里是歧义的:第一个字符 " 会被当成开引号,然后 ' 变成一个数据字符,整条变成 "=SUM(A1)" 加一个尾随单引号。Excel 会按普通公式处理。
现在的顺序下,=SUM(A1) 没有逗号引号换行,直接输出 '=SUM(A1)——' 在第一格。如果值本身带逗号,比如 =a,b,先变 '=a,b,再包引号成 "'=a,b"。Excel 剥掉外层引号得到 '=a,b,第一个字符仍是 ',标记生效。
这种“两步操作必须按序”的坑在文本处理里很常见:转义和包装都是位置敏感的,顺序一错,前一步的成果会被后一步的边界规则吃掉。
8. \r 归一化:引号里的 \r 也会变成 \n
解析开头有一行:
const s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
CRLF 归一化成 LF,然后再把剩下的孤立 \r 也归一化。第一步是对的:RFC 4180 规定行尾是 CRLF,编辑器也可能存成 LF,统一掉才能正确数行。
第二步有个副作用。这个替换作用在整份文本上,包括引号内部的字符。所以一个合法含有 \r 的字段——
"a\r\nb",x\n1,2
——会解析成 [ [ "a\n\nb", "x" ], [ "1", "2" ] ]:引号里的 CRLF 被换成了两个 LF。字段内容被改了,而且没有任何提示。
实务中“字段里真的有孤立 \r“几乎不会出现(现代系统要么统一 CRLF 要么统一 LF),所以这条可以接受。但它值得知道:归一化是全文的,不是”只在字段边界”。如果你的数据源是老 Mac 的行尾格式,或者某个字段里真的存了控制字符,这份解析器会静默改它。
对照一下:真正的 CSV 解析器(csv-parse、Python 的 csv 模块)都会在引号内保留原始行尾。这个选择属于“够用的简化”,不是标准行为。
9. null 变空串:另一个静默降级
JSON→CSV 方向的实测:
[{"a":1,"b":null,"c":2}] => a,b,c\n1,,2
null 变成了空单元格。这和 CSV 里真正的空单元格(源数据里那个字段就没有值)在输出上完全一样。
转回来时 csvToJson 把空串读成空字符串:[ { "a": "1", "b": "", "c": "2" } ]。于是 null 和 "" 在 roundtrip 之后变成同一个值。
这是有意的——CSV 没有 null 的概念,只有“这个格子是空的”。但你得知道代价:如果你用 CSV 做中转,null 字段的信息在写出那一刻就丢了,而且没有任何一行日志告诉你。这和 YAML 那篇里 text: | 被当字符串吐出是同一类失败:类型降级,链路报告成功。
判断标准一样:如果你需要在 CSV 之后区分“没值”和“值是 null”,那 CSV 不是合适的中转格式,得另想办法(比如专门的哨兵字符串,但那又是另一种约定)。
10. 这套解析能覆盖什么,覆盖不了什么
能覆盖的:RFC 4180 的引号状态机(含 field === '' 闸门和 "" 转义)、引号内的逗号与换行、未闭合引号返回 null、尾随换行的两种坏情况、CRLF/LF 归一化、表头去重与列数校验(在 csvToJson 层)、以及写入侧的公式注入防护。
覆盖不了的:
内嵌 \r 被归一化成 \n,全文作用,引号内也不放过。对真实数据几乎无影响,但这是与标准解析器的行为差异。
引号位置只认字段开头。a"b 里的引号是字面字符,不是开引号。这是刻意的收紧——宽松解析器会把它当开引号,然后整行崩掉。
注入防护只看第一个字符。正则 ^[=+@\t] 从位置 0 起匹配。这个边界是刻意画窄的(见第 6 节),但意味着“防护”和“数据可读”是一组取舍,而不是免费的。
CSV 没有 null。写出侧 null 变空串,读回侧空串仍是空串,roundtrip 之后两者不可区分。
一句话收:CSV 的全部复杂度都在引号里,而引号的全部难度都在”它什么时候是语法、什么时候是数据“。RFC 4180 用一条规则回答了这个问题——引号只在字段为空时是语法。split(',') 的答案是”永远是数据”,于是错位。