ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript substring() 全面解析:边界规则、底层实现与工程踩坑指南

JavaScript substring() 全面解析:边界规则、底层实现与工程踩坑指南 JavaScript 的substring()大概是字符串方法里最被“低估”的一个——不是因为它功能弱而是因为用得太随意。很多人觉得它不过是个“截取子串”的黑盒扔进去两个索引把返回值拿过来用就行。但实际上我在带项目和做代码评审时见过太多因为负数参数、参数倒置、以及和substr混淆而产生的线上 bug。这篇文章不打算只抄一遍 MDN 文档而是想把这个方法彻底讲透从基础语法到边界规则从 V8 底层实现到真实项目里的踩坑复盘一次说清。不管你是刚入门的前端新手还是写了三五年 JavaScript 的老手只要还在和字符串打交道这篇文章都值得你花十分钟读完。其中几个“反直觉行为”很可能就是你下次 bug 的来源。1. substring() 是什么从方法签名到三个自动纠偏规则1.1 方法签名与一次最普通的截取substring()是字符串对象上的一个实例方法作用是从一个字符串里取出指定索引范围内的子串并作为新字符串返回。它的完整签名是这样字符串.substring(indexStart) 字符串.substring(indexStart, indexEnd)这里indexStart是必填的起始索引indexEnd是可选参数表示结束位置的索引。光看签名可能你会以为它和 Java 里的substring一样但 JavaScript 版本的细节要多得多。先看最常见的用法const str Hello, JavaScript; console.log(str.substring(0, 5)); // Hello console.log(str.substring(7)); // JavaScript console.log(str.substring(0)); // Hello, JavaScript第一个例子从索引 0 取到索引 5第二个例子只传一个参数表示从索引 7 一直取到字符串末尾。这里有一个新手最容易忽略的点substring(0, 5)返回的是“Hello”这 5 个字符索引 5 所在的字符,并不在结果里。也就是说结束位置的字符是不包含在结果里的。1.2 结束位置不包含在结果里这不是设计失误而是一种统一约定许多第一次接触这个方法的人都会问为什么不包含indexEnd位置的字符这其实是编程语言里非常常见的“左闭右开”区间设计也就是[indexStart, indexEnd)。这个约定有几个很实用的好处可以很方便地计算子串长度substring(a, b)的结果长度永远是b - a在参数合法的情况下不用再额外减一。方便连续切片要把一个字符串切成几段只需要让前一次截取的结束位置等于后一次的开始位置不会漏掉或重复字符。便于和数组操作对齐数组的slice(start, end)也是同样的左闭右开规则字符串和数组的操作习惯保持一致能显著降低记忆成本。举个例子把abcdef切成ab、cd、ef三段const s abcdef; s.substring(0, 2); // ab s.substring(2, 4); // cd s.substring(4, 6); // ef每段的结束索引正好是下一段的开始索引这种写法在工作里非常常见尤其是处理固定格式报文和表格数据时。1.3 三个自动纠偏规则越界、负数和大小交换substring()和很多字符串方法不一样它自带“容错机制”。当传入的参数比较“离谱”时它会按照以下规则自动修正如果参数是负数按 0 处理。如果参数超过字符串长度按字符串长度处理。如果indexStart大于indexEnd两个参数会自动交换。这三条规则简直是把“家长式照顾”写进了规范。看几个例子const s abcdef; console.log(s.substring(-100, 2)); // ab 负数按 0 处理 console.log(s.substring(2, 100)); // cdef 越界按 length 处理 console.log(s.substring(4, 1)); // bcd 自动交换成 (1, 4)第一眼看到交换规则时我第一反应是“这很贴心”用久了才发现这种贴心有时候会掩盖真正的 bug。后面我会专门讲这个坑。先把基础规则记牢负数变 0、过大变 length、前后倒序则交换这三个行为必须烂熟于心。2. 负数和 undefined两个让新手集体翻车的边界2.1 负数不等于从尾部截取这和 slice 完全不同上面说了substring()遇到负参数会直接按 0 处理。但很多写过 Python 或 Ruby 的人会想当然地以为“负索引是从尾部倒数”。这是非常危险的思维惯性。比如字符串abcdef索引 -1 对应的字符按照“从尾部倒数”的理解应该是f但substring()里根本没有负索引的概念const s abcdef; console.log(s.substring(-1)); // abcdef-1 被当作 0 console.log(s.substring(-3, 2)); // ab等同于 substring(0, 2)如果你真的想实现“从倒数第 3 个字符开始截到末尾”这样常见的需求substring()做不到得换成slice()console.log(s.slice(-3)); // def console.log(s.slice(2, -1)); // cdeslice()和substring()在参数规则上看着很像但核心区别就在这里slice()支持负索引尾部计数substring()不支持。以后封字符串截取工具函数时这俩绝不能用错。2.2 undefined 被当作 0 之后发生了最反直觉的事比负数更隐蔽的是undefined的处理。看这段代码const s abcdef; console.log(s.substring(2, undefined)); // abc console.log(s.slice(2, undefined)); // cdef同样都是从索引 2 开始第二个参数传undefined结果居然完全不同。slice(2, undefined)返回的是从索引 2 到末尾的cdef符合直觉而substring(2, undefined)返回的却是abc。为什么因为substring()在处理参数时先要把undefined转换成数值转换结果就是 0。于是substring(2, undefined)内部变成了substring(2, 0)紧接着触碰到了我们上面说的“自动交换规则”2 大于 0所以两个参数交换实际执行的是substring(0, 2)结果自然成了ab前面的部分。这非常反直觉。我在真实项目里见过有人把第二个参数从一个变量改成显式undefined然后列表里的文本全部“掉头”了排查了整整一个下午。记住substring(start, undefined)并不等于substring(start)它等于substring(0, start)。2.3 自动交换特性的另一面它可能粉饰你的逻辑错误自动交换规则在某些场景下确实能“救场”比如你想写substring(end, start)却写反了也不会出错。但反过来想如果你的 start 和 end 来自动态计算交换规则会让参数传反的问题“静默消失”而不是抛错暴露出来。我举个真实场景你写了一个分页函数根据当前页和每页条数去截取数组再用join()之后substring截掉多余内容。页数从 1 开始没问题但如果某个接口返回的页数从 0 开始start 和 end 的顺序就可能倒置。此时因为自动交换代码不会报错甚至输出结果看起来“合理”而一旦参数里混入负数或undefined行为就会突然变化。这类问题属于“潜伏型 bug”比直接报错难查得多。3. 与 slice()、substr() 三兄弟划清界限工程选型逻辑3.1 一张表说清三个方法的参数行为JavaScript 里有三个名字和功能都极其相似的字符串截取方法substring()、slice()、substr()。尤其substr()和substring()只差一个字母参数含义却完全不同。把它们放在一起看最容易记住差异方法参数含义负数处理start end 时是否修改原字符串状态substring(start, end)start 起点end 结束点不含按 0 处理自动交换否标准方法长期可用slice(start, end)start 起点end 结束点不含从尾部倒数计数返回空字符串否标准方法推荐使用substr(start, length)start 起点length 截取个数start 可为负数无此概念否已废弃deprecated勿用于新代码这里要重点说明substr()虽然很多老项目还在用连一些教程网站都还在教但它已经被列入标准建议淘汰名单。它和substring()长得太像参数含义又不同害人无数。const s abcdef; console.log(s.substring(2, 4)); // cd从索引 2 到 4 console.log(s.substr(2, 4)); // cdef从索引 2 开始取 4 个字符只从代码字面上看substring(2, 4)和substr(2, 4)结果差了整整两位而且在某些取值组合下其中一个是空字符串另一个却能返回一长串内容。建议所有还在新代码里写substr()的情况看到这里就改成slice()或substring()。3.2 工程里的选型原则什么场景用哪个既然三兄弟各有脾气工程实践中到底该用哪个我个人的选型原则是这样从尾部倒着截取无脑用slice()只要是负索引需求只有slice()能做substring()没戏。新代码里优先统一用slice()它的行为最符合程序员直觉支持负索引也没有诡异的自动交换和undefined陷阱团队成员上手成本最低。不排斥substring()但必须知道它的边界规则如果团队里有人特别熟悉substring()并且代码里明确需要利用“自动交换”的容错能力比如处理可能传反的参数那么继续用它也没问题只是要把规则写在注释里。禁止substr()没有例外。哪怕是已经上了线的老代码也建议在下次重构时顺手替换掉。技术选型不是追求“越高级越好”而是追求“团队里所有人都能写出可预期的代码”。你选了substring()就要让所有人记住它的三条纠偏规则选了slice()规则更少更直观出问题的概率自然更低。4. 原理解剖字符串不可变、返回新串以及 V8 里的内存细节4.1 为什么 substring() 永远不会改动原字符串JavaScript 里的字符串是不可变类型。所谓不可变意思是一旦字符串创建它的内部字符序列就无法再被修改。你看到的每个“修改字符串”操作底层其实都是创建了一个新字符串然后把变量指向它原字符串本身并没有动。substring()也不例外。当你调用const original Hello, JavaScript; const part original.substring(7); console.log(original); // Hello, JavaScript 原串没有任何变化 console.log(part); // JavaScript 返回的是全新字符串理解了这个机制你在写代码时就会特别留意“是否需要保存原串”这个问题。比如在做文本高亮、脱敏这类需要同时展示“原内容”和“处理后内容”的场景你完全不需要担心substring()会把原数据搞坏放心地把返回值赋给新变量即可。4.2 截取长字符串后的小碎片一个容易忽略的性能细节以 V8 引擎为例字符串在底层有两种常见表示形式扁平字符串Flat String和切片字符串Sliced String。当你在 V8 里调用substring()或slice()截取一个较长字符串的一部分时如果结果长度小于原始长度的某个比例老版本 V8 是结果长度小于等于原始长度的一半且结果长度大于等于某个阈值不同版本细节有差异引擎并不总是立刻把字符复制到一块新内存里而是可能创建一个指向原始字符串的“切片”对象。这个优化本身是为了减少内存拷贝但会产生一个非常隐蔽的副作用如果你截取了一个 100MB 大字符串的其中 100 字节并且把这个 100 字节的小切片保存到长期引用的全局变量里那 100MB 的原始内存就无法被垃圾回收因为它还被那个小切片悄悄引用着。在现代 V8 版本里相关问题已经有所缓解但“截取大字符串后保留小切片导致大字符串迟迟无法释放”这个问题在线上环境依然有案例可查。如果你要做的是把大文件内容或长响应体切出一小段并长期持有建议主动“强制扁平化”const bigStr ...一个非常非常大的字符串...; const tiny bigStr.substring(0, 100) ; // 通过拼接强制生成新字符串加一个空字符串驱动引擎把切片内容拷贝成独立的扁平字符串从而切断对原始大字符串的引用。这个操作成本不高但能有效避免内存水位下不去的问题。5. 实战上手脱敏、截断、提取字段代码直接抄5.1 手机号与身份证脱敏用户在后台列表里看到自己的完整手机号大概率是要投诉的。脱敏是后台管理系统里最高频的字符串场景之一用substring()实现起来非常干净function maskPhone(phone) { if (typeof phone ! string || phone.length ! 11) { return phone; } return phone.substring(0, 3) **** phone.substring(7); } function maskIdCard(id) { if (typeof id ! string || id.length 8) { return id; } return id.substring(0, 6) ******** id.substring(id.length - 4); } console.log(maskPhone(13812345678)); // 138****5678 console.log(maskIdCard(110101199001011234)); // 110101********1234这里对入参做了长度和类型校验是一个很容易被忽略的细节。很多新手直接phone.substring(0, 3)就用如果后端临时返回了不完整数据轻则页面显示异常重则把脱敏逻辑带崩。任何字符串方法在调用前都要先确认来源数据是否符合预期。5.2 提取文件名扩展名与 URL 域名提取文件扩展名也是substring()的经典应用。核心思路是先用lastIndexOf(.)找到最后一个点的位置再从这个位置往后取function getFileExtension(filename) { const idx filename.lastIndexOf(.); if (idx -1) { return ; } return filename.substring(idx 1).toLowerCase(); } console.log(getFileExtension(report.2024.final.pdf)); // pdf console.log(getFileExtension(README)); // 注意这里没有用idx之后“从头取几个字符”的思路而是直接把起始位置传进去从.后面一路取到末尾天然适配任意长度的扩展名。如果是处理 URL 域名我更推荐用URL对象而不是手写正则或substringconst url new URL(https://blog.example.com/article/123); console.log(url.hostname); // blog.example.com规范 API 能帮你省掉大量边界判断。不过如果你被限制在不支持URL的环境里再考虑用substring()配合indexOf()手动切。5.3 商品标题截断显示省略号电商列表页的商品标题一般有 2 到 3 行的高度限制超长就要截断加省略号。一个通用截断函数长这样function truncateText(text, maxLength) { if (text.length maxLength) { return text; } const end maxLength - 1; return text.substring(0, end) …; } console.log(truncateText(这是很长的商品标题这是很长的商品标题, 10)); // 这是很长的商品标…这个函数最核心的细节在end maxLength - 1给省略号留一个字符的位置否则截出来 10 个字符又加一个省略号总长度就是 11视觉上反而溢出。这个“减一”的细节是很多人第一次写截断函数时会漏掉的地方。6. Unicode 与 emojisubstring() 最容易被忽视的“假截断”6.1 length 按 UTF-16 码元计数结果可能截出乱码String.prototype.length返回的并不是“字符个数”而是 UTF-16 编码单元的数量。绝大多数常用字符包括中英文和数字在 UTF-16 里占用一个码元所以length看起来挺正常。但 emoji、部分生僻字、特殊符号会占用两个码元问题就来了const emoji ; console.log(emoji.length); // 2 console.log(emoji.substring(0, 1)); // \ud83d单独一个无效码元 console.log(emoji.substring(1, 2)); // \ude00打印出来就是乱码也就是说用substring()去切包含 emoji 的字符串很容易把一个 emoji 从中间“腰斩”造成页面上出现替换字符、问号、方框等乱码。这种 bug 极难复现因为只在用户头衔、昵称、评论等带 emoji 的内容里才会出现。6.2 按码点截断Array.from 能帮上大忙要解决这个问题思路是把字符串先转成“真字符”数组再按数组下标截取最后重新拼回字符串。最朴素的实现是Array.from()const chars Array.from(用户abc); console.log(chars); // [, 用, 户, a, b, c] console.log(chars.length); // 6 console.log(chars.slice(0, 3).join()); // 用户Array.from()会按“码点”而不是“码元”来拆字符串所以 emoji 会被完整当成一个元素。类似的工具还有[...str]展开语法效果一样。但注意简单的“按码点截断”仍然无法处理“多个码点组成一个字符”的情况比如家庭 emoji ‍‍‍ 由多个码点通过零宽连接符组合而成Array.from()也会把它拆成好几个部分。如果你的业务必须精确处理这类“字素簇”可以考虑使用Intl.Segmenter这个国际化 API按照语言规则进行真正的字素分割const segmenter new Intl.Segmenter(zh-CN, { granularity: grapheme }); const parts Array.from(segmenter.segment(‍‍‍ 一起旅行)); console.log(parts); // 能正确按视觉字符切分6.3 带 emoji 的安全截断函数综合这些经验一个能用于生产环境的截断函数至少要处理“码点完整”这一个层次。我在项目里常用的是这样function safeTruncate(text, maxLength) { const chars Array.from(text); if (chars.length maxLength) { return text; } return chars.slice(0, maxLength - 1).join() …; } console.log(safeTruncate( 这是一个需要截断的标题, 6)); // …虽然它还不能做到对所有组合 emoji 都按视觉字符切分但足以处理绝大多数真实用户输入中的单个 emoji。如果你连组合 emoji 也要保再升级到Intl.Segmenter原理就是“先把字符串拆成更接近视觉字符的数组再重新组合”思路完全一致。7. 踩坑复盘我在生产环境里处理过的三个 substring 事故7.1 把 end 当长度用列表标题集体“短一截”之前接手过一个后台管理系统列表页的商品标题总是不对明明代码里写的是截取 10 个字符页面上却显示 9 个字符加省略号。找了一圈问题出在这样一段代码title.substring(0, title.length - 1) …写这段代码的人本意是“保留前 10 个字符后面加省略号”但他在思考时把substring的第二个参数当成了“要保留多少个字符”。当title长度为 11 时title.substring(0, 10)只能取出 10 个字符再加省略号总共才显示 11 个字符里的 10 个内容字符和“保留 10 个字符”的预期一致——但一旦标题长度从 11 变成 12结果马上就变成只保留 9 个内容字符了。这种问题的根因就是“把 end 下标和长度混为一谈”。substring(0, n)确实能取出 n 个字符前提是截取起点为 0所以“看起来像取了 n 个”一旦截取起点不是 0就再也对不上了。7.2 动态截取时参数顺序倒置自动交换掩盖了真 Bug另一个项目里我见过一段从数据流里提取关键位置内容的代码核心逻辑类似const start findPosition(data, [); const end findPosition(data, ]); const content data.substring(start, end);正常情况下它运行得很好。直到有一天数据源格式变化某个记录里右括号出现在左括号之前start变得比end大此时substring()不报错自动交换后把这两个位置之间的内容取了出来。从结果看确实也取出了一段“看起来有价值”的文本于是这个问题上线后很长时间都没人发现直到用户反馈解析结果时对时不对。复盘时我们意识到这个场景的期望行为应该是“解析失败并给出提示”而不是“静默返回一个可疑结果”。自动交换规则在这里扮演了帮凶。如果当时用的是slice()start end时会返回空字符串测试阶段就能发现异常。所以我后来在写解析类代码时会有意识地避免使用具有“自动纠偏”能力的方法让非法输入尽早暴露。7.3 老代码里的 substr 和 substring 只差一个字线上文本被截成乱码最后这个事故最有戏剧性。一个运营活动 H5 页面用户生成海报时上面的昵称总是变成乱码而且只发生在部分安卓机型上。我们顺着代码找发现业务同学从老项目里复制了一段“截断昵称”的工具函数return nickname.substr(0, 6) …;看报错现场昵称是 emoji 开头substr(0, 6)按 UTF-16 码元去数刚好把一个 emoji 从中间截断于是乱码。第一反应是“换substring”但真要换成substring(0, 6)也一样会截坏因为问题不在方法选择而在于没有按码点处理 emoji。这段经历给我的教训足够深刻老旧工具函数里的字符串截断既可能是substr的锅也可能是 emoji 的锅排查时两个方向都要看。如果你现在正维护着大量老代码建议全局搜索一下substr(的调用点逐个改成slice()或substring()同时把“按码点截断”的原则写进团队的代码规范里。最后分享一个我自己的习惯凡是写substring()我都会顺手在注释里写明两个参数的语义尤其是当 start 和 end 来自变量时。因为代码跑对了不代表逻辑对这个方法的自动交换太擅长粉饰 bug 了。希望这篇把substring()的脾气讲清楚之后大家都能少踩几个坑。
返回列表