探索-JavaScript-ES2025-版--四-

探索 JavaScript(ES2025 版)(四)

原文:exploringjs.com/js/book/index.html

译者:飞龙

协议:CC BY-NC-SA 4.0

20 大整数 – ES2020 的任意精度整数(高级)

原文:exploringjs.com/js/book/ch_bigints.html

  1. 20.1 为什么使用大整数?

  2. 20.2 大整数

    1. 20.2.1 超过 53 位整数的范围

    2. 20.2.2 示例:使用大整数

  3. 20.3 大整数字面量

    1. 20.3.1 大整数字面量中的下划线(_)作为分隔符(ES2021)
  4. 20.4 重用数字运算符用于大整数(重载)

    1. 20.4.1 算术运算符

    2. 20.4.2 松散相等(==)和不相等(!=

    3. 20.4.3 严格相等(===)和不相等(!==

    4. 20.4.4 排序运算符

    5. 20.4.5 位运算符(高级)

  5. 20.5 包装构造函数 BigInt

    1. 20.5.1 BigInt 作为构造函数和函数

    2. 20.5.2 BigInt.prototype.* 方法

    3. 20.5.3 BigInt.* 方法:类型转换

  6. 20.6 将大整数强制转换为其他原始类型

  7. 20.7 64 位值的 Typed Array 和 DataView 操作

  8. 20.8 大整数与 JSON

    1. 20.8.1 将大整数转换为字符串

    2. 20.8.2 解析大整数

  9. 20.9 常见问题:大整数

    1. 20.9.1 我如何决定何时使用数字和何时使用大整数?

    2. 20.9.2 为什么不能像大整数那样简单地增加数字的精度?

在本章中,我们将探讨 大整数,JavaScript 中的整数,其存储空间根据需要增长和缩小。

20.1 为什么使用大整数?

在 ECMAScript 2020 之前,JavaScript 处理整数的方式如下:

  • 在那时,只有一种浮点数和整数的类型:64 位浮点数(IEEE 754 双精度)。

  • 在底层,大多数 JavaScript 引擎透明地支持整数:如果一个数字没有小数位并且在一个特定的范围内,它可以在内部作为一个真正的整数存储。这种表示称为 小整数,通常适合 32 位。例如,V8 引擎 64 位版本的整数范围是从 −2³¹ 到 2³¹−1 (来源)。

  • JavaScript 数字也可以表示小整数范围之外的整数,作为浮点数。在这里,安全范围是±53 位。有关此主题的更多信息,请参阅“安全整数”(§18.9.4)。

有时,我们需要比有符号的 53 位更多的位数 – 例如:

  • X(以前是 Twitter)使用 64 位整数作为帖子的 ID(来源)。在 JavaScript 中,这些 ID 必须以字符串的形式存储。

  • 金融技术使用所谓的大整数(任意精度的整数)来表示货币金额。内部,金额被乘以,使得小数点消失。例如,美元金额乘以 100,使得美分消失。

20.2 大整数

大整数是整数的一个原始数据类型。大整数没有固定的位存储大小;它们的大小适应于它们所表示的整数:

  • 小整数用比大整数更少的位表示。

  • 可表示的整数没有负的下限或正的上限。

大整数字面量是一系列一个或多个数字,后跟一个 n – 例如:

123n

-* 等运算符被重载,并可以与大整数一起使用:

> 123n * 456n
56088n

大整数是原始值。typeof 对它们返回一个不同的结果:

> typeof 123n
'bigint'

20.2.1 整数超过 53 位

JavaScript 数字在内部表示为一个分数乘以一个指数(有关详细信息,请参阅“背景:浮点精度”(§18.8))。因此,如果我们超过最高的安全整数 2⁵³−1,仍然还有一些整数可以表示,但它们之间有间隔:

> 2**53 // can be represented but same as next number
9007199254740992
> 2**53 + 1 // wrong
9007199254740992

大整数使我们能够超过 53 位:

> 2n**53n
9007199254740992n
> 2n**53n + 1n
9007199254740993n

20.2.2 示例:使用大整数

这就是使用大整数的样子(基于提案中的示例的代码):

/**
 * Takes a bigint as an argument and returns a bigint
 */
function nthPrime(nth) {
  if (typeof nth !== 'bigint') {
    throw new TypeError();
  }
  function isPrime(p) {
    for (let i = 2n; i < p; i++) {
      if (p % i === 0n) return false;
    }
    return true;
  }
  for (let i = 2n; ; i++) {
    if (isPrime(i)) {
      if (--nth === 0n) return i;
    }
  }
}

assert.deepEqual(
  [1n, 2n, 3n, 4n, 5n].map(nth => nthPrime(nth)),
  [2n, 3n, 5n, 7n, 11n]
);

20.3 大整数字面量

与数字字面量类似,大整数字面量支持多种基数:

  • 十进制:123n

  • 十六进制:0xFFn

  • 二进制:0b1101n

  • 八进制:0o777n

负大整数通过在前面加一元减号运算符产生:-0123n

20.3.1 大整数字面量中的下划线(_)作为分隔符(ES2021)

与数字字面量类似,我们可以在大整数字面量中使用下划线(_)作为分隔符:

const massOfEarthInKg = 6_000_000_000_000_000_000_000_000n;

大整数常用于金融技术领域表示货币。分隔符在这里也有帮助:

const priceInCents = 123_000_00n; // 123 thousand dollars

与数字字面量一样,有两个限制:

  • 我们只能在两个数字之间放置一个下划线。

  • 我们在一行中最多只能使用一个下划线。

20.4 为大整数重用数字运算符(重载)

对于大多数运算符,我们不允许混合大整数和数字。如果我们这样做,会抛出异常:

> 2n + 1
TypeError: Cannot mix BigInt and other types, use explicit conversions

这条规则的原因是,没有一种通用的方法可以将数字和大整数强制转换为同一类型:数字无法表示超过 53 位的整数,大整数无法表示分数。因此,这些异常警告我们注意可能引起意外结果的错误。

考虑以下表达式:

2**53 + 1n

结果应该是 9007199254740993n 还是 9007199254740992

也不清楚以下表达式的结果应该是什么:

2n**53n * 3.3

20.4.1 算术运算符

二进制 +、二进制 -*** 的工作方式符合预期:

> 7n * 3n
21n

混合大整数和字符串是可以的:

> 6n + ' apples'
'6 apples'

/% 通过去除小数部分进行四舍五入(类似于 Math.trunc()):

> 1n / 2n
0n

一元 - 的工作方式符合预期:

> -(-64n)
64n

一元 + 不支持大整数,因为许多代码依赖于它将操作数强制转换为数字:

> +23n
TypeError: Cannot convert a BigInt value to a number

20.4.2 宽松相等(==)和不相等(!=

宽松相等(==)和不相等(!=)强制转换值:

> 0n == false
true
> 1n == true
true

> 123n == 123
true

> 123n == '123'
true

20.4.3 严格相等(===)和不相等(!==

严格相等(===)和不相等(!==)只有在它们具有相同类型时才认为值相等:

> 123n === 123
false
> 123n === 123n
true

20.4.4 排序运算符

排序运算符 <>>=<= 的工作方式符合预期:

> 17n <= 17n
true
> 3n > -1n
true

比较大整数和数字不会造成任何风险。因此,我们可以混合大整数和数字:

> 3n > -1
true

“练习”图标练习:将基于数字的代码转换为大整数

exercises/bigints/gcd-bigint_test.mjs

20.4.5 位运算符(高级)

20.4.5.1 数字的位运算符

位运算符将数字解释为 32 位整数。这些整数要么是无符号的,要么是有符号的。如果它们是有符号的,整数的负数是其二进制补码:如果我们将其整数与其二进制补码相加并忽略溢出(32 位之外的数字),则结果是零。

> 2**32 - 1 >> 0 // 0b11111111111111111111111111111111
-1

如果我们将由 32 个 1 组成的二进制数加 1,我们得到一个 1 后面跟着 32 个 0。超过 32 位的所有内容都是溢出,这意味着那个数字是零。

我们使用了有符号右移运算符(>>):我们将左操作数左移了 0 位,将其转换为 Int32(有符号)并再次转换为数字。

由于这些整数具有固定的大小,它们的最高位表示它们的符号:

> 2**31 >> 0 // highest bit is 1
-2147483648
> 2**31 - 1 >> 0 // highest bit is 0
2147483647

20.4.5.2 大整数的位运算符

对于大整数,位运算符将负号解释为无限大的二进制补码 – 例如:

  • -1···111111(1 无限向左扩展)

  • -2···111110

  • -3···111101

  • -4···111100

也就是说,负号更像是一个外部标志,而不是作为实际的位来表示。

20.4.5.3 位非运算符(~

位非运算符(~)反转所有位:

assert.equal(
  ~0b10n,
  -3n // ···111101
);
assert.equal(
  ~-2n, // ···111110
  1n
);

20.4.5.4 二进制位运算符(&|^

将二进制位运算符应用于大整数的工作方式与应用于数字类似:

> (0b1010n |  0b0111n).toString(2)
'1111'
> (0b1010n &  0b0111n).toString(2)
'10'

> (0b1010n | -1n).toString(2)
'-1'
> (0b1010n & -1n).toString(2)
'1010'

20.4.5.5 位有符号移位运算符 (<<>>)

大整数的有符号移位运算符保留数字的符号:

> 2n << 1n
4n
> -2n << 1n
-4n

> 2n >> 1n
1n
> -2n >> 1n
-1n

回想一下,-1n 是一个无限向左延伸的 1 的序列。这就是为什么左移它不会改变它的原因:

> -1n >> 20n
-1n

20.4.5.6 位无符号右移运算符 (>>>)

大整数没有无符号右移运算符:

> 2n >>> 1n
TypeError: BigInts have no unsigned right shift, use >> instead

为什么?无符号右移位背后的想法是将一个零从“左边”移入。换句话说,假设二进制位是有限数量的。

然而,对于负大整数(尤其是负数),没有“左边”;它们的二进制位无限延伸。

有符号右移位即使对于无限数量的数字也能正常工作,因为最高位被保留。因此,它可以适应大整数。

图标“练习”练习:通过大整数实现位集

exercises/bigints/bit-set_test.mjs

20.5 大整数包装构造函数 BigInt

与数字类似,大整数有相关的包装构造函数 BigInt

20.5.1 大整数作为构造函数和函数

  • new BigInt():抛出 TypeError

  • BigInt(x) 将任意值 x 转换为大整数。这与 Number() 类似,但有几个不同之处,这些不同之处在表 20.1 中总结,并在以下小节中详细解释。

x BigInt(x)
undefined 抛出 TypeError
null 抛出 TypeError
布尔值 false0ntrue1n
数字 示例:123123n
非整数 → 抛出 RangeError
大整数 x(无变化)
字符串 示例:'123'123n
不可解析 → 抛出 SyntaxError
符号 抛出 TypeError
对象 可配置(例如,通过 .valueOf()

表 20.1:将值转换为大整数。

20.5.1.1 转换 undefinednull

如果 xundefinednull,则抛出 TypeError

> BigInt(undefined)
TypeError: Cannot convert undefined to a BigInt
> BigInt(null)
TypeError: Cannot convert null to a BigInt

20.5.1.2 转换字符串

如果一个字符串不表示整数,BigInt() 抛出 SyntaxError(而 Number() 返回错误值 NaN):

> BigInt('abc')
SyntaxError: Cannot convert abc to a BigInt

不允许使用后缀 'n'

> BigInt('123n')
SyntaxError: Cannot convert 123n to a BigInt

大整数字面量的所有基数都是允许的:

> BigInt('123')
123n
> BigInt('0xFF')
255n
> BigInt('0b1101')
13n
> BigInt('0o777')
511n

20.5.1.3 非整数数字产生异常
> BigInt(123.45)
RangeError: The number 123.45 cannot be converted to a BigInt because
it is not an integer
> BigInt(123)
123n

20.5.1.4 转换对象

对象转换为大整数的转换方式可以配置——例如,通过重写 .valueOf()

> BigInt({valueOf() {return 123n}})
123n 

20.5.2 BigInt.prototype.* 方法

BigInt.prototype 包含原始大整数“继承”的方法:

  • BigInt.prototype.toLocaleString(locales?, options?)

  • BigInt.prototype.toString(radix?)

  • BigInt.prototype.valueOf()

20.5.3 BigInt.* 方法:类型转换

  • BigInt.asIntN(width, theInt)

    theInt转换为width位的整数(有符号)。这会影响值在内部如何表示。

  • BigInt.asUintN(width, theInt)

    theInt转换为width位的整数(无符号)。

20.5.3.1 示例:使用 64 位整数

转换允许我们创建具有特定位数的大整数值 - 例如,如果我们想限制自己使用 64 位整数,我们总是必须进行转换:

const uint64a = BigInt.asUintN(64, 12345n);
const uint64b = BigInt.asUintN(64, 67890n);
const result = BigInt.asUintN(64, uint64a * uint64b);

练习图标 练习:为大整数实现Number.parseInt()的类似功能

exercises/bigints/parse-bigint_test.mjs

20.6 将大整数转换为其他原始类型

此表显示了如果我们将大整数转换为其他原始类型会发生什么:

转换为 显式转换 诱导(隐式转换)
布尔值 Boolean(0n)false !0ntrue
Boolean(int)true !intfalse
数字 Number(7n)7 (示例) +intTypeError (1)
字符串 String(7n)'7' (示例) ''+7n'7' (示例)

脚注:

  • (1) 一元+不支持大整数,因为许多代码依赖于它将操作数强制转换为数字。

20.7 类型化数组和数据视图的 64 位值操作

多亏了大整数,类型化数组和数据视图可以支持 64 位值:

  • 类型化数组构造函数:

    • BigInt64Array

    • BigUint64Array

  • 数据视图方法:

    • DataView.prototype.getBigInt64()

    • DataView.prototype.setBigInt64()

    • DataView.prototype.getBigUint64()

    • DataView.prototype.setBigUint64()

20.8 大整数和 JSON

JSON 标准是固定的,不会改变。好处是旧的 JSON 解析代码永远不会过时。坏处是 JSON 不能扩展以包含大整数。

将大整数转换为字符串会抛出异常:

> JSON.stringify(123n)
TypeError: Do not know how to serialize a BigInt
> JSON.stringify([123n])
TypeError: Do not know how to serialize a BigInt

20.8.1 将大整数转换为字符串

因此,我们最好的选择是将大整数存储为字符串:

const bigintPrefix = '[[bigint]]';

function bigintReplacer(_key, value) {
  if (typeof value === 'bigint') {
    return bigintPrefix + value;
  }
  return value;
}

const data = { value: 9007199254740993n };
assert.equal(
  JSON.stringify(data, bigintReplacer),
  '{"value":"[[bigint]]9007199254740993"}'
);

20.8.2 解析大整数

以下代码展示了如何解析类似于我们在上一个示例中产生的字符串。

function bigintReviver(_key, value) {
  if (typeof value === 'string' && value.startsWith(bigintPrefix)) {
    return BigInt(value.slice(bigintPrefix.length));
  }
  return value;
}

const str = '{"value":"[[bigint]]9007199254740993"}';
assert.deepEqual(
  JSON.parse(str, bigintReviver),
  { value: 9007199254740993n }
);

20.9 常见问题解答:大整数

20.9.1 我该如何决定何时使用数字和何时使用大整数?

我的建议:

  • 使用数字表示最多 53 位和数组索引。理由:它们已经无处不在,并且大多数引擎(尤其是如果它们适合 31 位)可以有效地处理它们。出现的情况包括:

    • Array.prototype.forEach()

    • Array.prototype.entries()

  • 使用大整数表示大数值:如果你的无小数值不适合 53 位,你除了转向大整数别无选择。

所有现有的 Web API 都只返回和接受数字,并且仅在特定情况下升级到大整数。

20.9.2 为什么不就像处理大整数(bigints)那样增加数字的精度?

理论上可以将number分为integerdouble,但这会给语言增加许多新的复杂性(例如,几个仅限整数的运算符等)。我在一个 Gist中概述了这些后果。


致谢:

  • 感谢丹尼尔·埃伦伯格(Daniel Ehrenberg)审阅了此内容的早期版本。

  • 感谢丹·卡拉汉(Dan Callahan)审阅了此内容的早期版本。

21 Unicode – 简要介绍(高级)

原文:exploringjs.com/js/book/ch_unicode.html

  1. 21.1 码点与码位

    1. 21.1.1 码点

    2. 21.1.2 编码 Unicode 码点:UTF-32,UTF-16,UTF-8

  2. 21.2 网络开发中使用的编码:UTF-16 和 UTF-8

    1. 21.2.1 内部源代码:UTF-16

    2. 21.2.2 字符串:UTF-16

    3. 21.2.3 源代码在文件中:UTF-8

  3. 21.3 图形群组 – 真正的字符

    1. 21.3.1 图形群组与符号

Unicode 是一个用于表示和管理世界上大多数书写系统的标准。几乎所有处理文本的现代软件都支持 Unicode。该标准由 Unicode 联盟维护。每年发布一个新的标准版本(包括新的表情符号等)。Unicode 1.0.0 版本于 1991 年 10 月发布。

21.1 码点与码位

两个概念对于理解 Unicode 至关重要:

  • 码点是代表 Unicode 文本原子部分的数字。其中大部分代表可见符号,但它们也可以有其他含义,例如指定符号的某个方面(字母的重音,表情符号的肤色等)。

  • 码位是编码码点的数字,用于存储或传输 Unicode 文本。一个或多个码位编码一个单一的码点。每个码位具有相同的大小,这取决于所使用的编码格式。最流行的格式,UTF-8,具有 8 位码位。

21.1.1 码点

Unicode 的第一个版本有 16 位码点。从那时起,字符的数量大幅增长,码点的大小扩展到 21 位。这 21 位被分为 17 个平面,每个平面 16 位:

  • 平面 0:基本多语言平面 (BMP),0x0000–0xFFFF

    • 包含几乎所有现代语言的字符(拉丁字符、亚洲字符等)以及许多符号。
  • 平面 1:补充多语言平面 (SMP),0x10000–0x1FFFF

    • 支持历史书写系统(例如,埃及象形文字和楔形文字)以及额外的现代书写系统。

    • 支持表情符号和许多其他符号。

  • 平面 2:补充表意文字平面 (SIP),0x20000–0x2FFFF

    • 包含额外的 CJK(中文、日文、韩文)表意文字。
  • 平面 3–13:未分配

  • 平面 14:补充特殊用途平面 (SSP),0xE0000–0xEFFFF

    • 包含非图形字符,如标签字符和符号变体选择器。
  • 平面 15–16:补充专用区 (S PUA A/B),0x0F0000–0x10FFFF

    • 可供 ISO 和 Unicode 联盟以外的各方进行字符分配。未标准化。

平面 1-16 被称为补充平面或天体平面

让我们检查几个字符的代码点:

> 'A'.codePointAt(0).toString(16)
'41'
> 'ü'.codePointAt(0).toString(16)
'fc'
> 'π'.codePointAt(0).toString(16)
'3c0'
> '🙂'.codePointAt(0).toString(16)
'1f642'

代码点的十六进制数告诉我们,前三个字符位于平面 0(16 位以内),而表情符号位于平面 1。

21.1.2 编码 Unicode 代码点:UTF-32、UTF-16、UTF-8

编码代码点的三种主要方式是三种Unicode 转换格式(UTFs):UTF-32、UTF-16、UTF-8。每个格式的结尾数字表示其代码单元的大小(以位为单位)。

21.1.2.1 UTF-32 (Unicode 转换格式 32)

UTF-32 使用 32 位来存储代码单元,每个代码点一个代码单元。这是唯一具有固定长度编码的格式;所有其他格式都使用不同数量的代码单元来编码单个代码点。

21.1.2.2 UTF-16 (Unicode 转换格式 16)

UTF-16 使用 16 位代码单元。它按以下方式编码代码点:

  • BMP(Unicode 的第一个 16 位)存储在单个代码单元中。

  • 阿斯特拉尔平面:BMP 包含 0x10_000 个代码点。鉴于 Unicode 总共有 0x110_000 个代码点,我们还需要编码剩余的 0x100_000 个代码点(20 位)。BMP 有两个未分配的代码点范围,提供了必要的存储空间:

    • 最显著的 10 位(前导代理高代理):0xD800-0xDBFF

    • 最不显著的 10 位(后缀代理低代理):0xDC00-0xDFFF

因此,每个 UTF-16 代码单元要么:

  • BMP 代码点(一个标量

  • 一个前导代理

  • 一个后缀代理

如果代理单独出现,没有其伙伴,则称为孤代理

这是代码点的位如何分布在代理之间的:

0bhhhhhhhhhhllllllllll // code point - 0x10000
0b110110hhhhhhhhhh     // 0xD800 + 0bhhhhhhhhhh
0b110111llllllllll     // 0xDC00 + 0bllllllllll

JavaScript 字符串中的每个字符都是一个 UTF-16 代码单元。例如,考虑代码点 0x1F642(🙂),它由两个 UTF-16 代码单元表示 – 0xD83D 和 0xDE42:

> '🙂'.codePointAt(0).toString(16)
'1f642'
> '🙂'.length
2
> '🙂'.split('')
[ '\uD83D', '\uDE42' ]

让我们从代码点推导出代码单元:

> (0x1F642 - 0x10000).toString(2).padStart(20, '0')
'00001111011001000010'
> (0xD800 + 0b0000111101).toString(16)
'd83d'
> (0xDC00 + 0b1001000010).toString(16)
'de42'

相比之下,代码点 0x03C0(π)是 BMP 的一部分,因此由单个 UTF-16 代码单元表示 – 0x03C0:

> 'π'.length
1

21.1.2.3 UTF-8 (Unicode 转换格式 8)

UTF-8 有 8 位代码单元。它使用 1-4 个代码单元来编码一个代码点:

代码点 代码单元
0000–007F 0bbbbbbb (7 bits)
0080–07FF 110bbbbb, 10bbbbbb (5+6 bits)
0800–FFFF 1110bbbb, 10bbbbbb, 10bbbbbb (4+6+6 bits)
10000–1FFFFF 11110bbb, 10bbbbbb, 10bbbbbb, 10bbbbbb (3+6+6+6 bits)

注意:

  • 每个代码单元的位前缀告诉我们:

    • 它是代码单元系列中的第一个吗?如果是,将跟随多少个代码单元?

    • 它是代码单元系列中的第二个或后续的吗?

  • 0000–007F 范围内的字符映射与 ASCII 相同,这导致与旧软件有一定的向后兼容性。

三个示例:

字符 代码点 代码单元
A 0x0041 01000001
π 0x03C0 11001111, 10000000
🙂 0x1F642 11110000, 10011111, 10011001, 10000010

21.2 网络开发中使用的编码:UTF-16 和 UTF-8

在网络开发中使用的 Unicode 编码格式是:UTF-16 和 UTF-8。

21.2.1 源代码内部:UTF-16

ECMAScript 规范在内部将源代码表示为 UTF-16。

21.2.2 字符串:UTF-16

JavaScript 字符串中的字符基于 UTF-16 代码单元:

> const smiley = '🙂';
> smiley.length
2
> smiley === '\uD83D\uDE42' // code units
true

关于 Unicode 和字符串的更多信息,请参阅“文本的原子:码点、JavaScript 字符、图形群” (§22.7)。

21.2.3 文件中的源代码:UTF-8

现在,HTML 和 JavaScript 文件几乎总是以 UTF-8 编码。

例如,这是现在 HTML 文件通常开始的格式:

<!doctype html>
<html>
<head>
  <meta charset="UTF-8">
···

21.3 图形群——真正的字符

一旦我们考虑世界上的各种书写系统,字符的概念就会变得非常复杂。这就是为什么有几个不同的 Unicode 术语,它们都以某种方式意味着“字符”:码点图形群符号等。

在 Unicode 中,一个 码点 是存储在计算机中的文本的原子部分。

然而,一个 图形群 最接近于屏幕或纸张上显示的符号。它被定义为“一个水平可分割的文本单位”。因此,官方 Unicode 文档也将其称为用户感知字符。编码一个图形群需要一个或多个码点。

例如,德文那加里语中的 kshi 被编码为 4 个码点。我们使用 Array.from() 将字符串拆分为一个包含码点的数组:

许多表情符号由多个码点组成:

国旗表情符号是图形群,由两个码点组成——例如,日本的国旗:

21.3.1 图形群与符号

符号是一个抽象概念,也是书面语言的一部分:

  • 它在计算机内存中通过一个 图形群 表示——一个由一个或多个码点(数字)组成的序列。

  • 它通过 符号 在屏幕上绘制。符号是一个图像,通常存储在字体中。可能需要多个符号来绘制单个符号——例如,符号“é”可能通过将“e”符号与“´”符号组合来绘制。

在谈论 Unicode 时,概念与其表示之间的区别是微妙的,并且可能会变得模糊。

图标“外部”关于图形群体的更多信息

更多信息,请参阅 Manish Goregaokar 撰写的“让我们停止将意义归因于码点”

22 字符串

原文:exploringjs.com/js/book/ch_strings.html

  1. 22.1 速查表:字符串

    1. 22.1.1 处理字符串

    2. 22.1.2 JavaScript 字符与码点与图形簇

    3. 22.1.3 字符串方法

  2. 22.2 普通字符串字面量

    1. 22.2.1 转义
  3. 22.3 访问 JavaScript 字符

  4. 22.4 字符串连接

    1. 22.4.1 通过 + 进行字符串连接

    2. 22.4.2 通过数组连接(.push().join()

  5. 22.5 在 JavaScript 中将值转换为字符串有陷阱

    1. 22.5.1 示例:有问题的代码

    2. 22.5.2 四种常见的将值转换为字符串的方法

    3. 22.5.3 使用 JSON.stringify() 将值转换为字符串

    4. 22.5.4 解决方案

    5. 22.5.5 自定义对象转换为字符串的方式

  6. 22.6 比较字符串

  7. 22.7 文本原子:码点、JavaScript 字符、图形簇

    1. 22.7.1 处理代码单元(JavaScript 字符)

    2. 22.7.2 处理码点

    3. 22.7.3 处理图形簇

  8. 22.8 快速参考:字符串

    1. 22.8.1 转换为字符串

    2. 22.8.2 文本原子的数值

    3. 22.8.3 String.prototype.*: 正则表达式方法

    4. 22.8.4 String.prototype.*: 查找和匹配

    5. 22.8.5 String.prototype.*: 提取

    6. 22.8.6 String.prototype.*: 组合

    7. 22.8.7 String.prototype.*: 转换

    8. 22.8.8 本快速参考的来源

22.1 速查表:字符串

在 JavaScript 中,字符串是原始值且不可变。也就是说,与字符串相关的操作总是产生新的字符串,而不会更改现有的字符串。

22.1.1 处理字符串

字符串字面量:

const str1 = 'Don\'t say "goodbye"'; // string literal
const str2 = "Don't say \"goodbye\""; // string literals
assert.equal(
  `As easy as ${123}!`, // template literal
  'As easy as 123!',
);

反斜杠用于:

  • 转义字面量分隔符(前两个示例的示例的第一行和第二行)

  • 表示特殊字符:

    • \\ 表示反斜杠

    • \n 表示换行符

    • \r 表示回车符

    • \t 表示制表符

String.raw 标签模板(行 A)内部,反斜杠被视为普通字符:

assert.equal(
  String.raw`\ \n\t`, // (A)
  '\\ \\n\\t',
);

将值转换为字符串:

> String(undefined)
'undefined'
> String(null)
'null'
> String(123.45)
'123.45'
> String(true)
'true'

复制字符串的一部分

// There is no type for characters;
// reading characters produces strings:
const str3 = 'abc';
assert.equal(
  str3[2], 'c' // no negative indices allowed
);
assert.equal(
  str3.at(-1), 'c' // negative indices allowed
);

// Copying more than one character:
assert.equal(
  'abc'.slice(0, 2), 'ab'
);

字符串连接:

assert.equal(
  'I bought ' + 3 + ' apples',
  'I bought 3 apples',
);

let str = '';
str += 'I bought ';
str += 3;
str += ' apples';
assert.equal(
  str, 'I bought 3 apples',
);

22.1.2 JavaScript 字符与码点与图形簇

  • 码点是 21 位 Unicode 字符。

  • JavaScript 字符是 16 位 UTF-16 码单元。为了编码一个码点,我们需要一个到两个码单元。大多数码点适合在一个码单元中。

  • 图形簇用户感知字符,代表书写符号。大多数图形簇只有一个码点长,但它们也可以更长——例如,一些表情符号。

示例——由多个码点组成的图形簇:

const graphemeCluster = '😵‍💫';
assert.equal(
  // 5 JavaScript characters
  '😵‍💫'.length, 5
);
assert.deepEqual(
  // Iteration splits into code points
  Array.from(graphemeCluster),
  ['😵', '\u200D', '💫']
);

更多关于如何处理文本的信息,请参阅“文本原子:码点、JavaScript 字符、图形簇”(§22.7)。

22.1.3 字符串方法

本小节简要概述了字符串 API。本章末尾有一个更全面的快速参考快速参考。

查找子字符串:

> 'abca'.includes('a')
true
> 'abca'.startsWith('ab')
true
> 'abca'.endsWith('ca')
true

> 'abca'.indexOf('a')
0
> 'abca'.lastIndexOf('a')
3

分割和连接:

assert.deepEqual(
  'a, b,c'.split(/, ?/),
  ['a', 'b', 'c']
);
assert.equal(
  ['a', 'b', 'c'].join(', '),
  'a, b, c'
);

填充和修剪:

> '7'.padStart(3, '0')
'007'
> 'yes'.padEnd(6, '!')
'yes!!!'

> '\t abc\n '.trim()
'abc'
> '\t abc\n '.trimStart()
'abc\n '
> '\t abc\n '.trimEnd()
'\t abc'

重复和更改大小写:

> '*'.repeat(5)
'*****'
> '= b2b ='.toUpperCase()
'= B2B ='
> 'ΑΒΓ'.toLowerCase()
'αβγ'

22.2 平凡字符串字面量

平凡字符串字面量由单引号或双引号分隔:

const str1 = 'abc';
const str2 = "abc";
assert.equal(str1, str2);

单引号使用得更频繁,因为它使得提及 HTML 更容易,在 HTML 中双引号是首选的。

下一章介绍了模板字面量,它给我们:

  • 字符串插值

  • 多行

  • 原始字符串字面量(反斜杠没有特殊含义)

22.2.1 转义

反斜杠允许我们创建特殊字符:

  • Unix 行结束符:'\n'

  • Windows 行结束符:'\r\n'

  • 制表符:'\t'

  • 反斜杠:'\\'

反斜杠还允许我们在字符串字面量内部使用该字面量的分隔符:

assert.equal(
  'She said: "Let\'s go!"',
  "She said: \"Let's go!\"");

22.3 访问 JavaScript 字符

JavaScript 没有额外的字符数据类型——字符始终以字符串的形式表示。

const str = 'abc';

// Reading a JavaScript character at a given index
assert.equal(str[1], 'b');

// Counting the JavaScript characters in a string:
assert.equal(str.length, 3);

我们在屏幕上看到的字符被称为图形簇。其中大多数由单个 JavaScript 字符表示。然而,也有一些图形簇(特别是表情符号)由多个 JavaScript 字符表示:

> '🙂'.length
2

这是如何工作的解释见“文本原子:码点、JavaScript 字符、图形簇”(§22.7)。

22.4 字符串连接

22.4.1 通过 + 进行字符串连接

如果至少有一个操作数是字符串,则加号运算符(+)将任何非字符串转换为字符串并将结果连接起来:

assert.equal(3 + ' times ' + 4, '3 times 4');

赋值运算符 += 如果我们想逐步组装一个字符串,那么它很有用:

let str = ''; // must be `let`!
str += 'Say it';
str += ' one more';
str += ' time';

assert.equal(str, 'Say it one more time');

Icon “details”通过 + 连接是高效的

使用 + 组装字符串相当高效,因为大多数 JavaScript 引擎都会对其进行内部优化。

Icon “exercise”练习:字符串连接

exercises/strings/concat_string_array_test.mjs

22.4.2 通过数组进行连接(.push().join()

有时,通过数组绕道进行字符串连接可能会有用,特别是如果它们之间需要分隔符(例如行 A 中的 ', '):

function getPackingList(isAbroad = false, days = 1) {
  const items = [];
  items.push('tooth brush');
  if (isAbroad) {
    items.push('passport');
  }
  if (days > 3) {
    items.push('water bottle');
  }
  return items.join(', '); // (A)
}
assert.equal(
  getPackingList(),
  'tooth brush'
);
assert.equal(
  getPackingList(true, 7),
  'tooth brush, passport, water bottle'
);

22.5 JavaScript 中转换值到字符串的陷阱

JavaScript 中将值转换为字符串比看起来要复杂:

  • 大多数方法都有它们无法处理的值。

  • 我们并不总是看到所有的数据。

22.5.1 示例:有问题的代码

你能否在以下代码中找到问题?

class UnexpectedValueError extends Error {
  constructor(value) {
    super('Unexpected value: ' + value); // (A)
  }
}

对于某些值,这段代码在行 A 抛出异常:

> new UnexpectedValueError(Symbol())
TypeError: Cannot convert a Symbol value to a string
> new UnexpectedValueError({__proto__:null})
TypeError: Cannot convert object to primitive value

继续阅读以获取更多信息。

22.5.2 四种常见的将值转换为字符串的方法

  1. String(v)

  2. v.toString()

  3. '' + v

  4. `${v}`

以下表格显示了这些操作与各种值的表现情况(#4 产生的结果与#3 相同)。

String(v) '' + v v.toString()
undefined 'undefined' 'undefined' TypeError
null 'null' 'null' TypeError
true 'true' 'true' 'true'
123 '123' '123' '123'
123n '123' '123' '123'
"abc" 'abc' 'abc' 'abc'
Symbol() 'Symbol()' TypeError 'Symbol()'
{a:1} '[object Object]' '[object Object]' '[object Object]'
['a'] 'a' 'a' 'a'
{__proto__:null} TypeError TypeError TypeError
Symbol.prototype TypeError TypeError TypeError
() => {} '() => {}' '() => {}' '() => {}'

让我们探究为什么这些值会产生异常或不是非常有用的结果。

22.5.2.1 棘手值:符号

符号必须显式地转换为字符串(通过 String().toString())。通过连接转换会抛出异常:

> '' + Symbol()
TypeError: Cannot convert a Symbol value to a string

为什么会这样?目的是为了防止意外地将一个符号属性键转换为字符串(这同样也是一个有效的属性键)。

22.5.2.2 棘手值:具有 null 原型的对象

如果没有 .toString() 方法,v.toString() 不起作用的原因很明显。然而,其他转换操作会按照以下顺序调用以下方法,并使用返回的第一个原始值(在将其转换为字符串后):

  • v[Symbol.toPrimitive]()

  • v.toString()

  • v.valueOf()

如果这些方法中没有一个存在,则会抛出 TypeError

> String({__proto__: null, [Symbol.toPrimitive]() {return 'YES'}})
'YES'
> String({__proto__: null, toString() {return 'YES'}})
'YES'
> String({__proto__: null, valueOf() {return 'YES'}})
'YES'

> String({__proto__: null}) // no method available
TypeError: Cannot convert object to primitive value 

我们可能在哪些地方遇到具有 null 原型的对象?

  • “具有 null 原型的对象作为字典”(§30.9.11.4)

  • “具有 null 原型的对象作为固定查找表”(§30.9.11.5)

  • “标准库中的 null 原型”(§30.9.11.6)

22.5.2.3 诡异的值:一般对象

简单对象默认的字符串表示并不很有用:

> String({a: 1})
'[object Object]'

数组有更好的字符串表示,但它们仍然隐藏了很多信息:

> String(['a', 'b'])
'a,b'
> String(['a', ['b']])
'a,b'

> String([1, 2])
'1,2'
> String(['1', '2'])
'1,2'

> String([true])
'true'
> String(['true'])
'true'
> String(true)
'true'

22.5.2.4 诡异的值:Symbol.prototype

你可能永远不会在野外遇到 Symbol.prototype(提供符号方法的对象)这个值,但它是一个有趣的边缘情况:如果 this 不是一个符号,Symbol.prototype[Symbol.toPrimitive]() 会抛出异常。这解释了为什么将 Symbol.prototype 转换为字符串不起作用:

> Symbol.prototype[Symbol.toPrimitive]()
TypeError: Symbol.prototype [ @@toPrimitive ] requires that 'this' be a Symbol
> String(Symbol.prototype)
TypeError: Symbol.prototype [ @@toPrimitive ] requires that 'this' be a Symbol

22.5.3 使用 JSON.stringify() 将值转换为字符串

JSON 数据格式是 JavaScript 值的文本表示。因此,JSON.stringify() 也可以用来将值转换为字符串。它在对象和数组中特别有效,因为正常的字符串转换有显著的不足:

> JSON.stringify({a: 1})
'{"a":1}'
> JSON.stringify(['a', ['b']])
'["a",["b"]]'

JSON.stringify() 对原型为 null 的对象没有问题:

> JSON.stringify({__proto__: null, a: 1})
'{"a":1}'

主要缺点是 JSON.stringify() 只支持以下值:

  • 原始值:

    • null

    • 布尔值

    • 数字(除了 NaNInfinity

    • 字符串

  • 非原始值:

    • 数组

    • 对象(除了函数)

对于大多数其他值,我们得到的结果是 undefined(而不是字符串):

> JSON.stringify(undefined)
undefined
> JSON.stringify(Symbol())
undefined
> JSON.stringify(() => {})
undefined

大整数会导致异常:

> JSON.stringify(123n)
TypeError: Do not know how to serialize a BigInt

产生 undefined 值的属性被省略:

> JSON.stringify({a: Symbol(), b: 2})
'{"b":2}'

数组元素中的值产生 undefined 时,会被转换为 null

> JSON.stringify(['a', Symbol(), 'b'])
'["a",null,"b"]'

下表总结了 JSON.stringify(v) 的结果:

JSON.stringify(v)
undefined undefined
null 'null'
true 'true'
123 '123'
123n TypeError
'abc' '"abc"'
Symbol() undefined
{a:1} '{"a":1}'
['a'] '["a"]'
() => {} undefined
{__proto__:null} '{}'
Symbol.prototype '{}'

更多信息,请参阅“数据转换为 JSON 的详细信息”(§48.3.1.3)。

22.5.3.1 多行输出

默认情况下,JSON.stringify() 返回单行文本。但是,可选的第三个参数可以启用多行输出,并允许我们指定缩进量 - 例如:

assert.equal(
JSON.stringify({first: 'Robin', last: 'Doe'}, null, 2),
`{
 "first": "Robin",
 "last": "Doe"
}`
);

22.5.3.2 通过 JSON.stringify() 显示字符串

JSON.stringify() 对于显示任意字符串很有用:

  • 结果总是适合单行。

  • 不可见字符,如换行符和制表符,变得可见。

示例:

const strWithNewlinesAndTabs = `
TAB->	<-TAB
Second line 
`;
console.log(JSON.stringify(strWithNewlinesAndTabs));

输出:

"\nTAB->\t<-TAB\nSecond line \n"

22.5.4 解决方案

遗憾的是,没有好的内置字符串化解决方案始终有效。在本节中,我们将探讨一个适用于所有简单用例的短函数,以及更复杂用例的解决方案。

22.5.4.1 简单解决方案:自定义 toString() 函数

字符串化的简单解决方案会是什么样子?

JSON.stringify() 对于许多数据都工作得很好,特别是普通对象和数组。如果它无法将给定的值字符串化,则返回 undefined 而不是字符串——除非该值是 bigint。然后它抛出异常。

因此,我们可以使用以下函数进行字符串化:

function toString(v) {
  if (typeof v === 'bigint') {
    return v + 'n';
  }
  return JSON.stringify(v) ?? String(v); // (A)
}

对于不支持 JSON.stringify() 的值,我们使用 String() 作为后备(行 A)。该函数只对以下两个值抛出异常——这两个值都由 JSON.stringify() 良好处理:

  • {__proto__:null}

  • Symbol.prototype

下表总结了 toString() 的结果:

toString()
undefined 'undefined'
null 'null'
true 'true'
123 '123'
123n '123n'
'abc' '"abc"'
Symbol() 'Symbol()'
{a:1} '{"a":1}'
['a'] '["a"]'
() => {} '() => {}'
{__proto__:null} '{}'
Symbol.prototype '{}'
22.5.4.2 用于字符串化的库
  • Sindre Sorhus 的库 stringify-object:“像 JSON.stringify 一样字符串化对象/数组,只是没有所有的双引号”
22.5.4.3 Node.js 的字符串化值函数

Node.js 有几个内置函数,提供了将 JavaScript 值转换为字符串的复杂支持——例如:

  • util.inspect(obj) “返回 obj 的字符串表示形式,该表示形式旨在用于调试”。

  • util.format(format, ...args) “使用第一个参数作为类似于 printf 的格式字符串返回格式化的字符串,该字符串可以包含零个或多个格式说明符”。

这些函数甚至可以处理循环数据:

import * as util from 'node:util';

const cycle = {};
cycle.prop = cycle;
assert.equal(
  util.inspect(cycle),
  '<ref *1> { prop: [Circular *1] }'
);

22.5.4.4 字符串化的替代方案:将数据记录到控制台

控制台方法,如 console.log(),通常会产生良好的输出,并且限制很少:

console.log({__proto__: null, prop: Symbol()});

输出:

[Object: null prototype] { prop: Symbol() }

然而,默认情况下,它们只显示到一定深度的对象:

console.log({a: {b: {c: {d: true}}}});

输出:

{ a: { b: { c: [Object] } } }

Node.js 允许我们为 console.dir() 指定深度——null 表示无限:

console.dir({a: {b: {c: {d: true}}}}, {depth: null});

输出:

{
  a: { b: { c: { d: true } } }
}

在浏览器中,console.dir() 没有选项对象,但允许我们交互式地逐步深入到对象中。

22.5.5 自定义对象转换为字符串的方式

22.5.5.1 自定义对象的字符串转换

我们可以通过实现方法 .toString() 来自定义对象字符串化的内置方式:

const helloObj = {
  toString() {
 return 'Hello!';
 }
};
assert.equal(
 String(helloObj), 'Hello!'
);

22.5.5.2 自定义转换为 JSON

我们可以通过实现方法 .toJSON() 来自定义对象转换为 JSON 的方式:

const point = {
  x: 1,
  y: 2,
  toJSON() {
 return [this.x, this.y];
 }
}
assert.equal(
 JSON.stringify(point), '[1,2]'
);

22.6 比较字符串

字符串可以通过以下运算符进行比较:

< <= > >=

有一个重要的注意事项需要考虑:这些运算符是根据 JavaScript 字符的数值进行比较的。这意味着 JavaScript 用于字符串的顺序与字典和电话簿中使用的顺序不同:

> 'A' < 'B' // ok
true
> 'a' < 'B' // not ok
false
> 'ä' < 'b' // not ok
false

正确比较文本超出了本书的范围。它通过 ECMAScript 国际化 API (Intl) 来支持。

22.7 文本原子:代码点、JavaScript 字符、图形群组

快速回顾 “Unicode - 简要介绍(高级)”(§21):

  • 代码点 是 Unicode 文本的原子部分。每个代码点的大小为 21 位。

  • 每个 JavaScript 字符都是一个 UTF-16 代码单元(16 位)。我们需要一个或两个代码单元来编码一个代码点。大多数代码点可以适应一个代码单元。

  • 图形群组用户感知字符)代表书写符号,如屏幕或纸张上显示的。编码单个图形群组需要一个或多个代码点。大多数图形群组只有一个代码点长。

以下代码演示了一个代码点由一个或两个 JavaScript 字符组成。我们通过 .length 来计算后者:

// 3 code points, 3 JavaScript characters:
assert.equal('abc'.length, 3);

// 1 code point, 2 JavaScript characters:
assert.equal('🙂'.length, 2);

以下表格总结了我们刚刚探讨的概念:

实体 大小 编码方式
JavaScript 字符(UTF-16 代码单元) 16 位
Unicode 代码点 21 位 1–2 代码单元
Unicode 图形群组 1+ 代码点

22.7.1 处理代码单元(JavaScript 字符)

字符串的索引和长度基于 JavaScript 字符——它们是 UTF-16 代码单元。

22.7.1.1 访问代码单元

代码单元的访问方式类似于数组元素:

> const str = 'αβγ';
> str.length
3
> str[0]
'α'

str.split('') 按代码单元分割:

> str.split('')
[ 'α', 'β', 'γ' ]
> 'A🙂'.split('')
[ 'A', '\uD83D', '\uDE42' ]

表情符号 🙂 由两个代码单元组成。

22.7.1.2 转义代码单元

要以十六进制形式指定代码单元,我们可以使用恰好四个十六进制数字的 Unicode 代码单元转义

> '\u03B1\u03B2\u03B3'
'αβγ'

ASCII 转义: 如果字符的代码点小于 256,我们可以通过恰好两个十六进制数字的 ASCII 转义 来引用它:

> 'He\x6C\x6Co'
'Hello'

图标“详情”ASCII 转义的官方名称:十六进制转义序列

这是第一个使用十六进制数的转义。

22.7.1.3 转换代码单元为数字(字符码)

要获取字符的字符码,我们可以使用 .charCodeAt()

> 'α'.charCodeAt(0).toString(16)
'3b1'

String.fromCharCode() 将字符码转换为字符串:

> String.fromCharCode(0x3B1)
'α'

22.7.2 处理代码点

22.7.2.1 访问代码点

迭代(在本书的后面描述)将字符串拆分为代码点:

const codePoints = 'A🙂';
for (const codePoint of codePoints) {
  console.log(codePoint + ' ' + codePoint.length);
}

输出:

A 1
🙂 2

Array.from() 使用迭代:

> Array.from('A🙂')
[ 'A', '🙂' ]

因此,这是我们在字符串中计算代码点数量的方法:

> Array.from('A🙂').length
2
> 'A🙂'.length
3

22.7.2.2   代码点的转义

Unicode 代码点转义 允许我们以十六进制形式(1–5 位数字)指定代码点。它产生一个或两个 JavaScript 字符。

> '\u{1F642}'
'🙂'

22.7.2.3   将字符串中的代码点转换为数字

.codePointAt() 返回 1–2 个 JavaScript 字符序列的代码点数字:

> '🙂'.codePointAt(0).toString(16)
'1f642'

String.fromCodePoint() 将代码点数字转换为 1–2 个 JavaScript 字符:

> String.fromCodePoint(0x1F642)
'🙂'

22.7.2.4   代码点的正则表达式

如果我们在正则表达式中使用标志 /v,它将更好地支持 Unicode 并匹配代码点而不是代码单元:

> '🙂'.match(/./g)
[ '\uD83D', '\uDE42' ]
> '🙂'.match(/./gv)
[ '🙂' ]

更多信息:“标志 /v: 对多代码点图形簇的有限支持 (ES2024)” (§46.11.4)。

22.7.3   与图形簇一起工作

22.7.3.1   访问图形簇

这是一个由 3 个代码点组成的图形簇:

const graphemeCluster = '😵‍💫';
assert.deepEqual(
  // Iteration splits into code points
  Array.from(graphemeCluster),
  ['😵', '\u200D', '💫']
);
assert.equal(
  // 5 JavaScript characters
  '😵‍💫'.length, 5
);

要将字符串拆分为图形簇,我们可以使用 Intl.Segmenter —— 这是一个不属于 ECMAScript 本身的类,而是 ECMAScript 国际化 API 的一部分。它被大多数 JavaScript 平台支持。以下是使用它的方法:

const segmenter = new Intl.Segmenter('en-US', { granularity: 'grapheme' });
assert.deepEqual(
  Array.from(segmenter.segment('A🙂😵‍💫')),
  [
    { segment: 'A', index: 0, input: 'A🙂😵‍💫' },
    { segment: '🙂', index: 1, input: 'A🙂😵‍💫' },
    { segment: '😵‍💫', index: 3, input: 'A🙂😵‍💫' },
  ]
);

.segmenter() 返回一个段对象的可迭代对象。我们可以通过 for-ofArray.from()Iterator.from() 等方式使用它。

22.7.3.2   图形簇的正则表达式

正则表达式标志 /v 为图形簇提供了一些有限的支持——例如,我们可以匹配具有可能多个代码点的表情符号,如下所示:

> 'A🙂😵‍💫'.match(/\p{RGI_Emoji}/gv)
[ '🙂', '😵‍💫' ]

更多信息:“标志 /v: 对多代码点图形簇的有限支持 (ES2024)” (§46.11.4)。

22.8   快速参考:字符串

22.8.1   转换为字符串

表 22.1 描述了如何将各种值转换为字符串。

x String(x)
undefined 'undefined'
null 'null'
布尔值 false'false'true'true'
数字 示例:123'123'
大整数 示例:123n'123'
字符串 x (输入,未更改)
符号 示例:Symbol('abc')'Symbol(abc)'
对象 可通过例如 toString() 配置

表 22.1:将值转换为字符串。

22.8.2   文本原子的数值

  • 字符码:表示 JavaScript 字符的数字。JavaScript 对 Unicode 代码单元 的称呼。

    • 大小:16 位,无符号

    • 将数字转换为字符串:String.fromCharCode()^(ES1)

    • 将字符串转换为数字:字符串方法.charCodeAt()^(ES1)

  • 码点:表示 Unicode 文本原子部分的数字。

    • 大小:21 位,无符号(17 平面,每个 16 位)

    • 将数字转换为字符串:String.fromCodePoint()^(ES6)

    • 将字符串转换为数字:字符串方法.codePointAt()^(ES6)

22.8.3 String.prototype.*: 正则表达式方法

以下方法列于正则表达式快速参考中:

  • String.prototype.match()

  • String.prototype.matchAll()

  • String.prototype.replace()

  • String.prototype.replaceAll()

  • String.prototype.search()

  • String.prototype.split()

22.8.4 String.prototype.*: 查找和匹配

  • String.prototype.startsWith(searchString, startPos=0) ES6

    如果searchString在字符串中从startPos位置出现,则返回true。否则返回false

    > '.gitignore'.startsWith('.')
    true
    > 'abcde'.startsWith('bc', 1)
    true
    
    
  • String.prototype.endsWith(searchString, endPos=this.length) ES6

    如果字符串长度为endPos时以searchString结尾,则返回true。否则返回false

    > 'poem.txt'.endsWith('.txt')
    true
    > 'abcde'.endsWith('cd', 4)
    true
    
    
  • String.prototype.includes(searchString, startPos=0) ES6

    如果字符串包含searchString,则返回true,否则返回false。搜索从startPos开始。

    > 'abc'.includes('b')
    true
    > 'abc'.includes('b', 2)
    false
    
    
  • String.prototype.indexOf(searchString, minIndex=0) ES1

    • 如果searchString出现在minIndex或之后:返回它被找到的最低索引。否则:返回-1
    > 'aaax'.indexOf('aa', 0)
    0
    > 'aaax'.indexOf('aa', 1)
    1
    > 'aaax'.indexOf('aa', 2)
    -1
    
    
  • String.prototype.lastIndexOf(searchString, maxIndex?) ES1

    • 如果searchString出现在maxIndex或之前:返回它被找到的最高索引。否则:返回-1

    • 如果缺少maxIndex,搜索从this.length - searchString.length开始(假设searchStringthis短)。

    > 'xaaa'.lastIndexOf('aa', 3)
    2
    > 'xaaa'.lastIndexOf('aa', 2)
    2
    > 'xaaa'.lastIndexOf('aa', 1)
    1
    > 'xaaa'.lastIndexOf('aa', 0)
    -1
    
    

22.8.5 String.prototype.*: 提取

  • String.prototype.slice(start=0, end=this.length) ES3

    返回从(包括)索引start开始到(不包括)索引end的字符串子串。如果索引是负数,在它被使用之前会加到.length上(-1变成this.length-1等)。

    > 'abc'.slice(1, 3)
    'bc'
    > 'abc'.slice(1)
    'bc'
    > 'abc'.slice(-2)
    'bc'
    
    
  • String.prototype.at(index: number) ES2022

    • 返回index位置的 JavaScript 字符作为字符串。

    • 如果索引超出范围,则返回undefined

    • 如果index是负数,在它被使用之前会加到.length上(-1变成this.length-1等)。

    > 'abc'.at(0)
    'a'
    > 'abc'.at(-1)
    'c'
    
    
  • String.prototype.substring(start, end=this.length) ES1

    使用.slice()代替此方法。.substring()在旧引擎中实现不一致,并且不支持负索引。

22.8.6 String.prototype.*: 组合

  • String.prototype.concat(...strings) ES3

    返回字符串和strings的连接。'a'.concat('b')等价于'a'+'b'。后者更为流行。

    > 'ab'.concat('cd', 'ef', 'gh')
    'abcdefgh'
    
    
  • String.prototype.padEnd(len, fillString=' ') ES2017

    在字符串后面添加(fillString 的片段)直到它达到所需的长度 len。如果它已经具有或超过 len,则返回而不做任何更改。

    > '#'.padEnd(2)
    '# '
    > 'abc'.padEnd(2)
    'abc'
    > '#'.padEnd(5, 'abc')
    '#abca'
    
    
  • String.prototype.padStart(len, fillString=' ') ES2017

    在字符串前面添加(fillString 的片段)直到它达到所需的长度 len。如果它已经具有或超过 len,则返回而不做任何更改。

    > '#'.padStart(2)
    ' #'
    > 'abc'.padStart(2)
    'abc'
    > '#'.padStart(5, 'abc')
    'abca#'
    
    
  • String.prototype.repeat(count=0) ES6

    返回重复 count 次的字符串。

    > '*'.repeat()
    ''
    > '*'.repeat(3)
    '***'
    
    

22.8.7 String.prototype.*: 转换

  • String.prototype.toUpperCase() ES1

    返回一个副本,其中所有小写字母字符都被转换为大写字母。这对于各种字母表的效果取决于 JavaScript 引擎。

    > '-a2b-'.toUpperCase()
    '-A2B-'
    > 'αβγ'.toUpperCase()
    'ΑΒΓ'
    
    
  • String.prototype.toLowerCase() ES1

    返回一个副本,其中所有大写字母字符都被转换为小写字母。这对于各种字母表的效果取决于 JavaScript 引擎。

    > '-A2B-'.toLowerCase()
    '-a2b-'
    > 'ΑΒΓ'.toLowerCase()
    'αβγ'
    
    
  • String.prototype.trim() ES5

    返回一个没有前导和尾随空白(空格、制表符、行终止符等)的字符串副本。

    > '\r\n#\t  '.trim()
    '#'
    > '  abc  '.trim()
    'abc'
    
    
  • String.prototype.trimStart() ES2019

    .trim() 类似,但仅删除字符串的开头:

    > '  abc  '.trimStart()
    'abc  '
    
    
  • String.prototype.trimEnd() ES2019

    .trim() 类似,但仅删除字符串的末尾:

    > '  abc  '.trimEnd()
    '  abc'
    
    
  • String.prototype.normalize(form = 'NFC') ES6

    • 根据 Unicode 规范化形式规范化字符串。Unicode 规范化形式

    • form 的值:'NFC', 'NFD', 'NFKC', 'NFKD'

  • String.prototype.isWellFormed() ES2024

    如果字符串格式不正确且包含 lone surrogates(有关更多信息,请参阅.toWellFormed()),则返回 true。否则,返回 false

    > '🙂'.split('') // split into code units
    [ '\uD83D', '\uDE42' ]
    > '\uD83D\uDE42'.isWellFormed()
    true
    > '\uD83D\uDE42\uD83D'.isWellFormed() // lone surrogate 0xD83D
    false
    
    
  • String.prototype.toWellFormed() ES2024

    每个 JavaScript 字符串字符都是一个 UTF-16 代码单元。一个代码点被编码为一个或两个 UTF-16 代码单元。在后一种情况下,这两个代码单元被称为 leading surrogatetrailing surrogate。没有其配对的代理字符被称为 lone surrogate。包含一个或多个单独代理字符的字符串是 ill-formed

    .toWellFormed() 将一个格式不正确的字符串转换为格式正确的字符串,通过将每个单独的代理字符替换为代码点 0xFFFD(“替换字符”)。该字符通常显示为 �(一个带有白色问号的黑色菱形)。它位于字符的 Specials Unicode 块中,位于 Basic Multilingual Plane 的末尾。这是维基百科关于替换字符的说明: “它用于指示当系统无法将数据流渲染为正确的符号时出现的问题。”

    assert.deepEqual(
      '🙂'.split(''), // split into code units
      ['\uD83D', '\uDE42']
    );
    assert.deepEqual(
       // 0xD83D is a lone surrogate
      '\uD83D\uDE42\uD83D'.toWellFormed().split(''),
      ['\uD83D', '\uDE42', '\uFFFD']
    );
    
    

22.8.8 本快速参考的来源

“练习”图标练习:使用字符串方法

exercises/strings/remove_extension_test.mjs

23 使用模板字面量和标记模板 ES6

原文:exploringjs.com/js/book/ch_template-literals.html

  1. 23.1 消除歧义:“模板”

  2. 23.2 模板字面量

  3. 23.3 标记模板

    1. 23.3.1 烹饪与原始模板字符串(高级)
  4. 23.4 标记模板的示例(通过库提供)

    1. 23.4.1 标记函数库:lit-html

    2. 23.4.2 标记函数库:regex

    3. 23.4.3 标记函数库:graphql-tag

  5. 23.5 通过模板标签String.raw的原始字符串字面量

  6. 23.6 多行模板字面量和缩进

    1. 23.6.1 修复:缩进文本并通过模板标签移除缩进

    2. 23.6.2 修复:不缩进文本并通过.trim()移除前导和尾随空白

  7. 23.7 通过模板字面量进行简单模板化(高级)

    1. 23.7.1 一个更复杂的例子

    2. 23.7.2 简单的 HTML 转义

在我们深入探讨“模板字面量”和“标记模板”这两个特性之前,让我们首先考察一下“模板”一词的多种含义。

23.1 消除歧义:“模板”

尽管这三个名称中都有“模板”,并且它们看起来很相似,但以下三者在本质上却有着显著的不同:

  • 文本模板是从数据到文本的函数。它在 Web 开发中经常被使用,通常通过文本文件定义。例如,以下文本定义了一个用于库Handlebars的模板:

    <div class="entry">
      <h1>{{title}}</h1>
      <div class="body">
        {{body}}
      </div>
    </div>
    
    

    此模板有两个空白需要填写:titlebody。它被这样使用:

    // First step: retrieve the template text, e.g. from a text file.
    const tmplFunc = Handlebars.compile(TMPL_TEXT); // compile string
    const data = {title: 'My page', body: 'Welcome to my page!'};
    const html = tmplFunc(data);
    
    
  • 模板字面量类似于字符串字面量,但具有额外的功能——例如,插值。它由反引号分隔:

    const num = 5;
    assert.equal(`Count: ${num}!`, 'Count: 5!');
    
    
  • 从语法上讲,一个标记模板是一个跟随函数(或者更确切地说,是一个评估为函数的表达式)的模板字面量。这会导致函数被调用。它的参数来自模板字面量的内容。

    const getArgs = (...args) => args;
    assert.deepEqual(
      getArgs`Count: ${5}!`,
      [['Count: ', '!'], 5] );
    
    

    注意,getArgs()接收来自字面量的文本以及通过${}插值的数据。

23.2 模板字面量

与普通字符串字面量相比,模板字面量有两个新特性。

首先,它支持字符串插值:如果我们把一个动态计算出的值放在${}中,它会被转换为字符串并插入到字面量返回的字符串中。

const MAX = 100;
function doSomeWork(x) {
  if (x > MAX) {
    throw new Error(`At most ${MAX} allowed: ${x}!`);
  }
  // ···
}
assert.throws(
  () => doSomeWork(101),
  {message: 'At most 100 allowed: 101!'});

第二,模板字面量可以跨越多行:

const str = `this is
a text with
multiple lines`;

模板字面量总是产生字符串。

23.3 标记模板

行 A 中的表达式是一个标记模板。它相当于调用 tagFunc() 并使用行 A 以下显示的参数。

function tagFunc(templateStrings, ...substitutions) {
  return {templateStrings, substitutions};
}

const setting = 'dark mode';
const value = true;

assert.deepEqual(
  tagFunc`Setting ${setting} is ${value}!`, // (A)
  {
    templateStrings: ['Setting ', ' is ', '!'],
    substitutions: ['dark mode', true],
  }
  // tagFunc(['Setting ', ' is ', '!'], 'dark mode', true)
);

在第一个反引号之前调用的函数 tagFunc 被称为标记函数。它的参数是:

  • 模板字符串(第一个参数):一个包含围绕插值 ${} 的文本片段的数组。

    • 在示例中:['Setting ', ' is ', '!']
  • 替换(剩余参数):插值值。

    • 在示例中:'dark mode'true

文字(字面量)的静态(固定)部分(模板字符串)与动态部分(替换)是分开的。

标记函数可以返回任意值。

23.3.1 烹饪与原始模板字符串(高级)

到目前为止,我们只看到了模板字符串的烹饪解释。但实际上,标记函数实际上得到两种解释:

  • 一个烹饪解释,其中反斜杠具有特殊意义。例如,\t 产生一个制表符字符。这种解释的模板字符串存储在第一个参数(一个数组)中。

  • 一个原始解释,其中反斜杠没有特殊意义。例如,\t 产生两个字符——一个反斜杠和一个 t。这种解释的模板字符串存储在第一个参数(一个数组)的 .raw 属性中。

原始解释通过 String.raw (稍后描述) 和类似的应用程序启用原始字符串字面量。

以下标记函数 cookedRaw 使用了两种解释:

function cookedRaw(templateStrings, ...substitutions) {
  return {
    cooked: Array.from(templateStrings), // copy only Array elements
    raw: templateStrings.raw,
    substitutions,
  };
}
assert.deepEqual(
  cookedRaw`\tab${'subst'}\newline\\`,
  {
    cooked: ['\tab', '\newline\\'],
    raw:    ['\\tab', '\\newline\\\\'],
    substitutions: ['subst'],
  });

我们还可以在标记模板中使用 Unicode 代码点转义(\u{1F642})、Unicode 代码单元转义(\u03A9)和 ASCII 转义(\x52):

assert.deepEqual(
  cookedRaw`\u{54}\u0065\x78t`,
  {
    cooked: ['Text'],
    raw:    ['\\u{54}\\u0065\\x78t'],
    substitutions: [],
  });

如果其中一个转义的语法不正确,相应的烹饪模板字符串是 undefined,而原始版本仍然是字面量:

assert.deepEqual(
  cookedRaw`\uu\xx ${1} after`,
  {
    cooked: [undefined, ' after'],
    raw:    ['\\uu\\xx ', ' after'],
    substitutions: [1],
  });

不正确的转义会在模板字面量和字符串字面量中产生语法错误。在 ES2018 之前,它们甚至在标记模板中产生错误。为什么会有这样的改变?现在我们可以使用标记模板来处理之前非法的文本——例如:

windowsPath`C:\uuu\xxx\111`
latex`\unicode`

23.4 标记模板的示例(通过库提供)

标记模板非常适合支持小型嵌入式语言(所谓的领域特定语言)。我们将继续使用一些示例。

23.4.1 标记函数库:lit-html

Lit 是一个用于构建网页组件的库,它使用标记模板进行 HTML 模板化:

@customElement('my-element')
class MyElement extends LitElement {

  // ···

  render() {
 return html`
 <ul>
 ${repeat(
 this.items,
 (item) => item.id,
 (item, index) => html`<li>${index}: ${item.name}</li>`
 )}
 </ul>
 `;
 }
}

repeat() 是一个用于循环的自定义函数。它的第二个参数为第三个参数返回的值生成唯一的键。注意该参数使用的嵌套标记模板。

23.4.2 标记函数库:正则表达式

由 Steven Levithan 编写的“regex”库(https://github.com/slevithan/regex)提供了模板标签,有助于创建正则表达式并启用高级功能。以下示例演示了它是如何工作的:

import {regex, pattern} from 'regex';

const RE_YEAR = pattern`(?<year>[0-9]{4})`;
const RE_MONTH = pattern`(?<month>[0-9]{2})`;
const RE_DAY = pattern`(?<day>[0-9]{2})`;
const RE_DATE = regex('g')`
 ${RE_YEAR} # 4 digits
 -
 ${RE_MONTH} # 2 digits
 -
 ${RE_DAY} # 2 digits
`;

const match = RE_DATE.exec('2017-01-27');
assert.equal(match.groups.year, '2017');

默认启用的以下标志:

  • 标记 /v

  • 标记 /x(模拟)通过 # 启用不重要的空白和行注释。

  • 标记 /n(模拟)启用仅命名捕获模式,这阻止了分组元字符 (···) 的捕获。

23.4.3 标签函数库:graphql-tag

graphql-tag 库让我们可以通过标签模板创建 GraphQL 查询:

import gql from 'graphql-tag';

const query = gql`
 {
 user(id: 5) {
 firstName
 lastName
 }
 }
 `;

此外,还有插件可以在 Babel、TypeScript 等中预编译此类查询。

23.5 通过模板标签 String.raw 的原始字符串字面量

原始字符串字面量通过标签函数 String.raw 实现。它们是字符串字面量,其中反斜杠不执行任何特殊操作(例如转义字符等):

assert.equal(
  String.raw`\back`,
  '\\back'
);

这有助于数据包含反斜杠时——例如,包含正则表达式的字符串:

const regex1 = /^\./;
const regex2 = new RegExp('^\\.');
const regex3 = new RegExp(String.raw`^\.`);

所有三个正则表达式都是等效的。使用普通字符串字面量时,我们必须写两次反斜杠,以转义该字面量。使用原始字符串字面量时,我们不必这样做。

原始字符串字面量也用于指定 Windows 文件名路径:

const WIN_PATH = String.raw`C:\Users\Robin\Documents`;
assert.equal(
  WIN_PATH, 'C:\\Users\\Robin\\Documents'
);

23.6 多行模板字面量和缩进

如果我们在模板字面量中放置多行文本,两个目标就产生了冲突:一方面,模板字面量应该缩进以适应源代码。另一方面,其内容行应该从最左侧列开始。

例如:

function div(text) {
  return `
 <div>
 ${text}
 </div>
 `;
}
console.log('Output:');
console.log(
  div('Hello!')
  // Replace spaces with mid-dots:
  .replace(/ /g, '·')
  // Replace \n with #\n:
  .replace(/\n/g, '#\n')
);

由于缩进,模板字面量很好地融入了源代码。然而,输出也是缩进的。我们不想在开头有返回,在结尾有返回加两个空格。

Output:
#
····<div>#
······Hello!#
····</div>#
··

有两种方法可以修复这个问题:通过标签模板或通过修剪模板字面量的结果。

23.6.1 修复:通过模板标签缩进文本并移除缩进

第一个修复是使用自定义模板标签来删除不需要的空白。它使用初始换行符之后的第一行来确定文本开始的列,并在所有地方缩短缩进。它还删除了非常开始的换行符和非常结束的缩进。这样的模板标签之一是 Desmond Brand 的 dedent

import dedent from 'dedent';
function divDedented(text) {
  return dedent`
 <div>
 ${text}
 </div>
 `;
}
console.log('Output:');
console.log(divDedented('Hello!'));

输出没有缩进:

Output:
<div>
  Hello!
</div>

23.6.2 修复:不缩进文本并通过 .trim() 移除前导和尾随空白

第二个修复更快,但也更脏:

function divDedented(text) {
  return `
<div>
 ${text}
</div>
 `.trim();
}
console.log('Output:');
console.log(divDedented('Hello!'));

字符串方法.trim()移除了开头和结尾的冗余空白字符,但内容本身不能缩进 - 它必须从最左侧列开始。这种解决方案的优势是我们不需要自定义标签函数。缺点是未缩进的文本与周围环境不太搭配。

输出与dedent相同:

Output:
<div>
  Hello!
</div>

23.7 通过模板字面量进行简单模板化(高级)

虽然模板字面量看起来像文本模板,但它们用于(文本)模板化的方法并不立即明显:文本模板从对象中获取数据,而模板字面量从变量中获取数据。解决方案是在一个函数体中使用模板字面量,该函数的参数接收模板数据 - 例如:

const tmpl = (data) => `Hello ${data.name}!`;
assert.equal(tmpl({name: 'Jane'}), 'Hello Jane!');

23.7.1 更复杂的示例

作为更复杂的示例,我们希望从一个地址数组中生成一个 HTML 表格。这是数组:

const addresses = [
  { first: '<Jane>', last: 'Bond' },
  { first: 'Lars', last: '<Croft>' },
];

生成 HTML 表格的函数tmpl()看起来如下:

const tmpl = (addrs) => `
<table>
 ${addrs.map(
 (addr) => `
 <tr>
 <td>${escapeHtml(addr.first)}</td>
 <td>${escapeHtml(addr.last)}</td>
 </tr>
 `.trim()
 ).join('')}
</table>
`.trim();

此代码包含两个模板函数:

  • 第一个(第 1 行)接受addrs,一个包含地址的数组,并返回一个包含表格的字符串。

  • 第二个(第 4 行)接受addr,一个包含地址的对象,并返回一个包含表格行的字符串。注意最后的.trim(),它移除了不必要的空白字符。

第一个模板函数通过将表格元素包裹在一个它连接成字符串的数组周围来生成结果(第 10 行)。这个数组是通过将第二个模板函数映射到addrs(第 3 行)的每个元素上产生的。因此,它包含带有表格行的字符串。

辅助函数escapeHtml()用于转义特殊的 HTML 字符(第 6 行和第 7 行)。其实现将在下一小节中展示。

让我们用地址调用tmpl()并记录结果:

console.log(tmpl(addresses));

输出是:

<table>
  <tr>
        <td>&lt;Jane&gt;</td>
        <td>Bond</td>
      </tr><tr>
        <td>Lars</td>
        <td>&lt;Croft&gt;</td>
      </tr>
</table>

23.7.2 简单的 HTML 转义

以下函数用于转义纯文本,以便在 HTML 中以纯文本形式显示:

function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;') // first!
    .replace(/>/g, '&gt;')
    .replace(/</g, '&lt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/`/g, '&#96;')
    ;
}
assert.equal(
  escapeHtml('Rock & Roll'), 'Rock &amp; Roll');
assert.equal(
  escapeHtml('<blank>'), '&lt;blank&gt;');

“练习”图标练习:HTML 模板化

练习(带额外挑战):exercises/template-literals/templating_test.mjs

posted @ 2025-12-12 18:01  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报