字符串截取这件事,说小也小,说大也大。小到一个表单校验里取手机号后四位,大到一套日志解析系统里按固定偏移量切分字段,背后都是substr()、substring()、slice()这三个方法在干活。我见过太多项目里这三个方法混着用,代码跑起来没问题,但一旦遇到负数参数、参数顺序颠倒、或者边界值等于长度的情况,行为差异立刻就暴露出来了。这篇文章就把这三个方法彻底拆开讲清楚:它们各自接受什么参数、负数怎么处理、参数大小关系如何影响结果、在什么场景下该选谁,以及我在实际项目里踩过的那些坑。不管你是刚接触字符串操作的新手,还是写了几年代码但一直靠试错来调参的老手,看完都能对这三个方法建立一套清晰的判断标准,不再靠猜。
1. 三个方法到底在做什么:先建立整体认知
1.1 它们解决的是同一类问题:从字符串里取一段
substr()、substring()、slice()这三个方法,本质上都在做同一件事——从一个已有字符串中截取一部分,返回一个新的字符串,原字符串不变。这个“不变”很关键,字符串在多数语言里是不可变类型,所有截取操作都是生成新串,不会修改原串。理解这一点,后面很多行为就顺了。
它们都接受起始位置参数,区别在于第二个参数的含义、负数的处理方式、以及参数顺序对结果的影响。很多人觉得这三个方法“差不多”,正是因为日常用的都是正数、且起始位置小于结束位置这种最规矩的情况,此时三者结果往往一致,差异被掩盖了。一旦参数越界或出现负数,差异就出来了。
我习惯用一个类比来记:把字符串想象成一排编了号的格子,起始位置是“从哪个格子开始拿”,第二个参数则各有各的理解方式。substr()的第二个参数是“拿几个”,substring()和slice()的第二个参数是“拿到哪个格子之前为止”。这个区别是理解一切差异的起点。
1.2 参数签名对比:一张表看清差异
先把三者的签名和核心行为列出来,后面再逐条展开。
| 方法 | 第二个参数含义 | 负数起始位置 | 负数结束位置 | 起始大于结束时 |
|---|---|---|---|---|
substr(start, length) | 截取的长度 | 从末尾倒数 | 视为 0 | 返回空串 |
substring(start, end) | 结束位置(不含) | 视为 0 | 视为 0 | 自动交换两参数 |
slice(start, end) | 结束位置(不含) | 从末尾倒数 | 从末尾倒数 | 返回空串 |
这张表是全文的骨架。你可以看到,substring()是最“温和”的,它把所有负数都当成 0,还会自动把小的参数放前面;slice()最“守规矩”,负数按倒数处理,但起始大于结束就老实返回空串;substr()则因为第二个参数是长度,行为逻辑和前两者不在一个维度上。
注意:
substr()在部分语言规范里已被标记为“遗留特性”,新代码里不推荐优先使用,但存量代码里大量存在,读懂它是必须的。
1.3 为什么会有三个功能重叠的方法
这不是设计冗余,而是历史演进的结果。早期不同实现各自引入了自己的截取方法,后来为了兼容性都保留了下来。substring()出现较早,设计上偏向“容错”,负数一律归零、参数顺序自动纠正,适合不想处理边界情况的场景。slice()借鉴了数组截取的设计,支持负数索引,语义更统一,适合需要从末尾定位的场景。substr()的“长度”语义在某些场景下更直观,比如“从第 3 位开始取 5 个字符”,但它的负数处理规则又和另外两个不一致,容易混淆。
理解这段历史,你就明白为什么不能简单地“记住一个就行”。存量项目里三种写法都可能出现,维护时得能准确判断每个调用的实际行为。
2. 负数参数:差异最集中的地方
2.1 slice() 的负数:从末尾倒数
slice()对负数的处理最符合直觉。起始位置为负,表示从字符串末尾往前数;结束位置为负,同样从末尾倒数。举个具体例子:
const str = "abcdefgh"; console.log(str.slice(-3)); // "fgh",从倒数第3个到末尾 console.log(str.slice(2, -2)); // "cdef",从索引2到倒数第2个之前 console.log(str.slice(-5, -2)); // "def",从倒数第5个到倒数第2个之前这里的关键是:负数先被转换成“长度 + 负数”的正索引,再按正常规则截取。"abcdefgh"长度是 8,-3就等价于8 - 3 = 5,所以slice(-3)等于slice(5),结果是"fgh"。这个转换过程是理解slice()负数行为的核心,记住“长度加负数”这个公式,任何负数参数都能心算出来。
我在处理文件扩展名、路径末段、日志尾部这类“从后往前”的需求时,几乎都用slice()。比如取文件扩展名,fileName.slice(fileName.lastIndexOf(".")),如果没找到点,lastIndexOf返回 -1,slice(-1)会取最后一个字符,这个边界行为需要额外判断,后面排查章节会细说。
2.2 substring() 的负数:一律归零
substring()对负数的态度是“不认”。任何负数参数,无论出现在哪个位置,统统当成 0 处理。这意味着substring(-3)和substring(0)完全一样,都是从开头截取。
const str = "abcdefgh"; console.log(str.substring(-3)); // "abcdefgh",-3 被当成 0 console.log(str.substring(2, -2)); // "ab",-2 被当成 0,等价于 substring(0, 2) console.log(str.substring(-5, -2)); // "",两个都归零,等价于 substring(0, 0)注意第二个例子:substring(2, -2)中,-2归零后变成substring(2, 0),而substring()会自动交换参数,变成substring(0, 2),结果是"ab"。这个“归零 + 交换”的组合行为,是substring()最容易让人算错的地方。你以为传了负数会从末尾取,实际上它从开头取,而且可能因为交换参数取了完全相反的一段。
2.3 substr() 的负数:起始可负,长度归零
substr()的负数规则又不一样。它的第一个参数(起始位置)支持负数,从末尾倒数;但第二个参数是长度,长度没有“负数从末尾数”的概念,所以负数长度一律当成 0,返回空串。
const str = "abcdefgh"; console.log(str.substr(-3)); // "fgh",起始从倒数第3个开始 console.log(str.substr(2, -2)); // "",长度为负,视为0 console.log(str.substr(-5, 3)); // "def",从倒数第5个开始取3个substr(-5, 3)中,-5转换成8 - 5 = 3,从索引 3 开始取 3 个字符,得到"def"。这个和slice()的负数起始转换逻辑一致,但第二个参数的含义完全不同,不能混用。
2.4 一张对照表把负数行为钉死
把上面三个例子汇总成表,方便对照记忆:
| 调用 | slice 结果 | substring 结果 | substr 结果 |
|---|---|---|---|
(str, -3) | "fgh" | "abcdefgh" | "fgh" |
(str, 2, -2) | "cdef" | "ab" | "" |
(str, -5, -2) | "def" | "" | ""(长度-2归零) |
(str, -5, 3) | "def" | "abc" | "def" |
这张表建议收藏。实际开发中遇到不确定的情况,对照这张表心算一遍,比在控制台反复试要快得多。特别是substring那一列,负数归零后还可能触发参数交换,结果往往和直觉相反。
3. 参数顺序与边界值:那些“看起来一样”的陷阱
3.1 起始大于结束时,三者行为分道扬镳
当第一个参数大于第二个参数时,三个方法的表现完全不同,这是仅次于负数的第二大坑。
substring()会自动交换两个参数,保证小的在前。substring(5, 2)等价于substring(2, 5),返回中间那段。这个设计初衷是容错,但实际效果是“你传反了它也不报错,还给你一个看似合理的结果”,反而容易掩盖逻辑错误。
slice()不交换,起始大于结束直接返回空串。slice(5, 2)返回""。这个行为更“诚实”,参数传反了立刻能从结果看出来。
substr()因为第二个参数是长度,不存在“起始大于结束”的概念,只要长度为正就正常截取。substr(5, 2)表示从索引 5 开始取 2 个字符,和substring、slice的参数含义根本不在一个维度。
const str = "abcdefgh"; console.log(str.substring(5, 2)); // "cde",自动交换 console.log(str.slice(5, 2)); // "",不交换 console.log(str.substr(5, 2)); // "fg",从5开始取2个这三个结果放在一起看,就能明白为什么不能凭记忆混用。同一个(5, 2),三种完全不同的输出。
3.2 边界值等于长度时的处理
当参数等于字符串长度时,三个方法都返回空串,这一点是一致的。str.slice(8)、str.substring(8)、str.substr(8)在长度为 8 的字符串上都返回""。超出长度的情况,slice()和substring()会截断到长度,substr()同样截断,都不会报错。
真正需要注意的是“结束位置等于长度”和“结束位置超出长度”的细微差别。对于slice()和substring(),结束位置超出长度会被截断为长度,结果和等于长度一样。对于substr(),长度超出剩余字符数时,取到末尾为止。这些行为在多数实现里是统一的,但跨语言使用时仍需确认,比如某些语言对越界参数会直接抛异常。
3.3 空字符串和单字符的边界
空字符串上调用这三个方法,只要参数合理,都返回空串。"".slice(0)、"".substring(0)、"".substr(0)都是""。单字符字符串上,"a".slice(-1)返回"a","a".substring(-1)返回"a"(负数归零),"a".substr(-1)返回"a"。这些边界情况在写通用工具函数时经常遇到,提前想清楚能省不少调试时间。
提示:写字符串处理工具函数时,建议在函数入口先判断输入是否为字符串、是否为空,把边界情况挡在外面,内部逻辑就能专注于正常路径。
4. 实操场景:什么时候用哪个方法
4.1 取固定位置字段:substring 或 slice 都行
处理定长格式的数据,比如从一行日志里按固定偏移量取时间戳、级别、消息体,用substring(start, end)最直观,因为“从哪到哪”的语义和定长格式天然契合。
const logLine = "2024-01-15T10:30:00 ERROR Something failed"; const timestamp = logLine.substring(0, 19); // "2024-01-15T10:30:00" const level = logLine.substring(20, 25); // "ERROR" const message = logLine.substring(26); // "Something failed"这种场景下slice()也能用,结果一样。选substring()更多是语义上的偏好——定长字段的起止位置都是正数,不需要负数索引,substring()的容错特性也不会带来麻烦。
4.2 从末尾取内容:slice 是首选
取文件扩展名、取路径最后一段、取字符串尾部固定长度,这些“从后往前”的需求,slice()的负数索引最顺手。
const path = "/var/log/app/error.log"; const fileName = path.slice(path.lastIndexOf("/") + 1); // "error.log" const ext = fileName.slice(fileName.lastIndexOf(".")); // ".log" const code = "ORDER-20240115-0001"; const serial = code.slice(-4); // "0001"slice(-4)取末尾 4 位,比substr(code.length - 4, 4)或substring(code.length - 4)都简洁。这里要注意lastIndexOf返回 -1 的情况,如果字符串里没有目标字符,slice(-1)会取最后一个字符,而不是你期望的空串或整个字符串。这个坑我在处理用户上传的文件名时踩过,没有扩展名的文件会取到文件名的最后一个字符当扩展名,后来加了判断才修好。
4.3 取指定长度:substr 的语义优势
“从第 N 位开始,取 M 个字符”这种需求,substr(N, M)的语义最直接。比如解析身份证号里的出生日期段,从第 6 位开始取 8 位:
const idCard = "110101199001011234"; const birthDate = idCard.substr(6, 8); // "19900101"用substring(6, 14)也能达到同样效果,但需要自己算 6 + 8 = 14。当长度是变量时,substr的优势更明显:substr(start, len)不用算结束位置。不过考虑到substr的遗留状态,新项目里我倾向于用slice(start, start + len)来替代,语义清晰且没有兼容性顾虑。
4.4 场景选择速查表
| 场景 | 推荐方法 | 理由 |
|---|---|---|
| 定长字段截取 | substring | 起止位置语义直观 |
| 从末尾取固定长度 | slice | 负数索引简洁 |
| 从指定位置取指定长度 | slice(start, start+len) | 避免 substr 遗留问题 |
| 需要容错、参数可能传反 | substring | 自动交换参数 |
| 需要严格判断参数合法性 | slice | 参数反了返回空串,容易发现 |
这张表不是死规矩,但能覆盖八成日常场景。剩下的两成,靠前面讲的负数规则和参数顺序规则来推导。
5. 常见问题与排查技巧实录
5.1 为什么 substr 有时能用有时报错
substr()在部分运行环境的严格模式下会被标记为不推荐,某些静态检查工具会直接报错。我遇到过项目升级构建工具后,原本能跑的substr调用全部报“已弃用”警告,虽然不影响运行,但 CI 流水线因为警告数量超标而失败。解决办法是全局替换成slice(start, start + len),替换时注意长度参数为负数的情况,substr负数长度返回空串,slice的start + len如果小于start也会返回空串,行为一致,可以安全替换。
5.2 负数参数算错导致截取结果为空
最常见的排查场景:明明想从末尾取,结果返回空串。原因通常是用了substring而不是slice。substring(-3)不会从末尾取,而是从开头取。排查时先确认方法名,再看参数正负。如果代码里三种方法混用,建议统一成slice,减少认知负担。
另一个隐蔽情况是slice(start, end)中start为正、end为负,且start转换后大于end转换后的值。比如"abcdefgh".slice(6, -3),-3转换成 5,6 > 5,返回空串。这种“一个正一个负”的参数组合最容易算错,建议在代码里加注释标明转换后的索引。
5.3 参数顺序传反的静默错误
substring自动交换参数的特性,会让“起始和结束写反”这种错误静默通过。比如想取substring(10, 5)实际想要的是substring(5, 10),结果一样,代码看起来没问题。但如果本意是“从 10 开始到 5 结束”(逻辑上不成立),substring会给你一个看似合理的结果,掩盖了逻辑错误。排查这类问题时,重点看参数的业务含义是否合理,而不是只看结果对不对。
5.4 跨语言使用时的行为差异
不同编程语言里同名方法的行为可能不同。比如某些语言的substr第二个参数是结束位置而不是长度,某些语言的slice不支持负数。跨语言移植代码时,不能假设行为一致。我的做法是:移植前先写一组边界测试用例,覆盖负数、参数反序、越界、空串四种情况,跑一遍确认行为,再动手改代码。这组测试用例本身也值得保留在项目里,作为字符串工具的回归测试。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 返回空串 | 起始大于结束(slice) | 检查参数顺序和负数转换 |
| 从开头取而非末尾 | 用了 substring 传负数 | 改用 slice |
| 取到的长度不对 | substr 长度参数为负 | 长度归零,检查计算逻辑 |
| 扩展名取到单个字符 | lastIndexOf 返回 -1 | 加判断,-1 时特殊处理 |
| 构建报弃用警告 | 使用了 substr | 替换为 slice(start, start+len) |
这张表是我在实际项目里反复用到的排查清单,遇到字符串截取问题时从上往下过一遍,基本能定位到原因。
6. 我踩过的坑和几条实用建议
第一个坑是slice的负数在“没找到分隔符”时的意外行为。取扩展名时fileName.slice(fileName.lastIndexOf(".")),如果文件名没有点,lastIndexOf返回 -1,slice(-1)取最后一个字符。这个 bug 在测试环境没暴露,因为测试文件都有扩展名,上线后用户上传了无扩展名文件才触发。修复方式是先判断lastIndexOf的返回值,大于等于 0 才截取。
第二个坑是substring的参数交换在循环里导致的性能问题。曾经写过一个循环里反复调用substring做字段提取,参数是动态计算的,偶尔会出现起始大于结束的情况,substring每次都做交换判断。虽然单次开销可忽略,但循环百万次后累积起来有可测量的差异。后来改成先在外面算好起止位置,确保起始小于结束,再调用slice,性能有改善。这个优化不是必须的,但在高频路径上值得注意。
第三个坑是跨浏览器或跨运行时的行为差异。早期某些环境对负数参数的处理和标准不一致,同一个slice(-3)在不同环境返回不同结果。现在主流环境已经统一,但如果项目需要兼容老旧环境,建议避免依赖负数参数,改用str.length - n显式计算。
几条实用建议:新代码统一用slice,需要长度语义时写slice(start, start + len);工具函数入口做参数校验,把负数、越界、非字符串输入挡在外面;写单元测试时必测负数、参数反序、空串、越界四种情况;代码审查时看到substring和substr多问一句“这里负数会怎样”,往往能提前发现隐患。
字符串截取看着简单,但三个方法的差异点集中在负数和参数顺序上,而这两处恰恰是日常编码里最容易凭直觉写错的地方。把本文的对照表和速查表存下来,下次写截取逻辑时对照一遍,能省下不少调试时间。