字符串方法

理解字符串方法的不可变语义、UTF-16 索引、搜索替换与区域规则,写出能正确处理真实文本边界的 JavaScript。

难度 进阶 时长 标准深度约 13分钟
版本 Node 24
what

JavaScript 字符串是不可变的 UTF-16 码元序列;字符串方法负责搜索、提取、转换、比较或拆分这些序列。

trap

lengthslice() 和多数位置参数按 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) 可能占一个或两个码元,而用户看到的一个符号还可能由多个码点组成。

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

工作原理

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

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

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

不可变结果与方法链

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

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

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

索引与半开区间

slice(start, end) 返回从 startend 之前的码元。省略 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() 只删除两端空白,正则替换再把内部连续空白折叠成一个空格。函数返回新值,调用前的字符串仍保持不变。

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());
"  Priority\t order  "
"Priority order"
true
PRIORITY ORDER

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

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

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

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

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}`);
  }
}
{"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() 足够。替换值通过函数返回,即使内容包含 $&,也会按字面量插入。

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);
}
Total: $&5
unresolved: team

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

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

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

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

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));
17
13
6
["👨‍👩‍👧‍👦"," ","c","a","f","é"]
👨‍👩‍👧‍👦 c…

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

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

陷阱

深入 文本边界不止一种

文本边界不止一种

码元、码点与字素簇

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

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

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

操作计数单位适合的需求
text.lengthUTF-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.matchSymbol.matchAllSymbol.searchSymbol.replaceSymbol.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) 并复用其 comparesensitivitynumericcaseFirstusage 等选项会改变哪些差异参与比较,必须由产品需求决定。

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

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

方法边界的测试方式

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

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

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

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

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

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

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?