前段时间处理一张客服工单导出表时,我碰上一个典型的“看着简单、动手很麻烦”的活儿:几百条记录全部堆在 A1 单元格里,每条记录有编号、日期、客户、电话、金额、备注,中间用竖线分隔,可备注字段里又有换行、逗号、甚至多余竖线。我要把它拆成二维表,日期转成真正可计算的日期,金额转成数值,这就是一次典型的“数据拆分且转换”。折腾到最后,最关键的转折点,是 WPS 的 JSA(JS 宏)里一个正则写法:懒惰匹配。
如果你也经常要处理粘贴进表格里的长文本、日志、工单、订单流水,这篇文章应该对你有用。我做这套脚本时没有用 VBA,直接用 WPS 自带的 JavaScript 宏,也就是 JSA,代码结构比 VBA 清爽不少,正则支持也更符合 JavaScript 的语法习惯。下面我会把思路、代码、踩坑记录都摊开讲,不绕弯子。
1. 一次真实的工单数据清洗,为什么“拆”要比“分列”麻烦
先说原始数据长什么样。在 Sheet1 的 A1 单元格里,放的是下面这段文本(实际当然比这长得多,这里只截取三段做演示):
记录开始|编号=O-1001|日期=2024-08-21|客户=张三|电话=18812341234|金额=199.00|备注=8月21日请工作日送货,联系电话确认 记录开始|编号=O-1002|日期=2024-08-21|客户=李四|电话=13912345678|金额=250.00|备注=包装里放一张手写贺卡 记录开始|编号=O-1003|日期=2024-08-20|客户=王五|电话=13711112222|金额=50.50|备注=备注字段跨了两行 第二行也算备注内容,不要被换行干扰希望得到的目标表格内容,是下面这样的结构:
| 编号 | 日期 | 客户 | 电话 | 金额 | 备注 |
|---|---|---|---|---|---|
| O-1001 | 2024-08-21 | 张三 | 18812341234 | 199 | 8月21日请工作日送货,联系电话确认 |
| O-1002 | 2024-08-21 | 李四 | 13912345678 | 250 | 包装里放一张手写贺卡 |
| O-1003 | 2024-08-20 | 王五 | 13711112222 | 50.5 | 备注字段跨了两行 第二行也算备注内容 |
第一反应肯定是:WPS 自带的分列功能不就能按竖线拆吗?确实可以,但分列有两个硬伤:
- 几千条记录不是一行一条,而是全挤在一个单元格里。如果先查换行符,备注里偏偏又有换行,分列会把记录切得乱七八糟。
- 记录与记录之间没有统一的“行”概念,你要先想办法把每一条记录变成一行,才能用分列。
你也可以说用查找替换,把“记录开始”全部替换成回车,不就行了吗?问题在于备注里的换行会跟着混进来,最后仍然需要对结果做二次清洗。这活儿看着小,实际做起来很考验数据规律。
所以我决定用 WPS 的 JSA 来写一个宏:先把长文本按照“记录开始”到“记录结束”的边界切分成多个独立块,这是“拆分”;再把每个独立块里的字段提取出来,将日期、金额转成 Excel 能识别的类型,这是“转换”。而第一步切片,靠的就是正则表达式里的懒惰匹配。
2. 先把“懒惰匹配”讲透:正则引擎在切分文本时怎么选边界
很多人在网上搜到“懒惰匹配”这个概念,知道写法是*?或+?,但没搞懂它到底解决什么问题。我用一个极简例子说明白。
假设你有一段文本:
记录开始 编号=1001 内容AA 记录结束 记录开始 编号=1002 内容BB 记录结束现在想提取“记录开始”和“记录结束”之间的内容。如果写成贪婪匹配:
记录开始(.*)记录结束正则引擎会一路吞到最后一个“记录结束”,匹配到的结果是:
编号=1001 内容AA 记录结束 记录开始 编号=1002 内容BB这不是你想要的。因为贪婪模式追求“最长匹配”,它会把所有能吞的都吞掉。
如果改成懒惰匹配:
记录开始(.*?)记录结束引擎会老老实实从当前“记录开始”往后找,遇到第一个“记录结束”就停,于是匹配到的是:
编号=1001 内容AA继续执行第二次匹配时,它会接着从上次结束的位置往后找,得到:
编号=1002 内容BB这就是懒惰匹配在数据拆分里的典型用途:把一段连续文本按边界标记切成多个独立记录。它不追求一口气吃完整段数据,而是“找到一个完整单位就先停下来”,配合全局匹配g,一条一条切。
如果用生活例子类比,贪婪匹配像是一个人在自助餐厅里把所有菜品全部夹到盘里,最后才知道哪些能吃;懒惰匹配则是要求他按顺序每样先夹一点,吃完再夹下一盘。对数据解析来说,显然“一盘一盘来”更可控。
在 WPS 的 JSA 里,正则直接使用 JavaScript 语法,惰性写法和 JS 完全一致。上面这个例子用代码写就是这样:
var text = "记录开始 编号=1001 内容AA 记录结束 记录开始 编号=1002 内容BB 记录结束"; var reg = /记录开始([\s\S]*?)记录结束/g; var m; while ((m = reg.exec(text)) !== null) { console.log(m[1]); }这里有个细节很重要:我没有用.*?,而是用[\s\S]*?。原因是 JavaScript 正则里的.默认不匹配换行符,如果文本跨行,.*?会匹配不到完整内容。[\s\S]的意思是“空白字符和所有非空白字符都算”,等价于真正意义的“任意字符”。
3. 在 WPS 里搭一个 JSA 脚本的工作环境
WPS 的 JavaScript 宏入口,不同版本略有差异,但大体路径是一致的。以 WPS 表格为例,在顶部选项卡找到“开发工具”,里面会有“JS 宏”或“宏”相关入口。点击进入编辑器后,你会看到一个类似代码编辑器的界面,里面可以新建模块,写入 JavaScript 代码。
如果你的 WPS 没有显示“开发工具”选项卡,通常需要在“文件 -> 选项 -> 自定义功能区”里把它勾选出来。这一步和 Excel 显示“开发工具”的思路差不多。
另外,执行宏之前要注意安全设置。WPS 默认可能会禁用宏,或者在你打开带宏文件时弹出提示。建议把包含宏的工作簿另存为支持宏的格式,避免文件关闭后代码丢失。在 WPS 表格中常见的做法是保存为.xlsm格式,这样下次打开还能继续编辑和运行这段 JSA 脚本。
我这里把脚本放在两个位置:一个主函数负责读取数据、写结果;几个辅助函数负责字段提取、日期转换、金额转换。这样可读性更好,也方便以后拿同一套机制处理其他文本。
4. 完整脚本拆解:段落切片、字段提取、类型转换一条龙
4.1 第一步:用懒惰匹配把长文本里的记录切片
核心正则我建议写成这样:
var recReg = /记录开始([\s\S]*?)(?=记录开始|记录结束)/g;这一段代码有讲究:
记录开始是每条记录的起始标记。([\s\S]*?)是非贪婪捕获,先从最短范围开始尝试。(?=记录开始|记录结束)是零宽正向断言,意思是“后面必须是记录开始或记录结束”,但断言部分不会被计入匹配结果。
使用(?=记录开始|记录结束)而不是直接在后面匹配“记录结束”,是为了防止最后一条记录缺少“记录结束”时被漏掉。举个例子,如果最后一条记录的末尾没有“记录结束”四个字,用记录结束直接结尾就无法匹配;而用向前断言 +$,遇到字符串末尾同样能成立。
在你编写循环时,要注意exec方法的工作原理。JavaScript 正则开启g后,regex.exec(str)会记住上次匹配结束的位置,保存在lastIndex属性里,下次继续从那个位置往后找,所以适合用来逐个提取多个匹配结果。
具体代码如下:
function SplitAndConvert() { var src = ThisWorkbook.Sheets("Sheet1"); var dst = ThisWorkbook.Sheets("Sheet2"); var rawText = String(src.Range("A1").Value2 || ""); if (!rawText) { alert("A1 里没有可处理的数据"); return; } // 表头 var headers = ["编号", "日期", "客户", "电话", "金额", "备注"]; for (var col = 0; col < headers.length; col++) { dst.Cells(1, col + 1).Value2 = headers[col]; } var recReg = /记录开始([\s\S]*?)(?=记录开始|记录结束)/g; var outRow = 2; var match; while ((match = recReg.exec(rawText)) !== null) { var recordText = clearEdge(match[1]); var no = getField(recordText, "编号"); var dateStr = getField(recordText, "日期"); var customer = getField(recordText, "客户"); var phone = getField(recordText, "电话"); var amountStr = getField(recordText, "金额"); var note = getField(recordText, "备注"); dst.Cells(outRow, 1).Value2 = no; dst.Cells(outRow, 2).Value2 = toDate(dateStr); dst.Cells(outRow, 3).Value2 = customer; dst.Cells(outRow, 4).Value2 = phone; dst.Cells(outRow, 5).Value2 = toNumber(amountStr); dst.Cells(outRow, 6).Value2 = note; outRow++; } // 日期列格式 dst.Range("B2:B" + (outRow - 1)).NumberFormat = "yyyy-mm-dd"; alert("共拆分 " + (outRow - 2) + " 条记录"); }clearEdge用来去掉记录文本首尾可能残留的空格、管道符等无用信息,我简单写成:
function clearEdge(str) { return str.replace(/^\s*\|?\s*/, "").replace(/\s*\|?\s*$/, "").trim(); }4.2 第二步:从每条记录里提取字段值
现在我们已经拿到类似下面这样的recordText:
编号=O-1001|日期=2024-08-21|客户=张三|电话=18812341234|金额=199.00|备注=8月21日请工作日送货,联系电话确认这时候可以使用getField函数按字段名提取值。我的写法是:
function getField(recordText, key) { var reg = new RegExp(key + "=([^|]*?)(?:\\||$)"); var m = reg.exec(recordText); return m ? trimStr(m[1]) : ""; }这个函数的工作原理:先拼出编号=这样的模式,然后用([^|]*?)捕获值,要求是:一直到下一个竖线|或字符串末尾$才算结束。[^|]表示“不是竖线的任何字符”,配合非贪婪匹配,能把字段值稳定取出来。
这里为什么不用贪心的(.*)?因为这条记录后面还有别的字段,如果贪心,很可能把后续字段一并吞进去。比如编号=(.*)在 JavaScript 默认不是全局匹配的情况下,会一直匹配到最后一个竖线之后的内容,显然不是我们想要的。用([^|]*?)来限定“只取到下一个竖线之前”,比单纯依赖懒惰匹配更可靠。
getField里我使用了new RegExp而不是直接写一个字面量,这样可以根据不同字段名动态构造正则,代码更简洁。如果你只有固定几个字段,也可以写成几个固定的正则表达式,语义更直观。
trimStr就是个普通的去首尾空格函数:
function trimStr(str) { return String(str).replace(/^\s+|\s+$/g, ""); }4.3 第三步:把日期和金额转成 Excel 真正能用的类型
很多从系统导出的数据,日期本质上只是“长得像日期的字符串”。这种字符串放进 Excel 之后,如果用筛选或计算,会出现各种问题。所以转换是必不可少的一步。
日期转换我写了toDate:
function toDate(str) { if (!str) return null; var parts = String(str).split("-"); if (parts.length !== 3) return null; var y = parseInt(parts[0], 10); var mo = parseInt(parts[1], 10) - 1; var d = parseInt(parts[2], 10); var dt = new Date(y, mo, d); return isNaN(dt.getTime()) ? null : dt; }这里有一个值得注意的点:JavaScript 的Date对象月份从 0 开始,所以parseInt(parts[1], 10) - 1。我见过不少新手踩过这个坑,解析出来的日期总是差一个月。
金额转换我写了toNumber:
function toNumber(str) { var v = parseFloat(String(str).replace(/,/g, "")); return isNaN(v) ? 0 : v; }如果源数据里有¥1,299.00这种带货币符号和千分位的格式,可以先统一去掉逗号和货币符号再parseFloat,这里我只处理逗号,实际使用按你的源数据情况再加替换规则就行。
写回表格时,我使用的是.Value2。在 WPS 的 JSA 里,.Value2和.Value的区别主要在于:.Value2不太受单元格格式影响,更适合做数据传递和再计算。日期对象直接赋给.Value2后,再统一设置NumberFormat = "yyyy-mm-dd",单元格里就会正常显示日期。
4.4 把脚本跑起来之后的输出效果
脚本执行后,Sheet2 会自动生成表头和数据。我实际测试的结果是:
- A列:编号,比如 O-1001
- B列:日期,显示为 2024-08-21,且单元格格式是日期
- C列:客户
- D列:电话
- E列:金额,变成数值,可以对它求和
- F列:备注,原样保留
整个过程不需要鼠标逐行操作,运行一次 JSA 宏就完成了。这就是“数据拆分且转换”最直接的落地效果。
5. 实际运行时的几个坑,我都替你踩过了
5.1.*不能匹配换行,跨行文本记得用[\s\S]
这是文本处理里最容易翻车的问题。如果备注字段里有换行,而你用.*?去匹配,会发现匹配结果在一行内结束,明明应该把第二行备注内容也捕获进来,结果却只抓到一半。
我第一版脚本用的就是:
var recReg = /记录开始(.*?)记录结束/g;结果第三段记录只有第一行入了结果,第二行备注内容被漏掉了。排查半天才发现是.不匹配换行导致的,改成[\s\S]*?后立刻正常。这个坑很隐蔽,尤其是数据量大的时候,不一定每次都能一眼看出来。
5.2 exec 循环和 lastIndex 的“状态残留”问题
使用带g标志的正则时,lastIndex会在每次exec后自动移动。如果你在同一个函数里重复调用同一个正则,可能会出现“明明能匹配到,第二次却不返回结果”的诡异情况,因为lastIndex已经停在末尾了。
建议养成两个习惯:
- 正则对象尽量局部创建,避免长期复用同一个全局对象。
- 如果必须复用,在每次进入循环前重置
lastIndex = 0。
尤其是你写了多个辅助函数,比如在getField里也创建了正则,这些正则没有g标志,通常不会有这种问题。但主切片正则一定带着g,所以要格外小心。
5.3 最后一条记录没有结束标记时,容易被静默丢弃
我在实际数据里遇到过一次,导出系统最后一条记录没有“记录结束”这个收尾标记。如果用:
/记录开始([\s\S]*?)记录结束/g去匹配,最后一条记录就彻底丢失了。这也是我建议使用:
/记录开始([\s\S]*?)(?=记录开始|记录结束)/g的原因。用向前断言(?=...),在遇到下一个“记录开始”或字符串末尾时都能让当前匹配正常结束。加了$这一层兜底,漏数据的概率就低很多。
5.4 大文本跑不动时,先关掉屏幕刷新再分批处理
如果 A1 里的文本有几十万字符,正常跑一次不会太慢,但如果你在循环里频繁操作单元格,比如逐个写入几百行数据,WPS 的界面刷新会拖慢速度。此时我一般会这样做:
Application.ScreenUpdating = false; // 真正的循环处理 Application.ScreenUpdating = true;若数据实在太大,建议先把原始文本按固定的“记录开始”标记切分成多个数组块,每块处理完再写回 Excel,避免在脚本内部长时间占用内存。实际处理几万条记录时,这个思路明显比一次性全量正则匹配更稳。
5.5 正则里的中文标记和转义
本文示例里所有标记都是中文,比如“记录开始”“记录结束”,这在 JavaScript 正则里完全合法,不需要额外转义。但如果你的标记里有[、(、*、?、|这类正则元字符,就要记得加反斜杠转义。例如,字段分隔符本身是竖线|,在正则里表示“或”,所以我在getField里写成\\|来匹配真正的竖线字符。
6. 这套“懒惰匹配拆数据”的思路,还能顺手干不少活
这套思路不只能用来处理工单表。我在后续工作中把同一个脚本改几条正则标记,处理过下面几类情况:
- 网页复制到 Excel 的搜索结果列表。每条结果的标题、链接、摘要混在一个单元格里,用类似
http([\s\S]*?)下次标题的边界模式去掉外壳。 - 日志文件的定时拆分。比如设备日志里每段日志以
时间戳+级别开头,用懒惰匹配把几十万字符的日志按一条条切出来,再提取关键字段。 - 批量清洗通讯录里的备注字段。比如“备注:转到市场部,后续跟进”,分隔符混乱,用懒惰匹配提取“转到”和“后续跟进”两个部分自动生成列。
说得直白一点,懒惰匹配解决的核心问题只有一个:当你面对一个很长、没有固定行分隔的字符串,需要按照某种重复出现的边界切片时,它就是最好的手术刀。WPS 的 JSA 让你能把这把手术刀封装成一次点击就能运行的宏,省下的是大量重复手工作业的时间。
最后分享一个我的操作习惯:写这类脚本时,我不会一次性写完所有功能,而是先在编辑器里跑一段最简单的alert(recordText),确认切片结果正确,再逐步加字段提取、日期转换、金额转换。每加一个功能就验证一次,看起来慢,实际上比全写完再统一排查要快很多。毕竟数据处理的问题,八成的 bug 都出在对原始数据规律的误判上,先确认边界,再追求功能,这个是稳的。