半夜被设备台账折磨到怀疑人生这种事,估计不少搞过生产管理或数据处理的小伙伴都经历过:标准要求编号格式统一成“前缀-三位流水号”,结果表格里什么妖魔鬼怪都有,MD-1、MD_23、配件MD-12保留、MD-3-2021……人工对着标准改到眼花,还免不了漏改错改。后来我用WPS JS宏,配合padStart()、padEnd()和正则零宽断言,把整个规范化过程写成了脚本,几千行数据几分钟跑完,再也不用半夜加班。这篇文章就把这套做法的思路、代码实现、以及我实际踩过的坑完整记录下来,给有同样需求的朋友做个参考。
1. 为什么要写编号规范化宏:一张让人头大的物料台账
1.1 编号乱象的典型表现
所谓的“规范编号”,看着是个小事,一旦数据量上来,就是个纯纯的体力活。我最常碰到的编号问题有这么几类:
- 位数不齐:同一个前缀下,有的是1位,有的是2位,有的是4位。比如
MD-1、MD-12、MD-1234,流水号该补零的没补,导致排序一塌糊涂。文本排序时MD-10永远排在MD-9前面,统计和筛选全是坑。 - 分隔符混乱:同一个编码体系里,今天用
MD-12,明天用MD_12,后天又冒出个MD.12。维护的人换了三波,谁都有自己的习惯,结果就是表里一片混乱。 - 描述文字掺杂:最麻烦的一种,编号不是单独占一列,而是跟描述文字混在一起,比如
配件 MD-12 保留、设备EQ-33-待检修。这种你没法只对整列做格式化,得先从文本里把编号抠出来再处理。 - 带业务后缀的复合编号:比如
MD-3-2021,前面的3是流水号,后面的2021是年份,两个数字段的处理规则不一样,如果一刀切全部补零,年份也会被改乱。
这些乱象混在一起,用Excel自带的文本函数一条条捋,工作量巨大;用手工改,眼睛容易花,还极易出错。我们当时的标准其实很简单:前缀统一大写,流水号补零到3位,年份保持4位,但就是这点“简单”要求,几万条数据让我改了整整一个下午,改完还心里没底。
1.2 为什么不用Excel公式一条条写
可能有人会说,用Excel公式也能做啊,比如IF嵌套LEN再拼RIGHT……确实,简单的补零场景用公式能凑合。但一旦遇到“描述和编号混在一起”“多种分隔符并存”“同一列里有几种不同规则”的情况,公式就会变得极其冗长,而且极易出错。
更关键的问题是,公式逻辑是一列一列铺开的,别人接手的时候根本看不懂。今天要规范的是设备编号,明天要规范采购订单号,后天要规范员工工号,每个场景都得重写一套公式,复用性几乎为零。
WPS表格对正则的支持一直比较有限,官方函数里没有原生的REGEX系列,传统做法只能靠SUBSTITUTE和MID组合硬抠,遇到像“编号前后有描述文字”这种场景就非常痛苦。而WPS JS宏能直接跑JavaScript,正则表达式是原生能力,配合String上的一堆方法,处理这类文本清洗任务手到擒来。
1.3 JS宏在这类任务上的真正优势
在做这类文本规范化任务时,JS宏最大的优势有三个:
一是批量处理能力强。一次读取一个区域的数据,在内存里做完整套逻辑,再一次写回表格,速度快,而且不会因为频繁读写单元格把表格拖垮。
二是逻辑可以函数化。把规范化规则封装成一个函数,下次遇到类似任务,拿过来改一下正则规则就能复用。我今天要写的normalizeCode函数,基本就是从当时的脚本里抽出来的。
三是能处理复杂文本。正则表达式的强大,配合replace回调函数,可以在一次遍历里同时完成“抠编号”“改前缀”“补零”三件事。这在纯公式环境里几乎做不到。
2. padStart()和padEnd():补位方法在WPS里的正确打开方式
2.1 padStart怎么用,为什么编号规范离不开它
padStart是ES2017给字符串加的补位方法,作用是:如果当前字符串长度不足目标长度,就在开头用指定字符补足。语法很简单:
String.prototype.padStart(targetLength, padString)第一个参数是目标长度,第二个参数是补位用的字符串,默认是空格。
比如'12'.padStart(3, '0')得到'012','1'.padStart(3, '0')得到'001','1234'.padStart(3, '0')得到什么?还是'1234'。注意,目标长度小于原字符串长度时,padStart不会截断原字符串。这个特性在编号规范化里特别有用——我们要做的是“不足位补零”,而不是“超长位截断”,padStart的行为恰好匹配这个需求。
在WPS JS宏里,为了处理像MD-1、MD-12这类流水号位数不齐的情况,我会先正则把数字部分抠出来,然后统一走一遍补零逻辑,写成函数就是:
function padNum(numStr) { return String(numStr).padStart(3, '0'); }调用padNum('1')得到'001',调用padNum('1234')得到'1234',完全符合“不足3位补0,超出3位保持原样”的规范化规则。
2.2 padEnd的用武之地:小数位插齐和版本号补全
padEnd和padStart正好相反,它在当前字符串的末尾补位:'12.5'.padEnd(4, '0')。不过这里有个精度陷阱需要特别注意。
假设期望金额格式是保留两位小数,字符串'12.5'要变成'12.50',如果直接写'12.5'.padEnd(4, '0'),得到的其实是'12.50',因为'12.5'长度是4,padEnd(4)不补,'12.5'.padEnd(5, '0')才是'12.50'。问题来了:如果原字符串已经是'12.55',你再调padEnd(5, '0'),得到的是'12.550',三位小数,反而破坏了目标格式。
所以正确的做法是:先用正则精确匹配到只有一位小数的数值,再补一位0,而不是无脑判断长度。我在项目中用的写法是这样:
function padDecimal(text) { return text.replace(/(\d+\.\d)/g, function(match, num) { return num.padEnd(num.length + 1, '0'); }); }这个函数只匹配一位小数的数字,匹配到之后在尾部补1个0,实现了12.5到12.50的转换,又不会误伤已经是两位小数的值。
2.3 三个容易踩的坑
用padStart和padEnd看起来简单,实际用起来有几个坑我真是踩过了才记住:
第一个坑:不传第二个参数,补位默认是空格。'1'.padStart(3)得到的是' 1',不是'001'。编号里混进空格比补齐位数更麻烦,排序、匹配全乱套。所以只要用到这两个方法,第二个参数必须显式传'0'。
第二个坑:数字类型不能直接调这两个方法。(12).padStart(3, '0')会直接报错,因为12是数值类型,不是字符串。表格单元格拿出来的值经常是数值,用之前一定先String()包一层,保险起见我的padNum函数里第一行就做了这个转换。
第三个坑:padString是可以重复填充的。比如'abcdef'.padStart(10, '12'),它会把'12'反复拼上去直到达到目标长度,得到'1212abcde'。如果你不小心传了一个多字符串,补出来的结果可能完全不是想象中那样。好在编号补零用单字符'0',这个坑概率不大,但知道总比不知道好。
3. 零宽断言:从混合文本里精准锁定编号位置
3.1 零宽断言到底是什么
零宽断言,听起来玄乎,其实就是正则里的一种“位置匹配”。普通匹配会“吃掉”字符,而断言只检查“当前位置后面或前面是否符合某个规则”,不消耗任何字符。你可以把它理解成公司门口的保安:保安只检查你工牌上写的部门是不是这个楼的,检查完了不会把你工牌拿走,你还是你,工牌还是工牌。
正则里的零宽断言有四种:
| 名称 | 写法 | 含义 | 匹配位置示例 |
|---|---|---|---|
| 正向前瞻断言 | (?=pattern) | 当前位置后面必须匹配pattern | \d(?=元):匹配数字,但要求数字后面是“元” |
| 负向前瞻断言 | (?!pattern) | 当前位置后面不能匹配pattern | \d(?!元):匹配数字,但不能后面跟“元” |
| 正向后顾断言 | (?<=pattern) | 当前位置前面必须匹配pattern | (?<=¥)\d+:匹配人民币符号后面的数字 |
| 负向后顾断言 | (?<!pattern) | 当前位置前面不能匹配pattern | (?<!¥)\d+:匹配非人民币符号后面的数字 |
3.2 编号提取场景里最常用的几种写法
在“从混合文本里抠编号”这个需求里,零宽断言能解决两类问题:
一是限定编号的边界。比如要提取MD-12-2024里的12,如果直接写/(MD[-_]?)(\d+)/,也能提取到12,因为\d+匹配到-就停了。但如果文本是MD-2024-12这种年份在前的,直接写/\d+/就会先匹配到2024,而不是12。这时候用前瞻断言把位置卡住就有用了,比如要匹配“后面跟了年份的编号”,可以写成:
/(\d+)(?=-20\d{2})/ // 匹配后面紧接 -2022 这种年份格式的编号段二是排除不想要的位置。比如只想处理设备编号MD后的数字,不想动版本号V1.2里的数字,负向前瞻就派上用场了。
我在实际项目里用得最多的还是捕获组和断言结合的方式。原因后面会讲,先看思路:用断言锁定位置,用捕获组拿到需要处理的数字,两者配合,既精准又安全。
3.3 在replace回调里把断言和捕获组结合起来
String.prototype.replace传回调函数时,回调的参数依次是完整匹配、各个捕获组……利用这个特性,可以在一次replace里完成“提取-修改-回填”。这是整个脚本的核心逻辑。
举个例子,文本是配件 MD-12 保留,目标是变成配件 MD-012 保留。正则写成/MD[-_]?(\d+)/gi:
var result = '配件 MD-12 保留'.replace(/MD[-_]?(\d+)/gi, function(match, num) { return 'MD-' + num.padStart(3, '0'); }); // 输出:配件 MD-012 保留这里match是整段匹配到的MD-12,num是捕获组1拿到的12。回调里用num做补零,再拼回固定前缀,replace会替换掉整段match。整个过程行云流水,MD_12、MD.12、md-3这些变体也都能被同一个正则处理掉。
替换之后原文本里不参与匹配的部分(比如“配件”“保留”这些描述文字)原样保留,不需要额外拼接,这就是replace比“先提取再拼接”更省心的地方。
4. 完整落地:从混乱台账到规范编号的JS宏代码
4.1 先把规则说清楚
写代码之前,规则必须定义清楚,否则写一半才发现需求理解偏了,返工成本很高。我当时的输入输出规则是这样定义的:
输入样例(A列):
| 原始文本 |
|---|
| MD-1 |
| MD_12 |
| 配件MD-23保留 |
| md-3-2021 |
| MD_1234 |
| EQ-2 |
输出期望(B列):
| 规范后文本 |
|---|
| MD-001 |
| MD-012 |
| 配件MD-023保留 |
| MD-003-2021 |
| MD-1234 |
| EQ-002 |
规则拆解:
- 前缀统一大写,统一用连字符
-做分隔。 - 流水号补零到3位,超过3位保持原样。
- 年份段(
-20xx后缀)保持4位,不做补零处理。 - 编号前后的描述文字不影响,保留在原位。
4.2 核心处理函数:normalizeCode
基于上面的规则,代码核心就是一个函数。放在WPS JS宏里的写法如下:
function normalizeCode(text) { if (text === null || text === undefined) return ''; var str = String(text).trim(); return str.replace(/(MD|EQ|SP)[-_.]?(\d+)/gi, function(match, prefix, num) { // prefix是匹配到的前缀,统一转大写 var upPrefix = prefix.toUpperCase(); // 流水号补零到3位,超出不截断 var padded = num.padStart(3, '0'); return upPrefix + '-' + padded; }); }这个函数干了三件事:第一步,用/(MD|EQ|SP)[-_.]?(\d+)/gi匹配所有需要处理的前缀和数字,[-_.]?把-、_、.三种分隔符都兼容掉;第二步,前缀toUpperCase统一大写;第三步,数字部分padStart(3, '0')补零。
注意,正则里的g标志很重要,它保证同一行文本里如果出现多个编号,所有编号都会被处理,而不是只处理第一个。比如MD-1 和 EQ-2这么一行,有了g,两个编号都会规范化成MD-001 和 EQ-002。
4.3 批量遍历和写回的完整宏
有了单个文本的处理函数,剩下的就是批量遍历。这一步如果没处理好容易出性能问题,我先把完整代码贴出来再逐段解释:
function 规范编号宏() { var sheet = ActiveSheet; // 找到A列最后一行 var lastRow = sheet.Cells(sheet.Rows.Count, 1).End(xlUp).Row; if (lastRow < 1) lastRow = 1; // 一次性读取A列全部数据到数组 var range = sheet.Range("A1:A" + lastRow); var data = range.Value2; // 判断返回的到底是二维数组还是一维数组 var isMatrix = Array.isArray(data) && Array.isArray(data[0]); var rowCount = isMatrix ? data.length : Math.max(data.length, 1); // 逐个处理,结果放进result数组 var result = []; for (var i = 0; i < rowCount; i++) { var raw = isMatrix ? data[i][0] : data[i]; var normalized = normalizeCode(raw); result.push([normalized]); } // 一次性写回B列 sheet.Range("B1:B" + lastRow).Value2 = result; alert("处理完成,共处理 " + rowCount + " 行数据。"); }这里有几个细节值得好好说。
为什么用End(xlUp)找最后一行:如果你的区域是固定的A1:A1000,但表格里只有200行有数据,后面800个空行也会被处理一遍。空值进normalizeCode会返回空字符串,虽然不影响结果但白白浪费性能。用End(xlUp)从表格最后一行往上找第一个有数据的行,能自动把处理范围压缩到真实数据区。
为什么要一次性读取再写回:如果循环里每次都读一个单元格再写一个单元格,数据量上到几千行之后,宏会肉眼可见地变慢。WPS表格的单元格交互非常耗时,而数组操作在内存里几乎无感。所以正确姿势是:一次性Value2读取到数组,内存里算完,再一次写回。
如何应对Value2返回结构的问题:Range.Value2对多行多列返回的通常是一个二维数组,形如[[第一行], [第二行]];但如果区域只有一列,不同版本的WPS可能返回一维数组,也可能返回二维数组。我代码里用Array.isArray(data[0])判断了一下,能兼容两种返回结构,避免踩到“data[i][0]取不到值”的坑。
4.4 运行之前先做两个小验证
宏写好了别急着全量跑。我的习惯是先在表格末尾找两三行“疑难杂症”文本单独测一下,重点验证四类情况:
- 空单元格:
normalizeCode('')要返回空字符串,不能报错。 - 纯描述文本不带编号:比如“待检修设备”,正则匹配不到,原样返回。
- 已规范过的不重复处理:
MD-001再跑一遍,结果还是MD-001,不能变成MD-0001。用padStart(3)保证了这个幂等性。 - 混合文本:
配件MD-23保留处理后变成配件MD-023保留,描述文字原样保留。
这几类都过了,再全量跑,基本不会出幺蛾子。
5. 实测中踩过的坑与排查链路
5.1 老版本WPS不支持padStart怎么办
我第一次把脚本发给同事,结果他那边一运行就报“对象不支持此方法”。查了一圈,问题出在WPS JS宏内置的JS引擎版本上——padStart和padEnd是ES2017引入的,某些老版本WPS的JS引擎还没有这些方法。
排查思路其实不难:先判断是不是方法缺失,手动执行一下'test'.padStart(2),看控制台报错类型,报TypeError: Object doesn't support property or method基本就实锤了。
解决方案是加polyfill,在脚本开头补一段兜底代码,有原生方法就用原生的,没有就用自己实现的:
if (!String.prototype.padStart) { String.prototype.padStart = function(maxLen, fillStr) { var str = String(this); var len = Math.max(maxLen || 0, str.length); var fill = fillStr === undefined ? ' ' : String(fillStr); if (len <= str.length) return str; var padLen = len - str.length; var repeats = Math.ceil(padLen / fill.length); return fill.repeat(repeats).slice(0, padLen) + str; }; }padEnd同理,只是拼的方向反过来。加了polyfill之后,同事那台旧版WPS也能正常跑,兼容性问题就算翻篇了。写JS宏如果打算给别人用,强烈建议统一加一段polyfill,别赌对方版本比你新。
5.2 后行断言的兼容性问题:看着很美但可能直接报错
这里必须单独拎出来说,因为这是零宽断言实战里最大的一道坎。
零宽断言里的后行断言(?<=...),是ES2018才进标准的,比padStart还晚一年。WPS JS宏的某些内置JS引擎只支持到ES5或ES6,连padStart都没有,后行断言大概率也悬。实测下来,最常见的现象是:宏不报错,但正则静默匹配不到任何东西,导致结果完全没被处理,白白困惑半天。
我的排查链路是这样的:
- 先单独抽出一行数据,用
/(?<=MD[-_]?)(\d+)/测试,用alert看匹配结果。 - 如果发现匹配不上,不要怀疑“是不是正则写错了”,先怀疑“是不是环境不支持后行断言”。
- 在脚本里加一行探针代码:
var probe = /(?<=\d)a/.test('1a'); alert(probe); // 支持返回true,不支持返回false或直接语法报错实测发现探针返回false之后,我彻底放弃后行断言,改用捕获组方案。但注意语法报错时整个脚本都跑不了,这时就别在同一个宏里修了,把探针放到单独的临时宏里测。
替代写法:后行断言能干的活儿,用捕获组基本都能干。以MD-12为例:
// 后行断言写法(环境可能不支持) var result = text.replace(/(?<=MD[-_]?)(\d+)/g, function(m) { return m.padStart(3, '0'); }); // 捕获组写法(兼容性好,推荐) var result = text.replace(/(MD[-_]?)(\d+)/g, function(match, prefix, num) { return prefix + num.padStart(3, '0'); });捕获组写法多套了一个非消耗的前缀捕获组,看起来比断言啰嗦一点,但它的执行逻辑完全不受JS引擎版本限制。在WPS JS宏里,只要能用捕获组,就优先捕获组,别去赌断言兼容性。
5.3 Range.Value2的数组结构比想象中复杂
另一个真实踩过的坑:Range.Value2返回的数组结构不一定是你以为的二维数组。
单列多行区域,Value2在不同版本的WPS里可能返回[["MD-1"], ["MD-2"]]这种嵌套数组,也可能返回["MD-1", "MD-2"]这种一维数组。我最初写的代码固定用data[i][0]取第一列,结果遇到一维数组时直接光脚——data[i]是字符串,data[i][0]变成了字符串的第一个字符,处理出的结果完全莫名其妙。
排查的时候我先用alert(JSON.stringify(data))打印结构,几秒钟就看出了问题。代码里加一个类型守卫就解决了:
var isMatrix = Array.isArray(data) && Array.isArray(data[0]);这个判断在4.3节代码里已经体现了。写宏涉及读取区域值、写区域值,永远先验证数据结构再往下走,这个习惯能省掉大量排错时间。
5.4 循环里逐个写单元格,数据多了宏就“卡死”
初版脚本我图省事,处理完一行就sheet.Range("B" + i).Value2 = result,几百行没感觉,跑到三千行之后,宏明显变慢,到五千行几乎走完一个单元格要停一秒。
原因不复杂:每次写单元格,WPS都要做一次界面刷新、公式重算、单元格状态更新,这个开销比内存操作大几个数量级。正确做法是先全部算完、存到数组里、最后一次性写回目标区域,也就是第4.3节代码里result数组的用法。
如果数据量特别大(比如几万行),还可以在宏开头关掉屏幕刷新:
Application.ScreenUpdating = false; // 中间处理逻辑 Application.ScreenUpdating = true;“批量读、内存算、批量写”这三板斧,是让JS宏从“能用”到“好用”的关键。
6. 参数速查与扩展建议:这套逻辑还能用到哪儿
6.1 一组常用速查表
写这类脚本时反复用到的知识点,我整理成两张表,方便直接抄。
padStart / padEnd 参数速查:
| 方法 | 参数 | 返回值说明 | 典型用途 |
|---|---|---|---|
str.padStart(len, fill) | len整数目标长度,fill补位字符串 | 不足len时头部补fill;超过len时不截断 | 编号补零、月份补零 |
str.padEnd(len, fill) | 同上 | 不足len时尾部补fill;超过时不截断 | 小数位插齐、版本号补零 |
| 注意点 | fill默认空格;数字要先转字符串;多字符fill会重复截取 |
零宽断言四种写法速查:
| 断言 | 写法 | 匹配位置条件 | 兼容性提示 |
|---|---|---|---|
| 正向前瞻 | (?=...) | 后面能匹配... | 老环境普遍支持 |
| 负向前瞻 | (?!...) | 后面不能匹配... | 老环境普遍支持 |
| 正向后顾 | (?<=...) | 前面能匹配... | ES2018才支持,WPS老版本慎用 |
| 负向后顾 | (?<!...) | 前面不能匹配... | ES2018才支持,WPS老版本慎用 |
后顾断言在WPS里用之前一定要测,替代方案就是多套一个捕获组。
6.2 从设备编号扩展到更多业务场景
这套“正则提取+padStart补零+replace回调回填”的组合拳,换个正则、改个补位长度,就能迁移到很多场景:
- 员工工号规范化:
EMP-1、emp_22这类统一成EMP-001。 - 采购订单号:订单号通常有年号前缀,比如
PO-2024-1,把流水号补成PO-2024-001,年份段不受影响,正好是上面复合编号规则的变体。 - 货品编码:分类码加流水码,比如
C01-1变成C01-001,分类码部分保持两位不被打乱。 - 金额小数位统一:报价单里
12.5和12.50混用,用前面4.2节那个padDecimal函数批量统一成两位小数。 - 日期批次编号:
2024-1-1这种日期格式补成2024-01-01,本质上也是padStart(2, '0')的小变体。
迁移的关键就两步:改正则里的前缀模式,改padStart的目标长度。框架结构完全不用动。
6.3 我的两个使用习惯,最后分享出来
最后分享两个没法写进代码注释里的习惯。
第一个习惯:跑宏之前一定先备份原始表格。JS宏一旦回写覆盖了原始数据,Ctrl+Z大部分时候救不回来。我后来不管多急,都先复制一份备份工作簿再运行脚本,几秒钟的事,能避免灾难性的后果。
第二个习惯:把处理函数保存到个人宏文件里。normalizeCode这个函数被我存进了常用宏库,每次遇到编号整理、文本清洗的活儿,直接调用,改改规则就能用。写一次、用三次以上,前期调试的功夫就赚回来了。这也是我为什么建议花时间把逻辑封装成独立函数,而不是写成只跑一次的一次性脚本——函数化的逻辑,才是真正能复用的资产。