一、问题背景
在实际业务中,经常需要导出大量数据到 Excel 文件,这可能会遇到以下问题:
- 浏览器内存占用过大
- 导出过程页面卡顿
- 请求超时
- 文件过大下载慢
二、前端导出方案
1. 使用 XLSX.js
1.1 基础导出
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
| import * as XLSX from 'xlsx';
class ExcelExport { constructor() { this.workbook = XLSX.utils.book_new(); } createSheet(data, sheetName = 'Sheet1') { const worksheet = XLSX.utils.json_to_sheet(data); XLSX.utils.book_append_sheet(this.workbook, worksheet, sheetName); } exportFile(fileName = 'export.xlsx') { XLSX.writeFile(this.workbook, fileName); } }
const exporter = new ExcelExport(); exporter.createSheet([ { name: 'John', age: 30 }, { name: 'Mary', age: 25 } ]); exporter.exportFile();
|
1.2 分片处理
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
| class ChunkExport { constructor(chunkSize = 1000) { this.chunkSize = chunkSize; this.workbook = XLSX.utils.book_new(); } async processDataInChunks(data) { const chunks = this.splitIntoChunks(data); const worksheet = XLSX.utils.json_to_sheet([]); for (let i = 0; i < chunks.length; i++) { await this.processChunk(worksheet, chunks[i], i); } XLSX.utils.book_append_sheet(this.workbook, worksheet, 'Sheet1'); } splitIntoChunks(data) { const chunks = []; for (let i = 0; i < data.length; i += this.chunkSize) { chunks.push(data.slice(i, i + this.chunkSize)); } return chunks; } async processChunk(worksheet, chunk, index) { return new Promise(resolve => { setTimeout(() => { const rows = XLSX.utils.json_to_sheet(chunk); if (index === 0) { worksheet['!ref'] = rows['!ref']; worksheet['!cols'] = rows['!cols']; Object.assign(worksheet, rows); } else { this.appendRows(worksheet, rows, index * this.chunkSize); } resolve(); }, 0); }); } appendRows(worksheet, rows, startRow) { Object.keys(rows).forEach(cell => { if (cell[0] === '!') return; const newCell = cell.replace(/\d+/, match => +match + startRow); worksheet[newCell] = rows[cell]; }); } async export(data, fileName = 'export.xlsx') { await this.processDataInChunks(data); XLSX.writeFile(this.workbook, fileName); } }
const exporter = new ChunkExport(1000); const largeData = Array.from({ length: 100000 }, (_, i) => ({ id: i, name: `User ${i}`, date: new Date().toISOString() }));
exporter.export(largeData, 'large-export.xlsx');
|
2. Web Worker 处理
2.1 主线程代码
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
| class WorkerExport { constructor() { this.worker = new Worker('excel-worker.js'); this.setupWorker(); } setupWorker() { this.worker.onmessage = (event) => { const { type, data } = event.data; switch (type) { case 'progress': this.updateProgress(data); break; case 'complete': this.downloadFile(data); break; case 'error': console.error('Export failed:', data); break; } }; } export(data, options = {}) { this.worker.postMessage({ type: 'start', data, options }); } updateProgress(percent) { console.log(`Export progress: ${percent}%`); } downloadFile(blob) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'export.xlsx'; link.click(); URL.revokeObjectURL(url); } terminate() { this.worker.terminate(); } }
|
2.2 Worker 线程代码
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
| importScripts('https://cdn.jsdelivr.net/npm/xlsx/dist/xlsx.full.min.js');
class ExcelWorker { constructor() { this.setupEventListeners(); } setupEventListeners() { self.onmessage = (event) => { const { type, data, options } = event.data; if (type === 'start') { this.processExport(data, options); } }; } async processExport(data, options) { try { const workbook = XLSX.utils.book_new(); const totalChunks = Math.ceil(data.length / 1000); for (let i = 0; i < totalChunks; i++) { const chunk = data.slice(i * 1000, (i + 1) * 1000); await this.processChunk(workbook, chunk, i === 0); self.postMessage({ type: 'progress', data: Math.round((i + 1) / totalChunks * 100) }); } const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); self.postMessage({ type: 'complete', data: new Blob([wbout], { type: 'application/octet-stream' }) }); } catch (error) { self.postMessage({ type: 'error', data: error.message }); } } async processChunk(workbook, chunk, isFirst) { return new Promise(resolve => { setTimeout(() => { if (isFirst) { const worksheet = XLSX.utils.json_to_sheet(chunk); XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1'); } else { XLSX.utils.sheet_add_json( workbook.Sheets['Sheet1'], chunk, { origin: -1 } ); } resolve(); }, 0); }); } }
new ExcelWorker();
|
三、后端导出方案
1. 流式处理
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
| async function streamDownload() { const response = await fetch('/api/export/stream', { headers: { 'Accept': 'application/octet-stream' } }); const reader = response.body.getReader(); const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } const blob = new Blob(chunks, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'export.xlsx'; link.click(); URL.revokeObjectURL(url); }
|
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
| const Excel = require('exceljs'); const stream = require('stream');
async function streamExport(req, res) { const workbook = new Excel.stream.xlsx.WorkbookWriter({ stream: res, useStyles: true }); const worksheet = workbook.addWorksheet('Sheet1'); worksheet.columns = [ { header: 'ID', key: 'id' }, { header: 'Name', key: 'name' }, { header: 'Date', key: 'date' } ]; const cursor = db.collection('data').find().cursor(); for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) { worksheet.addRow(doc).commit(); } await worksheet.commit(); await workbook.commit(); }
|
2. 任务队列处理
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
| class ExportTask { async createTask() { const response = await fetch('/api/export/task', { method: 'POST', body: JSON.stringify({ }) }); const { taskId } = await response.json(); return this.pollTaskStatus(taskId); } async pollTaskStatus(taskId) { while (true) { const response = await fetch(`/api/export/status/${taskId}`); const { status, url } = await response.json(); if (status === 'completed') { this.downloadFile(url); break; } else if (status === 'failed') { throw new Error('Export failed'); } await new Promise(resolve => setTimeout(resolve, 2000)); } } downloadFile(url) { const link = document.createElement('a'); link.href = url; link.download = 'export.xlsx'; link.click(); } }
|
四、最佳实践
1. 数据量判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class ExportStrategy { constructor(threshold = 10000) { this.threshold = threshold; } async export(data) { if (data.length <= this.threshold) { return this.clientExport(data); } else if (data.length <= this.threshold * 10) { return this.workerExport(data); } else { return this.serverExport(data); } } }
|
2. 性能优化
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
| class OptimizedExport { preprocessData(data) { return data.map(item => { const { id, name, date } = item; return { id, name, date }; }); } async batchProcess(data, batchSize = 1000) { const results = []; for (let i = 0; i < data.length; i += batchSize) { const batch = data.slice(i, i + batchSize); results.push(await this.processBatch(batch)); await new Promise(resolve => setTimeout(resolve, 0)); } return results; } cleanupMemory() { if (global.gc) { global.gc(); } } }
|
3. 用户体验
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
| class ExportUI { constructor() { this.progress = 0; this.status = 'idle'; } updateProgress(percent) { this.progress = percent; this.updateUI(); } updateStatus(status) { this.status = status; this.updateUI(); } updateUI() { const progressBar = document.querySelector('.progress-bar'); const statusText = document.querySelector('.status-text'); if (progressBar) { progressBar.style.width = `${this.progress}%`; } if (statusText) { statusText.textContent = this.getStatusText(); } } getStatusText() { const statusMap = { idle: '准备导出', processing: '正在导出...', completed: '导出完成', failed: '导出失败' }; return statusMap[this.status] || ''; } }
|
参考文献