看到这个标题的感觉是不是😨,又是标题党,骗关注来了,但是稍安勿躁,在js的世界里面,虽然看似有很多办法来判断,网上随便搜索一下也能有很多文章和示例代码,但是今天本文要说的是你或许还不知道的那一部分,你可能永远也不会遇到的那一部分,你或许应该了解了解,这样对字符串、Unicode等方面会有一个更加全面的认识💪。
关于字符串
字符串在工作中运用太广泛了,但是关于字符串的一些基本概念可能很多人不是那么清楚了,其实字符串使用的是UTF-16编码。对于一个字符,可能采用两个字节,也可能采用四个字节来保存,怎么区分呢,根据字符的Unicode处于BMP(基础多文种平面)还是非BMP,后面会简要介绍(详细介绍可参考文章末尾的链接),
String.prototype.length
对于字符串length属性,大家再熟悉不过了,也很常用,很多人都会清楚对于某些特殊字符(如emoji),length属性是判断不准确的,那我们需要搞清楚的是length到底判断的是什么:
length is the number of code units it contains
,
code units
就是码元,简单理解就是大致等价于Unicode转义符(\u+四个十六进制字符),先看看一个例子:
const s1 = '😂';
const s2 = '\uD83D\uDE02';
s1 === s2; // true
s1.length; // 2
看上去就是一个笑脸,但实际情况却是由两个Unicode转义符组成,那么length当然就是2了,为什么会这样呢,这里不做展开,关于这方面的文章也比较多,这里只想说明length判断的局限性和真实意图。
Unicode
前面说了很多Unicode,有编程经验的至少都听过吧,就是把全世界所有用得到的字符都一一用数字进行标记(Code Point,也叫码点),用十六进制表示范围在U+0000+U10FFFF之间,即0~1114111,因此可以表示1114112个字符。不光如此还分了17个平面,有一个基本多语言平面BMP是我们接触最多的字符集,例如英文和绝大部分汉字都在这个集合里面进行了编号,然后具体怎么存储就是UTF-8/UTF-16这些需要解决的问题,如果你对Unicode感兴趣,可以看看官网对所有字符的编码情况,其中汉字就在CJK那一栏,关于Unicode的知识就太多可说了,赶紧刹住车,还是回到本文主题,怎么判断字符串的长度吧。
字素(grapheme)
再啰嗦两句🐶,我们期望的长度是什么,其实就是我们视觉上看到的字符个数,可实际情况是真实存储的码元跟我们看到的很多时候是不一致的,看了《你不知道的JavaScript》一书,发现了字素(grapheme)这个概念,也就是我们视 觉上看到的渲染出来的单个字符,我们大部分时候就是要判断这个东西👀。
es6的支持
es6中有一些语法和API是可以识别Unicode编码的,这样就为判断提供了可行性,例如:
for (let v of '💐💟') {
console.log(v); 💐 💟
[...'💐💟'].length; // 2
Array.from('💐💟').length; // 2
Promise.all('💐💟').then((res) => {
console.log(res.length); // 2
const s = new Set('💐💟');
s.size; // 2
'💐💟'.match(/./gu); // ['💐', '💟']
const spRegexp = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
'💐💟'.replace(spRegexp, '_').length; // 2
这么多方式可以准确判断这些复杂的图标,不是解决了么,对于大部分情况是如此,但总有一些意外😭。
组合音标符号(Combining Diacritical Mark)
其实就是一组会修改前面相邻字符的码点(Code Point,也就是字符的数值编号),例如数学上的特殊符号,这种情况下使用刚刚提到的那些方法就不准确了,不信的话可以试试(代码摘抄至《你不知道的JavaScript(下卷)》):
const s1 = 'e\u0301'; // 'é'
[...s1].length; // 2
别慌🙉,我们还有办法,那就是String.prototype.normalize:
var s1 = "\xE9",
s2 = "e\u0301";
s1.normalize().length; // 1
s2.normalize(); // '\u00e9'
s1 === s2; // false
s1 === s2.normalize(); // true
这就成了,normalize干了啥呢,'e\u0301'转换成了'\u00e9',前面一种写法是小写字母e加上了一个组合音标符号,效果就是e加了个小帽子🚨,正好呢'\u00e9'也是定义的同一个字符(在BMP中),那么normalize就把多个Unicode转义符转换成等价的单个Unicode转义符了,看到这里所有问题不都解决了么,哎😔,但如果是多个组合音标符号同时修改一个字符呢?
var s1 = "e\u0301\u0330";
console.log(s1); // "ḛ́"
console.log(s1.length); // 3
var s2 = s1.normalize(); // '\u1e1b\u0301'
s2.length; // 2
这一次s加了两个组合音符符号,'\u0301'加了个帽子,'\u0330'加了个鞋子👡,变成了ḛ́,但是通过normalize却还是不符合预期啊,为什么呢,因为ḛ́这个字符没有在BMP里面编号,normalize找不到,因此不能变成一个Unicode转义符了,但是加了鞋子的字符有啊('e\u0330'),就像上一个例子一样,因此转换成了'\u1e1b'(那为啥没转换'e\u0301'=>'\u00e9'呢,可能是从右往左的顺序吧😭),最终变成了两个Unicode转义符,对于这种情况,我们是没有办法的,也就是文章标题说的通过js这些方法是不能准确获取字素的,那咋办,别问我,我不知道。
在实际工作中会碰到汉字匹配的问题,前面说到,汉字一部分在BMP(U+0000U+FFFF)中,另一部分在非BMP(U+10000U+10FFFF)中,具体细分还有好几个扩展集合:
下面是各个集合对应的Unicode编码范围:
CJK Unified Ideographs(Han): 4E00~9FFF
CJK Extension A: 3400~4DBF
CJK Extension B: 20000~2A6DF
CJK Extension C: 2A700-2B73F
CJK Extension D: 2B740-2B81F
太多了,就不一一列出来了,而在网上有一些匹配中文的正则是这样写的:
const regx1 = /[\u4E00-\u9FA5]+/g;
const regx2 = /[\u4E00-\u9FFF]+/g;
对比上面Unicode官网的编码范围,这两个正则只是匹配了一部分而已,很多生僻字都是没有的,regx2好歹还包含CJK Unified Ideographs(Han),而regx1都没有包含完,因此如果要包含所有的汉字,就要把这些扩展都包含进来才算完整,(如果你不考虑那些生僻字的话,也行呗)。
maxlength
最后还想说说maxlength属性,在input标签上添加这个属性,就可以控制输入字符的个数啦:
<input type="text" maxlength="1" />
<script>
const a = 'e\u0301\u0330'; // ḛ́
const b = '\u1e1b\u0301'; // ḛ́
const c = '💩'; // '\uD83D\uDCA9'
const d = '𪝑';
</script>
如果你尝试粘贴a、b、c和d,你会发现a粘贴的结果是e
,b的结果是ḛ
,c压根粘贴不进去,d呢变成�(乱码,无实意的替换符),其实maxlength就是判断的length属性,如果a.slice(0, maxlength)是在Unicode编码中有对应的字符,那么就会执行粘贴,否则就丢弃或者乱码(具体要看截取的十六进制编码在Unicode中的定义)。
参考资料: