A front-end Excel import and export solution based on SheetJS (xlsx) + ExcelJS, which has been deeply optimized for scenarios involving large data volumes, supports functions such as data validation, type conversion, complex table headers, template generation, and image embedding.
| Feature | Description |
|---|---|
| Data Import | Supports .xlsx / .xls / .csv, automatic header parsing, field mapping |
| Styled Export | Header styles, borders, freeze first row, custom column widths |
| Plain Data Export | SheetJS fast export, no styling, smaller file size |
| Image Embedding Export | Embed images (like avatars, product images) in each row |
| Complex Header Export | Multi-level headers, merged cells, dynamic columns |
| Template Generation | Import templates with dropdown validation, field descriptions, sample data |
| Data Validation | 10+ validation rules: required, number, range, email, phone, regex, enum, custom, etc. |
| Type Conversion | Automatic conversion of date serial numbers, comma-separated numbers, percentages, booleans, enum values, etc. |
| Error Reporting | Mark error cells (red background + comments), generate error summary table |
| Performance Optimization | Web Worker background building + batch yieldToMain, no UI blocking with large data |
┌─────────────────────────────────────────────────┐
│ Business Layer (Vue/React/...) │
├─────────────────────────────────────────────────┤
│ ExcelImporter │ ExcelExporter │ Validator │
│ (SheetJS Read) │ (ExcelJS Write) │ (Rules) │
├───────────────────┼──────────────────┼──────────┤
│ TypeConverter │ excelWorker │ │
│ (Type Convert) │ (Worker Build) │ │
├───────────────────┴──────────────────┴──────────┤
│ SheetJS (xlsx) │ ExcelJS │
│ (Fast Read/Export) │ (Style/Complex) │
└─────────────────────────────────────────────────┘
Responsibilities:
# Clone the project
git clone https://github.com/Adou377/excel-demo.git
cd excel-demo
# Install dependencies
npm install
# Start development server
npm run dev
# Build production version
npm run build
After starting, visit http://localhost:3000 to experience all features.
There are only 5 core utility files, zero framework dependencies, can be directly copied into any frontend project.
npm install exceljs xlsx
Copy the following files into your project (recommended in src/utils/excel/ directory):
src/utils/excel/
├── ExcelImporter.js # Importer
├── ExcelExporter.js # Exporter
├── TypeConverter.js # Type Converter
├── Validator.js # Data Validator
└── excelWorker.js # Web Worker Export
The import paths inside utility files need to be adjusted according to your directory structure:
// In ExcelImporter.js
import { Validator } from './Validator.js'
import { TypeConverter } from './TypeConverter.js'
// Ensure relative paths match your directory structure
import { ExcelImporter } from '@/utils/excel/ExcelImporter'
import { ExcelExporter } from '@/utils/excel/ExcelExporter'
import { Validator } from '@/utils/excel/Validator'
import { TypeConverter } from '@/utils/excel/TypeConverter'
If you use Webpack or other build tools, Web Worker import needs adjustment:
// Vite approach (this project)
const worker = new Worker(new URL('./utils/excelWorker.js', import.meta.url), { type: 'module' })
// Webpack approach
const worker = new Worker(new URL('../../utils/excel/excelWorker.js', import.meta.url))
// Or use worker-loader
import ExcelWorker from './excelWorker.js'
const worker = new ExcelWorker()
Utility classes are framework-agnostic, can be directly used in React, Angular, vanilla JS:
// React example
import { ExcelExporter } from './utils/excel/ExcelExporter'
function ExportButton({ data }) {
const handleExport = async () => {
const exporter = new ExcelExporter()
await exporter.export(data, [
{ title: 'Name', key: 'name', width: 12 },
{ title: 'Age', key: 'age', width: 8 }
], 'exported_data.xlsx')
}
return <button onClick={handleExport}>Export Excel</button>
}
Excel file import based on SheetJS, supporting field mapping, type conversion, data validation.
const importer = new ExcelImporter(config)
config options:
| Parameter | Type | Default | Description |
|---|---|---|---|
sheetIndex | number | 0 | Worksheet index to read |
headerRow | number | 1 | Header row number (1-based) |
dataStartRow | number | 2 | Data starting row number (1-based) |
fieldTypes | object | {} | Field type mapping, e.g. { age: 'integer', date: 'date' } |
fieldMapping | object | {} | Excel header to field mapping, e.g. { 'Name': 'name' } |
validationRules | object | {} | Validation rules, see Validator |
enumOptions | object | {} | Enum field options, e.g. { gender: ['Male', 'Female'] } |
import(file, onProgress?)Complete import flow: read → parse → convert → validate.
const importer = new ExcelImporter({
fieldTypes: {
name: 'string',
age: 'integer',
email: 'string',
birthday: 'date'
},
fieldMapping: {
'Name': 'name',
'Age': 'age',
'Email': 'email',
'Birthday': 'birthday'
},
enumOptions: {
gender: ['Male', 'Female']
},
validationRules: {
name: [{ type: 'required', message: 'Name is required' }],
age: [
{ type: 'required', message: 'Age is required' },
{ type: 'range', min: 0, max: 150, message: 'Age must be between 0-150' }
],
email: [{ type: 'email', message: 'Invalid email format' }]
}
})
const result = await importer.import(file, (percent, status) => {
console.log(`${percent}% - ${status}`)
})
// result structure:
// {
// success: boolean, // Whether all validations passed
// data: object[], // Valid data array
// errors: Error[], // Error list
// total: number, // Total rows
// validCount: number, // Valid rows count
// errorCount: number // Error rows count
// }
quickImport(file) (Static Method)Quick import, only reads data, no type conversion or validation. Suitable for simple data preview scenarios.
const result = await ExcelImporter.quickImport(file)
// result: { headers: string[], data: object[] }
Excel file export based on ExcelJS, supporting styles, complex headers, templates, error reports.
const exporter = new ExcelExporter(config)
config options:
| Parameter | Type | Default | Description |
|---|---|---|---|
sheetName | string | 'Sheet1' | Worksheet name |
headerStyle | object | see below | Header style |
dataStyle | object | see below | Data row style |
freezeRow | number | 1 | Number of rows to freeze, 0 means no freeze |
Default 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?)Standard export with styles and progress callback.
const exporter = new ExcelExporter()
await exporter.export(
[
{ name: 'Zhang San', age: 28, department: 'Engineering' },
{ name: 'Li Si', age: 32, department: 'Sales' }
],
[
{ title: 'Name', key: 'name', width: 12 },
{ title: 'Age', key: 'age', width: 8 },
{
title: 'Department', key: 'department', width: 12,
render: (value, row) => `${value} Dept`, // Custom render
style: { font: { bold: true } } // Custom cell style
}
],
'employee_data.xlsx',
(percent) => console.log(`Export progress: ${percent}%`)
)
columns options:
| Parameter | Type | Description |
|---|---|---|
title | string | Column title |
key | string | Data field name |
width | number | Column width, default 15 |
render | function | Custom render function (value, row) => newValue |
style | object | Custom cell style (ExcelJS Style) |
exportWithComplexHeader(data, config, filename?)Complex header export, supports multi-level headers and merged cells.
await exporter.exportWithComplexHeader(data, {
headers: [
['Name', 'Scores', 'Scores', 'Remarks'], // First header row
['', 'Chinese', 'Math', ''] // Second header row
],
merges: [
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }, // "Name" spans 2 rows
{ s: { r: 0, c: 1 }, e: { r: 0, c: 2 } }, // "Scores" spans 2 columns
{ s: { r: 0, c: 3 }, e: { r: 1, c: 3 } } // "Remarks" spans 2 rows
],
columns: [
{ key: 'name', width: 12 },
{ key: 'chinese', width: 10 },
{ key: 'math', width: 10 },
{ key: 'remark', width: 20 }
],
dataStartRow: 3 // Data starts at row 3
}, 'score_sheet.xlsx')
merges format: { s: { r, c }, e: { r, c } }, r is row index (0-based), c is column index (0-based).
exportTemplate(config, filename?)Generate import templates with dropdown validation, field descriptions, sample data.
await exporter.exportTemplate({
fields: [
{
key: 'name',
label: 'Name',
description: 'Required, max 20 characters',
width: 15,
required: true,
validation: { maxLength: 20 }
},
{
key: 'gender',
label: 'Gender',
description: 'Required, Male/Female',
width: 10,
required: true,
options: ['Male', 'Female'] // Generate dropdown
},
{
key: 'department',
label: 'Department',
description: 'Required, select from list',
width: 15,
required: true,
options: ['Engineering', 'Sales', 'HR', 'Finance']
}
],
sampleData: [
{ name: 'Zhang San', gender: 'Male', department: 'Engineering' },
{ name: 'Li Si', gender: 'Female', department: 'Sales' }
]
}, 'import_template.xlsx')
Generated template includes:
options automatically generate dropdown lists (supports 1000 rows)exportErrorReport(data, errors, columns, filename?)Generate error report, error cells are marked red with comments.
await exporter.exportErrorReport(
rawData, // Raw data array
[ // Error list
{ row: 2, field: 'email', value: 'abc', message: 'Invalid email format' },
{ row: 3, field: 'age', value: -1, message: 'Age must be between 0-150' }
],
[ // Column definitions
{ title: 'Name', key: 'name', width: 12 },
{ title: 'Email', key: 'email', width: 25 },
{ title: 'Age', key: 'age', width: 8 }
],
'error_report.xlsx'
)
Generated error report includes:
Handles data type differences between Excel and JavaScript.
const converter = new TypeConverter()
setEnumOptions(field, options)Set enum field options.
converter.setEnumOptions('department', ['Engineering', 'Sales', 'HR', 'Finance'])
convert(value, type, field?)Convert value to specified type.
converter.convert('1,234.56', 'number') // 1234.56
converter.convert('3.14', 'integer') // 3
converter.convert('3.14159', 'float') // 3.14 (2 decimal places)
converter.convert(0.85, 'percentage') // 85
converter.convert('yes', 'boolean') // true
converter.convert(44927, 'date') // Date object
converter.convert('Engineering', 'enum', 'department') // 'Engineering' (validate within enum)
Supported types:
| Type | Description | Example |
|---|---|---|
string | String, auto trim | ' hello ' → 'hello' |
number | Number, auto remove comma separators | '1,234.56' → 1234.56 |
integer | Integer | '3.14' → 3 |
float | Float, 2 decimal places | '3.14159' → 3.14 |
percentage | Percentage | 0.85 → 85, '85%' → 85 |
boolean | Boolean | 'yes'/'true'/'1' → true, 'no'/'false'/'0' → false |
date | Date | Excel serial number 44927 → Date, string auto parsed |
datetime | DateTime | Same as date |
enum | Enum value | Need to set options via setEnumOptions first |
Boolean recognition rules:
true: 'yes', 'true', '1', 'Y', 'y'false: 'no', 'false', '0', 'N', 'n'Excel date serial number explanation:
Excel internally stores dates as numbers (days since January 1, 1900), TypeConverter automatically converts them to JavaScript Date objects.
const validator = new Validator(rules)
const rules = {
name: [
{ type: 'required', message: 'Name is required' },
{ type: 'maxLength', value: 20, message: 'Name cannot exceed 20 characters' }
],
age: [
{ type: 'required', message: 'Age is required' },
{ type: 'number', message: 'Age must be a number' },
{ type: 'range', min: 0, max: 150, message: 'Age must be between 0-150' }
],
email: [
{ type: 'email', message: 'Invalid email format' }
],
phone: [
{ type: 'phone', message: 'Invalid phone number format' }
],
department: [
{ type: 'enum', value: ['Engineering', 'Sales'], message: 'Department not in allowed list' }
],
website: [
{ type: 'url', message: 'Invalid URL format' }
],
code: [
{ type: 'pattern', value: /^[A-Z]\d{6}$/, message: 'Invalid code format' }
],
customField: [
{ type: 'custom', validator: (value) => value !== 'admin', message: 'Cannot use admin' }
]
}
Supported validation types:
| Type | Parameters | Description |
|---|---|---|
required | - | Required validation |
number | - | Number validation |
integer | - | Integer validation |
range | min, max | Numeric range validation |
minLength | value | Minimum length validation |
maxLength | value | Maximum length validation |
email | - | Email format validation |
phone | - | Phone number validation (11-digit Chinese mainland) |
url | - | URL format validation |
pattern | value (RegExp) | Regex validation |
enum | value (string[]) | Enum value validation |
custom | validator (function) | Custom validation function, returns boolean |
// Validate single field
const result = validator.validateField('name', '')
// result: { valid: false, field: 'name', message: 'Name is required' }
// Validate entire row
const errors = validator.validateRow(row, rowIndex)
// errors: [{ row, field, value, message }, ...]
// Validate all data
const allErrors = validator.validateAll(dataArray)
// Uniqueness validation
const uniqueErrors = validator.validateUniqueness(dataArray, 'email')
Executes ExcelJS building and writing operations in Web Worker to avoid blocking main thread.
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(`Progress: ${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)
}
})
}
| Parameter | Type | Description |
|---|---|---|
data | object[] | Export data |
columns | object[] | Column definitions (same as ExcelExporter columns) |
filename | string | File name |
withImage | boolean | Whether to embed image |
imageBuffer | ArrayBuffer | Image ArrayBuffer data |
{
type: 'complete',
buildTime: number, // Build time (ms)
writeTime: number, // Write time (ms)
bufferSize: number, // File size (bytes)
buffer: ArrayBuffer, // File data (Transferable)
filename: string
}
const result = await workerExport(data, columns, 'export.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)
Traditional approach completes all operations in main thread at once, with large data (50k+ rows) this causes:
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() // Yield main thread, UI can respond
}
Principle: setTimeout(resolve, 0) puts subsequent code into macrotask queue, giving browser chance to handle rendering and user interaction events.
// Main thread
const worker = new Worker(new URL('./excelWorker.js', import.meta.url), { type: 'module' })
worker.postMessage({ data, columns })
// Worker thread (excelWorker.js)
self.onmessage = async function (e) {
const workbook = new ExcelJS.Workbook()
// ... build workbook (doesn't block main thread)
const buffer = await workbook.xlsx.writeBuffer()
self.postMessage({ type: 'complete', buffer }, [buffer]) // Transferable transmission
}
Principle: Web Worker executes in independent thread, won't block main thread UI rendering and event handling.
// In Worker
const transferable = rawBuffer instanceof ArrayBuffer
? rawBuffer
: rawBuffer.buffer.slice(rawBuffer.byteOffset, rawBuffer.byteOffset + rawBuffer.byteLength)
self.postMessage({ buffer: transferable }, [transferable])
Principle: Uses Transferable Objects to transmit ArrayBuffer, avoiding memory copy overhead of structured cloning.
// Inside 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))
}
Principle: Even in Worker, batch processing avoids long CPU occupation, allows Worker progress messages to be sent timely.
| Data Size | Naive Approach | Optimized Approach | Improvement |
|---|---|---|---|
| 10,000 rows | ~800ms | ~600ms | UI not frozen |
| 50,000 rows | ~4s | ~3s | UI not frozen |
| 100,000 rows | ~10s | ~7s | UI not frozen |
Key difference isn't total time, but that UI remains responsive during optimized approach, users can normally operate page.
import { ExcelImporter } from './utils/excel/ExcelImporter'
const importer = new ExcelImporter({
fieldMapping: { 'Name': 'name', 'Age': 'age' },
fieldTypes: { age: 'integer' }
})
const result = await importer.import(file)
if (result.success) {
console.log('Import successful', result.data)
} else {
console.log('Errors present', result.errors)
}
const importer = new ExcelImporter({
fieldTypes: {
name: 'string',
age: 'integer',
email: 'string',
phone: 'string'
},
enumOptions: {
gender: ['Male', 'Female']
},
validationRules: {
name: [{ type: 'required', message: 'Name is required' }],
age: [
{ type: 'required', message: 'Age is required' },
{ type: 'range', min: 18, max: 65, message: 'Age must be 18-65' }
],
email: [{ type: 'email', message: 'Invalid email format' }],
phone: [{ type: 'phone', message: 'Invalid phone format' }]
}
})
const result = await importer.import(file)
// Export error report
if (!result.success) {
const exporter = new ExcelExporter()
await exporter.exportErrorReport(result.data, result.errors, columns, 'error_report.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 = ['Name']
const header2 = ['']
dates.forEach(date => {
header1.push(date, date)
header2.push('Check In', 'Check Out')
})
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
}, 'attendance.xlsx')
const exporter = new ExcelExporter()
await exporter.exportTemplate({
fields: [
{ key: 'name', label: 'Name', description: 'Required', width: 15, required: true },
{ key: 'gender', label: 'Gender', description: 'Male/Female', width: 10, options: ['Male', 'Female'] },
{ key: 'dept', label: 'Department', description: 'Select from list', width: 15, options: ['Engineering', 'Sales'] }
],
sampleData: [
{ name: 'Zhang San', gender: 'Male', dept: 'Engineering' }
]
}, 'import_template.xlsx')
| Dependency | Version | Purpose |
|---|---|---|
exceljs | ^4.4.0 | Styled export, complex headers, image embedding, data validation |
xlsx (SheetJS) | ^0.18.5 | Fast Excel reading, plain data export |
vue | ^3.5.32 | Demo project UI framework (utility classes themselves don't depend on Vue) |
vite | ^8.0.9 | Build tool |
Core utility classes (ExcelImporter / ExcelExporter / TypeConverter / Validator / excelWorker) don't depend on any frontend framework, can be used in any environment: Vue, React, Angular, vanilla JS, etc.
A: Each has its strengths:
This solution chooses appropriate library based on scenario: import uses SheetJS (fast), styled export uses ExcelJS (full-featured), plain data export uses SheetJS (fast).
A: Yes. ExcelJS is pure JavaScript implementation, doesn't depend on DOM, can run normally in Web Worker. But note:
document, window in WorkerA: Webpack needs to adjust Worker import approach:
// Method 1: Use worker-loader
import ExcelWorker from 'worker-loader!./utils/excel/excelWorker.js'
const worker = new ExcelWorker()
// Method 2: Use URL syntax (Webpack 5+)
const worker = new Worker(new URL('./utils/excel/excelWorker.js', import.meta.url))
A: Following strategies can alleviate memory issues:
await yieldToMain() after each batch gives GC chance to collectdata.length = 0)A: Through config parameter in constructor or style property in column definition:
const exporter = new ExcelExporter({
headerStyle: {
font: { bold: true, color: { argb: 'FF000000' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF0000' } }
}
})
// Or customize a column's style in column definition
const columns = [
{
title: 'Status', key: 'status', width: 10,
render: (value) => value === 1 ? 'Enabled' : 'Disabled',
style: {
font: { bold: true },
alignment: { horizontal: 'center' }
}
}
]
Style format follows ExcelJS Style documentation.
A: Excel internally stores dates as numeric serial numbers. Ensure:
'date' type in fieldTypesnew Date()A: Use fieldMapping configuration:
const importer = new ExcelImporter({
fieldMapping: {
'Name': 'name',
'Age': 'age',
'Department': 'department',
'Hire Date': 'hireDate'
}
})
Headers not configured in fieldMapping will be used directly as field names.