先来看一个简单的例子:
'E汉𠮷'.length
// => 4
上述字符串明明只有三个字符,但使用length求得的长度值却是4,更常见的例子存在于表情字符中(假若我们把每个表情字符的长度认为是1):
'👍'.length
// => 2
要解释这一现象,得从UTF-16说起
UTF-16码位
JavaScript采用的是UTF-16编码的Unicode字符集,字符串则是由一组无符号的16位值组成的序列。在ES6出现以前,JavaScript字符串一直基于16位字符编码(UTF-16)构建,每16位的序列是一个编码单元(code point),代表一个字符。当然,在过去16位足够包含任何字符,直到Unicode引入扩展字符集,这种编码规则就开始变得不够用了。
Unicode码在全球范围的信息交换领域有着广泛的应用,它致力于为全世界每一个字符提供全球唯一的标识符,即码位(code point),码位是从0开始的数值。表示字符的数值称之为字符编码(character code)。字符编码将码位编码为内部一致的编码单元。
在UTF-16中,前216个码位均以16位的编码单元来表示,这个范围称之为基本多文种平面(Basic Multilingual Plane, BMP),也称作“零断面”(Plan 0),编码介于U+0000~U+FFFF之间。超出这个范围的码位仅用16位就无法表示了。那些不能表示为16位的Unicode字符集则遵循UTF-16编码规则,使用两个16位编码单元表示一个码位,即代理对(surrogate pair)。换言之,字符存在两种:
- 由一个16位编码单元表示的BMP字符
- 由两个编码单元32位表示的辅助平面字符
在ES6之前,所有的字符串操作都是基于16位字符编码单元的,若采用同样的方式处理含代理对的字符,得到的结果就会与预期不符,如同上文中的两个例子,“𠮷”和“👍”都是通过代理对表示的,JavaScript字符串操作将其视为两个16位字符。
获取真正长度
修饰符
ES6给正则表达式定义了一个支持Unicode的u修饰符。它将从编码单元操作模式切换为字符模式,正则表达式便不会视代理对为两个字符。即便ES6不支持字符串码位数量的检测,我们也能使用正则表达式的u修饰符解决这一问题:
function getCodePointLength(str) {
const result = str.match(/./gus);
return result ? result.length : 0;
}
getCodePointLength('E汉𠮷');
// => 3
getCodePointLength('👍');
// => 1
🔔 这个方法在统计长字符串中的码位数量时效率可能会很低。
👉 关于修饰符u及正则表达式的更多内容,可以参考笔者的这篇文章。
字符串迭代器
我们也可以改变字符串的默认迭代器,使用for-of循环操作字符而不是编码单元来达到计算字符串长度的目的:
function getCodePointLength(str) {
let len = 0;
for(const char of str) { len++; }
return len;
}
getCodePointLength('E汉𠮷');
// => 3
getCodePointLength('👍');
// => 1
访问字符串中的字符
ES5中规定可以通过方括号访问字符串中的字符,这使得字符串变得更像数组,如此我们便可以通过for循环实现字符串遍历。但是如果字符串中包含辅助平面字符,其结果同样可能与我们的预期不符:
function traversalString(str) {
for(let i = 0; i< str.length; i++) {
console.log(str[i]);
//console.log(str.charAt(i));
}
}
traversalString('E汉𠮷');
// => ‘E’
// => ‘汉’
// => ‘�’
// => ‘�’
traversalString('赞👍');
// => ‘赞’
// => ‘�’
// => ‘�’
由于[]或charAt操作的是编码单元而非字符,且辅助平面字符前后两个16位的编码单元都不表示任何可打印的字符,因此没有正常的输出。
同样的,解决这一问题也可以使用字符串迭代器:
function traversalString(str) {
for(const c of str) {
console.log(c);
}
}
traversalString('E汉𠮷');
// => ‘E’
// => ‘汉’
// => ‘𠮷’
traversalString('赞👍');
// => ‘赞’
// => ‘👍’
也可通过这种方式访问任意位置的字符
function getChar(str) {
const result = [];
for(const c of str) { result.push(c); }
return i => result[i];
}
getChar('E汉𠮷')(2);
// => '𠮷'
getChar('赞👍')(0);
// => '赞'
codePointAt
ES6 中新增了codePointAt方法,它与ES5中的charCodeAt相对应,这个方法接受编码单元的位置而非字符位置作为参数,返回字符串中给定位置对应的码位:
const str = '赞👍';
str.charCodeAt(0);
// => 36190
str.codePointAt(0);
// => 36190
// 返回位置1处的码位
str.charCodeAt(1);
// => 55357
// 返回位置1处的完整码位,即使这个码位包含多个编码单元
str.codePointAt(1);
// => 128077
str.charCodeAt(2)
// => 56397
str.codePointAt(2)
// => 56397
从执行结果上我们可以看出:
- 针对BMP字符,两者的返回值是相同的
- 针对非辅助平面字符,两者在这个字符的第二个编码单元上返回值也是相同的,区别在于
codePointAt在字符的第一个编码单元上返回了完整的码位,即使这个码位包含多个编码单元。
同时我们可以利用这个方法实现对BMP字符的判断:
function isBMP(c) {
return c.codePointAt(0) < 0xFFFF;
}
fromCodePoint
fromCodePoint是codePointAt方法的逆操作,根据指定的码位生成一个字符。它与ES5中的fromCharCode方法相对应,二者只有在传递非BMP的码位作为参数时其返回值才会不同。
String.fromCodePoint(128077);
// => '👍'
String.fromCharCode(128077)
// => '' (Chrome、Node)
// => '\uf44d' (FireFox)