vue中使用事件委托delegate来实现click-outside和点击事件处理

什么是事件委托

请看文档:developer.mozilla.org/zh-CN/docs/...

事件委托在十多年前jQuery大行其道的时候使用频率极高,彼时angular、react、vue还没有出生,事件委托是每个前端程序员知道并熟练掌握的一项性能优化技能,也是面试必问的问题。

因为现在虚拟dom、AST语法树让列表渲染的性能得到了大量的提升,事件委托被使用频率没有那么高而被忽视,所以现在的程序员了解和使用得比以前少了。

有什么使用场景

典型的应用场景有:

  1. 点击弹窗层外部元素外部隐藏弹出层

  2. 大数据列表

    大数据列表mdn的实例里已经有了,使用事件委托,对大数据列表优化的效果还是非常可观的。

  3. 处理v-html中的a标签点击事件。

click-outside

vue指令实现事件委托

ts 复制代码
import type { DirectiveBinding, ObjectDirective } from 'vue'

type DocumentHandler = <T extends MouseEvent>(e:T) => void
interface ListProps {
  documentHandler?: DocumentHandler
}

let nodeList: ListProps = {}

/**
 * 创建文档事件
 * 
 * @param el HTMLElement
 * @param binding binding
 * @returns Function
 */
function createDocumentHandler(
  el: HTMLElement,
  binding: DirectiveBinding
): DocumentHandler {
  return function (e: MouseEvent) {
    const target = e.target as HTMLElement
    if (el.contains(target)) {
      return false
    }
    binding.value(e)
  }
}

const clickOutsideHandle = (e: MouseEvent) => {
  const { documentHandler } = nodeList
  if (documentHandler) {
    documentHandler(e)
  }
}


export const clickOutside: ObjectDirective = {
  beforeMount(el, binding) {
    nodeList = {
      documentHandler: createDocumentHandler(el, binding)
    }
  },
  mounted() {
    window.addEventListener('click', clickOutsideHandle)
  },
  updated(el, binding) {
    nodeList = {
      documentHandler: createDocumentHandler(el, binding)
    }
  },
  unmounted() {
    window.removeEventListener('click', clickOutsideHandle)
  }
}
ini 复制代码
注册

<div v-click-outside="fn"></div>

扩展: 忽略元素 vue自定义指令传参 增加忽略列表

ts 复制代码
import type { DirectiveBinding, ObjectDirective } from 'vue'

type DocumentHandler = <T extends MouseEvent>(e:T) => void
interface ListProps {
  documentHandler?: DocumentHandler
}

let nodeList: ListProps = {}

const isIgnoresElement = (options = {}, target: HTMLElement) => {
  if (!target) {
    return false
  }
  const { ignores = [] } = options
  if (typeof ignores === 'string') {
    return document.querySelector(ignores).contains(target)
  } else if (Array.isArray(ignores) && ignores.length > 0) {
    return ignores.every((element) => {
      return document.querySelector(element).contains(target)
    })
  }
  return false
}
/**
 * 创建文档事件
 * 
 * @param el HTMLElement
 * @param binding binding
 * @returns Function
 */
function createDocumentHandler(
  el: HTMLElement,
  binding: DirectiveBinding
): DocumentHandler {
  return function (e: MouseEvent) {
    const target = e.target as HTMLElement
    if (el.contains(target)) {
      return false
    }
    if (isIgnoresElement(binding.arg, target)) {
      return false
    }
    binding.value(e)
  }
}

const clickOutsideHandle = (e: MouseEvent) => {
  const { documentHandler } = nodeList
  if (documentHandler) {
    documentHandler(e)
  }
}


export const clickOutside: ObjectDirective = {
  beforeMount(el, binding) {
    nodeList = {
      documentHandler: createDocumentHandler(el, binding)
    }
  },
  mounted() {
    window.addEventListener('click', clickOutsideHandle)
  },
  updated(el, binding) {
    nodeList = {
      documentHandler: createDocumentHandler(el, binding)
    }
  },
  unmounted() {
    window.removeEventListener('click', clickOutsideHandle)
  }
}

但上面的代码有一处致命的bug,一个页面中使用多次,只有最后一个元素的事件是生效的,下面将指令的实例中存储一个nodeList列表全局维护所有绑定了click-outside指令的节点,所有节点用通过一个click事件来处理,如果有存在任何一个节点,就不移除事件。支持多处使用。

js 复制代码
// 导入Vue的指令相关类型
import type { DirectiveBinding, Directive } from 'vue'

// 定义处理文档事件的类型
type DocumentHandler = <T extends MouseEvent>(e: T) => void
// 定义列表属性接口
interface ListProps {
  el: HTMLElement
  documentHandler?: DocumentHandler
}

// 初始化节点列表
const nodeList: ListProps[] = []

// 定义选项接口
interface IOptions {
  ignores?: string | string[]
}
// 检查是否为忽略的元素
const isIgnoresElement = (options: IOptions | undefined = {}, target: HTMLElement) => {
  // 如果选项不存在或目标元素不存在,则返回false
  if (!options || !target) {
    return false
  }
  // 解构忽略列表
  const { ignores = [] } = options
  // 根据忽略列表类型进行判断
  if (typeof ignores === 'string') {
    return document.querySelector(ignores)?.contains(target)
  } else if (Array.isArray(ignores) && ignores.length > 0) {
    return ignores.every((element) => {
      return document.querySelector(element)?.contains(target)
    })
  }
  return false
}

/**
 * 创建文档事件处理函数
 *
 * @param el HTMLElement
 * @param binding binding
 * @returns Function
 */
function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
  return function (e: MouseEvent) {
    const target = e.target as HTMLElement
    // 如果点击在目标元素内部,则返回false
    if (el.contains(target)) {
      return false
    }
    // 如果是忽略的元素,则返回false
    if (isIgnoresElement(binding.arg as IOptions, target)) {
      return false
    }
    // 执行绑定的值
    binding.value(e)
  }
}

// 点击外部处理函数
const clickOutsideHandle = (e: MouseEvent) => {
  // 遍历节点列表,执行文档处理函数
  nodeList.forEach((item) => {
    const { documentHandler } = item
    if (documentHandler && typeof documentHandler === 'function') {
      documentHandler(e)
    }
  })
}

// 点击外部指令
const clickOutside: Directive = {
  // 指令挂载时
  mounted(el, binding) {
    // 查找节点在节点列表中的索引
    const index = nodeList.findIndex((item) => {
      return item.el === el
    })
    // 如果节点不在列表中,则添加到节点列表中
    if (index === -1) {
      nodeList.push({
        el: el,
        documentHandler: createDocumentHandler(el, binding)
      })
      // 如果节点列表长度为1,则添加点击外部事件监听
      if (nodeList.length === 1) {
        window.addEventListener('click', clickOutsideHandle)
      }
    }
  },
  // 指令卸载前
  beforeUnmount(el, binding) {
    // 查找节点在节点列表中的索引
    const elInNodeListIndex = nodeList.findIndex((item) => {
      return item.el === el
    })
    // 如果节点在列表中,则从列表中移除
    if (elInNodeListIndex !== -1) {
      nodeList.splice(elInNodeListIndex, 1)
      // 如果节点列表为空,则移除点击外部事件监听
      if (nodeList.length === 0) {
        window.removeEventListener('click', clickOutsideHandle)
      }
    }
  }
}

// 导出指令名称和指令对象
export const name = 'ClickOutside'
export default clickOutside

使用方法

html 复制代码
<template>
  <!-- 有参数 -->
  <div v-click-outside:[clickOptions]="closeHandle">......</div>
  <!-- 无参数 -->
  <div v-click-outside="closeHandle">......</div>
</template>

<script setup lang="ts">
const clickOptions = {
    ignores: ['.ignore-click-out-element', '#xxxxx']
}
const closeHandle = () => {
  // 处理关闭事件
}
</script>

这是一个使用 Vue.js 的 v-click-outside 指令的示例代码。它定义了一个点击外部事件处理函数,并根据是否传入参数来决定如何使用指令。 在模板中,有两种使用 v-click-outside 指令的方式:

  1. 带参数的用法
html 复制代码
   <!-- 有参数 -->
   <div v-click-outside:[clickOptions]="closeHandle">...</div>

这种方式会将 clickOptions 对象作为参数传递给指令的处理函数。在示例中,clickOptions 对象包含了一个 ignores 属性,用于指定要忽略的元素类名或 ID。 2. 无参数的用法

html 复制代码
   <!-- 无参数 -->
   <div v-click-outside="closeHandle">...</div>

这种方式直接将处理函数 closeHandle 作为参数传递给指令。

在脚本部分,定义了 clickOptions 对象和 closeHandle 函数。clickOptions 对象用于配置点击外部事件的一些选项,例如要忽略的元素。closeHandle 函数是点击外部事件的处理函数,用于执行关闭操作或其他逻辑。

处理v-html中的a标签

下面这段代码模拟从后台获取html渲染到前端,我们假定内容是安全的,html中进行了初步的数据绑定。

在线预览 Vue SFC Playground (vuejs.org)

html 复制代码
<script setup lang="ts">
const TYPE = {
  ENTERPRISE: 'enterprise',
  PERSON: 'person',
}

const htmlText = [
  // 匹配到企业id,href和target不用写,这里只是用来表达js阻止了a链接的默认跳转
  '<a data-type="enterprise" data-eid="1111" data-name="xxx公司" href="https://wwww.baidu.com" target="_blank">xxx公司</a>',
  '<a data-type="enterprise" data-eid="333" data-name="bbb公司" href="https://wwww.baidu.com" target="_blank">bbb公司</a>',
  // 匹配到用户id
  '<a data-type="person" data-pid="2222" data-name="王某某" href="https://wwww.baidu.com" target="_blank">王某某</a>',
  // 没有匹配到用户id
  '<a data-type="person" data-pid="" data-name="李某某">李某某</a>',
]
const htmlStr = htmlText.join('')

const personClickHandle = (dataset) => {
  const { pid, name } = dataset
  // 根据参数判断是拼装路由,是否跳转、新开
  console.log(pid, name)
}
const enterpriseClickHandle = (dataset) => {
  const { eid, name } = dataset
  // 根据参数判断是拼装路由,是否跳转、新开
  console.log(eid, name)
}
const reportClickHandle = (evt) => {
  evt.preventDefault()
  const { target } = evt
  const { dataset } = target
  const keys = Object.keys(dataset)
  if (!keys.includes('type')) {
    return
  }
  const type = dataset['type']
  if (type === TYPE.ENTERPRISE) {
    enterpriseClickHandle(dataset)
  } else if (type === TYPE.PERSON) {
    personClickHandle(dataset)
  }
}
</script>

<template>
  <div class="demo">
    <div v-html="htmlStr" @click="reportClickHandle" class="report-html"></div>
  </div>
</template>
<!-- <style lang="less" scoped>

.demo {
  :deep(.report-html) {
    a {
      text-decoration: none;
    }

    a[data-eid]:not([data-eid='']):hover,
    a[data-pid]:not([data-pid='']):hover {
      color: #1864dc;
    }

    a[data-type='enterprise'] {
      color: #3981f4;
    }

    a[data-type='person'] {
      color: #3981f4;
    }

    a[data-type='person'] {
      color: #3981f4;
    }

    a[data-pid=''] {
      color: #000;
    }
  }
}
</style> -->
<style>
.demo {
  .report-html {
    a {
      text-decoration: none;
    }

    a[data-eid]:not([data-eid='']):hover,
    a[data-pid]:not([data-pid='']):hover {
      color: #1864dc;
    }

    a[data-type='enterprise'] {
      color: #3981f4;
    }

    a[data-type='person'] {
      color: #3981f4;
    }

    a[data-type='person'] {
      color: #3981f4;
    }

    a[data-pid=''] {
      color: #000;
    }
  }
}
</style>

上面这段代码中有<a data-type="enterprise" data-eid="1111" data-name="xxx公司" href="https://wwww.baidu.com" target="_blank">xxx公司</a>,渲染到v-html中点击时,会打开一个新的页面,但显然我不想让它能打开新的页面,我需要自己去控制逻辑,比如在app的webview中,我需要调用原生的方法来打开一个新的webview,好控制导航栏,如果直接a链接,会打开一个导航不受控制的webview。 所以我需要用一个事件委托来处理。

我在如容器绑定了一个点击事件,通过事件冒泡的原理来处理a标签的点击事件。使用evt.preventDefault()阻止a标签的默认行为。

接下来就可以获取元素的data-*,根据绑定的数据来进行相应的处理。

相关推荐
undefined&&懒洋洋8 分钟前
Web和UE5像素流送、通信教程
前端·ue5
大前端爱好者2 小时前
React 19 新特性详解
前端
随云6322 小时前
WebGL编程指南之着色器语言GLSL ES(入门GLSL ES这篇就够了)
前端·webgl
随云6322 小时前
WebGL编程指南之进入三维世界
前端·webgl
寻找09之夏3 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
非著名架构师3 小时前
js混淆的方式方法
开发语言·javascript·ecmascript
多多米10054 小时前
初学Vue(2)
前端·javascript·vue.js
敏编程4 小时前
网页前端开发之Javascript入门篇(5/9):函数
开发语言·javascript
柏箱4 小时前
PHP基本语法总结
开发语言·前端·html·php
新缸中之脑4 小时前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法