
先说个真实场景你从接口返回里拿到一个相对路径/upload/2024/report-v2.pdf现在需要把最后的文件名report-v2.pdf截出来。此刻十有八九会在substr()、substring()、slice()三个方法之间犹豫两秒随手选一个本地测试能通就提交了。直到某天产品经理说“这个字段末尾可能带个尾缀记得去掉”你工工整整写了str.substr(0, -1)结果返回了一个空字符串才意识到事情没那么简单。这种经历太常见了。JavaScript 里这三个看起来一模一样的字符串截取方法已经坑过不知道多少人了。论坛上相关问题翻来覆去就是这几类substr和substring到底差在哪为什么substring(1, 3)和substring(3, 1)结果一样slice(-3)能截出末尾三位substring(-3)却把整个字符串原样返回如果你也曾在这些边界行为上犯过迷糊这篇文章就是为你准备的。我不打算给你念文档而是直接帮你把三个方法彻底捋清楚参数规则、负数行为、隐藏的交换逻辑、实际项目里该怎么选。看完之后再遇到字符串截取需求你可以不用犹豫直接写出正确代码。1. 初见三个方法到底差在哪先记住3条核心差异1.1 一张表格看清全貌先别管底层实现把三个方法的关键差异放在同一张表里你一眼就能看出问题出在哪儿。方法语法第二个参数含义第一个参数支持负数第二个参数支持负数start end 时对原字符串影响数组也能用substr(start, length)str.substr(start, length)截取长度支持负数从末尾开始算支持但负数统一按 0 处理按原顺序执行返回空或不返回预期内容不修改否substring(start, end)str.substring(indexStart, indexEnd)结束索引不含不支持负数按 0 处理不支持负数按 0 处理自动交换两个参数不修改否slice(start, end)str.slice(beginIndex, endIndex)结束索引不含支持负数从末尾开始算支持负数从末尾开始算不交换返回空字符串不修改是这张表是全文的地图。后面所有细节基本都是在解释这张表里的某一行为什么会这样、遇到实际情况该怎么应对。1.2 两条一定要刻进脑子的黄金法则第一substr()的第二个参数是“截多长”而substring()和slice()的第二个参数是“截到哪”。这是最核心、最容易混淆的一点。很多人把substr(0, 5)理解成“从 0 截到 5”实际上它是“从 0 开始截 5 个字符”。前者是闭区间思想后者是半开区间思想两者在边界上的结果完全不同。第二负数参数的处理方式各不相同。slice()的负数最符合直觉负数表示“从末尾倒数”substring()对负数最简单粗暴一律变成 0substr()则是第一个参数支持负数、第二个参数不支持负数。正因如此同一个负数参数传到三个方法里可能得到三个完全不同的结果。1.3 为什么会有三个这么像的方法这里简单说点背景不是为了考古而是为了让你理解“存在即合理”背后的逻辑。substring()是 ECMAScript 第一版就有的老牌方法设计上就是从起始索引截到结束索引。slice()最初是数组的方法后来因为太好用也被挂到了字符串的原型上。而substr()属于浏览器早期各自为政时期的产物参数风格模仿了其他语言的substr习惯后续虽然被纳入规范却被明确标记为遗留特性Annex B官方态度是“能不用就不用”。理解了这段历史你就明白为什么现在的代码规范里普遍推荐用slice()它行为最一致、支持负数、还能在数组和字符串之间通用。2. 逐个击破substr() 的参数、负数和兼容性2.1 语法与常规用法substr()的语法是str.substr(start[, length])start是开始截取的索引位置length是截取的长度。如果省略length就默认截到字符串末尾。看几个最普通的例子const str JavaScript; console.log(str.substr(4)); // Script console.log(str.substr(4, 3)); // Scr console.log(str.substr(0, 4)); // Java console.log(str.substr(4, 100)); // Script注意substr(4, 100)并不会报错它会老老实实从索引 4 开始截到字符串末尾就停。这种“超长长度不报错”的特性其实也容易在不知不觉中掩盖逻辑问题后面我会再提到。2.2 start 为负数时到底怎么算substr()的第一个参数支持负数意思是“从字符串末尾倒数”。例如字符串长度是 10start -3就相当于start 7。const str JavaScript; console.log(str.substr(-3)); // ipt console.log(str.substr(-3, 2)); // ip console.log(str.substr(-99)); // JavaScript console.log(str.substr(99)); // 这里有两个很容易踩的边界如果负数的绝对值大于字符串长度比如substr(-99)实际计算时会被当成 0返回整个字符串。如果start是正数但已经超出字符串长度比如substr(99)就直接返回空字符串。所以substr()的start参数实际上是一个“会经过边界约束的偏移量”并不总是你肉眼看到的那个值。再看一个让人意外的场景console.log(hello.substr(0, -1)); // 第二个参数length传负数时会按 0 处理所以结果直接是空字符串。很多从slice()习惯过来的人以为substr(0, -1)能去掉最后一个字符结果被坑得不轻。这不是 bug就是规范如此。2.3 非标准但兼容的现状我不建议你再写substr()最尴尬的地方在于它虽然被写进了 ECMAScript 规范但被放在了“遗留特性”这个分类里。MDN 上甚至明确标注 “Deprecated”建议不要在新代码中使用。实际开发中会遇到什么麻烦呢第一TypeScript 的默认类型定义里已经给它打上了废弃标记写代码时编辑器会直接画波浪线。第二团队的 ESLint 规则如果开启了no-restricted-properties一旦你写了str.substr(...)代码检查直接报错。第三新人阅读老代码时看到substr的第一反应是去查它和substring的区别白白浪费沟通成本。所以我的建议很明确substr()你能不用就不用。遇到老代码可以留着但新写的代码统一走slice()。如果你非要用也请确保团队里每个人都清楚它的第二个参数是长度且不能传负数。3. 逐个击破substring() 自动交换索引的隐藏规则3.1 语法与常规用法substring()的语法是str.substring(indexStart[, indexEnd])它的两个参数都是索引位置返回从indexStart到indexEnd不包括indexEnd之间的字符串。如果省略第二个参数同样截到字符串末尾。const str JavaScript; console.log(str.substring(0, 4)); // Java console.log(str.substring(4)); // Script console.log(str.substring(4, 7)); // Scr单看常规用法substring()和slice()几乎一模一样也正因为这样很多人根本没想过它们在异常场景下会分道扬镳。3.2 start end 时自动交换这个行为真的很反直觉substring()有一个独一无二的行为当第一个参数大于第二个参数时它会自动交换两个参数的位置然后再截取。const str JavaScript; console.log(str.substring(4, 0)); // Java等价于 str.substring(0, 4) console.log(str.substring(7, 4)); // Scr等价于 str.substring(4, 7)你没看错substring(4, 0)返回的不是空字符串而是Java。这个设计本意是“不管你传参顺序如何结果都是两个索引之间的那段字符串”有点像是把两个参数当成一根绳子上的两个点反正截取的总是这两个点之间的部分。但在工程实践里这个“过分友好”的行为往往会掩盖 bug。我给你打个比方你要在地铁线路图上找从 A 站到 B 站的区间正常人都按起点、终点顺序写。结果突然有一天你写反了系统不但没提示你反而默默帮你调换了顺序返回了正确答案。这时候你可能永远都不会发现自己写反了。等哪天需求变了你改了其中一个参数但没改另一个麻烦就来了。代码审查时看到substring(7, 4)这种写法正常人都会愣一下这到底是故意交换还是手误为了减少理解成本我建议你默认永远保证indexStart indexEnd。3.3 负数会被规整为 0和 slice 的差别就在这里substring()对负数的处理非常简单粗暴所有负数都会被当成 0NaN也会被当成 0。const str JavaScript; console.log(str.substring(-1)); // JavaScript console.log(str.substring(-1, 4)); // Java console.log(str.substring(-99, 4)); // Java console.log(str.substring(NaN, 4)); // Java看到没substring(-1, 4)返回的是Java而如果你用slice(-1, 4)返回的会是空字符串因为它把-1解析成字符串末尾往前数一位。同样一行代码只是方法名不同结果天差地别。我以前接触过一个刚入行的同事他想截取JavaScript的最后四个字符时用了substring(-4)结果返回了完整的JavaScript他当时一度以为浏览器出 bug 了。其实就是他把substring的负数行为理解成了slice的负数行为。3.4 什么时候该用 substring说实话新项目里我几乎不主动用substring()。它唯一可能派上用场的场景是你明确知道自己需要“自动交换”这个容错能力。比如你想从两个索引之间取内容并且这两个索引的先后顺序可能受外部输入影响你希望不管顺序如何都返回中间那段。这种情况下substring确实能省一次比较。但反过来想想自动交换带来的可读性损失往往比它带来的便利更大。我更倾向于先用Math.min(start, end)和Math.max(start, end)把顺序处理好再调slice()。这样逻辑更显式别人读代码时扫一眼就懂。4. 逐个击破slice() 为何是我最常用的截取方法4.1 语法与常规用法slice()的语法是str.slice(beginIndex[, endIndex])beginIndex是起始索引endIndex是结束索引不包含。如果省略endIndex截取到末尾。const str JavaScript; console.log(str.slice(0, 4)); // Java console.log(str.slice(4)); // Script console.log(str.slice(4, 7)); // Scr常规场景和substring()完全一样。但它真正好用的地方在于负数和越界的处理逻辑非常统一。4.2 负索引怎么算一个公式讲清楚slice()的负索引规则很简单如果参数是负数就加上字符串长度再计算结果。const str JavaScript; // 长度是 10 console.log(str.slice(-4)); // ript等价于 str.slice(6) console.log(str.slice(-4, -1)); // rip等价于 str.slice(6, 9) console.log(str.slice(-99)); // JavaScript负数绝对值过大按 0 处理 console.log(str.slice(99)); // 这个“负数加上长度”的规则其实和数组的slice()完全一致。你只要在心里默认slice(-4)等于slice(长度 - 4)就不会再弄错。比如长度为 10 的字符串slice(-4)是slice(6)所以拿到的是索引 6 到末尾的ript。这种统一的规则让slice()在处理“从末尾截取固定长度”这种需求时特别顺手后面实战部分我会专门演示。4.3 start end 时不交换直接返回空字符串和substring()不同slice()不会自动交换参数。如果起始位置比结束位置大直接返回空字符串。const str JavaScript; console.log(str.slice(4, 0)); // console.log(str.slice(-1, -3)); // console.log(str.slice(7, 4)); // 我其实更喜欢这种“不聪明”的行为。参数写反了就该返回空字符串让问题暴露出来总比悄悄帮你修复要好。代码世界里显式永远比隐式靠谱。4.4 字符串和数组的统一体验是它最大的隐性优势slice()不仅字符串能用数组也能用。两者的参数规则、负数规则、边界规则完全一致。const arr [1, 2, 3, 4, 5]; console.log(arr.slice(1, 3)); // [2, 3] console.log(arr.slice(-2)); // [4, 5] console.log(arr.slice(3, 1)); // []这意味着你只需要记住一套行为就能同时覆盖字符串和数组的“切片”需求。很多语言把字符串当成字符数组处理而 JavaScript 的字符串方法并不继承数组方法但slice()用统一的行为把两者拉近了。从这个角度看把slice()当默认选项是最省心的事情。5. 实战场景从URL、文件名到动态截取的完整示例5.1 截取 URL 的 host需求是从一个完整 URL 里取出域名部分。常见的做法是先找到协议分隔符://再找到之后的第一个/两个位置之间就是 host。function getHost(url) { const protocolEnd url.indexOf(://) 3; const pathStart url.indexOf(/, protocolEnd); const end pathStart -1 ? undefined : pathStart; return url.slice(protocolEnd, end); } console.log(getHost(https://example.com/path/to?id1)); // example.com console.log(getHost(https://example.com)); // example.com这里用slice的优势在于当pathStart是-1时我把end设为undefinedslice就会一直截到末尾不需要额外再写一个判断。如果用substring因为负数会被归 0逻辑反而要绕一下。5.2 截取文件扩展名截取文件扩展名是再常见不过的需求。关键是找到最后一个点号的位置然后截取它后面的内容。function getExtension(fileName) { const dotIndex fileName.lastIndexOf(.); return fileName.slice(dotIndex 1); } console.log(getExtension(report.pdf)); // pdf console.log(getExtension(report.v2.final)); // final这里有个小坑如果文件名里没有点号lastIndexOf会返回-1加 1 后变成 0结果就把整个文件名当成扩展名返回了。实际项目里最好先判断一下function getExtensionSafe(fileName) { const dotIndex fileName.lastIndexOf(.); return dotIndex -1 ? : fileName.slice(dotIndex 1); }5.3 手机号脱敏从末尾截取固定长度前端经常要做脱敏展示。手机号一般是 11 位要保留前 3 位和后 4 位中间打码。const phone 13812345678; const head phone.slice(0, 3); const tail phone.slice(-4); const masked head **** tail; console.log(masked); // 138****5678这里slice(-4)完美体现了负索引的价值从末尾倒数 4 位。如果用substring得先算出长度再减 4写起来就没这么优雅了。类似的场景还有银行卡号末四位、订单号尾部校验位通通可以用slice(-n)搞定。5.4 用 indexOf 加 slice 动态截取中括号内容更通用的场景是截取两个字符之间的内容。比如日志信息里包含一个错误码[E1001]需要把E1001提取出来。const message 错误码 [E1001] 发生; const start message.indexOf([) 1; const end message.indexOf(]); if (start 0 end start) { const code message.slice(start, end); console.log(code); // E1001 }注意这里我加了end start的判断。如果日志格式不符合预期比如缺少右括号indexOf会返回-1此时slice(start, -1)会从开始位置一直截到倒数第一个字符结果完全不可控。先判断再截取是这类动态截取需求里最容易忽略的细节。6. 踩坑实录这些边界问题才是报错的根源6.1 substr 在 TypeScript 和 ESLint 里被标记弃用怎么办如果你在 TypeScript 项目里写str.substr(...)编辑器通常会给这条代码加删除线或者黄色波浪线。这是因为 TypeScript 的标准库类型里已经给substr标注了deprecated。遇到这种情况别硬扛直接改成slice。替换公式非常固定str.substr(start, length)等价于str.slice(start, start length)str.substr(start)等价于str.slice(start)举个例子// 改造前 const result JavaScript.substr(4, 3); // Scr // 改造后 const result JavaScript.slice(4, 4 3); // Scr这个公式能应对绝大多数场景前提是length不是负数。如果你原来传了负数length那说明代码本身就有问题正好借机把逻辑理清楚。6.2 substring 参数写反了却“一切正常”反而让人害怕我曾经在代码评审里见到这样一行const result str.substring(7, 2);我盯着看了三秒第一反应是这人写错了。但测试结果一跑居然是对的因为substring自动交换成了substring(2, 7)。这就是我前面说的“隐性容错”的坏处它让错误代码也能跑出正确结果。如果在工作中遇到这种写法我建议你直接改成const result str.slice(2, 7);明确告诉读者这里就是从 2 截到 7不存在任何交换逻辑。代码是写给人看的越直白越好。6.3 负 length 参数返回空字符串别拿它去掉末尾字符这是一个非常高发的误用场景。很多人知道slice(0, -1)可以去掉最后一个字符就顺手写成substr(0, -1)结果拿到一个空字符串。const word hello; console.log(word.slice(0, -1)); // hell console.log(word.substr(0, -1)); // 为什么因为substr的第二个参数是长度长度是-1时按 0 处理一个字符都不截自然是空的。所以记住要去掉末尾字符优先用slice。6.4 同一行代码换一个方法就是另一种结果为了直观我把同一个需求用三个方法各写一遍你看区别有多大需求期望substrsubstringslice取最后一个字符ohello.substr(-1)ohello.substring(-1)hellohello.slice(-1)o去掉最后一个字符hellhello.substr(0, -1)hello.substring(0, -1)hellohello.slice(0, -1)hell取索引 1 到末尾ellohello.substr(1)ellohello.substring(1)ellohello.slice(1)ello取后 3 位llohello.substr(-3)llohello.substring(-3)hellohello.slice(-3)llo看到没只有slice()在所有需求里都给出了符合直觉的结果。这也是为什么我一直强调默认用slice别给自己埋坑。7. 延伸对比splice、C# 与 MATLAB 的截取习惯7.1 splice 和 slice一字之差毁原数组很多前端面试题喜欢把slice和splice放在一起问。它俩不仅拼写像而且容易被买方方面前的“间接性”搞混。最关键的区别有两点slice不修改原数组splice会直接修改原数组slice的第二个参数是结束索引splice的第二个参数是删除个数。const arr [1, 2, 3, 4, 5]; const sliced arr.slice(1, 3); console.log(sliced); // [2, 3] console.log(arr); // [1, 2, 3, 4, 5] 原数组没变 const spliced arr.splice(1, 2); console.log(spliced); // [2, 3] console.log(arr); // [1, 4, 5] 原数组被改了substr和splice有一个相似之处第二个参数都是“数量”不是结束位置。这可能就是它们容易一起混淆的深层原因。我建议你把它们放在一起记忆substr(start, length)和splice(start, deleteCount)第二个参数都是“多少个”而substring/slice的第二个参数是“到哪结束”。7.2 C# 里截取字符串Substring 的参数习惯更接近 substrC# 的字符串截取方法是Substring它的签名是public string Substring(int startIndex, int length)注意第二个参数是长度不是结束索引。这一点和 JavaScript 的substr很像但它有两点不一样第一startIndex不支持负数传了就直接抛异常第二length如果导致超出字符串范围也会抛异常而不会悄悄截断。string text Hello, World!; Console.WriteLine(text.Substring(7, 5)); // World // 下面几行都会抛 ArgumentOutOfRangeException // Console.WriteLine(text.Substring(-1, 3)); // Console.WriteLine(text.Substring(0, 100));如果你是从 C# 转前端写 JS 时很容易把substr和 C# 的Substring混成一家人。记住一点C# 的Substring更像一个“严格模式”的substr而 JS 的slice才是那个行为最灵活、最好用的方法。7.3 MATLAB 的字符串截取又是另一套思路MATLAB 里处理字符串截取的常见方式有extractBefore、extractAfter和extractBetween它们不是按索引截而是按“子串标记”截。这种方式在文本模式匹配时非常方便。str Hello, World!; extractBefore(str, ,) % 返回 Hello extractAfter(str, , ) % 返回 World! extractBetween(str, [, ]) % 返回两个中括号之间的内容如果你在 MATLAB 里直接用索引就得用圆括号切片比如str(1:5)。这种做法在 JavaScript 里并不适用因为字符串不是真正的数组。跨语言对比的意义在于不要把所有语言都套进同一个思维模型但当你理解了“截取”的本质是“确定起点和终点”再去学任何一门语言的新 API 都会很快。7.4 我现在的个人选型习惯说实话以前我也被这几个方法坑过最狼狈的一次就是在字符串后面追加扩展名时想着用substr把结尾的旧扩展名去掉结果整个字符串变成了空串差点把灰度环境的数据给搞坏。从那以后我给自己定了一条非常“粗暴”的规矩第一所有字符串截取默认用slice()不解释、不犹豫。第二数组要浅拷贝也用slice()因为规则完全一致。第三只有需要删除数组元素时才用splice()。第四新代码里禁止写substr()见到直接改成slice(start, start length)。第五substring()只在维护老代码或者确实需要自动交换的场景下保留并且要写清楚注释。这个习惯帮我省了很多脑细胞和 Code Review 的沟通成本。你也可以试试先强制自己用slice写一个星期再回头看这三个方法就会觉得它们之间的区别其实没那么复杂。字符串截取这件事本质上就是“从哪开始、到哪结束、是否支持倒数”。把握住这三个问题你已经超过了大多数人。