laitimes

It turns out that the Vue3 ClickOutside directive is implemented like this!

It turns out that the Vue3 ClickOutside directive is implemented like this!

When we are developing some components, such as drop-down boxes or some modal boxes and other components, we hope to be able to put away or hide the corresponding elements when we click on the elements; this seems to be a very simple requirement, but in fact, there are a lot of judgment logic and code skills hidden in it, the author will combine the experience of reading element-plus and naive-ui-admin source code in the past few days, summarize and share some of my own experience and ideas.

Before learning the source code, let's lay the groundwork and understand the use of a few simple tool functions to facilitate subsequent understanding.

This article should be the most in-depth interpretation of the custom instruction ClickOutside on the whole network, the content of the article is long, remember to like and follow the collection if you feel that you have gained something, and you can click three times in a row.

Utility functions

The first is the on and off functions, which are used in naive-ui-admin to register binding and unbinding events for the function

export function on(
  element: Element | HTMLElement | Document | Window,
  event: string,
  handler: EventListenerOrEventListenerObject
): void {
  if (element && event && handler) {
    element.addEventListener(event, handler, false);
  }
}

export function off(
  element: Element | HTMLElement | Document | Window,
  event: string,
  handler: Fn
): void {
  if (element && event && handler) {
    element.removeEventListener(event, handler, false);
  }
}           

For example, when binding events to an element, it can also be very convenient to use, and it looks relatively simple:

const domClick = (ev) => {
    // ...
}
on(el, 'click', domClick)
off(el, 'click', domClick)           

By extension, using the combination of the on and off functions, we can also extend the once function to register a one-time event:

export function once(el: HTMLElement, event: string, fn: EventListener): void {
  const listener = function (this: any, ...args: unknown[]) {
    if (fn) {
      fn.apply(this, args);
    }
    off(el, event, listener);
  };
  on(el, event, listener);
}           

Here, instead of directly binding the fn function to the element, we cleverly define the listener function inside the function to bind it to the el element, and then execute the fn function inside the listener function after it is triggered, and then unbind it.

Custom directives

There are many common built-in directives such as v-if, v-show, v-model, etc., which can be used in vue, and we can easily and flexibly encapsulate our own directives to meet specific business needs and scenarios.

  • created:在绑定元素的 attribute 前
  • beforeMount: Called before the element is inserted into the DOM
  • mounted: The parent component and child nodes of the bound element are called after they are mounted
  • beforeUpdate: Called before the parent component of the bound element is updated
  • updated: After all the parent components and child nodes of the bound element are updated
  • beforeUnmount: The call before the parent component of the bound element is unmounted
  • unmounted: Called after the parent component of the bound element is unmounted
Note that there is no beforeCreate function.

There seem to be a lot of hook functions, but in fact, the common ones are mounted, updated, and beforeUnmount, and do some processing at the beginning, middle, and end of the life cycle, and the common way to write hook functions is as follows:

const myDirective = {
  mounted(el, binding, vnode, prevVnode) {
    // ...
  },
}           

Here we focus on the two parameters passed in the hook function: el and binding, el is obviously the dom element of the binding instruction, and binding is more interesting, it contains all kinds of binding data, it is itself an object, print it out, we see that it has the following properties:

  • value: value is the data we pass into the instruction.
  • arg: the parameter passed to the instruction.
  • dir: Command object.
  • instance: a component object that uses directives, not the DOM.
  • modifiers: Objects made up of modifiers.
  • oldValue: The previous value.

Let's say we write a custom directive:

<div v-click-outside:foo.stop.front="'hello'"></div>           

Then the binding object we print out will look like this:

{
  arg: "foo",
  dir: { mounted:f, beforeUnmount:f },
  instance: Proxy(Object),
  modifiers: { stop: true, front: true },
  oldValue: undefined,
  value: 'hello'
}           

Through this case, we can be very clear about the function of each parameter, oldValue is generally used when updating, and our most commonly used value is the value value, where the value value is not only limited to ordinary values, but also can be passed into objects or functions for execution, as we will see below.

Dynamic parameter directives

In the above example, v-click-outside:foo is written, and the value of the instruction parameter arg is the definite foo.

In the official documentation of vue3, we give such a scenario, we will a div, fix the layout fix to the side of the page, but need to change its position, although we can solve it by passing value into the object, but it is not very friendly, through the dynamic arg way we can easily implement this requirement;

<template>
<div>
  <div class="fixed" v-fixed:[direction]="200"></div>
  <div @click="changeDirection">改变方向</div>
</div>
</template>
<script setup>
import vFixed from '@/directives/fixed'
const direction = ref('left')

const changeDirection = () => {
  direction.value = 'right'
}
</script>           

With v-fixed:[direction], we pass the left value to the arg parameter, and click the button to toggle the value:

const fixed = {
  mounted(el, binding) {
    const s = binding.arg || 'left'
    el.style[s] = (binding.value || 200) + 'px'
  },
  updated(el, binding) {
    const s = binding.arg || 'left'
    el.style = ''
    el.style[s] = (binding.value || 200) + 'px'
  },
  beforeUnmount() {},
}

export default fixed           

In this way, we can switch between dynamic instruction parameters, and in addition, we can pass complex data such as arrays to arg.

Simplified implementation

Okay, now that we've covered the way with the introduction of utility functions and hook functions, and we have some understanding of custom directives, let's start with the simplest features and see how to implement a simplified version of ClickOutside.

import { on, off } from '@/utils/domUtils'

const clickOutside = {
  mounted(el, binding) {
    function eventHandler(e) {
      // 对el和binding进行处理,判断是否触发value函数
    }
    el.__click_outside__ = eventHandler
    on(document, 'click', eventHandler)
  },
  beforeUnmount(el) {
    if(typeof el.__click_outside__ === 'function'){
      off(document, 'click', el.__click_outside__)
    }
  },
}

export default clickOutside           

When we mount the instruction, we define and bind an eventHandler handler for the document, and mount it to the __click_outside__ attribute of the element, so that the event can be unbound when unloading.

The eventHandler function can only be defined in the directive, otherwise the el and binding cannot be obtained.

When using clickOutside, we pass a binding function to the value, so the value of binding.value is actually a function:

<template>
 <div v-click-outside="onClickOutside">
 </div>
</template>
<script setup>
const onClickOutside = () => {
  // ..
}
</script>           

The eventHandler function we defined above is also a trigger function for click events, which determines whether the target of the event is included in the el node, and executes the binding.value function if not.

{
  mounted(el, binding) {
    function eventHandler(e) {
      if (el.contains(e.target) || el === e.target) {
        return false
      }
      // 触发binding.value
      if (binding.value && typeof binding.value === 'function') {
        binding.value(e)
      }
    }
  }
}           

Here we use a contains function that returns a boolean value to determine if a node is a child of another node, as explained in the MDN documentation:

The contains() method returns a boolean value that indicates whether a node is a descendant of a given node, i.e., the node itself, its direct children (childNodes), the direct children of the child, and so on.

It should be noted that since contains will return the node itself judgment to true, which is not the result we want, we also need to display the judgment filter with el === e.target.

So the final directive looks like this:

import { on , off } from '@/utils/domUtils'
const clickOutside = {
  mounted(el, binding) {
    function eventHandler(e) {
      if (el.contains(e.target) || el === e.target) {
        return false
      }
      if (binding.value && typeof binding.value === 'function') {
        binding.value(e)
      }
    }
    el.__click_outside__ = eventHandler
    on(document, 'click', eventHandler)
  },
  beforeUnmount(el) {
    if(typeof el.__click_outside__ === 'function'){
      off(document, 'click', el.__click_outside__)
    }
  },
}
export default clickOutside           

Optimized for the simplified version

We continue to optimize this simplified version of the function, and we find that it is very troublesome to bind the document event every time the instruction is initialized and removed, and if the document binding event is put out and bound only once, wouldn't it reduce the cumbersome binding and unbinding each time.

on(document, 'click', (e) => {
  // ...
})

const clickOutside = {
  mounted(el, binding) {
    // ...
  }
}           

So, the next question is, how can the eventHandler function defined in each instruction be executed in the click event, so as to determine whether the binding.value function needs to be triggered?

Yes, we can define an array to collect the eventHandler function of all instructions, and execute it uniformly when clicked, but the problem with the array is that it is not easy to find the eventHandler function corresponding to each el when it is finally unbound.

However, here we have more cleverly defined a Map object, because our eventHandler function and el are one-to-one pairs, using the key value of the Map object to store any data:

const nodeList = new Map()

on(document, 'click', (e) => {
  for (const fn of nodeList.values()) {
    fn(e)
  }
})

const clickOutside = {
  mounted(el, binding) {
    function eventHandler(e) {
      // ...
    }
    nodeList.set(el, eventHandler)
  },
  beforeUnmount(el) {
    nodeList.delete(el)
  },
}           

We collect the eventHandler into the nodeList, trigger each eventHandler when the document is clicked, and then determine whether the bind.value needs to be triggered in the eventHandler.

The simplified version is upgraded and optimized

Although it is a simplified version, we can optimize it further, we found that the source code clickOutside.ts of naive-ui-admin does not register the click event, but registers the mouseup/mousedown event, why is that? We found the original sentence about the click/mouseup/mousedown event in MDN, which says this:

The click event is triggered when the button of the pointing device (usually the primary mouse key) is pressed and released on an element.

The mouseup/mousedown event fires on a pointing device, such as a mouse or touchpad, when the button is pressed within that element.

Therefore, in summary, the click event is only triggered by the left mouse button, while the mouseup/mousedown event is triggered by any fixed device, such as the right mouse button or the scroll wheel button in the middle.

Click on the DOM element, and the order of the three events is: mousedown, mouseup, click.

The conclusion drawn above can be verified in VueUse at the same time, if we use VueUse's onClickOutside, we will find that it only fires when the left mouse button is used, and element-plus can be triggered with three buttons at the same time.

If we open the VueUse source code, we will find that it is registered with the click event:

const cleanup = [
    useEventListener(window, 'click', listener, { passive: true, capture }),
    // 省略其他代码
]
cleanup.forEach(fn => fn())           

So knowing the difference between the three events, back to our simple version, we can upgrade: first rewrite the click event, divide it into mousedown and mouseup, but these two events have corresponding event objects e, we save one first, and then judge the two event objects in the eventHandler.

let startClick

on(document, 'mousedown', (e) => {
  startClick = e
})

on(document, 'mouseup', (e) => {
  for (const fn of nodeList.values()) {
    fn(e, startClick)
  }
})           

The eventHandler also receives the ev object instead of click, but mousedown/mouseup:

function eventHandler(mouseup, mousedown) {
  if (
    el.contains(mouseup.target) ||
    el === mouseup.target ||
    el.contains(mousedown.target) ||
    el === mousedown.target
  ) {
    return false
  }
  if (binding.value && typeof binding.value === 'function') {
    binding.value()
  }
}           

In this way, our simple function has been upgraded, and it can also support left, middle, and right-click events at the same time.

Source code implementation logic

After the iterative upgrade of the above simple version, I believe you should have a certain understanding of the overall implementation process and principle of ClickOutside, and the source code has basically been talked about; let's take a look at what logic is in its source code, but it is nothing more than a more comprehensive judgment, and the following is mainly based on the clickOutside.ts in the naive-ui-admin source code.

First, let's take a look at its main code structure:

import { on } from '@/utils/domUtils';
const nodeList = new Map();

let startClick: MouseEvent;

on(document, 'mousedown', (e: MouseEvent) => (startClick = e));
on(document, 'mouseup', (e: MouseEvent) => {
  for (const { documentHandler } of nodeList.values()) {
    documentHandler(e, startClick);
  }
});

function createDocumentHandler(el, binding) {
  return function (mouseup, mousedown) {
    // ..
  }
}

const ClickOutside: ObjectDirective = {
  beforeMount(el, binding) {
    nodeList.set(el, {
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    });
  },
  updated(el, binding) {
    nodeList.set(el, {
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    });
  },
  unmounted(el) {
    nodeList.delete(el);
  },
};

export default ClickOutside;           

We found that in addition to the createDocumentHandler function, other functions have been implemented in the above simplified version, since our handler function needs to use el and binding, the function of createDocumentHandler here is to create an anonymous closure handler function, store the handler function in nodeList, and then you can refer to el and binding.

So let's focus on what this createDocumentHandler does, first of all, it receives the el and binding parameters in the directive, and the anonymous function it returns is called in the mouseup event, and receives the mouseup and mousedown event objects.

Let's continue to look at what is done in the created documentHandler function, which mainly has 6 judgment flags, as long as one of the following 6 conditions is met, that is, it returns true, and the binding.value function will not be triggered:

return function (mouseup, mousedown) {
  // ...
  if (
    isBound ||
    isTargetExists ||
    isContainedByEl ||
    isSelf ||
    isTargetExcluded ||
    isContainedByPopper
  ) {
    return;
  }
  binding.value();
}           

The first two judgments are integrity judgments, the first test condition is to check whether binding or binding.instance exists, and the second test condition is whether the target element of mouseup/mousedown trigger target exists.

const mouseUpTarget = mouseup.target as Node;
const mouseDownTarget = mousedown.target as Node;

// 判断一
const isBound = !binding || !binding.instance;
// 判断二
const isTargetExists = !mouseUpTarget || !mouseDownTarget;           

The third and fourth judgments are element judgments, which are similar to our simplified version, isContainedByEl determines whether mouseUpTarget and mouseDownTarget are in the el element, if they are true, and isSelf is to determine whether the triggering element is el itself.

// 判断三
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
// 判断四
const isSelf = el === mouseUpTarget;           

The fifth and sixth judgments are special case judgments, and the fifth judgment is whether the target of the event is included by the element in excludes, and if so, isTargetExcluded is true.

// 判断五
const isTargetExcluded =
  (excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
  (excludes.length && excludes.includes(mouseDownTarget as HTMLElement));

const popperRef = (
  binding.instance as ComponentPublicInstance<{
    popperRef: Nullable<HTMLElement>;
  }>
).popperRef;

// 判断六
const isContainedByPopper =
  popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));           

Here we focus on the use of the Excludes filter array, under normal circumstances, the DOM node under the bound element EL is judged to be filtered, but in some cases, we need to filter other nodes additionally when clicking (this special case, we will see in the next article); When we created the documentHandler, we put together this array from this dynamic parameter directive arg:

function createDocumentHandler(el, binding) {
  let excludes = [];
  if (Array.isArray(binding.arg)) {
    excludes = binding.arg;
  } else {
    excludes.push(binding.arg);
  }
  // 其他判断条件
}           

Combined with the dynamic parameter directive above, we can use the following exclude to additionally add filtered DOMs:

<template>
  <div v-click-outside:[excludeDom]="clickOut"></div>
</template>
<script setup>
const excludeDom = ref([])
const clickOut = () => {}

onMounted(() => {
  excludeDom.value.push(document.querySelector(".some-class"));
});
</script>           

summary

This article summarizes the implementation logic of ClickOutside under vue3, from tool function encapsulation, to the learning of custom instructions, and then to the in-depth learning of the source code; although the overall logic of ClickOutside is not very complicated, but when I first read the source code, it is difficult to understand some of the usages; especially in the registration of events, why not use click, but use mouseup/ mousedown: After in-depth thinking and comparison, I slowly understood the author's intention.