JavaScript与字符编码

198 阅读11分钟

编码是编程的基础,本文重点介绍了字符编码在Java Script中的行为表现。

文中若有错误,欢迎抓虫。欢迎讨论。

第一章 先导概念:字符编码模型

本章讲解了关于字符编码的抽象概念,完整阅读有助于理解Unicode,ASCII,UTF之间的层级关系。

1.1 字符编码的现代编码模型

字符编码的现代编码模型在Unicode Technical Report (UTR) #17中,现代编码模型分为5个层次,所用的术语列在下面:

  1. 抽象字符表(Abstract character repertoire)

是一个语言系统支持的所有语言符号的集合。

举例来说,中文的每个汉字都可以理解为一个抽奖字符表;中文的比划符号也可以理解为抽象字符表,日文的50音字符也可以理解为一个抽奖字符表。

  1. 编码字符集(CCS:Coded Character Set)

是将 抽奖字符表 看作一个字符集合,将其中每个字符元素映射到一个数值上。字符集合及其映射统称为 编码字符集

本文的关键概念之一Unicode就是国际通用的编码字符集

  1. 字符编码表(CEF:Character Encoding Form)

字符编码表 基于 编码模型的 第二层:编码字符集 。进行了进一步的编码抽象。它将 编码字符集 中的 码位 转换成更节约存储空间的 码元

典型案例:UTF-8,UTF-16

  1. 字符编码方案(CES:Character Encoding Scheme)

为了方便文件存储或者传输,也可以压缩整体文件大小。

  1. 传输编码语法(transfer encoding syntax)

用于处理上一层次的字符编码方案提供的字节序列。一般其功能包括两种:一是把字节序列的值映射到一套更受限制的值域内,以满足传输环境的限制,例如Email传输时Base64或者quoted-printable,都是把8位的字节编码为7位长的数据;另一功能是压缩字节序列的值,如LZW或者行程长度编码等无损压缩技术。

典型案例:BASE64

1.2 编码空间(encoding space)

编码空间(encoding space)简单说就是包含指定字符的映射关系表。

编码空间 可以用其子集来表述,如行、列、面(plane)等。

编码空间中的一个位置(position)称为码位(code point)。

一个字符所占用的码位称为码位值(code point value)。

一个 第二层:编码字符集(例如Unicode) 就是把抽象字符映射为码位值。

1.3 码位(code point或code position)

码位是 Unicode 中一个字符的完整标识。在文中可以理解为 “第二层:编码字符集” 的基础单位。

该概念在《JavaScript高级程序设计(第4版)》中被翻译为码点。

例如,ASCII码包含128个码位,范围是16进制的0~7F(等同于10进制的0~127)。

而扩展ASCII码包含256个码位,范围是16进制的0~FF(等同于10进制的0~255)。

那么Unicode就包括1,114,112个码位,范围是16进制的0~10FFFF(等同于10进制的0~1,114,111)

注意:code Point 这个概念就是Javascript ES6方法中的String.fromCodePointString.codePointAt

1.4 码元(code unit)

码元在文中可以理解为 “第三层:字符编码表” 的基础单位。

注意:code Unit 是Javascript中String.fromCharCode的返回值,以及String.charCodeAt的输入值。

第二章:常见的字符编码集

本章汇总了前端开发过程中常见的字符编码集以及其定义,文中提及的粗体关键词均可搜索。

2.1 ASCII(American Standard Code for Information Interchange)

ASCII美国信息交换标准代码,起源于电报码。

局限在于只能显示26个基本拉丁字母、阿拉伯数字和英式标点符号,因此只能用于显示现代美国英语(且处理naïve、café、élite等外来语时,必须去除附加符号)。

虽然EASCII解决了部分西欧语言的显示问题,但对更多其他语言依然无能为力。

因此,现在的软件系统大多采用Unicode。

按照 字符编码的现代编码模型 定义,它可以划分到 第二层:编码字符集,和Unicode同级。

2.2 Unicode

Unicode,全称为Unicode标准(The Unicode Standard)。

它是国际统一通用的编码格式,它编码了世界上大部分的文字系统,使得电脑能以通用划一的字符集来处理和显示文字,不但减轻在不同编码系统间切换和转换的困扰,更提供了一种跨平台的乱码问题解决方案。

USC-2 是过时的 Unicode 标准版本,用于表示计算机和其他设备中的字符。

它在20世纪90年代后期被 Unicode 标准版本3(USC-3)所取代,后来又被当前版本 Unicode 13.0所取代。

USC-2用于编码范围广泛的字符,包括许多现代书面语言中使用的字符,以及符号和表情符号。它使用16位(2字节)来表示每个字符,允许它表示多达65,536个不同的字符。但是,这还不足以涵盖世界各地使用的所有字符,因此 Unicode 后来引入了更大字符集的其他版本。

Unicode最普遍的编码格式是和ASCII兼容的UTF-8,以及和UCS-2兼容的UTF-16。

UTF-8 和 UFT-16 中的UTF都是"Unicode/UCS Transformation Format"的首字母缩写。

UCS 是 Universal Character Set 的首字母缩写。

在表示一个Unicode的字符时,通常会用“U+”然后紧接着一组十六进制的数字来表示这一个字符。

基本多语言平面(BMP) 里的所有字符,要用四个数字(即2字节,共16位,例如U+4AE0,共支持六万多个字符);而在其他平面中的字符则需要使用五或六个数字,这种方式可以称为 代理对(Surrogate Pair)

旧版的Unicode标准使用相近的标记方法,但却有些微小差异:

在Unicode 3.0里使用“ U- ”然后紧接着八个数字,

而“ U+ ”则必须随后紧接着四个数字。

Unicode通过一个明确的名称和一个称为码位的整数来识别字符。

下文中出现的 U+ 前缀是指Unicode编码格式,后跟的数字皆为16进制。

例如,©字符被命名为 "版权标志",并以 U+ 00A9 作为其 码位

2.2.1 Unicode字符平面映射

Unicode字符平面映射是指Unicode的 编码空间 被划分为17个平面,每个平面有2^16(65,536)个码位。

其中一些码位还没有被分配到字符值,一些被保留给私人使用,还有一些被永久保留为非字符。

每个平面的码位都可以表示为十六进制值 xy 0000到 xy FFFF,其中 xy 是一个从00到10的十六进制值,标志着这些值属于哪个平面。

2.2.2 基本多语言平面(BMP)

基本多语言平面(BMP: Basic Multilingual Plane)Unicode字符平面映射 的第一个平面(xy为00),它包含了从U+0000到U+FFFF的代码点,这些是最常用的字符。

而中文文字也属于这个平面中,被称为中日韩统一表意文字,上表没有收录的生僻字大概无法在互联网世界中正常传达。

这种平面划分方式也是 编码空间 的典型案例。

其他16个平面(U+010000→U+10FFFF)被称为 补充平面

2.2.3 UTF-8(8-bit Unicode Transformation Format)

UTF-8

UTF-16比起UTF-8,好处在于大部分字符都以固定长度的字节(2字节)存储,但UTF-16却无法兼容ASCII编码。

UFT-8兼容ASCII编码。

2.2.4 代理对(Surrogate Pair)

一个 代理对 由两个 码元 组成,映射着Unicode中的一个 码位

BMP之外的字符,例如 U+1D306 的中心四角形(𝌆),只能在UTF-16中使用两个16位码元进行编码:0xD834 0xDF06。这被称为代理对。

请注意,一个代理对只代表一个字符。

代理对的第一个码元总是在0xD800到0xDBFF的范围内,Unicode标准现在称高位代理为前导代理(lead surrogates)。

代理对的第二个码元总是在0xDC00到0xDFFF的范围内,Unicode标准现在称低位代理为后尾代理(trail surrogates)。

UCS-2缺乏代理对的概念,因此将0xD834 0xDF06(以前的UTF-16编码)解释为两个独立的字符。


"𝌆".length //2

"𝌆".charCodeAt(0).toString(16).toUpperCase() //'D834'

"𝌆".charCodeAt(1).toString(16).toUpperCase() //'DF06'

2.2.5 字素簇(grapheme clusters)

字素簇 是以特定规则组成的Unicode字符序列。

emoji就是字素簇的典型案例:很多具有一系列变化的 emoji 实际上是由多个 emoji组成的,通常由名为 零宽连字(zero-width joiner,ZWJ) 的控制字符连接。

第三章 Java Script 应用实例

本章讲述了上述编码在Javascript中的实际应用。

3.1 码位和代理对的转换方式

Unicode标准3.0的第三章第七小节定义了代理对的转换算法。

我们将一个大于十六进制0xFFFF的码位指定为变量 C ,可以转换成由两个关键值变量 HL 组成的代理对 <H,L>。

转换算法如下:


H = Math.floor((C - 0x10000) / 0x400) + 0xD800

L = (C - 0x10000) % 0x400 + 0xDC00

而从代理对 <H,L> 转回变量 C 的方式如下:


C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000

3.1.1 零宽连字(zero-width joiner,ZWJ)

零宽连字(zero-width joiner,ZWJ)是一个控制字符,放在某些需要复杂排版语言(如阿拉伯语、印地语)或者Emoji的两个字符之间,使得这两个本不会发生连字的字符产生了连字效果。零宽连字符的Unicode码位是U+200D。


U+200D (HTML中的写法: &#8205; ‍ 若对该格式有疑问,请在文内搜索关键词“字符实体引用”)

对应的还有零宽不连字(zero-width non-joiner),本文不做讲解。

下列代码展示了emoji在Javascript字符串中的表现:


"😄".split(""); // ['\ud83d', '\ude04'];

//splits into two lone surrogates

// "Backhand Index Pointing Right: Dark Skin Tone"

[..."👉🏿"]; // ['👉', '🏿']

// splits into the basic "Backhand Index Pointing Right" emoji and

// the "Dark skin tone" emoji

// "Family: Man, Boy"

[..."👨‍👦"]; // [ '👨', '‍', '👦' ]

// splits into the "Man" and "Boy" emoji, joined by a ZWJ

// The United Nations flag

[..."🇺🇳"]; // [ '🇺', '🇳' ]

// splits into two "region indicator" letters "U" and "N".

// All flag emojis are formed by joining two region indicator letters

3.2 HTML字符实体引用

字符实体引用(character entity reference)是一段以连字号(&)开头、以分号(;)结尾的文本(字符串)。格式可以参见下放代码。

已被命名的字符实体引用可以参见超链接,有一些符号还是较为常用的。


//数字格式

const decimalNumber=8205 //十进制数字

const decimalNumberFormat = `#${decimalNumber}`

const characterEntityReferenceExample1 = `&${decimalNumberFormat};`

//'&#8205;'

characterEntityReferenceExample1.charCodeAt()

//8025

//同一个控制字符的命名表达格式

const name="zwj"

const characterEntityReferenceExample2 = `&${name};`

//'&#8205;'

characterEntityReferenceExample2.charCodeAt()

//8025

3.3 译文:JavaScript如何使用字符编码?

ECMAScript是JavaScript的标准化版本,定义了字符的解释方式。

符合本国际标准的实施应按照Unicode标准3.0版或更高版本和ISO/IEC 10646-1的规定解释字符,采用UCS-2或UTF-16作为编码形式,实施级别为3。如果没有指定采用的ISO/IEC 10646-1子集,则假定为BMP子集,集合300。如果采用的编码形式没有另外指定,则假定为UTF-16编码形式。

换句话说,JavaScript引擎被允许使用UCS-2或UTF-16。

然而,规范的特定部分需要一些UTF-16的知识,不管引擎的内部编码是什么。

当然,对于普通的JavaScript开发者来说,内部引擎的具体情况并不重要。

而更重要的问题是:JavaScript认为什么是 "字符",以及它如何公开这些字符。

在本文件的其余部分,短语代码单元和字符将被用来指代一个16位的无符号值,用于表示单一的16位文本单位。

短语 "Unicode字符 "是指由一个单一的Unicode标量值所代表的抽象的语言或排版单位(它可能长于16位,因此可能由一个以上的代码单元所代表)。

短语 "码点 "指的就是这样一个Unicode标量值。

Unicode字符仅指由单个Unicode标量值表示的实体:一个组合字符序列的组成部分仍然是单独的 "Unicode字符",尽管用户可能认为整个序列是一个单一的字符。

JavaScript将 码元 视为单独的字符,而人类通常以Unicode字符为单位进行思考。

基本多语言平面(BMP) 以外的Unicode字符,通常由两个 码元 组成。

在下方代码示例中:尽管该字符串只有一个Unicode字符,但在length的值中,代理对(Surrogate Pair) 被暴露出来,就像它们是字符一样。


'𝌆'.length == 2

'𝌆'=='\uD834\uDF06' //由两个16位编码单元组成的代理对。

这几乎就是UCS-2的工作方式。(唯一的区别是,从技术上讲,UCS-2不允许 代理对 ,而JavaScript字符串允许)。

你可以说它类似于UTF-16,不同之处在于

1.允许不成对的 代理对 半值(前导代理后尾代理)。

2.允许顺序错误的 代理对

3.以及 代理对 半值作为独立的字符显示。

我想你会同意把这种行为看作是 "支持 代理对功能 的UCS-2"。

这种类似UCS-2的行为影响了整个语言,这导致 补充平面 中的字符对应的正则表达式比支持UTF-16的语言要难写得多。

代理对 只有在被浏览器显示时(布局期间)才会被重新组合成完整的Unicode字符。Java Script引擎并不处理这部分逻辑。

为了证明这一点,你可以分别调用document.write()来写出高位代理和低位代理:


document.write('\uD834'); document.write('\uDF06');

//𝌆

这两个16位编码单元的Unicode字符最终被呈现为𝌆,而这是一个完整的字形。

小结

JavaScript引擎可以自由地在内部使用UCS-2或UTF-16。据我所知,大多数JS引擎都使用UTF-16,但无论他们做出什么选择,这只是一个实现细节,不会影响语言的特性。

然而,ECMAScript/JavaScript语言本身是按照UCS-2而不是UTF-16来暴露字符的。

如果你需要转义一个Unicode字符,在必要时将其分割,请随时使用原文作者的JavaScript escaper工具

如果你想计算一个JavaScript字符串中Unicode字符的数量,或者基于非BMP Unicode代码点创建一个字符串,你可以使用Punycode.js的实用函数在UCS-2字符串和UTF-16代码点之间转换。


// `String.length` replacement that only counts full Unicode characters

punycode.ucs2.decode('𝌆').length; // 1

// `String.fromCharCode` replacement that doesn’t make you enter the surrogate halves separately

punycode.ucs2.encode([0x1D306]); // '𝌆'

punycode.ucs2.encode([119558]); // '𝌆'

ECMAScript 6将支持字符串中的一种新的转义序列,即Unicode码位转义,例如: \u{1D306}。此外,它还将定义String.fromCodePoint和String#codePointAt,两者都可以直接解析码元和代理对。

Note:

注:如果你喜欢阅读关于JavaScript内部字符编码的文章,请查看《JavaScript有一个Unicode问题》,它更详细地解释了这种行为引起的实际问题,并提供了解决方案。

3.4 实例:JavaScript 转换 Emoji 格式存入 Mysql 数据库

emoji是起源于日本无线通信表情的表情符号。Unicode 6.0首次收录了Emoji的符号编码。

Emoji有统一定义的Unicode值,在不同的操作系统及浏览器中,代码会自动获取Unicode值并将其展示为系统文件本地保存的Emoji表情图案,例如安卓系统曾经使用过Blob风格的emoji

这篇文章展示了不同系统中的笑哭表情,对应图案多达数十种。

如果系统不支持Emoji,则会展示为不同形式的乱码。

在我开发过程中,遇到Emoji表情无法直接存入Mysql。

原因是:

1.MySQL 5.5.3 版本之前的UTF-8编码支持的最大字符长度为3个字节。emoji在JavaScript/Java的字符串中均是默认以UTF-16编码显示,举例来说:👩=2个代理对=4个字节。

2.MySQL 5.5.3 版本之后,新增utfmb4(8-bit Unicode Transformation Format Most byte 4 )编码,可以存储4个字节的Unicode码位字符。我作为前端没有数据库操作权限,不考虑改方案。

所以我考虑在前端将 UTF-16中的代理对字符 直接转换成 HTML格式的字符实体引用字符串传输给后端,就可以避免上述问题了。

这种存储方式即可以拿出来直接在前端使用,也符合搜索操作的需求。emoji组合表情也可以正常兼容。

考虑到ES6的codePoint兼容性问题,下例主要使用charCodeAt获取码元值进行换算。


function transUTF16SurrogatePairToHtmlEntity (string) {

//匹配UTF-16中的代理对字符

const surrogatePairRegex = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g

return string.replace(surrogatePairRegex, (matchPair) => {

const leadSurrogate = matchPair.charCodeAt(0), trailSurrogate = matchPair.charCodeAt(1)

const unicodeValue = (leadSurrogate - 0xD800) * 0x400 + 0x10000 + trailSurrogate - 0xDC00

return `&#${unicodeValue};`

})

}

const demoEmojiStringInJS = "👩‍👩‍👦‍👦一家四口"

const result = transUTF16SurrogatePairToHtmlEntity(demoEmojiStringInJS)

//'&#128105;‍👩‍👦‍👦一家四口'

result.split("")

// ['&', '#', '1', '2', '8', '1', '0', '5', ';', '‍', '&', '#', '1', '2', '8', '1', '0', '5', ';', '‍', '&', '#', '1', '2', '8', '1', '0', '2', ';', '‍', '&', '#', '1', '2', '8', '1', '0', '2', ';', '一', '家', '四', '口']

//可以看到emoji组合表情用到的“零宽连字符没有丢失”。

function transHtmlEntityToUTF16SurrogatePair (string) {

return string.replace(/&#(\d+);/g, (match, num) => {

if (num > 0xFFFF) {

//如果大于0xFFFF,需要转换成UTF-16的代理对

let leadSurrogate = Math.floor((num - 0x10000) / 0x400) + 0xD800

let trailSurrogate = Math.floor(num - 0x10000) % 0x400 + 0xDC00

return String.fromCharCode(leadSurrogate, trailSurrogate)

}

return match

})

}

transHtmlEntityToUTF16SurrogatePair(result)

//将 HTML 实体引用字符串转回 JS 字符串

Reference

  1. 字符编码的现代编码模型 :zh.wikipedia.org/wiki/%E5%AD…

  2. 编码空间(encoding space) :zh.wikipedia.org/wiki/%E5%AD…

  3. 码位 :zh.wikipedia.org/wiki/%E7%A0…

  4. String.fromCodePoint :developer.mozilla.org/zh-CN/docs/…

  5. String.codePointAt :developer.mozilla.org/zh-CN/docs/…

  6. String.fromCharCode :developer.mozilla.org/zh-CN/docs/…

  7. String.charCodeAt :developer.mozilla.org/zh-CN/docs/…

  8. ASCII :zh.wikipedia.org/wiki/ASCIIA…

  9. Unicode :zh.wikipedia.org/wiki/Unicod…

  10. Unicode字符平面映射 :zh.wikipedia.org/wiki/Unicod…

  11. 基本多语言平面(BMP: Basic Multilingual Plane) :en.wikipedia.org/wiki/Plane_…

  12. 中日韩统一表意文字 :zh.wikipedia.org/wiki/%E4%B8…

  13. UTF-8 :zh.wikipedia.org/wiki/UTF-8

  14. 字素簇 :developer.mozilla.org/zh-CN/docs/…

  15. Unicode标准3.0 :unicode.org/versions/Un…

  16. 零宽连字(zero-width joiner,ZWJ) :zh.wikipedia.org/wiki/%E9%9B…

  17. 零宽不连字(zero-width non-joiner) :zh.wikipedia.org/wiki/%E9%9B…

  18. 字符实体引用(character entity reference) :zh.wikipedia.org/wiki/%E5%AD…

  19. 已被命名的字符实体引用 :html.spec.whatwg.org/multipage/n…

  20. 译文:JavaScript如何使用字符编码? :mathiasbynens.be/notes/javas…

  21. ECMAScript定义 :es5.github.io/x2.html#x2

  22. 转义一个Unicode字符 :mathiasbynens.be/notes/javas…

  23. 原文作者的JavaScript escaper工具 :mothereff.in/js-escapes#…

  24. 计算一个JavaScript字符串中Unicode字符的数量 :mothereff.in/byte-counte…

  25. Punycode.js :github.com/mathiasbyne…

  26. Unicode码位转义 :mathiasbynens.be/notes/javas…

  27. 《JavaScript有一个Unicode问题》 :mathiasbynens.be/notes/javas…

  28. emoji :zh.wikipedia.org/wiki/%E7%B9…

  29. Blob风格的emoji :zh.wikipedia.org/wiki/Blob_e…

  30. 各种笑哭表情 :emojipedia.org/face-with-t…