最近在维护一个订单系统,表单里"联系电话"字段被用户投诉了好几轮。明明填了"0755-12345678"这种标准座机号,系统却提示格式错误;而"123-12345678"这种明显不合理的号码反倒能提交成功。定位半天,问题就出在JS固定电话正则写得不够细致:区号没做约束、分隔符只考虑了短横线、分机号直接没处理。今天把这块经验完整整理出来,从最常见的固定电话格式讲起,到一份可以直接抄进项目里的正则表达式,再到上线之后我踩过的几个真实大坑。
1. 先搞清楚要匹配什么:固定电话的常见形态
写正则之前最忌讳的事,就是上来就写\d{3,4}-\d{7,8}。正则的本质是"对业务规则的抽象表达",业务规则没理清,正则写得再漂亮也是错的。所以我先花点时间捋一下国内固定电话到底长什么样。
1.1 一个座机号由哪几部分组成
一个完整的国内固定电话由三块组成:
- 区号:以0开头,后面跟2到3位数字,整体长度是3到4位。比如010、021这种三位区号,以及0755、0312这种四位区号。
- 本地号码:7到8位数字。不同城市不一样,但业务上基本都能用
\d{7,8}覆盖。 - 分机号:通过分隔符连接,常见是1到5位数字。不是每个座机都有分机,所以整个分机部分应该是可选的。
用户在实际填写时,格式比我们想象的丰富得多。区号可能加括号变成(010) 12345678,连接符可能用空格,也可能什么都不写直接01012345678。更麻烦的是分机号,有人写-1234,有人写转1234,还有人用"12345678分机123"这种描述。
1.2 主流格式和"要不要带区号"的取舍
先把最常见的几种输入形态列出来:
| 格式示例 | 说明 |
|---|---|
| 010-12345678 | 最标准,三位区号加短横线 |
| 0755-12345678 | 四位区号加八位号码 |
| (010) 12345678 | 括号区号加空格 |
| 0312-1234567 | 四位区号加七位号码 |
| 010-12345678-1234 | 带分机号 |
| 0755 12345678 | 用空格做分隔符 |
这里有一个业务取舍要提前做:要不要接收不带区号的本地座机号。很多内部系统只要求填"12345678"这种七位或八位号码,但从校验角度我不建议把"无区号"和"带区号"混在同一个正则里。原因很直接:/^\d{7,8}$/会把QQ号、银行账号、一堆乱七八糟的数字串全部放行,等于没校验。更合理的做法是默认要求带区号,本地号码单独走一个规则,互不干扰。
提示:如果你的业务确实允许用户不填区号,别改一个正则去兼容,而是用两个正则分别校验,这样错误提示也能区分开:"请填写完整区号"和"本地号码格式不正确"是完全不同的两个提示文案。
2. 正则一步一步长这样:五个版本的演进对比
我比较推荐用演进的方式来写正则,每加一个需求就动一次正则,别想着一步到位。这样每个符号的作用都很清楚,后面维护的人(包括三个月后的自己)也能看懂当初为什么这么写。
2.1 第一版:先把骨架搭起来
const basicRegex = /^\d{3,4}-\d{7,8}$/; console.log(basicRegex.test('010-12345678')); // true console.log(basicRegex.test('0755-12345678')); // true console.log(basicRegex.test('123-12345678')); // true,问题出在这里第一版的问题非常明显:\d{3,4}匹配的是"任意3到4位数字",所以123-12345678这种区号完全不合理的号码也能通过。这个版本只能算骨架,明白结构长什么样,但绝对不能上生产。
2.2 第二版:区号必须0开头
const areaRegex = /^0\d{2,3}-\d{7,8}$/; console.log(areaRegex.test('010-12345678')); // true console.log(areaRegex.test('0755-12345678')); // true console.log(areaRegex.test('123-12345678')); // false关键改动就一个:0\d{2,3}。这个表达式读起来是"一个0,后跟2到3位数字",对应到业务上正好是"以0开头的3到4位区号"。010就是0加10,0755就是0加755。
如果你对区号规则比较较真,可以更严格一点,写成0[1-9]\d{1,2},这样区号的第二位不可能是0或1。但要注意一个例外:010的第二位就是1,所以这个写法会把北京区号排除掉。实际项目中0\d{2,3}基本够用,区号合法性一般靠数据表去校验,而不是靠正则穷举。
2.3 第三版:兼容括号和空格
用户不会按标准格式填写,这是铁律。第三版把括号区号和空格分隔都接进来:
const flexibleRegex = /^(?:\(0\d{2,3}\)|0\d{2,3})[\s-]?\d{7,8}$/; console.log(flexibleRegex.test('010-12345678')); // true console.log(flexibleRegex.test('(010) 12345678')); // true console.log(flexibleRegex.test('0755 12345678')); // true这版的核心是(?:\(0\d{2,3}\)|0\d{2,3}),用分支|把"带括号"和"不带括号"两种情况都列出来。注意这里用的是(?:...)非捕获组,而不是普通的(...),原因后面专门讲。[\s-]?表示连接符要么是空格,要么是短横线,也可以没有。
2.4 第四版:接住分机号
分机号是固定电话里最容易被忽略的部分。添加分机支持后,正则变成:
const extensionRegex = /^(?:\(0\d{2,3}\)|0\d{2,3})[\s-]?\d{7,8}(?:[\s-]?\d{1,5})?$/; console.log(extensionRegex.test('010-12345678-1234')); // true console.log(extensionRegex.test('0312-1234567-12')); // true(分机号短也没关系) console.log(extensionRegex.test('010-12345678')); // true分机部分(?:[\s-]?\d{1,5})?整体用?包起来,表示"可选"。[\s-]?负责处理分机号前的分隔符。这里我故意把分机位数放宽到\d{1,5},因为实际业务中真有人填2位分机号,你要是写成\d{3,5},人家好好的分机直接被判非法,体验很差。
2.5 最终版本和更严格的校验函数
综合所有需求之后,我项目里落地的最终版本长这样:
const LANDLINE_REGEX = /^(?:\(0\d{2,3}\)|0\d{2,3})[\s-]?\d{7,8}(?:[\s-]?\d{1,5})?$/; function isLandline(input) { return LANDLINE_REGEX.test(String(input).trim()); }这个版本能覆盖:010-12345678、0755-12345678、(010) 12345678、0312-1234567-1234、0755 12345678。
如果你的业务对区号和号码位数有强要求,比如"三位区号必须配八位号码",可以在正则外面再做一层判断:
const THREE_DIGIT_AREA_CODES = new Set(['010', '020', '021', '022', '023', '024', '025', '027', '028', '029']); function isLandlineStrict(input) { const value = String(input).trim(); const match = value.match(/^(?:\(0(\d{2,3})\)|0(\d{2,3}))[\s-]?(\d{7,8})(?:[\s-]?(\d{1,5}))?$/); if (!match) return false; const areaCode = match[1] ? '0' + match[1] : '0' + match[2]; const number = match[3]; if (THREE_DIGIT_AREA_CODES.has(areaCode) && number.length !== 8) { return false; } return true; }三位区号的城市基本就上面Set里那10个,其余地区都是四位区号。这套严格版适合做地址库或CRM系统的二次校验,普通表单用前面的宽松版就够了。五个版本放一起对比更直观:
| 版本 | 正则核心 | 能匹配 | 明显缺陷 |
|---|---|---|---|
| 骨架版 | ^\d{3,4}-\d{7,8}$ | 010-12345678 | 区号无0开头约束 |
| 0开头版 | ^0\d{2,3}-... | 0755-12345678 | 不支持括号、空格、分机 |
| 灵活版 | (?:\(0\d{2,3}\)|0\d{2,3}) | (010) 12345678 | 不支持分机 |
| 扩展版 | 加(?:[\s-]?\d{1,5})? | 010-12345678-1234 | 已经比较完整 |
| 严格版 | 函数内再判断区号位数 | 010-12345678 | 代码量增加 |
3. 每个符号都有讲究:正则细节拆解
初学者最容易犯的毛病是"抄一个正就能用,坏了不知道改哪里"。正则这东西,每个符号背后都有业务逻辑,拆开讲清楚比直接甩结论有用得多。
3.1 为什么区号写0\d{2,3}而不是\d{3,4}
\d{3,4}匹配的是"3到4位任意数字",它不关心第一位是不是0。而国内固定电话区号有一个硬性特征:必须以0开头。所以0\d{2,3}读作"0开头,后面2到3位数字",直接就把123-12345678这类脏数据挡在门外。
有人问,那写成0[1-9]\d{1,2}不是更严谨吗?从区号分配规则来说确实更严谨,区号第二位一般不会是0或1(010是历史遗留)。但我觉得正则要解决的只是"格式是否合法",区号到底存不存在应该交给数据校验层去查表,正则里强行做太细反而增加维护成本。合适的粒度才是好正则。
3.2 非捕获组(?:)的实用价值
这是我强烈建议所有写正则的人养成习惯的一点。看这两行:
// 用普通括号 const m1 = '010-12345678'.match(/(\(0\d{2,3}\)|0\d{2,3})[\s-]?(\d{7,8})/); // m1[1] 可能是 '(010)' 或 '010',m1[2] 才是本地号码 // 用非捕获组 const m2 = '010-12345678'.match(/(?:\(0\d{2,3}\)|0\d{2,3})[\s-]?(\d{7,8})/); // m2[1] 直接就是 '12345678'普通括号(...)会生成捕获组,match返回的数组里会多出几个元素,replace里的$1、$2引用也会跟着乱。非捕获组(?:...)只负责分组,不参与捕获,让真正需要捕获的内容索引保持稳定。在正则里,能用非捕获组的地方尽量别用捕获组,这是所有老手的共识。
3.3 连字符转义与字符组里的位置陷阱
-在正则的字符组[]外并没有特殊含义,直接写-也能匹配到短横线。但我建议养成写\-的习惯,理由很实际:看代码的人第一眼可能分不清这个-是想匹配短横线,还是想表达"从哪到哪"的范围。
在字符组[]里,-的位置就有讲究了。[\s-]把-放在最后,它就是一个普通字符;但如果你写成[-\s],虽然也能工作,可一旦后面加内容变成[a-z\s],-瞬间变成了范围连接符。最稳妥的写法是永远把-放在字符组的开头或结尾:
/[\s-]?/ // 推荐,- 在末尾 /[-\s]?/ // 也可以,但可读性差一点 /[a-z]/ // 这时候 - 是范围,不是字符3.4 test()带g标志的隐蔽Bug
这可能是整篇文章里最值钱的一个坑。看这段代码:
const regex = /^\d{7,8}$/g; console.log(regex.test('12345678')); // true regex.lastIndex; // 8 console.log(regex.test('12345678')); // false,因为 lastIndex 已经被改到末尾正则对象加了g标志之后,test()方法会维护一个lastIndex,上次匹配到哪,下次就从哪开始。同一个正则对象在校验函数里被调用两次,第一次返回true,第二次就可能返回false,而且这种Bug不是必现的,非常难排查。
解决办法很粗暴:做表单校验时,正则永远不要加g标志。g标志只在match()、replace()这类需要全局处理的方法里才需要。如果你希望正则复用,用const声明且不加g,就可以放心多次调用test()。
3.5 文本提取时的正向断言和反向断言
当你要从一段文本里提取座机号,而不是校验整个字符串时,情况又变了。直接match加g很容易出现"子串误匹配"——比如文本里有个8010-12345678,标准正则可能会从第二个字符开始,匹配出010-12345678。
解决方案是用断言卡住边界:
const extractRegex = /(?<!\d)(?:\(0\d{2,3}\)|0\d{2,3})[\s-]?\d{7,8}(?:[\s-]?\d{1,5})?(?!\d)/g; const text = '联系010-12345678或0755-12345678,备用8010-12345678'; const matches = text.match(extractRegex); console.log(matches); // ['010-12345678', '0755-12345678'](?<!\d)是负向后行断言,表示"前面不能是数字";(?!\d)是负向前行断言,表示"后面不能是数字"。
注意:后行断言是ES2018才支持的语法,如果你的项目还要兼容老版本浏览器或旧安卓WebView,就别用
(?<!\d)。替代方案是正常match之后,再用indexOf拿到每个匹配项的位置,手工检查前一个字符和后一个字符是不是数字,效果一样,只是代码多一点。
4. 把正则真正放进项目:校验、提取、脱敏与格式化
正则写出来只是第一步。实际项目里,同一个座机号要面对表单校验、数据清洗、展示脱敏、存储格式化这些完全不同的场景。下面这几种写法都是我直接抄进生产项目里用过的,可以放心参考。
4.1 表单校验的完整写法
表单校验最容易遗漏的是trim()。用户从输入框里复制粘贴一个号码,前后很可能带着看不见的空格,直接test()必然失败。所以标准写法是先清理再校验:
const LANDLINE_REGEX = /^(?:\(0\d{2,3}\)|0\d{2,3})[\s-]?\d{7,8}(?:[\s-]?\d{1,5})?$/; const MOBILE_REGEX = /^1[3-9]\d{9}$/; function validateContact(value) { const input = String(value).trim(); if (!input) return '请输入联系电话'; if (LANDLINE_REGEX.test(input) || MOBILE_REGEX.test(input)) return ''; return '联系电话格式不正确,座机示例:010-12345678'; }如果表单允许手机号和座机号都填,最好分开校验,因为错误提示可以更精准:"手机号格式不对"和"座机号格式不对"是两种完全不同的体验。千万别图省事写一个正则同时匹配两者,维护起来全是坑。
4.2 从一段话里批量提取座机号
我在做客户留言解析时遇到过需求:用户可能在备注里写"请打0755-12345678联系我",或者"传真010-12345678转1234"。正则加g标志批量提取就行:
function extractLandline(text) { const regex = /(?<!\d)(?:\(0\d{2,3}\)|0\d{2,3})[\s-]?\d{7,8}(?:[\s-]?\d{1,5})?(?!\d)/g; const matches = String(text).match(regex) || []; return [...new Set(matches)]; // 去重 }返回结果用Set去重很有必要,因为同一段文本里同一号码可能出现多次,提取出来做匹配时重复项会导致下游逻辑出问题。另外记得match找不到结果会返回null,用|| []兜底是常规操作。
4.3 座机号脱敏怎么处理
客服工单系统里经常要把座机号打码后展示给非授权人员。脱敏逻辑通常是:区号保留,本地号码保留开头几位,中间打码。八位和七位号码长度不一样,所以最好分开处理:
function maskLandline(phone) { const value = String(phone).trim(); const match8 = value.match(/^((?:\(0\d{2,3}\)|0\d{2,3})[\s-]?)(\d{3})\d{5}$/); if (match8) return match8[1] + match8[2] + '*****'; const match7 = value.match(/^((?:\(0\d{2,3}\)|0\d{2,3})[\s-]?)(\d{3})\d{4}$/); if (match7) return match7[1] + match7[2] + '****'; return value; } console.log(maskLandline('0755-12345678')); // 0755-123***** console.log(maskLandline('0312-1234567')); // 0312-123****这种写法依赖捕获组$1拿区号前缀、$2拿前三位号码,然后手动拼上星号。比在正则里直接做替换更直观,也更好控制保留位数。
4.4 多种输入统一格式化的实现
存储座机号之前,最好把用户输入的千奇百怪格式统一成标准形式。我常用的转换函数长这样:
function normalizeLandline(phone) { const value = String(phone) .trim() .replace(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xFEE0)); const match = value.match(/^\(?0(\d{2,3})\)?[\s-]?(\d{7,8})(?:[\s-]?(\d{1,5}))?$/); if (!match) return value; return '0' + match[1] + '-' + match[2] + (match[3] ? '-' + match[3] : ''); } console.log(normalizeLandline('(010) 12345678')); // 010-12345678 console.log(normalizeLandline('0755 12345678')); // 0755-12345678 console.log(normalizeLandline('0312-1234567-1234')); // 0312-1234567-1234注意match[1]捕获的是区号里0后面的数字,拼上去的时候要手动补回0。这套转换在数据入库前跑一遍,后续查询统计都会省心很多。
5. 真正上线后的踩坑记录:这些坑排错排到怀疑人生
正则本身写对,不代表线上就不会出事。下面这几个问题全是真实环境里碰到的,每一个都值得记进自己的排查清单。
5.1 全角数字和全角横杠
移动端输入法默认全角模式的时候,用户会输入0755-12345678这种"看起来完全正常"的号码。\d只能匹配ASCII数字,全角数字直接校验失败。
最省事的处理方式是用normalize('NFKC'),一键把全角字符转成半角:
'0755-12345678'.normalize('NFKC'); // '0755-12345678'如果不方便用normalize,也可以用字符码偏移:
str.replace(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xFEE0));全角横杠-也建议在预处理阶段统一替换成半角-,否则正则里的[\s-]根本匹配不到。
5.2 移动端自动填充带来的意外格式
给<input type="tel" autocomplete="tel">做校验时,浏览器自动填充可能会填出各种意外格式:带国家码的+86 0755 12345678、带括号的(0755) 12345678,甚至把姓名、邮箱一起塞进来。解决思路是"先洗净,再校验":
function cleanPhoneInput(value) { return String(value) .trim() .replace(/^\+?86[\s-]?/, '') // 去掉+86或86前缀 .replace(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xFEE0)); }如果业务上确实需要支持+86,那就在主正则前面加一段(?:\+?86[\s-]?)?,两种策略都可以,但一定要在项目里明确写清楚,不然下次接手的同事会一脸懵。
5.3 手机号、座机号和parseInt的几个边界
手机号是1开头,座机号是0开头,这两种号码在正则层面天然不会混淆,但业务层容易出问题。比如"联系电话"字段既允许手机又允许座机时,很多人会先判断/^1\d{10}$/,不是手机号再判断座机正则。这里有个小陷阱:如果你的手机号正则写成/^1\d{10}$/,那么010-12345678不会被误判成手机号,但某些比较老的号码段如果不小心写成/^1[3-9]\d{9}$/之外的形式,可能两头都不匹配。
还有一个和正则无关但和号码处理强相关的坑:别用parseInt处理电话号码。parseInt('010')在部分环境下会得到10(八进制解析),甚至直接报错。电话号码处理永远走字符串方法,slice、replace、match都可以,就是别转数字。
5.4 400/800电话别和座机号混在一起校验
如果业务涉及客服系统,用户可能填400-123-4567这类企业服务热线。注意这不是传统意义上的固定电话,不该混进同一个座机正则里。混在一起的结果就是:校验规则越来越复杂,最后谁都维护不了。
我一般会单独建一个正则:
const SERVICE_LINE_REGEX = /^400[\s-]?\d{3}[\s-]?\d{3,4}$/;然后在校验函数里按顺序判断:400热线、800热线、座机、手机,各走各的规则。号码类型直接分清楚,比在一个正则里堆|分支要清晰得多。
5.5 前后端正则不一致的教训
最后分享一个让我印象深刻的线上事故。前端表单用的是\d{7,8}做校验,后端Java接口用的是\d{7},结果所有7位号码都没问题,8位号码前端放行、后端拦截,用户提交之后就卡在"系统繁忙"的提示上。排查到最后才发现是前后端两套正则位数不一致。
现在的做法是:同一个正则规则在项目里只维护一份,前端JS、后端Java各自引用同一份规则描述,至少要把正则的版本号和允许格式写在注释里。我甚至会为核心校验函数写几个固定用例:
const testCases = [ ['010-12345678', true], ['0755-12345678', true], ['0312-1234567', true], ['010-1234567-1234', true], ['123-12345678', false], ['01012345678', true] ];每次改了正则,把这组用例跑一遍再提交。正则这种"写的时候很爽、维护的时候想哭"的东西,有测试用例兜底,比什么经验都管用。
这五个方向的坑,每一个都对应着真实用户输入和线上故障。座机号这个字段看似不起眼,做细了能省掉大量客服解释成本。写正则的底线是:宁可严格一点让用户补全格式,也不要宽松到把脏数据放进系统里。