HarmonyOS ArkTS:CSV导入解析协议与错行隔离(连载01)【共创稿事节】
财务同事从不同表格软件导出费用账单时,最棘手的不是把文本按逗号拆开,而是把格式错误与有效明细分开处理。一个金额字段混入字母,不应该让另外三条准确的记录跟着消失;反过来,也不能看到三条正常明细,就悄悄把错误行写进本地账本。这个导入入口在界面上看起来只是一个文件选择和确认按钮,真正决定质量的是中间那层可替换的解析协议。
本次从四条固定的费用明细切入:TX001、TX002、TX003、TX004。前三个有效行中,TX001金额为12500分、TX002为8600分,TX004为4300分;TX003 的金额写成 12.5x,属于格式错误。预期摘要是“读取4、有效3、错误1、待入库3”。本篇完成单行 CSV 字段解析、表头校核、类型转换和错误归档,确认导入动作放在结果预览之后。后续再将解析器替换为独立 HAR 组件,处理跨行引号、批量流式读取与升级兼容。

把文件、字段和业务记录分成三层
曾经常见的写法是拿到文本后直接调用 split(','),随后把数组塞进业务记录。只要备注字段里出现半角逗号,这种方法就会错列;遇到 Excel 或其他工具导出的双引号转义,字段位置更容易错。更糟糕的是业务层只收到“导入失败”,根本不知道哪一行出了问题。导入器不应该把“文本切分”和“金额规则”揉在一个页面事件里。
我会把流程分成三层。文件选择与文本读取负责提供可追踪的原始文本,解析层负责把 CSV 一行转换成字段数组,映射层按既定表头构造费用明细并记录错误。这里先使用应用内可替换的基础解析实现,不预设任何外部包已经通过兼容验证。将来经 ohpm 引入三方 CSV 包后,只要实现相同接口,页面、字段映射和异常表格都不需要知道具体库名。华为的添加依赖项文档介绍了远程依赖、本地模块和 HAR/HSP 等组织方式,实际引入时应以对应包的元数据和版本配套为准。
这份账单的表头采用 流水号,日期,金额分,说明。金额直接约定为整数分,是为了避免在导入时把十进制元转换为二进制浮点数。业务日期以 YYYY-MM-DD 保存,展示时再做本地化格式化。错误列表应包含物理行号、字段名、原始值和原因,方便用户回到源文件修改,而不是把一条错误笼统归到文件级别。
先定义解析器能返回什么
解析器应当只负责处理一行文本中的双引号和逗号,不负责判断费用金额是否符合业务规则。结果包含字段数组与错误描述;引号没有闭合,解析器给出错误,映射层不再猜测字段位置。首篇明确采用单行 CSV 协议,若字段中真的包含换行符,必须留到后续增量读取器按完整记录处理,不能在这里按物理行硬凑一个看似正确的账单。
代码给接口与样本,让导入页面可以替换实现。测试样本使用四条明细,对应页面的四个列表项;第一个字符串是文件表头,不计入读取的业务行数。日期与整数分在这一层都还是普通字符串。
export interface CsvLineResult {
cells: string[];
issue: string;
}
export interface CsvParserPort {
parse(line: string): CsvLineResult;
}
export const sourceLines: string[] = [
'流水号,日期,金额分,说明',
'TX001,2026-10-01,12500,物料',
'TX002,2026-10-02,8600,配件',
'TX003,2026-10-03,12.5x,差旅',
'TX004,2026-10-04,4300,办公'
];
这样设计还有一个好处:原始行保存在哪里,由文件入口决定;业务错误如何展示,由页面决定。两侧都不会直接调用解析器的内部私有方法。后面要把本地 HAR 放入组件目录,也不必为了包的 API 风格重新定义账本字段。
引号内的逗号不能切断字段
基础解析器要处理三件事:引号内的逗号保留为正文,成对双引号还原成一个双引号,引号结构不完整时返回明确的错误。对于引号结束后继续出现普通文字的情况,采取严格拒绝策略,避免把坏格式默默接进字段。这个策略与具体业务无关,替换底层依赖时应保留同等测试用例。
这里刻意使用字符循环而不是复杂的正则表达式。状态只有“当前是否处于引号内”和“刚刚闭合了引号”,能直接对应到输入字符。在小规模账单里它容易审查;面对大文件的分块和跨行字段,下一阶段需要扩展为保留状态的增量机。
import { CsvLineResult, CsvParserPort } from './ImportPort';
export class CsvLineParser implements CsvParserPort {
parse(line: string): CsvLineResult {
const cells: string[] = [];
let cell: string = '';
let quoted: boolean = false;
let closed: boolean = false;
for (let i: number = 0; i < line.length; i++) {
const ch: string = line.charAt(i);
if (ch === '"') {
if (quoted && line.charAt(i + 1) === '"') {
cell += '"';
i++;
} else if (quoted) {
quoted = false;
closed = true;
} else if (cell.length === 0 && !closed) {
quoted = true;
} else {
return { cells: [], issue: '双引号位置错误' };
}
} else if (ch === ',' && !quoted) {
cells.push(cell);
cell = '';
closed = false;
} else if (closed) {
return { cells: [], issue: '引号结束后存在多余字符' };
} else {
cell += ch;
}
}
if (quoted) {
return { cells: [], issue: '双引号未闭合' };
}
cells.push(cell);
return { cells: cells, issue: '' };
}
}
对这个解析器有一个容易犯的误判:"物料,配件" 是一个字段,并不增加字段数;"物料"后缀 违反本阶段约定,应被拒绝。行末出现逗号意味着存在一个空字段,也需要保留。这样映射层就能准确识别“说明为空”,而不是因为缺少数组元素导致字段整体左移。

映射层只认明确的字段协议
映射层要先确认表头与预期严格一致,再依次处理四条数据行。表头不匹配属于整份文件的结构性错误;单条金额错误则应留下错误行,同时继续检查其他数据。行号在界面里按数据行计数,因此 TX003 对应第3行,物理文件行号则为4。两种计数方式都应在代码中明确,防止用户拿着错误提示却找不到表格位置。
金额字段采用十进制非负整数字符串检查,之后转换为 number,并检查安全整数范围;业务规模如果需要超过安全整数范围,应改为专用整数或高精度表示,不能继续简单调用 Number。日期字段在这里做基础形状验证,日历合法性与时区策略留在导入规则层进一步细化。这样的职责划分能减少错误定位时的重复分支。
import { CsvParserPort } from '../parser/ImportPort';
export interface ExpenseRow {
id: string;
date: string;
cents: number;
note: string;
}
export interface RowIssue { row: number; field: string; message: string; }
export interface ImportPreview { accepted: ExpenseRow[]; issues: RowIssue[]; }
export function previewImport(lines: string[], parser: CsvParserPort): ImportPreview {
const accepted: ExpenseRow[] = [];
const issues: RowIssue[] = [];
const header = parser.parse(lines[0] ?? '');
if (header.issue !== '' || header.cells.join(',') !== '流水号,日期,金额分,说明') {
return { accepted: [], issues: [{ row: 0, field: '表头', message: '字段顺序不匹配' }] };
}
for (let index: number = 1; index < lines.length; index++) {
const result = parser.parse(lines[index].replace(/\r$/, ''));
if (result.issue !== '' || result.cells.length !== 4) {
issues.push({ row: index, field: 'CSV', message: result.issue || '列数错误' });
continue;
}
const id: string = result.cells[0];
const date: string = result.cells[1];
const amount: string = result.cells[2];
const note: string = result.cells[3];
const cents: number = Number(amount);
if (!/^\d+$/.test(amount) || !Number.isSafeInteger(cents)) {
issues.push({ row: index, field: '金额分', message: '金额必须是整数分' });
continue;
}
if (id.length === 0 || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: index, field: '标识或日期', message: '必要字段格式错误' });
continue;
}
accepted.push({ id: id, date: date, cents: cents, note: note });
}
return { accepted: accepted, issues: issues };
}
使用例子里的数据,解析器返回四条结构完整的行,映射器在第3行发现 12.5x 违反整数分规则,最终保留 TX001、TX002、TX004 三条候选记录。这些候选项还没有落地到持久化账本。页面应明确区分“有效”和“待入库”,待用户确认后才启动写入流程。错误行先保留原始值及提示,而不是自动四舍五入成另一个金额。

文件入口与资源释放不能藏在按钮里
本篇用明文样本建立解析协议,实际引入文件选择器后需要处理用户取消选择、文件内容过大、编码异常和读取过程中页面退出。读取句柄要在异常路径关闭;如果由库适配器提供流式接口,还要在页面生命周期结束时取消未完成的解析请求。不要在点击“确认导入”时又读取一次源文件,否则预览数据和真正写入的数据可能不是同一个版本。
性能上,四条行数据直接处理足够清晰,没必要为了少量文本开启复杂并发;当文件从几行增长到几万行,应该分批处理并汇总错误,避免一边解析一边创建几万个 UI 组件。后续引入 HAR 组件时,包版本、公开入口与返回协议需要用oh-package.json5 配置文档核对,而不是直接把外部依赖的内部对象暴露给页面。ArkTS 语言与 JS 互操作的总体机制可参考ArkTS 运行时。
这里真正固定下来的不是一个解析算法,而是“原始文件—字段数组—领域记录—错误行”的职责路径。它让账单有问题时能够指出是哪一个字段,也让可替换依赖拥有清晰的接入点。下一篇就在相同的四行费用数据上,把解析端口迁入本地 HAR,讨论 ohpm 依赖配置、导出接口与版本回退,不改动前端这套有效三条、错误一条的预览口径。
更多推荐


所有评论(0)