一起撸个vue组件库(一):拖拽组件

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

在这里要特别感谢这本小册,Vue.js 组件精讲,帮助是海量的,小册作者就是iView的作者,值得信赖!

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

image

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:↓当单击下鼠标,并移动之后执行。↓

image

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

image

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

image

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

image

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

image

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

image

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触发自己事件中心的事件,但由于触发的这个事件是在父组件作用域下的,所以就完成了父子之间的自定义事件通信,其实压根就是子组件自己玩自己的。

以下的两个方法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元素,但这个时候它已经被拖拽给打乱了。↓

image

这个时候我们需要知道真实顺序的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);
    }
  }
}

最后

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272

推荐阅读更多精彩内容

  • 很多人都对我说谈恋爱有多么美好有多么幸福。确实我承认谈恋爱真的很幸福。因为我遇到过让我一辈子都忘不了的女孩。那是我...
    Roy罗阅读 147评论 0 0
  • 突然发现 最近好像在重走艺考时候走过的城市 青岛 南京 淄博... 当年匆匆略过的景色 如今一一细细看来 也不错 ...
    闫丽彬阅读 148评论 0 0
  • 7月目标调整为保持每日阅读,让阅读变得更快乐! 一件事的坚持过程中会不断更新,也许是每天的自己,所思所想所...
    Linky230阅读 161评论 0 0
  • 昨天下午的口译课老师给我们讲了一节多课关于考研的事情。 (她是89年的,今年30,2012年考研,硕士毕业来的我们...
    有理想又独立的小张同学阅读 204评论 0 1
  • 预约的雨没有来 一片迟到的乌云路过 装模作样像要哭泣 又被一阵风哄走 脂肪堆成的躯壳 水分不怎么舍得离开 赘肉构筑...
    原野骑兵阅读 254评论 2 5