Source code analysis and improvement of v-clickoutside

Time:2021-3-8

1. Preface

Recently, we met a demand that the company needs to make a search selection component similar to Facebook. I plan to design it with the combination of El input and El cascade panel. In the design process, we refer to the source code of El cascade, in which the v-clickoutside custom instruction is worth studying, so we write an article to record it.

2. Basic knowledge

(1)v-directive

Vue document has been written very clearly, here is only the website:Vue Chinese document – custom instructions

(2)v-clickoutside

Put the source code in element UI first

//element-ui/src/utils/clickoutside.js
import Vue from 'vue';
import { on } from 'element-ui/src/utils/dom';

const nodeList = [];
const ctx = '@@clickoutsideContext';

let startClick;
let seed = 0;

!Vue.prototype.$isServer && on(document, 'mousedown', e => (startClick = e));

!Vue.prototype.$isServer && on(document, 'mouseup', e => {
  nodeList.forEach(node => node[ctx].documentHandler(e, startClick));
});

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[el[ctx].methodName]();
    } else {
      el[ctx].bindingFn && el[ctx].bindingFn();
    }
  };
}

/**
 * v-clickoutside
 *@ desc click the event outside the element to trigger
 * @example
 * ```vue
 * <div v-element-clickoutside="handleClose">
 * ```
 */
export default {
  bind(el, binding, vnode) {
    nodeList.push(el);
    const id = seed++;
    el[ctx] = {
      id,
      documentHandler: createDocumentHandler(el, binding, vnode),
      methodName: binding.expression,
      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;

    for (let i = 0; i < len; i++) {
      if (nodeList[i][ctx].id === el[ctx].id) {
        nodeList.splice(i, 1);
        break;
      }
    }
    delete el[ctx];
  }
};
//element-ui/src/utils/dom
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);
      }
    };
  }
})();

V-clickoutside is used to handle the click events outside the target node, and the most important thing is to close the expanded contents such as the drop-down box.

This program uses the design pattern of subscription publishing. First, it binds mouseup and MouseDown events in the document through the user-defined on method (functions compatible with two binding methods of addeventlistener and attachevent). The two events record the node when the mouse is pressed and released, and then compare with the target node. If the click element is the target node or the target node If the target node is included, the corresponding execution function will be triggered.

When executing the bind periodic function, first store the element in the NodeList, and assign an attribute named “@ @ clickoutsidecontext” to the element. The object stores ID (used to mark the element, so that the element can be removed from the NodeList in the unbind periodic function), documenthandler (used to store the execution function), methodname( binding.expression ),bindingFn( binding.value )。

When the update cycle function is executed, the CTX attribute in El is updated. When the unbind function is executed, the object will be rejected according to the ID recorded in the CTX attribute in El.

The createdocumenthandler function will return a function that can access mouseup (the element when the mouse is pressed) and MouseDown (the element when the mouse is released) through the closure. When the function is executed, it will judge whether the function bound in v-clickoutside is executed according to the following conditions:

  1. !vnode || ! vnode.context || ! mouseup.target || ! mousedown.target : judge vnode and vnode.context Are there any other goals
  2. el.contains ( mouseup.target ) ||  el.contains ( mousedown.target ) || el === mouseup.target : judge whether the current target node is the node when the mouse is released, or whether it contains the element when the mouse is clicked or released
  3. ( vnode.context.popperElm &( vnode.context.popperElm .contains( mouseup.target ) || vnode.context.popperElm .contains( mousedown.target ))): determine whether the Popper elm in the virtual node vnode exists on the suspended component.

If any of the above conditions are met, the function will return. Otherwise, the execution method stored in the attribute named “@ @ clickoutsidecontext” in the bound target node will be executed.

3. Improvement of source code

Analysis: I feel that El [CTX] is designed to facilitate other scopes to access the execution functions bound by el. Based on my situation, I changed NodeList into (key, value) of map to save El [CTX]. The improvements are as follows:

let clickInEvent
let nodeEventRecorder = new Map()

document.addEventListener('mousedown', e => (clickInEvent = e))
document.addEventListener('mouseup', e => {
  nodeEventRecorder.forEach((value) => {
    value(e, clickInEvent)
  })
})

function createHandler (el, binding, vnode) {
  return function (mouseup = {}, mousedown = {}) {
    if (!vnode ||
      !vnode.context ||
      !mouseup.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 && vnode.context[binding.expression]) {
      vnode.context[binding.expression]()
    } else {
      if (typeof (binding.value) === 'function') {
        binding.value()
      } else {
        throw new Error('value should be a function')
      }
    }
  }
}

let directive = {
  bind (el, binding, vnode) {
    nodeEventRecorder.set(el, createHandler(el, binding, vnode))
  },
  update (el, binding, vnode) {
    nodeEventRecorder.set(el, createHandler(el, binding, vnode))
  },
  unbind (el) {
    nodeEventRecorder.delete(el)
  }
}

export default directive

Some people will ask why you don’t use object as the container type for storing mappings. That’s because the key of object can only be data of string or symbol type. The key of a map can be any data type, including a node.

After replacing with map, there are no variables such as El [CTX] and Seed +. You don’t need to traverse the list to perform deletion in unbind.

The results are as followsSource code analysis and improvement of v-clickoutside