Skip to content

深入理解 ES6 #2-字符串和正则表达式

更好的 Unicode 支持

在 ES6 出现以前,JS 字符串一直基于 16 位字符编码(UTF-16)进行构建。

每 16 位的序列是一个编码单元(code unit),代表一个字符。

lengthcharAt() 等字符串属性和方法都是基于这种编码单元构造的。

Unicode 引入扩展字符集后,16 位的序列不足以表示所有的字符,编码规则不得不进行改变。

UTF-16 码位(code point)

在 UTF-16 中,前 216 个码位均以 16 位的编码单元表示,这个范围被称作基本多文种平面(BMP,Basic Multilingual Plane)

超出这个范围的码位则要归属于某个辅助平面(supplementary plane),其中的码位仅用 16 位就无法表示了。

为此,UTF-16 引入了代理对(surrogate pair),其规定用两个 16 位编码单元表示一个码位。

这也就是说字符串里的字符有两种

  1. 由一个编码单元 16 位表示的 BMP 字符
  2. 由两个编码单元 32 位表示的辅助平面字符

在 ES5 中,所有字符串的操作都是基于 16 位编码单元。如果采用同样的方式处理包含代理对的 UTF-16 编码字符,得到的结果可能与预期不符。

js
let text = "𠮷";

// 期望的长度是 1,但是由于使用代理对表示的,返回值为 2
console.log(text.length); // 2
// 匹配一个字符的正则表达式失败
console.log(/^.$/.test(text)); // false
// 前后两个 16 位的编码单元都不表示任何可打印的字符,所以都不返回合法的字符
console.log(text.charAt(0)); // �
console.log(text.charAt(1)); // �
// charCodeAt 返回每个 16 位编码单元对应的数值
console.log(text.charCodeAt(0)); // 55362
console.log(text.charCodeAt(1)); // 57271

在 ES6 中强制使用 UTF-16 字符串编码来解决上述问题。

codePointAt() 方法

该方法为 ES6 新增方法,接受编码单元的位置而非字符位置作为参数,返回与字符串中给定位置对应的码位,即一个整数值。

js
let text = "𠮷 a";

console.log(text.charCodeAt(0)); // 55362
console.log(text.charCodeAt(1)); // 57271
console.log(text.charCodeAt(2)); // 97

// 返回完整的码位,即使这个码位包含多个编码单元
console.log(text.codePointAt(0)); // 134071
// 返回值同 charCodeAt 相同
console.log(text.codePointAt(1)); // 57271
// BMP 字符集字符,返回值同 charCodeAt 相同
console.log(text.codePointAt(2)); // 97

对于 BMP 字符集中的字符,codePointAt() 方法的返回值与 charCodeAt() 方法的相同,而对于非 BMP 字符集来说返回值则不同。

使用 codePointAt 方法检测一个字符占用的编码单元数量。

js
function is32Bit(c) {
    return c.codePointAt(0) > 0xFFFF;
}

console.log(is32Bit("𠮷")); // true
console.log(is32Bit("a")); // false

String.fromCodePoint() 方法

根据指定的码位生成一个字符。

js
console.log(String.fromCodePoint(134071)); // "𠮷"

normalize() 方法

提供 Unicode 的标准化形式。

在对比字符串之前,一定先把它们标准化为同一种形式。

看一个示例,比较两个字符 "切" 和 "𠮷" 的大小。

js
console.log("切".codePointAt(0)); // 64000
console.log("𠮷".codePointAt(0)); // 134071

console.log("切".charCodeAt(0)); // 64000
console.log("𠮷".charCodeAt(0)); // 55362

console.log("切" < "𠮷"); // false
console.log("切".normalize() < "𠮷".normalize()); // true

"切" 的 Unicode 是 64000,"𠮷" 的 Unicode 是 134071。

字符串比较时,"切" 应该小于 "𠮷" 才对,但是比较结果是 false

而对字符串使用 normalize() 方法标准化之后再比较就和预想的结果一致了。

正则表达式 u 修饰符

正则表达式可以完成简单的字符串操作,但默认将字符串中的每一个字符按照 16 位编码单元处理。

为解决这个问题,ES6 给正则表达式定义了一个支持 Unicode 的 u 修饰符。

js
let text = "𠮷";

console.log(text.length); // 2
console.log(/^.$/.test(text)); // false
console.log(/^.$/u.test(text)); // true

正则表达式 /^.$/ 匹配所有单字节字符串。

没有使用 u 修饰符时,会匹配编码单元;

使用 u 修饰符时,会匹配字符。

计算码位数量

ES6 不支持字符串码位数量的检测(length 属性仍然返回字符串编码单元的数量)。

但使用 u 修饰符后,可以通过正则表达式计算出码位的数量。

js
function codePointLength(text) {
    let result = text.match(/[\s\S]/gu);
    return result ? result.length : 0;
}

console.log("abc".length); // 3
console.log("𠮷 bc".length); // 4

console.log(codePointLength("abc")); // 3
console.log(codePointLength("𠮷 bc")); // 3

NOTE

这个方法虽然有效,但是统计长字符串中的码位数量时,运行效率很低。

检测 u 修饰符支持

u 修饰符是语法层面的变更,尝试在不兼容 ES6 的 JS 引擎中使用它会导致语法错误。

js
function hasRegExpU() {
    try {
        var pattern = new Regexp(".", "u");
        return true;
    } catch (ex) {
        return false;
    }
}

其它字符串变更

字符串中的子串识别

ES6 提供了以下 3 个新方法来识别字符串中的子串。

  • includes() 方法

    如果在字符串中检测到指定文本则返回 true,否则返回 false

  • startsWith() 方法

    如果在字符串的起始部分检测到指定文本则返回 true,否则返回 false

  • endsWith() 方法

    如果在字符串的结束部分检测到指定文本则返回 true,否则返回 false

以上方法都接受两个参数

  1. 指定要搜索的文本

  2. 可选参数 指定一个开始搜索的位置的索引值

js
let msg = "Hello World!";

console.log(msg.startsWith("Hello")); // true
console.log(msg.endsWith("!")); // true
console.log(msg.includes("o")); // true

console.log(msg.startsWith("o")); // false
console.log(msg.endsWith("world!")); // false
console.log(msg.includes("x")); // false

console.log(msg.startsWith("o", 4)); // true
console.log(msg.endsWith("o", 8)); // true
console.log(msg.includes("o", 8)); // false

repeat() 方法

接受一个 number 类型的参数,表示该字符串重复的次数;

返回值是当前字符串重复一定次数后的新字符串。

js
console.log("x".repeat(3)); // "xxx"
console.log("hello".repeat(2)); // "hellohello"
console.log("abc".repeat(4)); // "abcabcabcabc"

其它正则表达式语法变更

正则表达式 y 修饰符

它会影响正则表达式搜索过程中的 sticky 属性,当在字符串中开始字符匹配时,它会通知搜索从正则表达式的 lastIndex 属性开始进行,如果在指定位置没有成功匹配,则停止继续匹配。

js
let text = "hello1 hello2 hello3",
    pattern = /hello\d\s?/,
    result = pattern.exec(text),
    globalPattern = /hello\d\s?/g,
    globalResult = globalPattern.exec(text),
    stickyPattern = /hello\d\s?/y,
    stickyResult = stickyPattern.exec(text);

console.log(result[0]); // "hello1 "
console.log(globalResult[0]); // "hello1 "
console.log(stickyResult[0]); // "hello1 "

pattern.lastIndex = 1;
globalPattern.lastIndex = 1;
stickyPattern.lastIndex = 1;

result = pattern.exec(text);
globalResult = globalPattern.exec(text);
stickyResult = stickyPattern.exec(text);

// 没有修饰符的正则表达式忽略 lastIndex 的变化
console.log(result[0]); // "hello1 "
// 使用 g 修饰符的正则表达式,从 e 开始搜索,匹配"hello2 "
console.log(globalResult[0]); // "hello2 "
// 使用 y 修饰符的粘滞正则表达式,从 e 开始匹配不到相应字符串,就此终止,stickyResult 的值为 null
console.log(stickyResult[0]); // 抛出错误:Uncaught TypeError: Cannot read property '0' of null

当执行操作时,y 修饰符会把上次匹配后面一个字符的索引保存在 lastIndex 中;

如果该操作匹配的结果为空,则 lastIndex 会被重置为 0.

g 修饰符的行为与此相同。

js
let text = "hello1 hello2 hello3",
    pattern = /hello\d\s?/,
    result = pattern.exec(text),
    globalPattern = /hello\d\s?/g,
    globalResult = globalPattern.exec(text),
    stickyPattern = /hello\d\s?/y,
    stickyResult = stickyPattern.exec(text);

console.log(result[0]); // "hello1 "
console.log(globalResult[0]); // "hello1 "
console.log(stickyResult[0]); // "hello1 "

console.log(pattern.lastIndex); // 0
console.log(globalPattern.lastIndex); // 7
console.log(stickyPattern.lastIndex); // 7

result = pattern.exec(text);
globalResult = globalPattern.exec(text);
stickyResult = stickyPattern.exec(text);

console.log(result[0]); // "hello1 "
console.log(globalResult[0]); // "hello2 "
console.log(stickyResult[0]); // "hello2 "

console.log(pattern.lastIndex); // 0
console.log(globalPattern.lastIndex); // 14
console.log(stickyPattern.lastIndex); // 14

关于 y 修饰符还需要记住两点:

  1. 只有调用 exec()test() 这些正则表达式对象的方法时才会涉及 lastIndex 属性;调用字符串的方法,例如 match(),则不会触发粘滞行为。

  2. 对于粘滞正则表达式而言,如果使用 ^ 字符来匹配字符串开端,只会从字符串的起始位置或者多行模式的首行进行匹配。

    lastIndex 为 0 时,是否使用粘滞正则表达式并无差别;

    如果 lastIndex 不为 0,则该表达式永远不会匹配到正确结果。

检测 y 修饰符是否存在

js
let pattern = /hello\d/y;
console.log(pattern.sticky); // true

如果 JS 引擎支持粘滞正则表达式,则 sticky 属性为 true,否则为 false。这个属性是只读的。

检测 JS 引擎是否支持 y 修饰符

js
function hasRegExpY() {
    try {
        var pattern = new RegExp(".", "y");
        return true;
    } catch (ex) {
        return false;
    }
}

正则表达式的复制

js
var re1 = /ab/i,
    re2 = new RegExp(re1);

ES6 增加了一个可选参数,为正则表达式指定一个修饰符。

js
var re1 = /ab/i, // 使用 i 修饰符,添加大小写无关的特性
    // ES5 中会抛出错误,ES6 中正常运行
    re2 = new RegExp(re1, "g"); // 使用 g 修饰符代替 i

console.log(re1.toString()); // "/ab/i"
console.log(re2.toString()); // "/ab/g"

console.log(re1.test("ab")); // true
console.log(re2.test("ab")); // true

console.log(re1.test("AB")); // true
console.log(re2.test("AB")); // false

flags 属性

获取正则表达式所使用的修饰符。

js
let re = /ab/g;
console.log(re.source); // "ab"
console.log(re.flags); // "g"

模板字面量

基础语法

js
let message = `Hello world!`;
console.log(message); // Hello world!
console.log(typeof message); // string
console.log(message.length); // 12

反撇号的转义 (使用反斜杠 \)

js
let message = `\`Hello\` world!`;
console.log(message); // `Hello` world!
console.log(typeof message); // string
console.log(message.length); // 14

单、双引号不需要转义。

多行字符串

js
let message = `Multiline
string`;

console.log(message); // Multiline
                      // string
console.log(message.length); // 16

反撇号中的所有空白字符都属于字符串的一部分,所以千万要注意缩进。

也可以显示的使用 \n 来实现换行。

js
let message = `Multiline\nstring`;

字符串占位符

模板字面量中可以使用占位符功能。

js
let name = "Nicholas",
    message = `Hello, ${name}.`;
console.log(message); // "Hello, Nicholas."

模板字面量可以访问作用域中所有可访问的变量,无论是在严格模式还是非严格模式,尝试嵌入一个未定义的变量总是会抛出错误。

除变量外,还可以嵌入其它内容,如运算式、函数调用等。

js
let count = 10,
    price = 0.25,
    message = `${count} items cost $${(count * price).toFixed(2)}.`;
console.log(message); // "10 items cost $2.50."

可以在一个模板字面量中嵌入另一个模板字面量。

js
let name = "Nicholas",
    message = `Hello, ${
        `my name is ${name}`
    }.`;
console.log(message); // "Hello, my name is Nicholas."

标签模板

每个模板标签都可以执行模板字面量上的转换并返回最终的字符串值。
标签指的是在模板字面量第一个反撇号(`)前方标注的字符串。

js
let message = tag`Hello world`;

定义标签

标签可以是一个函数。

  • 第一个参数是一个数组,包含 JS 解释过后的字面量字符串

  • 之后的所有参数都是每一个占位符的解释值

js
function tag(literals, ...substitutions) {

}

使用下面具体的例子来说明这两个参数的值。

js
function passthru(literals, ...substitutions) {
    let result = "";

    for (let i = 0; i < substitutions.length; i++) {
        result += literals[i];
        result += substitutions[i];
    }

    result += literals[literals.length -1];

    return result;
}

let count = 10,
    price = 0.25,
    message = passthru`${count} items cost $${(count * price).toFixed(2)}.`;

console.log(message); // 10 items cost $2.50.

literals 的值:

json
[
  "", // 第一个占位符前的空字符串
  " items cost $", // 第一、二个占位符之间的字符串
  "." // 第二个占位符后的字符串
]

substitutions 的值:

json
[
  10, // 第一个占位符的值
  "2.50" // 第二个占位符的值
]

literals 的数量总是比 substitutions 多一个。

上面例子中的 passthru 方法模拟了模板字面量的默认行为。

在模板字面量中使用原始值

使用内建的 String.raw() 标签:

js
let message1 = `Multiline\nstring`,
    message2 = String.raw`Multiline\nstring`;

console.log(message1); // "Multiline
                       // string"
console.log(message2); // "Multiline\nstring"

message1 中的 \n 被解释为一个新行,message2 中的 \n 则以原始值现实。