JavaScript 如何正确处理 Unicode 编码问题!

45次阅读

共计 12148 个字符,预计需要花费 31 分钟才能阅读完成。

JavaScript 处理 Unicode 的方式至少可以说是令人惊讶的。本文解释了 JavaScript 中的 处理 Unicode 相关的痛点,提供了常见问题的解决方案,并解释了 ECMAScript 6 标准如何改进这种情况。
Unicode 基础知识
在深入研究 JavaScript 之前,先解释一下 Unicode 一些基础知识,这样在 Unicode 方面,我们至少都了解一些。
Unicode 是目前绝大多数程序使用的字符编码,定义也很简单,用一个 码位(code point) 映射一个字符。码位值的范围是从 U+0000 到 U+10FFFF,可以表示超过 110 万个字符。下面是一些字符与它们的码位。

A 的码位 U+0041
a 的码位 U+0061
© 的码位 U+00A9
☃ 的码位 U+2603
???? 的码位 U+1F4A9

码位 通常被格式化为十六进制数字,零填充至少四位数,格式为 U + 前缀。
Unicode 最前面的 65536 个字符位,称为 基本多文种平面(BMP-—Basic Multilingual Plane),又简称为“零号平面”, plane 0), 它的 码位 范围是从 U+0000 到 U+FFFF。最常见的字符都放在这个平面上,这是 Unicode 最先定义和公布的一个平面。
剩下的字符都放在 辅助平面 (Supplementary Plane) 或者 星形平面(astral planes),码位范围从 U+010000 一直到 U+10FFFF,共 16 个辅助平面。
辅助平面内的码位很容易识别: 如果需要超过 4 个十六进制数字来表示码位,那么它就是一个辅助平面内的码。
现在对 Unicode 有了基本的了解,接下来看看它如何应用于 JavaScript 字符串。
转义序列
在谷歌控制台输入如下:
>> ‘\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.
高位的 10 比特的值(值的范围为 0..0x3FF)被加上 0xD800 得到第一个码元或称作高位代理。
低位的 10 比特的值(值的范围也是 0..0x3FF)被加上 0xDC00 得到第二个码元或称作低位代理(low surrogate),现在值的范围是 0xDC00..0xDFFF.

使用代理对,所有辅助平面中的码位 (即从 U+010000 到 U+10FFFF) 都可以表示,但是使用一个转义来表示基本平面的码位,以及使用两个转义来表示辅助平面中的码位,整个概念是令人困惑的,并且会产生许多恼人的后果。
使用 JavaScript 字符串方法来计算字符长度
例如,假设你想要计算给定字符串中的字符个数。你会怎么做呢?
首先想到可能是使用 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 中字符串反转
下面是一个类似问题的示例: 在 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

字符串方法中的 Unicode 的问题
这种行为也会影响其他字符串方法。
将码位转转换为字符
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 等),因此在使用它们时要小心。
正则表达式中的 Unicode 问题
匹配码位和 Unicode 标量值
正则表达式中的点运算符 (.) 只匹配一个“字符”,但是由于 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

实战中的 bug 以及如何避免它们
这种行为会导致许多问题。例如,Twitter 每条 tweet 允许 140 个字符,而它们的后端并不介意它是什么类型的字符——是否为辅助平面内的字符。但由于 JavaScript 计数在其网站上的某个时间点只是读出字符串的长度,而不考虑代理项对,因此不可能输入超过 70 个辅助平面内的字符。(这个 bug 已经修复。)
许多处理字符串的 JavaScript 库不能正确地解析辅助平面内的字符。
例如,Countable.js 它没有正确计算辅助平面内的字符。
Underscore.string 有一个 reverse 方法,它不处理组合标记或辅助平面内的字符。(改用 Missy Elliot 的算法)
它还错误地解码辅助平面内的字符的 HTML 数字实体,例如 &#x1F4A9;。许多其他 HTML 实体转换库也存在类似的问题。(在修复这些错误之前,请考虑使用 he 代替所有 HTML 编码 / 解码需求。)
原文:
https://firebase.google.com/d…
代码部署后可能存在的 BUG 没法实时知道,事后为了解决这些 BUG,花了大量的时间进行 log 调试,这边顺便给大家推荐一个好用的 BUG 监控工具 Fundebug。
你的点赞是我持续分享好东西的动力,欢迎点赞!
一个笨笨的码农,我的世界只能终身学习!
更多内容请关注公众号《大迁世界》!

正文完
 0