1. 程式人生 > 實用技巧 >[Node.js] 後端服務匯出CSV資料流給前端下載

[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 註解非常簡單。