vue3的sortablejs封装 踩坑心得(vuedraggable vue3版本)

196 阅读5分钟

最近在研究electron-vite 编写自己的东西 安装了vuedraggable 作者5年前就停止更新了 只兼容vue1 2版本 如果直接用sortablejs编写 不美观 且要处理 数组的数据更新 映射到界面上并且对重渲染重新绑定sortable实例的时机也要处理 今天讲下我踩坑的地方

根据原来vuedraggable的使用形式进行编写代码

vue3 option api 写法

<template>
  <div v-if="refresh">
    <!-- 使用动态组件 -->
    <component
      :is="tag"
      id="custom-drag"
      v-bind="attrs"
      v-on="listeners"
    >
      <div
        v-if="$slots.header"
        class="disabled-filter"
      >
        <slot name="header" />
      </div>
      <slot></slot>
      <div
        v-if="$slots.footer"
        class="disabled-filter"
      >
        <slot name="footer" />
      </div>
    </component>
  </div>
</template>
<script>
import Sortable from 'sortablejs'
export default {
  props: {
    // v-model绑定
    modelValue: {
      type: Object,
      default: () => {
        return {}
      }
    },
    // 自定义的tag组件
    tag: {
      type: String,
      default: 'div'
    },
    // 绑定到动态组件上的数据
    componentData: {
      type: Object,
      default: () => {
        return {
          on: {},
          attrs: {},
          props: {}
        }
      }
    },
    // 处理move事件 为了能拿到返回值
    move: {
      type: Function,
      default: () => true
    }
  },
  // 监听事件 映射回sortable配置上响应
  emits: [
    'update:modelValue',
    'endEvent',
    'chooseEvent',
    'unchooseEvent',
    'startEvent',
    'onSetEvent',
    'addEvent',
    'updateEvent',
    'sortEvent',
    'removeEvent',
    'filterEvent',
    'cloneEvent',
    'changeEvent'
  ],
  data() {
    return {
      // 用于重渲染的标志位
      refresh: true,
      // sortable实例
      sort: null
    }
  },
  computed: {
    // 映射model数据的操作
    list: {
      get() {
        return this.modelValue
      },
      set(value) {
        this.$emit('update:modelValue', value)
      }
    },
    // :is 中 props和attrs 是直接写在标签上的 所以直接合并
    attrs() {
      let { attrs = {}, props = {} } = this.componentData
      return { ...attrs, ...props }
    },
    // v-on 形式上的事件映射 需要+on
    listeners() {
      return this.componentData && this.componentData.on
    },
    // 如果有header 则索引+1
    slotIndex() {
      return this.$slots.header ? 1 : 0
    }
  },
  watch: {
    // 监听数据变化 重新渲染数组
    list: {
      handler(nv, ov) {
        // 判断原实例是否存在并销毁
        this.sort && this.sort.destroy()
        // 重新渲染
        this.createSort()
      },
      deep: true
    }
  },
  mounted() {
    this.createSort()
  },
  methods: {
    // 处理索引
    handleIndex(evt) {
      if (evt.oldIndex !== null) evt.oldIndex -= this.slotIndex
      if (evt.newIndex !== null) evt.newIndex -= this.slotIndex
      if (evt.newDraggableIndex !== null) evt.newDraggableIndex -= this.slotIndex
      if (evt.oldDraggableIndex !== null) evt.oldDraggableIndex -= this.slotIndex
    },
    // 创建拖拽
    createSort() {
      this.refresh = false
      // 等到下一帧重渲染
      this.$nextTick(() => {
        this.refresh = true
        // 这次也要等待 不然dom还没生成出来
        requestAnimationFrame(() => {
          let dom = document.querySelector('#custom-drag')
          if (!dom) return new Error('未找到dom')
          // 为什么不用ref 因为只有正常标签下拿的的dom 其他拿的是组件数据 $el才能拿到dom 不如直接用id
          // 对外部事件进行emit调用
          this.sort = new Sortable(dom, {
            ...this.$attrs,
            filter: '.disabled-filter,' + this.$attrs.filter,
            setData: (/** DataTransfer */ dataTransfer, /** HTMLElement*/ dragEl) => {
              this.$emit('onSetEvent', dataTransfer, dragEl)
            },

            // 元素被选中
            onChoose: (/**Event*/ evt) => {
              this.handleIndex(evt)
              this.$emit('chooseEvent', evt)
            },

            // 元素未被选中的时候(从选中到未选中)
            onUnchoose: (/**Event*/ evt) => {
              this.handleIndex(evt)
              this.$emit('unchooseEvent', evt)
            },

            // 开始拖拽的时候
            onStart: (/**Event*/ evt) => {
              this.handleIndex(evt)
              this.$emit('startEvent', evt)
            },

            // 结束拖拽 处理数据源
            onEnd: (/**Event*/ evt) => {
              this.handleIndex(evt)
              let { oldIndex, newIndex } = evt
              const currentRow = this.list.splice(oldIndex, 1)[0]
              this.list.splice(newIndex, 0, currentRow)
              // evt.clone && evt.clone.remove()
              this.$emit('endEvent', evt)
            },

            // 元素从一个列表拖拽到另一个列表
            onAdd: (/**Event*/ evt) => {
              this.handleIndex(evt)

              this.$emit('addEvent', evt)
            },

            // 列表内元素顺序更新的时候触发
            onUpdate: (/**Event*/ evt) => {
              this.handleIndex(evt)

              this.$emit('updateEvent', evt)
            },

            // 列表的任何更改都会触发
            onSort: (/**Event*/ evt) => {
              this.handleIndex(evt)

              this.$emit('sortEvent', evt)
            },

            // 元素从列表中移除进入另一个列表
            onRemove: (/**Event*/ evt) => {
              this.handleIndex(evt)
              this.$emit('removeEvent', evt)
            },

            // 试图拖拽一个filtered的元素
            onFilter: (/**Event*/ evt) => {
              this.handleIndex(evt)
              this.$emit('filterEvent', evt)
            },

            // 拖拽移动的时候 这个特别处理从props传入 因为要获取判定
            onMove: (/**Event*/ evt, /**Event*/ originalEvent) => {
              this.handleIndex(evt)
              let result = this.move(evt, originalEvent)
              // 去除经过头尾插槽的经过效果
              if (evt.related.classList.contains('disabled-filter')) {
                return false
              }
              return result
            },

            // clone一个元素的时候触发
            onClone: (/**Event*/ evt) => {
              this.handleIndex(evt)
              this.$emit('cloneEvent', evt)
            },

            // 拖拽元素改变位置的时候
            onChange: (/**Event*/ evt) => {
              this.handleIndex(evt)
              this.$emit('changeEvent', evt)
            }
          })
        })
      })
    }
  }
}
</script>

在页面中使用

<template>
  		<draggable
          v-model="list"
          handle=".mdi-drag"
          tag="v-list"
          :component-data="getComponentData()"
          :move="move"
        >
          <template #header>
            <v-list-subheader>header</v-list-subheader>
          </template>
          <v-list-item v-for="item in list">
            <v-icon class="cursor-pointer ml-2">mdi-drag</v-icon>
            {{item}}
          </v-list-item>
          <template #footer>
            <v-list-subheader>footer</v-list-subheader>
          </template>
        </draggable>
</template>
<script>
export default{
	methods:{
		getComponentData() {
	      return {
	        attrs: {
	          class: 'pa-2 mt-2'
	        },
	        props: {
	          'bg-color': '#eee'
	        },
	        on: {
	          click: (e) => {
	            console.log(1)
	          }
	        }
	      }
	    }
	}
}
</script>

在这里插入图片描述

vue3 composition api render式写法

<script>
import {
  h,
  defineComponent,
  ref,
  nextTick,
  useAttrs,
  watch,
  computed,
  useSlots,
  onMounted,
  toRefs,
  getCurrentInstance
} from 'vue'
import Sortable from 'sortablejs'
export default defineComponent({
  props: {
    // v-model绑定
    modelValue: {
      type: Object,
      default: () => {
        return {}
      }
    },
    // 自定义的tag组件
    tag: {
      type: String,
      default: 'div'
    },
    // 绑定到动态组件上的数据
    componentData: {
      type: Object,
      default: () => {
        return {
          on: {},
          attrs: {},
          props: {}
        }
      }
    },
    // 处理move事件 为了能拿到返回值
    move: {
      type: Function,
      default: () => true
    }
  },
  // 监听事件 映射回sortable配置上响应
  emits: [
    'update:modelValue',
    'endEvent',
    'chooseEvent',
    'unchooseEvent',
    'startEvent',
    'onSetEvent',
    'addEvent',
    'updateEvent',
    'sortEvent',
    'removeEvent',
    'filterEvent',
    'cloneEvent',
    'changeEvent'
  ],
  setup(props, { emit }) {
    // 处理props和model
    let { tag, componentData, move } = props
    let { modelValue } = toRefs(props)
    let sort = ref(null)
    let refresh = ref(true)
    // 计算列表数据
    const list = computed({
      get() {
        return modelValue.value
      },
      set(value) {
        emit('update:modelValue', value)
      }
    })
    // 获取$attrs的数据
    const attrs = useAttrs()
    const slots = useSlots()
    // 计算增加的索引
    const slotIndex = computed(() => {
      return slots.header ? 1 : 0
    })
    const handleIndex = (evt) => {
      if (evt.oldIndex !== null) evt.oldIndex -= slotIndex.value
      if (evt.newIndex !== null) evt.newIndex -= slotIndex.value
      if (evt.newDraggableIndex !== null) evt.newDraggableIndex -= slotIndex.value
      if (evt.oldDraggableIndex !== null) evt.oldDraggableIndex -= slotIndex.value
    }
    // 渲染拖拽事件
    const createSort = () => {
      sort.value && sort.value.destroy()
      refresh.value = false
      nextTick(() => {
        refresh.value = true
        requestAnimationFrame(() => {
          sort.value = new Sortable(document.querySelector('#custom-draggable'), {
            ...attrs.value,
            filter: '.disabled-filter,' + attrs.filter,
            setData: (/** DataTransfer */ dataTransfer, /** HTMLElement*/ dragEl) => {
              emit('onSetEvent', dataTransfer, dragEl)
            },

            // 元素被选中
            onChoose: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('chooseEvent', evt)
            },

            // 元素未被选中的时候(从选中到未选中)
            onUnchoose: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('unchooseEvent', evt)
            },

            // 开始拖拽的时候
            onStart: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('startEvent', evt)
            },

            // 结束拖拽
            onEnd: (/**Event*/ evt) => {
              handleIndex(evt)
              let { oldIndex, newIndex } = evt
              const currentRow = list.value.splice(oldIndex, 1)[0]
              list.value.splice(newIndex, 0, currentRow)
              emit('endEvent', evt)
            },

            // 元素从一个列表拖拽到另一个列表
            onAdd: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('addEvent', evt)
            },

            // 列表内元素顺序更新的时候触发
            onUpdate: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('updateEvent', evt)
            },

            // 列表的任何更改都会触发
            onSort: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('sortEvent', evt)
            },

            // 元素从列表中移除进入另一个列表
            onRemove: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('removeEvent', evt)
            },

            // 试图拖拽一个filtered的元素
            onFilter: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('filterEvent', evt)
            },

            // 拖拽移动的时候
            onMove: (/**Event*/ evt, /**Event*/ originalEvent) => {
              handleIndex(evt)
              let result = move(evt, originalEvent)
              // 去除经过头尾插槽的经过效果
              if (evt.related.classList.contains('disabled-filter')) {
                return false
              }
              return result
            },

            // clone一个元素的时候触发
            onClone: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('cloneEvent', evt)
            },

            // 拖拽元素改变位置的时候
            onChange: (/**Event*/ evt) => {
              handleIndex(evt)
              emit('changeEvent', evt)
            }
          })
        })
      })
    }

    // 监听变动
    watch(
      list,
      () => {
        createSort()
      },
      {
        deep: true
      }
    )

    onMounted(() => {
      createSort()
    })

    // 获取外部tag是不是注册的组件 这里有坑
    const transformComponent = () => {
      // 获取用户输入的带-的标签转换为驼峰
      let components = getCurrentInstance().appContext.components
      // 首字母大写
      let tagName = tag.replace(/-(\w)/g, (_, letter) => letter.toUpperCase())
      // 可能是多个斜杠
      let getName = tagName.charAt(0).toUpperCase() + tagName.slice(1)
      if (!components[getName]) {
        console.error(`组件${getName}不存在`)
        return tag
      }
      return components[getName]
    }
    return () => {
      // 处理渲染数据
      return refresh.value
        ? h(
            transformComponent(),
            {
              id: 'custom-draggable',
              ...(componentData.attrs || {}),
              ...(componentData.props || {}),
              ...(componentData.on || {})
            },
            [
              // 具名插槽header,
              slots.header ? h('div', { class: 'disabled-filter' }, slots.header()) : null,
              // 插槽渲染
              slots.default(),
              slots.footer ? h('div', { class: 'disabled-filter' }, slots.footer()) : null
            ]
          )
        : h('div')
    }
  }
})
</script>

使用方式和上面相同 但是注意常用事件是要加on的

<template>
        <renderer
          v-model="list"
          handle=".mdi-drag"
          tag="v-list"
          :component-data="getComponentData()"
        >
          <template #header>
            <v-list-subheader>header</v-list-subheader>
          </template>
          <v-list-item v-for="item in list">
            <v-icon class="cursor-pointer ml-2">mdi-drag</v-icon>
            {{item}}
          </v-list-item>
          <template #footer>
            <v-list-subheader>footer</v-list-subheader>
          </template>
        </renderer>
</template>
<script>
export default{
  methods: {
    getComponentData() {
      return {
        attrs: {
          class: 'pa-2 mt-2'
        },
        props: {
          'bg-color': '#eee'
        },
        on: {
          onClick: (e) => {
            console.log(1)
          }
        }
      }
    }
  }
}
</script>

踩坑点

为什么写了两版api的代码 因为一开始其实用option api写出来超级快 就是卡在了tag自定义渲染的部分 :is一直无法渲染正确的自定义组件 我以为是render的问题 用option去写render函数一直无效就直接开了一个新的文件写 发现setup下语法糖的return h渲染出来的也无效 只能用defineComponent踩渲染成功

问题点1:vite加载vuetify的机制 autoImport导致 :is失效

绞尽脑汁发现class的属性应用到了自定义组件上 但是其他props合并的属性不生效 打开了F12一看 好家伙 直接给我渲染出<v-list></v-list>在界面上 灵光一闪 去检查全局注册的组件 好家伙app.appContext.components 下只有两个组件 RouterLink 和 RouterView 去检查了vite 是autoImport 那是只有我在使用的某个组件时候才会加载

解决方案

引入所有组件

import { createVuetify } from 'vuetify'
import 'vuetify/styles'
import '@mdi/font/css/materialdesignicons.css'
import * as components from "vuetify/components"
export default createVuetify({
  icons: {
    defaultSet: 'mdi'
  },
  components
})

问题点2:机制问题 v-on 和 h函数里放事件绑定的方式不同

在is写法中 :on是可以直接 写 click……之类的 在render中的 click事件要写成onClick

问题点3:增加了自定义插槽 sortablejs认为也是拖拽元素

在增加了首尾插槽后 我发现拖拽的时候会拖拽出null的东西出来 一下 oh~ 是sortablejs认为是拖拽元素 导致索引变动了 数组的索引都+1了

解决方案 onEnd时候处理 有header的插槽 索引就-1 并传递正确的索引回事件里

         onEnd: (/**Event*/ evt) => {
              evt.oldIndex -= this.slotIndex
              evt.newIndex -= this.slotIndex
              evt.newDraggableIndex -= this.slotIndex
              evt.oldDraggableIndex -= this.slotIndex
              let { oldIndex, newIndex } = evt
              const currentRow = this.list.splice(oldIndex, 1)[0]
              this.list.splice(newIndex, 0, currentRow)
              // evt.clone && evt.clone.remove()
              this.$emit('endEvent', evt)
            }

问题点4:阻止经过事件在头尾插槽响应动画

被认为是插槽元素还会伴随着 可以被经过动画影响的情况 所以我们要对move事件进行修改 以及头尾插槽 添加标识的class

解决方案

            // 拖拽移动的时候 这个特别处理从props传入 因为要获取判定
            onMove: (/**Event*/ evt, /**Event*/ originalEvent) => {
              let result = this.move(evt, originalEvent)
              // 去除经过头尾插槽的经过效果
              if (evt.related.classList.contains('disabled-filter')) {
                return false
              }
              return result
            },

这就是这次编写一个封装sortablejs组件的心得