ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

el-table实现Excel式方向键移动光标:单元格高亮与滚动跟随实战

el-table实现Excel式方向键移动光标:单元格高亮与滚动跟随实战 做后台系统最常被运营同事吐槽的一件事就是表格数据一多光靠鼠标点来点去特别费劲。之前接了一个数据审核模块的需求几十行数据要逐条看、逐条改用户提了一个很自然的想法——“能不能像Excel那样用上下左右键来回移动光标”el-table作为Element UI里使用频率最高的表格组件默认并没有提供键盘方向键导航这件事只能自己动手。我在这套方案里实现了方向键移动光标、单元格高亮、自动滚动还顺带处理了合并单元格和滚动条错位的坑今天把这套实现完整梳理一遍给遇到同样需求的朋友做个参考。1. 为什么el-table需要自己实现方向键导航1.1 默认行为分析highlight-current-row与内置键盘能力在 Element UI 里表格组件默认是支持“当前行”这回事的。把 highlight-current-row 打开鼠标点击行会有背景色高亮而且在 2.x 版本的底层实现里配合 row-key 后键盘的向上/向下键其实是可以切换当前行的。这个内置行为我在好多项目里都用过但它有两个天然限制第一它只支持上下行切换左右键一概不管第二高亮的最小单位是“整行”不是单元格。可实际录入场景里运营同事要的是“光标一格一格走”像 Excel 一样上下左右都能到还能看明白现在的格子是哪个。所以内置能力只能覆盖一丢丢需求剩下的得自己补。Element Plus 的情况稍微好一点新版在某些条件下对单元格导航做了增强但依旧没有做成开箱即用的“方向键全键盘导航”。与其去翻版本迭代碰运气不如直接自己封装一层一来行为完全可控二来在任何版本上都稳定不用跟着组件库的更新提心吊胆。1.2 需求拆解上下左右键移动光标到底要解决什么把“方向键移动光标”翻译成技术语言其实就三件事第一要有一个能持续变化的“光标坐标”。我用 rowIndex 代表行位置、colIndex 代表列位置按一次方向键坐标做一次加减法再校验一下边界这是整个功能的发动机。第二要有一个能看得见的高亮反馈。坐标变了界面上就得有响应。用一个 cell-style 函数根据坐标返回样式是最轻量的做法不会影响原有行高亮还能顺便做出“蓝色边框淡蓝背景”这种类似 Excel 的效果。第三光标跑出可视区域时自动滚动跟随。表格数据一旦超过容器高度用户不可能每按一次键都去拖动滚动条程序要自己把目标单元格滚出来。这三件事中间还有一层隐藏需求焦点管理。方向键这种按键本身会被浏览器用来滚动页面、移动输入框光标如果不管焦点在哪里键盘事件就会串味儿。比如焦点在某个按钮上按上下键可能变成切换按钮焦点焦点在输入框里按左右键会移动文本光标这些都要在代码里主动拦住。1.3 技术选型思路不动组件源码靠事件监听与状态同步我一开始也想过直接改 Element UI 源码或者 fork 一份表格组件后来果断放弃。原因很简单表格组件的内部实现版本差异太大Vue2 的 Element UI 和 Vue3 的 Element Plus 连滚动容器 class 都不一样直接改源码意味着后续升级组件库就全是冲突。更稳的做法是“外层包一层”在表格外层加一个可聚焦的容器键盘事件挂在这个容器上用一份 JSON 坐标驱动 cell-style 和 setCurrentRow。这样组件库怎么升级都不影响逻辑也被压缩在一个 hooks 文件里可以挪到任意项目复用。这个方案在 Element UI、Element Plus、甚至 ant-design-vue 的 a-table 上都跑通过区别只是选择器和 API 名要改一下。2. 核心实现el-table方向键移动光标的完整方案2.1 如何在表格上挂载键盘事件键盘事件不能直接扔给 el-table因为 el-table 没有内部焦点区域事件到达不了。我的做法是给外层包一层 div加上 tabindex0让它变成可聚焦元素。template div reftableWrapper tabindex0 classkeyboard-table keydownhandleKeydown clickhandleWrapperClick el-table reftableRef :datatableData :cell-stylecellStyle :span-methodspanMethod highlight-current-row row-keyid row-clickhandleRowClick el-table-column v-forcol in columns :keycol.prop :propcol.prop :labelcol.label / /el-table /div /template有个细节要提醒tabindex 加了之后浏览器会在容器四周画出焦点框记得在样式中把 outline 干掉。不干也不影响功能就是丑尤其表格有边框时那个焦点虚线框特别突兀。.keyboard-table:focus { outline: none; }这层 div 承担了“焦点接收器”的角色。用户点击表格任意位置时焦点自然落到外层容器上如果焦点跑到了别的区域方向键就由其它元素处理不会引起误触发。我在点击事件里也补了一个 focus 调用确保点击空白区域时焦点不会丢。handleWrapperClick() { this.$refs.tableWrapper.focus() }2.2 行列索引维护与方向键坐标计算光标位置数据我习惯放 data 里结构是 { rowIndex: 0, colIndex: 0 }对象比两个散装字段好维护后面做函数传参也方便。data() { return { tableData: [], columns: [ { prop: name, label: 姓名 }, { prop: age, label: 年龄 }, { prop: city, label: 城市 } ], cursor: { rowIndex: 0, colIndex: 0 } } }按键处理的逻辑很简单就是四个方向的分支判断再统一做边界校验methods: { handleKeydown(event) { const keyMap { ArrowUp: { rowOffset: -1, colOffset: 0 }, ArrowDown: { rowOffset: 1, colOffset: 0 }, ArrowLeft: { rowOffset: 0, colOffset: -1 }, ArrowRight: { rowOffset: 0, colOffset: 1 } } const offset keyMap[event.key] if (!offset) return // 焦点在输入控件里时不拦截方向键 const activeEl document.activeElement if (activeEl (activeEl.tagName INPUT || activeEl.tagName TEXTAREA)) { return } event.preventDefault() let { rowIndex, colIndex } this.cursor const nextRow rowIndex offset.rowOffset const nextCol colIndex offset.colOffset // 边界校验 if (nextRow 0 || nextRow this.tableData.length) return if (nextCol 0 || nextCol this.columns.length) return this.updateCursor(nextRow, nextCol) }, updateCursor(rowIndex, colIndex) { this.cursor.rowIndex rowIndex this.cursor.colIndex colIndex const row this.tableData[rowIndex] this.$refs.tableRef.setCurrentRow(row) this.scrollToCell(rowIndex, colIndex) } }这里有两段代码值得细说。第一段是 keyMap 的写法把方向键和坐标增量映射成一张表比 switch 清晰得多后面要支持“Ctrl方向键直接跳首列/末列”也好扩展。第二段是 activeEl 的过滤不加这段表格里的输入框只要获得焦点按方向键就会既移动输入框里的文本光标又移动表格光标两件事打架用户体验直接崩溃。边界校验我用的是“到边界就什么都不做”这也是最符合直觉的方案。想做成循环也可以比如第一列再按左跳到最后一列第一行再按上跳到最后一行无非是把越界坐标取模一次但大多数业务场景并不需要这种“轮回”行为反而容易让用户摸不清位置。2.3 单元格高亮样式与当前行状态同步坐标更新后要高亮当前格子。我优先用 cell-style 函数做这件事它会在每个单元格渲染时调用拿到的参数里带着 rowIndex 和 columnIndex和 cursor 一比就能决定要不要加样式cellStyle({ rowIndex, columnIndex }) { if (rowIndex this.cursor.rowIndex columnIndex this.cursor.colIndex) { return { backgroundColor: #e6f7ff, boxShadow: inset 0 0 0 2px #1890ff } } return {} }boxShadow 用 inset 模拟内描边是为了不被外层的 border 盖住。之前我试过直接 outline在部分浏览器里 outline 会跑到 td 的 border 上面视觉上有点歪inset 阴影更稳。加 inset 2px 的蓝色以后当前格子的四边会有一条明显的内边框即使表格本身有高亮行色也不冲突两个高亮叠在一起时仍然能分清“哪个是光标格子”。同时我给 el-table 打开了 highlight-current-row并且在 updateCursor 里调了 setCurrentRow这样当前行的状态是跟 Element 组件内部同步的后续如果再监听 row-click、selection-change 之类的事件不会出现状态错位。如果你不关心整行状态这一行 setCurrentRow 也可以去掉只靠 cell-style 高亮目标格子就够了。3. 进阶优化滚动跟随与体验补全3.1 光标移出可视区后自动滚动表格方向键移动光标如果不处理滚动用户按到屏幕边缘就觉得“卡住了”——其实坐标还在变只是目标格子被滚出了视野。要解决这个问题得知道当前滚动容器是谁以及目标格子在视口里的位置关系。el-table 的滚动容器是 .el-table__body-wrapper目标格子通过 querySelector 找到之后用 getBoundingClientRect 拿它和滚动容器的上下左右边界对比哪边超出就补哪边的 scrollTop / scrollLeftscrollToCell(rowIndex, colIndex) { this.$nextTick(() { const bodyWrapper this.$refs.tableRef.$el.querySelector(.el-table__body-wrapper) if (!bodyWrapper) return const rows bodyWrapper.querySelectorAll(.el-table__row) const targetRow rows[rowIndex] if (!targetRow) return const targetCell targetRow.querySelectorAll(td)[colIndex] if (!targetCell) return const wrapperRect bodyWrapper.getBoundingClientRect() const cellRect targetCell.getBoundingClientRect() if (cellRect.top wrapperRect.top) { bodyWrapper.scrollTop cellRect.top - wrapperRect.top } else if (cellRect.bottom wrapperRect.bottom) { bodyWrapper.scrollTop cellRect.bottom - wrapperRect.bottom } if (cellRect.left wrapperRect.left) { bodyWrapper.scrollLeft cellRect.left - wrapperRect.left } else if (cellRect.right wrapperRect.right) { bodyWrapper.scrollLeft cellRect.right - wrapperRect.right } }) }用 getBoundingClientRect 的差值算 scroll而不是直接赋值绝对位置能避免目标元素在多层滚动容器里因 offsetParent 不同而产生的计算错误。差值有正有负直接加给 scrollTop / scrollLeft 就行浏览器会自动把溢出值收敛到合法范围。这个方法还有个好处不管表格外面还套了多少层 overflow 容器目标单元格相对于滚动容器视口的坐标计算始终是准的因为 getBoundingClientRect 返回的都是相对浏览器视口的位置差值不受嵌套层数影响。3.2 边界情况固定列、禁用表格和大量数据渲染项目里一旦用了 fixed 固定列滚动容器就不是一个了。Element 会生成 .el-table__fixed-body-wrapper 和主表体两个容器方向键横向移动时目标单元格可能在固定列区域也可能在主区域。我实测下来最省心的做法是滚动跟随只用主表体的 bodyWrapper 做判断。因为固定列是漂浮在主表体上方的视图它的滚动其实是跟着主表体 scrollLeft 变化的方向键移动后主表体滚到位固定列会同步内容。如果你的列宽很大、固定列内容很多再单独处理 fixedBodyWrapper 的 scrollTop 也不迟。遇到表格数据是异步加载、还没渲染完的情况scrollToCell 里的 this.$nextTick 会保证 DOM 存在后再查节点。再极端一点如果你一次性渲染了上千行方向键按下后频繁 querySelector 和 getBoundingClientRect 会有一定性能开销但实际测试几千行内体感完全可以接受真到那种万行级虚拟滚动场景el-table 本身也不合适了建议直接换虚拟表格方案。除此之外还有一类情况是表格数据为空。按下方向键时 tableData.length 为 0updateCursor 里拿不到 rowsetCurrentRow 也不会报错因为数据为空时组件内部直接忽略了。我在 handleKeydown 的边界判断里已经覆盖了 nextRow this.tableData.length 的分支空数据时自然 return所以不用担心报错。3.3 可编辑单元格的联动聚焦与数据绑定方向键导航最典型的落地场景是表格录入。比如单元格里放 input用户输完一个格子按右焦点应该自动落到下一个格子的输入框里而不是停留在当前 input 上。我通常在 updateCursor 之后加一个 focusEditableCell 方法focusEditableCell(rowIndex, colIndex) { this.$nextTick(() { const column this.columns[colIndex] if (!column.editable) return const bodyWrapper this.$refs.tableRef.$el.querySelector(.el-table__body-wrapper) const rows bodyWrapper.querySelectorAll(.el-table__row) const targetRow rows[rowIndex] if (!targetRow) return const targetTd targetRow.querySelectorAll(td)[colIndex] if (!targetTd) return const input targetTd.querySelector(input, textarea, select) input input.focus() }) }columns 里给可编辑列加一个 editable 标记只有标记列才执行聚焦。聚焦前用 $nextTick 等 DOM 更新完成不然下拉框组件那种异步渲染的输入框还不在页面上querySelector 会落空。这里有个反直觉的坑焦点一旦进入 input下一次方向键事件就会先被 input 消费掉于是 2.2 里的 activeEl 过滤逻辑又起作用了。要么过滤掉输入控件里方向键的移动行为让用户先按 Enter 确认再移动要么干脆允许方向键在输入框之间跳转但在跳转前先触发 input 的 blur把数据写回表格。两种做法对应不同的交互规范没有绝对对错。我做数据审核的时候选的是后者方向键在输入框里按下时先触发当前输入框 change 事件把值写进表格然后移动光标到下一个格子并聚焦。这样用户连续录入完全不需要碰鼠标效率提升非常明显。4. el-table相关常见坑点排查实录4.1 为什么el-table会出现两条横向滚动条及宽度对齐问题很多朋友发现表格横向内容一多表体和表头之间会冒出一条分割线似的滚动条或者整张表外面套了个滚动容器后出现了内外两条横向滚动条。这里得分两种情形。第一种是表格本身没有固定列但外层父容器给了 overflow: auto于是父容器产生一条滚动条el-table 自己又因为横向溢出产生一条滚动条。这个好解把父容器的 overflow 去掉或者给 el-table 设置固定宽度 / min-width让它的滚动条自己管自己。需要注意的是el-table 的表头和表体共享一个横向滚动容器我上面写的 scrollToCell 每次只操作 bodyWrapper 的 scrollLeft表头会通过内部机制同步不需要额外设置。第二种是固定列导致的“滚动条错位”。Element UI 在固定列场景下主表体底部滚动条的宽度会被固定列遮挡看起来像滚动条少了一截或者表头右侧多出一个空白区。常见修复方式是把 bodyWrapper 里滚动条轨道的宽度统一::v-deep .el-table__body-wrapper::-webkit-scrollbar { height: 8px; } ::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 4px; }这和方向键导航的关系在于如果你移动光标时同步了 scrollLeft但表头的滚动条位置没有跟齐就会看到高亮单元格和表头列错位。我在实际项目中会把表头和表体的滚动同步事件绑定在 bodyWrapper 的 scroll 上再手动校正表头 scrollLeft不过绝大多数情况下 Element 内部已经处理了只有自定义滚动条样式时才会踩到。4.2 .el-table::before修改样式不生效怎么解问这个问题的多半是想去掉 el-table 顶部那条横线或者给表格加边框时被那条横线挡住了。Element 用 .el-table::before 在表格顶部画了一条 1px 的线用来充当表头上边框。不生效的原因九成是 scoped 样式。Vue 单文件组件里的 scoped 样式会给选择器加上 data 属性而 .el-table::before 里的 ::before 伪元素选择器在 scoped 处理下没法精确匹配到 Element 内部的根节点于是样式被施加上去也会因为优先级不足被覆盖。解决思路是穿透::v-deep .el-table::before { height: 0 !important; z-index: 0; }如果这样还没生效检查是不是有其它样式文件在组件样式之后引入把高度又改回去了。Element 本身并没有给 ::before 设 height: 0 或很高的优先级理论上一个带 !important 的规则一定能打赢它。还有一类场景是用了 Element Plus 的 CSS 变量.el-table { --el-table-border-color: transparent; }设置这个变量可以让表格边框统一变透明不一定要和 ::before 死磕。但注意这只在 Element Plus 里生效Element UI 没有这套 CSS 变量体系还是老老实实用高度置空。4.3 合并单元格后方向键导航的容错处理el-table 开启 span-method 合并单元格之后rowIndex 和 colIndex 仍然按原始行列数递增但是 DOM 里有一部分格子其实被合并区域“吞掉”了。这时按方向键目标格子的 td 是不存在的querySelector 会拿到 undefined高亮和滚动跟随都失效。我的处理方式是维护一个“可导航坐标集”。具体做法是在拿到合并规则后写一个函数把每一行每一列的 span 信息解析出来凡是 rowSpan 或 colSpan 大于 1 的只保留合并区域的起始坐标其它被覆盖的坐标从导航集里剔除。buildNavIndexes() { const navIndexes [] const data this.tableData const columns this.columns for (let rowIndex 0; rowIndex data.length; rowIndex) { for (let colIndex 0; colIndex columns.length; colIndex) { const span this.spanMethod({ row: data[rowIndex], column: columns[colIndex], rowIndex, colIndex }) const rowspan span ? span.rowspan : 1 const colspan span ? span.colspan : 1 if (rowspan 1 colspan 1) { navIndexes.push(${rowIndex}-${colIndex}) } } } this.navIndexes navIndexes }方向键移动时就不再直接对 rowIndex / colIndex 加减而是在 navIndexes 里按顺序找上 / 下一个坐标。这样合并单元格区域天然被跳过光标只在真正可见、可交互的格子里移动。实现上稍微绕一点但比在 click 事件里监听 rowspan 值去推算靠谱得多。合并单元格的数据在初始化后一般不会频繁变化buildNavIndexes 只需要在表格数据加载完成后跑一次性能开销可以忽略。4.4 键盘事件失焦或冒泡导致的失效排查方向键没反应是部署后反馈最多的问题我把踩过的原因整理成一张速查表。现象可能原因处理方式点击表格任意区域后按方向键没动静外层容器没有 tabindex焦点根本没落在容器上给外层 div 加 tabindex0一开始正常打开弹窗后失效弹窗把焦点抢走了外层容器失焦在弹窗关闭后手动调用外层容器的 focus()输入框里按方向键表格也动没有过滤 input / textarea判断 document.activeElement 的标签名后 return页面按上下键会滚动而不是移动光标keydown 事件没 preventDefault在确认处理方向键后立即调用 event.preventDefault()按了左 / 右没反应上下正常光标坐标列数比实际列数少 / 多检查 columns 的注册顺序columnIndex 从 0 开始加了 fixed 列后滚动定位乱固定列表头与主表表头两个滚动容器不同步用主表体 bodyWrapper 的 scroll 事件统一同步这里最容易被忽略的是“弹窗关闭后失焦”。如果是 el-dialog 里嵌表格dialog 打开时会把焦点移到弹窗本身关闭后焦点不会自动回到外层容器必须手动调一下this.$refs.tableWrapper.focus()我的习惯是把这行代码写到 dialog 的 closed 事件回调里保证每次弹窗关闭后方向键导航立刻恢复。另外还有个细节el-table 本身的某些交互比如排序、展开行也可能让焦点从外层容器跑走想省事的话可以在 updateCursor 里再次调用 this.$refs.tableWrapper.focus()把焦点强行拉回来但注意不要和输入框聚焦逻辑打架优先级上要让可编辑单元格的聚焦逻辑先执行再决定拉不拉焦点。5. 一点个人建议这套 el-table 方向键移动光标方案前前后后我在四个后台项目里落地过其中两个是 Element UI两个是 Element Plus核心代码没有大改只是把 this.$refs 换成 ref.value把 :cell-style 参数结构保持一致即可。如果你刚开始做别急着把滚动跟随、合并单元格、可编辑聚焦全怼上去。先实现坐标移动和高亮让用户用起来顺了再按业务反馈逐步加复杂功能。功能做太多很容易陷入和组件库内部实现搏斗的泥潭。我在第一个项目里就是把滚动跟随的代码写得太复杂最后删到只剩十来行反而更稳。另外提一句方向键移动光标这种交互本质上是在给表格组件叠一套键盘语义。做之前最好和产品确认一下边界行为——是到边界就停还是循环跳转是否允许跳过不可编辑列是否需要 Tab 键参与。交互细节定了代码反而更好写。比如我们后来把“按 Tab 从最后一个可编辑单元格跳到下一行开头”也加进去了因为方向键只覆盖了二维移动行尾到下一行的衔接还是得靠 Tab 这种一维跳转键来补。希望这篇梳理能帮你少踩几个坑。
返回列表