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

相關文章
相關標籤/搜索