ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WPS表格转JSON实战:JS宏字段映射与类型转换

WPS表格转JSON实战:JS宏字段映射与类型转换

1. 先搞清楚要转的到底是什么:表格与JSON的映射关系

WPS另存为JSON这件事,很多人第一反应是找一个WPS插件,点一下按钮就完事。但我接触过的需求里,十次有八次最后还是要自己写脚本或者半手动调整,原因很简单——表格是给人看的,JSON是给程序吃的,两者对"一条数据"的定义根本不一样。WPS表格转换成JSON,本质上做的是一次结构化重组,而不是简单的格式换皮。你手上那张表可能是合并单元格、可能是三层表头、可能混着备注行和小计行,而目标JSON要的是干净的对象数组,中间那道鸿沟得有人填。

我在实际项目里遇到的情况大致分三类。第一类是配置表导出,比如游戏里的道具表、活动表,策划在WPS里维护,程序要JSON;第二类是对接接口,需要把业务方给的表单数据整理成API能接受的请求体;第三类是数据清洗的中间环节,先从Excel to JSON转一道,再交给下游脚本处理。这三类的共同点是:表格的结构是长期维护的,字段会增删,所以导出方式必须能跟着表结构一起演进,不能每次改表就重写一遍代码。

所以这篇内容不适合只想"点一下"的人,更适合愿意花半小时把规则定清楚、之后长期省事的人。下面我会把映射规则、技术路线、脚本实现、踩坑记录全部摊开讲,你可以直接抄,也可以只挑其中一段用。

1.1 一张二维表到对象数组的四种常见映射

同样是"表格转JSON",落地的结构差别很大,选错了后面全是返工。我见过和用过的映射方式主要有四种,各有各的适用边界。

第一种是行式对象数组,第一行当表头做键,之后每一行生成一个对象。这是最通用、最符合直觉的方式,也是大多数WPS导出JSON插件默认干的事。它适合字段固定、每行独立的情况,比如商品列表、人员名单。

第二种是键值对映射,两列搞定,左边是键,右边是值。这种适合配置文件,比如一堆零散参数、开关、阈值。它的好处是加一个参数只需要加一行,不用动脚本;坏处是没法表达列表和嵌套。

第三种是分组嵌套,某个字段相同的行归到一个对象里,形成"主表-子表"的父子结构。典型场景是订单和订单明细、分类和分类下的条目。这种结构手工转非常痛苦,但写好脚本之后是最省事的。

第四种是矩阵转置型,表头在第一列而不是第一行,或者行列都有维度。这种多见于统计报表,转JSON的需求相对少,一旦遇到基本只能定制。

下面这张表是我自己判断用哪种方式的依据,你可以对照着看:

表格特征推荐映射判断依据典型翻车点
一行一条记录,字段固定行式对象数组表头能唯一确定字段名表头有合并单元格
参数零散、经常增删键值对映射每次加参数不想改代码值里混了单位文字
有明显父子层级分组嵌套一个主键对应多条明细子表排序被打乱
多维度交叉统计矩阵转置行列都是分类维度空单元格含义不明

我个人的建议是:能上行式对象数组就上行式,因为它的心智负担最低,前端、后端、脚本都能直接吃。只有当你发现自己在写"遍历一遍再按某个字段分组"这种逻辑时,才考虑升级成嵌套结构。

1.2 表头即字段名:命名规范先定死

这一步最容易被跳过,但它是后期返工的最大来源。WPS表格里的表头往往是给人看的,写着"商品名称(必填)"、"价格(元)"、"是否上架 Y/N",直接拿来当JSON的key,你的下游代码里就会出现data["价格(元)"]这种写法,谁看谁难受。

我的做法是加一层字段名映射,在脚本里维护一张对照表,表格里怎么写随便,输出的时候统一转成规范命名。规范本身没有绝对标准,但要满足三条:全小写、用下划线或驼峰其中一种且只用一种、不含空格和中文标点。我习惯用下划线,因为它在各种语言里都不用转义。

// 表头中文名 -> JSON 字段名 const FIELD_MAP = { "商品ID": "sku_id", "商品名称(必填)": "sku_name", "价格(元)": "price", "是否上架 Y/N": "is_online", "备注信息": "remark" };

映射表还有一个隐藏好处:它天然是一份白名单。表格里偶尔会多出几列临时辅助计算用的数据,只要不在映射表里,导出时就会被自动忽略,不用每次手动删列。这一点在实际协作里价值极高,因为表是别人维护的,你控制不了他会加什么列。

注意:映射表一定要和表格放在一起管理,最好是同一个文件的不同工作表,或者干脆写在脚本顶部的常量区。我见过把映射表放在另一个文档里、结果文档被人挪走导致导出全乱的情况。

1.3 类型推断这件小事,坑最多

JSON是有类型的,表格没有。WPS表格里一个单元格显示的"123",可能是数字123,可能是文本"123",也可能是公式算出来的结果。如果你直接把单元格的值塞进JSON.stringify,出来的东西大概率不是你想要的。

几个高频问题我列一下。数字精度:超过15位的长数字(比如订单号、身份证号)在表格里会被当成数值处理,末尾直接变0,这时候必须在表格里预先设成文本格式,或者导出时统一按字符串处理。前导零:编号"007"导出成数字就变成7了。空单元格:是输出null、空字符串"",还是直接不输出这个键,三种做法下游处理方式完全不同,必须提前约定。日期:表格里的日期本质是一个序列号,直接取值会得到45xxx这样的数字,得手动格式化。

我的处理原则是:凡是标识类字段(ID、编号、手机号、邮编)一律按字符串输出,凡是参与计算的字段一律按数字输出,空值统一输出 null 而不是空字符串。这条规则定下来之后,下游基本不会再因为类型问题来找我。至于怎么判断,靠映射表再加一个类型字段就够了:

const FIELD_MAP = { "商品ID": { key: "sku_id", type: "string" }, "价格(元)": { key: "price", type: "number" }, "是否上架": { key: "is_online", type: "boolean" }, "备注信息": { key: "remark", type: "string" } };

多写这几行配置,换来的是下游不用再写一堆parseFloat和类型判断,这笔账怎么算都划算。

2. 三条技术路线怎么选

想把WPS表格转成JSON,能走的路其实不止一条。市面上说的"WPS导出JSON插件",实际上混着好几种完全不同的东西:有的是WPS自带的JS宏,有的是第三方做的加载项,有的压根不是插件,就是让你另存成CSV再去别处转。这三条路的成本、门槛、可控性差得很远,选错了会浪费很多时间。

2.1 路线一:WPS JS宏(JSA)自写导出脚本

这是我现在的主力方案。WPS的JS宏(官方叫JSA,JavaScript for Applications)允许你直接用JavaScript操作表格对象,读写单元格、遍历区域、生成字符串、写文件,全在本地完成,不依赖网络,不上传数据,也不受第三方插件更新影响。

它的优势很明确:完全可控。字段怎么映射、空值怎么处理、要不要嵌套,全部你说了算;而且脚本是存在文档里的,别人打开这个文件就能直接用,不用额外装东西。劣势也很明显:有学习成本,你得懂一点JavaScript,还得熟悉WPS的对象模型,第一次写可能要摸索一两个小时。

适合的人群是:需求会长期存在、表结构会变、对数据不能外传有要求的人。如果你的表每个月都要导一次,那这一两个小时的投入很快就能回本。

2.2 路线二:加载项/第三方插件

WPS的应用市场里确实有做格式转换的加载项,装上之后菜单里多一个按钮,点一下选个范围就导出。对偶尔用一次的人来说,这是最快的路径。

但我在实际使用中遇到过几个绕不开的问题。一是字段控制粒度粗,大多数插件只能做到"表头当key、行当对象",你想改个字段名、想过滤几列、想做嵌套,基本没戏。二是数据流向不明,部分在线转换工具是把表格上传到服务端处理的,如果表里有业务数据,这个风险得自己评估。三是版本依赖,插件跟着WPS版本走,某次升级之后按钮消失或者行为变化,是很常见的事。

我的态度是:一次性、临时性的转换可以用,长期维护的表不要依赖。判断标准很简单——如果这个表三个月后还要再导一次,就别用插件了,写脚本。

2.3 路线三:另存为CSV再离线转换

这条最"土"但最稳。WPS表格另存为CSV,然后用一段Python或者Node脚本把CSV读进来转JSON。好处是CSV是纯文本,格式稳定,任何语言都有成熟的解析库,处理大文件也比在表格里跑脚本快得多。UTF-8编码的问题也更容易控制。

坏处是中间多了一次手工操作,容易出错——比如忘了另存为UTF-8编码的CSV,中文全变乱码;比如CSV里包含逗号和换行,列会被撑开。而且一旦表格里有多个工作表,就得一张一张存,非常繁琐。

我通常把这条路线当作兜底方案:当表格特别大(几十万行)、或者JS宏跑崩了的时候,退回CSV路线,用Python的pandas几行代码就能搞定,稳定得像块砖。

2.4 三条路线的横向对比

维度JS宏自写脚本第三方加载项CSV中转+外部脚本
上手成本中(需懂JS)低中(需装Python/Node)
字段可控性完全可控弱完全可控
数据安全性全本地视插件而定全本地
大文件表现一般(万行级尚可)一般好(十万行级无压力)
可复用性高(脚本存文档里)低(换机器要重装)中(脚本单独维护)
适合场景长期维护的配置表临时一次性转换超大数据量、批量处理

我的实际组合是:日常小表用JS宏,超大数据量退回CSV+脚本。两条路都留着,遇到什么情况都不慌。

3. 环境准备与WPS版本差异

动手写之前,先把环境和版本确认清楚,能省掉后面一半的调试时间。WPS的JS宏在不同版本、不同平台上的能力差异比想象中大,尤其是文件读写这块,Windows桌面版和别的版本完全不是一回事。

3.1 JS宏编辑器的入口与开发设置

打开WPS表格,在"工具"或者"开发工具"选项卡里能找到"JS宏"入口(不同版本位置略有差别,有的在"工具"下的"开发工具"子菜单里)。点进去会弹出一个编辑器窗口,左边是工程结构,右边是代码编辑区,底部有立即窗口,可以边写边看结果。

第一次用建议先做三件事:新建一个模块,写一个最简单的函数验证环境通不通,比如弹个提示框;确认立即窗口能正常输出;确认脚本能不能保存到当前文档里。这三步都过了,再开始写正式逻辑。

// 环境自检:确认能拿到当前工作表 function checkEnv() { const ws = Application.ActiveSheet; alert("当前表名:" + ws.Name + ",已用行数:" + ws.UsedRange.Rows.Count); }

这段跑通说明对象模型可用,后面所有操作都是在这个基础上展开的。

3.2 版本差异与兼容性检查清单

我在不同机器上跑同一份脚本,遇到过"读取正常但写文件报错"、"数组下标从0开始还是从1开始不一致"这类问题。踩过几次之后,我养成了开工前先过一遍检查清单的习惯:

  • 文件写入能力:Windows桌面版一般可以通过ActiveXObject调用系统的文件对象来写文本;部分版本内置了文件对象;还有一些版本对本地文件访问有限制,只能借助单元格中转。这一点必须先测,因为它决定了整个方案能不能闭环。
  • 区域取值返回的数组下标:一次性读取一个区域的值,返回的二维数组在不同环境里下标起点可能不同,有的从0开始,有的从1开始。写循环之前先打印一行看看,比事后调试省事得多。
  • 编码支持:写中文到文件时,默认编码很可能不是UTF-8,得显式指定,否则下游读到的是乱码。
  • 单元格取值方式:Value2、Text、Value拿到的东西不完全一样,Text是显示出来的字符串,Value2是原始值。要类型就用Value2,要显示效果就用Text。

提示:把这份清单做成一个checkEnv()函数,每次换机器先跑一遍,比凭记忆判断靠谱得多。

4. 从零写一个可用的导出脚本

下面这部分是整篇的核心。我会从最简单的版本开始,一层一层往上加功能,每一版都能单独跑,你可以按需取用。代码用的是WPS的JS宏语法,和浏览器里的JavaScript有差异,注意区分。

4.1 第一版:单表平铺导出

先解决"能跑"的问题。目标很朴素:把当前工作表的数据读出来,第一行当表头,之后每行生成一个对象,最后打印成JSON字符串看看对不对。

function exportBasic() { const ws = Application.ActiveSheet; const used = ws.UsedRange; const rowCount = used.Rows.Count; const colCount = used.Columns.Count; // 一次性把整个区域的值读进来,比逐个单元格读快很多 const raw = ws.Range(ws.Cells(1, 1), ws.Cells(rowCount, colCount)).Value2; // 表头 const headers = []; for (let c = 1; c <= colCount; c++) { headers.push(String(raw[1][c] || "").trim()); } // 数据行 const result = []; for (let r = 2; r <= rowCount; r++) { const obj = {}; for (let c = 1; c <= colCount; c++) { const key = headers[c - 1]; if (!key) continue; // 空表头直接跳过 let val = raw[r][c]; if (val === undefined || val === null || val === "") { obj[key] = null; // 空值统一给 null } else { obj[key] = val; } } // 整行全空的跳过,很多表末尾会有这种行 if (Object.keys(obj).some(k => obj[k] !== null)) { result.push(obj); } } console.log(JSON.stringify(result, null, 2)); }

这段代码有三个地方值得展开说。

第一,为什么用Value2而不是逐个Cells(r,c).Value2。逐单元格读取,每读一次都是一次跨进程调用,一千行二十列就是两万次调用,实测下来要好几秒;一次性读区域只调用一次,同样规模几十毫秒就完了。这个差距在数据量上来之后是数量级的。

第二,为什么下标从1开始。区域取值返回的二维数组,在这个环境里下标是从1起的,raw[1][c]是第一行。这一点和浏览器里的JavaScript完全不同,是新手最容易翻车的地方。如果你发现取出来的第一行是空的,八成就是这个原因,改成从0开始试试。

第三,为什么整行全空的要跳过。实际维护的表末尾经常留着几行格式刷过的空行,UsedRange会把它们算进来,不跳过的话JSON里就会多出一堆全是null的对象。

4.2 第二版:批量读取区域与性能优化

第一版能跑,但有两个隐患。一是UsedRange有时候会比你想象的大,比如表格下方曾经填过内容又删了,格式还在,它就认为那片区域是"已用"的,读进来一堆空行;二是列数超过实际字段数时,会多出很多空表头,虽然代码里跳过了,但循环还是白跑。

更稳的做法是自己扫一遍确定边界。另外,如果表里有公式列,Value2拿到的是计算结果,一般够用;但如果某列是纯展示用的拼接列,可以只读需要的列。

// 扫描实际数据边界:找到最后一行有内容的行 function findLastRow(ws, colCount) { let last = 1; for (let r = ws.Rows.Count; r >= 1; r--) { let hasValue = false; for (let c = 1; c <= colCount; c++) { const v = ws.Cells(r, c).Value2; if (v !== undefined && v !== null && String(v).trim() !== "") { hasValue = true; break; } } if (hasValue) { last = r; break; } } return last; }

这个函数的思路是从下往上扫,找到第一个有内容的行就停。缺点是最坏情况要扫很多行,所以可以加一个优化:先从UsedRange的最后一行往下找,通常几次就能命中。

关于性能,我做过一次粗略的对比,数据规模是一千行、二十列,在同一台机器上跑:

方案平均耗时说明
逐单元格读取约 4~6 秒两万次跨调用,肉眼可见卡顿
一次性区域读取约 60~120 毫秒推荐做法
一次性读取 + 跳过空行约 80~150 毫秒多一次判断,但结果更干净

数据量再往上走,比如五万行,脚本就开始吃力了,这时候老实退回CSV路线,别硬扛。

4.3 第三版:写文件(UTF-8 与 BOM 的坑)

到这里数据在内存里已经是正确的JSON字符串了,但还没落地。最直接的做法是先输出到控制台,复制粘贴出去,临时用一次可以,长期用太蠢。得写文件。

Windows桌面版的WPS里,常见的做法是通过系统提供的文件流对象写入。这里有个很关键的坑:普通的文本写入对象写出来的UTF-8带BOM,或者干脆只能写别的编码,下游解析器读到开头的不可见字符,直接报语法错误。

function writeUtf8File(path, text) { // 用支持指定编码的流对象写入,避免乱码 const stream = new ActiveXObject("ADODB.Stream"); stream.Type = 2; // 文本模式 stream.Charset = "utf-8"; // 明确指定 UTF-8 stream.Open(); stream.WriteText(text); stream.SaveToFile(path, 2); // 2 = 覆盖写入 stream.Close(); }

用这个方式写出来的文件,中文字符是完整的。但它同样绕不开BOM的问题——文件开头会多出三个字节的标记。大多数解析库能自动处理,但有些严格的JSON解析器会因此报错。如果你的下游对BOM敏感,有两个处理方式:一是下游读文件时按utf-8-sig打开;二是写入之后再做一次去除,不过这一步在JS宏里比较麻烦。

我的做法是在下游做兼容,比在导出端做手脚简单得多。这是个务实的取舍:导出端已经够复杂了,能少一件事就少一件。

如果所在版本的WPS不支持ActiveXObject,还有个退路:把JSON字符串写进一个单元格里,然后手动复制出去,或者用WPS的"另存为"把那个单元格存成文本文件。这招土,但确实能救命。

4.4 第四版:多工作表合并与嵌套结构

单个表导出只是热身,真实需求往往是多个工作表一起导,或者父子结构嵌套。

多工作表的情况很好处理,遍历Sheets、对每张表跑一遍前面的逻辑,最后合成一个对象,键是表名:

function exportAllSheets() { const out = {}; const count = Application.Sheets.Count; for (let i = 1; i <= count; i++) { const ws = Application.Sheets.Item(i); const name = ws.Name; if (name.startsWith("_")) continue; // 下划线开头的是辅助表,跳过 out[name] = sheetToArray(ws); // 复用前面的单表逻辑 } return out; }

嵌套结构稍微费点脑子。以"订单-明细"为例,表格里的样子往往是这样的:订单号这一列,同一个单号连续出现多行,每行对应一条明细。要转成{order_id: "A001", items: [{...}, {...}]},就得做分组。

function groupByKey(rows, keyField, childFields) { const map = new Map(); for (const row of rows) { const key = row[keyField]; if (!map.has(key)) { // 主表字段:去掉子表字段 const head = {}; for (const k in row) { if (!childFields.includes(k)) head[k] = row[k]; } head.items = []; map.set(key, head); } const child = {}; for (const k of childFields) child[k] = row[k]; map.get(key).items.push(child); } return Array.from(map.values()); }

这里有个细节必须提:分组依赖行的顺序。如果表格被人按某个列重新排过序,同一个订单号的行不再连续,用上面的写法还是能正确分组(因为用的是Map,不看连续性),但如果你用"检测到单号变化就新建对象"的写法,就会裂成多个对象。这是我在实际项目里踩过的坑,后来全部改成Map方案,稳。

4.5 第五版:字段过滤、类型转换与空值处理

最后一层是清洗。前面几版都是"表格里有什么就输出什么",真实场景里往往要做减法:去掉内部字段、转换类型、补齐默认值。

我把这些规则集中写成一个转换函数,和读取逻辑解耦:

const SCHEMA = { sku_id: { type: "string", required: true }, sku_name: { type: "string", required: true }, price: { type: "number", default: 0 }, is_online: { type: "boolean", default: false }, remark: { type: "string", default: "" } }; function convertRow(raw, fieldMap) { const obj = {}; for (const cn in fieldMap) { const rule = fieldMap[cn]; let v = raw[cn]; if (v === undefined || v === null || String(v).trim() === "") { obj[rule.key] = rule.default !== undefined ? rule.default : null; continue; } if (rule.type === "number") { const n = Number(String(v).replace(/[^\d.\-]/g, "")); obj[rule.key] = isNaN(n) ? 0 : n; } else if (rule.type === "boolean") { const s = String(v).trim().toLowerCase(); obj[rule.key] = ["1", "y", "yes", "是", "true", "上架"].includes(s); } else { obj[rule.key] = String(v).trim(); } } return obj; }

这段代码里有几个刻意的设计。数字清洗用了正则去掉非数字字符,是因为实际表格里经常写成"¥99.00"或者"99元",直接Number()会得到NaN。布尔转换列了一堆可能的写法,因为不同人维护同一张表,有人写Y,有人写是,有人写1,不兼容就会静默出错。默认值的存在让下游少写一堆判空,尤其是布尔字段,缺省成false比null安全。

注意:布尔转换千万别用Boolean(v)。表格里的"false"字符串在JavaScript里是真值,Boolean("false")返回true。这个坑非常隐蔽,我调了半天才发现问题出在类型转换上。

5. 常见问题与排查实录

脚本写完之后真正的挑战才开始——各种莫名其妙的现象。下面这些是我这两年攒下来的,按发生频率排序。

5.1 数据层面的问题

导出的JSON里日期全变成五位数。表格里的日期本质是序列号,1900年1月1日算起的天数。解决办法是导出时判断这一列的类型,用格式化函数转成YYYY-MM-DD字符串。最简单的方式是用单元格的Text属性拿到显示值,但那样会受单元格格式影响,不够稳。

长数字末尾变0。订单号、身份证号这类超过15位的数字,表格的数值精度撑不住。唯一的解法是在表格里就把它设成文本格式,光靠导出端补救没用,因为精度在录入那一刻就丢了。

中文字段名排序错乱。用for...in遍历对象时,键的顺序不保证。如果要保证字段顺序和表头一致,得用数组存键名,输出时按数组顺序拼接,或者用JSON.stringify之前的中间数组处理。

某些行莫名消失。八成是空行判断写得过于激进。我早期写过一个"所有字段都是空就跳过"的逻辑,结果有一行数据只填了备注列,被误判成空行。后来改成"所有字段都为空"才跳,注意是"所有"不是"任一"。

5.2 脚本运行层面的问题

提示对象不支持该属性。环境能力不一致,通常是文件写入那一块。先跑checkEnv(),确认能不能创建文件对象,不能的话换单元格中转方案。

跑着跑着卡死。逐单元格访问大区域,越跑越慢。参考前面的表格,改成一次性区域读取。

同一个脚本换台机器就报错。检查数组下标起点、检查是否有版本特有的API、检查路径分隔符。这三项占兼容性问题的绝大多数。

中文乱码。九成是编码没指定。写文件时显式指定UTF-8,读取时也确认编码,两头对齐才行。

5.3 问题速查表

现象最可能原因处理方式
日期变成数字单元格值是序列号按列类型做格式化
长数字末尾变0数值精度丢失表格里预设文本格式
中文乱码写入未指定编码流对象显式设 UTF-8
JSON解析报错文件带 BOM下游按 utf-8-sig 读
多出空对象空行未过滤判断所有字段均为空再跳过
布尔值全为 true用了 Boolean() 转换显式判断字符串取值
脚本换机器失效下标起点/API 差异先跑环境自检
分组结果裂成多条依赖行顺序改用 Map 按主键聚合
表头字段名带括号未做字段映射维护映射表统一命名
运行缓慢逐单元格访问一次性读取区域

6. 实战场景与扩展玩法

前面讲的是通用能力,这一节说说不同场景下怎么调。同样一套脚本,改几行就能适配完全不同的需求。

6.1 配置表/词条表导出

这类表的特点是:字段多、行数不一定多、但需要每次改动都重新导一遍。所以脚本要能一键跑完,最好还能自动带上版本号或者时间戳。

我的做法是给脚本加一个入口函数,导出时自动在文件名里塞时间和表名,避免覆盖:

function exportWithTimestamp() { const json = exportAllSheets(); const d = new Date(); const stamp = d.getFullYear() + String(d.getMonth() + 1).padStart(2, "0") + String(d.getDate()).padStart(2, "0") + "_" + String(d.getHours()).padStart(2, "0") + String(d.getMinutes()).padStart(2, "0"); const path = "D:\\export\\config_" + stamp + ".json"; writeUtf8File(path, JSON.stringify(json)); alert("已导出:" + path); }

时间戳这一步看着不起眼,但省掉了"到底哪个是最新版"的反复确认。团队协作里这个价值很大。

另外,配置表通常需要校验。比如某个字段必须有值、某个字段的取值范围固定。我在导出前会跑一遍检查,发现问题直接弹窗列出来,而不是等下游跑起来才报错。校验逻辑很简单,遍历一遍看必填字段是否为空就行,但能挡住大半的低级错误。

6.2 数据校验与导出前自检

我把校验分成三档。阻断级:必填字段为空、主键重复,这类直接不让导出。警告级:字段值格式可疑,比如价格是负数、日期超出合理范围,弹窗提示但允许继续。提示级:表里有疑似多余的空列或者辅助列,提醒一下。

function validate(rows, schema) { const errors = []; const seen = new Set(); rows.forEach((row, i) => { for (const key in schema) { const rule = schema[key]; const v = row[key]; if (rule.required && (v === null || v === undefined || v === "")) { errors.push("第" + (i + 2) + "行:" + key + " 必填但为空"); } } if (row["sku_id"]) { if (seen.has(row["sku_id"])) { errors.push("第" + (i + 2) + "行:主键重复 " + row["sku_id"]); } seen.add(row["sku_id"]); } }); return errors; }

行号加2是有讲究的:数组下标从0开始,表头占一行,所以实际行号是下标加2。这个提示让维护表的人能直接定位到出问题的那一行,比笼统说"有错误"有用得多。

6.3 把导出做成半自动流水线

如果导出频率高到每天好几次,手动点按钮也烦。可以再往前推一步:把脚本挂到文档的某个事件上,或者做成一个简单的按钮控件,点一下就完成"读取-校验-转换-写文件-提示"全流程。

按钮的做法很简单,在表格里插入一个形状,右键指定宏,指向你的入口函数。这样维护表的人完全不用懂代码,看到按钮点一下就行。我做过一个版本,按钮点完之后还会在指定单元格里写一行"最后导出时间",让所有人心里有数。

再往上,如果你们有版本管理工具,可以让导出脚本把文件直接写进项目目录,配合提交前检查,基本就实现了"改表即生效"。不过这一步要谨慎,最好保留人工确认的环节,不然一次误操作可能把坏数据推上去。

我在实际使用中的几点体会

说几个文档里不会写、但真正影响体验的细节。

别追求一次写完美。我第一版脚本只做最简单的平铺导出,用了两个月之后才加嵌套和校验。一开始就想着"支持所有情况",结果写了一堆用不上的分支,反而难维护。

把映射表当资产管。字段映射、类型定义、默认值,这三样东西组合起来其实就是一份轻量的数据契约。它值得单独维护、加注释、必要时进版本库。我后来把所有表的映射抽到一个专门的配置文件里,脚本只负责读配置和执行,改字段完全不用动逻辑代码。

留一个调试开关。脚本里加个DEBUG常量,打开的时候把中间结果打印到立即窗口,关掉的时候安静执行。排查问题时这个开关能省掉大量加删打印语句的时间。

大表别硬刚。遇到几万行的表,与其花半天优化脚本,不如老老实实另存CSV丢给Python处理。工具是用来省时间的,不是用来证明技术的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进