让表格式录入像 Excel 一样顺滑:回车/方向键自动跳转焦点的 Vue 插件设计与实现
在「批量录入 / 表格内编辑」这种场景里,用户的肌肉记忆非常明确:
- Enter:录完当前格,去下一个
- ↑ ↓ ← →:像表格一样移动到相邻单元格
- 禁用/隐藏的格子要自动跳过
- 一个页面可能有多个表格,不能互相串焦点
看起来像"加个事件监听"就能搞定,但真正做起来你会踩到一堆坑:Element Plus 输入组件的真实可聚焦节点不在你绑定的元素上、行列顺序和视觉顺序不一致、动态增删行导致索引错乱、指令里还不能直接 inject()......
这篇文章我会从一个资深前端的视角,把我在项目里落地的方案完整拆开:如何用 Provider + Directive + FocusManager 搭一个可维护、可扩展、可分组隔离的焦点跳转系统。
代码对应目录:
src/plugins/focus/(Vue 3 + TS)
1. 需求拆解:不要只盯着 "Enter 跳下一个"
如果你只做 keydown Enter -> querySelectorAll('input') -> focus(),很快就会发现它"不像产品希望的那样工作"。这类交互至少要把需求拆成 5 件事:
- 线性跳转:Enter 按视觉顺序跳到下一个输入框
- 网格跳转 :方向键按
(row, col)去相邻格子 - 跳过规则:disabled / readonly / 不可见 / 已被卸载的节点要跳过
- 分组隔离:多个表格/区域互不干扰(作用域)
- 动态列表:增删行、筛选、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.ts:FocusProvider(作用域)src/plugins/focus/v-focus-item.ts:指令(注册 + 键盘监听 + 清理)src/plugins/focus/focusManager.ts:排序与跳转算法src/plugins/focus/rowProvider.ts、src/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(线性顺序)和方向键(网格定位),管理器维护了三份结构:
nodes: FocusNode[]:全量节点,按(row, col)排序
用于focusNext()这种"线性向后扫描"rows: Map<row, FocusNode[]>:每行一个数组,按col排序
用于left/right这种"同一行内移动"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/readOnlyaria-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