CSV导出,漫漫趟坑路

2,808 阅读8分钟

(免责声明:本文根据真实经历改编,时间线跨度比较大,有些问题的时效性有待验证)

起源

CSV——字符分隔值文件格式。是在数据类应用中非常常见的文件格式,有着如下诸多好处:

  • 轻量,构造简单
  • 纯文本格式
    • 相较于xls这种私有的二进制格式,CSV非常便于使用代码进行解析和内容编辑
    • 相较于xlsx,虽然该格式有公共规范,但文件本身是个压缩包,纯前端的编辑、构造成本太高
  • 默认打开方式是Excel,具有不错的可视化展示效果

说完了CSV种种好处,下面,我和CSV(Excel)的羁绊,拉开了序幕~

原始文明时期

靠天吃饭!行不行我不确定,但应该能行!

信心满满的初心者

(牛逼哄哄的新手团,第一集就团灭了)

所谓前端导出,无外乎就是这样的代码。因为“看似”太简单,第一版实现时自然也就没借助“开源世界”的力量:

const lines: string[][] = [ //... ];
const content: string[] = [];

// 将每一行数据插入
for (const line of lines) {
  content.push(line.join(','));
}

// 模拟a标签点击
const a = document.createElement('a');
// 文本内容增加换行
const blob = new Blob([content.join('\n')], {
  type: 'text/csv',
});
a.download = title;
a.href = URL.createObjectURL(blob);
a.click();

URL.revokeObjectURL(a.href);

开发:“测试了一下,效果正确。”

产品:“开发完了,赶紧上线!”

...(上线一天后)...

用户A:“怎么回事?导出的文件都乱码了?”

痛的领悟

在需求开发过程中,为了快速上线,往往只是为了满足眼前的效果,没有从根源上去了解技术背后的完整作用机理(例如,开发的同时,真的去了解了CSV的相关规范等知识吗?)。 仅仅根据几个随手模拟的case进行测试,运气好一点,可能实现了一部分用户场景。但剩余的、未覆盖的场景,随着使用者的增多和深入,无一例外的会打脸开发者。

补丁1号:添加BOM

第一版代码,因为没有照顾到中文,或者说,没有照顾到Excel是如何识别文件编码的。
在学习了字符编解码的知识后,需要在生成的文件头部,识趣的加上utf-8的BOM,现象就都正确了。

// 修复中文乱码
const blob = new Blob([new Uint8Array([0xef, 0xbb, 0xbf]), content.join('\n')], {
  type: 'text/csv',
});

农业文明时期

出现了套路~我打过补丁的地方,一定行!

补丁2号:显式声明文件后缀

用户B:“咦?我下载的怎么是这个?”

没多久,有的用户又出现了下载的文件无后缀的问题(一个Chrome Bug),导致系统无法默认使用Excel打开;此外,也发现过Chrome某些版本上会出现:如果文件名为空时,下载的文件后缀会变成zip。

修复方式就是,在download这个attribute上写上完整后缀,以及确保文件名存在。

// 修复文件后缀不正确
a.download = `${title || '未命名'}.csv`;

补丁3号:处理错列情况

用户C:“下载的表格布局错乱了!”

排查用户的CSV发现,这是因为:前端生成的CSV默认使用了,分隔符,而当用户数据的某一列文本里也存在,时,会导致最终Excel展示时,分列错误。

诸如:

产品,销量
桌子,100
容器,箱子,500

它的展示效果是:

产品 销量
桌子 100
容器 箱子 500

修复方式也不复杂,就是在每一格数据外,包一层双引号,这样就可以正确的展示分列。

// 修复数据中包含","导致的错列
content.push(line.map(v => `"${v}"`).join(','));

补丁4号:处理错列情况2

...(补丁3号发布后,不到1小时)...

用户D:“导出的文件内容怪怪的,我昨天用的时候还是好的呀”

开发:“这。。。”

上一个补丁才刚兼容了文本中包含,的情况,结果却引起了另一个场景的bug。因为上个补丁引入了"来包裹文本内容,但如果文本中同时存在",那就会出现另一个场景中的错列现象。

诸如:

产品,销量
"显示器15"","200"
"显示器17"","100"

它的展示效果是:

产品 销量
显示器15",200"
显示器17",100"

修复方式还是很简单,就是需要特意对引号进行转义,"需要变为""

// 修复数据中包含"导致的错列
content.push(line.map(v => `"${v.replace(/"/g, '""')}"`).join(','));

痛的领悟

这一时期,持续不断的线上bug修复确实酸爽的不行,堵一个,又漏了另一个,补丁打的不亦乐乎,此起彼伏。其中,重点回顾两次错列问题,其实就是因为没有认真了解CSV规范所致。仔细阅读规范可以发现,wiki已经非常清晰地说明了这几种场景,以及对应的处理方式。

值得庆幸的是,经过这个时期的重重补丁轰炸。现在生成的CSV,已经是100%符合标准的规范产物了。

工业文明时期

双管齐下,逐步精细~新的风暴已经出现~

生成的产物确实是规范了,但它的“运行环境”却没那么规范。Excel为了处理CSV中的种种边界问题,偷偷夹杂了点私货(私有规范)。
但是,这些私有规范并不是万能的,即使遵守它们,有时候又不能完美解决所有问题。此外,Excel作为一个闭源软件,一旦它出现不符合预期的问题,排查起来,难于上青天。

后门1号:BOM

这个后门在早期版本中,已经体会过了。如果究其原因,推测是Excel为了快速识别文件的编码格式,而偷偷加了个规范。这里就不赘述了。

后门2号:Meta

有一个问题,其实从第一版功能中就一直存在,只是一直没有花精力解决。问题:代码中使用的列分隔符是,,但是用户的Excel配置并不一定将,视为分隔符。很多时候,用户打开CSV看到的并不是分好的一列列数据,而是未分割的一整行。

从产品角度出发,初期可以通过文档指引,让用户通过Excel的功能“数据tab -> 分列”来自行实现数据分割。不过,作为注重用户体验的开发人员,还是希望能够达到用户无感知的,自动化分列效果。

这时候,就接触到了Excel的第二个后门——Excel metadata。CSV的头部可以增加诸如这样的信息:sep={实际使用的分隔符},来显式声明分隔符。

诸如:

sep=-
key-value
a-1
b-2

即使是非常特殊的分隔符,Excel也能正确识别并完成分列,并自动忽略meta内容不做展示:

key value
a 1
b 2

看上去,似乎是个很美好的后门~

痛的领悟

然而,后门和天坑往往相辅相成。使用黑科技的时候,往往只专注了眼前的部分,部分场景确实获得便利的同时,另一部分场景,可能再也无法支持了~

天坑:Meta和BOM不能共存

如果使用了Meta,那无论在文件头部,还是在Meta结束位置插入BOM,都会统统失效,Excel无法再通过BOM识别文件编码了,也就是,乱码问题又回来了。

但机智的开发是不会那么容易屈服的,既然不能插入BOM,那整个文件都使用locale编码方式即可,只需要在生成文件的过程中对内容进行一次编码(转码)即可。

中文环境下,使用gb18030编码方式:

// 需要引入依赖库,上文提及的字符编解码文章中提及过
import { TextEncoder } from 'text-encoding';

// 将字符串content进行编码
const buf = new TextEncoder('gb18030', {
  NONSTANDARD_allowLegacyEncoding: true,
}).encode(`sep=\t\r\n${content}`); 

// 后续逻辑照旧
const blob = new Blob([buf], { type: 'text/csv' });

天坑:GB字符集的天生缺陷

随着业务拓展,上面的方案持续时间不久,就又碰到了不兼容场景。

一位俄罗斯大汉客户:“……&%##@!***……”(大意是,他下载的文件俄文乱码了。此外,他还好心的告知:在俄罗斯,本地编码一般为windows-1251。最后,他还建议使用Unicode字符集)

开发:“Спасибо”(谢谢)

真的是太难了。。。环环相挂,躲一个坑,就会进另一个坑,这一路摸爬滚打,已经摔得体无完肤。

我尝试搜索了如何通过浏览器获取本地编码的方式,但没有找到解法(确实这两者没有必然联系)。为了尽可能的保留Meta能力(因为它确实有价值),最终只能通过判断语言环境再打了个补丁。但这毕竟不是长久之计,哪天业务发展到其他国家,势必又是一坨乱码。

编码的问题虽然一直如鲠在喉,但在没有新解法之前,只能暂且搁置。因为,Excel这个大Boss还有许多其他难关在等着开发者~

天坑:有效数字位数

电商相关的行为数据中,往往存在这么一列——订单ID,该数据列本身是文本,生成的文件内容如下:

sep=,
order_id,order_amt
"201901010000123123","100"

用Excel打开时,第一列会被识别为数值,最终展示结果是20190101000012300,丢失了精度。

究其原因,是因为Excel的数值类型用的是遵循IEEE 754规范的双精度浮点数(同js的Number,有关知识可以参阅数据精度文章),这里的有效位数只能达到15位,剩余的数据无法存储,导致精度丢失(大数问题)。

其实,这个问题在日常运营中也会碰到,还算是个常见问题。用户给出的意见是:在这些超长的数值文本前,加上一些特殊符号,以避免Excel将该文本识别为数值类型。

不过此时,我不禁在想,既然都有私有Meta了,为啥不扩充点能力,允许指定列类型呢?

天坑:看似数值,但不是数值

这个问题之坑,真的让人印象深刻。

用户Z:“为什么我无法对这列数据进行求和?结果都是#VALUE!?”

开发:“这???”

我一直都没太在意这个问题,一开始以为:那列数据中存在了异常字符而已。直到我亲眼看到了这个现象,确实是非常诡异。正常来说,Excel会将识别为数值类型的列进行右对齐,文本类型的列进行左对齐。用户的那一列经仔细确认,确实都是数值,但Excel却没有将该列识别为数值类型,甚至,它也不是文本类型,因为其他转型函数均会报类型错误。

这个问题我跟踪了2~3周,用了各种方法,也对比了很多Excel版本的行为,一次机缘巧合下,我发现这个问题只会出现在英文环境下的Mac Office 16,如果系统语言切换为中文,Excel也能正确识别。

从最早发现问题的Mac Office 16.15,到后面升级成最新的16.16.8,这问题都在。测试bug的方式很简单:Excel有一个转型函数“VALUE”,该函数的官方示例是=VALUE("$1,000"),结果应该是数值型的1000,而在这些存在问题的Excel版本(英文环境)下,会显示类型错误(#VALUE!)。

于是,我向Office官方反馈了这个现象,遗憾的是,技术人员无法在他们本地复现这个问题。因为用户的数据是真实业务数据,我不方便转发那份似乎“有问题”的CSV。再加上沟通效率太低,该问题不了了之~

(近日,在最新的16.16.15版本中测试,该问题已经修复~)

生态文明时期

黎明的曙光?似乎都和谐了~

再战遗留问题

虽然整个技术方案已经稳定运行了好几个月。但心中总有那么个心结:如果业务拓展到其他语言环境,那怎么办?开发不可能枚举所有的语言对编码的映射;而且,现在的方案也无法兼顾中文环境下,用户看其他语言文本的场景。

这段期间,断断续续也在关注着相关问题,直到,我看到了这位大仙多年前的疯狂测试:作者尝试了各种编码+BOM+分隔符的组合,然后,测试生成的CSV在Excel 2003和Excel 2007下的展示情况。

功夫不负有心人,在这十几种组合的测试下,他发现了,只有UTF16LE + BOM + \t的组合,能够完美地实现:

  • Excel可以正确识别分列
  • 文本不会出现乱码(Unicode字符集包括了世界所有语言字符)

真的是给大神跪下了,依据这条价值连城的信息,核心代码最终变成了:

/** 产生符合规范的CSV单元格内容 */
function getCSVString(v: string): string;
/** 将字符串转为utf16le编码 */
function encodeUtf16le(s: string): Uint8Array;

// 1. 生成规范的CSV
for (const line of lines) {
  content.push(line.map(getCSVString).join('\t'));
}

// 2. 将文本内容转为utf16le编码
const buf = encodeUtf16le(content.join('\n'));

// 3. 在文件头部增加utf16le的BOM
new Blob([new Uint8Array([0xff, 0xfe]), buf], { type: 'text/csv' });




Reference