翻了翻element-ui源碼,發現一個很實用的指令clickoutside

前言

指令(directive)在 vue 開發中是一項很實用的功能,指令可以綁定到某一元素或組件,使功能的顆粒度更精細。今天在翻 element-ui 的源碼時,發現一個還挺實用的工具指令,跟大夥分享一下。

clickoutside 的使用及效果

該指令的源碼在 src/utils 下的 clickoutside.js。它功能是指令需要接收一個函數,當用戶滑鼠點擊的區域在綁定指令的元素之外時,會觸發該函數。

那麼使用這個指令能夠實現什麼功能呢?我想到一個功能,就像我們常用的抽屜組件,在點擊抽屜之外的區域時,抽屜就會消失(但 elementui 中不是用這種方式,而是用一個遮罩層實現)。

接下來我們來看看怎麼玩這個指令,很簡單,只需要引入這個文件註冊指令就好了。

// main.js
import Vue from 'vue'
import clickoutside from 'element-ui/src/utils/clickoutside'

Vue.directive('clickoutside', clickoutside)

使用:

<div v-show="show" v-clickoutside="handler"><div>
export default {
    data() {
        return {
            show: true
        }
    },
    methods: {
        handler() {
            this.show = false
        }
    }
}

效果:

源碼分析

clickoutside 看起來還挺不錯,下面看看它是如何實現的。首先是它的指令鉤子定義:

const nodeList = [];
const ctx = '@@clickoutsideContext';

let seed = 0;

export default {
  // 指令綁定時觸發
  bind(el, binding, vnode) {
    // 每次綁定時會把dom元素存放到 nodeList 中
    nodeList.push(el);
    // 創建遞增id標識
    const id = seed++;
    // 在dom元素上設置一些屬性和方法
    // ctx的作用是一個標識,為了不和原生的屬性衝突
    el[ctx] = {
      id,
      // 這個是點擊元素區域外時會執行的函數,後面會提到
      documentHandler: createDocumentHandler(el, binding, vnode),
      // 綁定的值表達式,值相當於上面例子中的 "handler" 字元串
      methodName: binding.expression,
      // 綁定的值,值相當於上面例子中的 handler 函數
      bindingFn: binding.value
    };
  },
  // 組件更新時觸發
  update(el, binding, vnode) {
    el[ctx].documentHandler = createDocumentHandler(el, binding, vnode);
    el[ctx].methodName = binding.expression;
    el[ctx].bindingFn = binding.value;
  },
  // 指令解綁時觸發
  unbind(el) {
    let len = nodeList.length;
    // 找到對應的dom元素,從 nodeList 移除它
    for (let i = 0; i < len; i++) {
      if (nodeList[i][ctx].id === el[ctx].id) {
        nodeList.splice(i, 1);
        break;
      }
    }
    // 移除之前添加的自定義屬性
    delete el[ctx];
  }
};

源碼內部會對 docuemnt 滑鼠事件進行監聽:

let startClick;

// 滑鼠按下時 記錄按下元素的事件對象
!Vue.prototype.$isServer && on(document, 'mousedown', e => (startClick = e));

// 滑鼠鬆開時 遍歷 nodeList 中的元素,執行 documentHandler
!Vue.prototype.$isServer && on(document, 'mouseup', e => {
  nodeList.forEach(node => node[ctx].documentHandler(e, startClick));
});

接下來最核心的就是 documentHandler 函數,它是由 createDocumentHandler 創建出來的:

function createDocumentHandler(el, binding, vnode) {
  // 接收參數為:滑鼠鬆開和滑鼠按下的事件對象
  return function(mouseup = {}, mousedown = {}) {
    // 這裡一系列的判斷點擊區域是否在元素內,如果在區域內則跳出
    if (!vnode ||
      !vnode.context ||
      !mouseup.target ||
      !mousedown.target ||
      el.contains(mouseup.target) ||
      el.contains(mousedown.target) ||
      el === mouseup.target ||
      (vnode.context.popperElm &&
      (vnode.context.popperElm.contains(mouseup.target) ||
      vnode.context.popperElm.contains(mousedown.target)))) return;
    // 執行我們綁定指令時的函數
    if (binding.expression &&
      el[ctx].methodName &&
      vnode.context[el[ctx].methodName]) {
      // vnode.context 是組件實例上下文
      // 就像開頭的例子,methodName 是 "handler",通過索引上下文的屬性找到 methods 中定義的 handler 函數
      vnode.context[el[ctx].methodName]();
    } else {
      el[ctx].bindingFn && el[ctx].bindingFn();
    }
  };
}

至此整個指令流程分析就完了。

小插曲

在經過一些demo的使用後,發現該指令在某些場景下會出現不理想的效果。例如:抽屜內有 el-select 選擇欄時,選擇欄的 dom 是掛載到 body 下,導致在點擊完選擇項後被判斷為區域外點擊。

其實這也符合邏輯,因為點擊的地方也確實在區域外,只是在這種場景下看起來像是「bug」一樣。然後我發現源碼里提供了一個選項解決這種問題。可以在使用指令的組件 data 里定義 popperElm 屬性,它的值是一個 dom

export default {
    mounted() {
        this.popperElm = document.querySelector('.el-select-dropdown.el-popper')
    }
}

在源碼里會通過 popperElm 進行判斷:

if (!vnode ||
    !vnode.context ||
    !mouseup.target ||
    !mousedown.target ||
    el.contains(mouseup.target) ||
    el.contains(mousedown.target) ||
    el === mouseup.target ||
    (vnode.context.popperElm &&
    (vnode.context.popperElm.contains(mouseup.target) ||
    vnode.context.popperElm.contains(mousedown.target)))) return;

如果 popperElm 包含滑鼠點擊的 dom 則跳出邏輯。

然後我又想到了一個問題,popperElm 只能設置一個,當有多個選擇欄組件時,還是會出現上面所說的情況。我的想法是,把 clickoutsidecopy 一份下來,把 popperElm 改成可以接受數組類型,判斷時去循環判斷,這樣應該可以解決問題。

還有一件有趣的事,我在全局搜索時發現 element-ui 里好像沒有用到這個指令。

結語

clickoutside 不止抽屜的場景,只要你想在點擊某個元素區域之外做些事情,都可以考慮它。

除了這個,還有很多優秀的第三方指令,例如 element-ui 中的 v-loading 可以實現局部的載入動畫,常用的 vue-lazyload 中的 v-lazy 可以實現圖片的懶載入。

個人認為指令屬於那種用得少但很實用的東西,可能在開發功能時都沒有考慮到用指令來實現,如果你還不了解指令,趕快學起來。

感謝閱讀

歡迎關注公眾號【奔跑的前端er】,專註於分享前端技術文章,和大家一起進步。

Tags: