Fundebug经受权转载,版权归原做者全部。javascript
JavaScript 处理 Unicode 的方式至少能够说是使人惊讶的。本文解释了 JavaScript 中的 处理 Unicode 相关的痛点,提供了常见问题的解决方案,并解释了ECMAScript 6 标准如何改进这种状况。前端
在深刻研究 JavaScript 以前,先解释一下 Unicode 一些基础知识,这样在 Unicode 方面,咱们至少都了解一些。java
Unicode 是目前绝大多数程序使用的字符编码,定义也很简单,用一个 码位(code point) 映射一个字符。码位值的范围是从 U+0000
到 U+10FFFF
,能够表示超过 110 万个字符。下面是一些字符与它们的码位。git
码位 一般被格式化为十六进制数字,零填充至少四位数,格式为 U +前缀
。github
Unicode 最前面的 65536 个字符位,称为 基本多文种平面(BMP-—Basic Multilingual Plane),又简称为“零号平面”, plane 0),它的 码位 范围是从 U+0000
到 U+FFFF
。最多见的字符都放在这个平面上,这是 Unicode 最早定义和公布的一个平面。正则表达式
剩下的字符都放在 **辅助平面(Supplementary Plane)**或者 星形平面(astral planes) ,码位范围从 U+010000
一直到 U+10FFFF
,共 16 个辅助平面。算法
辅助平面内的码位很容易识别:若是须要超过 4 个十六进制数字来表示码位,那么它就是一个辅助平面内的码。express
如今对 Unicode 有了基本的了解,接下来看看它如何应用于 JavaScript 字符串。小程序
在谷歌控制台输入以下:segmentfault
>> '\x41\x42\x43' 'ABC' >> '\x61\x62\x63' 'abc'
如下称为十六进制转义序列。它们由引用匹配码位的两个十六进制数字组成。例如,\x41
码位为 U+0041
表示大写字母 A。这些转义序列可用于 U+0000
到 U+00FF
范围内的码位。
一样常见的还有如下类型的转义:
>> '\u0041\u0042\u0043' 'ABC' >> 'I \u2661 JavaScript!' 'I ♡ JavaScript!
这些被称为 Unicode转义序列。它们由表示码位的 4 个十六进制数字组成。例如,\u2661
表示码位为 \U+2661
表示一个心。这些转义序列能够用于 U+0000
到 U+FFFF
范围内的码位,即整个基本平面。
可是其余的全部辅助平面呢? 咱们须要 4 个以上的十六进制数字来表示它们的码位,那么如何转义它们呢?
在 ECMAScript 6中,这很简单,由于它引入了一种新的转义序列: Unicode 码位转义。例如:
>> '\u{41}\u{42}\u{43}' 'ABC' >> '\u{1F4A9}' '💩' // U+1F4A9 PILE OF POO
在大括号之间可使用最多 6 个十六进制数字,这足以表示全部 Unicode 码位。所以,经过使用这种类型的转义序列,能够基于其代码位轻松转义任何 Unicode 码位。
为了向后兼容 ECMAScript 5 和更旧的环境,不幸的解决方案是使用代理对:
>> '\uD83D\uDCA9' '💩' // U+1F4A9 PILE OF POO
在这种状况下,每一个转义表示代理项一半的码位。两个代理项就组成一个辅助码位。
注意,代理项对码位与原始码位全不一样。有公式能够根据给定的辅助码位来计算代理项对码位,反之亦然——根据代理对计算原始辅助代码位。
辅助平面(Supplementary Planes)中的码位,在 UTF-16 中被编码为一对16 比特长的码元(即32bit,4Bytes),称做代理对(surrogate pair),具体方法是:
0x10000
,获得的值的范围为 20 比特长的 0..0xFFFFF
.0..0x3FF
)被加上 0xD800
获得第一个码元或称做高位代理。0..0x3FF
)被加上 0xDC00
获得第二个码元或称做低位代理(low surrogate),如今值的范围是 0xDC00..0xDFFF
.使用代理对,全部辅助平面中的码位(即从 U+010000
到 U+10FFFF
)均可以表示,可是使用一个转义来表示基本平面的码位,以及使用两个转义来表示辅助平面中的码位,整个概念是使人困惑的,而且会产生许多恼人的后果。
例如,假设你想要计算给定字符串中的字符个数。你会怎么作呢?
首先想到多是使用 length
属性。
>> 'A'.length // 码位: U+0041 表示 A 1 >> 'A' == '\u0041' true >> 'B'.length // 码位: U+0042 表示 B 1 >> 'B' == '\u0042' true
在这些例子中,字符串的 length
属性刚好反映了字符的个数。这是有道理的:若是咱们使用转义序列来表示字符,很明显,咱们只须要对每一个字符进行一次转义。但状况并不是老是如此!这里有一个稍微不一样的例子:
>> '𝐀'.length // 码位: U+1D400 表示 Math Bold 字体大写 A 2 >> '𝐀' == '\uD835\uDC00' true >> '𝐁'.length // 码位: U+1D401 表示 Math Bold 字体大写 B 2 >> '𝐁' == '\uD835\uDC01' true >> '💩'.length // U+1F4A9 PILE OF POO 2 >> '💩' == '\uD83D\uDCA9' true
在内部,JavaScript 将辅助平面内的字符表示为代理对,并将单独的代理对部分开为单独的 “字符”。若是仅使用 ECMAScript 5 兼容转义序列来表示字符,将看到每一个辅助平面内的字符都须要两个转义。这是使人困惑的,由于人们一般用 Unicode 字符或图形来代替。
回到这个问题:如何准确地计算 JavaScript 字符串中的字符个数 ? 诀窍就是如何正确地解析代理对,而且只将每对代理对做为一个字符计数。你能够这样使用:
var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; function countSymbols(string) { return string // Replace every surrogate pair with a BMP symbol. .replace(regexAstralSymbols, '_') // …and *then* get the length. .length; }
或者,若是你使用 Punycode.js,利用它的实用方法在 JavaScript 字符串和 Unicode 码位之间进行转换。decode
方法接受一个字符串并返回一个 Unicode 编码位数组;每一个字符对应一项。
function countSymbols(string) { return punycode.ucs2.decode(string).length; }
在 ES6 中,可使用 Array.from 来作相似的事情,它使用字符串的迭代器将其拆分为一个字符串数组,每一个字符串数组包含一个字符:
function countSymbols(string) { return Array.from(string).length; }
或者,使用解构运算符 ...
:
function countSymbols(string) { return [...string].length; }
使用这些实现,咱们如今能够正确地计算码位,这将致使更准确的结果:
>> countSymbols('A') // 码位:U+0041 表示 A 1 >> countSymbols('𝐀') // 码位: U+1D400 表示 Math Bold 字体大写 A 1 >> countSymbols('💩') // U+1F4A9 PILE OF POO 1
考虑一下这个例子:
>> 'mañana' == 'mañana' false
JavaScript告诉咱们,这些字符串是不一样的,但视觉上,没有办法告诉咱们!这是怎么回事?
JavaScript转义工具会告诉你,缘由以下:
>> 'ma\xF1ana' == 'man\u0303ana' false >> 'ma\xF1ana'.length 6 >> 'man\u0303ana'.length 7
第一个字符串包含码位 U+00F1
表示字母 n 和 n 头上波浪号,而第二个字符串使用两个单独的码位(U+006E
表示字母 n 和 U+0303
表示波浪号)来建立相同的字符。这就解释了为何它们的长度不一样。
然而,若是咱们想用咱们习惯的方式来计算这些字符串中的字符个数,咱们但愿这两个字符串的长度都为 6,由于这是每一个字符串中可视可区分的字符的个数。要怎样才能作到这一点呢?
在ECMAScript 6 中,解决方案至关简单:
function countSymbolsPedantically(string) { // Unicode Normalization, NFC form, to account for lookalikes: var normalized = string.normalize('NFC'); // Account for astral symbols / surrogates, just like we did before: return punycode.ucs2.decode(normalized).length; }
String.prototype
上的 normalize
方法执行 Unicode规范化,这解释了这些差别。 若是有一个码位表示与另外一个码位后跟组合标记相同的字符,则会将其标准化为单个码位形式。
>> countSymbolsPedantically('mañana') // U+00F1 6 >> countSymbolsPedantically('mañana') // U+006E + U+0303 6
为了向后兼容 ECMAScript5 和旧环境,可使用 String.prototype.normalize polyfill。
然而,上述方案仍然不是完美的——应用多个组合标记的码位老是致使单个可视字符,但可能没有 normalize 的形式,在这种状况下,normalize 是没有帮助。例如:
>> 'q\u0307\u0323'.normalize('NFC') // `q̣̇` 'q\u0307\u0323' >> countSymbolsPedantically('q\u0307\u0323') 3 // not 1 >> countSymbolsPedantically('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞') 74 // not 6
若是须要更精确的解决方案,可使用正则表达式从输入字符串中删除任何组合标记。
// 将下面的正则表达式替换为通过转换的等效表达式,以使其在旧环境中工做 var regexSymbolWithCombiningMarks = /(\P{Mark})(\p{Mark}+)/gu; function countSymbolsIgnoringCombiningMarks(string) { // 删除任何组合字符,只留下它们所属的字符: var stripped = string.replace(regexSymbolWithCombiningMarks, function($0, symbol, combiningMarks) { return symbol; }); return punycode.ucs2.decode(stripped).length; }
此函数删除任何组合标记,只留下它们所属的字符。任何不匹配的组合标记(在字符串开头)都保持不变。这个解决方案甚至能够在 ECMAScript3 环境中工做,而且它提供了迄今为止最准确的结果:
>> countSymbolsIgnoringCombiningMarks('q\u0307\u0323') 1 >> countSymbolsIgnoringCombiningMarks('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞') 6
上面的算法仍然是一个简化—它仍是没法正确计算像这样的字符:நி,汉语言由连体的 Jamo 组成,如 깍, 表情字符序列,如 👨👩👧👦 ((👨 U+200D
+ 👩 U+200D
+ 👧 + U+200D
+ 👦)或其余相似字符。
Unicode 文本分段上的 Unicode 标准附件#29 描述了用于肯定字形簇边界的算法。 对于适用于全部 Unicode脚本的彻底准确的解决方案,请在 JavaScript 中实现此算法,而后将每一个字形集群计为单个字符。 有人建议将Intl.Segmenter(一种文本分段API)添加到ECMAScript中。
下面是一个相似问题的示例:在JavaScript中反转字符串。这能有多难,对吧? 解决这个问题的一个常见的、很是简单的方法是:
function reverse(string) { return string.split('').reverse().join(''); }
它彷佛在不少状况下都颇有效:
>> reverse('abc') 'cba' >> reverse('mañana') // U+00F1 'anañam'
然而,它彻底打乱了包含组合标记或位于辅助平面字符的字符串。
>> reverse('mañana') // U+006E + U+0303 'anãnam' // note: the `~` is now applied to the `a` instead of the `n` >> reverse('💩') // U+1F4A9 '��' // `'\uDCA9\uD83D'`, the surrogate pair for `💩` in the wrong order
要在 ES6 中正确反转位于辅助平面字符,字符串迭代器能够与 Array.from
结合使用:
function reverse(string) { return Array.from(string).reverse().join(''); }
可是,这仍然不能解决组合标记的问题。
幸运的是,一位名叫 Missy Elliot 的聪明的计算机科学家提出了一个防弹算法来解释这些问题。它看上去像这样:
我把丁字裤放下,翻转,而后倒过来。我把丁字裤放下,翻转,而后倒过来。
事实上:经过将任何组合标记的位置与它们所属的字符交换,以及在进一步处理字符串以前反转任何代理对,能够成功避免问题。
// 使用库 Esrever (https://mths.be/esrever) >> esrever.reverse('mañana') // U+006E + U+0303 'anañam' >> esrever.reverse('💩') // U+1F4A9 '💩' // U+1F4A9
这种行为也会影响其余字符串方法。
String.fromCharCode
能够将一个码位转换为字符。 但它只适用于 BMP 范围内的码位 ( 即从 U+0000
到U+FFFF
)。若是将它用于转换超过 BMP 平面外的码位 ,将得到意想不到的结果。
>> String.fromCharCode(0x0041) // U+0041 'A' // U+0041 >> String.fromCharCode(0x1F4A9) // U+1F4A9 '' // U+F4A9, not U+1F4A9
惟一的解决方法是本身计算代理项一半的码位,并将它们做为单独的参数传递。
>> String.fromCharCode(0xD83D, 0xDCA9) '💩' // U+1F4A9
若是不想计算代理项的一半,可使用 Punycode.js 的实用方法:
>> punycode.ucs2.encode([ 0x1F4A9 ]) '💩' // U+1F4A9
幸运的是,ECMAScript 6 引入了 String.fromCodePoint(codePoint)
,它能够位于基本平面外的码位的字符。它能够用于任何 Unicode 编码点,即从 U+000000
到 U+10FFFF
。
>> String.fromCodePoint(0x1F4A9) '💩' // U+1F4A9
为了向后兼容ECMAScript 5 和更旧的环境,使用 String.fromCodePoint() polyfill
。
若是使用 String.prototype.charAt(position)
来检索包含字符串中的第一个字符,则只能得到第一个代理项而不是整个字符。
>> '💩'.charAt(0) // U+1F4A9 '\uD83D' // U+D83D, i.e. the first surrogate half for U+1F4A9
有人提议在 ECMAScript 7 中引入 String.prototype.at(position)
。它相似于charAt
,只不过它尽量地处理完整的字符而不是代理项的一半。
>> '💩'.at(0) // U+1F4A9 '💩' // U+1F4A9
为了向后兼容 ECMAScript 5 和更旧的环境,可使用 String.prototype.at() polyfill/prollyfill。
相似地,若是使用 String.prototype.charCodeAt(position)
检索字符串中第一个字符的码位,将得到第一个代理项的码位,而不是 poo 字符堆的码位。
>> '💩'.charCodeAt(0) 0xD83D
幸运的是,ECMAScript 6 引入了 String.prototype.codePointAt(position)
,它相似于 charCodeAt
,只不过它尽量处理完整的字符而不是代理项的一半。
>> '💩'.codePointAt(0) 0x1F4A9
为了向后兼容 ECMAScript 5 和更旧的环境,使用 String.prototype.codePointAt()_polyfill。
假设想要循环字符串中的每一个字符,并对每一个单独的字符执行一些操做。
在 ECMAScript 5 中,你必须编写大量的样板代码来判断代理对:
function getSymbols(string) { var index = 0; var length = string.length; var output = []; for (; index < length - 1; ++index) { var charCode = string.charCodeAt(index); if (charCode >= 0xD800 && charCode <= 0xDBFF) { charCode = string.charCodeAt(index + 1); if (charCode >= 0xDC00 && charCode <= 0xDFFF) { output.push(string.slice(index, index + 2)); ++index; continue; } } output.push(string.charAt(index)); } output.push(string.charAt(index)); return output; } var symbols = getSymbols('💩'); symbols.forEach(function(symbol) { console.log(symbol == '💩'); });
或者可使用正则表达式,如 var regexCodePoint = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
并迭代匹配
在 ECMAScript 6中,你能够简单地使用 for…of
。字符串迭代器处理整个字符,而不是代理对。
for (const symbol of '💩') { console.log(symbol == '💩'); }
不幸的是,没有办法对它进行填充,由于 for…of
是一个语法级结构。
此行为会影响几乎全部字符串方法,包括此处未明确说起的方法(如 String.prototype.substring
,String.prototype.slice
等),所以在使用它们时要当心。
正则表达式中的点运算符(.
)只匹配一个“字符”, 可是因为JavaScript将代理半部分公开为单独的 “字符”,因此它永远不会匹配位于辅助平面上的字符。
>> /foo.bar/.test('foo💩bar') false
让咱们思考一下,咱们可使用什么正则表达式来匹配任何 Unicode字符? 什么好主意吗? 以下所示的,.
这w个是不够的,由于它不匹配换行符或整个位于辅助平面上的字符。
>> /^.$/.test('💩') false
为了正确匹配换行符,咱们可使用 [\s\S]
来代替,但这仍然不能匹配整个位于辅助平面上的字符。
>> /^[\s\S]$/.test('💩') false
事实证实,匹配任何 Unicode 编码点的正则表达式一点也不简单:
>> /[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/.test('💩') // wtf true
固然,你不但愿手工编写这些正则表达式,更不用说调试它们了。为了生成像上面的一个正则表达式,可使用了一个名为 Regenerate的库,它能够根据码位或字符列表轻松地建立正则表达式:
>> regenerate().addRange(0x0, 0x10FFFF).toString() '[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]'
从左到右,这个正则表达式匹配BMP字符、代理项对或单个代理项。
虽然在 JavaScript 字符串中技术上容许使用单独的代理,可是它们自己并不映射到任何字符,所以应该避免使用。术语 Unicode标量值 指除代理码位以外的全部码位。下面是一个正则表达式,它匹配任何 Unicode 标量值:
>> regenerate() .addRange(0x0, 0x10FFFF) // all Unicode code points .removeRange(0xD800, 0xDBFF) // minus high surrogates .removeRange(0xDC00, 0xDFFF) // minus low surrogates .toRegExp() /[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/
Regenerate做为构建脚本的一部分使用的,用于建立复杂的正则表达式,同时仍然保持生成这些表达式的脚本的可读性和易于维护。
ECMAScript 6 为正则表达式引入一个 u
标志,它会使用 .
操做符匹配整个码位,而不是代理项的一半。
>> /foo.bar/.test('foo💩bar') false >> /foo.bar/u.test('foo💩bar') true
注意 .
操做符仍然不会匹配换行符,设置 u
标志时,.
操做符等效于如下向后兼容的正则表达式模式:
>> regenerate() .addRange(0x0, 0x10FFFF) // all Unicode code points .remove( // minus `LineTerminator`s (https://ecma-international.org/ecma-262/5.1/#sec-7.3): 0x000A, // Line Feed <LF> 0x000D, // Carriage Return <CR> 0x2028, // Line Separator <LS> 0x2029 // Paragraph Separator <PS> ) .toString(); '[\0-\t\x0B\f\x0E-\u2027\u202A-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]' >> /foo(?:[\0-\t\x0B\f\x0E-\u2027\u202A-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])bar/u.test('foo💩bar') true
考虑到 /[a-c]/
匹配任何字符从 码位为 U+0061
的字母 a 到 码位为 U+0063
的字母 c,彷佛/[💩-💫]/ 会匹配码位 U+1F4A9
到码位 U+1F4AB
,然而事实并不是如此:
>> /[💩-💫]/ SyntaxError: Invalid regular expression: Range out of order in character class
发生这种状况的缘由是,正则表达式等价于:
>> /[\uD83D\uDCA9-\uD83D\uDCAB]/ SyntaxError: Invalid regular expression: Range out of order in character class
事实证实,不像咱们想的那样匹配码位 U+1F4A9
到码位 U+1F4AB
,而是匹配正则表达式:
U+D83D(高代理位)
从 U+DCA9
到 U+D83D
的范围(无效,由于起始码位大于标记范围结束的码位)
U+DCAB(低代理位)
>> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCA9') // match U+1F4A9 true >> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4A9}') // match U+1F4A9 true >> /[💩-💫]/u.test('💩') // match U+1F4A9 true >> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAA') // match U+1F4AA true >> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AA}') // match U+1F4AA true >> /[💩-💫]/u.test('💪') // match U+1F4AA true >> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAB') // match U+1F4AB true >> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AB}') // match U+1F4AB true >> /[💩-💫]/u.test('💫') // match U+1F4AB true
遗憾的是,这个解决方案不能向后兼容 ECMAScript 5 和更旧的环境。若是这是一个问题,应该使用 Regenerate 生成 es5兼容的正则表达式,处理辅助平面范围内的字符:
>> regenerate().addRange('💩', '💫') '\uD83D[\uDCA9-\uDCAB]' >> /^\uD83D[\uDCA9-\uDCAB]$/.test('💩') // match U+1F4A9 true >> /^\uD83D[\uDCA9-\uDCAB]$/.test('💪') // match U+1F4AA true >> /^\uD83D[\uDCA9-\uDCAB]$/.test('💫') // match U+1F4AB true
这种行为会致使许多问题。例如,Twitter 每条 tweet 容许 140 个字符,而它们的后端并不介意它是什么类型的字符——是否为辅助平面内的字符。但因为JavaScript 计数在其网站上的某个时间点只是读出字符串的长度,而不考虑代理项对,所以不可能输入超过 70 个辅助平面内的字符。(这个bug已经修复。)
许多处理字符串的JavaScript库不能正确地解析辅助平面内的字符。
例如,Countable.js 它没有正确计算辅助平面内的字符。
Underscore.string
有一个 reverse 方法,它不处理组合标记或辅助平面内的字符。(改用 Missy Elliot 的算法)
它还错误地解码辅助平面内的字符的 HTML 数字实体,例如 💩
。 许多其余 HTML 实体转换库也存在相似的问题。(在修复这些错误以前,请考虑使用 he 代替全部 HTML 编码/解码需求。)
原文:
https://firebase.google.com/docs/cloud-messaging/
代码部署后可能存在的BUG无法实时知道,过后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给你们推荐一个好用的BUG监控工具 Fundebug。
你的点赞是我持续分享好东西的动力,欢迎点赞!
一个笨笨的码农,个人世界只能终身学习!
更多内容请关注公众号《大迁世界》!
Fundebug专一于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了9亿+错误事件,付费客户有Google、360、金山软件、百姓网等众多品牌企业。欢迎你们免费试用!