首先推荐两篇文章了解字符编码相关细节

Unicode与JavaScript详解

Javascript中的string类型使用UTF-16编码

Unicode、UTF-8 与 UTF-16

在开发的过程中,经常会碰到 Unicode,UTF-8,UTF-16 等等术语,在很多的时候我一直是将它们混为一谈的,不过今天详细的了解了它们之间的不同,作为本章的前言,方便本章内容的理解。

首先 Unicode 表示的是字符集,而 UTF-8,UTF-16 表示的是字符编码。所谓的字符集指的就是字符的集合,Unicode 字符集几乎包含了世界上所有国家的字符,所以 Unicode 又被称为是万国码,如果使用 Unicode 字符集的话,那么可以表示几乎所有国家中的字符(ASCII字符集只包含了英文,而 GBK 字符集包含中日韩等几个国家的字符以及英文字符)。对于每一个字符,Unicode 都为它们对应了一个码点(Code Point),码点从 0 开始计算,例如 \u0000 表示的就是 null,表示空的意思。

UFT-8,UTF-16 则是将 Unicode 字符集中包含的字符编码为数字存储在计算机中,UTF-8 和 UTF-16 分别对应不同的编码规则。这里详细介绍一下 UTF-16,因为 JavaScript 的编码与之相关。

所谓的 UTF-16 指的是使用 16 位即 2 个字节去表示一个字符,不过随着 Unicode 中包含的字符越来越多,使用 2 个字节已经无法表示 Unicode 中所有的字符,所以实际上 UTF-16 使用 2 个字节或者 4 个字节来表示 Unicode 中的字符,那什么时候用 2 个字节表示,什么时候使用 4 个字节表示?

为了解决这个问题,UTF-16 将字符分为了两部分

  • 基本平面:由前 $65536(2^{16})$ 个字符组成,即 \u0000-\uFFFF
  • 辅助平面:剩余其它字符组成

对于位于基本平面中的字符使用 2 个字节表示,而对于辅助平面中的字符使用 4 个字节表示。

那 UTF-16 是怎么解码的呢? 如果在解码时发现某 2 个字节表示的数在 \uD800-\uDFFF 之间,则说明这是一个使用 4 个字节表示的字符,如果不在 \uD800-\uDFFF 之间,则说明是基本平面的字符。

但是 JavaScript 并不是使用 UTF-16 进行编码的,而是使用 UCS-2 进行编码的!!!UCS-2 由于发明的比较早,当时 Unicode 中的字符使用 2 个字节即可表示,所以 UCS-2 对于任意的字符都是使用 2 个字节进行编码的。所以在 JavaScript 中有个 bug 就是它不认 4 个字节表示的字符!!!

上面的那段是我从网上看到,然后总结的,但是我有一个疑问,既然 UCS-2 对任意字符都使用 2 个字节进行编码,这不就意味着有的字符无法被编码,而且也不会有 4 个字节表示的字符,我觉得应该是使用 UTF-16 进行编码,但是使用的是 UCS-2 进行解码(仅仅是我的猜测)。

为什么 JavaScript 不使用 UTF-16 进行编码?

因为在 JavaScript 发明的时候,只有 UCS-2 该种编码被提出来了,UTF-16 还没有被提出来

let text = '𠮷'

console.log(text.length) // 2
console.log(/^.$/.test(text)) // false
console.log(text.charCodeAt(0)) // 55362
console.log(text.charCodeAt(1)) // 57271

上面的字符串 ‘𠮷’ 明明是一个字符,但是 length 属性却显示有两个字符,这是因为 ‘𠮷’ 是辅助平面上的字符,具有 4 个字节,而 JavaScript 并不认识 4 个字节的字符,会将其解释为两个 2 字节的字符,所以这就是 length 属性为 2 的原因。

并且在使用正则表达式时也有 bug,/^.$/ 表示任意只有一个字符的字符串,按照常理来说,/^.$/.test(text) 测试的结果应该是true,但是最后的结果显示是 false

charCodeAt 方法可以获得 Unicode 的 Code Ponit,但是很明显它不能得到 4 字节的 Unicode 字符的 Code Point,只是将这个 4 字节的字符作为两个 2 字节的字符,得到两个 Code Point。

更好的 Uniode 支持

codePointAt()

上面的 charCodeAt()方法无法得到 4 字节字符的 Code Point,而 ES6 提供了 codePointAt() 方法,通过该方法可以得到 4 字节字符的 Code Point

let text = '𠮷'

console.log(text.codePointAt(0)); // 134071
console.log(text.codePointAt(1)); // 57271

上面的 134071 是 ‘𠮷’ 的 Code Point。另外通过 codePointAt 方法可以很容易的知道字符是否为 4 字节的字符

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

console.log(is32BitChar(text)); // true

String.fromCodePoint()

String.fromCodePoint() 的作用与 codePointAt() 方法互补,它的作用是根据 Code Point 得到字符

let c = String.fromCodePoint(134071);
console.log(c);

在我的控制台打印出的一个乱码,不知道是不是我控制台编码的问题。

normalize

有的时候,有多个 Code Point 序列对应一个字符

let string1 = '\u00F1';
let string2 = '\u006E\u0303';

console.log(string1); // ñ
console.log(string2); // ñ

虽然他们表示都是同一个字符,但是因为他们的 Code Point 序列不同,所以直接进行比较时,它们是不同的

let string1 = '\u00F1';            // ñ
let string2 = '\u006E\u0303'; // ñ

console.log(string1 === string2); // false
console.log(string1.length); // 1
console.log(string2.length); // 2

而 normalize 正是来解决这个问题的,它通过将字符转化为标准化的形式,具体见下面的 MDN

String.prototype.normalize()

正则表达式的 u 标签

如果正则表达式使用了 u 标签,那么他会基于字符匹配而不是 Code Point 进行匹配

let text = '𠮷'

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

通过 length 属性,我们得到的并不是字符串的长度,我们可以通过 u 标签计算有多少个 Code Point 从而得到字符串的真实长度

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

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

字符串

识别子串

在 ES5 中,我们经常使用 indexOf 方法来识别子串,在 ES6 中新引入了三个方法来帮助我们更加方便的识别子串

  • includes
  • startsWith
  • endsWith

通过方法的名称即可知道方法的作用。这些方法都接收两个参数

  • 要识别的子串,substr
  • 起始位置,index

对于 index 的含义,includes 和 startsWith 的含义表示以 index 开始识别,而 endsWith 表示以 length - index 结束识别。index 是可选的,如果不传入该参数,默认从字符串的首部开始搜索。

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', 4)); // true
console.log(msg.endsWith('o', 8)); // true
console.log(msg.includes('o', 8)); // false

如果向 startsWith、endsWith、includes 中传入正则表达式,那么会抛出一个错误。

repeat

repeat 方法接收一个数字参数 n,它的作用是将字符串复制 n 遍然后返回

console.log("hello ".repeat(3)); // "hello hello hello "

正则表达式

y Flag

ES6 增加了一个 y 标签,该标签表示从 lastIndex 处精确匹配

// 没有使用修饰符
let pattern = /hello\d\s?/;
// 使用了 g 修饰符
let globalPattern = /hello\d\s?/g;
// 使用了 y 修饰符
let stickyPattern= /hello\d\s?/y;
let text = "hello1 hello2 hello3";

// 进行一次匹配
let result = pattern.exec(text);
let globalResult = globalPattern.exec(text);
let stickyResult= stickyPattern.exec(text);

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

// 修改 lastIndex
pattern.lastIndex = 1;
globalPattern.lastIndex = 1;
stickyPattern.lastIndex = 1;

// 修改 lastIndex 对无修饰符的正则对象无影响
result = pattern.exec(text);
// 从 lastIndex 处开始进行搜索匹配
globalResult = globalPattern.exec(text);
// 从 lastIndex 处进行精确匹配,如果不匹配则不再进行后续匹配
stickyResult= stickyPattern.exec(text);

console.log(result[0]); // hello1
console.log(globalResult[0]); // hello2
console.log(stickyResult[0]); // TypeError: Cannot read property '0' of null

当修改包含 y flag 的正则对象的 lastIndex 为 1 后在进行匹配时,并没有匹配到任何东西,因为 index 为 1 后的字符为 ello…,无法与 /hello\d\s?/ 精确匹配,所以不会匹配到任何的东西。

y flag 会存储上次匹配的字符的下一个字符的下标,该值会被存储在lastIndex 中

let stickyPattern= /hello\d\s?/y;
let text = "hello1 hello2 hello3";

let stickyResult= stickyPattern.exec(text);
console.log(stickyResult[0]); // hello1
console.log(stickyPattern.lastIndex); // 7

stickyResult= stickyPattern.exec(text);
console.log(stickyResult[0]); // hello2
console.log(stickyPattern.lastIndex); // 14

stickyResult= stickyPattern.exec(text);
console.log(stickyResult[0]); // hello3
console.log(stickyPattern.lastIndex); // 20

通过正则对象的 sticky 属性可以知道该对象是否指定了y flag

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

正则表达式的复制

在 ES5 中可以通过 RegExp 构造函数进行正则表达式的复制

let re1 = /a\db/i,
re2 = new RegExp(re1);

但是在构造函数的第二个参数指定了修饰符,那么在 ES5 会报错,而在 ES6 中是可以的,并且新指定的修饰符会覆盖原来的修饰符

let re1 = /a\db/i,
// 在 ES5 中会报错
re2 = new RegExp(re1, "g");

console.log(re1.toString()); // "/a\db/i"
console.log(re2.toString()); // "/a\db/g"

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

flags 属性

在 ES5 中想获得正则对象包含哪些修饰符,一般通过解析 toString 得到的字符串

let getFlags = function(re) {
let str = re.toString();
return str.substring(str.lastIndexOf("/") + 1, str.length)
}

let re = /abc/igu;
console.log(getFlags(re)); // "giu"

在 ES6 中为正则对象添加了一个 flags 属性,可以通过 flags 属性来获得正则对象包含的修饰符

let re = /abc/igu;
console.log(re.source); // "abc"
console.log(re.flags); // "giu"

模板字符串

ES5 中字符串处理难点:

  • 多行字符串
  • 字符串格式化
  • HTML 转义

ES6 引入模板字符串来解决该问题。

基本语法

使用 ` 号将字符串括起来

let str = `Hello World!`;

console.log(str); // "Hello World"
console.log(typeof str); // string

多行字符串

ES6 之前表示多行字符串的写法

let str = "Hello \n\
World!";

console.log(str); // "Hello
// World!"

// 或者
let message = ["Hello", "World!"].join("\n");

console.log(message); // Hello
// World!

ES6 中更简单的写法

let str = `Hello
World!`;

console.log(str); // Hello
// World!

在 `` 中所有的空白字符都会被算作是字符串的一部分

通过上面的写法,可以写出易于阅读的 HTML 字符串

let html = `
<div>
<p>Hello World!</p>
</div>
`;

模板替换

在 ES5 中一般如下拼接字符串

let name = "Alice";
let message = "Hello, My name is " + name + ", nice to meet you!";

console.log(message); // Hello, My name is Alice, nice to meet you!

在 ES6 中我们可以使用占位符来方便实现字符串的拼接

let name = "Alice";
let message = `Hello, My name is ${name}, nice to meet you!`

console.log(message); // Hello, My name is Alice, nice to meet you!

其中 ${name} 会被本地变量 name 替换,最后形成的字符串就是被替换的字符串。替换的语法就是${},其中 {} 中的内容可以是变量名,也可以是表达式

let price = 2.5,
count = 3;
let message = `${count} items cost $${(price * count).toFixed(2)}.`

console.log(message); // 3 items cost $7.50.

如果 $ 后面没有 {} 的话,会被当做是正常的美元字符进行输出。

因为模板字符串本身也是表达式,这意味着可以在模板字符串中嵌入模板字符串

let name = "Alice";
let message = `Hello, ${
`my name is ${name}.`
}`;

console.log(message); // Hello, my name is Alice.

标签模板

标签对模板字符串进行转换,并返回最终的字符串值。标签放置在模板的前方

let str = tag`Hello World!`;

其中 tag 就是标签,tag 标签会对模板字符串进行处理,返回的字符串作为最终模板字符串的值

let str = tag`Hello World!`;
function tag() {
return "hello";
}

console.log(str); // hello

一个标签其实就是一个函数,它接收两个参数

  • 由 ${} 分割的模板字符串数组
  • ${} 中表达式计算后的结果形成的数组
let price = 2.5,
count = 3;
let message = tag`${count} items cost $${(price * count).toFixed(2)}.`
function tag(literals, ...substitutions) {
console.log(literals); // [ '', ' items cost $', '.' ]
console.log(substitutions); // [ 3, '7.50' ]
}

在 message 的模板字符串中,有两个 ${},所以会被分为三段,这三段的内容形成的数组就是第一个参数的值,两个 ${} 中表达式计算后的值形成的数组是第二个参数的值。

下面使用自定义的标签来模拟模板字符串的默认行为

function defaultTemplate(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 price = 2.5,
count = 3;
let message = defaultTemplate`${count} items cost $${(price * count).toFixed(2)}.`

console.log(message); // 3 items cost $7.50.

标签还可以访问到模板字符串的原始信息,主要指能够访问字符转义。JavaScript 提供了 String.raw 标签以模板访问字符串的原始信息

let str1 = String.raw`Hello\nWorld!`;
let str2 = `Hello\nWorld!`;

console.log(str1); // Hello\nWorld!
console.log(str2); // Hello
// World!

传入标签的 literals 参数有一个 raw 属性,里面保存的就是字符串的原始信息,自定义 raw 标签来模拟 String.raw

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

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

return result;
}

与 defaultTemplate 不同的就是将 literals[i] 换为了 literals.raw[i]。