本文整理自技术分享,可以在这里获取演示文稿。
「不超过 20 个字符」应该是每个前端工程师都处理过的需求。当你不假思索的写出 input.length <= 20
这样的代码时,有没有仔细思考过 length
是如何定义的?它能否准确表示字符的数量?
String.prototype.length
The String type is the set of all ordered sequences of zero or more 16-bit unsigned integer values (“elements”) up to a maximum length of 253 - 1 elements.
The number of elements in the String value represented by this String object.
以上两段分别是 ECMAScript 中对字符串类型以及字符串 length
属性的定义。可以看到字符串 length
属性代表的是字符串中 element 的个数,而 element 则表示一个 16 bit 的无符号整型数值,即 1 个字节对。也就是说,length
等于字符串占用的字节数 / 2。
UTF-16 编码
明确了 length
的定义再来讨论第二个问题:length
是否代表字符的数量?既然 length
表示的是字符串由多少个字节对组成,那么这个问题就变成了:1 个字符是否恰好占用 1 个字节对?很显然这就涉及到了编码问题。
我们知道 JavaScript 中的字符串使用的是 UTF-16 编码。根据定义,UTF-16 是一种变长编码,需要使用 1 个或 2 个字节对来表示一个字符。到这里,我们终于可以回答开篇提出的问题:length
其实并不能准确表示字符数量。
具体来说,根据 UTF-16 的编码细节,我们经常使用的字符大多数都可以通过 1 个字节对进行编码,因此 length
属性在大多数情况下都可以正常工作。最常见的例外情况是 emoji 字符,例如「😂」(U+1F602)的 length
就是 2,汉字「𫟹」(U+2B7F9)的 length
也是 2。当字符串中包含这些字符时,使用 length
属性就无法得到符合预期的结果了。
代理对技术
解决了 length
是否准确的问题,新的问题随之而来:UTF-16 编码在何时使用 1 个字节对,何时使用 2 个字节对?又是如何使用 2 个字节对来表示 1 个字符的呢?这就要涉及到 Unicode 字符分区以及代理对技术。
Unicode 共定义了 0x10FFFF 个码点,被分为 0 - 16 共 17 个平面。其中 0 号平面被称为基本多语种平面,1 - 16 号平面被称为扩展平面。基本多语种平面包含了从 0x0000 到 0xFFFF 共 65536 个码点,覆盖了世界主要语言的常用字符和标点。UTF-16 中,这部分码点可以用 1 个字节对来编码。
对于扩展平面中 0x010000 - 0x10FFFF 共 1048576 个码点,则需要通过代理对技术来编码。具体地,我们在基本多语种平面中预留 Math.sqrt(1048576)
即 1024 个码点作为代理码点使用,使用 2 个代理码点组成的代理对即可表示全部 1048576 个码点。
举例来讲,假设我们预留基本多语种平面中的 0xD800 - 0xDBFF 这 1024 个码点作为代理码点,则要表示「😂」(U+1F602)这个字符,可以按照以下步骤计算代理对:
代理对高位 = Math.floor((0x01F602 - 0x010000) / 1024) + 0xD800 = 0xD83D |
实际编码时,为了实现编码自同步的特性,需要在基本多语种平面中分别保留代理对高位和代理对低位两组共 2048 个码点(实际使用的代理对高位为 0xD800 - 0xDBFF,代理对低位为 0xDC00 - 0xDFFF)。根据新的规则得到的代理对如下:
代理对高位 = Math.floor((0x01F602 - 0x010000) / 1024) + 0xD800 = 0xD83D |
可以使用 charCodeAt
方法获取代理对中的单个码点来验证我们的计算是否正确:
"😂".charCodeAt(0).toString(16); |
将代理对还原为对应的码点只需要进行对应的逆向运算即可,这里就不再赘述了。
小结
完整弄清楚了 JavaScript 中 String.prototype.length
的定义、Unicode 的分区以及 UTF-16 编码中的代理对技术,我们就可以使用代码来计算一个字符串中 Unicode 字符数:
function countUnicodeCharacters(str) { |
觉得太麻烦?ES2015 中其实也提供了对应的 API 来完成 Unicode 码点级别的操作,包括:String.prototype[@@iterator]
、String.prototype.codePointAt
以及 String.fromCodePoint
。使用 String.prototype[@@iterator]
我们可以很方便的以 Unicode 码点为单位来计算字符串长度:
[...input].length <= 20; |
至此,我们使用字符串中 Unicode 码点数量取代字节对数量更精确地对字符进行计数,但这并不是字符计数问题的终点:我们上面讨论的是以一个 Unicode 码点就是一个可见字符为基础的,但事实并不如此,例如 [..."café"].length !== [..."café"].length
,虽然两个字符串都只有 4 个字符,但前者的 Unicode 码点数为 4,而后者的 Unicode 码点数为 5,这又是为什么呢?其中的原因我们会在下篇文章中继续讨论。