
字符串处理是前端开发里绕不开的基本功而substr()、substring()、slice()这三个方法几乎每个写过 JavaScript 的人都用过但真正能把它们的边界行为说清楚的人并不多。我见过太多项目里因为混用这几个方法导致截取结果差一位、负数参数处理不符合预期、甚至在生产环境出现诡异 bug 的案例。这篇内容就来把这三个方法的脾气秉性彻底拆开讲透从参数语义、边界处理、负数索引、参数交换、到实际项目中的选型建议全部用可运行的代码验证一遍。不管你是刚接触 JS 的新手还是写了几年代码但一直靠试出来的老手看完都能对字符串截取这件事有一个清晰、确定、不再靠猜的认知。1. 三个方法到底在截什么参数语义的底层差异要理解这三个方法的区别不能只背 API 文档得先搞清楚它们各自对起始位置和结束位置的定义逻辑。这三个方法签名看起来很像但内部对参数的解释规则完全不同这正是所有困惑的根源。1.1 substr() 的起点加长度模型substr(start, length)的设计思路是从start位置开始往后数length个字符。注意第二个参数是长度不是结束位置。这是它和另外两个方法最本质的区别。const str abcdefghij; console.log(str.substr(2, 3)); // cde console.log(str.substr(2)); // cdefghij console.log(str.substr(0, 5)); // abcdesubstr(2, 3)的意思是从索引 2字符c开始取 3 个字符得到cde。这个模型在我要从某处开始取固定长度的场景下非常直观比如截取固定宽度的编号、取前 N 位等。但substr()有一个重要的历史包袱它在 ECMAScript 规范里被标记为Annex B规范性可选也就是说它属于浏览器必须支持但规范不推荐使用的遗留特性。这不是说它不能用而是说在新代码里尤其是需要跨环境比如某些非浏览器 JS 运行时时它的可用性不如另外两个方法有保障。1.2 substring() 的起点到终点自动纠偏模型substring(start, end)的语义是取从start到end不含end之间的字符。它的特殊之处在于会自动处理参数大小关系如果start end它会自动交换两个参数。const str abcdefghij; console.log(str.substring(2, 5)); // cde console.log(str.substring(5, 2)); // cde 参数被自动交换 console.log(str.substring(2)); // cdefghijsubstring(5, 2)和substring(2, 5)结果完全一样因为它内部会把小的当起点、大的当终点。这个特性有时候很方便但也很危险——当你本意是想表达从 5 到 2这种反向区间时它不会报错而是默默给你一个你可能不想要的结果。1.3 slice() 的支持负数不纠偏模型slice(start, end)的语义和substring()类似也是取start到end不含end但它有两个关键不同支持负数索引且不会自动交换参数。const str abcdefghij; console.log(str.slice(2, 5)); // cde console.log(str.slice(-3)); // hij 从倒数第3个开始 console.log(str.slice(2, -2)); // cdefgh 从索引2到倒数第2个 console.log(str.slice(5, 2)); // 不交换直接返回空串负数索引是slice()的杀手锏。-3表示从字符串末尾往前数 3 个位置也就是length - 3。这个能力在处理去掉末尾几位这类需求时特别顺手比如去掉文件扩展名、去掉末尾的换行符等。1.4 三者参数语义对照表把上面的规则整理成一张表方便随时对照方法第二参数含义负数处理参数交换规范地位substr(start, length)截取长度start 支持负数length 负数视为 0不交换Annex B 遗留substring(start, end)结束位置负数视为 0自动交换标准slice(start, end)结束位置支持负数不交换标准这张表建议直接记在脑子里。实际开发中 90% 的困惑都能通过对照这张表找到答案。我个人的经验是只要涉及负数索引优先想slice()只要涉及取固定长度才考虑substr()substring()则适合明确知道起止位置且不需要负数语义的场景。2. 负数参数最容易翻车的地方负数参数是这三个方法行为差异最集中的区域也是实际项目里 bug 的高发地带。很多人凭感觉写代码测试时用的是正数上线后遇到负数输入就出问题。这一节把负数相关的所有边界情况都跑一遍。2.1 substr() 的负数起点substr()的第一个参数支持负数表示从末尾往前数。但第二个参数length如果是负数会被当作 0 处理返回空串。const str abcdefghij; console.log(str.substr(-3)); // hij 从倒数第3个开始到结尾 console.log(str.substr(-3, 2)); // hi 从倒数第3个开始取2个 console.log(str.substr(3, -2)); // length为负返回空串 console.log(str.substr(-100, 3)); // abc 起点超出左边界从0开始注意最后一行-100明显超出了字符串长度substr()会把它钳制到 0然后正常取 3 个字符。这种越界不报错、自动钳制的行为是这三个方法的共同特点也是为什么它们容易掩盖逻辑错误。2.2 substring() 把负数当 0substring()对负数的处理最简单粗暴任何负数都视为 0。它不支持从末尾倒数的语义。const str abcdefghij; console.log(str.substring(-3)); // abcdefghij 负数视为0取全部 console.log(str.substring(-3, 5)); // abcde 起点视为0 console.log(str.substring(3, -2)); // abc 终点视为0然后交换成(0,3)最后一行特别值得注意substring(3, -2)先把-2转成0得到(3, 0)然后因为3 0自动交换成(0, 3)结果是abc。这个连锁反应如果不清楚规则根本猜不到结果。2.3 slice() 的负数才是真正的倒数slice()的负数语义最符合直觉负索引表示从末尾倒数且超出范围时会被钳制到有效区间。const str abcdefghij; console.log(str.slice(-3)); // hij console.log(str.slice(-3, -1)); // hi console.log(str.slice(3, -2)); // cdefgh console.log(str.slice(-100, 3)); // abc 起点钳制到0 console.log(str.slice(3, -100)); // 终点钳制到030返回空slice(3, -100)的结果是空串因为-100被钳制到 0变成slice(3, 0)而slice()不交换参数起点大于终点直接返回空串。这个行为和substring()形成鲜明对比。2.4 负数场景的实战建议在实际项目里我总结出一条经验如果需求描述里出现倒数末尾去掉最后 N 位这类词直接用slice()配合负数不要试图用substring()去绕。用substring()处理负数需求代码会变得又长又难懂而且极易出错。举个真实场景去掉文件名的扩展名。const filename report.final.pdf; // 推荐slice lastIndexOf const name filename.slice(0, filename.lastIndexOf(.)); // report.final // 不推荐用 substring 硬凑 const idx filename.lastIndexOf(.); const name2 filename.substring(0, idx); // 这个还行但一旦涉及负数就麻烦再比如去掉末尾的换行符let line some content\n; // slice 负数写法简洁明确 if (line.slice(-1) \n) { line line.slice(0, -1); }这种写法读起来就是如果最后一个字符是换行就去掉最后一个字符语义和代码一一对应维护起来毫无压力。3. 参数交换与越界那些不报错但结果诡异的情况这三个方法有一个共同的设计哲学尽量不抛异常而是返回一个合理的结果。这个设计在容错性上有好处但也意味着当你传错参数时代码不会告诉你而是默默给你一个错误的结果。这一节专门梳理这些沉默的陷阱。3.1 substring() 的参数交换是双刃剑substring()自动交换参数这个特性在某些场景下确实方便。比如你不确定两个索引谁大谁小直接传进去让它自己排const str abcdefghij; const a 7, b 2; // 想取 a 和 b 之间的部分不用手动比较 console.log(str.substring(a, b)); // cdefg但问题在于当你本意是想表达一个空区间或者反向区间时它不会给你空串而是给你交换后的结果。这在处理用户输入或动态计算的索引时可能掩盖真正的逻辑错误。// 假设 start 和 end 来自外部输入本意是 start 应该小于 end function getRange(str, start, end) { return str.substring(start, end); // 如果 start end不会报错而是交换 } // 调用方传错了参数顺序函数却正常返回了结果 console.log(getRange(abcdefghij, 8, 3)); // defgh而不是空串或报错如果换成slice()同样的错误会返回空串反而更容易被发现function getRange(str, start, end) { return str.slice(start, end); // start end 时返回空串 } console.log(getRange(abcdefghij, 8, 3)); // 所以我的建议是在需要严格校验区间合法性的场景用slice()而不是substring()让错误尽早暴露。3.2 越界索引的钳制规则三个方法对越界索引的处理都是钳制到有效范围但具体规则略有不同。对于substr()const str abc; console.log(str.substr(10, 5)); // 起点越界返回空 console.log(str.substr(-10, 5)); // abc 起点钳制到0 console.log(str.substr(1, 100)); // bc 长度超出取到结尾对于substring()const str abc; console.log(str.substring(10, 15)); // 都越界返回空 console.log(str.substring(-5, 2)); // ab 负数转0 console.log(str.substring(1, 100)); // bc 终点钳制到length对于slice()const str abc; console.log(str.slice(10, 15)); // 起点越界 console.log(str.slice(-10, 2)); // ab 起点钳制到0 console.log(str.slice(1, 100)); // bc 终点钳制到length console.log(str.slice(1, -100)); // 终点钳制到010返回空这些规则看起来琐碎但实际写代码时尤其是处理动态计算的索引时理解钳制规则能帮你预判结果避免为什么这里返回空串的困惑。3.3 一个容易忽略的细节参数不是数字时这三个方法在接收非数字参数时会先做类型转换。这个转换遵循ToIntegerOrInfinity抽象操作简单说就是能转成数字的转转不了的变NaN而NaN会被当作 0。const str abcdefghij; console.log(str.substring(3, 6)); // def 字符串数字可以转 console.log(str.substring(null, 3)); // abc null 转 0 console.log(str.substring(undefined, 3)); // abc undefined 转 NaN 再转 0 console.log(str.substring(true, 3)); // bc true 转 1 console.log(str.substring({}, 3)); // abc 对象转 NaN 再转 0slice()和substr()的行为类似。这个特性平时用不到但在处理来自 JSON、表单、URL 参数的看起来像数字的值时可能会悄悄生效。我踩过一次坑从 URL query 里拿到的索引是字符串2直接传给slice()居然正常工作当时还以为是巧合后来才明白是隐式转换在起作用。虽然能跑但依赖隐式转换不是好习惯建议在传参前显式用Number()或parseInt()转换让意图更明确。4. 实战选型什么场景用哪个方法理论讲完了落到实际项目里到底该怎么选这一节结合常见需求场景给出明确的选型建议并解释背后的理由。4.1 场景对照与推荐方案需求场景推荐方法理由取前 N 个字符slice(0, n)或substring(0, n)语义清晰标准方法取从某位置开始的固定长度substr(start, len)参数模型天然匹配去掉末尾 N 个字符slice(0, -n)负数语义直观取末尾 N 个字符slice(-n)同上取两个索引之间的内容slice(start, end)不交换参数错误易暴露不确定起止顺序想自动排序substring(start, end)自动交换省事需要兼容老环境substring()/slice()都是标准方法从这张表能看出一个趋势slice()的适用范围最广substring()在特定场景有优势substr()则逐渐被边缘化。这不是说substr()不能用而是说在新项目里除非确实需要起点长度的语义否则优先考虑另外两个。4.2 为什么 substr() 逐渐不推荐substr()被标记为 Annex B 不是没有原因的。它的起点长度模型虽然直观但和另外两个方法的起点终点模型不一致导致开发者在切换方法时容易搞混。而且它的负数处理规则start 支持负数length 不支持也比slice()更复杂。更重要的是现代 JavaScript 里有很多替代方案。比如要取固定长度可以用slice(start, start length)const str abcdefghij; const start 2, len 3; // substr 写法 console.log(str.substr(start, len)); // cde // slice 等价写法 console.log(str.slice(start, start len)); // cde后者虽然多写了一点但语义更明确而且完全避开了 Annex B 的兼容性顾虑。在需要严格跨环境的代码比如同时跑在浏览器和某些服务端运行时里这个差异可能很关键。4.3 一个真实的重构案例我之前接手过一个老项目里面有一段处理身份证号脱敏的代码用的是substr()function maskId(id) { return id.substr(0, 6) ******** id.substr(14); }这段代码逻辑上没问题但项目后来要迁移到一个对 Annex B 支持不完整的环境substr()直接不可用。重构时改成了slice()function maskId(id) { return id.slice(0, 6) ******** id.slice(14); }改动很小但彻底消除了兼容性隐患。这个案例说明即使当前环境支持substr()从长期维护角度用slice()替代是更稳妥的选择。4.4 性能上的差异其实可以忽略很多人关心这三个方法谁更快。实测下来在现代 JS 引擎里三者的性能差异在绝大多数场景下可以忽略不计。字符串截取本身是 O(n) 操作n 是截取长度真正的性能瓶颈往往在别处比如频繁的字符串拼接、正则匹配等。我做过一个简单的基准测试对长度为 1000 的字符串做 100 万次截取const str a.repeat(1000); const N 1000000; console.time(slice); for (let i 0; i N; i) str.slice(100, 200); console.timeEnd(slice); console.time(substring); for (let i 0; i N; i) str.substring(100, 200); console.timeEnd(substring); console.time(substr); for (let i 0; i N; i) str.substr(100, 100); console.timeEnd(substr);结果三者的耗时在同一量级差异在误差范围内。所以选型时应该优先考虑语义清晰和兼容性而不是微乎其微的性能差异。5. 常见误区与排查清单这一节把我在实际项目和代码 review 中遇到的高频误区整理出来配合排查方法方便你在遇到问题时快速定位。5.1 误区一以为 substring 和 slice 完全一样这是最常见的误解。两者在正数参数下确实行为一致但一旦涉及负数或参数顺序就分道扬镳了。const str abcdefghij; // 正数场景两者一致 console.log(str.substring(2, 5)); // cde console.log(str.slice(2, 5)); // cde // 负数场景完全不同 console.log(str.substring(-3)); // abcdefghij console.log(str.slice(-3)); // hij // 参数顺序场景完全不同 console.log(str.substring(5, 2)); // cde console.log(str.slice(5, 2)); // 排查方法只要代码里出现了负数参数立刻检查用的是不是slice()。如果用的是substring()且期望负数语义那基本可以确定是 bug。5.2 误区二把 substr 的第二参数当成结束位置这个错误在新手里很常见因为substring()和slice()的第二参数都是结束位置很容易顺手把substr()也这么用。const str abcdefghij; // 本意是取索引 2 到 5即 cde console.log(str.substr(2, 5)); // cdefg 实际取的是从2开始的5个字符substr(2, 5)取的是从索引 2 开始的 5 个字符即cdefg而不是cde。这个错误在测试时如果只验证了长度或者没仔细看结果很容易漏过去。排查方法看到substr()时确认第二参数是长度而不是结束位置。一个简单的自检方式是问自己如果我想取到索引 5应该传什么答案是substr(2, 3)因为5 - 2 3。5.3 误区三忽略返回值可能是空串三个方法在参数无效时都会返回空串而不是抛异常。这意味着如果代码没有对空串做处理可能会在后续逻辑里引发问题。const str abc; const result str.slice(5, 2); // // 如果后续直接对 result 做操作可能出问题 console.log(result[0]); // undefined console.log(result.toUpperCase()); // 这个还好排查方法在关键路径上对截取结果做非空校验。尤其是当截取结果会被用作数组索引、对象键、或者传给其他函数时空串可能引发连锁反应。5.4 误区四用字符串索引直接访问字符虽然str[index]能访问单个字符但它和charAt()在越界时的行为不同而且不支持负数索引。在需要倒数访问时还是得用slice()或at()。const str abc; console.log(str[-1]); // undefined 不支持负数 console.log(str.slice(-1)); // c 正确方式 console.log(str.at(-1)); // c ES2022 新增也支持负数at()方法是较新的标准支持负数索引返回单个字符。如果你的环境支持处理取最后一个字符这类需求时at(-1)比slice(-1)更语义化。但要注意at()返回的是字符slice(-1)返回的是长度为 1 的字符串在大多数场景下两者可以互换但在严格比较时要注意类型。5.5 排查清单汇总遇到字符串截取相关的问题时按这个清单逐项检查确认方法名和参数语义是否匹配长度 vs 结束位置检查是否有负数参数有的话确认方法是否支持负数语义检查参数顺序确认是否需要自动交换检查越界情况确认钳制后的结果是否符合预期检查返回值是否可能为空串后续逻辑是否处理了空串检查参数类型避免依赖隐式类型转换这份清单看起来简单但实际排查时能覆盖绝大多数问题。我在 code review 时基本就是按这个顺序过一遍效率很高。6. 一些值得记住的边界行为最后补充几个平时不太遇到、但一旦遇到就很迷惑的边界行为算是给这篇内容收个尾。6.1 空字符串上的截取const empty ; console.log(empty.slice(0, 5)); // console.log(empty.substring(0, 5)); // console.log(empty.substr(0, 5)); // console.log(empty.slice(-1)); // 空字符串上做任何截取都返回空串不会报错。这个行为在链式调用里很有用不用担心空值导致崩溃。6.2 参数为 Infinity 和 NaNconst str abcdefghij; console.log(str.slice(0, Infinity)); // abcdefghij 终点钳制到length console.log(str.slice(-Infinity)); // abcdefghij 起点钳制到0 console.log(str.slice(NaN, 3)); // abc NaN转0 console.log(str.substring(Infinity, 3)); // abc Infinity转length后交换Infinity会被钳制到字符串长度NaN会被当作 0。这些行为在正常业务代码里很少触发但在处理数学计算结果作为索引时可能会意外遇到。6.3 代理对字符的截取JavaScript 字符串是 UTF-16 编码某些字符比如 emoji、部分生僻字占两个代码单元。用这三个方法按索引截取时如果截断位置正好落在代理对中间会产生乱码。const emoji abc; // 占两个代码单元 console.log(emoji.length); // 5 console.log(emoji.slice(0, 1)); // \uD83D 半个代理对乱码 console.log(emoji.slice(0, 2)); // 完整字符处理这类字符时应该用Array.from()或扩展运算符先转成字符数组再操作const chars Array.from(abc); console.log(chars.length); // 4 console.log(chars.slice(0, 1).join()); // 这个坑在处理用户昵称、评论内容时特别容易踩到因为用户输入里 emoji 很常见。我个人的做法是只要涉及用户输入的字符串截取先考虑是否需要按可见字符而非代码单元来截需要的话就用Array.from()转换后再处理。6.4 关于 at() 方法的补充前面提到了at()这里再展开一点。at()是 ES2022 引入的支持负数索引返回指定位置的字符。它不能截取区间只能取单个字符所以和这三个方法不是替代关系而是补充。const str abcdefghij; console.log(str.at(0)); // a console.log(str.at(-1)); // j console.log(str.at(100)); // undefined 越界返回 undefined注意at()越界返回undefined而slice()越界返回空串。这个差异在写条件判断时要注意。我在实际项目里的体会是字符串截取这件事看似简单但真正把边界情况都考虑清楚代码的健壮性会有明显提升。尤其是涉及用户输入、外部数据、动态索引的场景多花几分钟确认参数语义和边界行为能省下后面大量的调试时间。这三个方法没有绝对的优劣关键是理解它们各自的模型然后在合适的场景用合适的方法。如果拿不准slice()通常是最安全的选择因为它的行为最符合直觉错误也最容易暴露。