在一次工作中,偶然发现一个奇特的 Bug。当 Chrome 浏览器在渲染这个一位用户的信息时,导致了浏览器的崩溃。
Bug 简单复现(MacOS):如果你的 Chrome 版本低于 100,那么点开这个链接,页面会直接崩溃;如果你的 Chrome 版本低于 102,点击页面中的搜索栏,也会导致崩溃。
问题如何排查出来的?
- 排查接口返回,但 postman 能正常工作
- Chrome 开 Recorder 看看是不是代码性能有问题,导致内存溢出。发现崩溃发生在数据render,猜测数据有问题
- mock 接口返回数据,发现可以正常显示,确定是数据的问题,最后定位到用户昵称中的这个特殊字符
如何修复这个问题
1、向 Chromium 提交 Bug 反馈,并且在 Chrome 102.0.5005.40 版本中修复。根据Google Devs 的回复,Bug 的原因是引入的一个第三方字符引擎库导致的。这个库在解析 COMBINING ENCLOSING CIRCLE (组合封闭圈)字符时,会导致字符位置变更,使得 Blink 引擎无法处理。
2、对特殊字符做兼容处理
遍历整个昵称,如果含有 emoji,则往后拼接一个零宽的空字符。(后面根据上面的 Bug 反馈,其实只需要对 ⃝ 这个字符单独做处理即可)
1 | const emojiRegex = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/gi; |
Unicode是什么?
全世界有这么多国家,这么多的语种,字符数有成千上万种。如果能够把这些字符放到一个集合里,只要操作系统支持这个字符集,就不会产生乱码。为了解决这个问题,Unicode 标准出现了。
United Code? Unify Code? Blablabla….Whatever。。。来看看 Wikipidia 对其的定义:
Unicode(alias Universal Coded),is an information technology standard for the consistent encoding, representation, and handling of text expressed in most of the world’s writing systems.
—— from Wikipidia
那么它是如何编码的呢?为了方便理解,首先想象有一个平面,我们人为地把一些常用字符放到这个平面中。然后使用16进制对字符的位置进行编码,这个字符在平面上的位置也叫做码点。例如‘字’这个字符在第 23383 个位置,编码为 U+5B57
,‘节’这个字符在第 33410 个位置,编码为U+8282
。!
1 | // ' ' 字 节 跳 动 ! |
但是一个平面显然装不下所有的字符,最新的 Unicode 版本为2021年9月公布的14.0.0,已经收录超过 14 万个(其中 CJK 字符超过 9 万,占比 64%)。
因此,Unicode 还额外定义了其他 16 个平面,通过额外的两个十六进制数,来表示平面的位置。每个平面所表示的含义各不相同。
其中我们经常用到的 Emoji 就在1号平面下。
1 | // 为什么有两种表示方式呢? |
需要注意的是,在 Unicode 标准中只规定了代码点对应的字符,并没有规定这些代码点怎么存储。对于不同的操作系统以及编程语言,都其各自的编码方式进行存储,有用 UTF-8 的、也有用 UTF-16 和 UTF-32 的。
以😂
这个emoji为例,它的 Unicode 码位为 U+1F602
,下面是不同的系统对其的编码形式:
那 JavaScript 是如何编码的呢?在 ECMAScript 的标准中有提到:
When a String contains actual textual data, each element is considered to be a single UTF-16 code unit.
当一个字符串包含实际的文本数据时,每个元素被认为是一个单一的UTF-16编码单位。
UTF-16 的编码方式方式很巧妙,它是可变长度的。它用两个字节表示基本平面,如果是辅助平面的字符,则使用四个字节表示。
那么计算机在读取的过程中,遇到了两个字节,如何判断它本身是一个字符,还是需要将其他两个字节一起解读呢?
为解决这个问题,Unicode 将基本平面的两段代码点保留,不表示任意字符。110110xx xxxxxxxx
(0xd800
- 0xdbff
)为高位代理(High Surrogate),110111xx xxxxxxxx
(0xdc00
- 0xdfff
) 为低位代理(Low Surrogate)。当计算机读取到一个代理字符时,就知道这是一个辅助平面字符,需要将四个字节一起解读。
详细的编码方法为:
- 如果代码点位于
0x000000
-0x00ffff
,直接进行二进制编码,位数不够的左边充 0。 - 如果代码点位于
0x010000
-0x10ffff
,则:- 代码点减去
0x010000
,会得到一个位于0x000000
和0x0fffff
之间的数字。 - 这个数字转换为 12 位二进制数,位数不够的,左边充 0,记作:
yyyy yyyy yyxx xxxx xxxx
。 - 取出
yy yyyyyyyy
,并加上11011000 00000000
(0xD800
),得到高位代理。 - 取出
xx xxxxxxxx
,并加上11011100 00000000
(0xDC00
),得到低位代理。 - 高位代理和低位代理相连,得到
110110yy yyyyyyyy 110111xx xxxxxxxx
。
- 代码点减去
太长不看系列。。。如果是辅助平面的字符,Unicode 直接给了一个公式:
1 | const unicode2UTF16 = (codePoint) => { |
在 ES6 之前,Unicode 只能通过这种代理位字符的形式进行表示,ES6 增强了一些能力,例如上面提到的,可以直接将 Unicode 的码位包裹在 {}
中。
前端如何正确处理 Unicode
直接上案例。
1、字符长度
1 | 'A'.length // 1 |
可以看到,如果是辅助平面的字符,JS 会将高低位的代理字符分开计数,视作两个字符。这样可能会让人感到困惑,那么如何才能得到正确的计数呢?
在 ES6 中,可以使用 Array.from
或者 ...
拓展运算符。
1 | Array.from('𝐀').length // 1 |
我们注意到,最后一个 👨👩👧👦 emoji 似乎比较特殊,他的长度为什么是 7 呢?我们尝试看一下解构后的数组。
1 | [...'👨👩👧👦'] // ['👨', ''(这不是普通的空字符哦), '👩', '', '👧', '', '👦'] |
通过数组解构, 👨👩👧👦 被拆分成了👨👩👧👦
四个 emoji,并且使用一个特殊字符连接起来了。这个特殊的字符被称为 Zero Width Joiner (零宽连接符),它可将两个或多个其他字符按顺序连接在一起以创建新的表情符号。这样我们的 emoji 世界就多样化起来了,同时也体现了更多包容性。
1 | // 超级英雄,当然不能只有男性 |
Emoji 的历史也很精彩有趣,值得单开一期。
OK,回到正题。对于这种组合的特殊字符(不仅仅是 emoji),应该如何正确的统计字符呢?手动剔除 \u200d
?那如果有这种字符呢Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞?
有一种方式是 ES2021 里的 Intl.Segmenter 方法,专门对 Unicode 进行分割。也可以使用第三方库 grapheme-splitter 或者 lodash 的 _.split 方法,兼容性更好。
1 | const segmenter = new Intl.Segmenter("en-US", {granularity: "grapheme"}); |
Semi Input 组件如何兼容这个问题?它把决定权交给了用户,长度计算函数作为参数让用户传入,因此在使用需要计算长度场景需要注意处理特殊的符号。
2、Normalize
normalize 是 ES6 新增了一个 String 原型链上的方法,这个方法返回字符串的 Unicode 规范化形式。
1 | 'mañana' === 'mañana'. // false |
两个看起来一样的字符串,为什么不相等呢?有了上面的经验,我们尝试解构一下这个字符串。
1 | [...'mañana'] // ['m', 'a', 'ñ', 'a', 'n', 'a'] |
可以看到,第一个字符串包含码位 U+00F1
表示字母 n 和 n 头上波浪号,而第二个字符串使用两个单独的码位(U+006E
表示字母 n 和 U+0303
表示波浪号)来创建相同的字符,因此两个字符串并不相等。
要解决这种问题也很简单,可以使用 normalize 方法,将字符串转换为 Unicode 的规范形式再进行比较。
1 | 'mañana'.normalize() === 'mañana'.normalize() |
3、字符反转
如果是普通的字符串反转,我们可以利用数组的 reverse 方法。
1 | const reverse = (str) => str.split('').reverse().join(''); |
但是上面的 reverse 方法无法处理组合字符和辅助平面的字符,可以使用上面提到的 Lodash 的_.split 方法稍加改造。
1 | const reverse = (str) => _.split(str, '').reverse().join(''); |
总结
- Unicode,就是一个字符标准,它使用码点进行标记位置,将全世界各种各样的字符放进了一个集合。
- Unicode 处理场景,需要谨慎,能用第三方库处理的尽量别手写。
延伸阅读