版权声明:本文为百里飞洋原创博文,合理引用请附带原文地址,未经授权禁止任何平台全文转载。

免责须知:文章中所有代码仅为演示需要,不保证功能完整性。在生产环境中使用前请进行充分评估,本文作者不对因使用本文内容而导致的任何直接、间接、特殊或后果性损失承担责任。

00 前言

对于中后台系统来说,在前端网页实现 Excel 表的导入和导出是很常见的功能,通常用于批量数据的添加和指定条件数据的下载。有些是纯前端的实现,有些是需要后端配合数据库的读写来实现。

本文主要探究在前端主导的前提下(工作大量在前端),项目前端采用 Vue 2.7 + Element-UI、后端采用 Node.js + Express 技术栈实现相关功能。

01 数据导入

关于导入数据有两种思路:一种是在前端就解析好 Excel 文件,直接将数据内容发给后端;另一种是将 Excel 文件直接上传到后端解析。对于后端是 Node.js 的系统来说,不管采用哪种思路,都得由 JavaScript 语言实现。

在表格数据量不是过于庞大的前提下,将数据在前端就解析好,不仅能避免浪费服务器资源,也能及早校验文件不合规数据。因此我倾向于将 Excel 数据在前端就读取出来,让后端将全部注意力放在数据校验和导入操作上。

1.1 前端组件设计

我看了一些中后台开源框架的 Excel 导入组件的写法(如 vue-element-admin[2],或许是为了方便演示,都是放在单独的路由页面上,这为权限管理增加了麻烦。并且上传组件和数据展示组件是分离的,当文件读取解析成功后,上传组件也不会自动隐藏。

但我结合自身业务需求来说,将上传组件数据展示组件都封装在一个组件中是更加合适的,当用户审阅完文件解析出的数据准确无误后,再通过确定按钮发送给后端。同时,为了不占用页面空间,将该组件封装到弹窗中是一个不错的选择,这样在不需上传或者上传完毕后就可以直接隐藏组件。

1.2 读取功能实现

社区中常使用 xlsx 依赖包实现 Excel 的数据读取,用法详见 官方文档( 可结合 社区中文文档 学习)。该开源库又名 SheetJS,是一个用于从电子表格读取和写入数据的 JavaScript 库。[1]

如果使用 VueJS 和 SheetJS 处理和生成电子表格,探讨如何在 VueJS SFC (单文件组件)中加载 SheetJS,并比较常见的状态模型和数据流策略,可以参考官网 Sheets in VueJS Sites 文档。

  • 安装依赖 xlsx

    1
    npm install xlsx
  • 在 VueJS SFC 中导入全部代码

    1
    import XLSX from 'xlsx'

    或按需导入部分代码

    1
    import { read, utils, writeFile } from 'xlsx';
  • 读取数据,以下参考代码复制于 vue-element-admin 项目的 UploadExcel/index.vue 组件[2]

    其中 读取本地文件 写法可以参考 官方文档-Blob and File中文文档-

    其中 数据导入 代码写法可以参考 官方文档-Data Import中文文档-数据导入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    readerData(rawFile) {
    this.loading = true
    return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = e => {
    const data = e.target.result
    const workbook = XLSX.read(data, { type: 'array' })
    const firstSheetName = workbook.SheetNames[0]
    const worksheet = workbook.Sheets[firstSheetName]
    const header = this.getHeaderRow(worksheet)
    const results = XLSX.utils.sheet_to_json(worksheet)
    this.generateData({ header, results })
    this.loading = false
    resolve()
    }
    reader.readAsArrayBuffer(rawFile)
    })
    },

    获取表头

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    getHeaderRow(sheet) {
    const headers = []
    const range = XLSX.utils.decode_range(sheet['!ref'])
    let C
    const R = range.s.r
    /* start in the first row */
    for (C = range.s.c; C <= range.e.c; ++C) { /* walk every column in the range */
    const cell = sheet[XLSX.utils.encode_cell({ c: C, r: R })]
    /* find the cell in the first row */
    let hdr = 'UNKNOWN ' + C // <-- replace with your desired default
    if (cell && cell.t) hdr = XLSX.utils.format_cell(cell)
    headers.push(hdr)
    }
    return headers
    },

    生成数据

    1
    2
    3
    4
    5
    generateData({ header, results }) {
    this.excelData.header = header
    this.excelData.results = results
    // console.log(this.excelData)
    },
  • 表格预览

    1
    2
    3
    4
    5
    6
    <el-table v-show="readSuccess" :data="excelData.results" border size="mini" highlight-current-row>
    <el-table-column type="index" label="1">
    <template slot-scope="{ $index }">{{ $index + 2 }}</template>
    </el-table-column>
    <el-table-column v-for="item of excelData.header" :key="item" :prop="item" :label="item" />
    </el-table>

1.3 组件代码示例

以下代码基于 Element-UI 二次封装,仅为逻辑示例,不保证功能完整性。[3-6]

父组件(这里采用了异步的方式导入子组件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<template>
<div>
<el-row>
<el-col :span="24">
<el-button size="medium" type="warning" icon="el-icon-upload2" @click="handleImport">导入</el-button>
<el-button size="medium" type="primary" icon="el-icon-download">导出</el-button>
</el-col>
</el-row>

<!-- Excel导入组件 -->
<import-excel-component
v-if="importExcelDialogVisible"
:visible.sync="importExcelDialogVisible"
@confirm-import="handleConfirmImport"
></import-excel-component>
</div>
</template>

<script>
export default {
components: {
importExcelComponent: () => import('@/components/importExcel/index.vue'),
},
data() {
return {
// Excel导入组件是否可见
importExcelDialogVisible: false,
}
},
methods: {
// 导入
handleImport() {
this.importExcelDialogVisible = true
},
// 确认导入
handleConfirmImport(excelData) {
console.log(excelData)
},
},
}
</script>

子组件(实现数据导入的核心组件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
<template>
<el-dialog
title="导入 Excel 文件"
:visible.sync="dialogVisible"
:before-close="handleClose"
:width="readSuccess ? 'calc(100% - 80px)' : '400px'"
style="min-width: 800px"
top="40px"
v-loading="readingFileLoading"
element-loading-text="文件读取中"
>
<!-- 提示信息 -->
<el-alert
v-show="readSuccess"
title="为确保数据导入准确,请滚动页面至表格底部,审查解析出的所有数据是否合规正常。如若一切正常,可点击最下方的【确认导入】按钮提交数据。"
description="如若存在任何异常数据(如缺失字段、数据乱码等与原文件不一致的情况),请点击【取消】按钮关闭对话框,然后尝试修正数据后再次导入。"
type="warning"
:closable="false"
style="position: relative; transform: translateY(-20px)"
show-icon
center
>
</el-alert>

<!-- 文件选择 -->
<el-upload v-show="!readSuccess" action="#" drag :auto-upload="false" accept=".xlsx,.xls" list-type="text" :limit="1" :on-change="handleChange">
<i class="el-icon-upload"></i>
<div class="el-upload__text">将 xlsx/xls 文件拖到此处,或 <em>点击选择</em></div>
<!-- <div class="el-upload__tip" slot="tip">只能上传 xlsx/xls 文件,且不超过5MB</div> -->
<div class="el-upload__tip" slot="tip">请去除表格中的冗余行、列等无效数据,确保内容完整规范</div>
</el-upload>

<!-- 表格预览 -->
<el-table v-show="readSuccess" :data="excelData.results" size="mini" border resizable highlight-current-row>
<el-table-column type="index" label="1">
<template slot-scope="{ $index }">{{ $index + 2 }}</template>
</el-table-column>
<el-table-column v-for="item of excelData.header" :key="item" :prop="item" :label="item" />
</el-table>

<!-- 统计信息 -->
<div v-show="readSuccess" style="margin-top: 10px">
<p>
成功读取该表格共 <span class="red-bold">{{ excelData.header.length }}</span> 条有效列、
<span class="red-bold">{{ excelData.results.length + 1 }}</span> 条有效行,去除表头行后,共计
<span class="red-bold">{{ excelData.results.length }}</span> 条内容数据。 请与原文件进行核对,确保内容无误后再进行导入操作。
</p>
</div>

<span slot="footer" class="dialog-footer">
<el-button @click="handleClose">取 消</el-button>
<el-button type="primary" :disabled="!readSuccess" @click="handleImport">确认导入</el-button>
</span>
</el-dialog>
</template>

<script>
// 导入 XLSX 库 import XLSX from 'xlsx'
import { read as xlsxRead, utils as xlsxUtils } from 'xlsx'

export default {
props: {
visible: {
type: Boolean,
default: false,
},
},
data() {
return {
// 对话框是否可见
dialogVisible: this.visible,
// 是否正在读取文件
readingFileLoading: false,
// 读取的文件数据
excelData: {
header: [],
results: [],
},
// 是否已经读取成功
readSuccess: false,
}
},
methods: {
handleClose(done) {
this.$confirm('确认关闭 Excel 导入对话框?')
.then((_) => {
this.dialogVisible = false
this.$emit('update:visible', this.dialogVisible)
done()
})
.catch((_) => {})
},
// 文件状态改变
handleChange(file, fileList) {
// console.log(file, fileList)

// 获取二进制文件
const rawFile = file.raw
// console.log(rawFile)

// 校验文件类型
if (!/\.(xlsx|xls)$/.test(rawFile.name)) {
this.$message({ type: 'warning', center: true, message: '请选择 xlsx/xls 后缀的文件' })
return false
}

// 校验文件大小
const isLt5M = rawFile.size / 1024 / 1024 < 5
if (!isLt5M) {
this.$message({ type: 'warning', center: true, message: '请不要选择超过 5MB 的文件' })
return false
}

// 读取文件数据
this.readerData(rawFile)
},
// 读取文件数据
readerData(rawFile) {
// 开始读取文件
this.readingFileLoading = true

return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const data = e.target.result
const workbook = xlsxRead(data, { type: 'array' })
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const header = this.getHeaderRow(worksheet)
const results = xlsxUtils.sheet_to_json(worksheet)
this.generateData({ header, results })
this.loading = false
resolve()
}
reader.readAsArrayBuffer(rawFile)
})
},
// 获取表头行
getHeaderRow(sheet) {
const headers = []
const range = xlsxUtils.decode_range(sheet['!ref'])
let C
const R = range.s.r
/* start in the first row */
for (C = range.s.c; C <= range.e.c; ++C) {
/* walk every column in the range */
const cell = sheet[xlsxUtils.encode_cell({ c: C, r: R })]
/* find the cell in the first row */
let hdr = 'UNKNOWN ' + C // <-- replace with your desired default
if (cell && cell.t) hdr = xlsxUtils.format_cell(cell)
headers.push(hdr)
}
return headers
},
// 生成数据
generateData({ header, results }) {
this.excelData.header = header
this.excelData.results = results
// console.log(this.excelData)

// 读取成功
this.readSuccess = true
this.readingFileLoading = false
},
// 确认导入
handleImport() {
// 触发父组件的确认导入事件
this.$emit('confirm-import', this.excelData)
},
},
}
</script>

<style lang="less" scoped>
// 红色加粗提示数据
.red-bold {
color: red;
font-weight: bold;
font-size: 18px;
}
</style>

我给出的上述代码示例,在选中 Excel 前是小弹窗,文件读取过程中使页面全屏处于加载状态防止用户干扰,读取成功后变为占满全屏的大弹窗,用户审阅完数据后点击确认按钮,才会将数据传递给父组件。

上述读取代码虽然可以过滤空白行只读取有效内容,但是也有其局限性。因为默认会将第一行(读取到的第一个有效内容行)的内容作为表头,如果第一行表头中存在空白单元格,则该单元格所在列的数据均不能正确读取。如果上传的 Excel 表为空文件,也会导致代码报错,使页面一直停留在读取(加载)状态。

1.4 数据格式处理

我以一张河南省部分高校的数据表为例,读取文件后可以得到如下数据对象,包含表头数组 header 和内容数组 result(其中 备注 字段为选填,其他字段为必填):

1
2
3
4
5
6
7
{
"header":["序号","学校名称","学校标识码","主管部门","所在地","办学层次","备注"],
"results":[
{"序号":1504,"学校名称":"河南师范大学","学校标识码":4141010476,"主管部门":"河南省","所在地":"新乡市","办学层次":"本科"},
{"序号":1547,"学校名称":"郑州西亚斯学院","学校标识码":4141014654,"主管部门":"河南省教育厅","所在地":"郑州市","办学层次":"本科","备注":"民办"},
]
}

那我们如何设计后端接口,又如何校验收到的数据,使其更容易、更准确导入 MySQL 数据库中呢?

如果直接将以上数据直接发送给后端,由于 json 键值对中的很多键名是中文,这不太利于后端数据校验,我们不妨将数据转换为这种格式(其中 remark 备注字段为选填,其他字段为必填):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[
{
list_id: 1504,
name: '河南师范大学',
code: 4141010476,
department: '河南省',
location: '新乡市',
level: '本科',
},
{
list_id: 1547,
name: '郑州西亚斯学院',
code: 4141014654,
department: '河南省教育厅',
location: '郑州市',
level: '本科',
remark: '民办',
},
]

于是就可以在父组件中进行数据格式的转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 确认导入
handleConfirmImport(excelData) {
console.log(excelData)

// 处理Excel数据
const collegeData = this.handleExcelData(excelData)
console.log(collegeData)
},
// 处理Excel数据
handleExcelData(excelData) {
console.log(excelData)

// 原始json格式
// {
// "header":["序号","学校名称","学校标识码","主管部门","所在地","办学层次","备注"],
// "results":[
// {"序号":1504,"学校名称":"河南师范大学","学校标识码":4141010476,"主管部门":"河南省","所在地":"新乡市","办学层次":"本科"},
// {"序号":1547,"学校名称":"郑州西亚斯学院","学校标识码":4141014654,"主管部门":"河南省教育厅","所在地":"郑州市","办学层次":"本科","备注":"民办"},
// ]
// }

// 目标array格式
// [
// { list_id: 1504, name: '河南师范大学', code: 4141010476, department: '河南省', location: '新乡市', level: '本科' },
// { list_id: 1547, name: '郑州西亚斯学院', code: 4141014654, department: '河南省教育厅', location: '郑州市', level: '本科', remark: '民办' },
// ]

// 定义表头映射关系
// ["序号","学校名称","学校标识码","主管部门","所在地","办学层次","备注"]
const headerMap = {
序号: 'list_id',
学校名称: 'name',
学校标识码: 'code',
主管部门: 'department',
所在地: 'location',
办学层次: 'level',
备注: 'remark',
}

// 处理数据
const targetData = excelData.results.map((item) => {
const obj = {}
for (const key in item) {
if (Object.hasOwnProperty.call(item, key)) {
const element = item[key]
obj[headerMap[key]] = element
}
}
return obj
})

return targetData
},

1.5 接口数据校验

下一步就要思考如何进行上述数据数组的后台校验了,通常我们在后端进行的是数据表单的验证,如使用 joi 模块进行字段的类型约束合规校验。但是怎么应对这种复杂数组呢?每一个数组项都是一个多属性对象。

其实 Joi 也有数据校验的方法,结合官方文档 array 方法,可以写出如下的校验文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* 高校信息验证规则模块
* 数据来源于“中华人民共和国教育部”于2023年6月19日发布的《全国高等学校名单》https://hudong.moe.gov.cn/qggxmd/
*/

const Joi = require("joi");

// 定义学校序号的验证规则,最小值为 1,最大值为 2820 的整数
const list_id = Joi.number()
.integer()
.min(1)
.max(2820)
.required()
.error(new Joi.ValidationError("存在不合规的“学校序号”字段,需要介于 1 和 2820 之间"));

// 定义学校名称的验证规则,不超过 50 个字符
const name = Joi.string().max(50).required().error(new Joi.ValidationError("存在不合规的“学校名称”字段,最多可填写 50 个字符"));

// 定义学校标识码的验证规则,最小值为 4111010001,最大值为 4165014828 的整数
const code = Joi.number()
.integer()
.min(4111010001)
.max(4165014828)
.required()
.error(new Joi.ValidationError("存在不合规的“学校标识码”字段,需要介于 4111010001 和 4165014828 之间"));

// 定义主管部门的验证规则,不超过 50 个字符
const department = Joi.string().max(50).required().error(new Joi.ValidationError("存在不合规的“主管部门”字段,最多可填写 50 个字符"));

// 定义所在地的验证规则,不超过 50 个字符
const location = Joi.string().max(50).required().error(new Joi.ValidationError("存在不合规的“所在地”字段,最多可填写 50 个字符"));

// 定义办学层次的验证规则,取值有:本科、专科
const level = Joi.string().valid("本科", "专科").required().error(new Joi.ValidationError("存在不合规的“办学层次”字段,只能填写“本科”或“专科”"));

// 定义备注的验证规则,不超过 10 个字符,非必填
const remark = Joi.string().max(50).required().error(new Joi.ValidationError("存在不合规的“备注”字段,最多可填写 10 个字符"));

// 定义高校数据的验证规则,数组类型,数组最小项数为 1,最大项数为 2820
// 每个数组元素是一个对象,其中 remark 为非必填字段
// 目标array格式
// [
// { list_id: 1504, name: '河南师范大学', code: 4141010476, department: '河南省', location: '新乡市', level: '本科' },
// { list_id: 1547, name: '郑州西亚斯学院', code: 4141014654, department: '河南省教育厅', location: '郑州市', level: '本科', remark: '民办' },
// ]
const collegeData = Joi.array()
.min(1)
.max(2820)
.items(
Joi.object({
list_id,
name,
code,
department,
location,
level,
remark,
})
)
.required()
// .error(new Joi.ValidationError("高校数据数组不合规"));

// 验证规则对象 - 导入高校数据
module.exports.importCollegeData_schema = {
body: {
collegeData,
},
};

其中,如果想让报错信息更具体,可以注释掉 .error(new Joi.ValidationError("高校数据数组不合规")); 这行代码,因为我们自定义的错误信息会覆盖 Joi 所有的错误消息。去掉自定义报错内容后,当具体字段值不合规时会给出我们针对该字段的自定义错误消息。

如果想要让错误提示更更更具体,可以去掉对应字段验证规则的 .error() 方法,让 Joi 自动生成详细的错误消息。通过 Joi 自动生成的错误消息,可以详细到指出数组中哪一项的哪个字段不合规。

无论是选择自定义更通俗易懂报错信息,还是选择让 Joi 自动生成具体报错位置,都是不错的方式。后者可能存在用户理解门槛,因为需要用户了解表头中英文字段名的映射关系和数组索引的概念,这本身就是一种苛责。为降低使用门槛和理解难度,我们通常在实际开发中都会设有防呆设计(如敏感操作二次确认弹窗、醒目关键词内容标红等),更别提让非计科专业的普通用户理解“何为索引”这种平时用不到的东西了。

比如报错信息 "collegeData[6].level" must be one of [本科, 专科] 的意思是数组索引6的位置(即数组第7项,即要上传的带表头的原 Excel 表的第8行)的 level 字段的取值必须是本科或专科。这显然没有直接提示用户 存在不合规的“办学层次”字段,只能填写“本科”或“专科” 更易快速理解,只不过数据行过多的时候排查得稍微慢一些,但起码可以让他们理解页面的弹窗报错指的是自己文件的原因,而不是网站系统的报错。

1.6 数据库表设计

前期数据处理的大麻烦解决了,接下来的事情就简单了。我们可以将数据库表 college 设计为如下形式:

该数据表仅供参考,严谨来说,除了主键 id,字段 list_idnamecode 字段都应该是唯一的,你可以自行创建一个包含这些字段的 UNIQUE 索引。[7]

Column Name Datatype PK NN AI Default 说明
id int 主键 id
uid int 导入该数据的用户 uid
timestamp bigint 导入时间戳
list_id int 序号
name varchar(50) 学校名称
code bigint 学校标识码
department varchar(50) 主管部门
location varchar(50) 所在地
level varchar(10) 办学层次
remark varchar(10) 备注

其中:

  • list_id 序号:最小值为 1,最大值为 2820 的整数
  • name 学校名称:不超过 50 个字符
  • code 学校标识码:最小值为 4111010001,最大值为 4165014828 的整数
  • department 主管部门:不超过 50 个字符
  • location 所在地:不超过 50 个字符
  • level 办学层次:取值有:本科、专科
  • remark 备注:不超过 10 个字符,非必填

如果你有疑惑,为什么 code 字段用 bigint 类型而不是 int 类型,是因为在 MySQL 中,INT 数据类型的最大值为 2147483647,而 BIGINT 数据类型的最大值为 9223372036854775807,所以 BIGINT 更适合存储这个字段。

如果你觉得 varchar(10) 也能存储这十位 code 标识码,理论上是这样的。对于存储十位数的字段,BIGINTVARCHAR(10) 都可以使用,但是选择哪种类型取决于你如何使用这个字段。

  • 如果你打算对这个字段进行数值运算(例如,加、减、乘、除、比较大小等),那么 BIGINT 是更好的选择。因为 BIGINT 是数值类型,可以直接进行数值运算。
  • 如果你只是把这个字段当作标识符或者代码(例如,你不会对它进行数值运算,只是用来显示或者作为查询的条件),那么 VARCHAR(10) 可能是更好的选择。因为 VARCHAR(10) 可以存储任何十个字符的字符串,包括那些以零开头的数值。

总的来说,如果这个字段是数值并且你需要进行数值运算,那么使用 BIGINT;如果这个字段是标识符或者代码,并且你不需要进行数值运算,那么使用 VARCHAR(10)

但是(我很喜欢说但是),如果你使用 VARCHAR(10) 来存储大整数,在后期想要按照大小进行排序,查询的结果可能就不是你所期望的了。在 SQL 中,VARCHAR 类型的字段是按照字典顺序进行排序的,而不是按照数值大小。这意味着,例如,字符串 '100' 会被认为小于字符串 '20',因为 '1' 字符的 ASCII 值小于 '2'。这在 JavaScript 中的运行结果也是一致的,因为 console.log('20' > '100') 的结果也是 true

1.7 后端数据插入

请求体中的高校数据数组通过校验后,我们就可以将其数组项逐一插入到数据库了。

但是如何批量插入数据呢?循环遍历逐行插入,还是一次性多行插入?具体代码又怎么写?

处理函数的核心代码,可以参考以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 获取当前操作的时间戳
const operationTime = Date.now();

// 高校数据数组,每个元素是一个对象,其中数组项的 remark 字段可能不存在
// [
// { list_id: 1504, name: '河南师范大学', code: 4141010476, department: '河南省', location: '新乡市', level: '本科' },
// { list_id: 1547, name: '郑州西亚斯学院', code: 4141014654, department: '河南省教育厅', location: '郑州市', level: '本科', remark: '民办' },
// ]
// 获取高校数据
const collegeData = req.body.collegeData;
// console.log(collegeData);

// 定义 SQL 语句,向高校库表中插入数据,同时记录 import_uid 和 import_time
const sql = `INSERT INTO college (import_uid, import_time, list_id, name, code, department, location, level, remark) VALUES ?`;
db.query(
sql,
[
req.body.collegeData.map((item) => [
req.auth.uid,
operationTime,
item.list_id,
item.name,
item.code,
item.department,
item.location,
item.level,
item.remark || null, // 如果 remark 字段不存在,使用 null
]),
],
(err, results) => {
// 执行 SQL 语句失败
if (err) return res.send({ status: 500, message: err.message });
// 执行 SQL 语句成功,但影响行数不为正数
if (results.affectedRows <= 0) return res.send({ status: 500, message: "高校数据导入失败!" });

// 高校数据导入成功
res.send({
status: 200,
message: "高校数据导入成功!",
});
}
);

其中 item.remark || null, 你也可以写成 item.remark, 我测试了一下都能成功执行

在 JavaScript 中,如果 item.remarkundefined,那么 item.remark || null 会返回 null。这是因为 || 运算符会返回第一个“真值”(truthy)的操作数,如果没有真值的操作数,就返回最后一个操作数。在 JavaScript 中,undefined 是“假值”(falsy),所以如果 item.remarkundefineditem.remark || null 就会返回 null

然而,如果你的数据库可以接受 undefined 作为插入值,那么 item.remarkitem.remark || null 的效果是一样的。在这种情况下,你可以选择使用哪种写法。

如果你的数据库不能接受 undefined 作为插入值,那么你应该使用 item.remark || null。这样,如果 item.remarkundefined,你就会插入 null,而不是 undefined

总的来说,item.remark || null 是更安全的写法,因为它可以确保你总是插入一个数据库可以接受的值。

这里说个小技巧,若要反复测试导入功能,可以在清空数据表后,执行 ALTER TABLE college AUTO_INCREMENT = 1; 语句,来重置 AUTO_INCREMENT 的起始值,这样再插入数据时自增 id 还能从 1 开始。[7]


02 数据导出

关于导出数据也有两种思路:后端直接生成 Excel 文件,或者将数据发送给前端生成 Excel 文件。

这里其实还是要具体情况具体分析,大量数据肯定让后端生成,但一般情况我也更倾向于采用纯前端的方式。

2.1 前端函数设计

在中后台开源框架 vue-element-admin 中,Excel导出功能的解决方法是在 js-xlsx 的基础上又封装了一个工具函数,然后在组件需要时导入函数,从而实现Excel导出页面的功能需求。[9]

但该框架二次封装的 Export2Excel.js 实现代码不仅需要依赖 js-xlsx,还要依赖两个额外模块(说明文档):

  1. file-saver 是客户端保存文件的第三方解决方案

    1
    npm install file-saver

    框架使用该模块可能是为了兼容性考虑,故没有使用原生代码实现

  2. script-loader 可以将指定的模块 JavaScript 文件转成纯字符串通过 eval 方式执行

    1
    npm install script-loader -D

    框架使用该模块我一开始不知意欲何为,因为似乎文件中并没有用到,并且经测试不安装也可以正常导出

    后来才知道在框架早期需要引入三个依赖,并且由于这几个模块不支持 import 引入,所以需要 script-loader 来将他们挂载到全局环境下:[12]

    1
    2
    3
    4
    5
    6
    7
    8
    // main.js
    import Blob from './excal/Blob.js'
    import Export2Excel from './excal/Export2Excel.js'

    // Export2Excel.js
    require('script-loader!file-saver');
    require('script-loader!./Blob');
    require('script-loader!xlsx/dist/xlsx.core.min');

    但现在 XLSX 依赖已支持 import * as XLSX from 'xlsx' 这样引入。

2.2 导出功能实现

将整个 vue-element-admin/Export2Excel.js 文件复制到项目的 utils 目录

注意:该仓库年久失修,要将第二行 import XLSX from 'xlsx' 改为 import * as XLSX from 'xlsx',否则会报错 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'utils'),我已向作者提交 PR #4228

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
/* eslint-disable */
import { saveAs } from 'file-saver'
import * as XLSX from 'xlsx' // import XLSX from 'xlsx'

function generateArray(table) {
var out = [];
var rows = table.querySelectorAll('tr');
var ranges = [];
for (var R = 0; R < rows.length; ++R) {
var outRow = [];
var row = rows[R];
var columns = row.querySelectorAll('td');
for (var C = 0; C < columns.length; ++C) {
var cell = columns[C];
var colspan = cell.getAttribute('colspan');
var rowspan = cell.getAttribute('rowspan');
var cellValue = cell.innerText;
if (cellValue !== "" && cellValue == +cellValue) cellValue = +cellValue;

//Skip ranges
ranges.forEach(function (range) {
if (R >= range.s.r && R <= range.e.r && outRow.length >= range.s.c && outRow.length <= range.e.c) {
for (var i = 0; i <= range.e.c - range.s.c; ++i) outRow.push(null);
}
});

//Handle Row Span
if (rowspan || colspan) {
rowspan = rowspan || 1;
colspan = colspan || 1;
ranges.push({
s: {
r: R,
c: outRow.length
},
e: {
r: R + rowspan - 1,
c: outRow.length + colspan - 1
}
});
};

//Handle Value
outRow.push(cellValue !== "" ? cellValue : null);

//Handle Colspan
if (colspan)
for (var k = 0; k < colspan - 1; ++k) outRow.push(null);
}
out.push(outRow);
}
return [out, ranges];
};

function datenum(v, date1904) {
if (date1904) v += 1462;
var epoch = Date.parse(v);
return (epoch - new Date(Date.UTC(1899, 11, 30))) / (24 * 60 * 60 * 1000);
}

function sheet_from_array_of_arrays(data, opts) {
var ws = {};
var range = {
s: {
c: 10000000,
r: 10000000
},
e: {
c: 0,
r: 0
}
};
for (var R = 0; R != data.length; ++R) {
for (var C = 0; C != data[R].length; ++C) {
if (range.s.r > R) range.s.r = R;
if (range.s.c > C) range.s.c = C;
if (range.e.r < R) range.e.r = R;
if (range.e.c < C) range.e.c = C;
var cell = {
v: data[R][C]
};
if (cell.v == null) continue;
var cell_ref = XLSX.utils.encode_cell({
c: C,
r: R
});

if (typeof cell.v === 'number') cell.t = 'n';
else if (typeof cell.v === 'boolean') cell.t = 'b';
else if (cell.v instanceof Date) {
cell.t = 'n';
cell.z = XLSX.SSF._table[14];
cell.v = datenum(cell.v);
} else cell.t = 's';

ws[cell_ref] = cell;
}
}
if (range.s.c < 10000000) ws['!ref'] = XLSX.utils.encode_range(range);
return ws;
}

function Workbook() {
if (!(this instanceof Workbook)) return new Workbook();
this.SheetNames = [];
this.Sheets = {};
}

function s2ab(s) {
var buf = new ArrayBuffer(s.length);
var view = new Uint8Array(buf);
for (var i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
return buf;
}

export function export_table_to_excel(id) {
var theTable = document.getElementById(id);
var oo = generateArray(theTable);
var ranges = oo[1];

/* original data */
var data = oo[0];
var ws_name = "SheetJS";

var wb = new Workbook(),
ws = sheet_from_array_of_arrays(data);

/* add ranges to worksheet */
// ws['!cols'] = ['apple', 'banan'];
ws['!merges'] = ranges;

/* add worksheet to workbook */
wb.SheetNames.push(ws_name);
wb.Sheets[ws_name] = ws;

var wbout = XLSX.write(wb, {
bookType: 'xlsx',
bookSST: false,
type: 'binary'
});

saveAs(new Blob([s2ab(wbout)], {
type: "application/octet-stream"
}), "test.xlsx")
}

export function export_json_to_excel({
multiHeader = [],
header,
data,
filename,
merges = [],
autoWidth = true,
bookType = 'xlsx'
} = {}) {
/* original data */
filename = filename || 'excel-list'
data = [...data]
data.unshift(header);

for (let i = multiHeader.length - 1; i > -1; i--) {
data.unshift(multiHeader[i])
}

var ws_name = "SheetJS";
var wb = new Workbook(),
ws = sheet_from_array_of_arrays(data);

if (merges.length > 0) {
if (!ws['!merges']) ws['!merges'] = [];
merges.forEach(item => {
ws['!merges'].push(XLSX.utils.decode_range(item))
})
}

if (autoWidth) {
/*设置worksheet每列的最大宽度*/
const colWidth = data.map(row => row.map(val => {
/*先判断是否为null/undefined*/
if (val == null) {
return {
'wch': 10
};
}
/*再判断是否为中文*/
else if (val.toString().charCodeAt(0) > 255) {
return {
'wch': val.toString().length * 2
};
} else {
return {
'wch': val.toString().length
};
}
}))
/*以第一行为初始值*/
let result = colWidth[0];
for (let i = 1; i < colWidth.length; i++) {
for (let j = 0; j < colWidth[i].length; j++) {
if (result[j]['wch'] < colWidth[i][j]['wch']) {
result[j]['wch'] = colWidth[i][j]['wch'];
}
}
}
ws['!cols'] = result;
}

/* add worksheet to workbook */
wb.SheetNames.push(ws_name);
wb.Sheets[ws_name] = ws;

var wbout = XLSX.write(wb, {
bookType: bookType,
bookSST: false,
type: 'binary'
});
saveAs(new Blob([s2ab(wbout)], {
type: "application/octet-stream"
}), `${filename}.${bookType}`);
}

2.3 组件代码示例

以下代码基于 Element-UI 二次封装,仅为逻辑示例,不保证功能完整性。

本组件仅为 导出Excel功能 核心逻辑,已省略 导入Excel功能获取表格json数据 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<template>
<div>
<el-row>
<el-col :span="24">
<el-button size="medium" type="primary" icon="el-icon-download" :loading="exportLoading" @click="handleExport"> 导出高校列表 </el-button>
</el-col>
</el-row>
</template>

<script>
export default {
data() {
return {
// 导出按钮加载状态
exportLoading: false,
// 表格数据
tableData: [],
}
},
methods: {
// 导出
handleExport() {
// 设置导出按钮加载状态
this.exportLoading = true
// 导出Excel
import('@/utils/exportExcel').then(excel => {
// console.log(excel)
const tHeader = ['学校名称', '学校标识码', '主管部门', '所在地', '办学层次', '备注']
const filterVal = ['name', 'code', 'department', 'location', 'level', 'remark']
const list = this.tableData
// console.log(list) // 高校数据
const data = this.formatJson(filterVal, list)
// console.log(data) // 格式化后的高校数据
excel.export_json_to_excel({
header: tHeader, // 导出数据的表头
data, // 导出的数据
filename: '河南省58所本科高等学校名单', // 导出文件名
autoWidth: true, // 单元格是否要自适应宽度
bookType: 'xlsx', // 导出文件类型,支持xlsx、csv、txt
})
// 重置导出按钮加载状态
this.exportLoading = false
})
},
// 格式化Json,将对象数组转换为二维数组,方便导出Excel
formatJson(filterVal, jsonData) {
return jsonData.map(v => filterVal.map(j => {
return v[j]
}))
},
},
}
</script>

其中,由于 js-xlsx 体积还是很大的,导出功能是一个常见但不是很常用的功能,所以上述使用的时候用了 懒加载 的方法。说实话我好像也是第一次见在 Vue2 的 methods 里使用这种 import() 异步引入,或者说 动态导入 的写法:

1
2
3
import('@/utils/exportExcel').then(excel => {
// console.log(excel)
}

由于 import() 是一个异步操作,因此需要在调用它的函数上使用 async/await 语法糖,或者用 Promise 的 .then() 写法。

【文心一言】:

在Vue中,你可以使用 import() 语法来动态导入JavaScript文件。这是一个静态函数,可以用来代替静态的 import 语句。

下面是一个例子,展示了如何根据条件动态导入不同的JavaScript模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 假设你有两个JavaScript模块:moduleA.js 和 moduleB.js
// moduleA.js 内容如下:
export default {
sayHello() {
console.log('Hello from Module A!');
}
};

// moduleB.js 内容如下:
export default {
sayGoodbye() {
console.log('Goodbye from Module B!');
}
};

// 在你的Vue组件中,你可以这样动态导入模块:
<template>
<div>
<button @click="loadModule('moduleA')">Load Module A</button>
<button @click="loadModule('moduleB')">Load Module B</button>
</div>
</template>

<script>
export default {
data() {
return {
dynamicModule: null
};
},
methods: {
async loadModule(moduleName) {
try {
this.dynamicModule = await import(`./${moduleName}.js`);
if (this.dynamicModule) {
// 根据需要调用模块中的函数
if (moduleName === 'moduleA') {
this.dynamicModule.sayHello();
} else if (moduleName === 'moduleB') {
this.dynamicModule.sayGoodbye();
}
}
} catch (error) {
console.error(`Error loading the module: ${error}`);
}
}
}
};
</script>

在上面的例子中,我们有一个Vue组件,其中包含两个按钮,用于动态加载不同的模块。点击按钮时, loadModule 方法会被调用,并传递模块的名称。这个方法使用 import() 函数动态地加载指定的模块文件,并将导入的模块对象存储在 dynamicModule 数据属性中。加载完成后,可以根据需要调用模块中的函数。


03 总结

总之这个 Excel 导入导出功能确实有点复杂,写代码找实践方案的时候着实被恶心到了。本文也只是实现了只有一行表头的表格 情况,并且没有考虑多级嵌套表头的表格复杂样式的表格 需求。

3.1 前端 Excel 导入导出补充方案

在查阅的众多博客文章中,我发现框架 vue-element-admin 代码解决方案所流传使用的范围确实够广,十个都有八个参考的是那个。但是也不乏一些其他的、更简便的、或更复杂的实现方案,但实现效果有待考究:

3.2 Node 插入多行数据到数据库

除了本文 Copilot 辅助生成的代码写法,逛博客的过程中也看到了一些其他我看得懂的、看不懂的写法:


(本文完)

【参考内容】

[1] 官方文档 - SheetJS CE Docs

[2] 开源框架 vue-element-adminExcel导入组件源码Excel导入组件使用示例Excel导入页面演示

[3] CSDN - vue项目中,如何实现excel的导入导出excel导入功能-组件封装

[4] 掘金 - Vue中实现Excel导入与导出功能(核心代码)

[5] CSDN - vue项目的excel的导入与导出

[6] 博客园 - vue+elementui导入Excel文件(基于vue-element-admin中的uploadExcel组件)

[7] CSDN - 使用Navicat给MySQL数据表创建索引、索引的说明

[8] 极客笔记 - MySQL 如何重置 AUTO_INCREMENT 在测试数据库导入表时,可以在清空数据表后,执行 ALTER TABLE college AUTO_INCREMENT = 1; 语句,来重置 AUTO_INCREMENT 的起始值

[9] 开源框架 vue-element-adminExcel导出函数源码Excel导出函数使用示例Excel导出页面演示

[10] 知乎 - vue使用xlxs导入和导出Excel(纯前端导出),xlxl-style修改单元格样式

[11] CSDN - vue简单实现导出和导入复杂表头excel表格功能【纯前端版本和配合后端版本】

[12] CSDN - Vue + Element 实现导入导出Excel

特别感谢 GitHub Copilot 在本文撰写时提供的无偿帮助!