三個對不上的數
一段 JSON 貼進去,狀態列的欄數、下載檔案的位元組數、剪貼簿文字的位元組數常常互相解釋不了:多出來的鍵進不進表頭、下載為什麼比複製多 25 個位元組、16 個鍵為什麼只剩 4 欄。我把範本裡的 generateCSV、csvCell、copyTable 逐字抽出來,餵給 Node 與 headless Chrome 跑過一遍。
表頭是所有記錄鍵的聯集
第 138–144 行逐筆收集鍵並去重,只在沒見過時追加。輸入 [{"a":1,"b":2},{"a":3,"c":4}] 出 3 欄、allFields=["a","b","c"]、csv 19 個位元組;按第一筆記錄的鍵算只有 2 欄,c 會整欄消失。50 筆互不相交的鍵出 50 欄。
這篇範例 3 筆記錄、聯集 24 個鍵,勾不勾展平都是 24 欄:addr 拆成兩個鍵多 1 欄,empty:{} 少 1 欄。缺鍵的儲存格走第 159、200 行,預覽是空的 <td></td>,CSV 裡是兩個逗號之間的空字串。
圖 1:聯集欄數與兩種誤算口徑。
點號展平,陣列不拍下標
第 113 行 prefix + '.' + key 寫死點號:{"u":{"id":{"v":1}}} 出 u.id.v,N 層同名鍵的鍵長 = 2N − 1,1000 層實測 1999 個字元。陣列走第 116–117 行整段 JSON.stringify,{"a":[1,2,3]} 的鍵仍是 a,值被加引號成 "[1,2,3]",一個下標欄都不會有。
遞迴沒有深度上限參數:1000 層與 3000 層正常出表,6000 層穩定拋 RangeError: Maximum call stack size exceeded;同一份輸入壓到 100000 層,JSON.parse 仍成功,爆的是範本自己的遞迴,紅框標題照樣寫「JSON 解析錯誤」。
加引號只認 4 個字元
第 208 行 /[",\r\n]/ 是唯一條件:雙引號、逗號、CR、LF,命中就首尾各加 1 個引號,值裡的雙引號按第 209 行翻倍,1 個位元組的 " 出 4 個位元組。表頭也過第 195 行的 csvCell,鍵 a,b 的首列是 "a,b"。
首尾帶空格的 前置空格 、分號 甲;乙;丙、定位字元 甲\t乙,以及 =1+1、+86138、-123、@mention,全部原樣不加引號。
三條通路三種位元組
BOM 不在 generateCSV 裡,第 193 行從空字串起手,只在第 219 行 new Blob(['\uFEFF' + csv]) 補上:下載位元組 = csv 位元組 + 3,12 組實測無一例外。CSV 行尾是 CRLF(第 195、201 行兩處 + '\r\n'),複製是單個 LF 加定位字元(第 241、246 行)而且不調用 csvCell,末尾還有第 248 行 text.trim() 削首尾。
| 通路 | 位元組 | 分隔與行尾 | 過不過 csvCell |
|---|---|---|---|
generateCSV() | 435 | 逗號 + CRLF | 過 |
| 下載的 Blob | 438 | 再加 3 個位元組 BOM | 過 |
copyTable() | 413 | 定位字元 + LF | 不過 |
三個數出自這篇 3 筆記錄、24 欄、表頭開且不展平的範例:25 個位元組的差 = 3 個位元組 BOM + 4 個行終止符 CR + 18 個引號字元 − 被 trim 削掉的行尾字元。
不轉義的後果:輸入 [{"a":"x","b":"甲\t乙","c":"d"},{"a":"p\nq","b":"r","c":"s"}],csv 31 個位元組、欄數守得住,複製文字每行的定位字元數卻是 2,3,0,2、共 4 個物理列。而 [{"a":" x "}] 關表頭時 csv 7 個位元組保住兩側空格,複製只剩 1 個位元組。在 JSON 轉 Excel 裡走複製之前,我一般先確認值裡沒有定位字元和換行。
圖 2:一份輸入、三條出口。
16 個鍵進、4 個欄出
第 139、142 行用 {} 做去重集合,查的是原型鏈,於是與 Object.prototype 同名的鍵一進來就是真值、永遠進不了 fields。實測吃掉 12 個,含 constructor、toString、valueOf、__proto__;把 length、name、value、x 這四個正常鍵一起塞進同一筆記錄,16 鍵進、列: 4。
聯集被吃空後第 191 行退化成只取第一筆記錄的鍵:輸入 [{"constructor":1},{"toString":2},{"valueOf":3}],預覽 0 欄,csv 卻有 90 個位元組,兩次出現 33 個字元的 function Object() { [native code] },出自第 200 行的 String(v)。第 111 行又假定 hasOwnProperty 一定存在,[{"hasOwnProperty":1,"a":2}] 勾展平就拋 obj.hasOwnProperty is not a function。
不勾展平時值變成了什麼
第 21 行的 j2e-flatten 預設不勾,第 134 行因此不做展平,巢狀值被 String(v) 寫成 15 個字元的 [object Object]:這篇 addr 欄兩列都是它,北京和 100000 在匯出裡找不到;陣列變成加引號的 "1,2"。
空物件更隱蔽:{"a":{},"b":1} 勾展平後 2 鍵進 1 欄出,整欄消失;null 只把值寫空、欄保留。而 null、空字串 "" 和「這一列沒有這個鍵」在 CSV 裡都是 a,b\r\n1,\r\n,位元組完全相同。
這一頁產的是 CSV
下載檔案前 8 個位元組實測 ef bb bf 6e 61 6d 65 2c,BOM 之後直接是 name,;ZIP 簽名 50 4b 03 04 與 OLE 簽名 d0 cf 11 e0 都沒命中,type 是 23 個字元的 text/csv;charset=utf-8;。貼進表格軟體那一端要防什麼,json-to-excel 與 CSV 那篇寫過。
escHtml(第 176–178 行只動 &、<、> 和雙引號這 4 個字元)只被第 151 行的 <th> 與第 159 行的 <td> 呼叫,匯出通路 0 次,所以它只污染預覽,text.trim() 只影響複製文字,下載的位元組裡兩樣都不摻。
問答
Q:欄數為什麼比記錄裡見過的鍵少?
兩種:鍵名撞上 Object.prototype 的那 12 個成員,整欄被吃掉;或者值是空物件 {},勾展平後整欄消失。
Q:下載為什麼比複製多 25 個位元組?
差在 3 個位元組 BOM、4 個行終止符 CR 和 18 個引號字元上,再扣掉 trim 削去的行尾字元;但下載 = csv + 3 這條不變。
Q:能讓分號或以 = 開頭的值也加引號嗎?
不能,第 208 行那個字元集沒有開關,頁面也不是全量轉義。