简介
数字输入框,如下图,就是一个有着加减按钮的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) return
的e.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其实也不难,总之要搞懂所有的代码很难,只能关注部分核心逻辑