
Handsontable 数据绑定实战指南六大数据结构、数据装载 API 与空值语义全解析【免费下载链接】handsontableJavaScript Data Grid / Data Table with a Spreadsheet Look Feel. Works with React, Angular, and Vue. Supported by the Handsontable team ⚡项目地址: https://gitcode.com/gh_mirrors/ha/handsontable本篇技术指南系统讲解 HandsontableJavaScript/React/Angular/Vue 通用数据表格的数据绑定机制如何用数组的数组、数组的对象、函数式数据源等结构填充表格如何通过data配置项与loadData()/updateData()/updateSettings()等核心 API 装载和修改数据以及emptyValue空值语义与按引用绑定的底层原理。读完本文你将能够根据业务数据形态选择合适的绑定方案并在不改动数据源结构的前提下自由操作表格数据。兼容的数据结构总览Handsontable 接受三种主流数据源数组的数组适合网格化场景、数组的对象适合行对象化场景以及函数式数据源适合动态绑定。此外还支持列方向数据源、嵌套对象映射、自定义 schema 等进阶形态。所有示例代码均可在 binding-to-data 示例目录 中找到完整可运行版本。数组的数组Array of Arrays数组的数组AoA是面向网格化场景的首选尤其适合需要让终端用户自由操纵网格的场景例如插入列、删除行、装饰单元格等。它的每一行就是一行数据每个单元格直接对应数组中的一个元素import Handsontable from handsontable/base; import { registerAllModules } from handsontable/registry; // 注册 Handsontable 的全部模块。 registerAllModules(); const container document.querySelector(#example1); const data [ [, Tesla, Nissan, Toyota, Honda, Mazda, Ford], [2017, 110, 11, 12, 13, 15, 16], [2018, 10, 11, 12, 13, 15, 16], [2019, 10, 11, 12, 13, 15, 16], [2020, 10, 11, 12, 13, 15, 16], [2021, 10, 11, 12, 13, 15, 16], ]; new Handsontable(container, { data, startRows: 5, startCols: 5, height: auto, width: auto, colHeaders: true, autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, });要点说明该示例对应的源码位于 example1.js在 React/Angular/Vue 中分别对应 react/example1.jsx、angular/example1.ts、vue/example1.vue。registerAllModules()一次性注册所有功能模块生产环境可按需引入以减小包体积。colHeaders: true用第一行数据作为列头autoWrapRow/autoWrapCol控制键盘导航的换行行为。数组的数组 选择性显示列有时你的数据源列很多但只想展示其中一部分。此时可以通过columns配置项做选择性投影完全相同的data源跳过Tesla列原数组索引 1只渲染其余列new Handsontable(container, { data, colHeaders: true, height: auto, width: auto, columns: [ { data: 0 }, // 跳过第二列原数组索引 1 { data: 2 }, { data: 3 }, { data: 4 }, { data: 5 }, { data: 6 }, ], autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, });这里columns数组的顺序就是表格的显示顺序data字段指向原始数组的索引对于 AoA 是数字索引对于对象数组则是属性名。完整代码见 example2.js。数组的对象Array of Objects当数据天然以行对象形态存在例如 API 返回的实体列表时数组的对象AoO是最自然的选择const data [ { id: 1, name: Ted Right, address: }, { id: 2, name: Frank Honest, address: }, { id: 3, name: Joan Well, address: }, { id: 4, name: Gail Polite, address: }, { id: 5, name: Michael Fair, address: }, ]; new Handsontable(container, { data, colHeaders: true, height: auto, width: auto, autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, });完整代码见 example3.js。如果数据以JSON 字符串形式到达例如来自 API 响应先用JSON.parse()解析为对象数组再传给 Handsontable——解析后的结构与上面的对象数组完全一致const data JSON.parse(jsonString);若想将对象数组数据源与自定义单元格渲染器、复选框单元格类型、货币格式的数字列组合使用参见内置单元格类型示例与数字格式化章节。数组的对象 columns 为函数动态绑定将columns配置为函数是动态绑定的最佳实践——当列结构随索引动态变化、或需要在不同列应用不同配置时函数形式比静态数组更灵活。该函数接收视觉列索引column返回该列的配置对象const data [ { id: 1, name: { first: Ted, last: Right }, address: }, { id: 2, address: }, { id: 3, name: { first: Joan, last: Well }, address: }, ]; new Handsontable(container, { data, colHeaders: true, height: auto, width: auto, columns(column) { switch (column) { case 0: return { data: id }; case 1: return { data: name.first }; // 点号路径直接映射嵌套属性 case 2: return { data: name.last }; case 3: return { data: address }; default: return {}; } }, autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, });从源码结构看columns的data字段支持name.first这类点号路径内部会解析为对嵌套对象的读写示例中第 2 行缺少name对象表格仍能正常渲染空单元格。完整代码见 example4.js。数组的对象 列映射嵌套对象当数据包含嵌套对象时用静态columns数组做列映射是最直观的方案——每个元素显式声明绑定到哪个属性路径new Handsontable(container, { data, colHeaders: true, height: auto, width: auto, columns: [{ data: id }, { data: name.first }, { data: name.last }, { data: address }], autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, });该写法与columns 为函数形态在效果上等价都通过data路径绑定区别在于静态数组适合列结构固定、函数适合需要按索引动态计算的场景。完整代码见 example5.js。数组的对象 自定义数据 schema使用对象数据绑定时Handsontable 需要知道新增一行时该创建什么结构。如果数据源至少有一行它会根据第一行自动推断结构但如果从空数据源起步就必须显式提供dataSchema选项作为新增行的模板new Handsontable(container, { data: [], // 空数据源 dataSchema: { id: null, name: { first: null, last: null }, address: null }, startRows: 5, startCols: 4, colHeaders: [ID, First Name, Last Name, Address], height: auto, width: auto, columns: [{ data: id }, { data: name.first }, { data: name.last }, { data: address }], autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, });要点dataSchema的结构必须与columns中声明的属性路径一一对应含嵌套name.first/name.last否则新增行将缺少可写字段。dataSchema的默认值定义位于 metaSchema.ts其 JSDoc 示例正是上述结构。dataSchema也可以是一个构造函数见下一节此时 Handsontable 会用new语义创建实例。完整代码见 example6.js。函数数据源与 schema封装对象当dataSchema是一个不直接暴露成员变量的对象构造函数时可以为每个columns项的data成员指定函数由函数负责读写封装对象的私有属性。下面的model()工厂函数用闭包把数据藏在_priv中只通过attr()方法暴露读写接口而property(attr)返回一个 getter/setter 函数供columns绑定new Handsontable(container, { data: [ model({ id: 1, name: Ted Right, address: }), model({ id: 2, name: Frank Honest, address: }), model({ id: 3, name: Joan Well, address: }), model({ id: 4, name: Gail Polite, address: }), model({ id: 5, name: Michael Fair, address: }), ], dataSchema: model, height: auto, width: auto, colHeaders: [ID, Name, Address], columns: [{ data: property(id) }, { data: property(name) }, { data: property(address) }], autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, }); function model(person) { const _pub { id: undefined, name: undefined, address: undefined, attr: () _pub, }; const _priv {}; for (const prop in person) { if (person.hasOwnProperty(prop)) { _priv[prop] person[prop]; } } _pub.attr (attr, val) { if (typeof val undefined) { // GET返回私有属性 return _priv[attr]; } // SET写入私有属性 _priv[attr] val; return _pub; }; return _pub; } function property(attr) { return (row, value) row.attr(attr, value); }完整代码见 example7.js。这种模式常见于需要封装数据访问逻辑如日志、校验、ORM 风格对象的场景。列方向数据源Column-oriented dataHandsontable 按行读取数据。如果数据以每列一个数组的形态到达例如某些后端统计接口你无需维护一份转置副本——只需把每个列的data选项指向它所属的数组。Handsontable 无法自行区分行列方向[[a, b], [c, d]]既可以理解为两行也可以理解为两列因此必须通过columns显式声明你的意图。data选项仍要求每行一个条目所以用一个小对象记录自身的索引并通过dataSchema源源不断地创建新对象const source [ [a, b], // 列 A [c, d, e], // 列 B ]; const rowCount source.reduce((max, column) Math.max(max, column.length), 0); const restamp rows { rows.forEach((row, index) { row.index index; }); return rows; }; // Handsontable 会就地 splice 这个数组因此它始终是行顺序的活视图。 const rows restamp(Array.from({ length: rowCount }, () ({ index: 0 }))); // 必须用普通函数而非箭头函数arguments.length 用于区分读与写。 const accessor columnIndex function(row, value) { if (arguments.length 1) { // 行可能缺失网格测量尺寸或重放撤销步骤时。 return row ? source[columnIndex][row.index] ?? null : null; } if (row) { source[columnIndex][row.index] value; } }; const settings { data: rows, columns: source.map((column, columnIndex) ({ data: accessor(columnIndex) })), dataSchema: () ({ index: -1 }), };按你所用框架的方式把settings传给 Handsontable——作为构造函数的第二个参数或作为组件HotTable的 settings。此时网格按各列的跨度渲染行长度不等的列在较短列底部留下空单元格一次编辑会直接写回source不存在任何副本会滞后。不过这只是读写已有单元格的全部配置新增和删除行还需要两个额外的钩子。网格新建的行在列数组里还没有槽位其行对象仍持有dataSchema的标记值-1于是 accessor 会把输入该行的值写到source[columnIndex][-1]——这是数组对象上的一个属性而不是数组元素。网格能显示该值因为 accessor 读回同一个槽位但该值永远不会进入你的数据而且网格创建的每一行都共享这一个槽位。所以只要存在任何创建行的途径——右键菜单、minSpareRows或alter()——都要通过afterCreateRow与afterRemoveRow保持列数组同步。两个钩子报告的都是视觉visual行索引而列数组是物理physical的因此要先换算再 splice。对于插入新行对象仍带dataSchema标记它在rows中的位置就是物理索引。对于删除使用钩子的physicalRows参数并先 splice 索引最大的行const settings { // ... 上面的选项 afterCreateRow(index, amount) { const at rows.findIndex(row row.index -1); source.forEach(column { column.splice(at -1 ? index : at, 0, ...new Array(amount).fill(null)); }); restamp(rows); // 用 this.render() 而不是保存实例的变量配合 minSpareRows 时 // 此钩子在 Handsontable 初始化完成之前就会执行此时变量还未赋值。 this.render(); }, afterRemoveRow(index, amount, physicalRows) { [...physicalRows].sort((a, b) b - a).forEach(row { source.forEach(column column.splice(row, 1)); }); restamp(rows); this.render(); }, };关于该方案还有三点须知getSourceData()返回行对象的副本因此无法通过它重新 stamp 行索引——请始终持有作为data传入的原数组。撤销删除行会通过列 accessor 恢复被删的单元格值无需自行记录。这些值是在行重新回到网格之后写入的因此上面的afterCreateRow钩子必须先为恢复的行在列数组中分配槽位。columns固定了列数量所以alter()无法插入新列。要加列请向sourcepush 一个新数组并用新的columns数组调用updateSettings()。在钩子中识别变更的列使用函数数据源时每个列的data选项是一个 getter/setter 函数。在beforeChange与afterChange中每个变更元组的第二个元素是prop。对函数式列而言prop就是那个 accessor函数——既不是属性名也不是列索引。在 TypeScript 中该 accessor 的类型被导出为ColumnDataGetterSetterFunction可参见TypeScript 类型。该类型在 handsontable/src/settings.ts 中定义并在 core/types.ts 等核心类型文件中使用。要定位到底是哪一列发生了变化对prop调用propToCol()afterChange(changes, source) { if (source loadData || !changes) { return; } changes.forEach(([row, prop, oldValue, newValue]) { const column this.propToCol(prop); // column 即视觉列索引 }); }你也可以把prop与在columns中定义的 accessor 函数直接比较——前提是保留了对它的引用。注意与beforeValidate和afterValidate不同变更钩子把 accessor 函数作为prop传入。网格需要这个引用才能把值写回你的数据模型。propToCol()的实现位于 dataMap.ts内部通过propToColCache缓存物理列索引以提升性能见 dataMap.ts。关于变更钩子的更多内容参见事件与钩子。不提供数据No data默认情况下如果不提供任何数据Handsontable 会渲染一个空的 5×5 网格example9.jsnew Handsontable(container, { autoWrapRow: true, autoWrapCol: true, height: auto, licenseKey: non-commercial-and-evaluation, });要改变默认渲染的行数或列数使用startRows与startCols选项。从源码看metaSchema.ts这两个选项仅在构造函数中且未提供data时生效当与minSpareRows/minSpareCols同时设置时起始行列会计入备用的余量计算。数据操纵 API 方法理解按引用绑定Handsontable 通过引用而非值来绑定数据源它不会复制输入数据集而是依赖 JavaScript 直接操作对象。因此在网格中录入的任何数据都会修改原始数据源。需要注意的是这一点只适用于单元格值编辑。结构性变化如移动、排序、过滤行不会重排源数组——Handsontable 将行顺序以索引元数据的形式另行存储详见行移动的数据模型行为。提示Handsontable 以引用方式初始化源数据但你不应依赖这一点。例如不要通过保存的引用在外部直接改动输入数据集中的值——某些数据处理机制并未针对这种外部改动做好准备。避免该问题的最佳实践是在把数据传给网格之前先拷贝一份要从 Handsontable 外部修改数据请使用 API 方法。例如调用setDataAtCell()后变更会立即显示在屏幕上example10.jsconst hot new Handsontable(container, settings); hot.setDataAtCell(0, 1, Ford);向 Handsontable 注入数据有多个途径下面逐一介绍最常用的几种。data配置选项通常你希望初始化时就带数据不提供的话表格会渲染空的 5×5 网格。最简单的做法是把数据数组作为data选项放进初始化配置对象const hot new Handsontable(container, { data: newDataset, // ... 其他配置选项 });各框架封装组件的等价写法React把数据数组作为HotTable的dataprop 传入HotTable data{newDataset} /Angular把数据数组作为HotTable的dataInput()传入import { GridSettings } from handsontable/angular-wrapper; data newDataset, gridSettings: GridSettings {};hot-table [data]data [settings]gridSettings /Vue 3把数据数组放进绑定到HotTable的hotSettingsref 中script setup import { ref } from vue; import { HotTable } from handsontable/vue3; const hotSettings ref({ data: newDataset, // ... 其他配置选项 }); /script template HotTable :settingshotSettings / /template数据装载 API 方法要在已初始化的实例中整体替换数据可以使用以下数据装载方法之一loadData()——用参数提供的数据集替换 Handsontable 当前数据。注意自版本12.0.0起该方法会重置表格的配置选项和索引映射器信息因此初始化之后在表格上做的部分工作如单元格格式、行列顺序状态可能会丢失。hot.loadData(newDataset);从源码看loadData()会执行metaManager.clearCellsCache()与instance.initIndexMappers()见 core.ts这正是重置单元格状态与行列索引映射的实现依据。updateData()——同样用参数数据集替换当前数据但不会重置配置选项和/或索引映射器信息因此可以安全地只换数据、保留其余状态hot.updateData(newDataset);从源码看updateData()保留单元格状态格式化、readOnly、行状态行顺序与列状态列顺序并触发beforeUpdateData/afterUpdateData/afterChange钩子见 core.ts。该方法自11.1.0起可用。updateSettings()——更新表格配置同时也能用来替换数据。自12.0.0起它在内部通过updateData()完成数据替换唯一的例外是初始化期间的那次自动调用那次走的是loadData()hot.updateSettings({ data: newDataset, // ... 其他配置选项 });Angular 封装还提供了内置机制当Input data的变化被ngOnChanges检测到时wrapper 会以Input提供的数据调用hot.updateData(newDataset)。要使用 Handsontable API需要拿到实例引用React 中读取HotTable组件引用的hotInstance属性参见实例方法Angular 中读取HotTableComponent的hotInstance参见实例访问Vue 中给HotTable加模板ref后读取其hotInstance属性。数据修改 API 方法如果只想修改数据集的一部分以下是常用的方法。需要留意的是每一次被接受的变更——哪怕只是一个单元格——都会让 Handsontable 重渲染所有可见单元格。当你要连续调用多个此类方法时请用batch()把它们包起来让网格只渲染一次。setDataAtCell()——替换单个单元格的数据或执行一系列单单元格替换// 用给定值替换 (0, 2) 视觉坐标处的单元格内容 // 0 为视觉行索引2 为视觉列索引。 hot.setDataAtCell(0, 2, New Value); // 用给定值替换 (0,2)、(1,2) 和 (2,2) 处的单元格。 const changes [ [0, 2, New Value], [1, 2, Different Value], [2, 2, Third Replaced Value], ]; hot.setDataAtCell(changes);setDataAtRowProp()——与setDataAtCell()类似但允许用视觉行索引 数据行属性名来定位单元格特别适合数组的对象类型// 替换 (0, title) 处单元格0 为视觉行索引title 为行对象属性。 hot.setDataAtRowProp(0, title, New Value); // 替换第一行中 props 为 id、firstName、lastName 的单元格。 const changes [ [0, id, 22], [0, firstName, John], [0, lastName, Doe], ]; hot.setDataAtRowProp(changes);setSourceDataAtCell()——显示数据的坐标可能与内部存储方式不同需要更直接地定位单元格时使用。此方法的row与columns/prop参数表示**物理physical**索引。物理与视觉索引的关系参见理解数据与索引// 替换 (0, 2) 处单元格0 为物理行索引2 为物理列索引。 hot.setSourceDataAtCell(0, 2, New Value); // 替换 (0, title) 处单元格0 为物理行索引title 为行属性。 hot.setSourceDataAtCell(0, title, New Value); // 替换第一物理行中 props 为 id、firstName、lastName 的单元格。 const changes [ [0, id, 22], [0, firstName, John], [0, lastName, Doe], ]; hot.setSourceDataAtCell(changes);populateFromArray()——通过提供起始可选结束坐标和二维数据数组替换数据集中的一块区域const newValues [ [A, B, C], [D, E, F] ]; // 用 newValues 替换从 (1, 1) 到 (2, 3) 的视觉坐标区域。 hot.populateFromArray(1, 1, newValues); // 替换从 (1, 1) 到 (2, 2) 的区域落在该范围之外的值会被忽略。 hot.populateFromArray(1, 1, newValues, 2, 2);注意populateFromArray()无法修改只读单元格。空单元格的值emptyValue 语义空单元格可以存放null或空字符串两种值。它们在网格里看起来一样但在数据源中是不同的值一旦数据离开网格差异就显现在numeric、date或time列中空字符串是一个本该是数字或日期的地方却是字符串对数据库而言也不等于NULL。默认情况下清空方式决定了存入的值清空方式存入的值按下Delete或Backspacenull用null调用setDataAtCell()null在范围内填充空白单元格null合并单元格覆盖数据null清空单元格编辑器并确认粘贴空白单元格把emptyValue设为null可以让以上所有路径都统一存入nullconst hot new Handsontable(container, { data: getData(), emptyValue: null, });你可以对整张网格设置也可以只对空字符串不合语义的列单独设置const hot new Handsontable(container, { data: getData(), columns: [ // 文本列继续存空字符串 { data: name }, // 以下列在清空时存 null { data: amount, type: numeric, emptyValue: null }, { data: due, type: date, emptyValue: null }, ], });该选项只改变清空后的单元格存什么。0或false是真实值不是空单元格永远不会受影响。emptyValue的完整取值语义为默认、null、undefined视为未设置、其他任意值原样存储定义在 metaSchema.ts该映射是单向的只把空字符串改写成你设置的值反向不成立原本就存null的路径不受影响。某些列配置本身赋予了含义此时会保持原样在checkbox列中用作checkedTemplate或uncheckedTemplate的是该列定义的两种状态之一不是空单元格。在autocomplete或dropdown列中列入source的是一个可选项。两者都继续存。提示只有数组形态的source才会做这种检查。函数形态的source通过回调回答而值在回调运行之前就已存储因此它返回的空白选项不会被察觉emptyValue会像其他列一样作用于该列。提示打开单元格编辑器但不输入任何内容直接确认永远不改变单元格——无论emptyValue设成什么。此时不写入任何内容也不会触发afterChange钩子无论单元格是否配置了validator都是如此。因为不存在变更beforeChange也不会运行所以取消变更的处理器在这种空确认下无事可做。不过已配置校验的单元格在该确认时仍然会被校验因此allowInvalid行为不变非法值依旧会保持编辑器打开——校验器直接针对单元格的存储值运行这正是无需写入即可触发校验的原因。提示从网格外部粘贴无法保留null与的区别。剪贴板只承载文本或 HTML两者都无法把单元格标记为null因此粘贴的空白单元格与其他清空路径一样遵循emptyValue设置。使用数据副本Working with a copy of data处理给 Handsontable 的数据时最佳实践是在装载前先克隆数据源以避免按引用绑定带来的意外修改。可以用structuredClone(data)、传统的JSON.parse(JSON.stringify(data))或任何其他深拷贝函数import Handsontable from handsontable/base; import { registerAllModules } from handsontable/registry; registerAllModules(); const container document.querySelector(#example11); const data [ [, Tesla, Nissan, Toyota, Honda, Mazda, Ford], [2017, 10, 11, 12, 13, 15, 16], // ... 其余行 ]; new Handsontable(container, { data: structuredClone(data), // 装载副本保护原始数据 height: auto, autoWrapRow: true, autoWrapCol: true, licenseKey: non-commercial-and-evaluation, });完整示例见 example11.js。服务端数据加载Server-side loading当完整数据集存放在服务端时应使用dataProvider而不是绑定一个巨大的本地数组——这样可以避免一次性把全部数据载入内存实现按需拉取。具体方案参见服务端数据指南。关联 API 参考配置选项data、dataProvider、dataSchema、emptyValue。核心方法alter()、clear()、getData()、getDataAtCell()、getDataAtCol()、getDataAtProp()、getDataAtRow()、getDataAtRowProp()、getSchema()、getSourceData()、getSourceDataArray()、getSourceDataAtCell()、getSourceDataAtCol()、getSourceDataAtRow()、loadData()、populateFromArray()、setDataAtCell()、setDataAtRowProp()、setSourceDataAtCell()、updateData()、updateSettings()。钩子afterCellMetaReset、afterChange、afterLoadData、afterSetDataAtCell、afterSetDataAtRowProp、afterSetSourceDataAtCell、afterUpdateData、afterUpdateSettings、beforeLoadData、beforeUpdateData、modifyData、modifyRowData、modifySourceData。本节要点回顾Handsontable 接受三种主要数据结构数组的数组、数组的对象以及函数式数据源。初始数据集可以通过data选项传入也可以作为HotTable组件的 prop/slot 传入。loadData()用于替换数据并重置配置updateData()用于替换数据但保留设置updateSettings({ data })用于在更新其他选项的同时更新数据。Handsontable 通过引用绑定数据。传入前请拷贝数据集避免意外的原地修改。对于存放在服务端的大数据集请使用dataProvider而不是本地数组。emptyValue决定空单元格存储null还是应按数据出口数据库、后端的要求统一配置。下一步配置选项——学习如何配置网格的每个方面。保存数据——把变更持久化到后端或本地存储。【免费下载链接】handsontableJavaScript Data Grid / Data Table with a Spreadsheet Look Feel. Works with React, Angular, and Vue. Supported by the Handsontable team ⚡项目地址: https://gitcode.com/gh_mirrors/ha/handsontable创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考