Javascript有个Unicode的天坑

rugw7343 7年前
   <p>最近笔者在项目中遇到了emoji表情的处理,期间发现js处理多字节字符时会有较多坑,记录一下与各位分享。</p>    <p>本文涉及知识点:</p>    <p>Unicode (BMP/SP)</p>    <p>UTF-8 UTF-16 UTF-32 UCS-2</p>    <p>javascript字符处理</p>    <h2>Unicode</h2>    <p>Unicode是目前绝大多数程序使用的字符编码,定义也很简单,用一个码点(code point)映射一个字符。码点值的范围是从U+0000到U+10FFFF,可以表示超过110万个符号。下面是一些符号与它们的码点</p>    <ul>     <li>A 的码点 U+0041</li>     <li>a 的码点 U+0061</li>     <li>© 的码点 U+00A9</li>     <li>☃ 的码点 U+2603</li>     <li>:hankey: 的码点 U+1F4A9</li>    </ul>    <p>对于每个码点,Unicode还会配上一小段文字说明,可以在codepoints.net查到,比如 :hankey:的码点说明</p>    <p>Unicode最前面的65536个字符位,称为基本平面(BMP-—Basic Multilingual Plane),它的码点范围是从U+0000到U+FFFF。最常见的字符都放在这个平面,这是Unicode最先定义和公布的一个平面。</p>    <p>剩下的字符都放在补充平面(Supplementary Plane),码点范围从U+010000一直到U+10FFFF,共16个。</p>    <h2>UTF与UCS</h2>    <p>UTF(Unicode transformation format)Unicode转换格式,是服务于Unicode的,用于将一个Unicode码点转换为特定的字节序列。常见的UTF有</p>    <p>UTF-8 可变字节序列,用1到4个字节表示一个码点</p>    <p>UTF-16 可变字节序列,用2或4个字节表示一个码点</p>    <p>UTF-32 固定字节序列,用4个字节表示一个码点</p>    <p><a href="/misc/goto?guid=4959727845956430946" rel="nofollow,noindex">UTF-8</a> 对ASCⅡ编码是兼容的,都是一个字节,超过U+07FF的部分则用了复杂的转换方式来映射Unicode,具体不再详述。</p>    <p>UTF-16对于BMP的码点,采用2个字节进行编码,而BMP之外的码点,用4个字节组成代理对(surrogate pair)来表示。其中前两个字节范围是U+D800到U+DBFF,后两个字节范围是U+DC00到U+DFFF,通过以下公式完成映射(H:高字节 L:低字节 c:码点)</p>    <p>H = Math.floor((c-0x10000) / 0x400)+0xD800</p>    <p>L = (c – 0x10000) % 0x400 + 0xDC00</p>    <p>比如:hankey:用UTF-16表示就是”\uD83D\uDCA9″</p>    <p>UCS(Universal Character Set)通用字符集,是一个ISO标准,目前与Unicode可以说是等价的。</p>    <p>相对于UTF,UCS也有自己的转换方法(编码)。如</p>    <p>UCS-2 用2个字节表示BMP的码点</p>    <p>UCS-4 用4个字节表示码点</p>    <p>UCS-2是一个过时的编码方式,因为它只能编码基本平面(BMP)的码点,在BMP的编码上,与UTF-16是一致的,所以可以认为是UTF-16的一个子集。</p>    <p>UCS-4则与UTF-32等价,都是用4个字节来编码Unicode。</p>    <h2>javascript字符处理</h2>    <p>辣莫,js到底是用的啥编码呢?答案是UCS-2。咦,刚刚不是说UCS-2过时了吗?首先看下年表</p>    <p>1990 UCS-2 诞生</p>    <p>1995.5 JavaScript 诞生</p>    <p>1996.7 UTF-16 诞生</p>    <p>也就是说,Brendan Eich在写JS的时候,UTF-16还没问世,所以只能用UCS-2的方式来处理字符,也因此留下了隐患。</p>    <h3>坑1——length属性</h3>    <p>先看一个简单的例子:</p>    <p>>”\uD83D\uDCA9″ === “:hankey:”</p>    <p>>true</p>    <p>>”:hankey:”.length</p>    <p>>2</p>    <p>因为”:hankey:”在JS的编码是”\uD83D\uDCA9″,而JS认为每16位(2字节)即表示一个字符,所以一坨大便是占2个字符的。我们经常用length来判断字符串长度,那产品不干了呀,说好可以输入10个字,为毛输了5个emoji就不给输入了?</p>    <p>怎么破?可以用万能的正则匹配</p>    <pre>  <code class="language-javascript">var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; // 匹配UTF-16的代理对     function countSymbols(string) {   return string   // 把代理对改为一个BMP的字符.   .replace(regexAstralSymbols, '_')   // …这时候取长度就妥妥的啦.   .length;  }  countSymbols(':hankey:'); // 1  </code></pre>    <h3>坑2——反转字符串</h3>    <p>js里怎么反转(reverse)字符串?相信有些同学已经想到了一个极简的方案</p>    <pre>  <code class="language-javascript">function reverse(str) {      return str.split('').reverse().join('');  }  </code></pre>    <p>js虽没有直接的反转字符串的API,但是数组有啊,转数组反转之后再转回字符串,嘿嘿嘿,是不是很机智?这时候Unicode大爷又出来打脸了:你们呐,sometimes naive!</p>    <p>拿刚才的函数反转带有:hankey:的字符串试试</p>    <pre>  <code class="language-javascript">reverse('这是一坨:hankey:')  "��坨一是这"  </code></pre>    <p>�的Unicode码点是+UFFFD,通常用来表示Unicode转换时无法识别的字符(也就是乱码)</p>    <p>当:hankey:(\uD83D\uDCA9)通过上述方法反转时,变成\uDCA9\uD83D,不是一个合法的代理对(高低字节范围不同),同时,Unicode规定代理对范围内的码点不能单独出现,所以js只能用�表示了。</p>    <p>怎么破?</p>    <ol>     <li>ES6的Array.from支持代理对的解析</li>    </ol>    <pre>  <code class="language-javascript">function reverse(string) {   return Array.from(string).reverse().join('');  }  </code></pre>    <ol>     <li>使用 Esrever (reverse反转之后就是esrever…)</li>    </ol>    <h3>坑3——码点与字符互转</h3>    <p>String.fromCharCode可以将一个码点转换为字符,比如</p>    <pre>  <code class="language-javascript">String.fromCharCode(0x0041)  'A'  </code></pre>    <p>但超过BMP平面的就跪了。</p>    <pre>  <code class="language-javascript">>> String.fromCharCode(0x1F4A9) // U+1F4A9  '' // U+F4A9, not U+1F4A9  </code></pre>    <p>事实上这个API是支持俩参数的,分别是代理对的高低字节。所以需要通过公式计算出对应的高低字节</p>    <pre>  <code class="language-javascript">>> String.fromCharCode(0xD83D, 0xDCA9)  ':hankey:' // U+1F4A9  >> ':hankey:'.charCodeAt(0)  0xD83D  </code></pre>    <p>一个字,蛋疼!</p>    <p>怎么破? ES6大法好。</p>    <pre>  <code class="language-javascript">>> String.fromCodePoint(0x1F4A9)  ':hankey:' // U+1F4A9  >> ':hankey:'.codePointAt(0)  0x1F4A9  </code></pre>    <h3>坑4——正则匹配</h3>    <p>正则匹配符 . 只能匹配单个“字符”,但js将代理对当成两个单独的“字符”处理,所以匹配不到任何辅助平面字符。</p>    <pre>  <code class="language-javascript">>> /foo.bar/.test('foo:hankey:bar')  false  </code></pre>    <p>思考一下,什么正则表达式可以表示任何Unicode字符? 显然 . 是不够的,因为它不能匹配辅助平面字符或者换行符。那么用\s\S呢?</p>    <pre>  <code class="language-javascript">``  >> /^[\s\S]$/.test(':hankey:')  false  ```  </code></pre>    <p>怀疑人生了~~正确的匹配任意Unicode字符的正则如下:</p>    <pre>  <code class="language-javascript">>> /[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/.test(':hankey:') // wtf  true  </code></pre>    <p>怎么破? ES6给出一个简单的方法——增加一个u标志</p>    <pre>  <code class="language-javascript">>> /foo.bar/u.test('foo:hankey:bar')  true  </code></pre>    <p>注意:这里的 . 还是不能匹配换行符。</p>    <h2>ES6的Unicode支持</h2>    <p>从上面的例子中可以看出,ES6已经在很努力地填坑了。对于Unicode字符,ES6支持新的表示方法</p>    <p>\u{1F4A9} 加上花括号后,可以把码点直接填进去来表示,而不用去计算代理对。再补充2点:</p>    <p>1. 为了向后兼容,字符串的length属性还是用双字节判断的,所以要用Array.from(str).length。</p>    <p>2. 遍历字符串的时候,可以用 for(let s of str) {}</p>    <p> </p>    <p> </p>    <p>来自:http://www.alloyteam.com/2016/12/javascript-has-a-unicode-sinkhole/</p>    <p> </p>