字符串截取异常

101 阅读3分钟

最近在处理一些特殊国家的语言,如果按照常规方法截取字符串长度,容易出现一种现象就是截取长度后,字符串内容发生变化了,因此记录下原因及正常处理的方法

原因

背景:早期字符是以ucs-2的格式存储,每个文字对应16位空间,每个空间就是一个码元,到了后期字符越来越多,就以utf-16格式存储,每个文字对应16或者32位,即有可能会是两个码元存储一个字符,而我们常用length去截取的话,一般都是针对码元去操作的。𠮷是两个码元存储,a是一个码元存储,一个字符就是一个码点,一个码点可以包含两个码元或者一个码元

image.png

字符以 UTF-16 的格式储存,每个字符固定为2个字节。对于那些需要4个字节储存的字符(Unicode 码点大于0xFFFF的字符),JavaScript 会认为它们是两个字符。因此使用常规字符串段截取,如果存在大于0xFFFF的字符就会出现问题

var s="𠮷a";
s.length // 3
s.charAt(0) // '\uD842'
s.charAt(1) // '\uDFB7'
s.charAt(2) // 'a'
s.charCodeAt(0) // 55362
s.charCodeAt(1) // 57271
s.charCodeAt(2) // 97

String.fromCharCode(s.charCodeAt(0)) //'\uD842'
String.fromCharCode(s.charCodeAt(1)) //'\uDFB7'
String.fromCharCode(s.charCodeAt(2)) //'a'

上面代码中,汉字“𠮷”(注意,这个字不是“吉祥”的“吉”)的码点是0x20BB7,UTF-16 编码为0xD842 0xDFB7(十进制为55362 57271),需要4个字节储存。对于这种4个字节的字符,JavaScript 不能正确处理,字符串长度会误判为2,而且charAt()方法无法读取整个字符,charCodeAt()方法只能分别返回前两个字节和后两个字节的值

识别大于0xFFFF的字符

ES6 提供了String.fromCodePoint()方法,可以识别大于0xFFFF的字符

ES6 提供了codePointAt()方法,能够正确处理 4 个字节储存的字符,返回一个字符的码点

var s = '𠮷a';

s.codePointAt(0) // 134071
s.codePointAt(1) // 57271
s.codePointAt(2) // 97
String.fromCodePoint(s.codePointAt(0)) //'𠮷'
String.fromCodePoint(s.codePointAt(1)) //'\uDFB7'
String.fromCodePoint(s.codePointAt(2)) //'a'

codePointAt()方法的参数,是字符在字符串中的位置(从 0 开始)。上面代码中,JavaScript 将“𠮷a”视为三个字符,codePointAt 方法在第一个字符上,正确地识别了“𠮷”,返回了它的十进制码点 134071(即十六进制的20BB7)。在第二个字符(即“𠮷”的后两个字节)和第三个字符“a”上,codePointAt()方法的结果与charCodeAt()方法相同。

codePointAt()方法会正确返回 32 位的 UTF-16 字符的码点。对于那些两个字节储存的常规字符,它的返回结果与charCodeAt()方法相同。

使用常规方法截取长度问题

var s = '𠮷a';
s.length // 3
s.charAt(2) // 'a'
String.fromCharCode(s.codePointAt(2))//'a'

codePointAt()方法的参数,仍然是不正确的。比如,上面代码中,以肉眼观察字符a在字符串s的正确位置序号应该是 1,但是必须向codePointAt()方法传入 2。解决这个问题的一个办法是使用for...of循环,因为它会正确识别 32 位的 UTF-16 字符。

处理处理大于0xFFFF的字符串

1、for...of

var s = '𠮷a';
for (let ch of s) { console.log(ch,ch.length,ch.codePointAt(0),String.fromCodePoint(ch.codePointAt(0)))
}

// 𠮷 2 134071 𠮷
// a 1 97 a

2、使用扩展运算符(...)进行展开运算。

var s='𠮷a';
var arr = [...s]; 
arr.forEach(ch=>{
     console.log(ch,ch.length,ch.codePointAt(0),String.fromCodePoint(ch.codePointAt(0)))
}) 
//𠮷 2 134071 𠮷
//a 1 97 a

3、通用长度截取方法

String.prototype.sliceByPoint = function (pStart, pEnd) {
  let result = "";//最终截取结果
  let pIndex = 0;//码点的指针(一个码点代表一个字符串)
  let cIndex = 0;//码元的指针(超过0xffff的码点拥有两个码元)
  while (1) {
    if (pIndex >= pEnd || cIndex >= this.length) {
      break;
    }
    const point = this.codePointAt(cIndex);
    if (pIndex >= pStart) {
      result += String.fromCodePoint(point);
    }
    pIndex++;
    cIndex += point > 0xffff ? 2 : 1;
  }
  return result
}
"𠮷a".sliceByPoint(0,1) //"𠮷"
"𠮷a".sliceByPoint(1,2) //"a"
"𠮷a".slice(0,1) //"uD842"
"𠮷a".slice(1,2) //"uDFB7"
"𠮷a".slice(2,3)//"a"

参考

es6 fromCodePoint