第十四集: 從零開始實現一套pc端vue的ui組件庫( Popover彈出框 )

第十四集: 從零開始實現一套pc端vue的ui組件庫( Popover彈出框 )

1. 本集定位

Popover組件不一樣於alert這種霸道總裁, 它更傾向於輔助顯示某些未顯示完整的內容, toast組件與其相比更偏向'提示', Popover更偏向於'展現', 但屬於一種'輕展現', 畢竟不會出現'蒙層'等效果.
別看它小小的, 它裏面的門道還很多, 最主要的就是他的定位問題, 好比說它設定爲出如今元素上方, 但是元素本身已經在最頂上了, 此時就須要給他'換個方位'展現了, 關於這個定位的計算方式還能夠在其餘組件上應用, 好比下一集要寫的'日期組件', 還有就是這個彈出框的消失時機, 本人更推薦只要滾動就清除它, 每次計算他的位置所消耗的性能很高的, 由於每次都會觸發重排與重繪, 話很少說本次咱們就一塊兒來搞一搞這個小東西.🏀 css

效果展現
圖片描述vue

2. 需求分析

  1. 可配置觸發的形式, 好比'點擊'與'懸停'.
  2. 可定義組件出現的位置, '上左'、'上中'、'上右'等等狀況吧...
  3. 本組件可能會被大批量的使用, 性能優化方面須要重點考慮.
  4. 及時移除相關事件

3. 基礎的搭建

vue-cc-ui/src/components/Popover/index.jsgit

export { default } from './main/index';

vue-cc-ui/src/components/Popover/main/popover.vuegithub

<template>
// 老套路, 父級
  <div class="cc-popover" ref='popover'>
// 內容區域
      <div class="cc-popover__content" ref='content'>
      // 這裏分了兩層是爲了解決一會遇到的問題的
        <div class="cc-popover__box">
          <slot name="content"> 請輸入內容</slot>
        </div>
      </div>
     // 這個是被包裹的元素;
     // 要用咱們的popover標籤起來纔有效;
      <slot />
  </div>
</template>
export default {
  name: "ccPopover",
  props: {
    // 事件類型用戶本身傳, 本次只支持兩種模式
    trigger: {
      type: String,
      default: "hover",
      // 這裏爲了擴展因此這樣寫
      // 只有兩種狀況能夠優化爲只要不是click就默認給hover
      validator: value => ["click", "hover"].indexOf(value) > -1
    },
    placement: {
    // 方位咱們定位的範圍是, 每一個方向都有'開始','中間','結束'三種狀況
      type: String,
      default: "right-middle",
      validator(value) {
        let dator = /^(top|bottom|left|right)(-start|-end|-middle)?$/g.test(
          value
        );
        return dator;
      }
    }
  },

初始化項目的一些操做
經過用戶的輸入, 來給dom添加監聽事件
下面的on 方法 實際上是借鑑了element-ui的寫法, 有所收穫.element-ui

mounted() {
    this.$nextTick(() => {
    // 獲取到當前用戶定義的事件類型
      let trigger = this.trigger,
      // 本次選擇操做dom
        popover = this.$refs.popover;
      if (trigger === "hover") {
        // hover固然要監聽 進入與離開的事件拉
        on(popover, "mouseenter", this.handleMouseEnter);
        on(popover, "mouseleave", this.handleMouseLeave);
      } else if (trigger === "click") {
        on(popover, "click", this.handlClick);
      }
    });
  },

on方法的封裝
element還判斷了是否是服務器環境等操做, 咱們這裏只選取了瀏覽器端相關的代碼.數組

vue-cc-ui/src/assets/js/utils.js瀏覽器

// 添加事件, element-ui判斷是否是服務器環境
export function on(element, event, handler) {
  if (element && event && handler) {
    element.addEventListener(event, handler, false);
  }
}
// 移除事件
export function off(element, event, handler) {
  if (element && event) {
    element.removeEventListener(event, handler, false);
  }
}

4. 從點擊事件提及

假設用戶傳入的事件類型是'click', mounted裏面的操做已經綁定了相應的事件'handlClick',接下來的任務是:性能優化

  1. 讓提示框出現或者消失.
  2. 若是是出現, 計算要出如今什麼位置.
  3. 若是是出現, 爲document綁定事件, 用於隱藏這個popover.

思路概述服務器

  1. this.init變量來配合v-if, 這樣保證組件在沒有被使用過的狀況下, 永遠不會渲染出來.
  2. 涉及到頻繁點擊時, v-show就要登場了, this.show控制v-show, 因此這兩個指令能夠來一次親密配合.
  3. 事件不要綁定在body上, 有一種可能就是用戶body沒有徹底包裹內容, 好比不設高度.
handlClick() {
      // 無論怎麼樣只要觸發一次, 這個值就會把v-if永遠置成true;
      this.init = true;
      // 在他自己被css屬性隱藏的時候
      if (this.$refs.content && this.$refs.content.style.display === "none") {
        // 必須這樣強制寫, 
        // 不然與以後的代碼配合時, 有bug沒法消失
        this.$refs.content.style.display = "block";
        this.show = true;
      } else {
      // 除了第一次以外, 以後都只是變換這個this.show的'真假'
        this.show = !this.show;
      }
      // 不要監聽body, 由於可能height不是100%;
      // 這個document其實也能夠由用戶指定
      // 放入讓popover消失的函數, 這樣方便以後的移除事件操做
      this.show && document.addEventListener("click", this.close);
    },

點擊消失事件app

close(e) {
    // 確定要判斷事件源究竟是不是我們的popover組件
      if (this.isPopover(e)) {
        this.show = false;
        // 點擊完就能夠移除了, 下次操做再綁定就能夠
        // 由於若是往document綁定太多事件, 會很是卡, 很是卡
        document.removeEventListener("click", this.close);
      }
    },

isPopover

  1. 這個負責判斷點擊的元素是否是popover組件
  2. 點擊popover彈出層裏面的元素, 也算是點擊popover, 由於用戶可能會經過slot傳入一些結構, 這種狀況不能關閉.
isPopover(e) {
      let dom = e.target,
        popover = this.$refs.popover,
        content = this.$refs.content;
        // 1: 點擊popover包裹的元素, 關閉popover
        // 2: 點擊popover內容區元素, 不關閉popover
      return !(popover.contains(dom) || content.contains(dom));
    },

上面講述了具體的出現與消失的邏輯, 接下來咱們來讓他真正的出如今屏幕上

watch: {
   // 咱們會監控v-if的狀況, 第一次渲染的時候才作這裏的操做, 並且只執行一次
    init() {
      this.$nextTick(() => {
        let trigger = this.trigger,
          dom = this.$refs.content,
          content = this.$refs.content;
          // 這裏有人會有疑問, 這什麼鬼寫法
          // 這裏是由於append操做屬於剪切, 因此不會出現兩個元素
          // 其實這個元素出現以後就一直存在與頁面上了, 除非銷燬本組件
          // 組件銷燬的時候, 咱們會document.body.removeChild(content);
        document.body.appendChild(dom);
        if (trigger === "hover") {
          on(content, "mouseenter", this.handleMouseEnter);
          on(content, "mouseleave", this.handleMouseLeave);
        }
      });
    },
    // 這個纔是每次顯示隱藏都會觸發的方法
    show() {
    // 判斷只有顯示提示框的時候纔回去計算位置
      if (this.show) {
        this.$nextTick(() => {
          let { popover, content } = this.$refs,
            { left, top, options } = getPopoverposition(
              popover,
              content,
              this.placement
            );
          // 有了座標, 就能夠很開心的定位了
          this.left = left;
          this.top = top;
          // 這個配置是決定 '小三角' 的位置的
          this.options = options;
        });
      }
    }
  },

5. 重點問題, 獲取顯示的位置 getPopoverposition

思路

  1. 先實驗是否能夠按照用戶傳進來的座標進行展現.
  2. 若是不能夠按照用戶傳進來的座標進行展現, 循環全部展現方式, 查看是否有可用的方案.
  3. 獲取dom座標會引發重排重繪, 因此獲取座標的工做咱們只作一次.

vue-cc-ui/src/assets/js/vue-popper.js

// 受到vue源碼實例化vue部分的啓發, 有了以下寫法.
// CONTANT 常數: 物體距離目標的間隙距離, 單位px;
function getPopoverPosition(popover, content, direction,CONTANT ) {
   // 這個show本次用不到, 爲之後的組件作準備
  let result = { show: true };
  // 1: 讓這個函數去初始化'參與運算的全部參數';
  // 把處理好的值, 付給result對象
  getOptions(result, popover, content, direction,CONTANT );
  // 2: 拿到屏幕的偏移
  let { left, top } = getScrollOffset();
  // 3: return出去的座標, 必定是針對當前可視區域的
  result.left += left;
  result.top += top;
  return result;
}

先把全部可能作成列表, 也許有人有疑問, 爲何不把list這個組for循環生成, 那是由於for循環也是須要性能的, 這樣直接下來能夠減小運算, 因此不少不必的運算儘可能不要寫

const list = [
  'top-end',
  'left-end',
  'top-start',
  'right-end',
  'top-middle',
  'bottom-end',
  'left-start',
  'right-start',
  'left-middle',
  'right-middle',
  'bottom-start',
  'bottom-middle'
];

getOptions 初始化運算所需參數

function getOptions(result, popover, content, direction,CONTANT = 10) {
 // 1: 可能會反覆的調用, 因此來個深複製
  let myList = list.concat(),
    client = popover.getBoundingClientRect();// 獲取popover的可視區距離
 // 2: 每次使用一種模式, 就把這個模式從list中幹掉, 這樣直到數組爲空, 就是全部可能性都嘗試過了
  myList.splice(list.indexOf(direction), 1);
 // 3: 把參數整理好, 傳給處理函數
  getDirection(result, {
    myList,
    direction,
    CONTANT,
    top: client.top,
    left: client.left,
    popoverWidth: popover.offsetWidth,
    contentWidth: content.offsetWidth,
    popoverHeight: popover.offsetHeight,
    contentHeight: content.offsetHeight
  });
}

getDirection
代碼有點多, 可是邏輯很簡單, 我來講一下思路

  1. 好比用戶傳入的是'top-end' 拆分爲 top 與 end 字段
  2. 也就是要出如今目標元素的上方,靠右邊.
  3. 針對end--> result.left = 目標元素左側距離可視區 + 目標元素寬度 - 彈出框寬度;
  4. 針對top--> result.top = 目標元素上方距離可視區 - 彈出框高度 - 二者間距距離;
  5. 沒有什麼複雜邏輯, 就是單純的算術
function getDirection(result, options) {
  let {
    top,
    left,
    CONTANT,
    direction,
    contentWidth,
    popoverWidth,
    contentHeight,
    popoverHeight
  } = options;
  result.options = options;
  let main = direction.split('-')[0],
    around = direction.split('-')[1];
  if (main === 'top' || main === 'bottom') {
    if (around === 'start') {
      result.left = left;
    } else if (around === 'end') {
      result.left = left + popoverWidth - contentWidth;
    } else if (around === 'middle') {
      result.left = left + popoverWidth / 2 - contentWidth / 2;
    }
    if (main === 'top') {
      result.top = top - contentHeight - CONTANT;
    } else {
      result.top = top + popoverHeight + CONTANT;
    }
  } else if (main === 'left' || main === 'right') {
    if (around === 'start') {
      result.top = top;
    } else if (around === 'end') {
      result.top = top + popoverHeight - contentHeight;
    } else if (around === 'middle') {
      result.top = top + popoverHeight / 2 - contentHeight / 2;
    }
    if (main === 'left') {
      result.left = left - contentWidth - CONTANT;
    } else {
      result.left = left + popoverWidth + CONTANT;
    }
  }

  testDirection(result, options);
}

testDirection 檢驗算出來的值是否可以出如今用戶的視野裏面
思路

  1. 算出彈出框的四個角, 是否都在可視區以內, 是否有顯示不全的.
  2. 好比說left爲負數, 確定有被遮擋的地方.
  3. 若是不符合要求, 就繼續循環list裏面的類型, 從新算定位的left與top.
  4. 若是循環到最後都沒有合適的, 那就用最後一個方案.
function testDirection(result, options) {
  let { left, top } = result,
    width = document.documentElement.clientWidth,
    height = document.documentElement.clientHeight;
  if (
    top < 0 ||
    left < 0 ||
    top + options.contentHeight > height ||
    left + options.contentWidth > width
  ) {
    // 還有能夠循環的
    if (options.myList.length) {
      options.direction = options.myList.shift();
      getDirection(result, options);
    } else {
      // 實在不行就在父級身上
      result.left = options.left;
      result.right = options.right;
    }
  } else {
    result.show = true;
  }
}

dom結構上要相應的加上對應的樣式
這裏的click必定不能夠用stop修飾符, 會干擾用戶的正常操做.
這裏咱們加上一個動畫, 看起來漸隱漸現的有點美感.

<div class="cc-popover"
       ref='popover'>
    
    <!-- 不可使用stop 會阻止用戶的操做 -->
    <transition name='fade'>
      <div v-if="init"
           ref='content'
           v-show='show'
           class="cc-popover__content"
           :class="options.direction"
           :style="{ // 這裏就是控制定位的關鍵
               top:top+'px',
               left:left+'px'
           }">
        <div class="cc-popover__box">
          <slot name="content"> 請輸入內容</slot>
        </div>
      </div>
    </transition>
    <slot />
  </div>

6. hover狀態

上面在watch裏面也有體現了, 與click的區別就是, 綁定的事件不一樣
這裏消失有200毫秒的延遲, 是由於用戶離開目標元素,多是爲了移入popover彈出框

// 移入
    handleMouseEnter() {
      clearTimeout(this.time);
      this.init = true;
      this.show = true;
    },
    // 移出
    handleMouseLeave() {
      clearTimeout(this.time);
      this.time = setTimeout(() => {
        this.show = false;
      }, 200);
    }

7. 定義'清除指令'與收尾工做

vue-cc-ui/src/components/Popover/main/index.js
思路

  1. 掛載$clearPopover 命令, 執行效果是隱藏屏幕上全部的popover提示框
  2. 以前工做遇到這樣一個狀況, 有兩張表單, 定位在一塊兒, 一個在上面一個在下面,結果切換的時候,上一份表單的popover在第二份上面, 由此我根據須要一個全局的清理方法 .
  3. 監聽window的滾動事件, 每次滾動把全部的popover都隱藏.
  4. 自定義指令 v-scroll-clear-popover, 放在某個元素上, 就會監聽這個元素的滾動事件, 從而隱藏popover彈出框.
  5. 固然了, 這些監聽滾動的方法, 都作了節流, 400毫秒觸發一次
import Popover from './popover.vue';
import prevent from '@/assets/js/prevent';
Popover.install = function(Vue) {
  Vue.component(Popover.name, Popover);
  Vue.prototype.$clearPopover = function() {
    let ary = document.getElementsByClassName('cc-popover__content');
    for (let i = 0; i < ary.length; i++) {
        ary[i].style.display = 'none';
    }
  };
  // 監聽指令
  window.addEventListener('scroll',()=>{
    prevent(1,() => {
      Vue.prototype.$clearPopover()
    },400);
  },false)
   
  Vue.directive('scroll-clear-popover', {
    bind: el => {
      el.addEventListener('scroll', ()=>{
        prevent(1,() => {
          Vue.prototype.$clearPopover()
        },400);
      }, false);
    }
  });
};

export default Popover;

不要小看這個, 若是沒有這個收尾工做, 也許內存都爆了.
移除全部事件, 刪除dom元素

beforeDestroy() {
    let { popover, content } = this.$refs;
    off(content, "mouseleave", this.handleMouseLeave);
    off(popover, "mouseleave", this.handleMouseLeave);
    off(content, "mouseenter", this.handleMouseEnter);
    off(popover, "mouseenter", this.handleMouseEnter);
    off(document, "click", this.close);
    document.body.removeChild(content);
  }

展現一下最終效果

圖片描述

圖片描述

圖片描述

圖片描述

圖片描述

圖片描述

end

你們均可以一塊兒交流, 共同窗習,共同進步, 早日實現自我價值!!
下一集聊聊'日曆組件'

工程github地址:github
我的技術博客(組件的官網):技術博客

相關文章
相關標籤/搜索