基于 SheetJS (xlsx) + ExcelJS 的前端 Excel 导入导出解决方案,面向大数据量场景进行了深度优化,支持数据校验、类型转换、复杂表头、模板生成、图片嵌入等功能。
| 功能 | 说明 |
|---|---|
| 数据导入 | 支持 .xlsx / .xls / .csv,自动解析表头、字段映射 |
| 带样式导出 | 表头样式、边框、冻结首行、自定义列宽 |
| 纯数据导出 | SheetJS 快速导出,无样式,文件更小 |
| 图片嵌入导出 | 每行数据嵌入图片(如头像、商品图) |
| 复杂表头导出 | 多级表头、合并单元格、动态列 |
| 模板生成 | 带下拉验证、字段说明、示例数据的导入模板 |
| 数据校验 | 10+ 种校验规则:必填、数字、范围、邮箱、手机号、正则、枚举、自定义等 |
| 类型转换 | 日期序列号、千分位数字、百分比、布尔值、枚举值等自动转换 |
| 错误报告 | 标记错误单元格(红色背景 + 批注),生成错误汇总表 |
| 性能优化 | Web Worker 后台构建 + 分批 yieldToMain,大数据量不阻塞 UI |
┌─────────────────────────────────────────────────┐
│ 业务层 (Vue/React/...) │
├─────────────────────────────────────────────────┤
│ ExcelImporter │ ExcelExporter │ Validator │
│ (SheetJS 读取) │ (ExcelJS 写入) │ (校验规则) │
├───────────────────┼──────────────────┼──────────┤
│ TypeConverter │ excelWorker │ │
│ (类型转换) │ (Worker后台构建) │ │
├───────────────────┴──────────────────┴──────────┤
│ SheetJS (xlsx) │ ExcelJS │
│ (快速读取/纯导出) │ (样式导出/复杂功能) │
└─────────────────────────────────────────────────┘
职责分工:
# 克隆项目
git clone https://github.com/Adou377/excel-demo.git
cd excel-demo
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
启动后访问 http://localhost:3000,可体验全部功能。
核心工具文件仅 5 个,零框架依赖,可直接复制到任何前端项目中使用。
npm install exceljs xlsx
将以下文件复制到你的项目中(建议放在 src/utils/excel/ 目录下):
src/utils/excel/
├── ExcelImporter.js # 导入器
├── ExcelExporter.js # 导出器
├── TypeConverter.js # 类型转换器
├── Validator.js # 数据校验器
└── excelWorker.js # Web Worker 导出
工具文件内部的 import 路径需要根据你的目录结构调整:
// ExcelImporter.js 中
import { Validator } from './Validator.js'
import { TypeConverter } from './TypeConverter.js'
// 确保相对路径与你的目录结构一致
import { ExcelImporter } from '@/utils/excel/ExcelImporter'
import { ExcelExporter } from '@/utils/excel/ExcelExporter'
import { Validator } from '@/utils/excel/Validator'
import { TypeConverter } from '@/utils/excel/TypeConverter'
如果你使用 Webpack 或其他构建工具,Web Worker 的引入方式需要调整:
// Vite 写法(本项目)
const worker = new Worker(new URL('./utils/excelWorker.js', import.meta.url), { type: 'module' })
// Webpack 写法
const worker = new Worker(new URL('../../utils/excel/excelWorker.js', import.meta.url))
// 或者使用 worker-loader
import ExcelWorker from './excelWorker.js'
const worker = new ExcelWorker()
工具类与框架无关,可直接在 React、Angular、原生 JS 中使用:
// React 示例
import { ExcelExporter } from './utils/excel/ExcelExporter'
function ExportButton({ data }) {
const handleExport = async () => {
const exporter = new ExcelExporter()
await exporter.export(data, [
{ title: '姓名', key: 'name', width: 12 },
{ title: '年龄', key: 'age', width: 8 }
], '导出数据.xlsx')
}
return <button onClick={handleExport}>导出 Excel</button>
}
基于 SheetJS 的 Excel 文件导入,支持字段映射、类型转换、数据校验。
const importer = new ExcelImporter(config)
config 配置项:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
sheetIndex | number | 0 | 读取的工作表索引 |
headerRow | number | 1 | 表头所在行号(从1开始) |
dataStartRow | number | 2 | 数据起始行号(从1开始) |
fieldTypes | object | {} | 字段类型映射,如 { age: 'integer', date: 'date' } |
fieldMapping | object | {} | Excel 表头到字段的映射,如 { '姓名': 'name' } |
validationRules | object | {} | 校验规则,详见 Validator |
enumOptions | object | {} | 枚举字段的可选值,如 { gender: ['男', '女'] } |
import(file, onProgress?)完整导入流程:读取 → 解析 → 转换 → 校验。
const importer = new ExcelImporter({
fieldTypes: {
name: 'string',
age: 'integer',
email: 'string',
birthday: 'date'
},
fieldMapping: {
'姓名': 'name',
'年龄': 'age',
'邮箱': 'email',
'生日': 'birthday'
},
enumOptions: {
gender: ['男', '女']
},
validationRules: {
name: [{ type: 'required', message: '姓名不能为空' }],
age: [
{ type: 'required', message: '年龄不能为空' },
{ type: 'range', min: 0, max: 150, message: '年龄范围0-150' }
],
email: [{ type: 'email', message: '邮箱格式不正确' }]
}
})
const result = await importer.import(file, (percent, status) => {
console.log(`${percent}% - ${status}`)
})
// result 结构:
// {
// success: boolean, // 是否全部校验通过
// data: object[], // 有效数据数组
// errors: Error[], // 错误列表
// total: number, // 总行数
// validCount: number, // 有效行数
// errorCount: number // 错误行数
// }
quickImport(file) (静态方法)快速导入,仅读取数据,不做类型转换和校验。适合简单的数据预览场景。
const result = await ExcelImporter.quickImport(file)
// result: { headers: string[], data: object[] }
基于 ExcelJS 的 Excel 文件导出,支持样式、复杂表头、模板、错误报告。
const exporter = new ExcelExporter(config)
config 配置项:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
sheetName | string | 'Sheet1' | 工作表名称 |
headerStyle | object | 见下方 | 表头样式 |
dataStyle | object | 见下方 | 数据行样式 |
freezeRow | number | 1 | 冻结行数,0 表示不冻结 |
默认 headerStyle:
{
font: { bold: true, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } },
alignment: { horizontal: 'center', vertical: 'middle' },
border: { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } }
}
export(data, columns, filename?, onProgress?)标准导出,带样式和进度回调。
const exporter = new ExcelExporter()
await exporter.export(
[
{ name: '张三', age: 28, department: '技术部' },
{ name: '李四', age: 32, department: '销售部' }
],
[
{ title: '姓名', key: 'name', width: 12 },
{ title: '年龄', key: 'age', width: 8 },
{
title: '部门', key: 'department', width: 12,
render: (value, row) => `${value}部`, // 自定义渲染
style: { font: { bold: true } } // 自定义单元格样式
}
],
'员工数据.xlsx',
(percent) => console.log(`导出进度: ${percent}%`)
)
columns 配置项:
| 参数 | 类型 | 说明 |
|---|---|---|
title | string | 列标题 |
key | string | 数据字段名 |
width | number | 列宽,默认 15 |
render | function | 自定义渲染函数 (value, row) => newValue |
style | object | 自定义单元格样式(ExcelJS Style) |
exportWithComplexHeader(data, config, filename?)复杂表头导出,支持多级表头和合并单元格。
await exporter.exportWithComplexHeader(data, {
headers: [
['姓名', '成绩', '成绩', '备注'], // 第一行表头
['', '语文', '数学', ''] // 第二行表头
],
merges: [
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }, // "姓名" 跨2行
{ s: { r: 0, c: 1 }, e: { r: 0, c: 2 } }, // "成绩" 跨2列
{ s: { r: 0, c: 3 }, e: { r: 1, c: 3 } } // "备注" 跨2行
],
columns: [
{ key: 'name', width: 12 },
{ key: 'chinese', width: 10 },
{ key: 'math', width: 10 },
{ key: 'remark', width: 20 }
],
dataStartRow: 3 // 数据从第3行开始
}, '成绩表.xlsx')
merges 格式说明: { s: { r, c }, e: { r, c } },r 为行索引(从0开始),c 为列索引(从0开始)。
exportTemplate(config, filename?)生成导入模板,带下拉验证、字段说明、示例数据。
await exporter.exportTemplate({
fields: [
{
key: 'name',
label: '姓名',
description: '必填,不超过20字',
width: 15,
required: true,
validation: { maxLength: 20 }
},
{
key: 'gender',
label: '性别',
description: '必填,男/女',
width: 10,
required: true,
options: ['男', '女'] // 生成下拉列表
},
{
key: 'department',
label: '部门',
description: '必填,从列表选择',
width: 15,
required: true,
options: ['技术部', '销售部', '人事部', '财务部']
}
],
sampleData: [
{ name: '张三', gender: '男', department: '技术部' },
{ name: '李四', gender: '女', department: '销售部' }
]
}, '导入模板.xlsx')
生成的模板包含:
options 的字段自动生成下拉列表(支持1000行)exportErrorReport(data, errors, columns, filename?)生成错误报告,错误单元格标红并添加批注。
await exporter.exportErrorReport(
rawData, // 原始数据数组
[ // 错误列表
{ row: 2, field: 'email', value: 'abc', message: '邮箱格式不正确' },
{ row: 3, field: 'age', value: -1, message: '年龄必须在0-150之间' }
],
[ // 列定义
{ title: '姓名', key: 'name', width: 12 },
{ title: '邮箱', key: 'email', width: 25 },
{ title: '年龄', key: 'age', width: 8 }
],
'错误报告.xlsx'
)
生成的错误报告包含:
处理 Excel 与 JavaScript 之间的数据类型差异。
const converter = new TypeConverter()
setEnumOptions(field, options)设置枚举字段的可选值。
converter.setEnumOptions('department', ['技术部', '销售部', '人事部', '财务部'])
convert(value, type, field?)将值转换为指定类型。
converter.convert('1,234.56', 'number') // 1234.56
converter.convert('3.14', 'integer') // 3
converter.convert('3.14159', 'float') // 3.14 (保留2位小数)
converter.convert(0.85, 'percentage') // 85
converter.convert('是', 'boolean') // true
converter.convert(44927, 'date') // Date 对象
converter.convert('技术部', 'enum', 'department') // '技术部' (校验是否在枚举内)
支持的类型:
| 类型 | 说明 | 示例 |
|---|---|---|
string | 字符串,自动 trim | ' hello ' → 'hello' |
number | 数字,自动去除千分位 | '1,234.56' → 1234.56 |
integer | 整数 | '3.14' → 3 |
float | 浮点数,保留2位小数 | '3.14159' → 3.14 |
percentage | 百分比 | 0.85 → 85,'85%' → 85 |
boolean | 布尔值 | '是'/'true'/'1' → true,'否'/'false'/'0' → false |
date | 日期 | Excel 序列号 44927 → Date,字符串自动解析 |
datetime | 日期时间 | 同 date |
enum | 枚举值 | 需先通过 setEnumOptions 设置可选值 |
布尔值识别规则:
true:'是'、'对'、'真'、'true'、'yes'、'y'、'1'false:'否'、'错'、'假'、'false'、'no'、'n'、'0'Excel 日期序列号说明:
Excel 内部将日期存储为数字(从1900年1月1日起的天数),TypeConverter 会自动将其转换为 JavaScript Date 对象。
const validator = new Validator(rules)
const rules = {
name: [
{ type: 'required', message: '姓名不能为空' },
{ type: 'maxLength', value: 20, message: '姓名不能超过20个字符' }
],
age: [
{ type: 'required', message: '年龄不能为空' },
{ type: 'number', message: '年龄必须是数字' },
{ type: 'range', min: 0, max: 150, message: '年龄必须在0-150之间' }
],
email: [
{ type: 'email', message: '邮箱格式不正确' }
],
phone: [
{ type: 'phone', message: '手机号格式不正确' }
],
department: [
{ type: 'enum', value: ['技术部', '销售部'], message: '部门不在可选范围内' }
],
website: [
{ type: 'url', message: 'URL格式不正确' }
],
code: [
{ type: 'pattern', value: /^[A-Z]\d{6}$/, message: '编码格式不正确' }
],
customField: [
{ type: 'custom', validator: (value) => value !== 'admin', message: '不能使用admin' }
]
}
支持的校验类型:
| 类型 | 参数 | 说明 |
|---|---|---|
required | - | 必填校验 |
number | - | 数字校验 |
integer | - | 整数校验 |
range | min, max | 数值范围校验 |
minLength | value | 最小长度校验 |
maxLength | value | 最大长度校验 |
email | - | 邮箱格式校验 |
phone | - | 手机号校验(中国大陆11位) |
url | - | URL 格式校验 |
pattern | value (RegExp) | 正则校验 |
enum | value (string[]) | 枚举值校验 |
custom | validator (function) | 自定义校验函数,返回 boolean |
// 校验单个字段
const result = validator.validateField('name', '')
// result: { valid: false, field: 'name', message: '姓名不能为空' }
// 校验一整行
const errors = validator.validateRow(row, rowIndex)
// errors: [{ row, field, value, message }, ...]
// 校验全部数据
const allErrors = validator.validateAll(dataArray)
// 唯一性校验
const uniqueErrors = validator.validateUniqueness(dataArray, 'email')
将 ExcelJS 的构建和写入操作放到 Web Worker 中执行,避免阻塞主线程。
function workerExport(data, columns, filename, options = {}) {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('./utils/excelWorker.js', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e) => {
const msg = e.data
if (msg.type === 'progress') {
console.log(`进度: ${msg.percent}%`)
} else if (msg.type === 'complete') {
worker.terminate()
resolve(msg)
} else if (msg.type === 'error') {
worker.terminate()
reject(new Error(msg.error))
}
}
worker.onerror = (e) => {
worker.terminate()
reject(new Error(e.message))
}
const payload = { data, columns, filename, ...options }
if (options.imageBuffer) {
worker.postMessage(payload, [options.imageBuffer])
} else {
worker.postMessage(payload)
}
})
}
| 参数 | 类型 | 说明 |
|---|---|---|
data | object[] | 导出数据 |
columns | object[] | 列定义(同 ExcelExporter 的 columns) |
filename | string | 文件名 |
withImage | boolean | 是否嵌入图片 |
imageBuffer | ArrayBuffer | 图片的 ArrayBuffer 数据 |
{
type: 'complete',
buildTime: number, // 构建耗时 (ms)
writeTime: number, // 写入耗时 (ms)
bufferSize: number, // 文件大小 (bytes)
buffer: ArrayBuffer, // 文件数据(Transferable)
filename: string
}
const result = await workerExport(data, columns, '导出.xlsx')
const blob = new Blob([result.buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = result.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
传统方案在主线程一次性完成所有操作,数据量大时(5万行+)会导致:
async function yieldToMain() {
return new Promise(resolve => setTimeout(resolve, 0))
}
const BATCH_SIZE = 5000
for (let i = 0; i < totalCount; i += BATCH_SIZE) {
const batch = generateData(Math.min(BATCH_SIZE, totalCount - i))
allData.push(...batch)
await yieldToMain() // 让出主线程,UI 可以响应
}
原理: setTimeout(resolve, 0) 将后续代码放入宏任务队列,让浏览器有机会处理渲染和用户交互事件。
// 主线程
const worker = new Worker(new URL('./excelWorker.js', import.meta.url), { type: 'module' })
worker.postMessage({ data, columns })
// Worker 线程 (excelWorker.js)
self.onmessage = async function (e) {
const workbook = new ExcelJS.Workbook()
// ... 构建工作簿(不阻塞主线程)
const buffer = await workbook.xlsx.writeBuffer()
self.postMessage({ type: 'complete', buffer }, [buffer]) // Transferable 传输
}
原理: Web Worker 在独立线程执行,不会阻塞主线程的 UI 渲染和事件处理。
// Worker 中
const transferable = rawBuffer instanceof ArrayBuffer
? rawBuffer
: rawBuffer.buffer.slice(rawBuffer.byteOffset, rawBuffer.byteOffset + rawBuffer.byteLength)
self.postMessage({ buffer: transferable }, [transferable])
原理: 使用 Transferable Objects 传输 ArrayBuffer,避免结构化克隆的内存拷贝开销。
// excelWorker.js 内部
const BATCH_SIZE = 5000
for (let i = 0; i < totalRows; i += BATCH_SIZE) {
const batch = data.slice(i, Math.min(i + BATCH_SIZE, totalRows))
for (const item of batch) {
worksheet.addRow(item)
}
self.postMessage({ type: 'progress', percent: ... })
await new Promise(resolve => setTimeout(resolve, 0))
}
原理: 即使在 Worker 中,分批处理也能避免长时间占用 CPU,让 Worker 的进度消息能及时发送。
| 数据量 | 朴素方案 | 优化方案 | 提升 |
|---|---|---|---|
| 10,000 行 | ~800ms | ~600ms | UI 不卡顿 |
| 50,000 行 | ~4s | ~3s | UI 不卡顿 |
| 100,000 行 | ~10s | ~7s | UI 不卡顿 |
关键差异不在总耗时,而在于优化方案期间 UI 保持响应,用户可以正常操作页面。
import { ExcelImporter } from './utils/excel/ExcelImporter'
const importer = new ExcelImporter({
fieldMapping: { '姓名': 'name', '年龄': 'age' },
fieldTypes: { age: 'integer' }
})
const result = await importer.import(file)
if (result.success) {
console.log('导入成功', result.data)
} else {
console.log('存在错误', result.errors)
}
const importer = new ExcelImporter({
fieldTypes: {
name: 'string',
age: 'integer',
email: 'string',
phone: 'string'
},
enumOptions: {
gender: ['男', '女']
},
validationRules: {
name: [{ type: 'required', message: '姓名必填' }],
age: [
{ type: 'required', message: '年龄必填' },
{ type: 'range', min: 18, max: 65, message: '年龄18-65' }
],
email: [{ type: 'email', message: '邮箱格式错误' }],
phone: [{ type: 'phone', message: '手机号格式错误' }]
}
})
const result = await importer.import(file)
// 导出错误报告
if (!result.success) {
const exporter = new ExcelExporter()
await exporter.exportErrorReport(result.data, result.errors, columns, '错误报告.xlsx')
}
async function exportLargeData(data, columns, filename) {
const worker = new Worker(
new URL('./utils/excel/excelWorker.js', import.meta.url),
{ type: 'module' }
)
return new Promise((resolve, reject) => {
worker.onmessage = (e) => {
if (e.data.type === 'complete') {
worker.terminate()
const blob = new Blob([e.data.buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
resolve()
} else if (e.data.type === 'error') {
worker.terminate()
reject(new Error(e.data.error))
}
}
worker.postMessage({ data, columns, filename })
})
}
async function exportWithImage(data, columns, filename) {
const imageResp = await fetch('/avatar.png')
const imageBuffer = await imageResp.arrayBuffer()
const worker = new Worker(
new URL('./utils/excel/excelWorker.js', import.meta.url),
{ type: 'module' }
)
return new Promise((resolve, reject) => {
worker.onmessage = (e) => {
if (e.data.type === 'complete') {
worker.terminate()
const blob = new Blob([e.data.buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
resolve()
} else if (e.data.type === 'error') {
worker.terminate()
reject(new Error(e.data.error))
}
}
worker.postMessage(
{ data, columns, withImage: true, imageBuffer, filename },
[imageBuffer] // Transferable
)
})
}
const dates = ['2024-01-15', '2024-01-16', '2024-01-17']
const header1 = ['姓名']
const header2 = ['']
dates.forEach(date => {
header1.push(date, date)
header2.push('上班', '下班')
})
const merges = [{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }]
dates.forEach((_, i) => {
merges.push({ s: { r: 0, c: i * 2 + 1 }, e: { r: 0, c: i * 2 + 2 } })
})
const columns = [{ key: 'name', width: 12 }]
dates.forEach((_, i) => {
columns.push({ key: `checkIn_${i}`, width: 10 })
columns.push({ key: `checkOut_${i}`, width: 10 })
})
const exporter = new ExcelExporter()
await exporter.exportWithComplexHeader(attendanceData, {
headers: [header1, header2],
merges,
columns,
dataStartRow: 3
}, '考勤表.xlsx')
const exporter = new ExcelExporter()
await exporter.exportTemplate({
fields: [
{ key: 'name', label: '姓名', description: '必填', width: 15, required: true },
{ key: 'gender', label: '性别', description: '男/女', width: 10, options: ['男', '女'] },
{ key: 'dept', label: '部门', description: '从列表选择', width: 15, options: ['技术部', '销售部'] }
],
sampleData: [
{ name: '张三', gender: '男', dept: '技术部' }
]
}, '导入模板.xlsx')
| 依赖 | 版本 | 用途 |
|---|---|---|
exceljs | ^4.4.0 | 带样式导出、复杂表头、图片嵌入、数据验证 |
xlsx (SheetJS) | ^0.18.5 | 快速读取 Excel、纯数据导出 |
vue | ^3.5.32 | 演示项目 UI 框架(工具类本身不依赖 Vue) |
vite | ^8.0.9 | 构建工具 |
核心工具类(ExcelImporter / ExcelExporter / TypeConverter / Validator / excelWorker)不依赖任何前端框架,可在 Vue、React、Angular、原生 JS 等任何环境中使用。
A: 两者各有优势:
本方案根据场景选择合适的库:导入用 SheetJS(快),带样式导出用 ExcelJS(功能全),纯数据导出用 SheetJS(快)。
A: 可以。ExcelJS 是纯 JavaScript 实现,不依赖 DOM,可以在 Web Worker 中正常运行。但需要注意:
document、window 等浏览器 APIA: Webpack 需要调整 Worker 的引入方式:
// 方式1:使用 worker-loader
import ExcelWorker from 'worker-loader!./utils/excel/excelWorker.js'
const worker = new ExcelWorker()
// 方式2:使用 URL 语法(Webpack 5+)
const worker = new Worker(new URL('./utils/excel/excelWorker.js', import.meta.url))
A: 以下策略可以缓解内存问题:
await yieldToMain() 让 GC 有机会回收data.length = 0)A: 通过构造函数的 config 参数或列定义的 style 属性:
const exporter = new ExcelExporter({
headerStyle: {
font: { bold: true, color: { argb: 'FF000000' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF0000' } }
}
})
// 或者在列定义中自定义某列样式
const columns = [
{
title: '状态', key: 'status', width: 10,
render: (value) => value === 1 ? '启用' : '禁用',
style: {
font: { bold: true },
alignment: { horizontal: 'center' }
}
}
]
样式格式遵循 ExcelJS 的 Style 文档。
A: Excel 内部将日期存储为数字序列号。确保:
fieldTypes 中将日期字段声明为 'date' 类型new Date() 解析A: 使用 fieldMapping 配置:
const importer = new ExcelImporter({
fieldMapping: {
'姓名': 'name',
'年龄': 'age',
'部门': 'department',
'入职日期': 'hireDate'
}
})
未在 fieldMapping 中配置的表头将直接作为字段名使用。