作者简介: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 等等。
当使用 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 和 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-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。
附: