字符串方法

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

难度 进阶 时长 标准深度约13分钟
版本 Node 24
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()次数无效或结果过大时可能抛错
规范化Unicodenormalize()默认使用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()只删除两端空白,正则替换再把内部连续空白折叠成一个空格。函数返回新值,调用前的字符串仍保持不变。

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()和切片位置也建立在码元索引上。补充平面码点由一对代理码元表示,因此"😀".length是2。

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

字素簇更接近用户感知的“一个字符”。Intl.Segmenter的granularity: "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.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()会修改数组,即使比较的是不可变字符串。不要把字符串语义误推到外层容器。

延伸阅读

检查点

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

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