开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。
红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否
原始值包装类型:
ECMAScript 提供三种原始包装类型:Boolean、Number、String,让原始值也能调用方法。包装类型是引用类型的一个子类,是一种特殊的引用类型。
这一节最为重要的是String这个包装类型。
可以说,包装类型就是原始值的临时替身,让原始值能调用方法,用完即丢,原始值本身不变。
JS类型层次:
1
2
3
4
5
6
7
ECMAScript 语言层面(我们写代码用的)
├── 原始类型(Primitive):string, number, boolean, null, undefined, symbol, bigint
│ └── 特点:值直接存,不存地址
│
└── 引用类型(Reference):object
├── 普通引用类型:Array, Date, RegExp, Function, ...
└── 包装类型:String, Number, Boolean
自动包装过程:
原始值本身不是对象,不应该有方法。但在以读模式访问原始值时,后台自动执行三步:
1
2
3
4
5
6
7
let s1 = "some text";
let s2 = s1.substring(2);
// 后台等价于:
let s1 = new String("some text"); // 1. 创建包装类型实例
let s2 = s1.substring(2); // 2. 调用方法
s1 = null; // 3. 销毁实例
Boolean 和 Number 同理,分别用各自的包装类型。
包装实例用完即销毁,不能手动给原始值添加属性(赋了也会丢失)
这个机制只在读模式访问时触发,原始值本质上还是原始值
原始值包装类型的生命周期:
引用类型通过 new 实例化后,得到的对象在离开作用域时才销毁。而原始值包装对象不同——它只存在于访问它的那行代码执行期间,执行完就销毁。
这就解释了为什么不能在运行时给原始值添加属性:
1
2
3
4
let s1 = "some text";
s1.color = "red"; // 临时创建一个 String 对象,加了 color 属性
// 然后对象被销毁
console.log(s1.color); // undefined ← 这次访问又创建了新对象,新对象当然没有 color 属性
第二行和第三行各创建了不同的 String 对象,互不相干。
显式创建与转型函数本质不同,原始值包装类型的构造函数可以显式使用new调用,但容易和同名的转型函数的调用混淆:
1
2
3
4
5
6
7
8
let value = "25";
let number = Number(value); // 转型函数 → 原始数值
typeof number; // "number"
let obj = new Number(value); // 构造函数 → Number 对象实例
typeof obj; // "object"
obj instanceof Number; // true
两者结果完全不同,应避免混用。
其他需要注意的点:
typeof对包装对象返回"object",而不是"string"或"number"- 所有包装对象转为布尔值都是
true Object()构造函数本质上是个工厂方法,会根据传入值的类型返回对应包装类型的实例:
1
2
let obj = new Object("some text");
obj instanceof String; // true
原始值包装类型让原始值能够调用方法,但这些包装对象用完即销毁,与引用类型对象的生命周期完全不同。不建议显式创建包装对象,否则容易分不清代码中处理的是原始值还是对象。
Boolean:
Boolean 是对应布尔值的引用类型,通过构造函数创建:
1
let booleanObject = new Boolean(true);
实例重写了 valueOf() 和 toString(),分别返回原始值 true/false 和字符串 "true"/"false"。不过 Boolean 对象在实际开发中用得极少,而且容易引发误用。
布尔表达式中的陷阱:Boolean 对象最大的坑在于——所有对象在布尔表达式中都会自动转换为 true,哪怕这个对象包装的值是 false:
1
2
3
4
5
6
7
let falseObject = new Boolean(false);
let result = falseObject && true;
console.log(result); // true ← 和直觉相反!
let falseValue = false;
result = falseValue && true;
console.log(result); // false ← 正常行为
直觉上 false && true 应该等于 false,但 falseObject 是一个对象,在布尔表达式中被当作 true 处理,所以实际执行的是 true && true,结果为 true。
原始值与 Boolean 对象的区别:除了布尔表达式中的行为差异,typeof 和 instanceof 也能区分两者:
1
2
3
4
5
6
7
8
let falseObject = new Boolean(false);
let falseValue = false;
typeof falseObject; // "object"
typeof falseValue; // "boolean"
falseObject instanceof Boolean; // true
falseValue instanceof Boolean; // false
| 原始值 | Boolean 对象 | |
|---|---|---|
typeof | "boolean" | "object" |
instanceof Boolean | false | true |
| 布尔表达式中的值 | 按实际值判断 | 永远为 true |
原始布尔值和 Boolean 对象行为完全不同,混用极易出错。永远直接使用原始值 true/false,不要用 new Boolean()以创建boolean对象。
Number:
Number 是对应数值的引用类型,通过构造函数创建:
1
let numberObject = new Number(10);
与其他包装类型一样,Number 重写了继承的 valueOf()、toLocaleString() 和 toString() 方法。其中 valueOf() 返回原始数值,后两者返回数值字符串。toString() 还支持传入基数参数,用于不同进制转换:
1
2
3
4
5
let num = 10;
num.toString(); // "10"
num.toString(2); // "1010" ← 二进制
num.toString(8); // "12" ← 八进制
num.toString(16); // "a" ← 十六进制
格式化方法:
Number 类型提供了一套专门用于将数值格式化为字符串的方法。
toFixed() — 按指定小数位数格式化:
1
2
3
4
5
let num = 10;
num.toFixed(2); // "10.00" ← 不够位补0
let num2 = 10.005;
num2.toFixed(2); // "10.01" ← 四舍五入
这个方法常用于处理货币金额。但需要注意浮点数精度问题:0.1 + 0.2 的结果并不是精确的 0.3,而是 0.30000000000000004,toFixed() 在做舍入时也会受此影响。可表示的范围是 0~20 位小数。
toExponential() — 科学记数法形式:
1
2
let num = 10;
num.toExponential(1); // "1.0e+1"
接收一个参数指定结果中保留的小数位数。对于较大的数才会用到这种表示法。
toPrecision() — 自动选择最合理的形式:
1
2
3
4
let num = 99;
num.toPrecision(1); // "1e+2" ← 99 不能用1位精确表示,舍入为100
num.toPrecision(2); // "99"
num.toPrecision(3); // "99.0"
toPrecision() 根据数值和指定的精度位数,在固定小数位和科学记数法之间自动选择最合适的表达方式,精度范围是 1~21 位。
typeof 与 instanceof 的区别:
与 Boolean 对象类似,Number 对象也容易和原始数值混淆:
1
2
3
4
5
6
7
let numberObject = new Number(10);
let numberValue = 10;
typeof numberObject; // "object"
typeof numberValue; // "number"
numberObject instanceof Number; // true
numberValue instanceof Number; // false
原始数值 typeof 返回 "number",而 Number 对象返回 "object"。两者行为不同,不建议混用。
isInteger() 与安全整数:
Number.isInteger() 用于判断一个数值是否为整数。需要注意,小数部分的 0 不会影响判断:
1
2
3
Number.isInteger(1); // true
Number.isInteger(1.00); // true ← 1.00 本质上仍是整数
Number.isInteger(1.01); // false
IEEE 754 浮点数格式能精确表示的整数范围是 -(2^53 - 1) 到 2^53 - 1。超出这个范围的数值,即使尝试以整数形式存储,其二进制编码也可能对应完全不同的值:
1
2
Number.isSafeInteger(2 ** 53); // false ← 超出安全范围
Number.isSafeInteger((2 ** 53) - 1); // true
Number 提供了丰富的方法来格式化和检测数值,但和 Boolean 一样,不建议直接使用 new Number() 创建对象,直接使用原始数值即可——isInteger()、isSafeInteger()、toFixed() 等方法都可以直接在原始数值上调用。
String:
String 是对应字符串的引用类型,可以通过构造函数创建:
1
let stringObject = new String("hello world");
String 对象的 valueOf()、toLocaleString() 和 toString() 都返回原始字符串值。每个 String 对象还有一个 length 属性,表示字符数量,属于String 对象的方法可以在所有字符串原始值上调用:
1
2
let stringValue = "hello world";
console.log(stringValue.length); // 11
值得注意的是,即使字符串包含双字节字符,也按单字符计数。String 类型提供了大量方法用于解析和操作字符串,原始字符串值可以直接调用这些方法。
JavaScript字符:
JavaScript 字符串由 16 位码元(code unit)组成,对多数字符来说,一个码元对应一个字符:
1
2
let message = "abcde";
console.log(message.length); // 5
为什么是16位:JavaScript 诞生于 1995 年,正值 Unicode 统一字符编码标准的时期。Unicode 最初的设计是统一使用 16 位,每个字符占 2 字节,理论能表示 65,536 个字符——足够覆盖世界上大多数语言。
JavaScript 直接采用了这个 16 位方案(当时叫 UCS-2)。
length 属性返回的就是码元数量,charAt() 也基于码元定位,返回给定索引位置该码元对应的字符:
1
2
let message = "abcde";
console.log(message.charAt(2)); // "c" ← 查找索引2处的码元
JavaScript 内部混合使用了 UCS-2 和 UTF-16 两种编码方式。对于 U+0000~U+FFFF 范围内的字符(基本多文种平面,BMP),两者码元完全相同,所以这个范围内的字符,length 和 charAt() 的行为符合直觉。
超出这个范围的字符(比如很多 emoji)需要两个码元(代理对)表示,此时 length 会大于实际可见字符数。
要深入了解关于字符编码的内容,推荐Joel Spolsky写的博客文章:“The Absolute Minimum Every Software Developer Absolutely,Positively Must Know About Unicode and Character Sets (No Excuses!)”。
另一个有用的资源是Mathias Bynens的博文:“JavaScript’s Internal Character Encoding: UCS-2 or UTF-16?”。
charCodeAt() 返回指定索引的码元值:
1
2
3
4
5
6
7
let message = "abcde";
// Unicode "Latin small letter C"的编码是U+0063
console.log(message.charCodeAt(2)); // 99
// 十进制99等于十六进制63
console.log(99 === 0x63); // true
fromCharCode() 根据码元值创建字符串:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Unicode "Latin small letter A"的编码是U+0061
// Unicode "Latin small letter B"的编码是U+0062
// Unicode "Latin small letter C"的编码是U+0063
// Unicode "Latin small letter D"的编码是U+0064
// Unicode "Latin small letter E"的编码是U+0065
console.log(String.fromCharCode(0x61, 0x62, 0x63, 0x64, 0x65)); // "abcde"
// 0x0061 === 97
// 0x0062 === 98
// 0x0063 === 99
// 0x0064 === 100
// 0x0065 === 101
console.log(String.fromCharCode(97, 98, 99, 100, 101)); // "abcde"
在 U+0000~U+FFFF 范围内(基本多文种平面,BMP),每个字符恰好对应一个 16 位码元,length、charAt()、charCodeAt()、fromCharCode() 都能正常工作。
16 位只能表示 65536 个字符,超过这个范围的字符采用代理对(surrogate pair):用两个 16 位码元表示一个字符,即每个字符使用另外16位去选择一个增补平面。此时上述方法就会出错:
1
2
3
4
5
6
let message = "ab☺de"; // "☺" = U+1F60A,超出了BMP
message.length; // 6 ← 错误!代理对算2个码元
message.charAt(2); // " " ← 乱码,只取了前半段代理
message.charCodeAt(2); // 55357 ← 前半段代理码元
message.charCodeAt(3); // 56842 ← 后半段代理码元
正确识别代理对需要用 codePointAt() 和 String.fromCodePoint():
1
2
message.codePointAt(2); // 128522 ← 完整的码点值(U+1F60A)
String.fromCodePoint(128522); // "☺"
用展开操作符遍历字符串也能智能识别码点:
1
[..."ab☺de"]; // ["a", "b", "☺", "d", "e"] ← 正确识别
| 方法 | 单位 | 超出 BMP 的问题 |
|---|---|---|
charAt() | 码元 | 代理对被拆成乱码 |
charCodeAt() | 码元 | 只返回前半段代理值 |
codePointAt() | 码点 | ✅ 正确返回完整码点 |
fromCharCode() | 码元 | 需手动拼接两个代理值 |
fromCodePoint() | 码点 | ✅ 直接接收码点值 |
现代 JS 中,处理可能含 emoji 或其他增补字符的字符串时,应优先使用 codePointAt() 和 fromCodePoint(),而不是 charCodeAt() 和 fromCharCode()。
normalize()方法:
同一个字符可能有好几种编码方式,有的字符既可以通过一个BMP字符表示,也可以通过一个代理对表示,看起来一样,但比较时不相等:
1
2
3
4
5
6
7
let a1 = String.fromCharCode(0x00C5); // Å(带圈的大写A)
let a2 = String.fromCharCode(0x212B); // Å(长度单位"埃")
let a3 = String.fromCharCode(0x0041, 0x030A); // Å(A + 组合圆圈)
console.log(a1, a2, a3); // Å, Å, Å — 看起来一样
console.log(a1 === a2); // false
console.log(a1 === a3); // false
为解决这个问题,Unicode提供了4种规范化形式,可以将类似上面的字符规范化为一致的格式,无论底层字符的代码是什么。这4种规范化形式是:NFD(Normalization Form D)、NFC(Normalization Form C)、NFKD(Normalization Form KD)和NFKC(Normalization Form KC)。可以使用normalize()方法对字符串应用上述规范化形式,使用时需要传入表示哪种形式的字符串:”NFD”、”NFC”、”NFKD” 或”NFKC”。
这4种规范化形式的具体细节超出了本书范围,有兴趣的读者可以自行参考UAX 15#: Unicode Normalization Forms中的1.2节“Normalization Forms”。
| 形式 | 名称 | 含义 |
|---|---|---|
| NFD | Normalization Form D | 规范分解 |
| NFC | Normalization Form C | 规范分解后组合 |
| NFKD | Normalization Form KD | 兼容分解 |
| NFKC | Normalization Form KC | 兼容分解后组合 |
可用 normalize() 方法转换:
1
a1.normalize("NFC"); // 返回规范化后的字符串
在比较字符串之前,应该先规范化,或者通过比较字符串与其调用normalize()的返回值,就可以知道该字符串是否已经规范化了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let a1 = String.fromCharCode(0x00C5);
let a2 = String.fromCharCode(0x212B);
let a3 = String.fromCharCode(0x0041, 0x030A);
// U+00C5是对0+212B进行NFC/NFKC规范化之后的结果
console.log(a1 === a1.normalize("NFD")); // false
console.log(a1 === a1.normalize("NFC")); // true
console.log(a1 === a1.normalize("NFKD")); // false
console.log(a1 === a1.normalize("NFKC")); // true
// U+212B是未规范化的
console.log(a2 === a2.normalize("NFD")); // false
console.log(a2 === a2.normalize("NFC")); // false
console.log(a2 === a2.normalize("NFKD")); // false
console.log(a2 === a2.normalize("NFKC")); // false
// U+0041/U+030A是对0+212B进行NFD/NFKD规范化之后的结果
console.log(a3 === a3.normalize("NFD")); // true
console.log(a3 === a3.normalize("NFC")); // false
console.log(a3 === a3.normalize("NFKD")); // true
console.log(a3 === a3.normalize("NFKC")); // false
// 统一用 NFC 规范化后再比较
console.log(a1.normalize("NFC") === a2.normalize("NFC")); // true
console.log(a1.normalize("NFC") === a3.normalize("NFC")); // true
同一个字符可能由不同码点组合而成,比较前应统一规范化形式。
字符串操作方法:
concat():
拼接字符串,返回新字符串,不修改原字符串:
1
2
3
let stringValue = "hello ";
stringValue.concat("world", "!"); // "hello world!"
stringValue; // "hello" ← 原字符串不变
实际开发中更常用
+操作符。
slice()、substring()、substr():
三个方法都提取子字符串,参数含义不同:
| 方法 | 第一个参数 | 第二个参数 |
|---|---|---|
slice() | 开始位置 | 结束位置(不含) |
substring() | 开始位置 | 结束位置(不含) |
substr() | 开始位置 | 子字符串长度 |
1
2
3
4
5
let stringValue = "hello world";
stringValue.slice(3, 7); // "lo w"
stringValue.substring(3, 7); // "lo w"
stringValue.substr(3, 7); // "lo worl" ← 7是长度
省略第二个参数时,都提取到字符串末尾。
三种方法的负参数处理差异:
| 方法 | 负参数处理 |
|---|---|
slice() | 负值 → 字符串长度 + 负值 |
substring() | 负值 → 0,且自动交换大小 |
substr() | 第一个负值 → 长度 + 负值;第二个负值 → 0 |
slice()方法将所有负值参数都当成字符串长度加上负参数值。 substr()方法将第一个负参数值当成字符串长度加上该值,将第二个负参数值转换为0。 substring()方法会将所有负参数值都转换为0。
1
2
3
4
5
6
7
8
9
let stringValue = "hello world"; // 长度11
stringValue.slice(-3); // "rld" ← 相当于 slice(8)
stringValue.substring(-3); // "hello world" ← 相当于 substring(0)
stringValue.substr(-3); // "rld" ← 相当于 substr(8)
stringValue.slice(3, -4); // "lo w" ← 相当于 slice(3, 7)
stringValue.substring(3, -4); // "hel" ← 相当于 substring(0, 3)
stringValue.substr(3, -4); // "" ← 相当于 substr(3, 0)
substring(3, -4)会把 -4 转成 0,再交换成substring(0, 3),所以返回"hel"。
slice() 是最常用的,因为它和数组 slice() 语法一致,负参数从后往前数也更直观,substr() 已废弃。
| 方法 | 问题 |
|---|---|
substr() | 已被标记为废弃(deprecated),MDN 明确建议不再使用 |
substring() | 负参数转 0 的行为不够直观,且会自动交换参数顺序 |
slice() | 负参数语义清晰(从后往前数),行为一致,与数组的 slice() 也完全相同 |
字符串位置方法:
indexOf() 与 lastIndexOf():
在字符串中定位子字符串,两个方法都返回位置(未找到返回 -1):
| 方法 | 搜索方向 |
|---|---|
indexOf() | 从字符串开头往后查找 |
lastIndexOf() | 从字符串末尾往前查找 |
1
2
3
4
let stringValue = "hello world";
stringValue.indexOf("o"); // 4 ← 第一个 "o"(在 "hello" 中)
stringValue.lastIndexOf("o"); // 7 ← 最后一个 "o"(在 "world" 中)
如果字符串中只有一个 “o”,两个方法返回相同的位置。
第二个参数:指定起始位置:
两个方法都可以接收第二个参数,表示从哪个位置开始搜索:
indexOf(字符, 起始位置)— 从该位置向字符串末尾搜索lastIndexOf(字符, 起始位置)— 从该位置向字符串开头搜索
1
2
stringValue.indexOf("o", 6); // 7 ← 从位置6往后找,找到的是 "world" 中的 "o"
stringValue.lastIndexOf("o", 6); // 4 ← 从位置6往回找,找到的是 "hello" 中的 "o"
传入第二个参数后,两者返回结果恰好相反,因为搜索方向相反。
找出所有出现的位置:
利用第二个参数配合循环,可以遍历字符串中所有匹配的位置:
1
2
3
4
5
6
7
8
9
10
let stringValue = "Lorem ipsum dolor sit amet, consectetur adipisicing elit";
let positions = new Array();
let pos = stringValue.indexOf("e");
while (pos > -1) {
positions.push(pos);
pos = stringValue.indexOf("e", pos + 1); // 从「当前位置+1」开始,防止死循环
}
console.log(positions); // [3, 24, 32, 35, 52]
工作流程:
- 第一次
indexOf("e")找到第一个匹配 - 把位置存入数组
- 下一次从
pos + 1开始继续找 - 直到pos找不到(返回
-1),循环结束
核心技巧是
pos + 1,如果仍从当前位置找就会陷入无限循环,因为indexOf会一直返回同一个位置。
字符串包含方法:
startsWith()、endsWith()、includes()
三个方法在字符串中搜索子字符串,都返回布尔值:
| 方法 | 检查位置 |
|---|---|
startsWith() | 从字符串开头匹配 |
endsWith() | 从字符串末尾匹配 |
includes() | 搜索整个字符串 |
1
2
3
4
5
6
7
8
9
10
let message = "foobarbaz";
message.startsWith("foo"); // true
message.startsWith("bar"); // false
message.endsWith("baz"); // true
message.endsWith("bar"); // false
message.includes("bar"); // true
message.includes("qux"); // false
第二个参数的作用不同:
startsWith() 和 includes() — 第二个参数指定搜索起始位置:
1
2
3
4
5
6
message.startsWith("foo"); // true ← 从索引0开始检查
message.startsWith("foo", 1); // false ← 从索引1开始检查,前面被忽略
// 此时相当于检查"oobarbaz"是否以"foo"开头,显然不是
message.includes("bar"); // true
message.includes("bar", 4); // false ← 从索引4开始往后找,"bar"在索引3,被忽略了
endsWith() — 第二个参数指定当作字符串末尾的位置(即截断到此处):
1
2
message.endsWith("bar"); // false ← "foobarbaz" 不以 "bar" 结尾
message.endsWith("bar", 6); // true ← 截取前6位 "foobar",以 "bar" 结尾
1
2
3
4
5
"foobarbaz"
012345678
前6位:foobar
bar ← 从索引3开始,恰好是 "bar" ✓
endsWith的第二个参数是”把字符串看成多长”,不是”从哪个位置开始往前找”。
一般来说只用includes(),多数场景只需要判断有没有这个子字符串,不关心在开头还是结尾。
trim()方法:
trim() 删除字符串前后的所有空格,返回新字符串,原字符串不变:
1
2
3
let stringValue = " hello world ";
stringValue.trim(); // "hello world"
stringValue; // " hello world " ← 原字符串不变
trimStart() / trimEnd()方法可以删除一侧的空格,在阿拉伯语和希伯来语等从右向左的语言中有着重要意义。
1
2
3
4
let s = " foo ";
s.trimStart(); // "foo " ← 只删左边
s.trimEnd(); // " foo" ← 只删右边
trimLeft()/trimRight()是旧写法,trimStart()/trimEnd()是新写法( ECMAScript 的padStart()/padEnd()用 start/end 命名,为保持一致也推荐用新的)。
repeat()方法:
ECMAScript在所有字符串上都提供了repeat()方法。这个方法接收一个整数参数,表示要将字符串复制多少次,然后返回拼接所有副本后的结果。
1
2
3
let stringValue = "na ";
console.log(stringValue.repeat(16) + "batman");
// na na na na na na na na na na na na na na na na batman
padStart()和padEnd()方法:
这两个方法均可在字符串一侧填充字符,直到达到指定长度,两个方法的第一个参数是长度,第二个参数是可选的填充字符串,默认为空格(U+0020):
1
2
3
4
5
6
7
let stringValue = "foo";
stringValue.padStart(6); // " foo" ← 左边填充空格
stringValue.padStart(9, "."); // "......foo"
stringValue.padEnd(6); // "foo " ← 右边填充空格
stringValue.padEnd(9, "."); // "foo......"
第二个参数可以是多个字符,会循环拼接后截断:
1
2
stringValue.padStart(8, "bar"); // "barbafoo" ← "barbar" 截断到5位再拼 "foo"
stringValue.padEnd(8, "bar"); // "foobarba"
如果目标长度 ≤ 字符串本身长度,直接返回原字符串:
1
stringValue.padStart(2); // "foo" ← 长度2 < 3,不填充
常见用途:格式化数字补零,如
String(5).padStart(2, "0")→"05"。
字符串迭代与解构:
字符串的原型上暴露了一个@@iterator方法,表示可以迭代字符串的每个字符。可以像下面这样手动使用迭代器:
1
2
3
4
5
6
7
let message = "abc";
let stringIterator = message[Symbol.iterator]();
console.log(stringIterator.next()); // {value: "a", done: false}
console.log(stringIterator.next()); // {value: "b", done: false}
console.log(stringIterator.next()); // {value: "c", done: false}
console.log(stringIterator.next()); // {value: undefined, done: true}
在for-of循环中可以通过这个迭代器按序访问每个字符:
1
2
3
4
5
6
7
8
9
for (const c of "abcde") {
console.log(c);
}
// a
// b
// c
// d
// e
有了这个迭代器之后,字符串就可以通过解构操作符来解构了。比如,可以更方便地把字符串分割为字符数组:
1
2
3
let message = "abcde";
console.log([...message]); // ["a", "b", "c", "d", "e"]
...是展开运算符(spread operator)。字符串内置了迭代器(iterator),展开运算符会调用这个迭代器逐个取出字符:
1
2
3
[...message]
// 等价于
message.split("") // ["a", "b", "c", "d", "e"]
和split(“”) 比较,普通情况下两者结果一样,但遇到 emoji 等代理对字符时不同:
1
2
3
4
let str = "ab☺c";
str.split(""); // ["a", "b", "?", "?", "c"] ← 代理对被拆成两个乱码
[...str]; // ["a", "b", "☺", "c"] ← 正确识别
因为展开运算符调用的是迭代器,迭代器能识别完整的码点(codePoint),而 split("") 是按 16 位码元切割,遇到代理对就会拆坏。
字符串大小写转换:
4 个方法分两组:
| 方法 | 作用 |
|---|---|
toUpperCase() | 转大写(通用) |
toLowerCase() | 转小写(通用) |
toLocaleUpperCase() | 转大写(地区特定) |
toLocaleLowerCase() | 转小写(地区特定) |
1
2
3
4
5
6
let stringValue = "hello world";
stringValue.toUpperCase(); // "HELLO WORLD"
stringValue.toLocaleUpperCase(); // "HELLO WORLD"
stringValue.toLowerCase(); // "hello world"
stringValue.toLocaleLowerCase(); // "hello world"
toLocaleLowerCase()和toLocaleUpperCase()方法旨在基于特定地区实现,多数语言下两者结果相同,但少数语言(如土耳其语)的 Unicode 大小写转换有特殊规则,通用版会转错,必须用地区版才能正确处理。
不确定代码会运行在什么语言环境时,优先用
toLocaleUpperCase()/toLocaleLowerCase(),更安全。
字符串模式匹配方法:
match() 与 matchAll():
match() 本质上等价于 RegExp.exec(),接收一个正则表达式,返回匹配结果数组:
1
2
3
4
5
let text = "cat, bat, sat, fat";
let matches = text.match(/.at/);
matches[0]; // "cat" ← 完整匹配的字符串
matches.index; // 0 ← 匹配位置
加上全局标记 g 后,match() 会返回所有匹配项,但捕获组信息会全部丢失:
1
2
3
4
const text = "abcdeazcde";
text.match(/a(.)c/); // ['abc', 'b', index: 0, ...] ← 有捕获组
text.match(/a(.)c/g); // ['abc', 'azc'] ← 捕获组丢了
如果既需要匹配多个结果,又需要保留捕获组,应该用 matchAll()。它返回一个可迭代对象,每个元素都是完整的匹配结果(和不带 g 的 match() 格式相同):
1
2
3
4
5
[...text.matchAll(/a(.)c/g)];
// [
// ['abc', 'b', index: 0, input: 'abcdeazcde', groups: undefined],
// ['azc', 'z', index: 5, input: 'abcdeazcde', groups: undefined]
// ]
matchAll()必须传入带g标记的正则,否则会报错。返回的是迭代器,需要用[...]展开或for...of遍历。
search():
只关心”在哪里”,返回第一个匹配的位置索引,未找到返回 -1,始终从字符串开头开始搜索:
1
"cat, bat, sat, fat".search(/at/); // 1 ← "cat" 中 "at" 的位置
replace() 与 replaceAll():
replace() 接收两个参数:匹配模式(字符串或正则)和替换内容(字符串或函数)。
第一个参数是字符串时,只替换第一个匹配项:
1
2
3
4
5
const text = "cat, bat, sat, fat";
text.replace("at", "ond"); // "cond, bat, sat, fat" ← 只替换了第一个
text.replace(/at/g, "ond"); // "cond, bond, sond, fond" ← 全部替换
text.replaceAll("at", "ond"); // "cond, bond, sond, fond" ← 全部替换
replaceAll() 是 ES2021 新增的方法,直接实现了全局替换,不需要写正则。
第二个参数为字符串时,有几个特殊的字符序列可以用来插入正则表达式匹配的值:
| 序列 | 含义 |
|---|---|
$$ | 插入字面量 $ |
$& | 整个匹配项 |
$' | 匹配项之后的内容 |
$` | 匹配项之前的内容 |
$n | 第 n 个捕获组的内容 |
1
2
3
text.replace(/(.at)/g, "word ($1)");
// "word (cat), word (bat), word (sat), word (fat)"
// $1 引用了第一个捕获组 (.at) 匹配到的内容
第二个参数为函数时,每次匹配都会调用该函数,函数的返回值作为替换内容。函数参数依次为:匹配的字符串、各捕获组(如果有)、匹配位置、原始字符串:
1
2
3
4
5
6
7
8
9
10
11
12
13
function htmlEscape(text) {
return text.replace(/[<>"&]/g, function(match, pos, originalText) {
switch(match) {
case "<": return "<";
case ">": return ">";
case "&": return "&";
case '"': return """;
}
});
}
htmlEscape('<p class="greeting">Hello world!</p>');
// "<p class="greeting">Hello world!</p>"
这个例子把 HTML 中的 4 个特殊字符(<、>、&、")替换成对应的 HTML 实体,防止 XSS 注入。用函数作为第二个参数的好处是可以根据每次匹配的具体内容动态决定替换成什么。
split():
按分隔符把字符串拆成数组,分隔符可以是字符串或正则表达式:
1
2
3
4
5
let colorText = "red,blue,green,yellow";
colorText.split(","); // ["red", "blue", "green", "yellow"]
colorText.split(",", 2); // ["red", "blue"] ← 第二个参数限制返回数组的长度
colorText.split(/[^,]+/); // ["", ",", ",", ",", ""] ← 用"非逗号内容"作分隔符
最后一个例子稍微绕一点:/[^,]+/ 匹配的是逗号之间的内容(即颜色名称),用它们作分隔符,剩下的就是逗号。前后出现空字符串,是因为 “red” 出现在字符串开头、“yellow” 出现在末尾,分隔符匹配到了边界位置。
localeCompare()方法:
按字母表顺序比较两个字符串,返回三个值之一:
| 返回值 | 含义 |
|---|---|
| 负值 | 字符串排在参数前面 |
| 0 | 两个字符串相等 |
| 正值 | 字符串排在参数后面 |
1
2
3
4
5
let stringValue = "yellow";
stringValue.localeCompare("brick"); // 1 ← "yellow" 在字母表中排在 "brick" 后面
stringValue.localeCompare("yellow"); // 0 ← 相等
stringValue.localeCompare("zoo"); // -1 ← "yellow" 在字母表中排在 "zoo" 前面
不要用具体数值判断,虽然返回值通常是 -1 / 0 / 1,但规范只保证符号,不保证具体数值——不同浏览器引擎可能返回其他负数或正数。所以判断时应该用符号比较,不要写死数字:
1
2
3
4
5
6
7
8
9
let result = stringValue.localeCompare(value);
if (result < 0) {
console.log(`排在前面`);
} else if (result > 0) {
console.log(`排在后面`);
} else {
console.log(`相等`);
}
写 result < 0 而不是 result === -1,这样在任何引擎上都能正确工作。
这个方法也是地区相关的,localeCompare() 的比较规则取决于运行环境所在的地区和语言,这也是它叫 localeCompare 的原因。比如在英语环境中,大写字母排在小写字母前面("A" < "a"),但在其他语言中未必如此。
如果需要对用户展示的字符串进行排序(比如通讯录、城市列表),用
localeCompare()比直接用</>更准确,因为它会遵循用户所在语言的排序规则。
