- A+
前言
指令(directive
)在 vue
开发中是一项很实用的功能,指令可以绑定到某一元素或组件,使功能的颗粒度更精细。今天在翻 element-ui
的源码时,发现一个还挺实用的工具指令,跟大伙分享一下。
clickoutside 的使用及效果
该指令的源码在 src/utils
下的 clickoutside.js
。它功能是指令需要接收一个函数,当用户鼠标点击的区域在绑定指令的元素之外时,会触发该函数。
那么使用这个指令能够实现什么功能呢?我想到一个功能,就像我们常用的抽屉组件,在点击抽屉之外的区域时,抽屉就会消失(但 elementui
中不是用这种方式,而是用一个遮罩层实现)。
接下来我们来看看怎么玩这个指令,很简单,只需要引入这个文件注册指令就好了。
// main.js import Vue from 'vue' import clickoutside from 'element-ui/src/utils/clickoutside' Vue.directive('clickoutside', clickoutside)
使用:
<div v-show="show" v-clickoutside="handler"><div>
export default { data() { return { show: true } }, methods: { handler() { this.show = false } } }
效果:
源码分析
clickoutside
看起来还挺不错,下面看看它是如何实现的。首先是它的指令钩子定义:
const nodeList = []; const ctx = '@@clickoutsideContext'; let seed = 0; export default { // 指令绑定时触发 bind(el, binding, vnode) { // 每次绑定时会把dom元素存放到 nodeList 中 nodeList.push(el); // 创建递增id标识 const id = seed++; // 在dom元素上设置一些属性和方法 // ctx的作用是一个标识,为了不和原生的属性冲突 el[ctx] = { id, // 这个是点击元素区域外时会执行的函数,后面会提到 documentHandler: createDocumentHandler(el, binding, vnode), // 绑定的值表达式,值相当于上面例子中的 "handler" 字符串 methodName: binding.expression, // 绑定的值,值相当于上面例子中的 handler 函数 bindingFn: binding.value }; }, // 组件更新时触发 update(el, binding, vnode) { el[ctx].documentHandler = createDocumentHandler(el, binding, vnode); el[ctx].methodName = binding.expression; el[ctx].bindingFn = binding.value; }, // 指令解绑时触发 unbind(el) { let len = nodeList.length; // 找到对应的dom元素,从 nodeList 移除它 for (let i = 0; i < len; i++) { if (nodeList[i][ctx].id === el[ctx].id) { nodeList.splice(i, 1); break; } } // 移除之前添加的自定义属性 delete el[ctx]; } };
源码内部会对 docuemnt
鼠标事件进行监听:
let startClick; // 鼠标按下时 记录按下元素的事件对象 !Vue.prototype.$isServer && on(document, 'mousedown', e => (startClick = e)); // 鼠标松开时 遍历 nodeList 中的元素,执行 documentHandler !Vue.prototype.$isServer && on(document, 'mouseup', e => { nodeList.forEach(node => node[ctx].documentHandler(e, startClick)); });
接下来最核心的就是 documentHandler
函数,它是由 createDocumentHandler
创建出来的:
function createDocumentHandler(el, binding, vnode) { // 接收参数为:鼠标松开和鼠标按下的事件对象 return function(mouseup = {}, mousedown = {}) { // 这里一系列的判断点击区域是否在元素内,如果在区域内则跳出 if (!vnode || !vnode.context || !mouseup.target || !mousedown.target || el.contains(mouseup.target) || el.contains(mousedown.target) || el === mouseup.target || (vnode.context.popperElm && (vnode.context.popperElm.contains(mouseup.target) || vnode.context.popperElm.contains(mousedown.target)))) return; // 执行我们绑定指令时的函数 if (binding.expression && el[ctx].methodName && vnode.context[el[ctx].methodName]) { // vnode.context 是组件实例上下文 // 就像开头的例子,methodName 是 "handler",通过索引上下文的属性找到 methods 中定义的 handler 函数 vnode.context[el[ctx].methodName](); } else { el[ctx].bindingFn && el[ctx].bindingFn(); } }; }
至此整个指令流程分析就完了。
小插曲
在经过一些demo的使用后,发现该指令在某些场景下会出现不理想的效果。例如:抽屉内有 el-select
选择栏时,选择栏的 dom
是挂载到 body
下,导致在点击完选择项后被判断为区域外点击。
其实这也符合逻辑,因为点击的地方也确实在区域外,只是在这种场景下看起来像是“bug”一样。然后我发现源码里提供了一个选项解决这种问题。可以在使用指令的组件 data
里定义 popperElm
属性,它的值是一个 dom
。
export default { mounted() { this.popperElm = document.querySelector('.el-select-dropdown.el-popper') } }
在源码里会通过 popperElm
进行判断:
if (!vnode || !vnode.context || !mouseup.target || !mousedown.target || el.contains(mouseup.target) || el.contains(mousedown.target) || el === mouseup.target || (vnode.context.popperElm && (vnode.context.popperElm.contains(mouseup.target) || vnode.context.popperElm.contains(mousedown.target)))) return;
如果 popperElm
包含鼠标点击的 dom
则跳出逻辑。
然后我又想到了一个问题,popperElm
只能设置一个,当有多个选择栏组件时,还是会出现上面所说的情况。我的想法是,把 clickoutside
给 copy
一份下来,把 popperElm
改成可以接受数组类型,判断时去循环判断,这样应该可以解决问题。
还有一件有趣的事,我在全局搜索时发现 element-ui
里好像没有用到这个指令。
结语
clickoutside
不止抽屉的场景,只要你想在点击某个元素区域之外做些事情,都可以考虑它。
除了这个,还有很多优秀的第三方指令,例如 element-ui
中的 v-loading
可以实现局部的加载动画,常用的 vue-lazyload
中的 v-lazy
可以实现图片的懒加载。
个人认为指令属于那种用得少但很实用的东西,可能在开发功能时都没有考虑到用指令来实现,如果你还不了解指令,赶快学起来。
感谢阅读
欢迎关注