返回

搞定 AngularJS JSON 导出 CSV/XLSX (含嵌套数据)

javascript

AngularJS 实现 JSON 导出 CSV/XLSX:一步步教你搞定

写 AngularJS 应用的时候,经常会碰到要把页面上或者程序里的数据导出来的需求,特别是导成 Excel 能打开的 XLSX 文件或者通用的 CSV 文件。这事儿吧,说难不难,说简单也不简单,特别是当你的 JSON 数据结构有点小复杂的时候。

这不,就有朋友遇到了这么个问题:他用 AngularJS,想把一个包含嵌套数组的 JSON 数据导出成 XLSX,试了半天,用了 AlaSQL 库,但导出的文件里没数据。咱们一起来看看这到底是咋回事,该怎么解决。

问题来了:想在 AngularJS 里导出 JSON 到 Excel 或 CSV?

这位朋友的代码大概是这样:

HTML 部分: (引入了 FileSaver.js, alasql.min.js, xlsx.core.min.js)

<html ng-app="myApp">
 <head>
  <!-- 确保这些库正确引入 -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/alasql/0.5.5/alasql.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.16.9/xlsx.core.min.js"></script>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.9/angular.min.js"></script> <!-- 假设使用 AngularJS -->
 </head>
 <body ng-controller="exportreportController">
  <button ng-click="exportData()" class="btn btn-primary" style="float:right;padding-right:10px;">导出&nbsp;&nbsp;<i class="fa fa-arrow-down"></i></button>

  <!-- 这里可以加个表格展示数据,方便看 -->
  <h2>原始数据预览</h2>
  <div ng-repeat="inverter in inverters">
      <h3>{{ inverter.InvDetails }}</h3>
      <table border="1" cellpadding="5">
          <thead>
              <tr>
                  <th>Id</th>
                  <th>Invertor_Id</th>
                  <th>Time_of_Reading</th>
                  <th>Lastreading</th>
                  <th>Readingby</th>
              </tr>
          </thead>
          <tbody>
              <tr ng-repeat="record in inverter.LstRecords">
                  <td>{{ record.Id }}</td>
                  <td>{{ record.Invertor_Id }}</td>
                  <td>{{ record.Time_of_Reading }}</td>
                  <td>{{ record.Lastreading }}</td>
                  <td>{{ record.Readingby }}</td>
              </tr>
          </tbody>
      </table>
  </div>

  <script>
    // JS 代码放后面
  </script>
 </body>
</html>

JavaScript (AngularJS Controller) 部分:

var app = angular.module('myApp', []);
app.controller('exportreportController', ['$scope', '$timeout', // 简化依赖注入
    function ($scope, $timeout) {

        $scope.inverters = [
            // ... (和问题中一样的 JSON 数据) ...
            {
                "InvDetails": "UPS",
                "LstRecords": [
                    { "Id": 1, "Invertor_Id": 1, "Time_of_Reading": "20170214", "Lastreading": 0, "Readingby": 0 },
                    { "Id": 87, "Invertor_Id": 1, "Time_of_Reading": "20170215", "Lastreading": 5, "Readingby": 10 },
                    { "Id": 110, "Invertor_Id": 1, "Time_of_Reading": "20170216", "Lastreading": 10, "Readingby": 92 },
                    { "Id": 111, "Invertor_Id": 1, "Time_of_Reading": "20170216", "Lastreading": 92, "Readingby": 95 }
                ]
            },
            {
                "InvDetails": "Power Supply",
                "LstRecords": [
                    { "Id": 2, "Invertor_Id": 2, "Time_of_Reading": "20170214", "Lastreading": 0, "Readingby": 0 },
                    { "Id": 88, "Invertor_Id": 2, "Time_of_Reading": "20170215", "Lastreading": 7, "Readingby": 13 },
                    { "Id": 109, "Invertor_Id": 2, "Time_of_Reading": "20170216", "Lastreading": 13, "Readingby": 25 },
                    { "Id": 112, "Invertor_Id": 2, "Time_of_Reading": "20170216", "Lastreading": 25, "Readingby": 49 }
                ]
            }
        ];

        $scope.exportData = function () {
            // alert("exporting"); // 这行可以注释掉或删掉
            var data = "";
            $scope.headers = [];
            angular.forEach($scope.inverters, function (value, key) {
                // debugger // 这行也可以注释掉或删掉
                var we = value.InvDetails;
                $scope.headers.push(we); // 收集 InvDetails 到 headers 数组
                $scope.last = value.LstRecords;
                angular.forEach($scope.last, function (value, key) {
                    // 问题在这里:每次循环都覆盖了 data 变量
                    data = {
                        "Id": value.Id,
                        "Invertor_Id": value.Invertor_Id,
                        "Time_of_Reading": value.Time_of_Reading,
                        "Lastreading": value.Lastreading,
                        "Readingby": value.Readingby
                    };
                })
            })

            $scope.result = [];
            $scope.result0 = []; // 这个变量好像没用到
            $scope.result.push({
                "Invertor1": JSON.stringify(data) // 问题:只把最后一个记录转成 JSON 字符串放进去了
            })
            // debugger // 这行也可以注释掉或删掉

            // 问题:传递给 alasql 的数据格式不对,只包含一个字段 "Invertor1",其值是最后一个记录的 JSON 字符串
            alasql('SELECT * INTO XLSX("john.xlsx",{headers:true}) FROM ?', [$scope.result]);
        };
    }]);

目标是点击“导出”按钮,把 $scope.inverters 里的数据,特别是 LstRecords 数组里的那些记录,整理好输出成一个 XLSX 文件或者 CSV 文件。

为啥我的代码跑不通?—— 原因分析

仔细看看 exportData 函数,就能发现几个问题:

  1. 数据处理逻辑不对:

    • 代码里用了一个 data 变量,在内层循环 (forEach $scope.last) 里,每次都会被重新赋值。这意味着循环结束后,data 变量里存的永远是 最后一个 LstRecords 里的 最后一条 记录。比如上面例子里,它最后会是 {"Id": 112, "Invertor_Id": 2, ...} 这条。
    • 然后,代码把这个 data 对象 JSON.stringify 处理,再放到一个只包含 "Invertor1" 这个键的临时对象里,最后 push$scope.result 数组。 $scope.result 最后只包含一个元素,类似 [{ "Invertor1": "{\"Id\":112,\"Invertor_Id\":2,...}" }]
  2. AlaSQL 的用法不匹配:

    • alasql('SELECT * INTO XLSX(...) FROM ?', [$scope.result]) 这句是想让 AlaSQL 把数据导出。但是,你传给它的数据 $scope.result[{ "Invertor1": "..." }] 这种结构的。AlaSQL 看到这个,默认会导出一个只有一列(列名叫 Invertor1)并且只有一行数据的表格,这一行数据就是那个 JSON 字符串。这显然不是我们想要的,我们想要的是多行多列的数据,每一行对应一条 LstRecords 记录。
  3. 数据结构没“拍平”:

    • 原始数据 $scope.inverters 是一个嵌套结构:一个逆变器数组,每个逆变器下面又有一个记录数组 (LstRecords)。要想把它变成适合表格展示(比如 CSV 或 XLSX)的格式,通常需要把这种嵌套结构“拍平”(Flatten)。意思就是,把子记录和它的父信息(比如 InvDetails)组合在一起,形成一条条独立的记录。比如,我们可能想要这样的数据格式(一个扁平的对象数组):
    [
      { "InvDetails": "UPS", "Id": 1, "Invertor_Id": 1, "Time_of_Reading": "20170214", "Lastreading": 0, "Readingby": 0 },
      { "InvDetails": "UPS", "Id": 87, "Invertor_Id": 1, "Time_of_Reading": "20170215", "Lastreading": 5, "Readingby": 10 },
      // ... 其他 UPS 的记录 ...
      { "InvDetails": "Power Supply", "Id": 2, "Invertor_Id": 2, "Time_of_Reading": "20170214", "Lastreading": 0, "Readingby": 0 },
      { "InvDetails": "Power Supply", "Id": 88, "Invertor_Id": 2, "Time_of_Reading": "20170215", "Lastreading": 7, "Readingby": 13 }
      // ... 其他 Power Supply 的记录 ...
    ]
    

    只有准备好这种扁平化的数据结构,AlaSQL 或者手动生成 CSV 才能正确工作。

解决方案:动手改造!

知道了问题在哪,改起来就容易多了。下面提供两种主要的解决方案:

方案一:利用 AlaSQL 库 (推荐)

既然已经引入了 AlaSQL,那咱们就用好它。核心思路就是先按上面说的,把数据“拍平”,然后再交给 AlaSQL 处理。

原理与作用

AlaSQL 是一个强大的 JavaScript SQL 数据库库,它能在浏览器里直接对 JavaScript 对象数组执行 SQL 查询,还能方便地把查询结果导出成 XLSX, CSV, JSON 等多种格式。咱们的目标就是构造一个符合 AlaSQL 要求的、扁平化的 JavaScript 对象数组。

操作步骤与代码

修改 exportData 函数如下:

app.controller('exportreportController', ['$scope', '$timeout',
    function ($scope, $timeout) {

        $scope.inverters = [
            // ... (原始 JSON 数据不变) ...
             {
                "InvDetails": "UPS",
                "LstRecords": [
                    { "Id": 1, "Invertor_Id": 1, "Time_of_Reading": "20170214", "Lastreading": 0, "Readingby": 0 },
                    { "Id": 87, "Invertor_Id": 1, "Time_of_Reading": "20170215", "Lastreading": 5, "Readingby": 10 },
                    { "Id": 110, "Invertor_Id": 1, "Time_of_Reading": "20170216", "Lastreading": 10, "Readingby": 92 },
                    { "Id": 111, "Invertor_Id": 1, "Time_of_Reading": "20170216", "Lastreading": 92, "Readingby": 95 }
                ]
            },
            {
                "InvDetails": "Power Supply",
                "LstRecords": [
                    { "Id": 2, "Invertor_Id": 2, "Time_of_Reading": "20170214", "Lastreading": 0, "Readingby": 0 },
                    { "Id": 88, "Invertor_Id": 2, "Time_of_Reading": "20170215", "Lastreading": 7, "Readingby": 13 },
                    { "Id": 109, "Invertor_Id": 2, "Time_of_Reading": "20170216", "Lastreading": 13, "Readingby": 25 },
                    { "Id": 112, "Invertor_Id": 2, "Time_of_Reading": "20170216", "Lastreading": 25, "Readingby": 49 }
                ]
            }
        ];

        $scope.exportData = function () {
            console.log("开始导出数据..."); // 加个日志方便调试

            // 1. 数据拍平处理
            var flattenedData = [];
            angular.forEach($scope.inverters, function (inverter) {
                var details = inverter.InvDetails; // 获取父级信息
                angular.forEach(inverter.LstRecords, function (record) {
                    // 创建一个新的对象,包含父级信息和子记录信息
                    var flatRecord = {
                        '设备详情': details, // 可以自定义列名
                        '记录ID': record.Id,
                        '逆变器ID': record.Invertor_Id,
                        '读取时间': record.Time_of_Reading,
                        '上次读数': record.Lastreading,
                        '读取人': record.Readingby // 或者 '本次读数'? 根据业务理解调整
                    };
                    flattenedData.push(flatRecord);
                });
            });

            console.log("拍平后的数据:", flattenedData); // 看看数据对不对

            if (flattenedData.length === 0) {
                alert("没有数据可以导出!");
                return;
            }

            // 2. 使用 AlaSQL 导出 XLSX
            var options = {
                headers: true, // 让 AlaSQL 自动使用对象的键作为表头
                // 可以添加其他 AlaSQL 选项,比如指定 sheet 名称
                // sheetid: '我的报告'
            };
            try {
                // SELECT * 表示选择所有列
                // INTO XLSX("文件名.xlsx", 配置)
                // FROM ? 表示数据源来自后面的参数数组
                alasql('SELECT * INTO XLSX("导出报告.xlsx", ?) FROM ?', [options, flattenedData]);
                console.log("XLSX 文件导出成功!");
            } catch (error) {
                console.error("导出 XLSX 时出错:", error);
                alert("导出失败,请查看控制台错误信息。");
            }

            // 如果也想支持导出 CSV
            // 可以加一个类似的按钮或判断逻辑
            // try {
            //     alasql('SELECT * INTO CSV("导出报告.csv", {headers: true, separator:","}) FROM ?', [flattenedData]);
            //     console.log("CSV 文件导出成功!");
            // } catch (error) {
            //     console.error("导出 CSV 时出错:", error);
            //     alert("导出 CSV 失败,请查看控制台错误信息。");
            // }
        };
    }]);

代码解释:

  1. 我们创建了一个空数组 flattenedData
  2. 通过两层 angular.forEach 遍历原始的 $scope.inverters 数据。
  3. 在内层循环中,对于 LstRecords 里的每一条 record,我们创建了一个新的对象 flatRecord。这个对象不仅包含了 record 自身的所有属性(比如 Id, Time_of_Reading 等),还把外层的 inverter.InvDetails 也加了进去,并给了它一个有意义的键名,比如 '设备详情'
  4. 你可以按需调整 flatRecord 里的键名,这些键名会成为 Excel 文件里的列标题(因为我们设置了 headers: true)。
  5. 把每个 flatRecord 添加到 flattenedData 数组里。
  6. 最后,调用 alasql('SELECT * INTO XLSX("导出报告.xlsx", ?) FROM ?', [options, flattenedData])。这里:
    • SELECT * 表示选择 flattenedData 里每个对象的所有属性作为列。
    • INTO XLSX("导出报告.xlsx", ?) 表示输出格式为 XLSX,文件名是 "导出报告.xlsx",第二个 ? 代表 options 对象。
    • FROM ? 表示数据来源是第三个 ? 代表的 flattenedData 数组。
    • options 对象里的 headers: true 告诉 AlaSQL 使用 flattenedData 里对象的键作为 Excel 的表头。

这样,点击按钮后,就会生成一个名为 "导出报告.xlsx" 的文件,里面的数据就是我们期望的扁平化表格。

进阶技巧

  • 自定义列名和顺序: 如果不想用对象本身的键名,或者想控制列的顺序,可以在 AlaSQL 查询里明确指定列和别名:
    var sql = 'SELECT 设备详情 AS "设备名称", `记录ID` AS "记录编号", 读取时间, 上次读数, 读取人 \
               INTO XLSX("自定义报告.xlsx", ?) FROM ?';
    // 注意:如果列名包含空格或特殊字符,建议用反引号或双引号包起来。
    alasql(sql, [options, flattenedData]);
    
  • 数据格式化: AlaSQL 可能不直接支持复杂的 Excel 单元格格式化(比如日期格式、数字精度)。如果需要精细控制,可能要考虑更专业的库,或者导出 CSV 后在 Excel 里手动调整。对于日期,确保 Time_of_Reading 字段是 Excel 能识别的格式(比如 YYYY-MM-DD),或者在导出前转换一下。
  • 错误处理: 加上 try...catch 块是个好习惯,可以在导出失败时给用户提示,并在控制台输出详细错误。

安全建议

  • 如果 $scope.inverters 的数据是用户输入的或者来自不可信的外部 API,导出前最好做一下数据校验和清理(Sanitization),防止潜在的注入攻击(虽然导出到 XLSX/CSV 的风险相对较低,但仍需注意,比如避免内容被 Excel 误解析为公式执行)。

方案二:手动生成 CSV 文件

如果你不想依赖 AlaSQL 的导出功能,或者只想导出简单的 CSV 格式,可以自己动手拼字符串。这需要用到 FileSaver.js(用户已经引入了)。

原理与作用

CSV (Comma-Separated Values) 是一种纯文本格式,用逗号(或其他分隔符)分隔不同的字段,用换行符分隔不同的记录行。手动生成 CSV 就是构造这么一个符合规范的大字符串,然后利用 FileSaver.js 把它包装成文件让用户下载。

操作步骤与代码

同样需要先做数据“拍平”。我们沿用方案一里的 flattenedData

app.controller('exportreportController', ['$scope', '$timeout',
    function ($scope, $timeout) {

        $scope.inverters = [ /* ... 原始数据 ... */ ];

        // --- 这里是方案一的数据拍平逻辑,保持不变 ---
        function getFlattenedData() {
            var flattenedData = [];
            angular.forEach($scope.inverters, function (inverter) {
                var details = inverter.InvDetails;
                angular.forEach(inverter.LstRecords, function (record) {
                    var flatRecord = {
                        '设备详情': details,
                        '记录ID': record.Id,
                        '逆变器ID': record.Invertor_Id,
                        '读取时间': record.Time_of_Reading,
                        '上次读数': record.Lastreading,
                        '读取人': record.Readingby
                    };
                    flattenedData.push(flatRecord);
                });
            });
            return flattenedData;
        }
        // --- 数据拍平逻辑结束 ---

        $scope.exportCsv = function () { // 新增一个导出 CSV 的函数
            console.log("开始导出 CSV 数据...");

            var flattenedData = getFlattenedData(); // 获取拍平后的数据

            if (flattenedData.length === 0) {
                alert("没有数据可以导出!");
                return;
            }

            // 1. 准备 CSV 内容
            var csvContent = "";

            // 准备表头 (从第一个数据对象的键获取)
            var headers = Object.keys(flattenedData[0]);
            csvContent += headers.join(',') + '\n'; // 用逗号连接,并添加换行符

            // 准备数据行
            angular.forEach(flattenedData, function (row) {
                var values = [];
                angular.forEach(headers, function (header) {
                    // 处理单元格内容,特别是包含逗号或引号的情况
                    var cellValue = row[header] === null || row[header] === undefined ? '' : String(row[header]);
                    // 简单的处理:如果值包含逗号、双引号或换行符,用双引号括起来,并将内部双引号替换为两个双引号
                    if (cellValue.includes(',') || cellValue.includes('"') || cellValue.includes('\n')) {
                        cellValue = '"' + cellValue.replace(/"/g, '""') + '"';
                    }
                    values.push(cellValue);
                });
                csvContent += values.join(',') + '\n'; // 用逗号连接,并添加换行符
            });

            console.log("生成的 CSV 字符串:", csvContent.substring(0, 200) + "..."); // 只显示部分内容

            // 2. 创建 Blob 对象
            // IMPORTANT: 为了让 Excel 正确识别 UTF-8 编码(特别是中文),通常需要在文件开头加上 BOM (Byte Order Mark)
            var bom = new Uint8Array([0xEF, 0xBB, 0xBF]); // UTF-8 BOM
            var blob = new Blob([bom, csvContent], { type: 'text/csv;charset=utf-8;' });

            // 3. 使用 FileSaver.js 下载
            try {
                saveAs(blob, "导出报告.csv"); // FileSaver.js 提供的函数
                console.log("CSV 文件导出成功!");
            } catch (error) {
                console.error("导出 CSV 时出错:", error);
                alert("导出 CSV 失败,请查看控制台错误信息。");
                // 可能需要提示用户浏览器是否支持,或者 FileSaver.js 是否正确加载
            }
        };

        // 你可以保留原来的 exportData 调用 AlaSQL,或者把它也改成调用 exportCsv
        $scope.exportData = $scope.exportCsv; // 例如,让原来的按钮触发 CSV 导出

    }]);

// 别忘了在 HTML 里加一个调用 exportCsv 的按钮,或者修改现有按钮的 ng-click
// <button ng-click="exportCsv()" class="btn btn-info">导出 CSV</button>

代码解释:

  1. 同样先调用 getFlattenedData() 获得扁平数据。
  2. 准备表头:flattenedData 的第一个对象里获取所有的键 (Object.keys()),用逗号 (join(',')) 把它们连接起来,最后加上换行符 (\n)。
  3. 准备数据行: 遍历 flattenedData 中的每个对象 (row)。对于每一行:
    • 我们再次遍历表头 (headers),确保按正确的顺序取值。
    • 获取 row[header] 的值。
    • 重要:处理特殊字符。 这是手动生成 CSV 最麻烦的地方。如果单元格的值本身包含逗号、双引号或换行符,需要按 CSV 标准进行转义:整个值要用双引号包起来,并且值里面原有的双引号要替换成两个双引号 ("")。代码里提供了一个简单的实现。
    • 把处理过的值 pushvalues 数组。
    • 用逗号连接 values 数组,并在末尾加换行符。
  4. 创建 Blob: 把表头和所有数据行拼成的 csvContent 字符串,连同一个 UTF-8 BOM(\uFEFF 或者 new Uint8Array([0xEF, 0xBB, 0xBF])),一起放进一个 Blob 对象。指定 MIME 类型为 text/csv;charset=utf-8;加上 BOM 对中文 Excel 打开 CSV 文件至关重要,否则可能乱码。
  5. 下载: 调用 saveAs(blob, "导出报告.csv"),浏览器就会提示用户下载文件。

进阶技巧

  • 分隔符: 如果不想用逗号,可以用别的分隔符(比如分号 ; 或制表符 \t),只要在 join()Blob 类型里相应修改就行。但逗号是最通用的。
  • 大型数据: 如果数据量非常大,一次性生成整个 CSV 字符串可能会消耗很多内存,甚至卡死浏览器。这时可以考虑流式生成或分片处理,但实现起来复杂得多,通常前端不太会遇到需要处理这么大导出的场景。
  • 库: 其实也有专门用于在前端生成 CSV 的库(比如 PapaParse 反过来用,或者简单的 json2csv 类型的库),可以帮你处理特殊字符转义等细节。如果觉得手动拼接太繁琐,可以考虑引入它们。

安全建议

  • 和方案一类似,注意输入数据的校验和清理。
  • 确保 FileSaver.js 库已经正确加载并且是可信来源。

现在,你应该能根据自己的需求,选择用 AlaSQL 还是手动生成 CSV 的方式,来解决 AngularJS 应用里 JSON 导出文件的问题了。两种方法都需要先把嵌套数据“拍平”,这是关键一步。