vue3+handsontable实现在线可编辑excel

Handsontable 是一款功能强大、高性能的 JavaScript 电子表格组件库,它提供了类似 Excel 的交互体验,支持多种前端框架集成,适用于构建数据密集型应用。以下是对 Handsontable 的详细介绍:

一、核心功能

  1. 数据编辑与验证:

    • 支持多种数据类型,包括文本、数字、日期、下拉选择等。
    • 提供数据验证功能,确保输入数据的准确性和完整性。
  2. 数据排序与过滤:

    • 支持单列或多列排序,方便用户快速浏览和分析数据。
    • 提供列筛选功能,用户可以根据条件筛选数据。
  3. 公式计算:

    • 内置公式引擎,支持常用数学函数和自定义公式。
    • 用户可以在表格中进行复杂的计算,满足各种业务需求。
  4. 单元格合并与拆分:

    • 支持灵活的单元格合并和拆分功能,方便展示复杂信息。
  5. 剪贴板操作:

    • 提供完整的剪贴板支持,包括复制、剪切和粘贴操作。
    • 用户可以像操作 Excel 一样轻松处理数据。
  6. 主题与样式:

    • 提供多种内置主题和样式,支持自定义样式以满足不同需求。
    • 用户可以通过 CSS 变量调整表格的外观和风格。

二、性能优化

  1. 虚拟滚动:

    • 支持虚拟滚动技术,可流畅处理数万行数据。
    • 适合大数据量场景,提高渲染速度和响应速度。
  2. 客户端行分页:

    • Handsontable 16.1.0 引入了客户端行分页功能。
    • 允许用户将大型表格划分为更小、更易于管理的区块,提高性能和可用性。

三、多框架支持

  1. React:

    • 提供专门的 React 组件和 hooks,方便在 React 项目中集成。
  2. Angular:

    • 提供完整的 Angular 包装器和指令,支持在 Angular 项目中使用。
  3. Vue:

    • 支持 Vue 2 和 Vue 3 版本,提供相应的封装库。
  4. 纯 JavaScript:

    • 也可以在纯 JavaScript 项目中直接使用 Handsontable。

四、应用场景

  1. 资源规划软件(ERP):

    • 用于显示和编辑库存、订单、客户等数据。
  2. 库存管理系统:

    • 提供直观的数据输入和编辑界面,实时更新库存状态。
  3. 数字平台:

    • 用于展示和编辑用户数据、配置信息等。
  4. 数据建模应用:

    • 处理大量数据,提供强大的数据编辑和验证功能。

五、优势与特点

  1. 易用性:

    • 类 Excel 交互体验,降低用户学习成本。
    • 开发者可以通过简单配置快速集成 Handsontable。
  2. 灵活性:

    • 支持自定义渲染、插件扩展,适配多样化业务需求。
    • 提供丰富的 API 和回调函数,方便开发者进行定制和扩展。
  3. 性能:

    • 虚拟滚动技术确保大数据量下的流畅操作。
    • 客户端行分页功能进一步提高性能和可用性。
  4. 生态完善:

    • 提供 TypeScript 类型定义、详细 API 文档及社区支持。
    • 开发者可以轻松获取帮助和资源,提高开发效率。

VUE3整合实现例子

复制代码
<template>
  <div class="diy-excel">
    <div class="diy-excel-toolbar">
      <el-button type="primary" size="small" @click="addRow">
        <el-icon><Plus /></el-icon>
        增加行
      </el-button>
      <el-button type="primary" size="small" @click="addColumn">
        <el-icon><Plus /></el-icon>
        增加列
      </el-button>
      <el-button size="small" @click="downloadExcel">
        <el-icon><Download /></el-icon>
        下载 Excel
      </el-button>
      <span class="diy-excel-info">{{ infoText }}</span>
    </div>
    <div class="diy-excel-hot" ref="hotContainerRef"></div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Plus, Download } from '@element-plus/icons-vue'
import Handsontable from 'handsontable'
import 'handsontable/dist/handsontable.full.min.css'
import * as XLSX from 'xlsx'

const DEFAULT_ROWS = 10
const DEFAULT_COLS = 6

const props = defineProps({
  /** 二维数组 [行][列],字符串。不传则默认 10×6 空表 */
  modelValue: {
    type: Array,
    default: undefined,
  },
})

const emit = defineEmits(['update:modelValue'])

const hotContainerRef = ref(null)
let hot = null
let resizeObserver = null
const stats = ref({ rows: DEFAULT_ROWS, cols: DEFAULT_COLS })

function toPlainData(val) {
  if (!val || !Array.isArray(val) || val.length === 0) return null
  return val.map((row) =>
    Array.isArray(row)
      ? row.map((cell) => (cell != null && cell !== undefined ? String(cell) : ''))
      : []
  )
}

function getDefaultData() {
  return Array.from({ length: DEFAULT_ROWS }, () =>
    Array.from({ length: DEFAULT_COLS }, () => '')
  )
}

function getDataToLoad() {
  const plain = toPlainData(props.modelValue)
  return plain || getDefaultData()
}

function snapshot() {
  if (!hot) return
  const data = hot.getData()
  const out = data.map((row) => (row ? [...row] : []))
  stats.value = { rows: out.length, cols: Math.max(0, ...out.map((r) => (r ? r.length : 0))) }
  emit('update:modelValue', out)
}

function createHot() {
  if (!hotContainerRef.value) return
  if (hot) {
    hot.destroy()
    hot = null
  }
  const data = getDataToLoad()
  const container = hotContainerRef.value
  const initialHeight = container.clientHeight || 300
  hot = new Handsontable(container, {
    data,
    rowHeaders: true,
    colHeaders: true,
    width: '100%',
    height: initialHeight,
    stretchH: 'all',
    manualColumnResize: true,
    manualRowResize: true,
    readOnly: false,
    licenseKey: 'non-commercial-and-evaluation',
    afterChange(_, source) {
      if (source !== 'loadData') snapshot()
    },
    afterCreateRow(_, __, source) {
      if (source !== 'loadData') snapshot()
    },
    afterRemoveRow() {
      snapshot()
    },
    afterCreateCol(_, __, source) {
      if (source !== 'loadData') snapshot()
    },
    afterRemoveCol() {
      snapshot()
    },
  })
  snapshot()
  resizeObserver = new ResizeObserver((entries) => {
    const entry = entries[0]
    if (entry && hot) {
      const h = entry.contentRect.height
      if (h > 0) hot.updateSettings({ height: h })
    }
  })
  resizeObserver.observe(container)
}

watch(
  () => props.modelValue,
  (val) => {
    const plain = toPlainData(val)
    if (!plain || !hot) return
    hot.loadData(plain)
    stats.value = {
      rows: plain.length,
      cols: Math.max(0, ...plain.map((r) => (r ? r.length : 0))),
    }
  },
  { deep: true }
)

onMounted(() => {
  nextTick(() => createHot())
})

onBeforeUnmount(() => {
  if (resizeObserver && hotContainerRef.value) {
    resizeObserver.unobserve(hotContainerRef.value)
    resizeObserver = null
  }
  if (hot) {
    hot.destroy()
    hot = null
  }
})

const infoText = computed(() => `${stats.value.rows} 行 × ${stats.value.cols} 列`)

function addRow() {
  if (hot) hot.alter('insert_row_below', hot.countRows() - 1)
}

function addColumn() {
  if (hot) hot.alter('insert_col_end')
}

function downloadExcel() {
  const data = hot ? hot.getData() : getDataToLoad()
  if (!data || data.length === 0) return
  const aoa = data.map((row) => (row ? row.map((c) => (c != null ? c : '')) : []))
  const wb = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Sheet1')
  XLSX.writeFile(wb, `表格_${new Date().toISOString().slice(0, 10)}.xlsx`)
}
</script>

<style lang="scss" scoped>
.diy-excel {
  width: 100%;
  height: 100%;
  min-height: 200px;
  display: flex;
  flex-direction: column;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  overflow: hidden;
  background: #fff;
  box-sizing: border-box;
}

.diy-excel-toolbar {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  background: #f5f7fa;
  border-bottom: 1px solid #dcdfe6;
}

.diy-excel-info {
  color: #909399;
  font-size: 12px;
  margin-left: 8px;
}

.diy-excel-hot {
  flex: 1;
  min-height: 0;
  width: 100%;
  overflow: auto;
  :deep(.handsontable) {
    position: relative;
    z-index: 0;
  }
}
</style>
相关推荐
m0_706653234 小时前
CQE建模与更新:Creo与Excel深度结合应用
excel
升职佳兴4 小时前
Excel 学习笔记整理:常用操作、数据清洗与公式应用实战
笔记·学习·excel
青衫客365 小时前
Excel 模板解析实践:基于 Apache POI 的结构化 Excel 解析方案
java·excel
城数派14 小时前
全国各省/直辖市/自治区CLCD1985~2024年30米土地利用数据(分省裁剪)
数据分析·excel
yivifu17 小时前
使用VBA区分简体中文段落和繁体中文段落的方法
word·excel·vba
盘古工具1 天前
一刷即用:Excel格式刷的多种妙用场景
windows·excel
缺点内向1 天前
.NET办公自动化教程:Spire.XLS操作Excel——导出TXT格式详解
c#·自动化·.net·excel
herinspace1 天前
管家婆iShop如何调整商品成本?
服务器·数据库·学习·电脑·excel
SunnyDays10111 天前
使用 Python 轻松操控 Excel 网格线:隐藏、显示与自定义颜色
开发语言·python·excel