返回笔记首页

Excel 导入导出

主题配置

具体的 代码地址:这里

注意分支哈 ,download下拉跑一下

4.2 Excel 导入导出 - 深度剖析

简历项目经验描述

版本1 - 适合初中级

plain
负责 Excel 导入导出功能开发,支持数据批量处理
- 基于 SheetJS 实现 Excel 文件导入导出,支持 .xlsx/.xls 格式
- 开发数据校验功能,支持必填项、格式、唯一性等 10+ 种校验规则
- 实现模板导出,用户按模板填写数据,导入成功率提升 80%

版本2 - 适合高级

plain
设计并实现企业级 Excel 处理方案,支撑核心业务数据流转
- 采用流式处理技术,10 万行 Excel 导出时间从 30s 降至 3s,内存占用减少 90%
- 实现复杂表头处理,支持多级表头、合并单元格、动态列,满足各类报表需求
- 开发智能错误提示系统,精确定位错误行列,修复时间缩短 70%
- 优化大文件导入,通过分片解析、Web Worker,100MB 文件处理时间 < 10s

版本3 - 适合架构方向

plain
主导 Excel 数据交换架构设计,建立统一的数据处理规范
- 抽象 Excel 处理引擎,支持模板化配置,新增导入导出场景开发时间从 2 天降至 2 小时
- 设计数据转换管道,支持字段映射、类型转换、数据清洗,准确率 99%+
- 建立 Excel 文件格式规范和校验体系,数据质量问题减少 85%
- 实现 Excel 与数据库的双向同步,支撑 20+ 业务系统的数据交换

面试标准回答话术

Q1: Excel 导入导出的技术选型和基本实现?

标准回答

"我们项目用的是 SheetJS (xlsx 库),这是目前最成熟的纯 JS Excel 处理库。

为什么选 SheetJS
  1. 纯 JavaScript 实现,不依赖后端,前端就能完成处理
  2. 支持 .xlsx、.xls、.csv 等多种格式
  3. 功能全面,支持样式、公式、图片等
  4. 社区活跃,文档完善
基本的导入流程
javascript
import * as XLSX from 'xlsx'

async function importExcel(file) {
  // 1. 读取文件
  const arrayBuffer = await file.arrayBuffer()

  // 2. 解析工作簿
  const workbook = XLSX.read(arrayBuffer, {
    type: 'array',
    cellDates: true,  // 自动解析日期
    cellFormula: false, // 不需要公式
  })

  // 3. 获取第一个工作表
  const sheetName = workbook.SheetNames[0]
  const worksheet = workbook.Sheets[sheetName]

  // 4. 转换为 JSON
  const jsonData = XLSX.utils.sheet_to_json(worksheet, {
    header: 1, // 返回数组,第一行是表头
    defval: null, // 空单元格返回 null
    blankrows: false, // 跳过空行
  })

  // 5. 提取表头和数据
  const headers = jsonData[0]
  const rows = jsonData.slice(1)

  // 6. 转换为对象数组
  const data = rows.map(row => {
    const obj = {}
    headers.forEach((header, index) => {
      obj[header] = row[index]
    })
    return obj
  })

  return data
}
基本的导出流程
javascript
function exportExcel(data, filename = 'export.xlsx') {
  // 1. 创建工作表
  const worksheet = XLSX.utils.json_to_sheet(data)

  // 2. 创建工作簿
  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')

  // 3. 生成文件
  XLSX.writeFile(workbook, filename)
}
文件读取的几种方式
  1. FileReader (兼容性好):
javascript
const reader = new FileReader()
reader.onload = (e) => {
  const data = new Uint8Array(e.target.result)
  const workbook = XLSX.read(data, { type: 'array' })
}
reader.readAsArrayBuffer(file)
  1. 直接用 File (现代浏览器):
javascript
const arrayBuffer = await file.arrayBuffer()
const workbook = XLSX.read(arrayBuffer)

这套方案在我们项目里很稳定,处理过最大 50MB 的 Excel 文件,10 万行数据,没有出过问题。"

Q2: 大文件导出如何优化性能?

标准回答

"大文件导出的性能瓶颈主要是两个:内存占用和生成时间。我做了三层优化:

优化1: 流式写入

传统方法是一次性把所有数据转成工作表,再生成文件,内存占用很大。我改用流式写入:

javascript
async function streamExport(dataSource, filename) {
  // 创建可写流
  const stream = XLSX.stream.to_csv(worksheet)

  // 分批写入数据
  const BATCH_SIZE = 1000
  let offset = 0

  while (true) {
    const batch = await dataSource.fetch(offset, BATCH_SIZE)
    if (batch.length === 0) break

    // 写入这批数据
    batch.forEach(row => {
      XLSX.stream.to_csv.write(stream, row)
    })

    offset += BATCH_SIZE

    // 释放内存
    if (global.gc) global.gc()
  }

  stream.end()
}
优化2: Web Worker 后台处理

把 Excel 生成放到 Worker 里,不阻塞主线程:

javascript
// main.js
const worker = new Worker('excel-worker.js')

worker.postMessage({
  type: 'export',
  data: data
})

worker.onmessage = (e) => {
  if (e.data.type === 'progress') {
    updateProgress(e.data.progress)
  } else if (e.data.type === 'complete') {
    const blob = e.data.blob
    downloadBlob(blob, 'export.xlsx')
  }
}
javascript
// excel-worker.js
import * as XLSX from 'xlsx'

self.onmessage = (e) => {
  const { type, data } = e.data

  if (type === 'export') {
    const workbook = XLSX.utils.book_new()

    // 分批处理
    const BATCH_SIZE = 1000
    for (let i = 0; i < data.length; i += BATCH_SIZE) {
      const batch = data.slice(i, i + BATCH_SIZE)

      if (i === 0) {
        // 第一批,创建工作表
        const worksheet = XLSX.utils.json_to_sheet(batch)
        XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
      } else {
        // 后续批次,追加数据
        const worksheet = workbook.Sheets['Sheet1']
        XLSX.utils.sheet_add_json(worksheet, batch, {
          skipHeader: true,
          origin: -1 // 追加到末尾
        })
      }

      // 报告进度
      self.postMessage({
        type: 'progress',
        progress: Math.round((i + batch.length) / data.length * 100)
      })
    }

    // 生成文件
    const wbout = XLSX.write(workbook, {
      bookType: 'xlsx',
      type: 'array'
    })

    self.postMessage({
      type: 'complete',
      blob: new Blob([wbout], { type: 'application/octet-stream' })
    })
  }
}
优化3: 压缩和格式优化
javascript
const workbook = XLSX.write(workbook, {
  bookType: 'xlsx',
  type: 'array',
  compression: true, // 启用压缩
  cellStyles: false,  // 不导出样式(如果不需要)
})
效果对比
  • 10 万行数据导出:
    • 优化前:30s,内存 500MB
    • 优化后:3s,内存 50MB
  • 用户体验:
    • 有进度条,不会感觉卡死
    • 不阻塞界面,可以继续操作

这套方案在我们的数据导出功能上线后,用户投诉减少了 90%。"

Q3: 复杂表头如何处理?

标准回答

"复杂表头主要是多级表头和合并单元格,SheetJS 提供了合并单元格的 API。

多级表头示例

假设要导出这样的表格:

plain
姓名  |    成绩    |  备注
      | 语文 | 数学 |

实现代码:

javascript
function exportWithComplexHeader(data) {
  // 1. 手动构建表头
  const headers = [
    ['姓名', '成绩', '成绩', '备注'],  // 第一行
    ['', '语文', '数学', '']          // 第二行
  ]

  // 2. 添加数据
  const rows = data.map(item => [
    item.name,
    item.chinese,
    item.math,
    item.remark
  ])

  // 3. 合并 headers 和 rows
  const sheetData = [...headers, ...rows]

  // 4. 创建工作表
  const worksheet = XLSX.utils.aoa_to_sheet(sheetData)

  // 5. 定义合并单元格
  worksheet['!merges'] = [
    { s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }, // 姓名,合并 A1:A2
    { s: { r: 0, c: 1 }, e: { r: 0, c: 2 } }, // 成绩,合并 B1:C1
    { s: { r: 0, c: 3 }, e: { r: 1, c: 3 } }, // 备注,合并 D1:D2
  ]

  // 6. 设置列宽
  worksheet['!cols'] = [
    { wch: 10 }, // A 列宽度
    { wch: 8 },  // B 列
    { wch: 8 },  // C 列
    { wch: 20 }, // D 列
  ]

  // 7. 生成工作簿
  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')

  XLSX.writeFile(workbook, 'complex-header.xlsx')
}
动态多级表头

有时表头层级是动态的,比如按日期分组:

javascript
function generateDynamicHeaders(dateRange) {
  const header1 = ['姓名']
  const header2 = ['']

  dateRange.forEach(date => {
    header1.push(date, date)  // 日期占两列
    header2.push('上班', '下班')
  })

  return [header1, header2]
}

function exportAttendance(data, dateRange) {
  const headers = generateDynamicHeaders(dateRange)

  const rows = data.map(person => {
    const row = [person.name]

    dateRange.forEach(date => {
      const record = person.records[date] || {}
      row.push(record.checkIn || '', record.checkOut || '')
    })

    return row
  })

  const sheetData = [...headers, ...rows]
  const worksheet = XLSX.utils.aoa_to_sheet(sheetData)

  // 计算合并单元格
  const merges = [
    { s: { r: 0, c: 0 }, e: { r: 1, c: 0 } } // 姓名列
  ]

  dateRange.forEach((date, index) => {
    const col = index * 2 + 1
    merges.push({
      s: { r: 0, c: col },
      e: { r: 0, c: col + 1 }
    })
  })

  worksheet['!merges'] = merges

  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')

  XLSX.writeFile(workbook, 'attendance.xlsx')
}
样式设置

SheetJS 免费版不支持样式,但可以用 xlsx-style 或手动设置:

javascript
// 设置表头样式
function styleHeader(worksheet, headerRows = 2) {
  const range = XLSX.utils.decode_range(worksheet['!ref'])

  for (let R = 0; R < headerRows; R++) {
    for (let C = range.s.c; C <= range.e.c; C++) {
      const cellAddress = { c: C, r: R }
      const cellRef = XLSX.utils.encode_cell(cellAddress)

      if (worksheet[cellRef]) {
        worksheet[cellRef].s = {
          font: { bold: true },
          fill: { fgColor: { rgb: 'CCCCCC' } },
          alignment: { horizontal: 'center', vertical: 'center' }
        }
      }
    }
  }
}

我们项目里有 50+ 种报表,都是用这套方法生成的,包括财务报表、考勤报表、销售报表等,表头层级最多 4 层,都能完美支持。"

Q4: 数据校验和错误提示如何实现?

标准回答

"数据校验是导入功能的核心,关系到数据质量。我设计了一套校验框架:

校验规则定义
javascript
const validationRules = {
  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: 'required', message: '邮箱不能为空' },
    { type: 'email', message: '邮箱格式不正确' }
  ],
  phone: [
    { type: 'required', message: '手机号不能为空' },
    { type: 'pattern', value: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }
  ],
  idCard: [
    { type: 'required', message: '身份证号不能为空' },
    {
      type: 'custom',
      validator: (value) => {
        // 自定义校验逻辑
        return validateIdCard(value)
      },
      message: '身份证号不正确'
    }
  ]
}
校验引擎
javascript
class Validator {
  constructor(rules) {
    this.rules = rules
  }

  // 校验单个字段
  validateField(field, value) {
    const fieldRules = this.rules[field]
    if (!fieldRules) return { valid: true }

    for (const rule of fieldRules) {
      const result = this.applyRule(rule, value)
      if (!result.valid) {
        return {
          valid: false,
          field,
          message: rule.message
        }
      }
    }

    return { valid: true }
  }

  // 应用规则
  applyRule(rule, value) {
    switch (rule.type) {
      case 'required':
        return {
          valid: value !== null && value !== undefined && value !== ''
        }

      case 'number':
        return {
          valid: !isNaN(Number(value))
        }

      case 'range':
        const num = Number(value)
        return {
          valid: num >= rule.min && num <= rule.max
        }

      case 'maxLength':
        return {
          valid: String(value).length <= rule.value
        }

      case 'email':
        return {
          valid: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
        }

      case 'pattern':
        return {
          valid: rule.value.test(value)
        }

      case 'custom':
        return {
          valid: rule.validator(value)
        }

      default:
        return { valid: true }
    }
  }

  // 校验整行数据
  validateRow(row, rowIndex) {
    const errors = []

    Object.keys(this.rules).forEach(field => {
      const result = this.validateField(field, row[field])
      if (!result.valid) {
        errors.push({
          row: rowIndex,
          field,
          value: row[field],
          message: result.message
        })
      }
    })

    return errors
  }

  // 校验所有数据
  validateAll(data) {
    const allErrors = []

    data.forEach((row, index) => {
      const errors = this.validateRow(row, index + 2) // +2 因为 Excel 行号从1开始,且第1行是表头
      allErrors.push(...errors)
    })

    return allErrors
  }
}
异步校验(唯一性检查)
javascript
async function validateUniqueness(data, field, apiCheck) {
  const errors = []
  const values = data.map(row => row[field])

  // 检查导入数据内部重复
  const duplicates = values.filter((v, i) => values.indexOf(v) !== i)
  if (duplicates.length > 0) {
    duplicates.forEach(value => {
      const rows = data
        .map((row, index) => row[field] === value ? index + 2 : null)
        .filter(r => r !== null)

      errors.push({
        rows,
        field,
        value,
        message: `${field} 在第 ${rows.join(',')} 行重复`
      })
    })
  }

  // 检查与数据库重复
  const response = await apiCheck(values)
  const existingValues = response.data

  existingValues.forEach(value => {
    const row = data.findIndex(r => r[field] === value) + 2
    errors.push({
      row,
      field,
      value,
      message: `${field} "${value}" 已存在`
    })
  })

  return errors
}
错误反馈

生成一个错误报告 Excel,标注出错误的单元格:

javascript
function generateErrorReport(data, errors) {
  // 创建工作表
  const worksheet = XLSX.utils.json_to_sheet(data)

  // 标注错误单元格(背景色红色)
  errors.forEach(error => {
    const cellAddress = XLSX.utils.encode_cell({
      r: error.row - 1,
      c: getColumnIndex(error.field)
    })

    if (worksheet[cellAddress]) {
      worksheet[cellAddress].s = {
        fill: { fgColor: { rgb: 'FFCCCC' } }
      }

      // 添加批注
      worksheet[cellAddress].c = [{
        a: 'System',
        t: error.message
      }]
    }
  })

  // 添加错误汇总工作表
  const errorSheet = XLSX.utils.json_to_sheet(errors.map(e => ({
    行号: e.row,
    字段: e.field,
    错误值: e.value,
    错误信息: e.message
  })))

  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, '数据')
  XLSX.utils.book_append_sheet(workbook, errorSheet, '错误汇总')

  return workbook
}
完整的导入流程
javascript
async function importWithValidation(file) {
  // 1. 解析 Excel
  const data = await parseExcel(file)

  // 2. 数据校验
  const validator = new Validator(validationRules)
  const syncErrors = validator.validateAll(data)

  // 3. 异步校验(唯一性)
  const asyncErrors = await validateUniqueness(data, 'email', checkEmailExists)

  // 4. 合并错误
  const allErrors = [...syncErrors, ...asyncErrors]

  if (allErrors.length > 0) {
    // 生成错误报告
    const errorReport = generateErrorReport(data, allErrors)
    XLSX.writeFile(errorReport, 'error-report.xlsx')

    return {
      success: false,
      errors: allErrors,
      errorCount: allErrors.length
    }
  }

  // 5. 导入数据
  await importData(data)

  return {
    success: true,
    count: data.length
  }
}

这套校验系统在我们的用户导入功能里,错误率从 30% 降到 5%,用户反馈说修改错误很方便,不用重新导入。"

Q5: 模板导出和数据填充?

标准回答

"模板导出就是提供一个预设好格式的 Excel 文件,用户按照模板填写数据后导入。

模板设计
javascript
function generateTemplate(config) {
  const {
    fields,        // 字段定义
    sampleData,    // 示例数据
    instructions   // 使用说明
  } = config

  // 1. 生成表头
  const headers = fields.map(f => f.label)

  // 2. 生成字段说明(第二行)
  const descriptions = fields.map(f => f.description || '')

  // 3. 生成示例数据
  const samples = sampleData || [
    fields.map(f => f.example || '')
  ]

  // 4. 组合数据
  const sheetData = [
    headers,
    descriptions,
    ...samples
  ]

  const worksheet = XLSX.utils.aoa_to_sheet(sheetData)

  // 5. 设置列宽
  worksheet['!cols'] = fields.map(f => ({ wch: f.width || 15 }))

  // 6. 样式设置
  // 表头加粗、背景色
  // 字段说明灰色、斜体
  // 示例数据正常

  // 7. 数据验证(下拉框)
  fields.forEach((field, index) => {
    if (field.options) {
      // 添加下拉列表
      addDropdown(worksheet, index, field.options)
    }
  })

  // 8. 添加说明工作表
  const instructionSheet = XLSX.utils.aoa_to_sheet([
    ['字段说明'],
    [''],
    ...fields.map(f => [f.label, f.description, f.validation])
  ])

  // 9. 生成工作簿
  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, '数据')
  XLSX.utils.book_append_sheet(workbook, instructionSheet, '说明')

  return workbook
}

// 使用示例
const template = generateTemplate({
  fields: [
    {
      key: 'name',
      label: '姓名',
      description: '必填,不超过20字',
      example: '张三',
      width: 15,
      validation: { required: true, maxLength: 20 }
    },
    {
      key: 'gender',
      label: '性别',
      description: '必填,男/女',
      example: '男',
      width: 10,
      options: ['男', '女'],
      validation: { required: true, enum: ['男', '女'] }
    },
    {
      key: 'department',
      label: '部门',
      description: '必填,从列表选择',
      example: '技术部',
      width: 15,
      options: ['技术部', '销售部', '人事部'],
      validation: { required: true }
    }
  ],
  sampleData: [
    ['张三', '男', '技术部'],
    ['李四', '女', '销售部']
  ]
})

XLSX.writeFile(template, 'import-template.xlsx')
数据验证(下拉框)

SheetJS 免费版不支持数据验证,需要手动实现或用付费版:

javascript
function addDropdown(worksheet, columnIndex, options) {
  // 这需要直接操作 Excel 的 XML
  // 或者使用 exceljs 库,它支持数据验证

  // 使用 exceljs 示例:
  const ExcelJS = require('exceljs')

  worksheet.getColumn(columnIndex + 1).eachCell({ includeEmpty: true }, (cell, rowNumber) => {
    if (rowNumber > 2) { // 跳过表头和说明
      cell.dataValidation = {
        type: 'list',
        allowBlank: false,
        formulae: [`"${options.join(',')}"`]
      }
    }
  })
}
智能填充

根据部分数据,自动生成 Excel:

javascript
function fillTemplate(template, data, mapping) {
  // template: 模板工作簿
  // data: 要填充的数据
  // mapping: 字段映射 { excel列: 数据字段 }

  const worksheet = template.Sheets[template.SheetNames[0]]
  const range = XLSX.utils.decode_range(worksheet['!ref'])

  // 找到数据起始行(跳过表头和说明)
  const dataStartRow = 3

  // 填充数据
  data.forEach((item, index) => {
    const row = dataStartRow + index

    Object.entries(mapping).forEach(([excelCol, dataField]) => {
      const col = XLSX.utils.decode_col(excelCol)
      const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })

      worksheet[cellAddress] = {
        t: 's', // 字符串类型
        v: item[dataField]
      }
    })
  })

  // 更新范围
  worksheet['!ref'] = XLSX.utils.encode_range({
    s: { r: 0, c: range.s.c },
    e: { r: dataStartRow + data.length - 1, c: range.e.c }
  })

  return template
}

我们的模板导出功能上线后,用户导入成功率从 50% 提升到 95%,客服咨询量减少了一半。"


核心难点与解决方案

难点1: 大文件导入的内存和性能优化

问题描述: 导入 100MB、10 万行的 Excel 文件时,浏览器崩溃或长时间无响应。

解决方案

"大文件导入要从解析、处理、校验三个环节优化:

解析优化 - 流式读取
javascript
async function streamParseExcel(file) {
  const CHUNK_SIZE = 10 * 1024 * 1024 // 10MB 一块
  const chunks = []

  // 分块读取文件
  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = file.slice(offset, offset + CHUNK_SIZE)
    const arrayBuffer = await chunk.arrayBuffer()
    chunks.push(new Uint8Array(arrayBuffer))
  }

  // 合并 chunks
  const fullData = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0))
  let position = 0
  chunks.forEach(chunk => {
    fullData.set(chunk, position)
    position += chunk.length
  })

  // 解析
  const workbook = XLSX.read(fullData, {
    type: 'array',
    sheetRows: 10000, // 只读取前 10000 行(可选)
  })

  return workbook
}
处理优化 - 分批次处理
javascript
async function processBatches(data, batchSize = 1000) {
  const results = []
  const errors = []

  for (let i = 0; i < data.length; i += batchSize) {
    const batch = data.slice(i, i + batchSize)

    // 处理这一批
    const batchResult = await processBatch(batch, i)
    results.push(...batchResult.success)
    errors.push(...batchResult.errors)

    // 更新进度
    const progress = Math.round((i + batch.length) / data.length * 100)
    updateProgress(progress)

    // 让出主线程,避免阻塞
    await new Promise(resolve => setTimeout(resolve, 0))
  }

  return { results, errors }
}

async function processBatch(batch, startIndex) {
  const success = []
  const errors = []

  batch.forEach((row, index) => {
    const rowIndex = startIndex + index + 2 // Excel 行号

    try {
      // 数据转换
      const processed = transformRow(row)

      // 数据校验
      const validation = validateRow(processed)
      if (!validation.valid) {
        errors.push({
          row: rowIndex,
          errors: validation.errors
        })
      } else {
        success.push(processed)
      }
    } catch (error) {
      errors.push({
        row: rowIndex,
        error: error.message
      })
    }
  })

  return { success, errors }
}
校验优化 - Worker 并行校验
javascript
// main.js
async function validateInWorker(data) {
  const workerCount = navigator.hardwareConcurrency || 4
  const chunkSize = Math.ceil(data.length / workerCount)

  const promises = []

  for (let i = 0; i < workerCount; i++) {
    const start = i * chunkSize
    const end = Math.min(start + chunkSize, data.length)
    const chunk = data.slice(start, end)

    const worker = new Worker('validation-worker.js')

    const promise = new Promise((resolve, reject) => {
      worker.onmessage = (e) => {
        resolve(e.data.errors)
        worker.terminate()
      }
      worker.onerror = reject
    })

    worker.postMessage({
      data: chunk,
      startIndex: start,
      rules: validationRules
    })

    promises.push(promise)
  }

  const results = await Promise.all(promises)
  return results.flat()
}
javascript
// validation-worker.js
self.onmessage = (e) => {
  const { data, startIndex, rules } = e.data
  const errors = []

  data.forEach((row, index) => {
    const rowIndex = startIndex + index + 2
    const validation = validateRow(row, rules)

    if (!validation.valid) {
      errors.push({
        row: rowIndex,
        errors: validation.errors
      })
    }
  })

  self.postMessage({ errors })
}

function validateRow(row, rules) {
  // 校验逻辑...
}
完整流程
javascript
async function importLargeFile(file) {
  try {
    // 1. 显示进度条
    showProgress('解析文件...')

    // 2. 流式解析
    const workbook = await streamParseExcel(file)
    const data = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]])

    showProgress(`解析完成,共 ${data.length} 行`)

    // 3. 并行校验
    showProgress('校验数据...')
    const errors = await validateInWorker(data)

    if (errors.length > 0) {
      showProgress(`发现 ${errors.length} 个错误`)
      showErrors(errors)
      return
    }

    // 4. 分批导入
    showProgress('导入数据...')
    await processBatches(data, 1000)

    showProgress('导入完成!')

  } catch (error) {
    showError(error.message)
  }
}
效果数据
  • 100MB 文件,10 万行数据
  • 解析时间:从 30s 降至 5s
  • 内存占用:从 1GB 降至 100MB
  • 不阻塞主线程,用户可继续操作"

难点2: 数据类型转换和格式化

问题描述: Excel 的日期、数字、布尔值等类型和 JavaScript 不一致,导入时需要转换。

解决方案

"Excel 的数据类型转换是个坑,需要针对每种类型特殊处理:

日期转换

Excel 的日期是数字(从 1900-01-01 开始的天数):

javascript
function excelDateToJS(serial) {
  // Excel 日期基准: 1900-01-01
  const excelEpoch = new Date(1900, 0, 1)
  const days = Math.floor(serial)
  const milliseconds = Math.round((serial - days) * 86400000)

  const date = new Date(excelEpoch.getTime() + (days - 1) * 86400000 + milliseconds)

  // Excel 有个 bug: 1900 年误认为是闰年
  if (serial >= 60) {
    date.setDate(date.getDate() - 1)
  }

  return date
}

// 或者用 SheetJS 内置的转换
const workbook = XLSX.read(data, {
  cellDates: true, // 自动转换日期
  dateNF: 'yyyy-mm-dd' // 日期格式
})
数字转换

Excel 的数字有精度问题,还有千分位格式:

javascript
function parseNumber(value) {
  if (typeof value === 'number') {
    return value
  }

  if (typeof value === 'string') {
    // 移除千分位逗号
    const cleaned = value.replace(/,/g, '')
    const number = parseFloat(cleaned)

    if (isNaN(number)) {
      throw new Error(`Invalid number: ${value}`)
    }

    return number
  }

  return null
}
百分比转换

Excel 的百分比实际存储是小数:

javascript
function parsePercentage(value) {
  if (typeof value === 'number') {
    // Excel 中 50% 存储为 0.5
    return value * 100
  }

  if (typeof value === 'string') {
    // 用户可能输入 "50%" 或 "0.5"
    if (value.endsWith('%')) {
      return parseFloat(value.slice(0, -1))
    } else {
      return parseFloat(value) * 100
    }
  }

  return null
}
布尔值转换

Excel 的布尔值可能是多种形式:

javascript
function parseBoolean(value) {
  if (typeof value === 'boolean') {
    return value
  }

  if (typeof value === 'string') {
    const lower = value.toLowerCase().trim()

    // 中文
    if (lower === '是' || lower === '对' || lower === '真') {
      return true
    }
    if (lower === '否' || lower === '错' || lower === '假') {
      return false
    }

    // 英文
    if (lower === 'true' || lower === 'yes' || lower === 'y') {
      return true
    }
    if (lower === 'false' || lower === 'no' || lower === 'n') {
      return false
    }

    // 数字
    if (lower === '1') return true
    if (lower === '0') return false
  }

  if (typeof value === 'number') {
    return value !== 0
  }

  return null
}
统一的类型转换器
javascript
class TypeConverter {
  constructor(config) {
    this.config = config
  }

  convert(value, type) {
    if (value === null || value === undefined || value === '') {
      return null
    }

    switch (type) {
      case 'string':
        return String(value).trim()

      case 'number':
        return this.parseNumber(value)

      case 'integer':
        return Math.floor(this.parseNumber(value))

      case 'float':
        const num = this.parseNumber(value)
        return Math.round(num * 100) / 100 // 保留两位小数

      case 'percentage':
        return this.parsePercentage(value)

      case 'boolean':
        return this.parseBoolean(value)

      case 'date':
        return this.parseDate(value)

      case 'datetime':
        return this.parseDateTime(value)

      case 'enum':
        return this.parseEnum(value)

      default:
        return value
    }
  }

  parseNumber(value) {
    // ... (前面的实现)
  }

  parseDate(value) {
    // 尝试多种解析方式

    // 1. Excel 日期序列号
    if (typeof value === 'number') {
      return excelDateToJS(value)
    }

    // 2. ISO 字符串
    if (typeof value === 'string') {
      // "2024-01-15"
      const iso = new Date(value)
      if (!isNaN(iso.getTime())) {
        return iso
      }

      // "2024/01/15"
      const slash = new Date(value.replace(/\//g, '-'))
      if (!isNaN(slash.getTime())) {
        return slash
      }

      // "15/01/2024" (DD/MM/YYYY)
      const parts = value.split('/')
      if (parts.length === 3) {
        const date = new Date(parts[2], parts[1] - 1, parts[0])
        if (!isNaN(date.getTime())) {
          return date
        }
      }
    }

    throw new Error(`Invalid date: ${value}`)
  }

  parseEnum(value, options) {
    const str = String(value).trim()

    if (options.includes(str)) {
      return str
    }

    // 尝试不区分大小写匹配
    const lowerValue = str.toLowerCase()
    const matched = options.find(opt => opt.toLowerCase() === lowerValue)

    if (matched) {
      return matched
    }

    throw new Error(`Invalid enum value: ${value}, expected one of: ${options.join(', ')}`)
  }
}
使用示例
javascript
const converter = new TypeConverter()

const fieldTypes = {
  name: 'string',
  age: 'integer',
  salary: 'float',
  rate: 'percentage',
  isActive: 'boolean',
  birthday: 'date',
  gender: 'enum'
}

const fieldEnums = {
  gender: ['男', '女', '其他']
}

function transformRow(row) {
  const transformed = {}

  Object.entries(fieldTypes).forEach(([field, type]) => {
    try {
      const value = row[field]
      const options = fieldEnums[field]

      transformed[field] = converter.convert(value, type, options)
    } catch (error) {
      throw new Error(`Field "${field}": ${error.message}`)
    }
  })

  return transformed
}

这套类型转换系统在我们项目里,处理了各种用户输入的奇葩格式,导入错误率从 20% 降到 2%。"

难点3: 样式和格式的保持

问题描述: 导出带样式的 Excel(颜色、字体、边框等),SheetJS 免费版不支持。

解决方案

"样式处理有三种方案:

方案1: 使用 exceljs (推荐)

exceljs 是一个功能更强大的库,支持样式:

javascript
import ExcelJS from 'exceljs'

async function exportWithStyle(data) {
  const workbook = new ExcelJS.Workbook()
  const worksheet = workbook.addWorksheet('Sheet1')

  // 1. 设置列
  worksheet.columns = [
    { header: '姓名', key: 'name', width: 15 },
    { header: '年龄', key: 'age', width: 10 },
    { header: '工资', key: 'salary', width: 15 }
  ]

  // 2. 样式化表头
  worksheet.getRow(1).eachCell(cell => {
    cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }
    cell.fill = {
      type: 'pattern',
      pattern: 'solid',
      fgColor: { argb: 'FF4472C4' }
    }
    cell.alignment = { horizontal: 'center', vertical: 'middle' }
    cell.border = {
      top: { style: 'thin' },
      left: { style: 'thin' },
      bottom: { style: 'thin' },
      right: { style: 'thin' }
    }
  })

  // 3. 添加数据
  data.forEach(item => {
    const row = worksheet.addRow(item)

    // 根据条件设置行样式
    if (item.salary > 10000) {
      row.getCell('salary').font = { color: { argb: 'FFFF0000' } }
      row.getCell('salary').fill = {
        type: 'pattern',
        pattern: 'solid',
        fgColor: { argb: 'FFFFEB9C' }
      }
    }
  })

  // 4. 设置行高
  worksheet.getRow(1).height = 25

  // 5. 冻结首行
  worksheet.views = [
    { state: 'frozen', xSplit: 0, ySplit: 1 }
  ]

  // 6. 导出
  const buffer = await workbook.xlsx.writeBuffer()
  const blob = new Blob([buffer], {
    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  })

  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'export.xlsx'
  a.click()
  URL.revokeObjectURL(url)
}
方案2: 使用模板填充

准备一个带样式的模板,填充数据:

javascript
async function exportFromTemplate(templateFile, data) {
  // 1. 读取模板
  const templateBuffer = await templateFile.arrayBuffer()
  const workbook = new ExcelJS.Workbook()
  await workbook.xlsx.load(templateBuffer)

  // 2. 获取工作表
  const worksheet = workbook.getWorksheet(1)

  // 3. 找到数据起始行(假设第2行开始是数据)
  const startRow = 2

  // 4. 填充数据
  data.forEach((item, index) => {
    const row = worksheet.getRow(startRow + index)

    // 复制模板行的样式
    if (index > 0) {
      const templateRow = worksheet.getRow(startRow)
      row.height = templateRow.height

      templateRow.eachCell((cell, colNumber) => {
        const targetCell = row.getCell(colNumber)
        targetCell.style = { ...cell.style }
      })
    }

    // 填充数据
    row.getCell(1).value = item.name
    row.getCell(2).value = item.age
    row.getCell(3).value = item.salary

    row.commit()
  })

  // 5. 导出
  const buffer = await workbook.xlsx.writeBuffer()
  downloadBuffer(buffer, 'export.xlsx')
}
方案3: HTML 表格转 Excel

用 HTML 表格带样式,转成 Excel:

javascript
function exportHTMLTable(tableElement) {
  const html = tableElement.outerHTML

  // 转换为 Excel XML 格式
  const excelXML = `
    <html xmlns:o="urn:schemas-microsoft-com:office:office"
          xmlns:x="urn:schemas-microsoft-com:office:excel">
      <head>
        <xml>
          <x:ExcelWorkbook>
            <x:ExcelWorksheets>
              <x:ExcelWorksheet>
                <x:Name>Sheet1</x:Name>
                <x:WorksheetOptions>
                  <x:DisplayGridlines/>
                </x:WorksheetOptions>
              </x:ExcelWorksheet>
            </x:ExcelWorksheets>
          </x:ExcelWorkbook>
        </xml>
      </head>
      <body>${html}</body>
    </html>
  `

  const blob = new Blob([excelXML], {
    type: 'application/vnd.ms-excel'
  })

  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'export.xls'
  a.click()
  URL.revokeObjectURL(url)
}

我们项目选择了 exceljs,虽然库比 SheetJS 大一些(200KB vs 600KB),但样式支持完善,导出的 Excel 很专业。"


(续完整技术实现部分...)

完整技术实现

1. Excel 导入器

javascript
// utils/ExcelImporter.js
import * as XLSX from 'xlsx'

export class ExcelImporter {
  constructor(config = {}) {
    this.config = {
      sheetIndex: 0,
      headerRow: 1,
      dataStartRow: 2,
      ...config
    }
    this.errors = []
  }

  async import(file) {
    try {
      // 1. 读取文件
      const data = await this.readFile(file)

      // 2. 解析工作表
      const rows = this.parseSheet(data)

      // 3. 转换数据
      const records = this.transformData(rows)

      // 4. 校验数据
      const validRecords = this.validateData(records)

      return {
        success: this.errors.length === 0,
        data: validRecords,
        errors: this.errors,
        total: records.length
      }
    } catch (error) {
      return {
        success: false,
        data: [],
        errors: [{ message: error.message }],
        total: 0
      }
    }
  }

  async readFile(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()

      reader.onload = (e) => {
        try {
          const data = new Uint8Array(e.target.result)
          const workbook = XLSX.read(data, {
            type: 'array',
            cellDates: true,
            cellFormula: false,
            cellStyles: false
          })
          resolve(workbook)
        } catch (error) {
          reject(error)
        }
      }

      reader.onerror = () => reject(new Error('文件读取失败'))
      reader.readAsArrayBuffer(file)
    })
  }

  parseSheet(workbook) {
    const sheetName = workbook.SheetNames[this.config.sheetIndex]
    const worksheet = workbook.Sheets[sheetName]

    // 转换为二维数组
    const data = XLSX.utils.sheet_to_json(worksheet, {
      header: 1,
      defval: null,
      blankrows: false,
      raw: false // 所有值转为字符串
    })

    return data
  }

  transformData(rows) {
    if (rows.length < this.config.dataStartRow) {
      throw new Error('文件中没有数据')
    }

    // 获取表头
    const headers = rows[this.config.headerRow - 1]

    // 获取数据行
    const dataRows = rows.slice(this.config.dataStartRow - 1)

    // 转换为对象数组
    return dataRows.map((row, index) => {
      const obj = { _row: this.config.dataStartRow + index }

      headers.forEach((header, colIndex) => {
        obj[header] = row[colIndex]
      })

      return obj
    })
  }

  validateData(records) {
    // 子类实现具体校验逻辑
    return records
  }
}

2. Excel 导出器

javascript
// utils/ExcelExporter.js
import ExcelJS from 'exceljs'

export class ExcelExporter {
  constructor(config = {}) {
    this.config = {
      sheetName: 'Sheet1',
      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' }
        }
      },
      ...config
    }
  }

  async export(data, columns, filename = 'export.xlsx') {
    const workbook = new ExcelJS.Workbook()
    const worksheet = workbook.addWorksheet(this.config.sheetName)

    // 设置列
    worksheet.columns = columns.map(col => ({
      header: col.title,
      key: col.key,
      width: col.width || 15
    }))

    // 样式化表头
    worksheet.getRow(1).eachCell(cell => {
      cell.style = this.config.headerStyle
    })
    worksheet.getRow(1).height = 25

    // 添加数据
    data.forEach(item => {
      const row = worksheet.addRow(item)

      // 应用行样式
      if (this.config.rowStyle) {
        row.eachCell(cell => {
          cell.style = this.config.rowStyle
        })
      }

      // 应用列样式
      columns.forEach((col, index) => {
        if (col.style) {
          row.getCell(index + 1).style = {
            ...row.getCell(index + 1).style,
            ...col.style
          }
        }
      })
    })

    // 冻结首行
    worksheet.views = [
      { state: 'frozen', xSplit: 0, ySplit: 1 }
    ]

    // 导出
    const buffer = await workbook.xlsx.writeBuffer()
    this.download(buffer, filename)
  }

  download(buffer, filename) {
    const blob = new Blob([buffer], {
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    })

    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
  }
}

3. 使用示例

vue
<!-- views/ExcelImportExport.vue -->
<template>
  <div class="excel-tools">
    <h2>Excel 导入导出</h2>

    <!-- 导入 -->
    <div class="import-section">
      <h3>数据导入</h3>

      <input
        ref="fileInputRef"
        type="file"
        accept=".xlsx,.xls"
        @change="handleFileChange"
        style="display: none"
      />

      <a-space>
        <a-button @click="downloadTemplate">
          下载模板
        </a-button>

        <a-button type="primary" @click="selectFile">
          选择文件
        </a-button>
      </a-space>

      <div v-if="importing" class="progress">
        <a-progress :percent="importProgress" />
        <span>{{ importStatus }}</span>
      </div>

      <div v-if="importResult" class="result">
        <a-alert
          :type="importResult.success ? 'success' : 'error'"
          :message="importResult.message"
          show-icon
        />

        <div v-if="importResult.errors.length > 0" class="errors">
          <h4>错误列表:</h4>
          <a-table
            :dataSource="importResult.errors"
            :columns="errorColumns"
            size="small"
            :pagination="{ pageSize: 10 }"
          />

          <a-button @click="downloadErrorReport">
            下载错误报告
          </a-button>
        </div>
      </div>
    </div>

    <!-- 导出 -->
    <div class="export-section">
      <h3>数据导出</h3>

      <a-space>
        <a-button type="primary" @click="handleExport">
          导出数据
        </a-button>

        <a-button @click="handleExportWithStyle">
          导出(带样式)
        </a-button>
      </a-space>

      <div v-if="exporting" class="progress">
        <a-progress :percent="exportProgress" />
        <span>生成中...</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ExcelImporter } from '@/utils/ExcelImporter'
import { ExcelExporter } from '@/utils/ExcelExporter'

const fileInputRef = ref(null)
const importing = ref(false)
const importProgress = ref(0)
const importStatus = ref('')
const importResult = ref(null)

const exporting = ref(false)
const exportProgress = ref(0)

const errorColumns = [
  { title: '行号', dataIndex: 'row', width: 80 },
  { title: '字段', dataIndex: 'field', width: 120 },
  { title: '错误信息', dataIndex: 'message', ellipsis: true },
]

// 下载模板
function downloadTemplate() {
  const exporter = new ExcelExporter()

  const columns = [
    { title: '姓名', key: 'name', width: 15 },
    { title: '年龄', key: 'age', width: 10 },
    { title: '部门', key: 'department', width: 15 },
    { title: '邮箱', key: 'email', width: 25 },
  ]

  const sampleData = [
    { name: '张三', age: 28, department: '技术部', email: 'zhangsan@example.com' },
    { name: '李四', age: 32, department: '销售部', email: 'lisi@example.com' },
  ]

  exporter.export(sampleData, columns, 'import-template.xlsx')
  message.success('模板下载成功')
}

// 选择文件
function selectFile() {
  fileInputRef.value.click()
}

// 文件选择变化
async function handleFileChange(e) {
  const file = e.target.files[0]
  if (!file) return

  importing.value = true
  importProgress.value = 0
  importStatus.value = '解析文件...'
  importResult.value = null

  try {
    // 创建导入器
    const importer = new UserImporter()

    // 模拟进度
    const progressInterval = setInterval(() => {
      if (importProgress.value < 90) {
        importProgress.value += 10
      }
    }, 200)

    // 导入
    const result = await importer.import(file)

    clearInterval(progressInterval)
    importProgress.value = 100

    if (result.success) {
      importStatus.value = '导入成功!'
      importResult.value = {
        success: true,
        message: `成功导入 ${result.data.length} 条数据`,
        errors: []
      }

      // 调用接口保存数据
      // await saveImportData(result.data)

    } else {
      importStatus.value = '导入失败'
      importResult.value = {
        success: false,
        message: `发现 ${result.errors.length} 个错误`,
        errors: result.errors
      }
    }

  } catch (error) {
    message.error('导入失败: ' + error.message)
  } finally {
    importing.value = false
    // 清空 input
    e.target.value = ''
  }
}

// 下载错误报告
function downloadErrorReport() {
  // 实现错误报告下载
  message.info('功能开发中')
}

// 导出数据
async function handleExport() {
  exporting.value = true
  exportProgress.value = 0

  try {
    // 获取数据
    const data = await fetchExportData()

    const columns = [
      { title: 'ID', key: 'id', width: 10 },
      { title: '姓名', key: 'name', width: 15 },
      { title: '年龄', key: 'age', width: 10 },
      { title: '部门', key: 'department', width: 15 },
      { title: '邮箱', key: 'email', width: 25 },
    ]

    const exporter = new ExcelExporter()
    await exporter.export(data, columns, 'users.xlsx')

    message.success('导出成功')

  } catch (error) {
    message.error('导出失败: ' + error.message)
  } finally {
    exporting.value = false
  }
}

// 带样式导出
async function handleExportWithStyle() {
  // 实现带样式的导出
  message.info('功能开发中')
}

// 模拟数据获取
async function fetchExportData() {
  return [
    { id: 1, name: '张三', age: 28, department: '技术部', email: 'zhangsan@example.com' },
    { id: 2, name: '李四', age: 32, department: '销售部', email: 'lisi@example.com' },
    { id: 3, name: '王五', age: 25, department: '人事部', email: 'wangwu@example.com' },
  ]
}

// 用户导入器
class UserImporter extends ExcelImporter {
  validateData(records) {
    const validRecords = []

    records.forEach(record => {
      const errors = []

      // 姓名校验
      if (!record['姓名']) {
        errors.push({ field: '姓名', message: '姓名不能为空' })
      }

      // 年龄校验
      const age = Number(record['年龄'])
      if (isNaN(age) || age < 0 || age > 150) {
        errors.push({ field: '年龄', message: '年龄必须是0-150的数字' })
      }

      // 邮箱校验
      if (record['邮箱'] && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(record['邮箱'])) {
        errors.push({ field: '邮箱', message: '邮箱格式不正确' })
      }

      if (errors.length > 0) {
        errors.forEach(error => {
          this.errors.push({
            row: record._row,
            ...error
          })
        })
      } else {
        validRecords.push({
          name: record['姓名'],
          age: Number(record['年龄']),
          department: record['部门'] || '',
          email: record['邮箱'] || ''
        })
      }
    })

    return validRecords
  }
}
</script>

<style scoped>
.excel-tools {
  padding: 24px;
}

.import-section,
.export-section {
  margin-bottom: 40px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}

.progress {
  margin-top: 16px;
}

.result {
  margin-top: 16px;
}

.errors {
  margin-top: 16px;
}
</style>

面试常见追问

Q: 如何处理 Excel 中的公式?

"SheetJS 默认只读取公式的计算结果,不读取公式本身。

如果需要保留公式:

javascript
const workbook = XLSX.read(data, {
  cellFormula: true // 保留公式
})

如果需要在导出时添加公式:

javascript
// 使用 exceljs
worksheet.getCell('D2').value = {
  formula: 'A2+B2',
  result: 10 // 可选,设置默认显示值
}
```"

### Q: 如何处理 Excel 中的图片?

"SheetJS 免费版不支持图片,需要用 exceljs:

```javascript
// 添加图片
const imageId = workbook.addImage({
  buffer: imageBuffer,
  extension: 'png'
})

worksheet.addImage(imageId, {
  tl: { col: 0, row: 0 }, // top-left
  ext: { width: 200, height: 200 }
})

// 读取图片
const images = worksheet.getImages()
images.forEach(image => {
  const imageData = workbook.getImage(image.imageId)
  // imageData.buffer 是图片数据
})
```"

### Q: 如何导出百万行数据?

"百万行数据不能一次性加载到内存,要用流式处理:

1. 后端分页返回数据
2. 前端分批请求,逐批写入 Excel
3. 使用 exceljs 的 stream API
4. 或者直接让后端生成 Excel,前端下载

我们项目里,超过 10 万行的数据都是后端导出。"

### Q: 如何实现 Excel 的在线预览?

"有几种方案:

1. 转成 HTML 表格显示(SheetJS 支持)
2. 转成 JSON,用表格组件显示
3. 用 Office Online 或 Google Sheets API
4. 用第三方服务如 Zoho Viewer

我们用的是转 JSON + 表格组件,简单有效。"

---

## 项目经验总结

### 踩过的坑

1. **日期格式问题** - Excel 的日期是数字,没有时区概念,需特殊处理
2. **编码问题** - CSV 文件可能是 GBK 编码,需要转换
3. **大文件卡死** - 没做流式处理,10 万行卡死浏览器
4. **公式丢失** - 导入后公式变成值,需要 cellFormula: true

### 性能数据

- 10 万行数据导出:< 5s
- 100MB 文件导入:< 10s
- 内存占用:< 200MB
- 文件体积:压缩后减少 30%

### 可以吹的点

- 支持 10+ 种数据类型自动转换
- 智能错误提示,精确到行列
- 模板化导入,成功率 95%+
- 流式处理,支持超大文件
- 样式完整保留,报表专业
```text