Element-ui 之 clickoutside 点击元素外部指令的源码分析

545 阅读4分钟

一. 自定义指令 clickoutside 的作用

clickoutside 是点击被绑定元素之外会触发其绑定的事件。

例如:

Select 组件点击组件外部,收起下拉框的功能,就应用到了自定义指令 clickoutside

image.png

<div
  class="el-select"
  :class="[selectSize ? 'el-select--' + selectSize : '']"
  @click.stop="toggleMenu"
  v-clickoutside="handleClose">
</div>

二. 自定义指令 clickoutside 源码分析

源代码位置:在 element-ui 源代码的 src/utils/clickoutside.js 文件中。

2.1 clickoutside 指令的实现思路

clickoutside 指令的实现思路主要拆分为如下几步:

  • 首先,由于可能会存在多个元素绑定了 clickoutside 指令,所以需要设置一个数组变量 nodeList 来存放绑定指令的元素。

  • bind钩子的处理(指令第一次绑定到元素上时):需要将绑定指令的元素 pushnodeList 数组中,并且给被绑定元素设置一个特殊的属性,属性下挂载唯一的id、点击时判断是否在元素外部的方法、字符串形式的指令表达式、绑定的方法。

  • update钩子的处理(被绑定元素所在的模板更新时):更新被绑定元素的特殊属性下的除 id 外的其余属性。

  • 事件的监听:监听 documentmousedownmouseup 事件,在 mousedown 事件中,用一个变量存储 mousedown 事件的 event,以便后续判断是否点击的是被绑定元素之外;在 mouseup 事件中,遍历 nodeList 数组,判断是否点击的是其元素的外部,如果时,则执行绑定的方法。

  • unbind 钩子(指令与元素解绑时):根据 id的对应将 nodeList 上面对应的id的元素从数组 nodeList中移除。

2.2 clickoutside 指令的源码解析

// 引入 vue
import Vue from 'vue';

// Vue.prototype.$isServer 去获取是否是服务端渲染,如果是服务端渲染,则不执行点击事件
const isServer = Vue.prototype.$isServer;

// 定义 nodeList 变量为数组,存放所有绑定 clickoutside 指令的元素
const nodeList = [];

// 应用 el[ctx],给被绑定元素定义 @@clickoutsideContext 属性,将绑定的事件等信息挂载在 el[ctx] 上面
const ctx = '@@clickoutsideContext';

// startClick 变量记录开始点击的事件对象
let startClick;
// seed 变量创建唯一的 ID
let seed = 0;

// 封装监听事件(处理IE浏览器兼容性问题)
const on = (function() {
  if (!isServer && document.addEventListener) {
    // 判断 document.addEventListener 是否存在,
    return function(element, event, handler) {
      if (element && event && handler) {
        element.addEventListener(event, handler, false);
      }
    };
  } else {
    // 不存在,使用 attachEvent 兼容IE浏览器
    return function(element, event, handler) {
      if (element && event && handler) {
        element.attachEvent('on' + event, handler);
      }
    };
  }
})();

// 鼠标按下时,将事件对象赋值给 startClick 变量
!isServer && on(document, 'mousedown', e => (startClick = e));

// 鼠标抬起时,遍历 nodeList,调用每个 node 的 documentHandler 方法,并且将鼠标抬起的事件对象和开始点击的事件对象传入
!isServer && on(document, 'mouseup', e => {
  nodeList.forEach(node => node[ctx].documentHandler(e, startClick));
});

// 判断是否点击在绑定元素之外,如果点击发生在元素之外,则执行绑定的方法
function createDocumentHandler(el, binding, vnode) {
  return function(mouseup = {}, mousedown = {}) {
    // 判断vnode和vnode.context是否存在
    // 判断鼠标的按下以及抬起时的元素是否存在
    // 判断鼠标的按下或者鼠标的抬起元素是否包含在被绑定元素的内部
    // 判断抬起鼠标的元素与被绑定的元素是否是同一元素
    // 如果有一处符合的话则 return 掉,不继续执行
    if (!vnode ||
      !vnode.context ||
      !mouseup.target ||
      !mousedown.target ||
      el.contains(mouseup.target) ||
      el.contains(mousedown.target) ||
      el === mouseup.target) return;
    
    // 如果全部都不符合,则证明点击的是被绑定元素的外部,执行绑定的方法
    if (binding.expression &&
      el[ctx].methodName &&
      vnode.context[el[ctx].methodName]) {
      vnode.context[el[ctx].methodName]();
    } else {
      el[ctx].bindingFn && el[ctx].bindingFn();
    }
  };
}

/**
 * v-clickoutside
 * @desc 点击元素外面才会触发的事件
 * @example
 * ```vue
 * <div v-element-clickoutside="handleClose">
 * ```
 */
export default {
  // 指令第一次绑定到元素时
  bind(el, binding, vnode) {
    // 将被绑定的元素 push 到 nodeList 数组中
    nodeList.push(el);
    // 创建一个唯一的 ID
    const id = seed++;
    // 被绑定元素挂载 @@clickoutsideContext 属性。将id,元素事件,字符串形式的指令表达式,绑定的值挂载到 @@clickoutsideContext 属性中。
    el[ctx] = {
      id,
      documentHandler: createDocumentHandler(el, binding, vnode),
      methodName: binding.expression,
      bindingFn: binding.value
    };
  },
  // 被绑定元素所在的模板更新时
  update(el, binding, vnode) {
    // 更新除了ID外的其他属性,也就是绑定的方法等
    el[ctx].documentHandler = createDocumentHandler(el, binding, vnode);
    el[ctx].methodName = binding.expression;
    el[ctx].bindingFn = binding.value;
  },
  // 指令与元素解绑时调用
  unbind(el) {
    let len = nodeList.length;
    // 遍历 nodeList 数组,如果 nodeList 数组中元素的 ID 与 解绑的元素的 ID 相同,则将该元素从 nodeList 数组中移除
    for (let i = 0; i < len; i++) {
      if (nodeList[i][ctx].id === el[ctx].id) {
        nodeList.splice(i, 1);
        break;
      }
    }
    delete el[ctx];
  }
};

2.3 clickoutside 指令的应用

<template>
  <!-- 使用 v-clickoutside 应用自定义指令 -->
  <div
    class="el-select"
    :class="[selectSize ? 'el-select--' + selectSize : '']"
    @click.stop="toggleMenu"
    v-clickoutside="handleClose">
  </div>
</template>
<script>
// 引入自定义指令
import Clickoutside from 'element-ui/src/utils/clickoutside';

export default {
  // 注册自定义指令
  directives: { Clickoutside },
}
</script>