[Node.js] 后端服务导出CSV数据流给前端下载
前端时间使用Java做了此功能,另一个使用Node.js开发的服务也需要此功能,所以使用TypeScript做了类似的封装,后来发现,TS做这些功能,代码看起来更简洁,嘿嘿。
直接上代码吧。
CsvUtils.ts
import { Response } from "express";
import { DateUtils, FXResponse } from "nodejs-fx";
import { GenderType } from "../model/GenderType";
const uuid = require('node-uuid');
const _reg1: RegExp = new RegExp("\"", 'g');
const _reg2: RegExp = new RegExp("\\\"", 'g');
/**
* CSV 下载辅助类
*/
export class CsvUtils {
private static charset: String = "utf-8";
/**
* 导出 CSV
* @param res Http请求Response
* @param fileName 可选,文件名,用户下载的文件名
* @param onLoadData 获取分页数据
*/
static async writeCsv<T>(res: Response,
_constructor: { new (...args: Array<any>): T },
onLoadData: (page: number) => Promise<PageDTO<T>>,
fileName: string = undefined
): Promise<any> {
try {
let cls = (new _constructor()).constructor.name;
let items: T[] = [];
let pageIndex: number = 1;
let count: number = undefined;
while (true) {
let result:PageDTO<T> = await onLoadData(pageIndex);
if (!result || !result.items || result.items.length == 0)
break;
if (pageIndex == 1) {
count = result.count;
}
if (pageIndex == 1 && count != undefined && count == result.items.length) {
return await this.writeCsvByItems(res, result.items, fileName, cls);
}
// items.push(...result.items);
result.items.forEach(item => {
items.push(item);
});
pageIndex++;
if (result.hasNext === true)
continue;
if (result.hasNext === false)
break;
if (count != undefined && items.length >= count)
break;
}
return await this.writeCsvByItems(res, items, fileName, cls);
} catch (e) {
return e;
}
}
/**
* 导出列表 CSV
* @param res Http请求Response
* @param items 数据列表
* @param fileName 可选,文件名,用户下载的文件名
*/
static async writeCsvByItems<T>(res: Response, items: Array<T>, fileName: string, className: string): Promise<any> {
this.setHttpHeader(res, fileName);
if (!items || items.length == 0)
return "";
// 筛选出拥有注解的字段
let fields = new Array<any>();
for (var o in items[0]) {
let rKey = className + "." + o.toLowerCase();
let reg = this.regMap.get(rKey);
if (reg && reg.ingore === true)
continue;
if (!reg || !reg.name) {
fields.push({v: o, t: o, conv: undefined});
} else {
fields.push({v: o, t: reg.name, conv: reg.converter})
}
}
if (fields.length == 0)
return "";
let result: string = "";
// 写入utf-8 BOM \0xef\0xbb\0xbf
result += "\uFEFF";
// 写入标题行
let strs = new Array<string>();
fields.forEach(v => {
strs.push(JSON.stringify(v.t));
});
let text = this.stringToCsvLines(strs) + "\n";
result += text;
// 写入内容
items.forEach(item => {
text = this.itemToString(item, fields);
if (!text) return;
result += text + "\n";
});
return result;
}
/** 设置下载用的 Http 响应头部 */
private static setHttpHeader(res: Response, fileName: string) {
if (!fileName) fileName = this.generateRandomFileName() + ".csv";
res.set({
"Content-Type": "application/octet-stream; charset=" + this.charset,
"Content-Disposition": "attachment;filename=" + encodeURIComponent(fileName),
"Pragma": "no-cache",
"Expires": 0
});
}
private static itemToString(item: any, fields: Array<any>): string {
let result = new Array<string>();
fields.forEach(data => {
let v = undefined;
if (data.conv) {
data.conv.data = item;
v = data.conv.execute(item[data.v]);
} else
v = item[data.v];
if (v == undefined || v === "") {
result.push("");
} else {
let txt = JSON.stringify(v);
if (txt.startsWith("{") || txt.startsWith("[")) {
txt = "\"" + txt.replace(_reg1, "\"\"") + "\"";
}
result.push(txt);
}
});
return this.stringToCsvLines(result);
}
private static generateRandomFileName(): string {
return uuid.v4().replace(new RegExp("-", 'g'), '');
}
private static stringToCsvLines(strs: Array<string>): string {
if (!strs || strs.length == 0) return "";
return strs.join(",");
}
// 注册的注解参数
static regMap: Map<string, CsvParams> = new Map<string, CsvParams>();
}
export class PageDTO<T> {
count: number = 0;
hasNext: boolean = true;
items: T[];
static load<T>(data: FXResponse<T[]>, pageSize: number) {
let result = new PageDTO<T>();
if (data && data.code == 0 && data.data) {
if (Array.isArray(data.data)) {
result.items = data.data;
} else if (data.data.list && Array.isArray(data.data.list)) {
result.items = data.data.list;
} else if (data.data.items && Array.isArray(data.data.items)) {
result.items = data.data.items;
}
if (result.items)
result.hasNext = result.items.length >= pageSize;
else
result.hasNext = false;
} else
throw data;
return result;
}
}
/**
* csv 注解
* @param name 字段名称(导出后显示的名称)
* @param ingore 是否忽略这个字段
* @param _constructor 转换器
* @param args 转换器构造参数(依次写)
*/
export function csv<T>(name: string, ingore: boolean = false,
_constructor: { new (...args: Array<any>): CsvConverterBase } = undefined,
...args: any
) {
return function(target:any, propertyName:string){
let p = new CsvParams();
p.name = name;
p.ingore = ingore;
if (_constructor) {
p.converter = new _constructor(...args);
}
CsvUtils.regMap.set(target.constructor.name + "." + propertyName.toLowerCase(), p);
}
}
export class CsvParams {
/** 字段名称 */
name: string;
/** 是否忽略 */
ingore: boolean;
/** 转换器 */
converter: CsvConverterBase;
}
export abstract class CsvConverterBase {
data: any;
abstract execute(value: any): string;
}
/**
* 时间戳转字符串 CSV转换器
*/
export class TimestampCsvConverter extends CsvConverterBase {
execute(value: any): string {
if (value == undefined) return "";
if (!Number.isNaN(value)) {
return DateUtils.formatDateTime(value);
} else
return value;
}
}
/**
* 性别类型CSV转换器
* @description @csv("会员标签", undefined, GenderTypeCsvConverter)
*/
export class GenderTypeCsvConverter extends CsvConverterBase {
execute(value: GenderType): string {
if (value == GenderType.female) return "女";
if (value == GenderType.male) return "男";
return "未知"
}
}
/**
* 字符串数组 CSV转换器
* @description @csv("会员标签", undefined, StringArrayCsvConverter)
*/
export class StringArrayCsvConverter extends CsvConverterBase {
field: string;
constructor(field: string) {
super();
this.field = field;
}
execute(value: any): string {
if (Array.isArray(value) && value.length > 0) {
if (typeof(value[0]) == 'string')
return value.join(",");
if (this.field) {
let items = [];
value.forEach(item => items.push(item[this.field]));
return items.join(",");
}
}
return value;
}
}
/**
* 布尔值 CSV转换器
* @description @csv("允许登录APP", undefined, BoolCsvConverter, "是", "否")
*/
export class BoolCsvConverter extends CsvConverterBase {
p1: string;
p2: string;
p3: string;
constructor(p1: string, p2: string, p3: string = "") {
super();
this.p1 = p1;
this.p2 = p2;
this.p3 = p3;
}
execute(value: any): string {
if (value === true)
return this.p1;
if (value === false)
return this.p2;
return this.p3 == undefined ? "" : this.p3;
}
}
/**
* 枚举值 CSV 转换器
* @description @csv("登录角色", undefined, EnumCsvConverter, {1: "管理员", 2: "普通员工", 3: "创建者"})
*/
export class EnumCsvConverter extends CsvConverterBase {
enumValue: Object;
constructor(enumValue: Object) {
super();
this.enumValue = enumValue;
}
execute(value: any): string {
if (value == undefined) return "";
let v = this.enumValue[value];
return v ? v : "";
}
}
/**
* 对象字段值 CSV 转换器
* @description @csv("图像地址", undefined, ObjectCsvConverter, "url")
*/
export class ObjectCsvConverter extends CsvConverterBase {
field: string;
constructor(field: string) {
super();
this.field = field;
}
execute(value: any): string {
if (!value || !this.field) return "";
if (Array.isArray(value)) {
// 数组取出每项的字段值后,用","分隔连接
let values = [];
value.forEach(item => {
values.push(item[this.field]);
});
return values.join(",");
} else
return value[this.field];
}
}
PageDTO 声明, 仅作参考: (主要是作分页用)
export class PageDTO<T> { count: number = 0; hasNext: boolean = true; items: T[]; static load<T>(data: Response<T[]>, pageSize: number) { let result = new PageDTO<T>(); if (data && data.code == 0 && data.data) { if (Array.isArray(data.data)) { result.items = data.data; } else if (data.data.list && Array.isArray(data.data.list)) { result.items = data.data.list; } else if (data.data.items && Array.isArray(data.data.items)) { result.items = data.data.items; } if (result.items) result.hasNext = result.items.length >= pageSize; else result.hasNext = false; } else throw data; return result; } }
调用举例:
@get("/list/pc/csv")
@validate
async getXXXListCsv(
@query('a') a: string,
@query('b') b: string,
@query('c') c: string
) {
return await CsvUtils.writeCsv(this.res, TestDTO, async (page): Promise<PageDTO<any>> => {
let data = await this.getList(page, 20, a, b, c);
return PageDTO.load(data, 20);
});
}
TestDTO 声明:
export class TestDTO { /** * 会员名称 */ @csv("会员名称") name:string; /** * 头像 */ @csv("", true) memberImage:MediaModel; /** * 性别 */ @csv("性别", undefined, GenderTypeCsvConverter) gender:GenderType; /** * 会员标签名称数组 */ @csv("会员标签", undefined, StringArrayCsvConverter, "name") tags:string[]|TagsDetail[]; /** * 加入时间 */ @csv("加入时间") jointime?: string; /** * 会员在该店铺的启用状态 */ @csv("启用状态", undefined, BoolCsvConverter, "启用", "未启用") enable?: boolean; }
可以看到,使用 @csv 注解非常简单。

浙公网安备 33010602011771号