1. 字符串截取三兄弟:为什么一个话题能写几十年
很多JavaScript初学者第一次接触字符串处理时,都会对着这三个方法发呆:substr()、slice()、substring(),名字长得像三胞胎,功能也几乎一样——都是截取字符串。但真到用的时候,却经常因为记错参数含义而出各种诡异的bug。这个话题在技术社区里的讨论热度从来没降过,面试题里也总能看到它们的身影,因为它背后其实藏着一整套关于JavaScript字符串处理的历史包袱、参数设计和边界行为。
简单说,这三个方法都能从字符串里“切”出一段子串。但它们的参数规则有明显差异:
substr(start, length):第二个参数是“截取长度”substring(start, end):第二个参数是“结束索引”,且会自动调整大小顺序slice(start, end):第二个参数也是“结束索引”,但支持负数从末尾倒数
substring和slice的差异,主要在负数处理上;substr则是完全不同的参数语义。这个知识点虽然基础,却是真正的高频坑,因为它牵涉到“索引从0开始”“左闭右开区间”“负数是否支持”这几个JavaScript字符串处理的底层规则。
我准备用一篇完整的拆解,把这三个方法的来龙去脉、参数细节、边界行为、实战选型全部讲清楚。不管你是刚入门的前端新人,还是写了好几年业务代码但偶尔还会查文档的老手,这篇内容应该都能帮你彻底结束对这三个方法“见一次查一次文档”的状态。
本文所有示例都基于JavaScript的
String.prototype上的原生方法,运行环境为Node.js或现代浏览器,结果以控制台输出为准。
2. 参数规则拆解:三个方法到底差在哪
2.1 substr:只有它用“长度”作为第二参数
substr(start, length)是这三个方法里唯一一个把第二参数定义为“截取长度”的。它的行为可以用一句话概括:从start位置开始,向右数length个字符。
const str = "JavaScript"; console.log(str.substr(0, 4)); // "Java" console.log(str.substr(4, 6)); // "Script" console.log(str.substr(4)); // "Script" // 省略第二参数时,默认截取到字符串末尾这里有一个容易忽略的细节:substr的start参数支持负数。当start为负数时,它会从字符串末尾反向计算起始位置,-1表示倒数第一个字符。
const str = "JavaScript"; console.log(str.substr(-3, 3)); // "ipt" // 从倒数第3个字符开始,截取3个字符 console.log(str.substr(-6)); // "Script" // 从倒数第6个字符开始,截取到末尾关于length参数,有两点值得注意。第一,如果length是负数或0,返回的是空字符串。第二,如果length超出了从start到末尾的实际长度,不会报错,而是直接截取到字符串末尾为止。这两个行为都很“宽容”,但也正因为宽容,容易掩盖代码里的逻辑错误。
还要明确一点:substr在ECMAScript规范中并不是标准方法。它最初来自JavaScript 1.2(Netscape时代),后来被各大浏览器广泛支持,但一直没有进入新版的ECMA-262规范正文。虽然如今所有主流环境(浏览器、Node.js)都能正常使用它,但在标准合规性上,它属于“非标准但事实上支持”的方法。在严格追求规范的项目里,一些代码规范工具会提示优先使用slice或substring。
2.2 substring:会自动帮你调整参数顺序
substring(start, end)的参数是两个索引,返回从start到end之间的字符(包含start,不包含end)。这个“左闭右开”的区间规则,和slice是一致的,很多不熟悉的人在这里会踩坑。
const str = "JavaScript"; console.log(str.substring(0, 4)); // "Java" // 索引0开始,到索引4之前结束 console.log(str.substring(4, 10)); // "Script" // 索引4开始,到索引10之前结束substring最特殊的行为是“参数交换”。如果start大于end,它不会按你写的顺序去截取,而是先交换两个参数,再执行截取。这一点在日常业务中很容易隐藏问题——比如你本意是从后面往前面截,结果它直接帮你把顺序纠正了,返回的结果反而是你预期的反面。
const str = "JavaScript"; console.log(str.substring(4, 0)); // "Java" // 你以为会得到"Script",实际得到的是"Java" // 因为内部把参数调整为 substring(0, 4)另外,substring遇到负数参数时,会把负数统一当成0处理。也就是说,任何负数索引在它面前都被“抹平”成了起点。
const str = "JavaScript"; console.log(str.substring(-3, 4)); // "Java" // -3 被当作 0,等价于 substring(0, 4)2.3 slice:规则最接近数组的截取方式
slice(start, end)的参数同样是两个索引,区间规则同样是左闭右开。它与substring最大的区别在于负数处理:slice会支持从末尾倒数的负数索引,并且不会自动交换参数顺序。
const str = "JavaScript"; console.log(str.slice(0, 4)); // "Java" console.log(str.slice(4, 10)); // "Script" console.log(str.slice(-6, 10)); // "Script" // -6 表示倒数第6个字符,等价于索引 4由于slice不交换参数,所以当start大于end时,它会直接返回空字符串,而不是像substring那样“好心”帮你改顺序。
const str = "JavaScript"; console.log(str.slice(4, 0)); // "" // start 大于 end,直接返回空字符串如果你想要实现“从某位置截到末尾”的效果,可以省略第二参数;想要从末尾倒数,也可以直接传入负数。这些行为规则和数组的Array.prototype.slice完全一致,所以熟悉数组操作的人上手slice特别快。
3. 三者对比例表:一眼看清参数与边界行为
为了方便对照,我把三个方法的差异整理成了一张速查表。这张表建议收藏,遇到不确定的时候直接返回来看。
| 对比项 | substr(start, length) | substring(start, end) | slice(start, end) |
|---|---|---|---|
| 第二参数含义 | 截取长度 | 结束索引(不含) | 结束索引(不含) |
| 负数start | 从末尾倒数 | 当作0处理 | 从末尾倒数 |
| 负数end | 不支持(负数长度返回空串) | 当作0处理 | 从末尾倒数 |
| start > end时 | 正常截取(从start开始数length个) | 自动交换参数再截取 | 返回空字符串 |
| 区间规则 | 无索引区间概念,按长度计数 | 左闭右开 [start, end) | 左闭右开 [start, end) |
| 省略第二参数 | 截取到末尾 | 截取到末尾 | 截取到末尾 |
| 标准性 | 非标准(但被广泛支持) | 标准(ES3+) | 标准(ES3+) |
一眼扫过去,最直观的记忆点是:substr看长度,substring看索引看负数会“软化”,slice看索引支持负数但不交换参数。
基于这张表,我每次在代码里选择方法时,基本遵循这个思路:
- 我需要“从某位置开始截取固定长度”——用
substr最直接 - 我需要“截取从a到b的一段”,且双方都确定为正数——用
substring - 我需要“从末尾倒数截取一段”,或者参数来自用户输入可能是负数——用
slice
这个选择思路看起来简单,实际操作时能减少很多不必要的判断代码。
4. 实战选型:什么场景该用哪个方法
4.1 固定长度截取场景,优先用substr
在很多真实业务里,我们需要的不是“从a索引到b索引”,而是“从某个位置开始,截取固定N个字符”。这种场景下,substr的参数语义最贴合需求,代码读起来也最自然。
举几个例子:
const phone = "13812345678"; // 取手机号前3位 const prefix = phone.substr(0, 3); // "138" // 取手机号后4位 const suffix = phone.substr(-4); // "5678"身份证号解析、订单号分段、日志格式截取,这类场景到处都是“固定长度”的需求。比如从一段日期时间字符串2025-03-15 14:30:00里分别取出日期和时间部分:
const datetime = "2025-03-15 14:30:00"; const datePart = datetime.substr(0, 10); const timePart = datetime.substr(11, 8); console.log(datePart); // "2025-03-15" console.log(timePart); // "14:30:00"这里如果非要用substring或slice,你还得先计算结束索引(起始索引+长度),代码会多一步计算,可读性反而下降。所以我的习惯是:明确的按长度截取需求,直接用substr,简单高效。
4.2 按索引区间截取,substring和slice怎么选
当需求变成“从索引a到索引b之间”的区间截取时,substring和slice才成为候选。二者之间,我更推荐默认使用slice。理由有三个:
第一,slice行为更可预测。因为substring会自动交换参数,当你的start和end来自动态变量时,一旦数据异常(比如start算大了),substring不会暴露问题,而是“悄悄地”返回一个你未必想要的结果,这种隐藏行为在调试时非常迷惑。slice则不同,参数顺序错了就返回空字符串,问题暴露得很明显,反而更容易追查。
第二,slice支持负数,比如从末尾往前截取的场景,用slice可以写出很简洁的代码:
const filePath = "/usr/local/nginx/logs/access.log"; // 取最后两级路径 const lastTwo = filePath.slice(-9); // "access.log" // 去掉后缀名 const nameWithoutExt = filePath.slice(0, -4); // ".../access"第三,slice的语义和数组方法完全一致。如果你也经常操作数组,那slice在字符串和数组两边通用一套规则,记忆成本更低。
4.3 关于substr“非标准”问题的实际影响
前面提到substr不是ECMAScript标准方法,有些读者可能担心:在项目里用substr会不会有兼容性或合规性风险?
从实际角度讲,所有现代JavaScript运行环境(包括浏览器和Node.js)都实现了substr,并且没有任何主流环境表示要移除它。ECMA-262虽然没把它写进标准正文,但它一直在规范附注(Annex B)里被列为“浏览器兼容性扩展”,所以它并不是“随时可能消失”的野路子API。Web上存量代码太多了,彻底移除法子方法会造成大规模兼容性灾难。
不过,在两种情况下我会刻意避开substr:
- 项目启用了严格的ESLint规则集(比如
eslint:recommended配合某些插件),其中no-restricted-properties可能禁用了它 - 团队里有代码规范明确要求只用标准方法
这时切换到slice通常只需要改一行,因为substr(start, length)在绝大多数场景可以改写为slice(start, start + length):
const str = "hello world"; // 等价写法 str.substr(6, 5); str.slice(6, 6 + 5); // 都返回 "world"如果只是个人项目或非严格规范约束的业务代码,substr放心用就好。
5. 最容易踩的坑:索引边界与参数记忆
5.1 左闭右开区间带来的偏一位问题
substring和slice都遵循“包含起始索引、不包含结束索引”的规则。这个规则经常让人在计算索引时偏一位。比如一个长度为10的字符串,你想截取前5个字符,正确写法是:
const str = "0123456789"; str.slice(0, 5); // "01234",不包含索引5的字符很多人会写成slice(0, 6),多截了一个字符。我的习惯是:先想清楚“我要的最后一个字符的索引是多少”,然后加1,就是end参数。
比如我想截取索引2到索引7之间的字符,最后一个是索引7,那么end参数就是8,写成slice(2, 8)。用这个“末尾索引+1”的口诀,基本不会出现偏一位的问题。
5.2 负数索引在三种方法中的表现截然不同
这是最容易踩坑的地方,因为三个方法对负数的处理完全不一样:
substr的start支持负数,length不支持负数substring的所有负数参数都被当作0slice的start和end都支持负数
用同一个示例字符串来对比一下,感受会更直观:
const str = "ABCDE"; console.log(str.substr(-2, 2)); // "DE" console.log(str.substring(-2, 2)); // "AB" // 注意:-2被当成了0,截取的是索引0到2的内容 console.log(str.slice(-2, 4)); // "D" // 从倒数第2个字符(索引3)到索引4之前,只有索引3一个字符substring把负数当0这个行为,特别容易在“从后往前取”的时候给你一个“看似正确但实际错误”的结果。我早年写过一个解析文件名的函数,用substring处理一个带负数的动态索引,结果返回的始终是前几个字符,排查了很久才发现负数全被归零了。从那之后,凡是索引可能为负的场景,我直接放弃substring。
5.3 参数大小关系导致的行为差异
substring和slice在start > end时的处理截然不同:
const str = "ABCDEF"; console.log(str.substring(4, 2)); // "CD" // 自动交换为 substring(2, 4) console.log(str.slice(4, 2)); // "" // 直接返回空字符串如果没有意识到substring会交换参数,那么在写依赖start > end逻辑的代码时,很可能会得到反直觉的结果。比如,你想从末尾往前截一段字符串,用substring写了substring(4, 2),你以为会得到从后往前的内容,实际上它返回的是正向区间的内容,整个逻辑就错了。
所以我个人的项目规范里有一条:索引顺序取决于业务逻辑时,统一用slice;只有确定了start <= end才允许用substring。
6. 那些容易混淆的“亲戚”:splice、split以及跨语言截取
搜索相关热词时,我注意到一个高频现象:很多人把splice和slice搞混。虽然splice是数组方法而不是字符串方法,但它的存在确实给新手带来了极大的困惑,这里必须单独拎出来说清楚。
6.1 splice和slice:一字之差,天壤之别
splice是Array.prototype上的方法,它的作用是“删除或替换数组元素”,而且直接修改原数组。slice则是“复制数组中一段元素,返回新数组”,并不会改变原数组。
const arr = ["a", "b", "c", "d"]; // splice:从索引1开始删2个元素,并插入"x" const spliced = arr.splice(1, 2, "x"); console.log(arr); // ["a", "x", "d"] console.log(spliced); // ["b", "c"] // slice:复制索引1到3之前的元素 const sliced = arr.slice(1, 3); console.log(arr); // 原数组保持不变 console.log(sliced); // 从新数组视角看,arr 已经是 ["a","x","d"] 了,所以这里结果需要往前对齐splice影响原数组,slice不影响,这是二者最核心的分界线。如果你在做数据操作时误把splice当成字符串方法调用,或者把字符串的slice行为套到数组上,就会遇到“原数据被改了”或者“截取结果不对”的问题。这也是为什么相关话题下总是有大量“splice和slice区别”搜索的原因。
顺带一提,如果你需要字符串的“把某一段替换掉”能力,应该用replace而不是splice,因为字符串是原始类型,本身就没有splice方法。
6.2 split也是截取?它是“分割”,不是“截取”
还有一个经常和这三个方法放在一起提的是split。split的职责是用分隔符把字符串拆成数组,和“按索引截取”完全是两个维度的事。
const str = "apple,banana,orange"; const arr = str.split(","); // ["apple", "banana", "orange"]split的场景是“把有规律的字符串拆成多个部分”,比如解析CSV、处理URL参数、按换行拆多行文本。它不是拿来做“从第几个字符到第几个字符”的。如果把split和substring搞混,典型的错误会是这样:
const str = "hello"; str.split(1, 3); // 返回 ["hello"],整个字符串作为单个元素 // 你期望的是"el",但split根本不按索引工作这类混淆的根源,其实还是对“按索引截取”和“按分隔符分割”这两类需求的分野不够清晰。按位置截取选本文的三个方法,按规则拆分选split。
6.3 跨语言视角:C#、MATLAB怎么截字符串
热搜词里出现了C#和MATLAB的字符串截取,说明很多人在不同语言之间切换时会对“截取字符串”的规则感到混乱。这里简单对照一下,方便有跨语言需求的读者建立全局认知。
C#中,常用的是Substring方法,它和JavaScript的substr有点类似,但它不支持负数:
string str = "Hello World"; string sub = str.Substring(6, 5); Console.WriteLine(sub); // 输出 "World"C#里实现“截取到末尾”,只需要传入起始索引:
string sub2 = str.Substring(6); // "World"而C#里如果要从末尾倒数的位置截取,需要先用Length减去偏移量:
string sub3 = str.Substring(str.Length - 5); // "World"MATLAB中,常见的是extractBetween或substring(MATLAB 2016b及以后版本支持substring,但版本之间行为不一致)。MATLAB的字符串索引从1开始,这又和JavaScript的从0开始不同,跨语言编码时非常容易出边界错误:
str = "Hello World"; sub = extractBetween(str, 1, 5); % 输出 "Hello"不同语言的索引起点、是否包含结束位置、是否支持负数这三个维度排列组合,就是“字符串截取”话题在跨语言场景下总有新文章出现的根本原因。理解了这三条轴之后,换语言时只需要对照查一下对应语言属于哪种组合,基本就不会再犯错了。
7. 常见问题速查:三个疑难场景排查实录
7.1 为什么substring(4, 0)返回的不是“从4到末尾”
这是搜索热词下最常见的问题之一。原因是substring会自动交换参数,substring(4, 0)等价于substring(0, 4)。如果你期望“从4到末尾”,应该写成substring(4),显式省略第二参数。或者换成slice(4),语义更直白。这个行为在官方文档里写得很清楚,但遇到实际代码时真的容易被忽略。
7.2 为什么slice(-3, -1)只截到倒数第二个字符
因为slice的左闭右开区间规则对负数同样生效。-3表示倒数第3个字符,-1作为结束位置表示“到倒数第一个字符之前停止”,所以结果不包含最后一个字符。
const str = "abcdef"; console.log(str.slice(-3, -1)); // "de"如果你想要最后3个字符slice(-3)就够了,不用提供第二个参数。这类“末尾少一位”的问题,往往是没搞清楚“结束索引不包含”在负数下也适用。
7.3 substr在处理长度超界时会发生什么
当substr的length参数大于从start到末尾的实际长度时,它不会报错,而是自动截到末尾。比如:
const str = "hello"; console.log(str.substr(3, 10)); // "lo" // 虽然给的长度是10,但实际只剩2个字符这正是我之前说的“宽容型行为”。它会掩盖一些逻辑上的错误,比如你预期字符串一定有足够长度,但实际数据并不满足,结果代码不报错但输出却不完整。最稳妥的做法是截取之前显式校验长度,或者改用slice配合Math.min来明确控制边界。
在实际开发中,我建议对“截取结果不符合预期”的问题,第一时间打印三个东西:原始字符串、起始索引、截取参数。至少有一半的问题,打印完这三个值之后自己就能定位了。
7.4 动态截取时怎么避免“隐藏问题”
实际开发里,截取参数往往来自变量而不是字面量。为了避免活活踩坑,我通常会在截取前做一次边界判断:
function safeSlice(str, start, end) { const len = str.length; const safeStart = Math.max(0, Math.min(start, len)); const safeEnd = end === undefined ? len : Math.max(safeStart, Math.min(end, len)); return str.slice(safeStart, safeEnd); }这段保护代码的思路是:把start限制在[0, len]范围内,把end限制在[safeStart, len]范围内,保证永远不会因为负数和越界而导致意外结果。虽然多了几行,但在处理用户输入或后端传输的数据时,这个前置校验值回票价。
8. 个人使用规范:我最终沉淀下来的几条习惯
聊了这么多规则和案例,最后分享一下我在项目里实际沉淀下来的几条使用习惯。这些习惯不一定是唯一正确解,但它们是经过多次返工和bug排查之后我最终固定的方案。
第一,默认入口是slice。无论是字符串还是数组,slice的行为最可预测,支持负数,不交换参数,兼容新老环境,风格统一。我在自己的代码规范里,凡是需要“按索引截取”的,无特殊理由都用slice。
第二,按长度截取才用substr。只有业务需求明确是“从第几位开始取多少位”时,substr的代码可读性优势才值得用。而且我会在注释里写清楚“这里按长度截取”,避免同事误以为是索引区间。
第三,一律不用substring做动态参数的处理。除非参数来源是已验证过的固定数字,比如从配置表里读出来的常量,我才允许用substring。凡是有变量参与、索引可能来自计算结果的,substring的自动交换和负数归零行为都是隐患。
第四,统一封装一个工具函数。在一个中大型项目里,多处用到的字符串截取逻辑最好收敛到一个公共模块里,而不是到处直接写slice或substr。这样当需求变更(比如统一改用正则匹配,或统一处理中文双字节)时,只改一个地方就够了。
我在实际项目中见过太多次因为直接散写截取逻辑导致后期难以维护的情况。字符串截取虽然是一个小知识点,但它在业务代码中的出现频率极高,值得从一开始就规范起来。
其实,这三个方法本身并不难,难的是在真实代码里形成稳定的判断力和使用习惯。希望这篇拆解能帮你少走一些弯路,下次见到它们的时候,不再是“查一次忘一次”的状态。