多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JavaScript字符串截取方法详解:slice、substr、substring与split的差异与实战

JavaScript字符串截取方法详解:slice、substr、substring与split的差异与实战 字符串截取这件事看起来简单到不值一提但我在实际项目里见过太多人在这四个方法上翻车。slice、substr、substring、split这四个名字摆在一起别说新手工作两三年的前端有时候都得愣一下——尤其是substr和substring名字长得像双胞胎参数行为却完全不同。更麻烦的是substr已经被标记为废弃deprecated但大量老项目还在用维护时你得能看懂它、改对它。这篇内容我打算把这四个方法彻底拆开讲清楚每个方法的参数到底怎么算、负数怎么处理、边界情况返回什么、什么场景该用哪个、什么场景千万别用哪个。不管你是刚学 JavaScript 的新手还是需要维护老代码的老手看完都能直接拿去用。我会用大量可运行的例子把那些文档里一笔带过、但实际开发中一定会踩的坑全部摊开。1. 为什么这四个方法总让人混淆1.1 名字相似但设计哲学完全不同先说一个反直觉的事实这四个方法里只有三个是截取返回子串split其实是分割返回数组。把它们放一起讲是因为实际开发中它们经常配合出现——先用split把字符串切成数组再对某一段用slice或substring处理。真正让人混淆的是slice、substr、substring这三个。它们的设计目标都是从原字符串里取一段出来但参数语义分成了两派slice和substring参数是起始索引和结束索引左闭右开不包含结束位置。substr参数是起始索引和要取的长度。这个差异是万恶之源。你写str.substr(2, 3)意思是从索引 2 开始取 3 个字符而str.substring(2, 3)意思是从索引 2 取到索引 3只取 1 个字符。同样的数字结果完全不同。我个人的记忆口诀是substr 的第二个参数是长度lengthsubstring 的第二个参数是位置position。substr里的 str 可以联想成 string length虽然这不是官方解释但对我自己很管用。1.2 负数参数是最大的分水岭如果说参数语义是第一个坑那负数处理就是第二个、也是更隐蔽的坑。三个方法对负数的态度截然不同方法起始参数为负结束/长度参数为负slice从末尾倒数从末尾倒数substring当作 0 处理当作 0 处理substr从末尾倒数长度为负则返回空串slice是唯一一个两个参数都支持负数的方法这也是它在现代开发中最受推荐的原因之一。substring遇到负数直接当 0而且它还有个更奇葩的行为——如果起始索引大于结束索引它会自动交换两个参数。这个自动交换看起来贴心实际上经常掩盖逻辑错误让你调试半天找不到问题。substr的负数处理介于两者之间起始可以是负数从末尾倒数但长度是负数就直接返回空字符串。1.3 废弃状态与兼容性现状必须明确一点substr在 ECMAScript 规范中已经被标记为 Annex B遗留特性也就是说它不是核心语言的一部分只是浏览器为了兼容老代码保留的。Node.js 和所有主流浏览器目前都还支持它但新代码不应该再用。我在实际维护项目时的策略是这样的新写的代码一律用slice需要长度语义时自己算start length。读老代码看到substr要格外小心尤其是带负数参数的必须逐行验证行为。重构时把substr(start, length)机械地替换成slice(start, start length)但要注意负数长度的边界情况。提示如果你在用 ESLint可以开启no-restricted-properties规则把substr禁掉从工具层面防止团队里有人继续用它。2. slice 的完整行为拆解与实战用法2.1 参数语义与返回值规则slice的签名是str.slice(beginIndex, endIndex)。规则很清晰beginIndex截取的起始位置包含该位置。endIndex截取的结束位置不包含该位置左闭右开。省略endIndex时一直取到字符串末尾。两个参数都可以是负数负数表示从末尾往前数-1是最后一个字符。const str abcdefgh; console.log(str.slice(2, 5)); // cde 索引2到4 console.log(str.slice(2)); // cdefgh 从索引2到末尾 console.log(str.slice(-3)); // fgh 最后3个字符 console.log(str.slice(-5, -2)); // def 从倒数第5到倒数第2不含 console.log(str.slice(5, 2)); // 起始大于结束返回空串注意最后一行slice(5, 2)返回空字符串它不会像substring那样自动交换参数。这个行为我认为是优点——它诚实地告诉你你给的区间是空的而不是偷偷帮你纠正。2.2 负数索引的换算逻辑很多人对负数索引是会用但说不清。其实换算规则很简单负数索引 字符串长度 对应的正数索引。以str abcdefgh长度 8为例slice(-3)→-3 8 5→ 等价于slice(5)→fghslice(-5, -2)→ 起始-5 8 3结束-2 8 6→ 等价于slice(3, 6)→def理解了这个换算你就能预判任何负数参数的结果。我习惯在调试时先把负数换算成正数写在纸上或注释里这样逻辑一目了然。还有一个边界如果换算后的起始索引仍然小于 0比如slice(-100)在长度 8 的字符串上会被钳制为 0也就是从头开始取。如果换算后的结束索引小于 0结果就是空串。2.3 实战场景截取文件扩展名与路径处理slice配合负数索引处理文件路径和扩展名特别顺手。这是我在实际项目里用得最多的场景之一。const filename report.final.2024.pdf; // 取扩展名从最后一个点之后开始 const ext filename.slice(filename.lastIndexOf(.) 1); console.log(ext); // pdf // 取文件名不含扩展名 const base filename.slice(0, filename.lastIndexOf(.)); console.log(base); // report.final.2024 // 取最后一段路径 const path /users/admin/docs/readme.md; const lastSegment path.slice(path.lastIndexOf(/) 1); console.log(lastSegment); // readme.md这里的关键是lastIndexOf返回位置1跳过那个点或斜杠然后交给slice取到末尾。整个链路没有魔法数字可读性很好。注意如果字符串里没有.lastIndexOf返回-1-1 1 0slice(0)会返回整个字符串。这个意外正确的行为有时候是好事有时候是 bug取决于你的业务逻辑。我一般会显式判断lastIndexOf是否大于 -1。2.4 处理 Unicode 与代理对的注意事项slice是按 UTF-16 代码单元操作的不是按用户感知的字符。对于大部分常用汉字和英文字母一个字符就是一个代码单元没问题。但遇到 emoji 或某些生僻字属于增补平面一个字符占两个代码单元slice可能把它们从中间切断产生乱码。const emoji ab; console.log(emoji.length); // 4因为占2个代码单元 console.log(emoji.slice(0, 2)); // a 半个显示为乱码如果业务涉及 emoji 或复杂文本应该用Array.from(str)或展开运算符[...str]先转成真正的字符数组再操作。这是我在做国际化项目时踩过的坑——用户昵称里带 emoji截断后显示成方块被投诉过。3. substring 的自动交换机制与隐藏陷阱3.1 参数规则与 slice 的差异substring的签名是str.substring(indexStart, indexEnd)看起来和slice一模一样但行为有三处关键差异负数参数一律当作 0不支持从末尾倒数。如果 indexStart indexEnd自动交换两者。因此substring永远不会返回空串除非两个参数相等。const str abcdefgh; console.log(str.substring(2, 5)); // cde 和 slice 一样 console.log(str.substring(5, 2)); // cde 自动交换这是和 slice 最大的区别 console.log(str.substring(-3)); // abcdefgh 负数当0从头取 console.log(str.substring(2, -1)); // ab 结束当0交换后变成 substring(0, 2)看最后一行substring(2, -1)先把-1变成0然后发现2 0交换成substring(0, 2)结果是ab。这个逻辑链条如果你不熟悉调试时会非常痛苦。3.2 自动交换是贴心还是陷阱官方文档把自动交换描述为一种便利但我的实际经验是它更多时候是陷阱。原因在于当你因为逻辑错误传入了颠倒的参数时slice会返回空串让你立刻发现问题而substring会默默给你一个看起来正常的结果把 bug 藏起来。我遇到过一个真实案例某段代码要根据用户输入的两个位置截取文本用户可能先点后面的位置再点前面的位置。开发者用substring处理测试时一切正常因为自动交换帮忙了。后来需求改成如果起始位置在结束位置之后应该提示用户重新选择结果因为substring的自动交换这个提示逻辑永远触发不了排查了很久才定位到。所以我的建议是如果你需要自动纠正颠倒参数的行为明确写出来否则用slice让错误暴露出来。3.3 什么时候 substring 仍然值得用说了这么多substring的坏话它也不是一无是处。在以下场景它反而更合适你确定参数永远是非负整数且需要无论顺序如何都取中间那段的语义。比如高亮文本时用户可能从后往前选你希望无论选择方向如何都取到同一段。维护老代码原逻辑就依赖自动交换贸然改成slice会改变行为。// 高亮场景无论用户从哪个方向选择都取中间那段 function highlight(str, pos1, pos2) { return str.substring(pos1, pos2); // 自动处理顺序 } console.log(highlight(hello world, 8, 2)); // llo wo这个场景下substring的自动交换确实省事。但我会在函数注释里明确写出依赖自动交换避免后人重构时踩坑。3.4 与 slice 的选型对照表需求推荐方法理由需要负数索引从末尾取slicesubstring不支持负数参数可能颠倒希望报错slice返回空串暴露问题参数可能颠倒希望自动纠正substring自动交换参数保证非负且顺序正确两者皆可行为一致新代码默认选择slice语义更一致支持负数这张表我贴在过自己的笔记里选型时扫一眼就行。4. substr 的长度语义与废弃后的替代方案4.1 第二个参数是长度不是位置substr的签名是str.substr(start, length)。这是它和另外两个方法最本质的区别。const str abcdefgh; console.log(str.substr(2, 3)); // cde 从索引2开始取3个字符 console.log(str.substr(2)); // cdefgh 从索引2到末尾 console.log(str.substr(-3)); // fgh 从倒数第3个开始到末尾 console.log(str.substr(-3, 2)); // fg 从倒数第3个开始取2个 console.log(str.substr(2, -1)); // 长度为负返回空串对比一下substr(2, 3)是cde而substring(2, 3)是c。同样的参数结果长度差了两个字符。这就是为什么我反复强调要看清第二个参数的含义。4.2 负数起始与负数长度的不同处理substr对两个参数的负数处理是不对称的起始为负从末尾倒数和slice一致。长度为负直接返回空字符串不会报错也不会取绝对值。console.log(abcdef.substr(-2)); // ef console.log(abcdef.substr(-2, 1)); // e console.log(abcdef.substr(2, -1)); // console.log(abcdef.substr(-100, 3)); // abc 起始被钳制为0最后一行值得注意起始-100换算后远小于 0被钳制为 0然后取 3 个字符得到abc。4.3 把 substr 安全迁移到 slice 的公式既然substr已废弃迁移是迟早的事。核心公式是substr(start, length) → slice(start, start length)但有几个边界要处理// 原始 str.substr(2, 3); // slice(2, 5) str.substr(-3, 2); // slice(-3, -1) str.substr(2); // slice(2) str.substr(-3); // slice(-3) str.substr(2, -1); // slice(2, 1) → 结果一致对于带负数起始和正数长度的情况slice(start, start length)里的start length可能算出来是正数需要仔细验证。比如substr(-3, 2)在长度 8 的字符串上起始是索引 5取 2 个到索引 7所以slice(-3, -1)或slice(5, 7)都对。我写了个小工具函数帮团队做批量迁移function substrToSlice(str, start, length) { if (length undefined) return str.slice(start); if (length 0) return ; // 处理负数起始 const realStart start 0 ? Math.max(str.length start, 0) : start; return str.slice(realStart, realStart length); }这个函数把substr的所有边界行为都复现了迁移时可以逐个对比验证。4.4 为什么规范要废弃 substr根本原因是substr的参数语义和其他字符串方法不一致容易造成混淆而且它的行为在早期浏览器实现中曾有分歧。规范制定者希望统一到起始结束的模型上所以把substr降级为遗留特性。实际影响是它不会突然消失因为太多老网站依赖它但新代码用它会被代码审查挑出来。我在团队里定的规矩是——代码里出现substr必须有注释说明为什么不能改否则一律要求重构。5. split 的分割逻辑与常见误用5.1 返回数组而非字符串split和前面三个方法根本不是一个类别。它返回的是数组不是字符串。签名是str.split(separator, limit)separator分隔符可以是字符串或正则表达式。limit可选限制返回数组的最大长度。const csv apple,banana,cherry,date; console.log(csv.split(,)); // [apple, banana, cherry, date] console.log(csv.split(,, 2)); // [apple, banana] 只取前2个 console.log(csv.split()); // 每个字符一个元素 console.log(a-b_c.split(/[-_]/)); // [a, b, c] 正则分隔5.2 空分隔符与空字符串的边界split有几个容易踩的边界console.log(.split(,)); // [] 空串分割返回含一个空串的数组 console.log(a,,b.split(,)); // [a, , b] 连续分隔符产生空串 console.log(abc.split()); // [a, b, c] console.log(abc.split()); // [abc] 不传分隔符整个作为一项.split(,)返回[]而不是[]这个反直觉的行为坑过不少人。如果你要判断分割后是否有内容不能只看length还要检查元素是否为空串。5.3 用正则分隔与捕获组的陷阱当分隔符是正则且包含捕获组时捕获组的内容也会被插入结果数组这是很多人不知道的行为console.log(2024-01-15.split(/(-)/)); // [2024, -, 01, -, 15] 分隔符本身也被保留了如果你不想要分隔符就别用捕获组console.log(2024-01-15.split(/-/)); // [2024, 01, 15]这个特性偶尔有用比如想保留分隔符做进一步处理但更多时候是意外。我在解析日志格式时被这个坑过结果数组长度比预期多了一倍。5.4 split 配合 slice 的经典组合实际开发中split和slice经常组合使用。比如解析 URL 路径、处理 CSV、提取特定字段// 取 URL 的最后一段 const url https://example.com/blog/posts/123; const lastPart url.split(/).slice(-1)[0]; console.log(lastPart); // 123 // 解析键值对 const config hostlocalhost;port8080;debugtrue; const parsed Object.fromEntries( config.split(;).map(item item.split()) ); console.log(parsed); // { host: localhost, port: 8080, debug: true }第二个例子里外层split(;)切成条目内层split()切成键值再用Object.fromEntries组装成对象。这个模式我在处理环境变量、查询字符串时用了无数次。注意split()如果值本身包含比如 base64 编码会被切碎。这时候应该用indexOf()找到第一个等号再用slice分开而不是无脑split。6. 四个方法的横向对比与选型决策6.1 一张表看清所有差异特性slicesubstringsubstrsplit返回值字符串字符串字符串数组第二参数含义结束位置结束位置长度最大数量支持负数起始是否当0是不适用支持负数结束是否当0否返回空不适用参数颠倒处理返回空串自动交换不适用不适用规范状态标准标准遗留废弃标准推荐度高中低高分割场景6.2 按场景选方法的决策树我在实际写代码时的决策逻辑是这样的要分割成数组→ 用split没得选。要截取子串且可能用到负数索引→ 用slice。要截取子串参数保证非负且需要自动纠正顺序→ 可以用substring。看到老代码里的substr→ 理解它的长度语义重构时转成slice。大部分情况下slice是默认答案。它的行为最可预测负数支持最完整也是现代代码库的主流选择。6.3 性能差异真的重要吗经常有人问这四个方法哪个快。实测下来在 V8 引擎里它们的性能差异微乎其微除非你在一个循环里执行几百万次否则不用考虑性能。真正影响性能的是正则表达式的复杂度split用正则时和字符串的不可变性导致的频繁创建。如果你的场景真的是高频字符串处理优化方向应该是减少中间字符串的创建而不是纠结用slice还是substring。我做过一个基准测试在 100 万次截取中slice和substring的耗时差异在 5% 以内完全在噪声范围内。6.4 常见面试题与易错点复盘最后整理几个我见过的高频易错点都是实际面试和代码审查中反复出现的// 1. 结果是什么 hello.substring(3, 1); // ll 自动交换成 substring(1, 3) // 2. 结果是什么 hello.slice(3, 1); // 不交换返回空 // 3. 结果是什么 hello.substr(1, 3); // ell 从索引1取3个 // 4. 结果是什么 hello.substring(1, 3); // el 从索引1到索引3不含 // 5. 结果是什么 a,b,,c.split(,); // [a, b, , c] 注意中间的空串 // 6. 结果是什么 hello.slice(-2); // lo hello.substring(-2); // hello 负数当0这六道题能全对说明你对这四个方法的理解已经到位了。第 3 和第 4 题放在一起最能检验你是否真的分清了长度和位置。7. 我在实际项目里踩过的坑与经验总结7.1 一个因 substr 负数长度导致的线上问题几年前我维护一个表单校验模块需要截取用户输入的前 N 个字符做预览。代码写的是input.substr(0, maxLength)一直没问题。后来需求改成如果输入超长从末尾截取有人改成了input.substr(-maxLength)测试通过。再后来另一个同事把maxLength的计算逻辑改了某个分支下maxLength变成了负数substr(-(-5))也就是substr(5)结果从索引 5 开始取到末尾完全不是预期。问题在于substr对负数长度的处理是返回空串但当起始也是负数时逻辑就绕了。如果当时用的是sliceslice(-maxLength)在maxLength为负时会变成slice(5)行为同样诡异。根本的修复是在计算maxLength时就保证它非负而不是依赖截取方法兜底。这个教训让我养成了一个习惯任何传给截取方法的参数在使用前都做一次显式的范围校验不把正确性寄托在方法的边界行为上。7.2 处理用户输入时的防御性写法用户输入是最不可控的。我现在的防御性写法大致是这样function safeSlice(input, start, end) { if (typeof input ! string) return ; const len input.length; // 把参数规范化到合法范围 let s Number.isInteger(start) ? start : 0; let e Number.isInteger(end) ? end : len; if (s 0) s Math.max(len s, 0); if (e 0) e Math.max(len e, 0); s Math.min(s, len); e Math.min(e, len); if (s e) return ; return input.slice(s, e); }这个函数把所有边界都显式处理了虽然啰嗦但在处理外部数据时非常稳。我把它放在项目的工具库里所有涉及用户输入的截取都走它。7.3 代码审查中我会重点看的几处在 review 别人的代码时看到字符串截取我会特别关注这几点有没有用substr如果有问清楚为什么不用slice。负数参数有没有注释负数索引虽然强大但可读性差最好有注释说明意图。split的结果有没有处理空串连续分隔符会产生空串很多 bug 源于此。substring的自动交换是不是被依赖了如果是加注释如果不是改成slice。emoji 和特殊字符如果输入可能包含 emoji检查是否用了Array.from处理。这些检查点帮我拦下过不少潜在问题。字符串处理看起来简单但它是 bug 的高发区因为边界情况太多而人脑不擅长记这些边界。7.4 给不同阶段开发者的建议如果你是刚入门先把slice和split用熟这两个覆盖 80% 的场景。substring和substr知道有这么回事就行遇到再查。如果你是有一定经验重点搞清楚substr的长度语义和substring的自动交换这两个是面试和代码审查的高频考点。如果你是在维护老项目把substr的迁移当成一个专项任务用我前面给的substrToSlice函数逐个替换并写测试验证别指望一次性全改对。字符串截取这件事说到底就是把参数语义记牢、把边界情况想全、把废弃方法换掉。没有多高深的技术但每一个细节都可能在某个深夜让你加班。把这四个方法吃透至少能让你在字符串处理上少踩一半的坑。
返回列表