让表格式录入像 Excel 一样顺滑

让表格式录入像 Excel 一样顺滑:回车/方向键自动跳转焦点的 Vue 插件设计与实现

在「批量录入 / 表格内编辑」这种场景里,用户的肌肉记忆非常明确:

  • Enter:录完当前格,去下一个
  • ↑ ↓ ← →:像表格一样移动到相邻单元格
  • 禁用/隐藏的格子要自动跳过
  • 一个页面可能有多个表格,不能互相串焦点

看起来像"加个事件监听"就能搞定,但真正做起来你会踩到一堆坑:Element Plus 输入组件的真实可聚焦节点不在你绑定的元素上、行列顺序和视觉顺序不一致、动态增删行导致索引错乱、指令里还不能直接 inject()......

这篇文章我会从一个资深前端的视角,把我在项目里落地的方案完整拆开:如何用 Provider + Directive + FocusManager 搭一个可维护、可扩展、可分组隔离的焦点跳转系统

代码对应目录:src/plugins/focus/(Vue 3 + TS)


1. 需求拆解:不要只盯着 "Enter 跳下一个"

如果你只做 keydown Enter -> querySelectorAll('input') -> focus(),很快就会发现它"不像产品希望的那样工作"。这类交互至少要把需求拆成 5 件事:

  1. 线性跳转:Enter 按视觉顺序跳到下一个输入框
  2. 网格跳转 :方向键按 (row, col) 去相邻格子
  3. 跳过规则:disabled / readonly / 不可见 / 已被卸载的节点要跳过
  4. 分组隔离:多个表格/区域互不干扰(作用域)
  5. 动态列表:增删行、筛选、Tab 切换都要正确更新顺序

拆完后你会意识到:这其实是一个"小型焦点路由系统",需要一个"管理器"来保存节点与规则。


2. 总体设计:Provider 管作用域,指令管注册,Manager 管算法

我最后选了一个非常 Vue 的结构(也是可维护性最好的结构):

  • <FocusProvider> :创建一个 FocusManager,通过 provide 向子树提供
    每个 Provider 都是一个独立分组,天然解决"多个表格串焦点"的问题。
  • v-focus-item 指令 :把输入框注册进 FocusManager,并监听 Enter / 方向键
    指令的优势是"不侵入业务组件",你不需要把业务输入框改成统一组件。
  • FocusManager :维护节点、排序、跳转策略
    这部分需要可测试、可扩展,不能散落在各种组件里。
  • (可选)<RowProvider> + createRowCursor() :解决"深层子组件拿不到 row"的问题
    表格类页面组件层级深、单元格组件复用多,row 层层传递非常痛苦。

对应文件结构(核心):

  • src/plugins/focus/index.ts:插件入口,注册指令/组件
  • src/plugins/focus/focusProvider.tsFocusProvider(作用域)
  • src/plugins/focus/v-focus-item.ts:指令(注册 + 键盘监听 + 清理)
  • src/plugins/focus/focusManager.ts:排序与跳转算法
  • src/plugins/focus/rowProvider.tssrc/plugins/focus/createRowCursor.ts:行号注入与生成

3. 作用域隔离:一个 Provider = 一个焦点分组

先把"边界"划清楚:同页面多个表格、弹窗里的表格、Tabs 里的表格,都不应该互相影响。

实现上非常简单:每个 <FocusProvider>setup() 里 new 一个管理器并 provide 出去即可:

ts 复制代码
// src/plugins/focus/focusProvider.ts
export const FocusManagerKey = Symbol('FocusManager')

export const FocusProvider = defineComponent({
  name: 'FocusProvider',
  setup(_, { slots }) {
    const manager = new FocusManager()
    provide(FocusManagerKey, manager)
    return () => slots.default?.()
  }
})

这段代码的价值在于:所有"焦点跳转"都发生在同一个 Provider 的子树里。你不需要在跳转逻辑里额外判断"我是不是跨表格了",结构已经帮你保证了。


4. 节点注册:为什么指令里不要 inject()

直觉上你会想:指令里 inject(FocusManagerKey) 拿到 manager,然后注册。

但 Vue 3 指令的生命周期钩子里,并不总是可靠地拥有 setup() 上下文,直接 inject() 很容易拿不到东西。更稳妥的方法是:从拥有该指令的组件实例上读取 provides

ts 复制代码
// src/plugins/focus/v-focus-item.ts(核心片段)
const manager: any = (binding.instance as any)?.$?.provides?.[FocusManagerKey as any]
if (!manager?.register || !manager?.unregister) return

这虽然看起来"有点黑魔法",但它非常实用:指令只需要依附在某个组件实例上,就能沿着最近的 Provider 拿到管理器。

如果你对这种写法很敏感:也可以把注册能力做成组件(<FocusItem>),用 inject() 获取 manager;但侵入性会明显变高。


5. 真正可聚焦的元素:Element Plus 最大的坑

业务里大量输入框来自 UI 库(例如 Element Plus 的 <el-input>)。你在模板上写的是:

vue 复制代码
<el-input v-focus-item="{ row, col }" />

但真正会接收键盘事件、真正能 focus() 的,是它内部渲染出来的 input/textarea,而不是你绑定指令的宿主节点。

所以指令里要做一个非常现实的妥协:把宿主当容器,优先找内部的 input,textarea

ts 复制代码
const rawTarget = hostEl.querySelector?.('input,textarea') ?? hostEl
const focusEl = rawTarget instanceof HTMLElement ? rawTarget : rawTarget?.$el
if (!(focusEl instanceof HTMLElement)) return

这一步做对了,后面所有 Enter/方向键/el.focus() 才有意义。


6. 输入框监听:Enter 线性跳转,方向键网格跳转(并规避 IME)

注册完节点后,指令给 focusEl 绑定一个 keydown 监听:

  • Enter :阻止默认(避免表单提交/换行),调用 focusNext(id)
  • Arrow :无修饰键时接管方向键(阻止默认光标移动),调用 focusByDirection(id, dir)
  • 中文输入法isComposing 时不处理,避免输入法候选阶段被拦截
ts 复制代码
const onKeydown = (e: KeyboardEvent) => {
  if ((e as any).isComposing) return

  if (!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.startsWith('Arrow')) {
    e.preventDefault()
    e.stopPropagation()
    // ...
  }

  if (e.key === 'Enter') {
    e.preventDefault()
    manager.focusNext(id)
  }
}

并且一定要在 unmounted 清理监听与注册,避免内存泄漏和"幽灵节点":

ts 复制代码
(el as any).__cleanup__ = () => {
  focusEl.removeEventListener('keydown', onKeydown)
  manager.unregister(id)
}

7. FocusManager:把"跳转"当作一个可复用算法问题

7.1 数据结构:既要线性顺序,也要按行快速定位

为了同时支持 Enter(线性顺序)和方向键(网格定位),管理器维护了三份结构:

  1. nodes: FocusNode[]:全量节点,按 (row, col) 排序
    用于 focusNext() 这种"线性向后扫描"
  2. rows: Map<row, FocusNode[]>:每行一个数组,按 col 排序
    用于 left/right 这种"同一行内移动"
  3. rowKeys: number[]:已存在的行号(有序)
    用于 up/down 这种"跨行寻找目标行",并支持环绕

你会在 src/plugins/focus/focusManager.ts 看到这些成员:

ts 复制代码
private nodes = reactive<FocusNode[]>([])
private nodesById = new Map<symbol, FocusNode>()
private rows = new Map<number, FocusNode[]>()
private rowKeys: number[] = []

7.2 跳过规则:别只判断 disabled

焦点跳转最容易"看起来没问题但线上难用"的点,就是跳过规则不够严谨。

这里我做了一个相对保守的 isFocusable(),会过滤:

  • 节点已卸载(!el.isConnected
  • disabled / aria-disabled="true"
  • readonly / readOnly
  • aria-hidden="true"、不可见(getClientRects().length === 0
  • tabIndex < 0(显式不参与 tab 序)

这能覆盖大部分业务"你不希望用户跳过去"的输入框状态。

7.3 Enter:按排序后的线性序列向后找可聚焦节点

Enter 的逻辑保持简单:从当前节点在 nodes 中的位置开始向后扫描,找到第一个可聚焦节点就 focus()

ts 复制代码
focusNext(id: symbol) {
  const i = this.nodes.findIndex(n => n.id === id)
  if (i === -1) return
  for (let j = i + 1; j < this.nodes.length; j++) {
    const el = this.nodes[j].el
    if (this.isFocusable(el)) {
      el.focus()
      break
    }
  }
}

为什么不用"直接算下一个 row/col"?因为真实表格经常有:

  • 某些列在某些行被条件隐藏
  • 某些单元格是只读/禁用
  • 行内控件数量不一样(可编辑列不一致)

线性扫描虽然看起来笨,但鲁棒性非常高,维护成本低。

7.4 方向键:按网格规则移动,并支持跨行环绕

方向键的难点是"体验一致":

  • left/right:同一行内移动;到边界时跳到下一行(或上一行)的最后/第一个
  • up/down:跨行移动时,优先落在 列号最接近 的输入框上
  • 如果目标行不存在可聚焦元素,要继续找下一行,并最终支持环绕(像循环表格)

这就是 focusByDirection() 做的事,核心策略是:

  • rowKeys 做一个有序行索引,lowerBound() 定位当前行在 rowKeys 的位置
  • mod() 支持 rowIndex + step 的环绕
  • 在目标行里通过"二分 + 双指针"找 col 最近的候选

这种算法写出来不像业务代码,但它很值得:一旦写对,后续的扩展(比如"只在到边界才跨行")基本只改策略,不动基础设施。


8. 行号注入:用 RowProvider 避免 row 层层传递

表格类页面常见结构是:

页面 -> 行组件 -> 单元格组件 -> 输入组件

如果你每层都传 row,业务代码会很快"黏住"。因此我加了一个可选能力:

  • 页面层用 createRowCursor() 生成递增 row
  • 每渲染一行用 <RowProvider :row="rowCursor.next()"> 包住该行
  • 深层组件里 inject(RowIndexKey) 拿到 row,再传给 v-focus-item

核心实现非常轻量:

ts 复制代码
// src/plugins/focus/createRowCursor.ts
export function createRowCursor() {
  let row = 0
  return { next: () => row++, reset: () => (row = 0) }
}

RowProvider 只是把 row 提供出去:

ts 复制代码
// src/plugins/focus/rowProvider.ts
export const RowIndexKey = Symbol('RowIndex')
provide(RowIndexKey, props.row)

额外提醒一个很实用的细节:如果表格会因为 Tab/筛选切换而重排,记得在切换时 rowCursor.reset(),避免 row 累加导致排序不符合视觉顺序。


9. 如何在业务里使用(最小心智负担版)

9.1 全局注册插件

插件入口在 src/plugins/focus/index.ts,全局注册一次即可:

ts 复制代码
import FocusPlugin from '@/plugins/focus/index'
app.use(FocusPlugin)

9.2 在表格区域包一层 Provider

vue 复制代码
<FocusProvider>
  <!-- 你的表格 / 表单区域 -->
</FocusProvider>

9.3 给输入框标记 row/col

vue 复制代码
<el-input v-focus-item="{ row, col: 0 }" />
<el-input v-focus-item="{ row, col: 1 }" />

如果你用了 RowProvider,深层组件只需要关心 col

ts 复制代码
const row = inject(RowIndexKey)!
vue 复制代码
<el-input v-focus-item="{ row, col: 2 }" />

10. 可扩展点:为什么这套结构后劲很足

当你把"焦点跳转"收敛成 FocusManager 的能力之后,扩展就会变得很自然:

  • Shift+Enter 跳上一个(实现 focusPrev
  • 增加"跳过规则"(不可见、aria-*、业务自定义 predicate)
  • 增加"选择器"参数(当内部可聚焦节点不是 input/textarea
  • 增加不同导航模式(比如只在光标到边界时才跨列/跨行)

最重要的是:业务侧 API 不用变------依旧是 v-focus-item="{ row, col }"


11. 结尾彩蛋:这套功能其实不是"我"写的第一版

如果你一路看到这里,我想给你说:

这个插件的第一版核心实现,几乎是codex生成的。

我做的不是"敲代码",而是把需求拆到足够清晰、把边界条件讲到足够苛刻,然后对 AI 的输出做 Review、补缺、压测思路和落地集成。

更直观地说:我把自己从"代码搬运工"升级成了"交互与架构的导演"。

当你开始用这种方式开发,你会发现效率提升只是副产品,真正改变的是你看待工程问题的方式:更强调建模、更强调边界、更强调可维护性。

这工时该如何填?不管了,先摸鱼了jym

相关推荐
明月_清风2 小时前
模仿 create-vite / create-vue 风格写一个现代脚手架
前端·后端
前端付豪2 小时前
必知 Express和 MVC
前端·node.js·全栈
重铸码农荣光2 小时前
CSS 也能“私有化”?揭秘模块化 CSS 的防坑指南(附 Vue & React 实战)
前端·css·vue.js
南囝coding2 小时前
CSS终于能做瀑布流了!三行代码搞定,告别JavaScript布局
前端·后端·面试
ccnocare2 小时前
git 创建远程分支
前端
全栈王校长2 小时前
Vue.js 3 项目构建神器:Webpack 全攻略
前端
1024小神2 小时前
cloudflare+hono使用worker实现api接口和r2文件存储和下载
前端
Anita_Sun2 小时前
Lodash 源码解读与原理分析 - Lodash 对象创建的完整流程
前端
米诺zuo2 小时前
TypeScript 知识总结
前端