一塊兒擼個組件庫(一):拖拽組件

頗有幸,將本身有使用過的,也是標準組件庫裏可能沒有的組件封裝成了一個小小的組件庫,沒想到start數破百了,vue-gn-components,接下來就是一步步豐富這個項目了~。期待你們的start~,這也是我持續豐富這個組件庫源源不斷的動力!css

在這裏要特別感謝這本小冊,Vue.js 組件精講,幫助是海量的,小冊做者就是iView的做者,值得信賴!vue

首先第一個添加的是一個拖拽組件,功能很簡單,就是讓渲染出來的dom是能夠拖拽的。至於具體的dom是啥,這個組件並不關心,使用slot承接,本身往裏面塞就行。node

vue的組件按照用途來講,能夠分爲三類 (開發難度依次遞增):

  • 展現組件:也就是平時業務開發還原設計稿的那些,將信息展現在頁面上,使用router切換。
  • 業務組件:針對當前公司的業務封裝抽取出來的的組件,不具備很強的通用性。
  • 獨立組件:不針對具體的業務,例如日期、表單,也就是標準組件庫裏的那些,通用性強。

vue組件的接口

組件接口就是三樣:props、自定義事件、插槽。也就是告知別人怎麼使用你的組件,因此一個組件在設計之初就要規劃好這三樣,使用者習慣你加功能,可不會習慣你改接口。這個拖拽組件設計以下:git

  • DragWrap<組件> 設計成了兩個組件。最外層容器的組件,完成Dom的移動及其餘邏輯。
  • DragItem<組件> 某一個須要拖拽的項,在這裏面將拖拽的信息派發給容器組件。
  • data<props> 接收一個數組,拖拽組件對應的渲染數據,拖拽以後Dom變了,原渲染的數組也須要變動。例如能夠告知後臺,下次進來就按照變動後的數據渲染。
  • watchData<事件> 派發出變動以後的和Dom一一對應的原數據。
  • drag: <具名插槽> 若是不寫具名插槽,點擊整個拖拽的項均可以拖拽,不然只有具名插槽裏的Dom才能控制整個項拖拽。

實現拖拽組件步驟

1. 拖拽改變當前Dom的順序。

2. 拖拽結束後,派發出改變的數據。

3. 完成插槽接口以及交互。

1. 拖拽改變當前Dom的順序

1.1 初識拖拽事件和屬性

h5拖拽事件github

標記:這個很重要!!! 不知道爲何不少人講拖拽都不講這個,也就是上面gif展現裏黃色的原點,它的位移決定了拖拽事件的行爲。當點擊開始拖拽以後,鼠標點擊所在的位置就是標記。vuex

dragstart:↓當單擊下鼠標,並移動以後執行。↓npm

drag:↓在dragstart執行以後,鼠標在移動時連續觸發。↓數組

dragend:↓當拖拽行爲結束,也就是鬆開鼠標的時候觸發。↓瀏覽器

dragenter:↓當正在拖拽的元素的標記進入某個Dom元素時觸發,自身首先會觸發。被進入的Dom元素會觸發這個事件。↓bash

dragover:當拖拽的元素的標記在進入的Dom元素上移動時觸發,在自身移動時也會觸發。

dragleave:↓當拖拽的元素在離開進入的Dom時觸發。↓

h5拖拽屬性

draggable:當須要某個元素能夠拖拽時,需設置爲true,默認爲false。選中的文本、圖片、連接默承認以拖拽。

DataTransfer對象:該屬性用於保存拖放的數據和交互信息,該組件沒有使用到,暫忽略。

1.2 組件編寫

經過上面對事件的理解,咱們想了想,只須要監聽三個事件dragstartdragenterdragend。須要知道開始拖拽時的元素是誰,拖拽後去往的元素是哪一個,以及最後拖拽的結束。由於每個拖拽的項都是一個組件,因此這三個事件每次拖拽都會觸發。因此咱們寫出如下代碼:

drag-item.vue
<template>
  <div
    @dragstart.stop="onDragstart"  // 拖拽開始時
    @dragenter.stop="onDragenter"  // 拖拽進入當前組件時
    @dragend.stop="onDragend"  // 拖拽結束時
    draggable  // 能夠拖拽
    class="__drag_item"
  >
    <slot />
  </div>
</template>

<script>
import Emitter from "../../mixins/emitter";

export default {
  name: "DragItem",
  mixins: [Emitter],
  mounted() {
    this.dispatch("DragWrap", "putChild", this.$el);  // this.$el爲當前組件實例對應的真實Dom。
    // 觸發DragWrap這個組件上的putChild方法,參數是當前組件的真實Dom。
  },
  methods: {
    onDragstart() {
      this.$el.style.opacity = "0.3";
      this.dispatch("DragWrap", "dragstart", this.$el); // 觸發dragstart
    },
    onDragenter() {
      this.dispatch("DragWrap", "dragenter", this.$el);  // 觸發dragenter
    },
    onDragend() {
      this.$el.style.opacity = "1";
      this.dispatch("DragWrap", "dragend");  // 觸發dragend
    }
  }
};
</script>
複製代碼

可能看的有點蒙,這裏解釋一下Emitter這麼個mixin,也是從iViewcopy的,是組件庫裏會常用到的兩個方法的注入,由於獨立組件是不會去使用vuexbus來通訊的,因此跨組件通訊要有本身的騷操做。

我這裏先解釋下vue自定義事件的原理,父組件經過this.$on往子組件的事件中心去註冊事件,子組件經過this.$emit觸發本身事件中心的事件,但因爲觸發的這個事件是在父組件做用域下的,因此就完成了父子之間的自定義事件通訊,其實壓根就是子組件本身玩本身的。簡單理解就是下面這樣的:

<template>
  <button @click="onClick">btn</button>
</template>

<script>
export default {
  created() {
    this.$on("testEvent", sayHi => {
      alert(sayHi);
    });
  },
  methods: {
    onClick() {
      this.$emit("testEvent", "hello Vue~");
    }
  }
};
</script>
複製代碼

如下的兩個方法broadcastdispatch它們的原理就是在當前組件找到目標組件的實例,只不過一個是向下,一個是向上。而後經過this.$emit去觸發目標組件已經經過this.$on註冊的事件,因而就能夠完成跨組件之間的通訊,它們找組件的方式是經過組件定義的name屬性。

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => {
    const name = child.$options.name;
    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
export default {
  methods: {
    dispatch(componentName, eventName, params) {
      let parent = this.$parent || this.$root;
      let name = parent.$options.name;
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;
        if (parent) {
          name = parent.$options.name;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    }
  }
};
複製代碼

第一篇會囉嗦點,寫獨立組件確實有不少須要先交代下。接下來咱們寫出如下DragWrap組件的代碼:

drag-wrap.vue
<template>
  <div ref="wrap" @dragenter.prevent @dragover.prevent>  // 阻止瀏覽器默認行爲,否則會顯示一個叉叉,很差看
    <slot />
  </div>
</template>

<script>
export default {
  name: "DragWrap",  // 組件名,很重要!
  created() {
    this.toDom = "";  // 拖拽時進入的元素
    this.fromDom = "";  // 拖拽起始的元素
    this.children = [];  // 存放全部子組件元素的集合,以後說明用途
    this.$on("dragstart", this.onDragstart);  // 子組件會$emit觸發dragstart,因此要先註冊
    this.$on("dragenter", this.onDragenter);  // 子組件會$emit觸發dragenter,因此要先註冊
    this.$on("dragend", this.onDragend);  // 子組件會$emit觸發dragend,因此要先註冊
    this.$on("putChild", child => {  // 這裏的child對應的是子組件的this.$el
      this.children.push(child);  // 將全部的子組件的Dom元素收集起來
    });
  },
  methods: {
    onDragstart(el) {
      this.fromDom = el;  // 記錄拖拽時開始的元素
    },
    onDragenter(el) {
      this.toDom = el;  // 由於拖拽會不停的觸發enter事件,因此進入的哪一個元素也要記錄下來
      if (this.fromDom === this.toDom) {
        return;
      }
    },
    onDragend() {}
  }
};
</script>
複製代碼

這裏有幾個要點須要先注意,this.$on必定要比this.$emit先執行,由於要先註冊才能被觸發吧,否則哪來事件觸發了。還有就是父子組件的鉤子執行順序,mounted是子組件先執行,created是父組件先執行。

好了,接下來咱們有了拖拽開始的元素以及進入的元素,接下來開始拖拽使用insertBefore交換它們的位置便可。不過這裏有個注意點就是要知道當前拖拽元素是往前拖動仍是日後拖動,因此咱們在DragWrap組件內添加如下代碼:

drag-wrap.vue
...
methods: {
  onDragenter(el) {
    this.toDom = el;
    if (this.fromDom === this.toDom) {
      return;
    }
    if(this.isPrevNode(this.fromDom, this.toDom)) {  // 判斷進入節點是否在起始節點的前面
      this.$refs["wrap"].insertBefore(this.fromDom, this.toDom); 
      // 將起始節點插入到進入節點的前面
    } else {  // 不然就是在以後
      this.$refs["wrap"].insertBefore(this.fromDom, this.toDom.nextSibling); 
      // 將起始節點插入到進入節點下一個兄弟節點的前面
    }
  },
  isPrevNode(from, to) {  // to是否在from的前面
    while(from.previousSibling !== null) {
      if(from.previousSibling === to) {
        return true;
      }
      from = from.previousSibling;
    }
  }
}
...
複製代碼

2. 拖拽結束後,派發出改變的數據。

通過上面代碼的編寫,如今元素已經能夠拖拽並按照咱們預想的切換Dom的位置,但這樣還僅僅不夠,Dom順序改了,對應的數據應該是什麼樣子,也須要知道,否則一刷新頁面就是老樣子也毫無心義。

2.1比較兩顆Dom樹

還記得咱們以前在created裏定義的this.children = []麼,它裏面包含了全部的拖拽組件的真實Dom元素,但這個時候它已經被拖拽給打亂了。↓

這個時候咱們須要知道真實順序的 Dom樹怎麼樣的,而後和這顆被打亂的 Dom進行對比,以計算出對應的數組順序被打亂成了什麼樣子,因此咱們在 DragWrap組件內添加如下代碼:

drag-wrap.vue
...
methods: {
  onDragend() {
    if (!this.data.length) return;
    const realDomOrder = [...this.$el.children].filter(child =>  //獲取真實的Dom樹
      child.classList.contains("__drag_item")
    );
    this.getDataOrder(realDomOrder, this.children);  // 對比兩顆樹
  },
  getDataOrder(realList, dragAfterList) {
    const order = realList.map(realItem => {  // 拿到打亂Dom樹對應的序號
      return dragAfterList.findIndex(dragItem => realItem === dragItem);
    });
    const newData = [];
    order.forEach((item, i) => {  // 將原數組的數據按照打亂的序號賦值給新數組
      newData[i] = this.data[item];
    });
    this.$emit("watchData", newData);  // 新數組的順序就對應打亂Dom的序號,派發出去
  }
}
...
複製代碼

3. 完成插槽接口以及交互。

3.1 完成具名插槽接口

這個時候拖拽整個drag-item組件的任意位置均可以進行拖拽,但有時候拖拽能夠觸發的位置用戶想本身定義,因此咱們須要給用戶這個接口,再DragItem內進行如下更改:

<template>
  <div
    @dragstart.stop="onDragstart"
    @dragenter.stop="onDragenter"
    @dragend.stop="onDragend"
    :draggable="!$slots.drag || isDrag"  // 若是有設置具名插槽,當前整個不能被拖拽
    :style="{cursor: !$slots.drag ? 'move': ''}" // 具名插槽決定這個組件的交互手勢
    class="__drag_item"
  >
    <slot name="drag" />  //  提供一個具名插槽drag
    <slot />
  </div>
</template>

export default {
  data() {
    return {
      isDrag: false
    };
  },
  mounted() {
    if(this.$slots.drag) {  // 若是有定義具名插槽drag
      this.setSlotAttr();
    }
    this.dispatch("DragWrap", "putChild", this.$el);
  },
  methods: {
    setSlotAttr() {
      const slotVNode = this.$slots.default.find(  // 找到vnode的第一個有效節點
        vnode => !vnode.data && vnode.text !== " "
      );
      const dragDom = slotVNode.elm.previousSibling;  
      // 具名插槽對應的真實Dom
      if (dragDom.previousSibling !== null) {  
        // 規定具名插槽內只能有一個根元素,不然報錯~
        throw "具名插槽內只能有一個根節點~";
      }
      dragDom.addEventListener("mouseenter", () => {  // 進入具名插槽的Dom,設置可拖動
        this.isDrag = true;
      });
      dragDom.addEventListener("mouseleave", () => {  // 離開具名插槽的Dom,設置不可拖動
        this.isDrag = false;
      });
      dragDom.style.cursor = "move";  // 手勢變爲可移動
    }
  }
}
複製代碼

不知道爲何,vue對應的默認插槽是能夠直接拿到真實Dom的,而具名插槽是沒法拿到的,有點坑~ 這裏使用這麼一個不太優雅的方式拿到,slotVNode.elm.previousSibling,親測也不影響使用。

而後咱們規定具名插槽內只能有一個根元素,否則下面設置的屬性就只能只對一個元素起做用。

3.2 完成交互

交換Dom位置時,左右有個10%的晃動吧~

<style scoped>
.__drag_item {
  animation: shake .3s;
}
@keyframes shake {
  0% {
    transform: translate3d(-10%, 0, 0);
  }
  50% {
    transform: translate3d(10%, 0, 0);
  }
  100% {
    transform: translate3d(0, 0, 0);
  }
}
</style>
複製代碼

組件安裝

npm i vue-gn-components

import { DragWrap, DragItem } from 'vue-gn-components';
import "vue-gn-components/lib/style/index.css";
Vue.use(DragWrap).use(DragItem)
複製代碼

組件調用

<template>
  <drag-wrap class="wrap" :data="list" @watchData="watchData">
    <drag-item class="item" v-for="(item, index) in list" :key="index">
      <template #drag>
        <div>拖拽Dom</div>
      </template>
      <div>{{item}}</div>
    </drag-item>
  </drag-wrap>
</template>

export default {
  data() {
    return {
      list: [111, 222, 333, 444, 555, 666, 777, 888, 999]
    };
  },
  methods: {
    watchData(newList) {
      console.log("newList", newList);
    }
  }
}
複製代碼

寫到最後

  • 目前這個組件是不支持嵌套使用的,也就是drag-item裏面不能再寫drag-wrap。嵌套的版本也寫出來了,邏輯比這個複雜了很多,不過最後發現好像沒什麼用。想了半天,感受只有一個場景會用到,開發一個拖拽進行佈局的工具,拖拽結束後,導出佈局代碼。算了,算了,這個需求搞不了,嵌套版本以後完善。

相關文章
相關標籤/搜索