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