聊一聊字符集与编码系列
���˶��ǹ���chan���̣�pin��ʦ��jingli���� 这是一段“经典”的乱码字符,内功深厚的码佬可以试试根据下面的“线索”把这句话变回来 😏 线索 中文 二进制数据:<Buffer c8 cb c8 cb b6 bc ca c7 b9 a4 a3 a8 63 68 61 6e a3 a9 b3 cc a3 a8 70 69 6e a3 a9 ca a6 a3 a8 6a 69 6e 67 6c 69 a3 a9 a1 a3> 平常的开发过程中,多多少少会碰到一些文字乱码问题,有时搜到解决办法复制粘贴就完事了,可是下次碰到时还是要百度 Google,一片糨糊,啊这~ 授人以鱼不如授人以渔,清楚其工作原理才能真正地了解字符集与编码。 编码流程 不同的字符集/编码 encoding 实现方式不一样,不过整个流程大同小异。二进制数据经过编码后转化为特定编码,然后通过预定义的字符集映射到某个字符。 下面列举了三个常见字符集 ASCII、GBK、UTF-8 的流程: +---------+ | 图形文字 | +---------+ ^ | 映射 | /------------\ | 字符集/编码 | \------------/ ^ | 编码 | 二进制数据 +------+ | doge | +------+ ^^^^ |||| 映 射 |||| /------------------------------\ | ASCII | \------------------------------/ ^ ^ ^ ^ | | | | charCode 100 111 103 101 | | | | 1100100 1101111 1100111 1100101 -------- -------- -------- -------- 1 Byte 1 Byte 1 Byte 1 Byte +-----+ | 狗头 | +-----+ ^ ^ | | 映射 | | /----------------------\ | GBK | \----------------------/ ^ ^ | | 区位码 (25, 23) (45, 23) | | | | 10111001 10110111 11001101 10110111 ----------------- ----------------- 双字节 双字节 +-------+ | 狗🐶 | +-------+ ^ ^ | | 映射 | | /----------------------------------\ | UTF-8 | \----------------------------------/ ^ ^ | | [代理码位] Unicode 码点 U+72D7 U+1F436[U+DC36] | | 11100111 10001011 10010111 11110000 10011111 10010000 10110110 -------------------------- ----------------------------------- 三字节 四字节 对字符串进行转义处理的方式某种程度上也能看作是“编码”,比如 base64 和 encodeURIComponent() 函数,不过它们的编码流程通常是处理字符类型数据,而不直接操作二进制。 不同字符集/编码储存二进制数据的方式是不一样的,所以二进制数据一般只能通过特定的字符集/编码转化成文本,比如“狗”使用 GBK 储存,其数据大小为 2B,若使用 UTF-8 转换此二进制数据得到的字符实际上是乱码,而只能通过 GBK 转换回可读文本。 由此可见,乱码是没有任何意义的,二进制数据在储存/编码的过程中“精度”像是丢失了,乱码字符无法再转换回原来的文本了。 实例一:网页乱码 对于 Web 初学者来说,通常碰到的第一个乱码问题就是浏览器渲染的网页乱码;又或者在写一些爬虫工具时,拉取的网页内容变成乱码。 解决办法就是使用正确的字符集/编码。 倘若保存的网页文件为 UTF-8 格式,那 <head> 元素里应指定为相同的 UTF-8 编码。比如下面的 index.html 文件在 Chrome 里就会显示乱码: <html> <head> <meta charset="GBK"> <!-- 错误 --> </head> <body>Welcome! 你好,世界</body> </html> 解决方案有两种: 修改字符集 GBK 为 UTF-8;或者删除 charset 声明(默认编码即 UTF-8,这也是 w3c 规范所指定的)文件 index.html 另存为 GBK 编码格式 早期 Windows 上记事本默认格式为 ANSI(中文环境下为 GBK)。 Unicode 未普及之前中文环境下很多经典软件保存的默认编码基本上都是 GBK 格式,不乏 Web IDE 软件,这也是导致网页乱码的起源之一。 17 年课设写的一个百度文库的爬虫的时候,也碰到过拉取的网页乱码。貌似以前的百度首页使用的就是中文字符集,而很多编程语言处理字符的默认编码是 UTF-8,导致 Python 的 urllib 爬取的网页源文本处理后变成了乱码。。。 这种情况解决办法是需要指定 GBK 编码,使用类似 iconv 这样的库对下载的网页的二进制数据进行转换,或者使用编程语言里原生处理字符的接口(比如 JavaScript 里 TextDecoder): import iconv from 'iconv-lite'; const res = await fetch(url); const buff = await res.arrayBuffer(); iconv.decode(buff, 'gbk'); // iconv new TextDecoder('gbk').decode(buff); // 原生 API 字符集 character set 字符集通常是一系列收录字符的集合,且包含其特定的编码(储存二进制)方式。 标准化 用的人多了渐渐地就成为标准了,就像学校里那些小草坪上的路一样。 +-------------+----------------+--------------------------+ | ASCII | | | +-------------+ ISO88591 | | | | Unicode | +------------------------------+ | | | +---------------------------------------------------------+ * ASCII 应用最广泛的、最早的一套标准字符集,由美国电子信息协会 1963 年 发布,其编码范围为 0-127,主要收录了拉丁字母、阿拉伯数字、 英式标点符号等等 128 个字符。 ------------- * ISO88591 更通俗的别名是 latin-1,由国际标准化组织发布的第一套 8 位 字符集,其编码范围为 0-255,基于 ASCII 又添加了一些拉丁字母和 符号(前 128 个字符和 ASCII 完全一样)。 ------------- * Unicode 伴随着互联网的发展,Unicode 如今成为了全球化的标准,使计算机能够 显示世界上绝大部分文字,至今仍在更新,收录了超过 14 万个字符(汉字 约八万余),向下兼容 ASCII 与 ISO88591,其编码范围由实现方式决定, Unicode 有多种实现方式,最广泛应用是 UTF-8。 UTF-8 是一种可变字节长度的字符集,理论上可用 1-6 个字节编码一个 字符,但在 rfc 规范中其编码范围为 0-0x10FFFF(至多四个字节)。 * ASCII 字符只需 1 个字节 * 更多的西欧字符等需要 2 个字节 * 其它字符(大部分汉字)需要 3 个字节 中文 +-------------+-----------+-----------+-------------------+ | GB2312 | | | +-------------+ CP936 | | | GBK | GB18030 | +-------------------------+-----------+ | | | +---------------------------------------------------------+ * GB2312 我国最早发布的简体中文字符集,由国家标准总局 1980 年发布,简称 GB(“国标拼音首字母”),收录了约七千字和部分西欧字母。GB2312 对 汉字进行“分区”处理,每区含 94 个字符,共计 94 个分区,用所在的 区和位表示汉字,是一种双字节编码。例如“万”在45区82位,所以区位码 为 (45,82)。 --------------- * CP936,GBK GBK 是基于 GB 字符集的扩展(“K”为拼音首字母),于 1995 年制订, 增录了两万余汉字(包括偏旁、部首)和图形符号。GBK 用 1-4 个字节 编码一个字符,因此单字节的字符与 ASCII 一致。 CP936 是 Windows 系统上中文字符集的编号,微软称为 Code Page (代码页),近似于 GBK 的子集。 --------------- * GB18030 最新的中文标准字符集,发布于 2000 年,收录了七万余汉字(包括少数 民族的文字),兼容 GBK 与 Unicode,其编码长度有 3 种: 1. 单字节,与 ASCII 一致 2. 双字节,与 GBK 一致 3. 四字节,Unicode 实现 GB18030 与 UTF-8 并不兼容,它们是 Unicode 两种不同的实现方式。 常见字符集的应用 ASCII 称的上是“古老”的基础字符集/编码,如今很多标准字符集向其兼容(或者说是基于其进行扩展),而且众所周知现代计算机的软硬件开发都是英语,ASCII 正是为此而设计,因此熟悉它是非常有必要的。 Latin-1 早期在西欧国家更为通用,很多经典的软件和库处理文本数据时都是使用它,耳熟能详的 MySQL 采用的默认编码。 UTF-8 于日益全球化后被广泛采用,现在很多流行的编程语言与文本编辑器默认使用的就是它,包括但不限于 PHP、Java、Python、HTML 等,Windows 记事本、VS Code 等等,也是 Unix 系统的默认编码,收录的字符非常全面(包括 emoji 表情 😀),堪称开发人员“必背”。 UTF-16 属于 Unicode 的另一种实现方式,编码(二进制 <--> Unicode 码点)方式与 UTF-8 有所不同,也是较为通用的字符集。JavaScript 语言中的字符类型就是 UTF-16 编码。实际上因为平台化差异,具体可分为大尾序和小尾序两种:utf-16be、utf-16le。 关于 BOM 头 经验老道的开发者可能听到过很多次这个名词,全称 Byte Order Mark,顾名思义就是一个字节顺序的标记。 上面提到 UTF-16 具体有两种,一般苹果系统使用的是大尾序,Windows 和 Linux 使用的是小尾序。为了区分 UTF-16 文件的大小尾序,此格式的文件开头就会被放一个 BOM 头: U+FFFE 代表 UTF-16 LE 小尾序U+FEFF 代表 UTF-16 BE 大尾序 U+FEFF 字符在 Unicode 中代表的意义是 ZERO WIDTH NO-BREAK SPACE,说的是个没有宽度也没有断字的空白,不可见。 以前的一些软件在保存 UTF-8 格式时,也会添加 BOM 头,事实上 UTF-8 储存时没有字节顺序一说,所以现在 UTF-8 都会省略,但并不排除可能存在,碰到什么文件编码格式错误时,不妨考虑考虑是否存在 BOM 头。 UTF-16 存在字节顺序是因为其编码方式,其 Unicode 码点一般由 1 个或 2 个码元组成,每个码元占用 2 个字节,而这两个字节不同平台读取时是有顺序的。如下表: 字符 UTF-16 十六进制编码 UTF-16 BE 表示 UTF-16 LE 表示 $ 0024 00 24 24 00 𤭢 D852 DF62 D8 52 DF 62 52 D8 62 DF GB2312 的出现,满足了计算机处理中文的基本需求,收录的汉字覆盖了约 99% 的使用频率,时过境迁已经变成“历史产物”了。 GBK 字符集更为全面,向下兼容 GB,处理中文字符的不二之选,貌似好像也就这一种选择,,,已知的中文字符集都是国家部门编订,科技公司好像没什么贡献,不过仔细想想应该是跟业务有关,我国没几个 OEM 厂商。CP936 字符集被使用在微软的中文 Windows 系统,微软也称之为代码页,打开 cmd.exe 右键“属性”就能看到或输入 chcp 命令查看。 GB18030 是现行标准,向下兼容并且包含 Unicode 实现,大多程序语言与第三方库都将其纳入支持的编码。 BIG5 繁體中文字符集,“大五碼”~名字很魔性。 不知道有没有同学会通过磁力 torrent 下载一些国外的电影和动漫 😏,这些文件通常有标明内嵌字幕的代码 [GB]、[BIG5] 啊什么的。 很多其它字符集也可以在 iconv-lite 的仓库文件里找到:encodings/dbcs-data.js, encodings/sbcs-data.js; 乱码问题s 了解完计算机处理字符的基本编码流程和常用字符集后,碰到乱码问题不说是“信手拈来”,至少能做到“心里有数”了。 实例一:网页乱码 实例二:数据库乱码 入门的时候和学校里教学用的基本都是 MySQL 数据库,而其默认字符集/编码是 latin-1,这如果在连接和插入时没有指定正确的编码,非常容易导致乱码或者执行 SQL 语句失败,不仅仅是说什么中文乱码了,其它日文、韩文什么的也一样,因为它就只支持那 256 个字符。 解决这种乱码问题一般得改两个地方,一处是要建立 MySQL 连接,一处是 MySQL server 要储存数据(库与表)。 SET NAMES utf8; # 连接 SET CHARACTER SET utf8; CREATE DATABASE 'xx' CHARACTER SET utf8; # 库与表 CREATE TABLE 'xx' CHARACTER SET utf8; 插入 emoji 表情失败,是由于数据库的编码历史遗留问题导致的。前面介绍过 UTF-8 至多会有四个字节,而 MySQL 当时在支持 UTF-8 编码时 rfc 草案限定了至多 3 字节,大部分的汉字储存时占用就是 3 字节,处理中文是没问题了,但新出现的 emoji 字符增长到了 4 字节,因此插入数据时就将报错。 MySQL 之后推出的 utf8mb4 字符集解决了四字节 UTF-8 字符的问题。建议现在创建数据库和表时都采用 utf8mb4 编码。 有大数据储存要求的话,英文环境下数据大小是完全一样的,因为 UTF-8 是一种可变字节长度的格式,保存一个拉丁字母时 latin-1 和 utf8mb4 占用的大小都只有一个字节,如果是常用的中文,gbk 相较于 utf8mb4 则更具有优势,占用的字节少一个,每十亿个中文字符可节省 ~1Gb 空间。 实例三:控制台乱码 有时在控制台里执行命令时,输出的是一堆看不懂的乱码,通常是在 Windows 中文环境下。拿 Node.js 举例: $ node -e "process.stdout.write(child_process.execSync('echo 狗头'))" ��ͷ 这是因为执行 echo 狗头 的程序 cmd.exe 默认代码页(字符集)是 cp936,属于中文字符集 gbk,而 node 处理字符时默认使用 UTF-8,这导致输出的二进制数据被错误编码为乱码。 解决方法之一是指明二进制数据的编码。 import { execSync } from 'child_process'; import iconv from 'iconv-lite'; const buff = execSync('echo 狗头'); // 需要手动指定 new TextDecoder('gbk').decode(buff); // "狗头" iconv.decode(buff, 'cp936'); // "狗头" // 默认的导致乱码 buff.toString(); // "��ͷ" new TextDecoder().decode(buff); // "��ͷ" 另一种办法是直接输出到 cmd.exe: $ node -e "child_process.execSync('echo 狗头',{stdio:[0,process.stdout,2]})" 狗头 JavaScript 世界中的字符 推荐阅读 MDN 上关于 binary strings 的介绍。 JS 字符串储存在内存中的二进制数据是 UTF-16 格式,能表示的码点范围 0-65535,超出的码点和整数“溢出”一样,将导致预期之外的结果。 String.fromCodePoint(2**16) === String.fromCodePoint(0); // true // js 中最大有效码点 String.fromCodePoint(2**16 - 1); // UTF-16 由一个或两个码元组成,一个字符至少需要 2 字节,也就是 16 位二进制。 // 而实际上 UTF-16 编码中二进制与 Unicode 码点之间不是直接转换的, // 这个最大码点范围限制应该是语言设计,与码元储存的位数无关。 这是语言底层的实现,正常开发过程中敲的代码保存到 html 文件或 js 文件仍是 UTF-8 格式,这意味着给 V8 引擎输入的是 UTF-8 字符,编译后输出、保存在内存中的是 UTF-16 格式。 语言的底层实现不必太过关心,字符集/编码的关键是要操纵二进制数据。JavaScript 提供 ArrayBuffer 抽象接口来操作二进制,字符类型的数据处理一般使用 Uint8Array,字符储存的最小字节即 8 位。Node.js 中 Buffer 就是基于 Uint8Array。 const buff = new ArrayBuffer(6); // 因为 ArrayBuffer 是抽象的,不能直接进行读写,需使用 TypedArray // 进行处理,比如 Uint8Array、Int32Array、Float64Array 等等 const u8s = new Uint8Array(buff); const dogeBinary = new TextEncoder().encode('狗头'); u8s = dogeBinary.copyWithin(0, 0); new TextDecoder().decode(u8s); // "狗头" // Node.js 可使用 Buffer Buffer.from(u8s).toString(); // "狗头" // iconv-lite iconv.decode(Buffer.from(u8s), 'utf-8'); // "狗头" 字符串的字节大小是由字符集/编码决定的: iconv.encode('abc', 'ascii').length === 'abc'.length; // true // 多字节字符串长度与字节大小不相等 '狗头'.length; // 2 // GBK 是双字节编码 iconv.encode('狗头', 'gbk').length; // 4 // UTF-8 中大部分汉字三字节 iconv.encode('狗头', 'utf-8').length; // 6 二进制接口 ArrayBuffer JS 中很少会处理二进制数据,作为一门高级语言其内置 API 封装的功能基本上满足了网页 99% 的需求,除非是需要直接处理源文件,譬如非纯文本格式的文件:.mp4 视频、.png 图片等等。 下面是一些获得二进制数据的常见 API: // XMLHttpRequest var xhr = new XMLHttpRequest(); xhr.responseType = 'arraybuffer'; xhr.open('GET', url); xhr.send(); var buffer = xhr.response; // FileReader const reader = new FileReader(); reader.onload = (ev) => { const buffer = ev.target.result; }; reader.readAsArrayBuffer(); // Node http 网络模块 http.request(url, (res) => { const buff = []; res.on('data', (chunk) => buff.push(chunk)); res.on('end', () => { const buffer = Buffer.concat(buff); }); }); // Node fs 文件模块 const buffer = await fs.readFile(path); 总的来说,常用的字符集/编码就那么几种,详细实现方式不管,牢记个大概还是很有必要的,而且它们在各种编程语言都很通用,一招鲜吃遍天 😎 参考链接 维基百科上的 UTF-8、ASCII、ISO/IEC 8859-1、Unicode、GB2312、GBK、GB18030、UTF-16The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)
JavaScript全屏阅读