导语
本文源于微信游戏春节王者摇心愿活动英雄语音祝福自定义输入模块开发过程, 对踩过的前端字符编码的坑进行记录总结
Unicode 字符
Unicode(中文: 万国码国际码统一码单一码)是计算机科学领域里的一项业界标准它对世界上大部分的文字系统进行了整理编码, 使得电脑可以用更为简单的方式来呈现和处理文字
简单地来说, Unicode 是一种字符编码, 它规定用一个码点表示一个字符, 其范围为 U+0000~ U+10FFFF , 可以表示超过 100 万个符号 Unicode 分成 17 个平面, 其中第 1 个平面称谓基本平面 (也称 BMP), 其范围为 U+0000~ U+FFFF, 另外 16 个平面称之为辅助平面, 每个辅助平面拥有 65536(即 2^16) 个字符
Unicode 只规定了字符编码, 而并没有规定具体的编码方式因此就产生了不同的编码方式, 包括 UTF-8UTF-16UTF-32 等等
UTF-16
UTF-16 是一种变长的编码方式, 可以用 2 个字节或者 4 个字节来编码 Unicode 字符 UTF-16 使用两个字节编码 Unicode 字符中的基本平面的字符, 使用 4 个字节编码 Unicode 字符中的辅助平面的字符
UTF-16 使用变成的编码方式, 那么如何判断一个字符是基本平面字符还是辅助平面字符?
UTF-16 规定了 BMP 中, 从 U+D800 到 U+DFFF 之间 BMP 的区段是永久保留不映射到字符, 可以利用这段区间来编码辅助平面的字符
简单来说, 从左到右扫描, 发现前两个字节不在 U+D800 到 U+DFFF 之 UTF 间, 则可认定这两个字节组成了一个基本平面的字符, 发现前两个字节处于 U+D800 到 U+DFFF 之间, 则需要读取下两个字节, 拼凑成四个字节组成一个辅助平面的字符
前面提到辅助平面有 16(即 2^4)个, 每个辅助平面拥有 65536(即 2^16)个字符, 因此辅助平面共有 2^20 个字符, 也就是说需要 20 位二进制位来对应这些字符
16 * 65536 = 2^4 * 2^16 = 2^20
从 U+D800 到 U+DFFF 之间刚好有 2^11 个码元, 因此 UTF-16 使用 U+D800 到 U+DBFF 之间 (共有 2^10 个) 码元作为高位, U+DC00 到 U+DFFF 之间 (共有 2^10 个) 作为低位, 这样子高低位 4 个字节组成的编码方式 (代理对) 就可以表示一个辅助平面的字符了
其中, 辅助平面字符 Unicode 到 UTF-16 代理对的转换规则如下( c 表示 Unicode 的码元, H 表示代理对的高位字节, L 表示代理对的低位字节):
- H = Math.floor((c - 0x10000) / 0x400) + 0xD800
- L = (c - 0x10000) % 0x400 + 0xDC00
以上面的音乐字符为例, 其 Unicode 字符的码元为 U+1F3B6, 可以通过 https://codepoints.net/ 查询到对应字符信息
- > H = Math.floor((0x1F3B6 - 0x10000) / 0x400) + 0xD800
- 0xd83c
- > L = (0x1F3B6 - 0x10000) % 0x400 + 0xDC00
- 0xdfb6
通过上面的转换规则可以算出其代理对为 \ud83c\udfb6
UCS-2
UCS-2 是 UTF-16 未出世之前的一种编码方式, 可以简单理解为 UTF-16 的子集它采用定长 2 字节编码, 因此只能表示基本平面的字符, 对于辅助平面字符, 它只能理解为这是 两个基本平面字符 , 无法正常表示
javascript 的编码方式
好了, 进入正题了前面讲了 UTF-16 和 UCS-2, 那么 javascript 到底是采用什么编码的呢?
这个要分情况来讲, javascript 引擎采用 UTF-16 编码, 而 javascript 语言本身的设计是采用 UCS-2 编码方式
因此, 当我们使用 UCS-2 编码方式设计的 javascript 接口来处理 UTF-16 编码的字符, 就会出现很多问题
比如:
那么如何解决这两者编码方式不一致造成的问题呢, 有两种方式:
ES6
新版本的 ECMA Script 提供了新的 API 来正确处理字符
利用正则表达式对其修正(项目也是采用这种方式)
- var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g
- // 获取字符的长度
- function countSymbols(string) {
- return string
- // 把代理对改为一个 BMP 的字符.
- .replace(regexAstralSymbols, '_')
- // 这时候取长度就妥妥的啦.
- .length;
- }
- // 获取前 6 个字符
- function sliceSymbols(str, limit) {
- var output = [];
- var index = 0;
- var oldStr = str;
- str = str.replace(regexAstralSymbols, function(input, offset, match) {
- if( offset > index ) {
- output = output.concat(match.slice(index, offset).split(""));
- }
- index = offset + input.length;
- output.push(input)
- return "";
- });
- if( index < oldStr.length ) {
- output = output.concat(oldStr.slice(index, oldStr.length).split(""));
- }
- return output.slice(0, limit).join("");
- }
实现效果如下:
上面的解决方法基本可以解决大部分的字符问题, 但是在遇到某些 emoji 表情依然会有些问题
emoji
emoji 表情符号是一种象形文字(图片符号), 通常以丰富多彩的形式呈现并在文本中以内联形式使用, 起源于日本 Unicode 对 emoji 表情做了划分范围, 大部分属于辅助平面字符, 目前 Unicode 中收录的 emoji 表情达到了 2700 多个因此, 在大部分情况下, 使用 UTF-16 的代理对来处理 emoji 表情是没有问题但在 emoji 表情中, 还存在着一些字符(Emoji Sequences), 它们没有显示的样式, 主要起着连接控制等作用目前有下面几种:
控制符 <U+FE0E> 和 <U+FE0F>
<U+FE0E>, 作用是让基础 Emoji 变成更接近文本样式( text-style )
<U+FE0F>, 作用则是让基础 Emoji 变成更接近 Emoji 样式( emoji-style )
零宽连接符 <U+200D>
emoji 除了单个 emoji 符号, 还可以通过零宽连接符将多个 emoji 连接成一个 emoji 比如 \ud83d\udc68 是表示一个 man,\ud83c\udf93 表示一个学士帽, 这两个通过零宽连接符连接起来
\ud83d\udc68\u200d\ud83c\udf93
就表示一个男学生了
因此, 为了解决 emoji 这些 Emoji Sequences, 将正则进行扩展:
var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF][\u200D|\uFE0F|\uFE0E]|[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
除了以上两种比较常见的 Emoji Sequences, 其实还有 Keycap Sequence, Flag Sequence, Tag Sequence, Modifier Sequence 等字符, 可以参考这里
来源: https://segmentfault.com/a/1190000013418463