JavaScript字符串截取全攻略:10种方法与避坑指南
在JS里做“截取字符串”这件事看起来简单到不行一个方法、两个参数、完事。可真到项目里处理中英文混排、Emoji、负数索引、废弃API的时候各种奇奇怪怪的结果能把人绕晕。这篇东西不是API文档的搬运我把实际开发里摸过的10种截取字符串的方式捋了一遍从最常用的三兄弟到处理Unicode和字节截断的进阶姿势都配上场景和坑希望你在下一个需求里能少走点弯路。1. substring、slice、substr一个函数名引发的三种行为这三种方法名字很像但行为差异很大刚入门的人经常搞混甚至有两年经验的人也会在参数上翻车。先看一段最直观的对比const str Hello, JavaScript; // 1. substring(start, end) —— 不包含 end自动交换参数 console.log(str.substring(0, 5)); // Hello console.log(str.substring(5, 0)); // Hello与上面结果相同参数被交换 console.log(str.substring(7)); // JavaScript // 2. slice(start, end) —— 不包含 end支持负数 console.log(str.slice(0, 5)); // Hello console.log(str.slice(5, 0)); // 不交换start end 返回空串 console.log(str.slice(-6)); // Script从倒数第6个取到末尾 // 3. substr(start, length) —— 第二个参数是长度已废弃 console.log(str.substr(7, 4)); // Java从索引7开始取4个1.1 substring 的“自动换位”陷阱substring最反直觉的地方就是它会自动把参数里较大的值当作 end。我见过不止一个同事写了str.substring(3, 0)期望得到空字符串结果返回了前三个字符调试了大半天。凡是涉及动态参数的地方这个特性很容易产生隐蔽bug。另一个特点是它会把负数直接当作0处理abcdef.substring(-2, 3); // abc负数被当成0 abcdef.substring(2, -1); // ab-1被当成0然后参数交换变成 substring(0, 2)很多人以为substring(-2)能像slice一样从末尾取字符结果取到的是整个字符串。记住一句话substring 只认非负整数且会自动排序参数。1.2 slice唯一支持负数索引的“正经派”slice的语义和数组的slice完全一致start和end都可以是负数表示从末尾开始倒数。而且它不会交换参数如果start end直接返回空字符串。这个一致性是我在实际项目中优先使用slice的最重要原因。看几个常见的实际用法// 取文件扩展名 const filename report_2024_final.pdf; filename.slice(filename.lastIndexOf(.)); // .pdf // 去掉最后2个字符 SJ12345.slice(0, -2); // SJ123 // 取末尾N个字符 6位验证码: 8A2K3Q.slice(-6); // 8A2K3Q尤其在处理不定长数据时负数索引能省掉大量length - n的计算。能用slice的场合我都会优先用slice。1.3 substr早已废弃却还在运行的老伙计substr(start, length)的第二个参数是截取长度不是结束位置这一点与substring、slice完全不同。它虽然在主流运行时里还没有被移除但已经被 ECMAScript 标准标记为废弃MDN 上也明确建议不要在新代码里使用。不过有一些老项目里它还在到处跑尤其是某些后台管理系统的历史代码。如果你在维护这类项目至少要知道它的行为和另外两个方法的区别不然碰到substr(0, 2)和substring(0, 2)混用的代码很容易理解错业务逻辑。三种方法对比方法第二参数含义支持负索引参数交换兼容性substring结束位置不含否负数按0是所有环境slice结束位置不含是否返回空串所有环境substr截取长度start支持length不支持否已废弃不推荐2. 转成数组再截取跳出“字符串方法”的思维定式字符串方法解决不了所有问题比如取中间某几段、按分隔符取片段、统计截取后的数组长度。这时候把字符串转成数组借用数组的slice、splice、filter等能力往往更顺手。2.1 split 配合 slice 处理“按规则取值”split本身不是截取方法但它配合数组的slice能实现非常灵活的“按分隔符截取片段”const csvLine 张三,25,前端开发,北京; // 只要前3个字段 csvLine.split(,).slice(0, 3).join(,); // 张三,25,前端开发 // 跳过开头2个字段取后面的 csvLine.split(,).slice(2).join(,); // 前端开发,北京 // 取最后一个字段 csvLine.split(,).slice(-1)[0]; // 北京这种写法在处理CSV、日志行、路由路径时特别方便。比如把/api/user/123/orders按/拆开后取第3段拿用户ID比用正则更直观也更容易读代码的人理解。注意一个问题split默认是大小写敏感、按精确字符串匹配的。如果分隔符可能有多种比如逗号和空格都要切开就需要split(/,|/)或split(/[,\s]/)这时候要用正则字面量。对于中文逗号和英文逗号混着的用户输入我一般建议用// 兼容中英文逗号、顿号、空格 input.split(/[,、\s]/);2.2 Array.from 与展开运算符让 Unicode 字符不再“裂开”这里必须先说一个很多人踩过的坑str[0]或slice(0, 1)在处理 Emoji、中文生僻字、组合字符时很容易截出半个字符。const emoji 庆祝; console.log(emoji.slice(0, 1)); // \ud83c —— 乱码 console.log(emoji.length); // 3不是4之所以出现这种情况是因为 JS 内部用 UTF-16 编码大部分常用字符用一个码元code unit就可以表示但像 Emoji、部分表情符号占用两个码元。.length和很多字符串方法基于码元数量工作所以遇到这类字符就会“切碎”。解决思路是把字符串先变成数组让每个 Unicode 字符成为数组的一个元素然后再截取const str 庆祝; const chars Array.from(str); // [, 庆, 祝, ] console.log(chars.slice(0, 2).join()); // 庆 // 展开运算符效果类似 const chars2 [...str]; // [, 庆, 祝, ] console.log(chars2.slice(1).join()); // 庆祝Array.from和展开运算符都遵循“迭代器协议”能正确识别码点code point所以可以正确处理大部分 Emoji 和生僻字。要再严谨一点把“字符边界”理解为用户感知的字素grapheme那就得用后面的Intl.Segmenter了。3. 用正则在“截取”这件事上打出降维打击正则不只是用来判断格式的它提取字符串片段的能力比很多人想象中强得多。尤其在“按模式截取”的场景下match、matchAll、replace三件套能让代码大幅简化。3.1 match 与 matchAll从文本里精准提取片段match配合分组相当于在字符串里“挖”出一块或多块内容const log 2025-01-15 14:23:11 [ERROR] 连接超时 (code: 401); // 提取时间和错误码 const time log.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)?.[0]; const code log.match(/code: (\d)/)?.[1]; // 用分组捕获 console.log(time); // 2025-01-15 14:23:11 console.log(code); // 401matchAll则适合全局提取多段内容比如从一段 HTML 里抓所有链接const html a href/aA/aa href/bB/a; const hrefs [...html.matchAll(/href([^])/g)].map(m m[1]); // [/a, /b]注意matchAll必须搭配g标志否则会抛TypeError。这个坑我踩过一次在新手群里也经常看到有人问“为什么 matchAll 报错”。3.2 replace 反向截取把不要的东西换成空串很多人用replace只做替换但它也可以用来“截取”把需要保留的那部分用正则捕获然后把整个字符串替换成捕获内容效果就是提取。// 提取字符串里的数字部分 const mixed 订单号A20250213-88; const digits mixed.replace(/\D/g, ); // 2025021388 // 提取中间5位 const phone 13812345678; const middle phone.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); // 138****5678用replace的好处是不用先match判断结果是否为null反正替换逻辑里天然有“匹配不到则原样返回”的兜底在处理容错性要求高的展示场景时更安全。3.3 按字节数截取中英文混排不再难后端接口经常有“字段长度不能超过X个字节”的限制一个中文占3个字节UTF-8一个英文占1个字节。用substring直接按字符数截长度一定会超。一个稳妥的按字节截断方案function truncateByBytes(str, maxBytes) { const encoder new TextEncoder(); let currentBytes 0; let result ; for (const char of str) { const byteLen encoder.encode(char).length; if (currentBytes byteLen maxBytes) break; result char; currentBytes byteLen; } return result; } truncateByBytes(前端开发工程师, 9); // 前端开9个字节 truncateByBytes(abc前端, 6); // abc前336这里用for...of而不是for (let i 0; i str.length; i)也是为了避免 Unicode 字符被拆成两个码元的问题。TextEncoder是大部分现代运行时内置的不需要额外引包。如果还要统计剩余字节数把循环里的currentBytes返回出来即可。4. ES2022 的 at 方法负索引访问的前排体验at()是 ES2022 新增的字符串原型方法它支持负数索引同时不会像substring那样把负数变成0。用它配合slice做截取代码可以像读英文一样顺畅const str JavaScript; // 取最后一个字符 str.at(-1); // t str[str.length - 1]; // t传统写法 // 取倒数第3个到末尾 str.slice(-3); // ipt // 取倒数第3个的前一个字符 str.at(-4); // cat()本身只取一个字符真正做范围截取还是要配slice。但在业务里有很多“取最后一位数字”“取路径最后一段”的场景at(-1)的可读性确实比arr[arr.length - 1]强很多。有一个兼容性提醒at()是相对较新的API如果项目需要兼容很老的 WebView 或某类定制内核直接使用前最好确认一下运行环境。通常我会在用到at()的地方加一个简单的垫片几行代码就能解决if (!String.prototype.at) { String.prototype.at function(index) { const len this.length; const i index 0 ? index : len index; return i 0 || i len ? undefined : this[i]; }; }5. Intl.Segmenter按用户可感知的“单词/字”来截取如果你的项目里有“截取前N个‘字’加省略号”这种需求并且字符串里可能出现各种肉眼可见的复合字符比如 Emoji、国旗、带音标的字母那字符串的length和Array.from都救不了你。拿“”这个家庭 Emoji 来说它实际上由多个码点组合而成Array.from会把每个码点拆开从用户视觉上看就碎成了好几块。Intl.Segmenter是专业处理这种问题的API它按语言学上的“字素簇”来分割文本const segmenter new Intl.Segmenter(zh-CN, { granularity: grapheme }); const str 一家人在排队; const segments [...segmenter.segment(str)]; const chars segments.map(s s.segment); console.log(chars.slice(0, 4).join()); // 一家用这个方法做“按用户感知字符截取”效果最接近真实渲染的显示逻辑。它的兼容性在主流新版本环境里已经不错但在一些老浏览器上还是要做好降级。降级方案通常是回退到Array.from如果连Array.from都没有就只能用最原始的码元循环。实际业务里我一般把截断函数按“能力检测”写这样一套代码可以兼容各种环境function safeTruncate(str, maxChars) { if (Intl.Segmenter) { const seg [...new Intl.Segmenter(zh-CN, { granularity: grapheme }).segment(str)]; return seg.slice(0, maxChars).map(s s.segment).join(); } return [...str].slice(0, maxChars).join(); }6. 十种方法选型什么场景下该用哪一个方法多不是目的用对才是。基于上面的内容我做了一个选型总结方便你拿到具体需求时直接对号入座。需求场景推荐方法理由基础截取参数都是正整数substring / slice语义清晰需要从末尾倒着截slice 或 at支持负索引按分隔符提取字段split slice不用写复杂正则文本包含 Emoji 等多码元字符Array.from / 展开运算符 slice避免截出半个字符按模式提取如日期、ID、金额match / replace 正则精准命中模式后端限定了字节长度TextEncoder 逐字符累计中英文混排时最准确严格按用户视觉“字”截取Intl.Segmenter尊重组合字符边界展示摘要、列表省略号自封装的 truncate 函数统一边界规则我自己在实际项目里的默认选择是普通业务优先slice因为它和数组方法一致团队新人最好理解需要处理用户输入内容时升级到Array.from或Intl.Segmenter遇到后端长度限制就直接上字节截断方案正则更多是用在“提取”而不是单纯的“截取”。7. 一个开箱即用的 truncate 封装示例把几种方法合到一起封装一个带省略号、兼容 Unicode、可配字节上限的截取函数很多项目都能直接复用function truncate(text, options {}) { const { maxChars, maxBytes, ellipsis …, } options; if (!text) return ; // 1. 如果需要按字节截断 if (maxBytes) { const encoder new TextEncoder(); let bytes 0; let result ; for (const char of text) { const len encoder.encode(char).length; if (bytes len maxBytes) { return result ellipsis; } bytes len; result char; } return result; } // 2. 按用户感知字符截断 if (maxChars) { let chars; if (Intl.Segmenter) { chars [...new Intl.Segmenter(zh-CN, { granularity: grapheme }).segment(text)] .map(s s.segment); } else { chars [...text]; } if (chars.length maxChars) return text; return chars.slice(0, maxChars).join() ellipsis; } return text; } // 用法示例 truncate(Hello World, { maxChars: 5 }); // Hello… truncate(前端开发与JavaScript实战, { maxBytes: 12 }); // 前端开发与…封装这个函数的时候注意一点省略号本身也是字符如果是按字节截断省略号也会占几个字节。如果后端对总字节有严格要求建议把省略号长度也纳入预算或者用空字符串代替。这点细节我在项目里被测试提过 bug处理方式是把maxBytes预留出省略号的字节数比如maxBytes - 3。8. 实际使用中最容易被忽略的四个隐性坑最后提一下我见过的、最容易让截取结果出错但又不太容易定位的四个问题。8.1 参数传了小数substring、slice内部都会做ToInteger也就是把小数直接截断成整数。比如abcdef.slice(0, 2.7)结果就是ab而不是abc。如果你的参数来自用户的拖动百分比之类的小数值建议先Math.floor。8.2 把 undefined 或 null 当作 startabc.slice(null, 2)返回ab因为null被当成0abc.slice(undefined, 2)也是ab。如果某个变量可能为空直接传给 slice又没走默认分支很容易取出和你想象不一致的内容。8.3 正则没加 g 导致的截取不完整match在没加g标志时只返回第一个匹配加g后返回所有匹配但会丢失分组捕获。这两者都容易让截取结果跟你预想的不一样。我建议按“是否需要分组”来决定用match还是matchAll而不是临时改标志。8.4 用 length 判断字符串是否“变短”一个包含 Emoji 的字符串截取前和截取后的.length对比可能会误导你因为多码元字符占2个甚至更多长度单位。判断截取是否生效应该用Array.from(str).length或Intl.Segmenter的结果长度不要直接用str.length。9. 如果只能记住一条建议这段算是我个人这几年调字符串问题的一点感受JS 截取字符串的方法本身不复杂复杂的是“字符”的定义在不同需求下完全不一样。你可能以为一个“字”是一个字符但后端说一个“字”可能是两个字节设计稿说一个“字”是视觉上一个整体数据库说一个“字”是一个 UTF-16 码元。开发的时候先问清楚需求里的“字符”指什么再决定用哪套截取方案比上来就写substring靠谱得多。所以这10种方法不是让你全都记在脑子里而是你的工具箱里要有这些不同的尺子。遇到基础截取拿slice量遇到用户可见文本拿Array.from量遇到后端字节限制拿TextEncoder量遇到组合字符拿Intl.Segmenter量。工具顺手了需求再偏门也不会慌。