原文链接:http://javascript.info/string,translate with ❤️ by zhangbao.

在 JavaScript 中,文本数据是作为字符串存在的。不存在单个字符类型。

字符串内部的存储格式总是 UTF-16 的,与页面的编码格式无关。

引号

我们来回顾下几种形式的引号。

字符串可以用单引号、双引号或者反引号包裹:

  1. let single = 'single-quoted';
  2. let double = "double-quoted";
  3. let backticks = `backticks`;

单引号和双引号基本是一样的。而用反引号包裹的字符串允许我们在内部插入表达式甚至函数调用:

  1. function sum(a, b) {
  2. return a + b;
  3. }
  4. alert(`1 + 2 = ${sum(1, 2)}.`); // 1 + 2 = 3.

使用反引号另一个好处是允许一个字符串跨越多个行:

  1. let guestList = `Guests:
  2. * John
  3. * Pete
  4. * Mary
  5. `;
  6. alert(guestList); // 这里列举了一系列的顾客, 占据在多行

如果我们尝试在单引号或者双引号包裹的字符串里使用这种写法的话,就会产生错误:

  1. let guestList = "Guests: // Error: Unexpected token ILLEGAL
  2. * John";

单引号和双引号来自于语言创建的远古时代,当年对多行字符串的需求没有考虑在内。而反引号形式的写法则出现得很晚,因此也更加通用。

反引号还允许我们在第一个反引号之前指定一个“模板函数”。语法是:funcstring``。函数 func 被自动调用,接收字符串和嵌入表达式,并可以处理它们。你可以在文档中了解更多信息。这被称为“标记模板”。这个特性使得将字符串包装成定制模板或其他功能变得更加容易,但是很少使用。

特殊字符

仍然可以通过使用所谓的“换行符”(写作 \n)来创建带有单引号的多行字符串:

  1. let guestList = "Guests:\n * John\n * Pete\n * Mary";
  2. alert(guestList); // a multiline list of guests

例如,下面两种写法意思一样:

  1. alert( "Hello\nWorld" ); // two lines using a "newline symbol"
  2. // two lines using a normal newline and backticks
  3. alert( `Hello
  4. World` );

还有其他较少使用的一些“特殊字符”。在这里列出出来:

字符 描述
\b 退格
\f 换页
\n 换行
\r 回车
\t Tab
\uNNNN Unicode 字符的十六进制代码表示,例如,\u00A9——是一个表示版权符号 ©。这里必须的精确的 4 位十六进制数字
\u{NNNNNNNN} 一些罕见的字符被编码为两个 Unicode 符号,占用了 4 个字节。这个长 Unicode 需要在它周围使用花括号包围。

Unicode 字符表示:

  1. alert( "\u00A9" ); // ©
  2. alert( "\u{20331}" ); // 佫, a rare chinese hieroglyph (long unicode)
  3. alert( "\u{1F60D}" ); // 😍, a smiling face symbol (another long unicode)

所有的特殊字符都以反斜杠字符 \ 开头,它也被称为“转义字符”。

如果我们想在字符串中插入一个引号,我们也会使用它。

例如:

  1. alert( 'I\'m the Walrus!' ); // I'm the Walrus!

正如你所看到的,我们必须在使用内部引号的前面附加反斜杠 \,否则它是表示字符串结束的。

当然,这种情况只发生在内部引号与外部包裹引号相同的情况。还有一种更优雅的方式,是使用双引号或者反引号:

  1. alert( `I'm the Walrus!` ); // I'm the Walrus!

请注意,反斜杠的存在是为了 JavaScript 正确读取字符串字符,之后就会消失。内存中的字符串没有 \,您可以从上面的 alert 示例中清楚地看到这一点。

但是如果我们就是需要在字符串出现反斜线 \ 呢?

这也是可以的,但我们需要写两次反斜线,也就是 \:

  1. alert( `The backslash: \\` ); // The backslash: \

字符串长度

length 属性表示字符串长度:

  1. alert( `My\n`.length ); // 3

注意,\n 是一个“特殊”字符,所以字符串总长度是 3。

⚠️ length️ 是属性

有其他编程语言背景的同学可能会误写成 str.length() 而不是当做属性 str.length 访问。这是不行的。

str.length 是一个数字属性,不是函数。不需要在它后面使用圆括号去调用他。

访问字符

想要获得字符串中第 pos 位置的字符,请使用中括号 [pos] 或者调用 str.charAt(pos)。第一个字符在位置 0 处。

  1. let str = `Hello`;
  2. // 第一个字符
  3. alert( str[0] ); // H
  4. alert( str.charAt(0) ); // H
  5. // 最后一个字符
  6. alert( str[str.length - 1] ); // o

方括号是一种现代的获取字符的方式,而 charAt 的存在主要是出于历史原因。

它们之间的唯一区别是,如果没有找到字符,[] 返回未 undefined,charAt 返回空字符串:

  1. let str = `Hello`;
  2. alert( str[1000] ); // undefined
  3. alert( str.charAt(1000) ); // '' (空字符串)

我们还可以使用 for..of 来遍历字符:

  1. for (let char of "Hello") {
  2. alert(char); // H,e,l,l,o (char becomes "H", then "e", then "l" etc)
  3. }

字符串是不可修改的

JavaScript 中字符串不可修改,不可能指望修改一个字符。

我们来证明这是不可能的:

  1. let str = 'Hi';
  2. str[0] = 'h'; // error
  3. alert( str[0] ); // doesn't work

通常的解决办法是创建一个全新的字符串,并将其分配给 str,而不是旧的字符串。

例如:

  1. let str = 'Hi';
  2. str = 'h' + str[1]; // 替换字符串
  3. alert( str ); // hi

在下面的部分中,我们将看到更多这样的例子。

改变形式

toLowerCase 和 toUpperCase 方法用来改变字符串形式:

  1. alert( 'Interface'.toUpperCase() ); // INTERFACE
  2. alert( 'Interface'.toLowerCase() ); // interface

或者,如果我们想要一个单独的字符:

  1. alert( 'Interface'[0].toLowerCase() ); // 'i'

查找子字符串

在字符串中查找子字符串有多种方法。

str.indexOf

第一个要提到的方法是 str.indexOf(substr, pos)。

它在 str 中查找 substr,从给定位置 pos 处,返回匹配到的子字符串的位置,没有找到的话,就返回 -1。

例如:

  1. let str = 'Widget with id';
  2. alert( str.indexOf('Widget') ); // 0, because 'Widget' is found at the beginning
  3. alert( str.indexOf('widget') ); // -1, not found, the search is case-sensitive
  4. alert( str.indexOf("id") ); // 1, "id" is found at the position 1 (..idget with id)

可选的第二个参数允许我们从指定位置处,去匹配子字符串。

例如,第一个出现“id”的地方在位置 1 处。为了查找下一此出现的位置,我们位置 2 处开始查找:

  1. let str = 'Widget with id';
  2. alert( str.indexOf('id', 2) ) // 12

如果我们要查找所有出现的字符串,可以在循环里执行 indexOf。每一次新调用产生的位置结果值,都是上一个查找位置之后的:

  1. let str = 'As sly as a fox, as strong as an ox';
  2. let target = 'as'; // let's look for it
  3. let pos = 0;
  4. while (true) {
  5. let foundPos = str.indexOf(target, pos);
  6. if (foundPos == -1) break;
  7. alert( `Found at ${foundPos}` );
  8. pos = foundPos + 1; // continue the search from the next position
  9. }

也可以写得更简短一些:

  1. let str = "As sly as a fox, as strong as an ox";
  2. let target = "as";
  3. let pos = -1;
  4. while ((pos = str.indexOf(target, pos + 1)) != -1) {
  5. alert( pos );
  6. }

⚠️str.lastIndexOf(pos)

有一个类似的方法 str.lastIndexOf(pos) 是从字符串末尾开始向前匹配字符串的。

它将以相反的顺序列举出子字符串出现情况。

当然,在 if 语句使用 indexOf 的返回结果作为判断依据,有些不太方便。我们不能像这样使用:

  1. let str = "Widget with id";
  2. if (str.indexOf("Widget")) {
  3. alert("We found it"); // doesn't work!
  4. }

上例中的 alert 框没有弹出来是因为 str.indexOf(‘Widgte’) 的返回结果是 0(表示匹配到了字符串的开头),但是 0 同时也是 falsy 值。

因此,因此我们需要判断的是将结果值与 -1 进行比较:

  1. let str = "Widget with id";
  2. if (str.indexOf("Widget") != -1) {
  3. alert("We found it"); // 现在 OK 了
  4. }

⚠️技巧:使用位判断 Not

一种老的技巧是使用位运算符 NOT ~。它将数字转换为一个 32 位整数(如果存在小数的话,就去除),然后反转二进制表示中的所有位。

对 32 位整数而言,~n 调用等同于 -(n+1)(依据 IEEE-754 格式)。

例如:

  1. alert( ~2 ); // -3, the same as -(2+1)
  2. alert( ~1 ); // -2, the same as -(1+1)
  3. alert( ~0 ); // -1, the same as -(0+1)
  4. alert( ~-1 ); // 0, the same as -(-1+1)

我们看到,~n 仅在 n == -1 时,结果为 0。

因此,if (~str.indexOf(‘…’)) 仅在 indexOf 的结果不是 -1 的时候才是 truthy。也就是说,有匹配的时候。

人们用它来简化 indexOf 检查:

  1. let str = "Widget";
  2. if (~str.indexOf("Widget")) {
  3. alert( 'Found it!' ); // 正常工作
  4. }

通常不建议使用非明显的语言特性,但是这个特殊的技巧在旧代码中被广泛使用,所以我们应该理解它。

主要记住:if (~str.indexOf(…)) 读作“如果找到”。

includes,startsWith,endsWith

更加现代的方式是使用 str.includes(substr, pos) 方法来判断 str 是否包含 substr,包含的话返回 true,否则返回 false。

如果我们需要测试匹配情况、但不需要知道它位置的时候,这是一个正确的选择:

  1. alert( "Widget with id".includes("Widget") ); // true
  2. alert( "Hello".includes("Bye") ); // false

str.includes 方法接收可选的第二个参数,表示开始查找的位置:

  1. alert( "Midget".includes("id") ); // true
  2. alert( "Midget".includes("id", 3) ); // false, 从位置 3 处开始就没有 "id" 出现了


str.startsWith 和 str.endsWith 的作用就像他们的名字一样容易理解:

  1. alert( "Widget".startsWith("Wid") ); // true, "Widget" starts with "Wid"
  2. alert( "Widget".endsWith("get") ); // true, "Widget" ends with "get"

获取子字符串

JavaScript 中提供了 3 个方法用来获取子字符串:substring,substr 和 slice。

str.slice(start[, end])

返回从 start 到 end(不包括)的子字符串。

例如:

  1. let str = "stringify";
  2. alert( str.slice(0, 5) ); // 'strin', the substring from 0 to 5 (not including 5)
  3. alert( str.slice(0, 1) ); // 's', from 0 to 1, but not including 1, so only character at 0

如果不提供第二个参数的话,slice 就会截取到字符串末尾。

  1. let str = "stringify";
  2. alert( str.slice(2) ); // ringify, from the 2nd position till the end

start/end 也支持负值,它的意思表示倒数第几个字符位置。

  1. let str = "stringify";
  2. // start at the 4th position from the right, end at the 1st from the right
  3. alert( str.slice(-4, -1) ); // gif

str.substring(start[, end])

返回 start 和 end 之间的字符串。

这个几乎等同于 slice 方法,此方法(不像 slice)支持负值,他们都被看做 0 对待,但是允许 start 的值比 end 的值大。

例如:

  1. let str = "stringify";
  2. // these are same for substring
  3. alert( str.substring(2, 6) ); // "ring"
  4. alert( str.substring(6, 2) ); // "ring"
  5. // ...but not for slice:
  6. alert( str.slice(2, 6) ); // "ring" (the same)
  7. alert( str.slice(6, 2) ); // "" (an empty string)

str.substr(start[, length])

从 start 位置开始截取指定长度的的子字符串。

相比较之前的方法,该方法允许我们指定截取的字符串长度,而不是指定截取到(不包含)的位置:

  1. let str = "stringify";
  2. alert( str.substr(2, 4) ); // ring, from the 2nd position get 4 characters

第一个参数可以使赋值,表示从倒数第几个开始截取字符串:

  1. let str = "stringify";
  2. alert( str.substr(-4, 2) ); // gi, from the 4th position get 2 characters

为了避免混淆,我们来总结一下:

方法 选择 负值
slice(start, end) 从 start 到 end(不包含)处的字符串 允许负值
substring(start, end) 从 start 到 end(不包含)处的字符串 负值被看做 0,允许 start 值比 end 大
substr(start, length) 从 start 位置开始,截取长度为 length 的字符串 允许 start 为负值

⚠️选择哪一个呢?

所以方法都可以正常工作。形式上,substr 有一个小缺点:该方法并没有在核心 JavaScript 规范中描述,而是在附录 B 中,被描述为因为历史问题而存在于浏览器中的特性。所以,对于非浏览器环境,可能并不支持此方法。但在实践中,它在任何地方都有效。

笔者发现自己几乎一直在使用 slice 方法。

比较字符串

从《比较》一章里,我们知道字符串比较按照字母表顺序一个一个字符进行比较的。

虽然,还是存在一些怪癖的:

  1. 小写字母总是大于大写字母:
  1. alert( 'a' > 'Z' ); // true
  1. 带有符号标记的字母是“不按次序的”
  1. alert( 'Österreich' > 'Zealand' ); // true

如果我们对这些国家的名字进行分类,这可能会导致奇怪的结果。通常人们会认为 Zealand 是在名单上是紧跟 Österreich 的。

为了理解发生了什么,让我们回顾一下 JavaScript 中的字符串的内部表示。

所以的字符都是以 UTF-16 方式编码的。就是说:每个字符都有一个对应的数字码。有一些特殊的方法可以返回字符的代码表示。

str.codePointAt(pos)

返回字符串中指定位置 pos 处字符的编码号:

  1. // 一个字母的不同形式表示也有不一样的码点
  2. alert( "z".codePointAt(0) ); // 122
  3. alert( "Z".codePointAt(0) ); // 90

str.fromCodePoint(code)

使用给定的码点值 code 来获得对应表示的字符。

  1. alert( String.fromCodePoint(90) ); // Z

我们也可以以 \u 后面跟字符十六进制的 Unicode 码的形式来显示字符。

  1. // 90 在十六进制系统里的表示形式是 5a
  2. alert( '\u005a' ); // Z

现在让我们看看从 65~220 范围的代码值所表示的字符。下例,根据这些代码值产生了由一系列的字符组成的字符串(包含拉丁字母和一些额外的字母):

  1. let str = '';
  2. for (let i = 65; i <= 220; i++) {
  3. str += String.fromCodePoint(i);
  4. }
  5. alert( str );
  6. // ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„
  7. // ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜ

看到没有?大写字母首先出现,紧跟着的是一些特殊字符,然后是小写字母。

现在知道 a > Z 的原因了吧。

字符串实际比较的是对应字符的底层数字码表示。大的数字码就表示对应字符就大。a 的码点(97)比 Z 所表示的码点(90)要大。

  • 所以的小写字母出现在大写字母之后,因此他们的码点也较大。

  • 一些字母,比如 Ö 是与主字母表分开的吗。这里,它大于 a~z 之间的任何字符码点值。

正确的比较

用于进行字符串比较的“正确”算法比它看起来要复杂得多,因为对于不同的语言来说,字母是不同的。同样的字母在不同国家的字母表中可能有不同的位置。

因此,浏览器需要知道要比较的语言。

幸运的是,所有的现代浏览器(IE10- 则需要引用额外的库 Intl.js)都支持国际化标准 ECMA 402

它提供了一种特殊的方法来比较不同语言中的字符串,遵循它们的规则。

调用 str.localeCompare(str2):

  • 根据语言规则,如果 str 比 str2 大的话,就返回 1。

  • 如果 str 比 str2 小的话,就返回 -1。

  • 如果相等就返回 0。

例如:

  1. alert( 'Österreich'.localeCompare('Zealand') ); // -1

这个方法实际上有两个额外的参数,在文档中指定,这允许它指定语言(默认从环境中获取),并设置额外的规则,如大小写形式“a”和“á”应该被视为相同的。

内部,Unicode

⚠️高级知识

这一节更深入地介绍了字符串内部结构。如果你打算处理表情符号、罕见的象形文字符号或其他罕见符号,这些知识对你很有用。

如果你不打算支持他们,你可以跳过这部分。

代理对

多数符号都有一个2字节码。大多数欧洲语言、数字、甚至大多数象形文字的字母都有一个2字节的表示。

但是2个字节只允许65536组合,这对于每个可能的符号来说都是不够的。因此,罕见的符号被编码为一对2字节的字符,称为“代理对”。

这些符号的长度是2:

  1. alert( '𝒳'.length ); // 2, MATHEMATICAL SCRIPT CAPITAL X
  2. alert( '😂'.length ); // 2, FACE WITH TEARS OF JOY
  3. alert( '𩷶'.length ); // 2, a rare chinese hieroglyph

请注意,在创建 JavaScript 时,代理对还不存在,因此语言没有正确地处理它们!

我们实际上在上面的每个字符串中都有一个单独的符号,但是 length 显示的值是 2。

String.fromCodePoint 和 str.codePointAt 是少数几个能正确处理代理对的方法。他们是最近才出现在语言中的。在他们之前,只能用 String.fromCharCodestr.charCodeAt 方法。这些方法实际上和fromCodePoint/codePointAt 一样,但是不支持用代理对表示的字符的调用。

但是,举例来说,获得一个符号可能很棘手,因为代理对被视为两个字符:

  1. alert( '𝒳'[0] ); // strange symbols...
  2. alert( '𝒳'[1] ); // ...pieces of the surrogate pair

注意,在没有彼此的情况下,代理对的部分没有意义。因此,上面例子中的警报实际上显示了垃圾。

从技术上讲,代理对也可以通过它们的代码检测出来:如果一个字符代码在 0xd800~0xdbff 之间,它是代理对的第一部分。下一个字符(第二部分)的代码必须是在 0xdc00~0xdfff 之间的。这些区间是专门为标准的代理对保留的。

  1. // charCodeAt is not surrogate-pair aware, so it gives codes for parts
  2. alert( '𝒳'.charCodeAt(0).toString(16) ); // d835, between 0xd800 and 0xdbff
  3. alert( '𝒳'.charCodeAt(1).toString(16) ); // dcb3, between 0xdc00 and 0xdfff

在后面的《迭代器》章节,你会找到更多的方法来处理代理对。这里可能也有专门的库,但是没有什么值得推荐的。

变音符号和规范化

在许多语言中,有一些符号是由基本字符组成的,在它下面有一个标记。

例如,字母 a 可以是这些符号的基本字符:àáâäãåā。最常见的“复合”字符在 UTF-16 表中有自己的代码。但不是所有的,因为有太多可能的组合。

为了支持任意的组合,UTF-16允许我们使用几个unicode字符。基本字符和一个或多个“标记”字符来“装饰”它。

例如,如果我们有 S 后面跟上特殊“上点”字符(代码 \u0307),它被显示为 Ṡ。

  1. alert( 'S\u0307' ); // Ṡ

如果我们在字母(或下面)上需要一个额外的标记——没问题,只要添加必要的标记字符即可。

例如,如果我们附加一个字符“下点”(代码 \u0323),那么我们就会看到“带有上点和下点的字符S”:Ṩ。

例如:

  1. alert( 'S\u0307\u0323' ); // Ṩ

这提供了很大的灵活性,但也有一个有趣的问题:两个字符在视觉上看起来是一样的,但是用不同的Unicode组合来表示。

例如:

  1. alert( 'S\u0307\u0323' ); // Ṩ, S + dot above + dot below
  2. alert( 'S\u0323\u0307' ); // Ṩ, S + dot below + dot above
  3. alert( 'S\u0307\u0323' == 'S\u0323\u0307' ); // false

为了解决这个问题,存在一个“unicode规范化”算法,该算法将每个字符串呈现为单个“正常”形式。

可以用 str.normailze() 方法实现。

  1. alert( "S\u0307\u0323".normalize() == "S\u0323\u0307".normalize() ); // true

有趣的是,在我们的情况下,normalize() 方法实际上将一个 3 个字符的序列组合成一个:\u1e68(一个有两个点的 S)。

  1. alert( "S\u0307\u0323".normalize().length ); // 1
  2. alert( "S\u0307\u0323".normalize() == "\u1e68" ); // true

在现实中,情况并非总是如此。原因是 Ṩ 这个符号是“足够通用的”,所以 UTF-16 的创造者将它包含在主表中并给出了代码。

如果您想了解更多关于规范化规则和变体的信息——它们在 Unicode 标准的附录中被描述:Unicode 规范化表单,但在大多数实际情况下,来自本节的信息就足够了。

总结

  • 有 3 中类型的引号。反引号允许一个字符串跨越多个行并嵌入表达式。

  • JavaScript 中的字符串是 UTF-16 编码的。

  • 我们可以使用像 \n 这样的特殊字符,以及使用 \u… 的方式插入 Unicode 字符。

  • 取得字符,使用 []。

  • 获取子字符串,使用 slice 或者 substring 方法。

  • 字符串小写/大写形式,使用 toLowerCase/toUpperCase 方法。

  • 搜索子字符串使用 indexOf 方法,或者 includes/startsWith/endsWith 简单检查。

  • 根据语言来比较字符,使用 localeCompare,否则他们是按照字符编码进行比较的。

这里还提供了其他有用的字符串方法:

  • str.trim() ——删除字符串开头和结尾的空格。

  • str.repeat(n)——重复字符串 n 次。

  • ……还有更多。详情查看手册

字符串也可以使用正则表达式进行搜索/替换的方法。但这个话题值得单独一章,所以我们稍后再讨论这个问题。

扩展阅读

(完)