作者簡介:nekron 螞蟻金服·資料體驗技術團隊
背景
因為中文的博大精深,以及早期檔案編碼的不統一,造成了現在可能碰到的檔案編碼有GB2312
、GBk
、GB18030
、UTF-8
、BIG5
等。因為編解碼的知識比較底層和冷門,一直以來我對這幾個編碼的認知也很膚淺,很多時候也會疑惑編碼名到底是大寫還是小寫,英文和數字之間是不是需要加“-”,規則到底是誰定的等等。
我膚淺的認知如下:
編碼 | 說明 |
---|---|
GB2312 | 最早的簡體中文編碼,還有海外版的HZ-GB-2312 |
BIG5 | 繁體中文編碼,主要用於臺灣地區。些繁體中文遊戲亂碼,其實都是因為BIG5編碼和GB2312編碼的錯誤使用導致 |
GBK | 簡體+繁體,我就當它是GB2312+BIG5,非國家標準,只是中文環境內基本都遵守。後來瞭解到,K居然是“擴充套件”的拼音首字母,這很中國。。。 |
GB18030 | GB家族的新版,向下相容,最新國家標準,現在中文軟體都理應支援的編碼格式,檔案解碼的新選擇 |
UTF-8 | 不解釋了,國際化編碼標準,html現在最標準的編碼格式。 |
概念梳理
經過長時間的踩坑,我終於對這類知識有了一定的認知,現在把一些重要概念重新整理如下:
首先要消化整個字元編解碼知識,先要明確兩個概念——字符集和字元編碼。
字符集
顧名思義就是字元的集合,不同的字符集最直觀的區別就是字元數量不相同,常見的字符集有ASCII字符集、GB2312字符集、BIG5字符集、 GB18030字符集、Unicode字符集等。
字元編碼
字元編碼決定了字符集到實際二進位制位元組的對映方式,每一種字元編碼都有自己的設計規則,例如是固定位元組數還是可變長度,此處不一一展開。
常提到的GB2312、BIG5、UTF-8等,如果未特殊說明,一般語義上指的是字元編碼而不是字符集。
字符集和字元編碼是一對多的關係,同一字符集可以存在多個字元編碼,典型代表是Unicode字符集下有UTF-8、UTF-16等等。
BOM(Byte Order Mark)
當使用windows記事本儲存檔案的時候,編碼方式可以選擇ANSI(通過locale判斷,簡體中文系統下是GB家族)、Unicode、Utf-8等。
為了清晰概念,需要指出此處的Unicode,編碼方式其實是UTF-16LE。
有這麼多編碼方式,那檔案開啟的時候,windows系統是如何判斷該使用哪種編碼方式呢?
答案是:windows(例如:簡體中文系統)在檔案頭部增加了幾個位元組以表示編碼方式,三個位元組(0xef, 0xbb, 0xbf)表示UTF-8;兩個位元組(0xff, 0xfe或者0xfe, 0xff)表示UTF-16(Unicode);無表示GB**。
值得注意的是,由於BOM不表意,在解析檔案內容的時候應該捨棄,不然會造成解析出來的內容頭部有多餘的內容。
LE(little-endian)和BE(big-endian)
這個涉及到位元組相關的知識了,不是本文重點,不過提到了就順帶解釋下。LE和BE代表位元組序,分別表示位元組從低位/高位開始。
我們常接觸到的CPU都是LE,所以windows裡Unicode未指明位元組序時預設指的是LE。
node的Buffer API中基本都有相應的2種函式來處理LE、BE,貼個文件如下:
const buf = Buffer.from([0, 5]);
// Prints: 5
console.log(buf.readInt16BE());
// Prints: 1280
console.log(buf.readInt16LE());
複製程式碼
Node解碼
我第一次接觸到該類問題,使用的是node處理,當時給我的選擇有:
- node-iconv(系統iconv的封裝)
- iconv-lite(純js)
由於node-iconv涉及node-gyp的build,而開發機是windows,node-gyp的環境準備以及後續的一系列安裝和構建,讓我這樣的web開發人員痛(瘋)不(狂)欲(吐)生(嘈),最後自然而然的選擇了iconv-lite。
解碼的處理大致示意如下:
const fs = require('fs')
const iconv = require('iconv-lite')
const buf = fs.readFileSync('/path/to/file')
// 可以先擷取前幾個位元組來判斷是否存在BOM
buf.slice(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf])) // UTF-8
buf.slice(0, 2).equals(Buffer.from([0xff, 0xfe])) // UTF-16LE
const str = iconv.decode(buf, 'gbk')
// 解碼正確的判斷需要根據業務場景調整
// 此處擷取前幾個字元判斷是否有中文存在來確定是否解碼正確
// 也可以反向判斷是否有亂碼存在來確定是否解碼正確
// 正規表示式內常見的\u**就是unicode碼點
// 該區間是常見字元,如果有特定場景可以根據實際情況擴大碼點區間
/[\u4e00-\u9fa5]/.test(str.slice(0, 3))
複製程式碼
前端解碼
隨著ES20151的瀏覽器實現越來越普及,前端編解碼也成為了可能。以前通過form表單上傳檔案至後端解析內容的流程現在基本可以完全由前端處理,既少了與後端的網路互動,而且因為有介面反饋,使用者體驗上更直觀。
一般場景如下:
const file = document.querySelector('.input-file').files[0]
const reader = new FileReader()
reader.onload = () => {
const content = reader.result
}
reader.onprogerss = evt => {
// 讀取進度
}
reader.readAsText(file, 'utf-8') // encoding可修改
複製程式碼
fileReader支援的encoding列表,可查閱此處。
這裡有一個比較有趣的現象,如果檔案包含BOM,比如宣告是UTF-8編碼,那指定的encoding會無效,而且在輸出的內容中會去掉BOM部分,使用起來更方便。
如果對編碼有更高要求的控制需求,可以轉為輸出TypedArray:
reader.onload = () => {
const buf = new Uint8Array(reader.result)
// 進行更細粒度的操作
}
reader.readAsArrayBuffer(file)
複製程式碼
獲取文字內容的資料緩衝以後,可以呼叫TextDecoder繼續解碼,不過需要注意的是獲得的TypedArray是包含BOM的:
const decoder = new TextDecoder('gbk')
const content = decoder.decode(buf)
複製程式碼
如果檔案比較大,可以使用Blob的slice來進行切割:
const file = document.querySelector('.input-file').files[0]
const blob = file.slice(0, 1024)
複製程式碼
檔案的換行不同作業系統不一致,如果需要逐行解析,需要視場景而定:
- Linux: \n
- Windows: \r\n
- Mac OS: \r
**注意:**這個是各系統預設文字編輯器的規則,如果是使用其他軟體,比如常用的sublime、vscode、excel等等,都是可以自行設定換行符的,一般是\n或者\r\n。
前端編碼
可以使用TextEncoder將字串內容轉換成TypedBuffer:
const encoder = new TextEncoder()
encoder.encode(String)
複製程式碼
值得注意的是,從Chrome 53開始,encoder只支援utf-8編碼2,官方理由是其他編碼用的太少了。這裡有個polyfill庫,補充了移除的編碼格式。
前端生成檔案
前端編碼完成後,一般都會順勢實現檔案生成,示例程式碼如下:
const a = document.createElement('a')
const buf = new TextEncoder()
const blob = new Blob([buf.encode('我是文字')], {
type: 'text/plain'
})
a.download = 'file'
a.href = URL.createObjectURL(blob)
a.click()
// 主動呼叫釋放記憶體
URL.revokeObjectURL(blob)
複製程式碼
這樣就會生成一個檔名為file的檔案,字尾由type決定。如果需要匯出csv,那隻需要修改對應的MIME type:
const blob = new Blob([buf.encode('第一行,1\r\n第二行,2')], {
type: 'text/csv'
})
複製程式碼
一般csv都預設是由excel開啟的,這時候會發現第一列的內容都是亂碼,因為excel沿用了windows判斷編碼的邏輯(上文提到),當發現無BOM時,採用GB18030編碼進行解碼而導致內容亂碼。
這時候只需要加上BOM指明編碼格式即可:
const blob = new Blob([new Uint8Array([0xef, 0xbb, 0xbf]), buf.encode('第一行,1\r\n第二行,2')], {
type: 'text/csv'
})
// or
const blob = new Blob([buf.encode('\ufeff第一行,1\r\n第二行,2')], {
type: 'text/csv'
})
複製程式碼
這裡稍微說明下,因為UTF-8和UTF-16LE都屬於Unicode字符集,只是實現不同。所以通過一定的規則,兩種編碼可以相互轉換,而表明UTF-16LE的BOM轉成UTF-8編碼其實就是表明UTF-8的BOM。
附:
本文介紹了字元編解碼,感興趣的同學可以關注專欄或者傳送簡歷至'tao.qit####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~