Vue3 自定义指令:ClickOutside(点击当前区域之外的位置)

2,897 阅读6分钟

哈喽,大家好,我是 SuperYing。今天我们聊一个 Vue3 自定义指令 --- ClickOutside,顾名思义,就是处理点击当前区域之外的位置的场景。

Vue 指令

首先,我们先来回顾一下 Vue 指令 相关的知识点。

简介

Vue 指令是带有 v- 前缀的特殊 attribute。指令 attribute 的值预期是 单个 JavaScript 表达式。指令的职责是当表达式的值改变时,将其产生的连带影响,响应式的作用于 DOM。

注册方式

1.全局注册
通过 Vue 实例对象的 directive 方法全局注册:

import { createApp } from 'vue'
const app = Vue.createApp({})
// 注册一个全局自定义指令 `v-focus`
app.directive('focus', {
  // 当被绑定的元素挂载到 DOM 中时……
  mounted(el) {
    // 聚焦元素
    el.focus()
  }
})

2.局部注册
Vue 组件提供 directives 选项,实现局部注册指令:

directives: {
  focus: {
    // 指令的定义
    mounted(el) {
      el.focus()
    }
  }
}

钩子函数

一个指令定义对象可以提供如下几个钩子函数 (均为可选):

  • created: 在绑定的 attribute 或事件监听器被应用之前调用。在指令需要附加在普通的 v-on 事件监听器调用前的事件监听器中时,这很有用。
  • beforeMount: 当指令第一次绑定到元素并且在挂载父组件之前调用。
  • mounted: 在绑定元素的父组件被挂在前调用。
  • beforeUpdate: 在更新包含组件的 VNode 之前调用。
  • updated: 在包含组件的 VNode 及其子组件的 VNode 更新后调用。
  • beforeUnmount: 在卸载绑定元素的父组件之前调用。
  • unmounted: 当指令与元素接触绑定且父组件已卸载时,只调用一次。

示例:

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

// 注册
app.directive('my-directive', {
  // 指令具有一组生命周期钩子:
  // 在绑定元素的 attribute 或事件监听器被应用之前调用
  created() {},
  // 在绑定元素的父组件挂载之前调用
  beforeMount() {},
  // 在绑定元素的父组件挂载之后调用
  mounted() {},
  // 在包含组件的 VNode 更新之前调用
  beforeUpdate() {},
  // 在包含组件的 VNode 及其子组件的 VNode 更新之后调用
  updated() {},
  // 在绑定元素的父组件卸载之前调用
  beforeUnmount() {},
  // 在绑定元素的父组件卸载之后调用
  unmounted() {}
})

// 注册 (函数指令)
app.directive('my-directive', () => {
  // 这将被作为 `mounted` 和 `updated` 调用
})

// getter, 如果已注册,则返回指令定义
const myDirective = app.directive('my-directive')

钩子函数参数:

  • el: 指令绑定到的元素。可用于直接操作 DOM。
  • binding: 包含以下属性的对象。
    • instance:使用指令的组件实例。
    • value: 传递给指令的值。例如在 v-my-directive="1 + 1" 中,该值为 2.
    • oldValue: 先前的值,仅在 beforeUpdate 和 updated 钩子中可用。无论是否有更改都可用。
    • arg: 传递给指令的参数(如果有的话)。例如在 v-my-directive:foo 中,arg 为 foo
    • modifiers: 包含修饰符(如果有的话)的对象。例如在 v-my-directive.foo.bar 中,modifiers 为 {foo: true,bar: true}
    • dir: 一个对象,在注册指令时作为参数传递。例如,在以下指令中
    app.directive('focus', {
      mounted(el) {
        el.focus()
      }
    })
    
    dir 将会是
    {
      mounted(el) {
        el.focus()
      }
    }
    
  • vnode: 一个真实 DOM 元素的蓝图,对应上面收到的 el 参数。
  • prevode: 上一个虚拟节点,仅在 beforeUpdate 和 updated 钩子中可用。

好啦,Vue 指令相关就回顾到这里,除了上面的内容之外,还有动态参数使用,简写及组件应用等。更多详情可以移步 Vue3 官网

ClickOutside 实现

应用场景

典型场景:假设我们有一个下拉框组件,当下拉框展开的时候,点击下拉框之外的元素可以自动关闭下拉框。

分析

首我们先来分析一下 ClickOutside 的需求。

  1. 若想要明确点击位置是否为当前 DOM 之外,就要确认当前 DOM 元素的范围,恰巧对应自定义指令的 el 参数客户。
  2. 事件监听。监听元素的相应事件,如 clickmousedown + mouseup 等,那么监听哪部分 DOM 呢,这部分 DOM 应该同时包括当前 DOM 和当前 DOM 之外的部分,那么 document 就很合适了。
  3. 现在元素和监听事件已经确认,在事件处理函数中对比 Event target,若 target 在当前 DOM 元素之外,触发指令绑定的处理函数即可。

代码

结合上面的过程分析,我们来实现一个丐版 ClickOutside

<template>
    <div v-click-outside="onClickOutside"></div>
</template>
<script setup>
// 定义局部自定义指令,这里是在 setup 标签下编写,指令名称以 v 开头,无需额外注册逻辑
const vClickOutside = {
    mounted(el, binding) {
        function eventHandler(e) {
            if (el.contains(e.target)) {
                return false
            }
            // 如果绑定的参数是函数,正常情况也应该是函数,执行
            if (binding.value && typeof binding.value === 'function') {
                binding.value(e)
            }
        }
        // 用于销毁前注销事件监听
        el.__click_outside__ = eventHandler
        // 添加事件监听
        document.addEventListener('click', eventHandler)
    },
    beforeUnmount(el) {
        // 移除事件监听
        document.removeEventListener('click', el.__click_outside__)
        // 删除无用属性
        delete el.__click_outside__
    }
}

// 自定义指令参数,点击外部区域的处理函数,如关闭弹窗
const onClickOutside = () => {
    console.log('点击了外部 DOM')
}
</script>

经典案例(Element-Plus)

// DOM 元素与其事件监听函数组成的 Map,key 是 DOM 元素,value 是对应的处理函数数组
const nodeList: FlushList = new Map()
// 鼠标操作分为两个阶段
// 1. mousedown 按下鼠标
// 2. mouseup 松开鼠标
// 完成以上两步,才算完成一次完整的 click 事件触发
// startClick 是 mousedown 事件处理函数的 Event 参数
let startClick: MouseEvent
if (isClient) {
    // 监听 mousedown 事件,设置 startClick 对象
    on(document, 'mousedown', (e: MouseEvent) => (startClick = e))
    // 监听 mouseup 事件,循环执行事件处理函数,但是至于是否真正执行,要满足 createDocumentHandler 中的执行条件才行。
    on(document, 'mouseup', (e: MouseEvent) => {
        for (const handlers of nodeList.values()) {
            for (const { documentHandler } of handlers) {
                documentHandler(e as MouseEvent, startClick)
            }
        }
    })
}

// 生成事件处理函数,供 mouseup 事件调用
function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
    // ClickOutside 允许通过参数设置例外的 target,点击该参数对应的 DOM 不会触发处理函数
    let excludes: HTMLElement[] = []
    // 正常情况,参数是数组形式,直接将该参数数组设置为例外 DOM
    if (Array.isArray(binding.arg)) {
        excludes = binding.arg
    } else if ((binding.arg as unknown) instanceof HTMLElement) {
        // 容错处理,如果参数是 HTMLElement,将其 push 到 excludes 中
        excludes.push(binding.arg as unknown as HTMLElement)
    }
    // 返回一个函数,参数是 mouseup 和 mousedown 事件处理函数的 Event 参数
    return function (mouseup, mousedown) {
        // popper(若存在的话,如下拉框等)
        const popperRef = (binding.instance as ComponentPublicInstance<{popperRef: Nullable<HTMLElement>}>).popperRef   
        // mouseup 事件触发的 target
        const mouseUpTarget = mouseup.target as Node
        // mousedown 事件触发的 target
        const mouseDownTarget = mousedown?.target as Node
        // 校验情况1:ClickOutside 是否绑定了处理函数
        const isBound = !binding || !binding.instance
        // 校验情况2:是否存在事件触发的 target
        const isTargetExists = !mouseUpTarget || !mouseDownTarget
        // 校验情况3:事件触发的 target 是否在 el 内
        const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget)
        // 校验情况4:事件触发的 target 是否为 el
        const isSelf = el === mouseUpTarget
        // 校验情况5:事件触发的 target 是否在例外 DOM 数组内
        const isTargetExcluded = (excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) || (excludes.length && excludes.includes(mouseDownTarget as HTMLElement))
        // 校验情况6:若存在 popper,事件触发的 target 是否在 popper 内
        const isContainedByPopper = popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget))
        // 若满足以上 6 中校验情况的任意一种,直接跳出,否则执行 ClickOutside 指令绑定的处理函数
        if (
            isBound ||
            isTargetExists ||
            isContainedByEl ||
            isSelf ||
            isTargetExcluded ||
            isContainedByPopper
        ) {
            return
        }
        binding.value(mouseup, mousedown)
    }
}

// clickOutside 实现代码
const ClickOutside: ObjectDirective = {
    beforeMount(el: HTMLElement, binding: DirectiveBinding) {
        // 当前的 DOM 元素可能存在多个处理好函数
        // 1.确定监听的 DOM 元素中是否存在当前 el,若不存在,初始化为空数组[]
        if (!nodeList.has(el)) {
            nodeList.set(el, [])
        }
        // 将事件处理函数添加到 el 对应的处理函数列表中
        nodeList.get(el).push({
            // 调用 createDocumentHandler 方法返回处理函数,内部会对是否校验各种边界情况,判断是否需要执行 binding.value 绑定的处理函数
            documentHandler: createDocumentHandler(el, binding),
            bindingFn: binding.value,
        })
    },

    updated(el: HTMLElement, binding: DirectiveBinding) {
        if (!nodeList.has(el)) {
            nodeList.set(el, [])
        }
        // 获取当前 el 绑定的所有处理函数
        const handlers = nodeList.get(el)
        // 获取更新前的处理函数索引
        const oldHandlerIndex = handlers.findIndex(
            (item) => item.bindingFn === binding.oldValue
        )
        // 设置新的处理函数,用于替换旧的处理函数
        const newHandler = {
            documentHandler: createDocumentHandler(el, binding),
            bindingFn: binding.value,
        }
        // 若原先存在处理函数,替换;若不存在,添加;
        if (oldHandlerIndex >= 0) {
            // replace the old handler to the new handler
            handlers.splice(oldHandlerIndex, 1, newHandler)
        } else {
            handlers.push(newHandler)
        }
    },
    unmounted(el: HTMLElement) {
        // 移除 el 绑定的所有监听事件处理函数
        nodeList.delete(el)
    },
}

好啦!以上便是「 Vue3 自定义指令:ClickOutside 」的全部内容,感谢阅读。

欢迎各路大佬讨论、批评、指正,共同进步才是硬道理!