背景
最近在做产品优化,产品让给表单增加一个功能,就是回车后自动进入下一个表单元素,这样就不用频繁使用鼠标进行切换了,可以大大提升表单输入的流畅性,让用户一路Next。
这个需求很合理,也非常通用,理论上全部表单都应该支持这样的效果,很多大厂的产品,也都是支持这个效果的,现在问题就变成了如何完美设计一个方案,以实现这个效果。
制定目标
在进行技术开发之前,我习惯先给自己定义下技术指标,而不是上来就做。定义好技术指标,就定义好了我们的需求,定义好了要做什么,做成什么样,而不是上来先考虑如何去做,这样更符合做事的方法论。
针对这个问题,我给自己定了如下几个目标:
配置要简单:
- 因为大量的表单都需要进行这个配置,所以配置一定要尽可能简单,尽量一行代码搞定
- 不需要配置顺序,根据表单中的顺序,自动聚焦下一个表单
要支持配置哪些表单元素参与回车聚焦
- 默认应该支持所有含有input和textarea的元素参与回车聚焦
- 但是也要支持自定义,自定义要简单,比如指定含有某个className的元素参与回车聚焦
- 要支持自动聚焦首个表单元素
- 要能够自动滚动到聚焦的表单元素
- 要能够跳过disabled的元素,以及一些不需要聚焦的表单元素,如radio、checkbox、submit等
- 要支持vue2和vue3
方案制定
网上有一些文章,基本都是针对某个表单元素,监听keydown事件,然后特殊处理其逻辑,这样的解决方案没有通用性,而且也非常复杂,每个表单都要大量的无效重复代码。
考虑通过指令的方式来解决这个问题,期望开发一个 v-focus-next指令,只要配置了这个指令,其中的表单元素就自动支持回车聚焦。
期望的使用方式:
<div v-focus-next>
<input/>
<input/>
<input/>
<input/>
<input/>
<textarea/>
</div>
组件也同样支持该指令
<el-form v-focus-next >
<el-form-item label="名称">
<el-input v-model="form.name" id="name" />
</el-form-item>
<el-form-item label="年龄">
<el-input v-model="form.age" id="age" disabled />
</el-form-item>
</el-form>
如果我们只想让className为 focus-next的参与回车聚焦,则这么配置。
<div v-focus-next="'.focus-next'">
<input class=focus-next/>
<input/>
<input class=focus-next/>
<input/>
<input class=focus-next/>
<textarea/>
</div>
自动聚焦首个表单,应该只要设置下autoFocus即可
<div v-focus-next.autoFocus>
</div>
到目前为止,我们只是在定义要做的事情应该是什么样的,并没有开始编码,无论是编写组件,还是指令,都希望先定义好对外的接口,然后评估这样的接口设计是否足够易用,最后再去实现。
记住:定义接口比实现更重要。
核心技术点
如何兼容Vue2和Vue3
Vue2和Vue3支持的指令钩子函数并不相同。
Vue3的指令
const myDirective = {
// 在绑定元素的 attribute 前
// 或事件监听器应用前调用
created(el, binding, vnode, prevVnode) {
// 下面会介绍各个参数的细节
},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件更新前调用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都更新后调用
updated(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载前调用
beforeUnmount(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载后调用
unmounted(el, binding, vnode, prevVnode) {}
}
Vue2的指令
// 注册一个全局自定义指令 `v-focus`
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el, binding, vNode, prevVnode) {
// 聚焦元素
el.focus()
},
bind: function(el, binding, vNode, prevVnode){},
update: function(el, binding, vNode, prevVnode){},
componentUpdated: function(el, binding, vNode, prevVnode){},
unbind: function(el, binding, vNode, prevVnode){},
})
可以看到,Vue2和Vue3只是钩子函数周期不同,参数基本还是一致,我们只要判断当前环境的Vue版本,然后设置不同的钩子函数即可。
我们知道,在开发Vue中间件时,install方法可以拿到当前环境的Vue实例,可以通过Vue.version来获取当前环境的Vue版本。
import focusNext3 from './focus-next3.js'; //vue3指令的具体实现
import focusNext2 from "./focus-next2.js"; //vue2指令的具体实现
export default {
install: function (Vue){
let version = Vue.version; //拿到Vue版本
if(version.startsWith('3.')) {
Vue.directive('focus-next', focusNext3);
}else if(version.startsWith('2.')){
Vue.directive('focus-next', focusNext2);
}else{
console.error('v-focus-next只支持vue2/3≈')
}
}
}
指令实现思路
在元素绑定了指令v-focus-next之后,我们可以监听当前绑定Dom的keydown事件,然后在keyDown事件中判断是否输入了回车符。如果输入了回车符,则获取当前事件event.target后的第一个有效表单元素,并调用该元素的focus方法。
在组件卸载之后记得清除掉监听的keydown事件。
function mounted (el, binding, vNode) {
function keyDown(event){
if(event.keyCode !== 13){
return;
}
let targetNode = event.target;
//找到下一个有效的节点
let nextNode = findNextNode(vNode.el, targetNode, binding);
if(!nextNode){
return;
}
setTimeout(()=>{
nextNode.focus();
});
}
el.addEventListener('keydown', keyDown);
el.__FOCUS_NEXT_KEYDOWN_HANDLER__ = keyDown;
}
function beforeUnmount (el, binding, vNode) {
el.removeEventListener("keydown", el.__FOCUS_NEXT_KEYDOWN_HANDLER__);
}
export default {
mounted,
beforeUnmount
}
接下来重点展示下如何获取当前event.target的下一个有效元素。
我们可以先找到所有支持回车聚焦的表单元素,然后查找当前event.target所在位置index,然后返回index+1位置的元素。
这里分2种情况:
event.target本身就属于支持回车聚焦的表单元素:
- 这种情况可以通过在所有支持回车聚焦的表单元素中,找到target所在位置即可
event.target不在支持回车聚焦的表单元素中
- 这种情况可根据dom位置,找到target后的第一个支持回车聚焦的表单元素
- dom1.compareDocumentPosition(dom2),可以判断两个dom的位置
export function findNextNode(rootDom, targetNode, binding){
let selector = binding.value || 'input, textarea';
//先找到该rootDom下所有有效的input、textarea元素
let nodes = findAllInputs(rootDom, selector);
let isByCompare = false;
let index = nodes.findIndex((item,index) => {
//如果回车事件的target和item相等,则说明找到了
if(item === targetNode || item.contains(targetNode)){
return true
}
//回车事件的target 不一定在所有有效的nodes中
//比如我们设置了只让 className='test'的元素支持聚焦回车
//那么某个没有className='test'的input回车时,nodes就不包含该target
//此时可以根据位置来判断,target后面的第一个有效元素,就是要自动聚焦的元素
if(targetNode.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_FOLLOWING){
isByCompare = true;
return true
}
return false
});
if(isByCompare){
return nodes[index]
}else{
if(index === -1 || index == nodes.length - 1){
return null;
}
return nodes[index + 1];
}
}
function findAllInputs(rootDom, selector){
//查询selector内部的所有有效input、textarea
//selector可能是className,绑定在div上,而非input、textarea上
//必须找到其内部的input、textarea
return [...rootDom.querySelectorAll(selector)].reduce(function(nodes, node) {
if(['INPUT', 'TEXTAREA'].includes(node.tagName)) {
nodes.push(node);
return nodes;
}
let childNodes = node.querySelectorAll('input, textarea');
if(childNodes.length){
let childNode = findFirstAvailableInput(childNodes)
if(childNode){
nodes.push(childNode);
}
return nodes;
}
return nodes;
},[]).filter(item=>{
if(item.tagName ==='INPUT'
&& !item.disabled
&& !['submit', 'reset', 'file', 'hidden', 'checkbox', 'radio'].includes(item.type)
){
return true;
}else if(item.tagName ==='TEXTAREA'
&& !item.disabled
){
return true;
}
return false;
})
}
function findFirstAvailableInput(nodes){
for(let i=0;i<nodes.length;i++){
const input = nodes[i];
if(input.tagName ==='INPUT'
&& !input.disabled
&& !['submit', 'reset', 'file', 'hidden', 'checkbox', 'radio'].includes(input.type)
){
return input;
}else if(input.tagName ==='TEXTAREA'
&& !input.disabled
){
return input;
}
}
}
自动聚焦
自动聚焦实现比较简单,可以在指令mounted时,找到第一个有效的支持回车聚焦的元素,调用其focus方法。
function mounted (el, binding, vNode) {
if(binding.modifiers.autoFocus){
autoFocus(vNode.el, binding)
}
//其他代码
}
export function autoFocus(rootDom, binding){
let selector = binding.value || 'input, textarea';
let nodes = findAllInputs(rootDom, selector);
if(nodes.length){
setTimeout(()=>{
nodes[0].focus()
})
}
}
完整代码可以查看我的github源码,欢迎动动发财的小手,帮忙点个赞。
https://github.com/501351981/v-focus-next
建议大家可以给表单元素加上该指令,表单的输入体验简直棒极了~~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。