CSV导出,漫漫趟坑路

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

起源

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

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

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

原始文明时期

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

信心满满的初心者

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

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

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);
复制代码

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

产品:“开发完了,赶忙上线!”布局

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

用户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

相关文章
相关标签/搜索