Vue3 custom instruction: ClickOutside (click a location outside the current area)

Hello, everyone. I'm SuperYing. Today, we are talking about a Vue3 user-defined instruction - ClickOutside. As the name suggests, it deals with the scene of clicking on a location outside the current area.

Vue instruction

First, let's review the knowledge points related to the Vue instruction.

brief introduction

The Vue instruction is a special attribute with a v-prefix. The value of the directive attribute is expected to be a single JavaScript expression. When the value of an expression changes, the function of the instruction is to apply the associated influence to the DOM in a responsive manner.

Registration method

1. Global registration
Global registration through the direct method of Vue instance object:

import { createApp } from 'vue'
const app = Vue.createApp({})
// Register a global custom instruction ` v-focus`
app.directive('focus', {
  // When the bound element is mounted in the DOM
  mounted(el) {
    // Focus element
    el.focus()
  }
})

2. Partial registration
Vue component provides the directives option to implement local registration instructions:

directives: {
  focus: {
    // Definition of instruction
    mounted(el) {
      el.focus()
    }
  }
}

Hook function

An instruction definition object can provide the following hook functions (all optional):

  • created: is called before the bound attribute or event listener is applied. This is useful when the instruction needs to be attached to the event listener before the ordinary v-on event listener is called.
  • beforeMount: top note is bound to the element for the first time and is called before mounting the parent component.
  • The top note is called before the parent component of the binding element is linked to mounted:.
  • beforeUpdate: is called before updating the VNode containing the component.
  • updated: is called after updating the VNode of the component and its subcomponents after updating it. Base note: VNode
  • Top note: beforeUnmount: is called before uninstalling the parent component of the binding element.
  • unmounted: only called once when the instruction is bound to the element and the parent component has been unloaded.

Example:

import { createApp } from 'vue'
const app = createApp({})

// register
app.directive('my-directive', {
  // The instruction has a set of lifecycle hooks:
  // Top note before the attribute of the binding element or the event listener is applied.
  created() {},
  // Call before the parent component of the binding element is mounted.
  beforeMount() {},
  // After the parent component of the binding element is mounted, it is called. Base note
  mounted() {},
  // Top note before VNode updates including components
  beforeUpdate() {},
  // After the VNode update of the VNode containing its components and its subcomponents, it is called.
  updated() {},
  // Calls before the parent component of the binding element is uninstalled
  beforeUnmount() {},
  // After the parent component of the binding element is uninstalled, it is called.
  unmounted() {}
})

// Registration (function instruction)
app.directive('my-directive', () => {
  // This will be called as' mounted 'and' updated '
})

// getter, if registered, returns the instruction definition
const myDirective = app.directive('my-directive')

Hook function parameters:

  • el: the element to which the instruction is bound. Can be used to directly manipulate DOM.
  • binding: an object that contains the following properties.
    • Instance: the instance of the component that uses the instruction.
    • Value: the value passed to the instruction. For example, in v-my-directive="1 + 1", the value is 2
    • oldValue: previous value, available only in beforeUpdate and updated hooks. Available with or without changes.
    • Arg: the parameter (if any) passed to the instruction. For example, in v-my-directive:foo, arg is foo.
    • Modifiers: object containing modifiers, if any. For example, in v-my-directive foo. In bar, modifiers are {foo: true, bar: true}.
    • dir: an object that is passed as a parameter when registering an instruction. For example, in the following instructions
    app.directive('focus', {
      mounted(el) {
        el.focus()
      }
    })
    
    dir will be
    {
      mounted(el) {
        el.focus()
      }
    }
    
  • vnode: a blueprint of real DOM elements, corresponding to the el parameters received above.
  • prevode: the previous virtual node, which is only available in the beforeUpdate and updated hooks.

Well, the Vue instruction is reviewed here. In addition to the above contents, there are also the use of dynamic parameters, abbreviations and component applications. More details can be moved Vue3 official website.

ClickOutside implementation

Application scenario

Typical scenario: suppose we have a drop-down box component. When the drop-down box is expanded, clicking an element outside the drop-down box can automatically close the drop-down box.

analysis

First, let's analyze the requirements of ClickOutside.

  1. If you want to know whether the click position is outside the current DOM, you need to confirm the range of the current DOM element, which happens to correspond to the el parameter of the user-defined instruction.
  2. Event listening. Listen to the corresponding events of the element, such as click, mousedown + mouseup, etc. which part of the DOM to listen to? This part of the DOM should include both the current Dom and parts outside the current DOM, so the document is very appropriate.
  3. Now that the element and the listening event have been confirmed, compare the Event target in the event handler function. If the target is outside the current DOM element, trigger the handler function bound by the instruction.

code

Combined with the above process analysis, we implement a new version of ClickOutside:

<template>
    <div v-click-outside="onClickOutside"></div>
</template>
<script setup>
// Define local user-defined instructions, which are written under the setup tag. The instruction name starts with v and no additional registration logic is required
const vClickOutside = {
    mounted(el, binding) {
        function eventHandler(e) {
            if (el.contains(e.target)) {
                return false
            }
            // If the bound parameter is a function, it should also be a function under normal circumstances. Execute
            if (binding.value && typeof binding.value === 'function') {
                binding.value(e)
            }
        }
        // Used for logging off event listening before destruction
        el.__click_outside__ = eventHandler
        // Add event listener
        document.addEventListener('click', eventHandler)
    },
    beforeUnmount(el) {
        // Remove event listener
        document.removeEventListener('click', el.__click_outside__)
        // Delete useless attributes
        delete el.__click_outside__
    }
}

// Customize the instruction parameters and click the processing function of the external area, such as closing the pop-up window
const onClickOutside = () => {
    console.log('Click external DOM')
}
</script>

Classic case (element plus)

// The DOM element and its event listening function form a Map. key is the DOM element and value is the corresponding handler array
const nodeList: FlushList = new Map()
// Mouse operation is divided into two stages
// 1. mousedown press the mouse
// 2. mouseup release the mouse
// Only after completing the above two steps can a complete click event be triggered
// startClick is the Event parameter of the mousedown Event handler
let startClick: MouseEvent
if (isClient) {
    // Listen to the mousedown event and set the startClick object
    on(document, 'mousedown', (e: MouseEvent) => (startClick = e))
    // Listen to the mouseup event and execute the event handling function in a loop, but whether it is actually executed or not depends on the execution conditions in createDocumentHandler.
    on(document, 'mouseup', (e: MouseEvent) => {
        for (const handlers of nodeList.values()) {
            for (const { documentHandler } of handlers) {
                documentHandler(e as MouseEvent, startClick)
            }
        }
    })
}

// Generate event handler function for mouseup event call
function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
    // ClickOutside allows you to set the exception target through the parameter. Clicking the DOM corresponding to the parameter will not trigger the processing function
    let excludes: HTMLElement[] = []
    // Normally, the parameter is in the form of array, and the parameter array is directly set as the exception DOM
    if (Array.isArray(binding.arg)) {
        excludes = binding.arg
    } else if ((binding.arg as unknown) instanceof HTMLElement) {
        // Fault tolerant processing. If the parameter is HTMLElement, push it into excludes
        excludes.push(binding.arg as unknown as HTMLElement)
    }
    // Returns a function whose parameters are the Event parameters of the mouseup and mousedown Event handling functions
    return function (mouseup, mousedown) {
        // popper (if any, the following pull box, etc.)
        const popperRef = (binding.instance as ComponentPublicInstance<{popperRef: Nullable<HTMLElement>}>).popperRef   
        // target triggered by mouseup event
        const mouseUpTarget = mouseup.target as Node
        // target triggered by mousedown event
        const mouseDownTarget = mousedown?.target as Node
        // Check case 1: whether ClickOutside is bound with a processing function
        const isBound = !binding || !binding.instance
        // Check whether the trigger event of target 2 exists
        const isTargetExists = !mouseUpTarget || !mouseDownTarget
        // Verification case 3: whether the target triggered by the event is within el
        const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget)
        // Verification case 4: whether the target triggered by the event is el
        const isSelf = el === mouseUpTarget
        // Verification case 5: whether the target triggered by the event is in the exception DOM array
        const isTargetExcluded = (excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) || (excludes.length && excludes.includes(mouseDownTarget as HTMLElement))
        // Verification case 6: if there is a popper, is the target triggered by the event within the popper
        const isContainedByPopper = popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget))
        // If any of the verification conditions in the above 6 are met, jump out directly; otherwise, execute the processing function bound by the ClickOutside instruction
        if (
            isBound ||
            isTargetExists ||
            isContainedByEl ||
            isSelf ||
            isTargetExcluded ||
            isContainedByPopper
        ) {
            return
        }
        binding.value(mouseup, mousedown)
    }
}

// clickOutside implementation code
const ClickOutside: ObjectDirective = {
    beforeMount(el: HTMLElement, binding: DirectiveBinding) {
        // There may be multiple handled functions for the current DOM element
        // 1. Determine whether the current el exists in the monitored DOM element. If it does not exist, initialize it to an empty array []
        if (!nodeList.has(el)) {
            nodeList.set(el, [])
        }
        // Add the event handler to el the corresponding handler list
        nodeList.get(el).push({
            // Call the createDocumentHandler method to return the processing function. It will check whether various boundary conditions are checked internally to determine whether binding is required Value bound handler
            documentHandler: createDocumentHandler(el, binding),
            bindingFn: binding.value,
        })
    },

    updated(el: HTMLElement, binding: DirectiveBinding) {
        if (!nodeList.has(el)) {
            nodeList.set(el, [])
        }
        // Gets all the processing functions of the current el binding
        const handlers = nodeList.get(el)
        // Get the index of processing function before update
        const oldHandlerIndex = handlers.findIndex(
            (item) => item.bindingFn === binding.oldValue
        )
        // Set a new handler to replace the old one
        const newHandler = {
            documentHandler: createDocumentHandler(el, binding),
            bindingFn: binding.value,
        }
        // If the original processing function exists, replace it; If not, add;
        if (oldHandlerIndex >= 0) {
            // replace the old handler to the new handler
            handlers.splice(oldHandlerIndex, 1, newHandler)
        } else {
            handlers.push(newHandler)
        }
    },
    unmounted(el: HTMLElement) {
        // Remove all listening event handling functions bound by el
        nodeList.delete(el)
    },
}

Tags: Javascript Front-end Vue elementUI

Posted by Trium918 on Tue, 19 Apr 2022 17:17:20 +0300