news 2026/10/5 7:09:42

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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)); // "cde"

slice()和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对象而不是手写正则或substring:

const 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 与 emoji:substring() 最容易被忽视的“假截断”

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()的脾气讲清楚之后,大家都能少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 4:22:42

JSP+Servlet外卖系统:四角色协同与订单状态机实战

简介&#xff1a;这是一套基于Java Web技术栈开发的完整外卖订餐系统实战项目&#xff0c;面向Java初学者与Web开发入门者&#xff0c;帮助掌握JSP、Servlet、MySQL及MVC分层架构在真实业务场景中的落地应用。资源包为ZIP格式&#xff0c;大小93.63MB&#xff0c;包含源代码、数…

作者头像 李华
网站建设 2026/10/4 4:22:05

MATLAB高频问题与算法实战:从安装License到图像处理

1. 安装、激活与License报错&#xff1a;从根源上解决“装不上、打不开”用MATLAB这些年&#xff0c;我见过最多的求助帖基本都集中在同一个阶段——软件刚下载完&#xff0c;还没来得及体验矩阵运算的爽快&#xff0c;就被安装和激活流程按在地上摩擦。尤其是这几年新版本迭代…

作者头像 李华
网站建设 2026/10/4 4:18:57

ACPI设备子树恢复:解析_CTXT还原与gReadyQueue调度机制

1. 问题现象&#xff1a;一条让你摸不着头脑的内核日志先说我是在什么场景下碰到这个问题的。一台跑着较新内核的服务器&#xff0c;固件里用了比较完整的ACPI表&#xff0c;系统在空闲状态下会自动触发PCIe设备的电源状态迁移&#xff0c;部分设备会进入D3cold。某次我在抓电源…

作者头像 李华
网站建设 2026/10/4 4:17:36

微信小程序点餐系统毕业设计:Java后端与数据库实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 4:16:44

基因组重复序列注释全流程:RepeatModeler建库与RepeatMasker实战指南

拿到一个刚组装的基因组&#xff0c;第一步不是急着做基因注释&#xff0c;而是先把里面的重复序列全部“揪出来”。这个问题我吃过亏&#xff1a;早期做某个非模式物种基因组时&#xff0c;直接拿Repbase数据库跑了RepeatMasker&#xff0c;结果后来做基因结构预测时&#xff…

作者头像 李华