学写vue组件库(一):拖拽组件

很有幸,将自己有使用过的,也是标准组件库里可能没有的组件封装成了一个小小的组件库,接下来就是一步步丰富这个项目了~。期待大家的 start~ ,这也是我持续丰富这个组件库源源不断的动力!更多组件↓

首先第一个添加的是一个拖拽组件,功能很简单,就是让渲染出来的 dom 是可以拖拽的。至于具体的 dom 是啥,这个组件并不关心,使用 slot 承接,自己往里面塞就行。



vue 的组件按照用途来说,可以分为三类 (开发难度依次递增):

  • 展示组件:也就是平时业务开发还原设计稿的那些,将信息展示在页面上,使用 router 切换。
  • 业务组件:针对当前公司的业务封装抽取出来的的组件,不具有很强的通用性。
  • 独立组件:不针对具体的业务,例如日期、表单,也就是标准组件库里的那些,通用性强。

vue 组件的接口

组件接口就是三样: props 、自定义事件、插槽。也就是告知别人怎么使用你的组件,所以一个组件在设计之初就要规划好这三样,使用者习惯你加功能,可不会习惯你改接口。这个拖拽组件设计如下:

  • DragWrap <组件> 设计成了两个组件。最外层容器的组件,完成 Dom 的移动及其他逻辑。
  • DragItem <组件> 某一个需要拖拽的项,在这里面将拖拽的信息派发给容器组件。
  • data <props> 接收一个数组,拖拽组件对应的渲染数据,拖拽之后 Dom 变了,原渲染的数组也需要变更。例如可以告知后台,下次进来就按照变更后的数据渲染。
  • watchData <事件> 派发出变更之后的和 Dom 一一对应的原数据。
  • drag : <具名插槽> 如果不写具名插槽,点击整个拖拽的项都可以拖拽,否则只有具名插槽里的 Dom 才能控制整个项拖拽。

实现拖拽组件步骤

1. 拖拽改变当前 Dom 的顺序。
2. 拖拽结束后,派发出改变的数据。
3. 完成插槽接口以及交互。

1. 拖拽改变当前 Dom 的顺序

1.1 初识拖拽事件和属性

h5 拖拽事件

标记 :这个很重要!!! 不知道为什么很多人讲拖拽都不讲这个,也就是上面 gif 展示里黄色的原点,它的位移决定了拖拽事件的行为。当点击开始拖拽之后,鼠标点击所在的位置就是标记。

dragstart :↓当单击下鼠标,并移动之后执行。↓



drag :↓在 dragstart 执行之后,鼠标在移动时连续触发。↓



dragend :↓当拖拽行为结束,也就是松开鼠标的时候触发。↓



dragenter :↓当正在拖拽的元素的标记进入某个 Dom 元素时触发,自身首先会触发。被进入的 Dom 元素会触发这个事件。↓



dragover :当拖拽的元素的标记在进入的 Dom 元素上移动时触发,在自身移动时也会触发。



dragleave :↓当拖拽的元素在离开进入的 Dom 时触发。↓



h5 拖拽属性

draggable :当需要某个元素可以拖拽时,需设置为 true ,默认为 false 。选中的文本、图片、链接默认可以拖拽。

DataTransfer对象 :该属性用于保存拖放的数据和交互信息,该组件没有使用到,暂忽略。

1.2 组件编写

通过上面对事件的理解,我们想了想,只需要监听三个事件 dragstart dragenter dragend 。需要知道开始拖拽时的元素是谁,拖拽后去往的元素是哪个,以及最后拖拽的结束。因为每一个拖拽的项都是一个组件,所以这三个事件每次拖拽都会触发。所以我们写出以下代码:

drag-item.vue
<template>
    @dragstart.stop="onDragstart"  // 拖拽开始时
    @dragenter.stop="onDragenter"  // 拖拽进入当前组件时
    @dragend.stop="onDragend"  // 拖拽结束时
    draggable  // 可以拖拽
    class="__drag_item"
    <slot />
</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 ,也是从 iView copy 的,是组件库里会经常使用到的两个方法的注入,因为独立组件是不会去使用 vuex bus 来通信的,所以跨组件通信要有自己的骚操作。

我这里先解释下 vue 自定义事件的原理,父组件通过 this.$on 往子组件的事件中心去注册事件,子组件通过 this.$emit 触发自己事件中心的事件,但由于触发的这个事件是在父组件作用域下的,所以就完成了父子之间的自定义事件通信,其实压根就是子组件自己玩自己的。

以下的两个方法 broadcast dispatch 它们的原理就是在当前组件找到目标组件的实例,只不过一个是向下,一个是向上。然后通过 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 />
</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>
    @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 />
</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 {
    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]