先说个真实场景:你从接口返回里拿到一个相对路径/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()对负数最简单粗暴,一律变成 0;substr()则是第一个参数支持负数、第二个参数不支持负数。正因如此,同一个负数参数传到三个方法里,可能得到三个完全不同的结果。
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()的第一个参数支持负数,意思是“从字符串末尾倒数”。例如字符串长度是 10,start = -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()对负数的处理非常简单粗暴:所有负数都会被当成 0,NaN也会被当成 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?id=1")); // "example.com" console.log(getHost("https://example.com")); // "example.com"这里用slice的优势在于,当pathStart是-1时,我把end设为undefined,slice就会一直截到末尾,不需要额外再写一个判断。如果用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 同一行代码,换一个方法就是另一种结果
为了直观,我把同一个需求用三个方法各写一遍,你看区别有多大:
| 需求 | 期望 | substr | substring | slice |
|---|---|---|---|---|
| 取最后一个字符 | "o" | "hello".substr(-1)=>"o" | "hello".substring(-1)=>"hello" | "hello".slice(-1)=>"o" |
| 去掉最后一个字符 | "hell" | "hello".substr(0, -1)=>"" | "hello".substring(0, -1)=>"hello" | "hello".slice(0, -1)=>"hell" |
| 取索引 1 到末尾 | "ello" | "hello".substr(1)=>"ello" | "hello".substring(1)=>"ello" | "hello".slice(1)=>"ello" |
| 取后 3 位 | "llo" | "hello".substr(-3)=>"llo" | "hello".substring(-3)=>"hello" | "hello".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 的参数习惯更接近 substr
C# 的字符串截取方法是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写一个星期,再回头看这三个方法,就会觉得它们之间的区别其实没那么复杂。字符串截取这件事,本质上就是“从哪开始、到哪结束、是否支持倒数”。把握住这三个问题,你已经超过了大多数人。