字符串长度为什么会跟预想的不一样

673 阅读4分钟

先来看一个简单的例子:

'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

fromCodePointcodePointAt方法的逆操作,根据指定的码位生成一个字符。它与ES5中的fromCharCode方法相对应,二者只有在传递非BMP的码位作为参数时其返回值才会不同。

String.fromCodePoint(128077);
// => '👍'

String.fromCharCode(128077)
// => '' (Chrome、Node)
// => '\uf44d' (FireFox)