天天看點

原來 Vue3 ClickOutside 指令是這樣實作的!

原來 Vue3 ClickOutside 指令是這樣實作的!

當我們在開發一些元件的時候,比如下拉框或者一些模态框等元件,我們希望在點選元素之外的時候就能夠把相應的元素收起來或者隐藏;這看似十分簡單的需求,其實隐藏着很多的判斷邏輯和代碼技巧在裡面,筆者就結合這幾天閱讀element-plus和naive-ui-admin源碼的經驗,總結分享自己的一些經驗和想法。

在學習源碼之前,我們先進行鋪墊一下,了解一下簡單幾個工具函數的使用,友善後續了解。

本文應該是全網最深入的對自定義指令ClickOutside的解讀,文章内容較長,覺得有所收獲的記得點贊關注收藏,一鍵三連。

工具函數

首先是on和off函數,在naive-ui-admin中用來給函數注冊綁定和解除綁定事件

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);
  }
}           

比如在給元素綁定事件時,也可以很友善的使用,看起來也比較簡潔:

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

這裡擴充一下,利用on和off函數組合,我們還能擴充出once函數,用來注冊一次性的事件:

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);
}           

在這裡,我們并不是直接将fn函數綁定到元素上,而是巧妙的在函數内部定義了listener函數綁定在el元素上,再在listener函數觸發後,在其内部執行一次fn函數後再進行解綁操作。

自定義指令

在vue中有很多v-if、v-show、v-model等常用内置的指令可以使用,同時我們可以很友善靈活的封裝自己的指令,來滿足特定的業務需求和場景;我們在setup一文中介紹了如何定義和引入定義好的指令,指令對象中我們可以使用如下七個聲明周期的鈎子函數:

  • created:在綁定元素的 attribute 前
  • beforeMount:在元素被插入到 DOM 前調用
  • mounted:綁定元素的父元件及子節點都挂載完成後調用
  • beforeUpdate:綁定元素的父元件更新前調用
  • updated:綁定元素的父元件所有及子節點更新完成後
  • beforeUnmount:綁定元素的父元件解除安裝前調用
  • unmounted:綁定元素的父元件解除安裝後調用
注意沒有beforeCreate函數。

鈎子函數看似比較多,其實常見常用的也就是mounted、updated和beforeUnmount,在生命周期開始、中間和結束時做一些處理,鈎子函數常見的寫法如下:

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

這裡我們重點看下鈎子函數傳入的兩個參數:el和binding;el很明顯就是綁定指令的dom元素,而binding就比較有趣了,它裡面含有各種綁定的資料,它本身是一個對象,把它列印出來,我們看到它有以下屬性:

  • value:value就是我們傳入到指令中的資料。
  • arg:傳遞給指令的參數。
  • dir:指令對象。
  • instance:使用指令的元件對象,非dom。
  • modifiers:由修飾符構成的對象。
  • oldValue:之前的值。

比如我們寫了一個自定義指令:

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

那麼我們列印出來的binding對象就會像是這樣的:

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

通過這個案例我們就能很清楚每個參數的作用,oldValue一般在update的時候會用到;而我們最常用的就是value值了,這裡的value值不僅僅局限于普通的數值,還可以傳入對象或者函數進行執行,我們在下面會看到。

動态參數指令

這裡還要額外介紹一下指令的arg屬性,利用這個指令屬性,我們還可以玩出很多花樣來;在上面的例子中,v-click-outside:foo這樣的寫法,指令參數arg的值就是确定的foo。

在vue3的官方文檔上就給出了這樣一個場景,我們将一個div,通過固定布局fix的方式固定再頁面一側,但是需要改變它的位置,雖然我們可以通過value傳入對象的方式解決,卻不是很友好,通過動态arg的方式我們就可以把這個需求給輕松實作了;

<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>           

通過v-fixed:[direction]的方式,我們給arg參數傳入了left的值;點選按鈕我們希望切換值:

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           

這樣我們就實作的動态指令參數的切換;除此之外,我們還可以給arg傳入數組等複雜的資料。

簡易版實作

好了,上面的工具函數和鈎子函數的介紹鋪墊完了,我們對自定義指令也有了一定的了解;我們從最簡單的功能開始,來看下如何實作一個簡易版本的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           

我們在指令挂載的時候,給document定義并且綁定了一個eventHandler處理函數,并且挂載到元素的__click_outside__屬性,友善在解除安裝的時候進行事件取消綁定。

eventHandler函數隻能放到指令中定義,否則擷取不到el和binding。

在使用clickOutside的時候,我們給value傳入綁定函數,是以binding.value的值接收到的其實是一個函數:

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

我們在上面定義的eventHandler函數也是點選事件的觸發函數,判斷事件的target是否包含在el節點中,如果不在的話就執行binding.value函數。

{
  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)
      }
    }
  }
}           

這裡用到了一個contains函數,它傳回一個布爾值,用來判斷某一節點是否是另一個節點的子節點,我們看下MDN文檔上的解釋:

contains()方法傳回一個布爾值,表示一個節點是否是給定節點的後代,即該節點本身、其直接子節點(childNodes)、子節點的直接子節點等。

需要注意的是,由于contains會将節點本身判斷傳回true,這不是我們想要的結果,是以我們還要顯示加一下el === e.target的判斷過濾條件。

是以最終的指令就是這樣的:

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           

簡易版優化

我們繼續對這個簡易版的函數進行優化,我們發現,每次指令初始化和移除時給document綁定事件很麻煩;如果把document的綁定事件放到外面來,隻綁定一次,不就減少了每次綁定和解綁的繁瑣了麼。

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

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

那麼,接下來的問題就來到了,怎麼能夠在click事件中能夠執行每個指令中定義的eventHandler函數,進而判斷出binding.value函數是否需要觸發執行呢?

沒錯,我們可以定義一個數組,來收集所有指令的eventHandler函數,點選時統一執行;不過數組帶來的問題是最後解綁時不容易去找到每個el對應的eventHandler函數。

不過這裡我們更加巧妙的定義了一個Map對象,由于我們的eventHandler函數和el是一一對用關系,利用Map對象的鍵值可以存儲任何資料的特性加持:

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)
  },
}           

我們将eventHandler收集到nodeList中,document點選時觸發每個eventHandler,再在eventHandler内部去判斷bind.value是否需要觸發。

簡易版更新優化

雖然是簡易版,不過我們還可以對它再再進行優化;我們發現naive-ui-admin的源碼clickOutside.ts中,并沒有注冊click事件,而是注冊了mouseup/mousedown事件,這是為什麼呢?我們在MDN中關于click/mouseup/mousedown事件找到了原話,是這樣說的:

當定點裝置的按鈕(通常是滑鼠的主鍵)在一個元素上被按下和放開時,click 事件就會被觸發。

mouseup/mousedown事件在定點裝置(如滑鼠或觸摸闆)按鈕在元素内按下時,會在該元素上觸發。

是以,總結下來就是,click事件隻是由滑鼠的左鍵觸發,而mouseup/mousedown事件是由任意定點裝置觸發的,比如滑鼠的右鍵或者中間的滾輪鍵,都是可以觸發的。

點選dom元素,三個事件的觸發順序是:mousedown、mouseup、click。

上面得出的結論,我們可以在VueUse中同時得到驗證;如果我們使用VueUse的onClickOutside,我們會發現它隻有在滑鼠左鍵時才會觸發;而element-plus則是三鍵同時可以觸發。

打開VueUse源碼中我們就會發現他注冊的就是click事件:

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

是以知道了三個事件的差別,回到我們的簡易版本,我們就可以進行更新了;首先将click事件進行改寫,分成mousedown和mouseup,不過這兩個事件都有對應的事件對象e,我們先存一個下來,在eventHandler裡面再對兩個事件對象進行判斷。

let startClick

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

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

eventHandler也接收的不是click的ev對象了,而是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()
  }
}           

這樣我們的簡易函數就更新完成了,也能同時支援左中右鍵的事件了。

源碼實作邏輯

經過上面的簡易版本的疊代更新,相信大家對ClickOutside整體的實作過程和原理應該有了一定的了解,基本也已經把源碼講了七七八八了;我們就來看下它的源碼中還有哪些邏輯,無非是判斷的更加全面一些,下面主要以naive-ui-admin源碼中的clickOutside.ts為主。

首先我們看下它主要的代碼結構:

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;           

我們發現除了createDocumentHandler這個函數之外,其他的功能在上面簡易版裡都已經實作了;由于我們的handler函數中需要用到el和binding,這裡的createDocumentHandler作用是建立一個匿名閉包handler函數,将handler函數存儲到nodeList,就能引用el和binding了。

是以我們重點來看下這個createDocumentHandler做了哪些事情,首先他接收了指令中的el和binding兩個參數,它傳回的匿名函數是在mouseup事件中被調用的,接收了mouseup和mousedown兩個事件對象。

我們繼續看建立出來的documentHandler函數中做了哪些的處理,它裡面主要有6個判斷的flag,隻要符合下面6個條件之一,即傳回true,就不觸發binding.value函數:

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

那麼這六個條件是什麼呢?我們逐一來解讀;前兩個判斷是完整性判斷,第一個檢驗條件是檢查binding或binding.instance是否存在,不存在isBound為true;第二個檢驗條件是mouseup/mousedown的觸發目标元素target是否都存在。

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

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

第三第四個判斷就是元素判斷了,和我們的簡易版本就有點類似,isContainedByEl判斷mouseUpTarget和mouseDownTarget是否在el元素中,如果在則為true;isSelf則是判斷觸發元素是否是el自身。

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

第五第六個判斷是特殊情況的判斷,判斷五是事件的target是否被excludes中的元素包含,如果是,isTargetExcluded為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));           

這裡我們重點來講一下excludes過濾數組的用法,正常情況下都是将綁定元素el下的dom節點判斷過濾,但是還有些情況下,我們需要在點選時額外過濾其他的節點(這種特殊的情況,我們在下面一篇文章會看到);這個時候就要用到excludes數組了,那它是怎麼來的呢?在建立documentHandler的時候,我們就從這個動态參數指令arg中拼了這個數組:

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

那麼結合上面的動态參數指令,我們就可以使用一下這個exclude來額外添加過濾的dom:

<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>           

總結

本文總結了vue3下ClickOutside的實作邏輯,從工具函數封裝,到自定義指令的學習,再到源碼的深入學習;雖然ClickOutside的整體邏輯并不是很複雜,但是剛開始筆者閱讀源碼的時候,很難了解其中的一些用法;尤其是在事件的注冊,為什麼不用click,而是使用mouseup/mousedown兩個事件組合;經過深入的思考和對比,才慢慢了解作者的用意。