# 字符串方法

Source: https://codewiki.com/zh/javascript/string-methods/

> - **what**: JavaScript 字符串是不可变的 UTF-16 码元序列；字符串方法负责搜索、提取、转换、比较或拆分这些序列。
> - **trap**: `length`、`slice()` 和多数位置参数按 UTF-16 码元计数，不等同于用户看到的字符；`replace()` 默认也只替换第一个字符串匹配项。
> - **fix**: 先明确任务需要码元、Unicode 码点还是字素簇，再选择方法；对用户可见文本使用 `Intl.Segmenter`，对区域化比较使用 `Intl.Collator`。

## 是什么，为什么存在

JavaScript 的字符串表示文本值。字符串原始值不可变：方法可以读取它，也可以返回转换后的新字符串，却不能改写原字符串中的某个位置。赋值只是让变量指向另一个字符串，不是修改原值。

字符串方法把常见文本操作组织在 `String.prototype` 上。你会用 `includes()` 判断固定片段，用 `slice()` 提取半开区间，用 `replace()` 或 `replaceAll()` 改写匹配项，用 `trim()` 清理边界空白，也会用大小写、规范化和区域比较方法处理国际化文本。

这些方法解决的是不同层次的问题。固定分隔符适合 `indexOf()` 与 `slice()`，模式语言属于正则表达式，语法结构则需要专用解析器。`split(',')` 不是 CSV 解析器，正则替换也不是 HTML 清理器。

字符串的底层计数单位会直接影响结果。JavaScript 按UTF-16 码元（UTF-16 code unit）保存和索引字符串；一个Unicode 码点（Unicode code point）可能占一个或两个码元，而用户看到的一个符号还可能由多个码点组成。

因此，“取前十个字符”不是完整需求。协议字段若按码元限制，可以直接使用 `length` 与 `slice()`；用户界面若按可见符号截断，就需要按字素簇（grapheme cluster）分段。先写清计数单位，方法选择才有确定答案。

## 工作原理

调用 `text.toUpperCase()` 时，JavaScript 读取 `text` 的值并执行对应方法。转换类方法返回新字符串，查询类方法可能返回布尔值、索引、数组或迭代器。旧变量不会自动接收结果，所以需要显式赋值或把返回值传给下一步。

常用方法可以按结果和匹配模型分类：

| 任务 | 首选方法 | 关键契约 |
| --- | --- | --- |
| 判断固定文本是否存在 | `includes()` | 区分大小写，返回布尔值 |
| 查找固定文本位置 | `indexOf()`、`lastIndexOf()` | 未找到时返回 `-1` |
| 判断前缀或后缀 | `startsWith()`、`endsWith()` | 可接收位置或长度参数 |
| 提取区间 | `slice()` | 结束位置不包含，支持负索引 |
| 按位置取一个码元 | `at()` | 支持负索引，越界返回 `undefined` |
| 按分隔符拆分 | `split()` | 捕获组可能进入结果数组 |
| 替换匹配项 | `replace()`、`replaceAll()` | 字符串查找值分别替换首项或全部 |
| 清理边界空白 | `trim()`、`trimStart()`、`trimEnd()` | 不改变字符串内部空白 |
| 填充到目标长度 | `padStart()`、`padEnd()` | 目标长度按码元计算 |
| 重复字符串 | `repeat()` | 次数无效或结果过大时可能抛错 |
| 规范化 Unicode | `normalize()` | 默认使用 NFC，不负责大小写 |
| 区域化比较 | `localeCompare()`、`Intl.Collator` | 只依赖结果的正负与零 |

### 不可变结果与方法链

方法链把上一步返回值作为下一步接收者。例如，`input.trim().toLowerCase()` 先创建去除边界空白的字符串，再基于该结果转换大小写。链式写法不改变每个步骤的语义，也不保证自动验证中间结果。

并非所有字符串方法都返回字符串。`includes()` 返回布尔值，`indexOf()` 返回数字，`match()` 可能返回数组或 `null`，`matchAll()` 返回迭代器。生成代码若不看返回类型就继续调用字符串方法，失败常出现在真正错误位置之后。

原始字符串可以通过自动装箱调用原型方法，但它不会永久变成 `String` 对象。业务代码通常应使用字符串原始值；`new String("text")` 创建的是对象，其真值判断与严格相等行为更容易造成混淆。

### 索引与半开区间

`slice(start, end)` 返回从 `start` 到 `end` 之前的码元。省略 `end` 就提取到末尾；负参数从长度末端换算。半开区间使 `slice(0, n)` 的结果长度通常就是 `n`，也让相邻区间可以直接写成 `slice(0, cut)` 与 `slice(cut)`。

`substring()` 也使用不包含结束位置的区间，但会把负数当作 `0`，并在起点大于终点时交换两者。除非维护依赖这种交换行为的代码，新代码通常用语义更直接的 `slice()`。历史方法 `substr()` 使用“起点加长度”模型，不应继续生成到新代码中。

位置参数仍然是码元索引。对包含代理对、组合标记或零宽连接符的文本使用任意 `slice()` 边界，可能产生孤立代理项或切开一个可见符号。协议要求与界面要求必须分开处理。

### 固定文本、正则表达式与替换值

固定文本搜索应优先使用 `includes()`、`indexOf()`、`startsWith()` 或 `endsWith()`。需要字符类别、重复或捕获组时再使用 `search()`、`match()`、`matchAll()` 和正则表达式替换。这样可以让匹配语义和失败方式保持可见。

当查找值是字符串时，`replace()` 只处理第一次出现，`replaceAll()` 处理所有不重叠出现。查找值是正则表达式时，是否全局匹配由 `g` 标志决定；把非全局正则表达式传给 `replaceAll()` 会抛出 `TypeError`。

替换字符串中的 `$&`、`$1`、`$`` 与 `$'` 等序列有特殊含义。若替换内容来自数据并且必须原样插入，应传入返回该内容的替换函数。函数返回值不会再次按这些替换标记解释。

## 示例

下面四个示例依次展示不可变清理、固定分隔符解析、字面量替换和字素簇截断。所有输出都来自 Node 24.14.0 实际运行对应文件。

### 1. 清理标签但保留原值

`trim()` 只删除两端空白，正则替换再把内部连续空白折叠成一个空格。函数返回新值，调用前的字符串仍保持不变。

<!-- quick -->

```javascript
// file: clean-label.js
function cleanLabel(value) {
  return value.trim().replace(/\s+/gu, " ");
}

const original = "  Priority\t order  ";
const cleaned = cleanLabel(original);

console.log(JSON.stringify(original));
console.log(JSON.stringify(cleaned));
console.log(original === "  Priority\t order  ");
console.log(cleaned.toUpperCase());
```

```text
"  Priority\t order  "
"Priority order"
true
PRIORITY ORDER
```


<!-- /quick -->

`JSON.stringify()` 让制表符和两端空格在输出中可见。第三行证明 `cleanLabel()` 没有改写 `original`；最后一行又基于清理结果创建大写字符串。

这个清理规则适合“内部任意空白都等价于一个空格”的字段。代码、诗歌、预格式化文本和某些语言内容并不满足该契约，不能无条件套用。

### 2. 用第一个分隔符提取字段

头字段以第一个冒号分隔名称和值。先用 `indexOf()` 定位，再用两个 `slice()` 处理半开区间，可以保留值中后续出现的冒号。

```javascript
// file: parse-header.js
function parseHeader(line) {
  const separator = line.indexOf(":");
  if (separator === -1) {
    throw new SyntaxError("missing colon");
  }

  const name = line.slice(0, separator).trim().toLowerCase();
  const value = line.slice(separator + 1).trim();
  return { name, value };
}

for (const line of [
  "Content-Type: application/json",
  "Location: https://example.test:8443/orders/7",
  "invalid header"
]) {
  try {
    console.log(JSON.stringify(parseHeader(line)));
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}
```

```text
{"name":"content-type","value":"application/json"}
{"name":"location","value":"https://example.test:8443/orders/7"}
SyntaxError: missing colon
```

若直接调用 `line.split(':')` 并解构前两项，URL 的端口部分会丢失。这里的代码只演示一个受控格式；真正的 HTTP 解析仍应交给运行时或协议库，因为字段语法还包含更多规则。

`indexOf()` 返回 `-1` 是必须处理的哨兵值。把它直接传给 `slice()` 会触发负索引语义，可能把格式错误的输入变成看似合理的数据。

### 3. 原样插入替换数据

模板标记是固定字符串，因此 `replaceAll()` 足够。替换值通过函数返回，即使内容包含 `$&`，也会按字面量插入。

```javascript
// file: fill-template.js
function fillTemplate(template, values) {
  let result = template;

  for (const [name, value] of Object.entries(values)) {
    const marker = `{{${name}}}`;
    result = result.replaceAll(marker, () => String(value));
  }

  const unresolved = [...result.matchAll(/\{\{(?<name>[a-z]+)\}\}/gu)]
    .map((match) => match.groups.name);
  if (unresolved.length > 0) {
    throw new Error(`unresolved: ${unresolved.join(", ")}`);
  }
  return result;
}

console.log(fillTemplate("Total: {{amount}}", { amount: "$&5" }));

try {
  console.log(fillTemplate("Hello {{name}} from {{team}}", { name: "Mira" }));
} catch (error) {
  console.log(error.message);
}
```

```text
Total: $&5
unresolved: team
```

如果写成 `replaceAll(marker, String(value))`，`$&` 会展开成匹配到的标记，第一行将错误地保留 `{{amount}}`。替换函数关闭了这层替换字符串语法，但不会让自制模板系统自动具备 HTML 转义、访问控制或表达式求值安全性。

最后的 `matchAll()` 在所有替换结束后报告未解析标记。它使用全局正则表达式，并从命名捕获组读取字段名；若模板语法继续扩展，应改用明确的模板解析器。

### 4. 按字素簇截断界面文本

`Intl.Segmenter` 按 Unicode 文本分段规则识别用户通常看到的符号。这个例子同时比较码元、码点和字素簇数量。

```javascript
// file: graphemes.js
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });

function graphemes(text) {
  return Array.from(segmenter.segment(text), ({ segment }) => segment);
}

function truncateGraphemes(text, limit) {
  const parts = graphemes(text);
  return parts.length <= limit ? text : `${parts.slice(0, limit).join("")}…`;
}

const label = "👨‍👩‍👧‍👦 cafe\u0301";
console.log(label.length);
console.log([...label].length);
console.log(graphemes(label).length);
console.log(JSON.stringify(graphemes(label)));
console.log(truncateGraphemes(label, 3));
```

```text
17
13
6
["👨‍👩‍👧‍👦"," ","c","a","f","é"]
👨‍👩‍👧‍👦 c…
```

家庭表情由多个码点和零宽连接符组成，末尾的 `é` 也由字母与组合标记组成。扩展运算符按码点迭代，仍会拆开这两类字素簇；分段器把它们各自作为一个界面符号。

这里的 `"en"` 是明确的区域设置（locale）参数。字素边界大多不依赖语言，但显式区域设置让行为和调用意图更清楚；单词或句子分段对语言更敏感。

## 陷阱

> **陷阱:** **忽略字符串不可变性。** `name.trim()` 或 `name.toLowerCase()` 不会改写 `name`，生成代码常调用方法后继续使用旧值。
>
> **修复方法：** 保存返回值，例如 `const normalized = name.trim()`。审查方法链时，跟踪每一步的接收者、返回类型和最终使用位置。

> **陷阱:** **把 `length` 当成可见字符数。** 表情、部分历史文字和组合序列会让码元数、码点数与字素簇数不同，任意 `slice()` 还可能切开代理对。
>
> **修复方法：** 协议限制应写明计数单位；按码点遍历使用字符串迭代器，按界面符号截断使用 `Intl.Segmenter`。测试代理对、组合标记与零宽连接序列。

> **陷阱:** **混淆 `replace()` 与全部替换。** 字符串查找值配合 `replace()` 只替换首项，而 `replaceAll()` 接收正则表达式时又要求 `g` 标志。
>
> **修复方法：** 从“首项还是全部”与“固定文本还是模式”两个维度选择 API。为零项、一项和多项匹配分别写测试，并让替换数据通过函数返回。

> **陷阱:** **用简单分割或正则解析完整语法。** `split(',')` 无法处理 CSV 引号字段，正则删除标签无法正确处理 HTML 解析规则，手写 URL 拆分也容易遗漏编码与相对地址。
>
> **修复方法：** 字符串方法只处理确实由固定分隔符定义的小格式。CSV、HTML、URL 和其他正式语法使用对应解析器，并在解析后验证业务约束。

> **陷阱:** **用大小写转换实现所有不区分大小写的比较。** `toLowerCase()` 不表达语言排序规则，也不能自动解决规范等价、标识符安全或所有多字符大小写映射。
>
> **修复方法：** 用户可见搜索与排序使用配置明确的 `Intl.Collator`；协议标识符遵守协议自己的 ASCII 或规范化规则。不要把展示层区域规则用于授权键。

> **陷阱:** **依赖 `localeCompare()` 恰好返回 `-1` 或 `1`。** 契约只保证负数、正数或零，具体幅度不应进入控制流。
>
> **修复方法：** 比较结果使用 `< 0`、`> 0` 与 `=== 0`。同一配置需要大量比较时复用 `Intl.Collator` 的 `compare`，并用目标区域的数据验证排序。

<!-- deep -->

## 文本边界不止一种

### 码元、码点与字素簇

ECMAScript 字符串的 `length` 是 UTF-16 码元数。方括号访问、`at()`、`charAt()`、`charCodeAt()` 和切片位置也建立在码元索引上。补充平面码点由一对代理码元表示，因此 `"😀".length` 是 `2`。

字符串迭代器、扩展运算符和 `Array.from(string)` 识别格式正确的代理对，按码点产生字符串片段。它们能避免把单个补充平面码点拆成两半，但组合标记、表情修饰符、地区旗帜和零宽连接序列仍可能由多个结果组成。

字素簇更接近用户感知的“一个字符”。`Intl.Segmenter` 的 `granularity: "grapheme"` 按 Unicode 分段规则给出边界，但它并不改变原字符串，也不声明每个簇具有相同显示宽度。终端列宽和字体排版仍是另外的问题。

| 操作 | 计数单位 | 适合的需求 |
| --- | --- | --- |
| `text.length` | UTF-16 码元 | JavaScript API 的原生长度契约 |
| `text.slice(a, b)` | UTF-16 码元 | 已定义为码元偏移的协议区间 |
| `[...text]` | Unicode 码点 | 不拆开格式正确的代理对的遍历 |
| `Intl.Segmenter` 字素模式 | 字素簇 | 用户可见符号的选择与截断 |
| Canvas 或排版 API | 渲染度量 | 像素宽度、换行与布局 |

`codePointAt(index)` 从码元位置读取码点。若 `index` 指向代理对的前半部分，它返回完整码点；若指向后半部分，它只返回该后半代理码元的数值。因此它解决的是读取表示，不是自动提供码点索引。

`String.fromCodePoint()` 按码点数值构造字符串，`String.fromCharCode()` 则按 16 位码元构造。处理完整 Unicode 码点时应使用前者；处理协议给出的原始 UTF-16 单元时，后者才直接对应输入模型。

### 格式不正确的 Unicode

JavaScript 字符串可以包含孤立代理码元。它仍是有效的 ECMAScript 字符串，却不是格式正确的 Unicode 标量值序列。切开代理对、从外部二进制错误解码或显式构造码元都可能产生这种值。

Node 24 中可用 `isWellFormed()` 检查字符串是否含孤立代理项，并用 `toWellFormed()` 把每个孤立代理项替换为 U+FFFD。这个转换会丢失原码元信息，因此应在契约边界有意执行，而不是作为隐藏的通用清理步骤。

编码器对格式错误字符串的处理方式取决于 API。跨系统传输前应明确允许的 Unicode 形式，并测试孤立代理项；不要假设每个接收方都保留或拒绝相同输入。

## 搜索与替换协议

### `indexOf()` 的哨兵值

`indexOf(search, fromIndex)` 返回首个匹配的码元索引，没有匹配时返回 `-1`。空查找字符串总能在钳制后的起始位置匹配，因此 `text.includes("")` 是 `true`；验证“输入非空”不能依赖该调用。

检查存在性时，`includes()` 比 `indexOf() !== -1` 更直接。真正需要切片位置或继续搜索时再保留索引。循环查找全部出现还要决定匹配能否重叠，以及空查找值如何推进，否则可能形成无限循环。

`startsWith()`、`endsWith()` 和 `includes()` 面向固定字符串，不接收正则表达式。这个限制能暴露误把模式当字面量的调用；需要模式时应显式选择正则 API。

### 符号方法分派

`match()`、`matchAll()`、`search()`、`replace()`、`replaceAll()` 和 `split()` 不只是把参数转换成普通字符串。对象若实现对应的 `Symbol.match`、`Symbol.matchAll`、`Symbol.search`、`Symbol.replace` 或 `Symbol.split`，操作会分派给该协议方法；`RegExp` 正是通过这些符号参与字符串操作。

这意味着来自不可信对象的“模式”可能执行用户代码。大多数应用边界应接收明确的字符串或经过控制的 `RegExp`，不要把任意对象直接传给这些方法并假设操作是纯查询。

`match()` 的返回形状取决于正则表达式是否带 `g` 标志：非全局匹配保留捕获组和索引，全局匹配主要返回完整匹配字符串。需要遍历每次匹配及其捕获组时，使用带 `g` 标志的 `matchAll()` 更稳定。

### 替换函数参数

替换函数会收到完整匹配、各捕获组、匹配偏移和原字符串；正则含命名捕获组时还会收到 `groups` 对象。可选捕获组未匹配时对应参数是 `undefined`。编写通用包装器时，不能假定倒数第二个参数永远是偏移，因为 `groups` 会改变参数尾部形状。

替换从原输入确定匹配位置，不会反复扫描刚插入的文本。因此把一项替换成同样包含查找文本的值不会自行无限循环。连续执行多个 `replaceAll()` 仍然有顺序效应，后一步可能处理前一步产生的内容。

字符串查找值按字面量处理，不需要为正则元字符转义。若动态需求确实是“替换这段固定文本”，传字符串通常比拼装 `RegExp` 更安全，也避免遗漏反斜杠和标志。

## 规范化、大小写与排序

### Unicode 规范化

视觉相同的文本可能由不同码点序列表示。例如，预组字符 `"é"` 与 `"e\u0301"` 严格比较并不相等。`normalize("NFC")` 可以把许多规范等价序列转换成统一的组合形式；NFD、NFKC 与 NFKD 具有不同分解和兼容性语义。

规范化不是清理、翻译或安全过滤。兼容性规范化可能折叠排版差异，大小写转换又是独立步骤；应用必须规定顺序与目标形式，并在写入、查询和唯一性检查中一致执行。

不要在签名或散列校验之前擅自规范化收到的原文，因为任何码元变化都会改变字节表示。协议若要求规范形式，应在协议定义的位置执行，并让双方使用同一编码与版本规则。

### 区域化大小写

`toLowerCase()` 与 `toUpperCase()` 使用默认 Unicode 大小写映射，不接收区域设置。`toLocaleLowerCase()` 与 `toLocaleUpperCase()` 接收区域参数，可以处理土耳其语 I 等区域相关映射。

大小写映射可能改变长度，也不保证往返恢复原文。德国小写 `ß` 的大写形式会扩展成多个码点；因此不能按原索引把转换后的字符串与原字符串逐位置对应。

协议关键字、编程语言标识符和安全令牌通常定义自己的大小写规则。对这些值使用用户区域设置会让结果随环境或账户语言变化。先遵守协议，再对纯展示文本使用区域化方法。

### 比较与排序

`localeCompare()` 适合少量区域化比较；大量排序可以创建一次 `Intl.Collator(locale, options)` 并复用其 `compare`。`sensitivity`、`numeric`、`caseFirst` 和 `usage` 等选项会改变哪些差异参与比较，必须由产品需求决定。

排序相等不等于字符串严格相等。配置为忽略重音或大小写的 collator 可能返回 `0`，但两个原字符串仍有不同码元。搜索命中、去重、唯一键与授权判断不应在没有契约时共用同一个宽松比较器。

排序结果还依赖运行时提供的国际化数据。需要跨服务或跨版本产生稳定持久顺序时，应保存明确排序键或规定服务端排序契约，而不是假设所有环境的默认区域设置相同。

## 方法边界的测试方式

字符串方法测试应围绕契约分区，而不是只选几个普通英文单词。至少覆盖空值边界、未找到状态、起点终点、重复匹配和返回类型。若输入来自外部，还要先决定非字符串值是拒绝还是显式转换。

索引相关测试应分别包含 BMP 字符、补充平面码点、组合序列与零宽连接序列。四类输入能揭示代码实际按码元、码点还是字素簇工作，也能发现测试标题写“字符”而断言只覆盖 ASCII 的问题。

替换测试应包含无匹配、一次匹配、多次匹配、捕获组未命中和含 `$&` 或 `$1` 的替换数据。动态正则表达式还要测试元字符与反斜杠，确认需求到底是字面量还是模式。

区域化测试必须固定区域和选项。依赖机器默认区域的快照会在开发机、CI 和用户设备之间漂移；只断言 `localeCompare()` 的正负或零，不断言具体为 `-1` 或 `1`。

方法链中的每个步骤都应保留有意义的失败。把 `match()` 的 `null` 随手改成空数组，或把缺失分隔符改成空字符串，可能掩盖损坏输入。调用方若确实允许缺失值，应在接口契约中明确表达这一分支。

最后测试原输入是否应保持不变。字符串本身不可变，但包含字符串的数组和对象仍然可变；`records.sort()` 会修改数组，即使比较的是不可变字符串。不要把字符串语义误推到外层容器。

<!-- /deep -->

[检查点: javascript/string-methods](https://codewiki.com/zh/javascript/string-methods/#checkpoint)

## 延伸阅读

- [MDN：`String`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)
- [MDN：`String.prototype.slice()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice)
- [MDN：`String.prototype.replace()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace)
- [MDN：`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter)
- [ECMAScript 规范：String 对象](https://tc39.es/ecma262/multipage/text-processing.html#sec-string-objects)
- [Unicode 标准附录 29：文本分段](https://www.unicode.org/reports/tr29/)
