你的表单支持回车自动聚焦吗?

背景

最近在做产品优化,产品让给表单增加一个功能,就是回车后自动进入下一个表单元素,这样就不用频繁使用鼠标进行切换了,可以大大提升表单输入的流畅性,让用户一路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的位置

[图片上传失败...(image-558122-1686460576553)]


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

建议大家可以给表单元素加上该指令,表单的输入体验简直棒极了~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容

  • blossom html css JavaScript 程序员入门的时候整理的一份学习笔记,见证了我的成长备份 标...
    blossom_绽放阅读 1,024评论 0 2
  • 本篇博客源地址 总结: 鼠标事件 1.click与dbclick事件ele.click()ele.click(ha...
    ZombieBrandg阅读 659评论 0 1
  • (续jQuery基础(1)) 第5章 DOM节点的复制与替换 (1)DOM拷贝clone() 克隆节点是DOM的常...
    凛0_0阅读 1,306评论 0 8
  • jquery介绍 jQuery是目前使用最广泛的javascript函数库 据统计,全世界排名前100万的网站,有...
    就是这么帅_567e阅读 1,033评论 0 0
  • 总结: 鼠标事件 1.click与dbclick事件$ele.click()$ele.click(handler(...
    阿r阿r阅读 1,591评论 2 10