Element源码分析系列7-InputNumber(数字输入框)

5,060 阅读9分钟

简介

数字输入框,如下图,就是一个有着加减按钮的input而已,多用于购物车商品数目添加减少,这个输入框组件初看上去应该不是很难,但是Element的具体实现却有很多值得学习的地方,看完源码才感觉真难!官网代码点此

数字输入框的html结构

这个组件的html结构较为简单,第一眼看上去我会以为是外层一个div,内层一个input,左右各一个span作为按钮,查看源码后也确实是这样,简化后的html结构如下

<div class='el-input-number'>
    <span class="el-input-number__decrease"></span>
    <span class="el-input-number__increase"></span>
    <el-input></el-input>
</div>

前2个span是加和减的按钮,最后的<el-input>是之前封装的输入框组件,注意不是原生的input,这里值得一提的是2个span都是绝对定位,且<el-input>的左右padding都是50px,如下图

也就是说这里的2个加减按钮是放在input的padding位置上的,是个包含关系而不是并排关系,2个span绝对定位,左边的left:1,右边的right:1,这种实现方式的好处我觉得是这样,如下图

当输入框获得焦点,输入框的border会高亮,给人一种这3部分是一个整体的感觉,css处理起来很简单,如果是3个部分并排,则还要单独处理左右2个span的border

具体各部分分析

先来看外层的div

<div
    @dragstart.prevent
    :class="[
      'el-input-number',
      inputNumberSize ? 'el-input-number--' + inputNumberSize : '',
      { 'is-disabled': inputNumberDisabled },
      { 'is-without-controls': !controls },
      { 'is-controls-right': controlsAtRight }
    ]">

第一行@dragstart.prevent第一眼看到这个我是懵逼的!这句话表明禁止了div的默认拖动行为,这里不是很明白,首先如果div要被拖动的话得设置draggable="ture"才行,而且为啥要禁止拖动?我试了下去掉这句话,再拖动这个组件

发现当你选中input中的数字时可以拖动数字出去,上图下面的浅色数字就是拖动出去的样子,还有个鼠标禁止的图案没能够截图,加上draggable="ture"就不能拖动选中的数字了
然后div中class部分的'el-input-number'规定了外层div的基本类,如下

可见div被设置为inline-block内联元素,然后设置了宽度,因为组件宽度是不会随着内容的变化而变化,所以定死了宽度,接下来3个类分别控制组件是否禁用(禁用逻辑前面几篇已经分析过了),是否显示加减按钮,是否将按钮放置于右侧,见下图

这3个类是否添加都是由用户传入对应的prop来实现,上图一个令我搞了好半天的scss代码就是,上图中右下角的减按钮

@include when(controls-right) {
@include e(decrease) {
      right: 1px;
      bottom: 1px;
      top: auto;
      left: auto;
      border-right: none;
      border-left: $--border-base;
      border-radius: 0 0 $--border-radius-base 0;
    }
}

这里的意思是当controls-right类被加上后,decrease这个类的css变化为上面的内容,也就是将减按钮从原本的左侧放置到右下角,我开始不明白这里的top:auto,left:auto是干嘛的,后来控制台调试得知,因为decrease类原本的top是1px,left是1px,当controls-right类被加上后,必须得设置top,left为auto,让浏览器自动计算top和left,否则就无法覆盖原本的top:1px,left:1px。另外一个值得一提的是,这里的加减按钮的height是如下设定的

height: auto;
line-height: #{($--input-height - 2) / 2};

指定height为auto,通过设置line-height值为输入框高度的一半减去border宽度来撑开高度,如果直接设置height为高度的一半也应该可以吧??然后输入框内的文字居中就是text-align:center实现

接下来看关键的加减按钮的逻辑实现,html代码如下,这个按钮是span实现的,不是原生button

<span
      class="el-input-number__decrease"
      role="button"
      v-if="controls"
      v-repeat-click="decrease"
      :class="{'is-disabled': minDisabled}"
      @keydown.enter="decrease">
      <i :class="`el-icon-${controlsAtRight ? 'arrow-down' : 'minus'}`"></i>
</span>

role属性作用是告诉Accessibility类应用(比如屏幕朗读程序,为盲人提供的访问网络的便利程序),这个元素所扮演的角色,主要是供残疾人使用。使用role可以增强文本的可读性和语义化,然后v-if的controls是个bool值,是用户传入的prop,用来控制是否显示该按钮,然后:class控制了该按钮是否显示禁用样式,@keydown.enter又让我疑惑了,这是在监听enter键按下,Vue官网相关的说明是给input加上这个事件,在input获得焦点时按下enter会触发对应的事件,但是为啥要给span也加个@keydown.enter,我试了点击enter没有任何反应,总之这里没搞明白

然后发现没有这个按钮没有@click事件,所有的点击处理逻辑都放在了v-repeat-click="decrease"里面,这里除了单击操作会使数字增加减少外,还有鼠标一直按着不放会快速增加减少数字,所有的逻辑都通过Vue中的自定义指令(directives)来实现,自定义指令通常用来对底层dom元素进行操作,触发特定的逻辑。在directives属性里进行声明

directives: {
      repeatClick: RepeatClick
}

这个key(repeatClick)就对应v-repeat-click,value(RepeatClick)是import进来的方法,代码见下面

import { once, on } from 'element-ui/src/utils/dom';
export default {
  bind(el, binding, vnode) {
    let interval = null;
    let startTime;
    const handler = () => vnode.context[binding.expression].apply();
    const clear = () => {
      if (new Date() - startTime < 100) {
        handler();
      }
      clearInterval(interval);
      interval = null;
    };

    on(el, 'mousedown', (e) => {
      if (e.button !== 0) return;
      startTime = new Date();
      once(document, 'mouseup', clear);
      clearInterval(interval);
      interval = setInterval(handler, 100);
    });
  }
};

这段代码就稍微复杂点,首先要熟悉Vue的自定义指令的内容,自定义指令会提供几个钩子函数,用来在特定的时机触发特定的逻辑,见下图

这里使用了bind钩子函数,可以理解为初始化调用一次,你想想这个指令内肯定是给元素绑定单击事件,所以只需要在bind内调用一次即可,然后bind的三个参数el,binding,vnode分别代表可操作的dom,一个binding对象,提供各种信息,和Vue编译生成的虚拟节点 binding对象如下

bind这个钩子函数内的逻辑需要触发让输入框内数字加减的方法,这个方法写在组件的methods内,那么如何得到这个方法呢,下面这句就能得到

const handler = () => vnode.context[binding.expression].apply();

这句话我只能说太高端,得去看源码才能写出来,首先vnode是vue生成的虚拟节点,就是一个js对象而已,里面属性很多,那么context又是啥,翻看vue源码得知vnode的结构如下

context是一个Component类型的数据结构,这个Component是flow定义的结构,具体可看vue源码中的flow内的内容,Component就是组件,所以这个context就是该vnode所在的组件上下文,再来看binding.expression,官网说这就是v-repeat-click="decrease"中的decrease方法,这个方法写在组件的methods内,那么context[binding.expression]就是context['decrease']因此就拿到了组件内的decrease方法,类似于在组件中使用this.decrease一样,然后最后的apply()就很奇怪了,apply的用法是参数的第一个表示要执行的目标对象,如果为null或者undefined则表示在window上调用该方法,这里没有参数,那就是undefined,所以是在window上执行,这个我也不确定到底说的对不对,我把这句话改为

const handler = () => vnode.context[binding.expression].apply(vnode);

也没出现错误,这里也没搞清楚为啥直接apply()就行,我再把上面的改成下面这种,也就是直接执行函数,也没报错,一切正常

const handler = () => vnode.context[binding.expression]()

回到bind方法的逻辑,发现这里并没有任何的click出现,也就是说没有绑定单击鼠标的事件,这里因为要处理按下去连续触发decrease方法,所以把单击和连续按下都糅合到一起了,如下

on(el, 'mousedown', (e) => {
      if (e.button !== 0) return;
      startTime = new Date();
      once(document, 'mouseup', clear);
      clearInterval(interval);
      interval = setInterval(handler, 100);
});

on这个方法来自于源码外层的目录,因为其他组件也能用到,所以抽离成一个公共方法放到util目录下。先看on的代码

export const on = (function() {
  if (!isServer && document.addEventListener) {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.addEventListener(event, handler, false);
      }
    };
  } else {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.attachEvent('on' + event, handler);
      }
    };
  }
})();

这个方法就是给元素绑定事件,if-else处理了兼容性的情况,attachEvent是ie的方法,addEventListener是其他主流浏览器的方法。on的第三个参数就是事件处理函数,on中第一句if (e.button !== 0) returne.button是按下了鼠标的哪个键

不等于0则是说明按下的不是左键,因为一般只处理左键的点击事件,注意onclick只响应鼠标左键的按下,而onmousedown则响应3个键的按下,所以这里要区分。

on最后一句interval = setInterval(handler, 100)设置了定时器定时执行handler方法从而每隔0.1s触发一次数字增加或减少事件,然后我们思考,按下去鼠标时给dom元素添加了事件:定时执行handler,那么在鼠标抬起时肯定要销毁这个定时器,否则将会无限触发handler方法,造成数字一直增加或减少,因此once(document, 'mouseup', clear)这句话就是在鼠标抬起时销毁定时器,先看clear方法

const clear = () => {
      if (new Date() - startTime < 100) {
        handler();
      }
      clearInterval(interval);
      interval = null;
    };

里面就是clearInterval销毁定时器,前面的if逻辑很关键,在按下鼠标时记录一个时间,抬起鼠标时检测当前时间 - 按下时的时间 < 100毫秒,如果是则触发一次点击,如果不写这个if,则无法实现单击操作,因为如果不写,由于interval = setInterval(handler, 100),在按下后100毫秒后才会触发一次点击,则在100毫秒内抬起鼠标时interval已经被clear了。最后注意下once(document, 'mouseup', clear)once是只触发一次的高阶函数,代码如下

export const once = function(el, event, fn) {
  var listener = function() {
    if (fn) {
      fn.apply(this, arguments);
    }
    off(el, event, listener);
  };
  on(el, event, listener);
};

这就是观察者模式里面的once的写法,本质上是复用on事件,只不过on的第三个参数加了修改,listener里会执行fn一次,然后就用off方法移除listener,因此达到了只执行一次的目的。还有个注意的点,once方法的第一个参数是document,这个也很关键,你可能以为在加减按钮上绑定onmousedown就应该在加减按钮上绑定onmouseup,这样做就会出bug,考虑一种情况,当你鼠标在加减按钮上按下时,然后移动鼠标到按钮外,再放开鼠标,此时会发现数字还在增加,这就是bug,因此要在document这个最外层的dom元素上绑定mouseup,这样mouseup事件总能被响应,否则乱移动鼠标就会造成数字一直增加

分析不动了,主要难点已经写完了,剩下的精度属性和step其实也不难,总之要搞懂所有的代码很难,只能关注部分核心逻辑