zl程序教程

您现在的位置是:首页 >  前端

当前栏目

VUE组件递归实现自定义目录及拖拽效果

Vue组件递归 实现 自定义 目录 效果 拖拽
2023-09-11 14:19:54 时间

  最近在做一个类似语雀一样的项目,自定义了一个目录,无限层级,并有拖动等效果(与语雀里知识库目录一样),自己手写,记录下代码。

  组件核心思想就是组件递归,很多插件的tree结构,其核心原理也就是用的组件递归。

一、我们来看看实例代码,不使用组件递归的话怎么写

1、组件

<template>
<div :class="{'expand': expand}">
  <div class="cata-item flex" :class="{'draggable': editing}"
  :draggable="editing"
  @dragstart="dragstart($event, item.id)">
    <template v-if="editing">
      <div class="sort-line" :class="{'b-color': peerShow}"
      @dragenter="peerShow = true"
      @dragleave="peerShow = false"
      @dragover.prevent
      @drop.prevent="dragDropPeer($event, item)">
        <div class="before"></div>
      </div>
      <div class="sort-line child" :class="{'b-color': childShow}"
      @dragenter="childShow = true"
      @dragleave="childShow = false"
      @dragover.prevent
      @drop.prevent="dragDropChild($event, item)">
        <div class="before"></div>
      </div>
    </template>
    <i class="nb-pull-down" :class="{'expand': expand}"
    v-if="item.childrenList.length > 0 || adding"
    @click="expand = !expand"></i>
    <a-input v-if="renaming"
    v-model="cataInfo.title"
    style="width: 500px;"
    @keyup.enter.native="submitAdd">
    </a-input>
    <div v-else class="title" @click="jump(item)">{{item.title}}</div>
    <div class="dot" :class="{'edit': editing}"></div>
    <div class="time" v-if="!editing">{{item.createdTime.substring(0,11)}}</div>
    <div class="operate" v-else>
      <a-popover v-if="!lastChild" overlayClassName="nav-popover" placement="bottom">
        <div class="popover-nav-box cata" slot="content">
          <div class="popover-item" @click="addType(item.id, 'article')">添加文章</div>
          <div class="popover-item" @click="addType(item.id, 'resource')">添加资源</div>
          <div class="popover-item" @click="addType(item.id, 'link')">添加链接</div>
          <div class="popover-item" @click="addType(item.id, '')">添加分组</div>
        </div>
        <i class="nb-add"></i>
      </a-popover>
      <a-popover overlayClassName="nav-popover" placement="bottom">
        <div class="popover-nav-box cata" slot="content">
          <a-popconfirm
            title="是否删除子级目录?"
            ok-text=""
            cancel-text=""
            @confirm="delCata(item.id, true)"
            @cancel="delCata(item.id, false)">
            <div class="popover-item">移除目录</div>
          </a-popconfirm>
          <div class="popover-item" @click="rename">重命名</div>
        </div>
        <i class="nb-more"></i>
      </a-popover>
    </div>
  </div>
  <div class="add-box" v-if="adding">
    <template v-if="cataInfo.type === 'link'">
      <a-input v-model="cataInfo.title"
      style="width: 300px;"
      placeholder="标题"
      @keyup.enter.native="submitAdd">
      </a-input>
      <a-input v-model="cataInfo.link"
      style="width: 300px;"
      placeholder="链接"
      @keyup.enter.native="submitAdd">
      </a-input>
    </template>
    <a-input v-else v-model="cataInfo.title"
    placeholder="标题"
    style="width: 500px;"
    @keyup.enter.native="submitAdd">
    </a-input>
  </div>
</div>
</template>
<script>
import { saveCatalogApi, delCatalogApi, sortCatalogApi } from '@/apis'
import { cbSuccess } from '@/utils'
export default {
  props: ['item', 'editing', 'dragId', 'lastChild'],
  data () {
    return {
      expand: false,
      renaming: false,
      adding: false,
      cataInfo: {
        type: '',
        title: '',
        link: ''
      },
      peerShow: false,
      childShow: false
    }
  },
  methods: {
    rename () {
      this.renaming = true
      this.cataInfo = this.item
    },
    addType (id, type) {
      this.adding = true
      this.cataInfo.type = type
      this.cataInfo.parentId = id
    },
    async submitAdd () {
      let _cata = this.cataInfo
      if (!_cata.title) {
        this.$message.error('标题不能为空')
        return
      }
      if (_cata.type === 'link' && !_cata.link) {
        this.$message.error('链接不能为空')
        return
      }
      let { data } = await saveCatalogApi(_cata)
      cbSuccess(data, _ => {
        this.$emit('refresh')
        this.adding = false
        this.cataInfo = {
          type: '',
          title: '',
          link: '',
          knowledgeId: this.$route.params.id
        }
        this.renaming = false
      })
    },
    async delCata (id, isAll) {
      let { data } = await delCatalogApi(id, isAll)
      cbSuccess(data, _ => {
        this.$emit('refresh')
      })
    },
    // 拖动排序
    dragstart (e, id) {
      this.$emit('start', id)
    },
    dragDropPeer (e, item) { // 拖动目标的同级
      if (this.dragId === item.id) return
      let _data = {
        id: this.dragId,
        newParentId: item.parentId,
        newSort: item.sort + 1
      }
      this.dragDrop(_data)
      this.peerShow = false
    },
    dragDropChild (e, item) { // 拖动目标的子级
      if (this.dragId === item.id) return
      let _data = {
        id: this.dragId,
        newParentId: item.id
      }
      this.dragDrop(_data)
      this.childShow = false
    },
    async dragDrop (_data) {
      let { data } = await sortCatalogApi(_data)
      cbSuccess(data, _ => {
        this.$emit('refresh')
      })
    },
    jump (item) {
      if (item.link) {
        window.open(item.link, '_blank')
      } else if (item.baseId) {
        let _routePath = this.$router.resolve(`/blog/${item.baseId}`)
        window.open(_routePath.href, '_blank')
      }
    }
  },
  mounted () {
    this.cataInfo.knowledgeId = this.$route.params.id
  }
}
</script>
<style lang="stylus" scoped>
.sort-line{
  width 100%
  box-sizing border-box
  margin-left 8px
  height 34px
  position absolute
  bottom 0
  border-bottom 2px solid transparent
  .before{
    width 8px
    height 8px
    border 2px solid #4882fc
    border-radius 50%
    position absolute
    left -7px
    bottom -5px
    display none
  }
  &.b-color{
    border-color #4882fc
    & > .before{
      display block
    }
  }
}
.sort-line.child{
  width calc(100% - 25px)
  margin-left 25px
}
.cata-item{
  position relative
  height 34px
  line-height 34px
  &:hover{
    background #F7F8FC
  }
  i{
    font-size 13px
    color #B8BECC
    opacity 0.5
    margin-right 10px
    cursor pointer
    &.expand{
      transform rotate(270deg)
    }
  }
  .dot{
    flex 1
    width 200px
    height 1px
    border-top: 1px dashed #B8BECC;
    opacity: 0.5;
    margin 0 30px
    &.edit{
      border-top none
    }
  }
}
.title{
  font-size 14px
  line-height 34px
  color #37393D
  cursor pointer
}
.ml23{
  .title{
    color #858A94
  }
}
.c3 .title{
  font-size 12px
}
.add-box{
  padding-left 23px
  text-align left
  &:hover{
    background #F7F8FC
  }
}
</style>

2、调用

    <div v-for="item in cts" :key="item.id">
      <CataItem :item="item" :editing="editing" @refresh="fetchData" :dragId="dragId" @start="dragstart"></CataItem>
      <template v-if="item.childrenList && item.childrenList.length > 0">
        <div class="ml23" v-for="c1 in item.childrenList" :key="c1.id">
          <CataItem :item="c1" :editing="editing" @refresh="fetchData" :dragId="dragId" @start="dragstart"></CataItem>
          <template v-if="c1.childrenList.length > 0">
            <div class="ml23" v-for="c2 in c1.childrenList" :key="c2.id">
              <CataItem :item="c2" :editing="editing" @refresh="fetchData" :dragId="dragId" @start="dragstart"></CataItem>
              <template v-if="c2.childrenList.length > 0">
                <div class="ml23" v-for="c3 in c2.childrenList" :key="c3.id">
                  <CataItem :item="c3" :editing="editing" @refresh="fetchData" :dragId="dragId" @start="dragstart"></CataItem>
                  <template v-if="c3.childrenList.length > 0">
                    <div class="ml23" v-for="c4 in c3.childrenList" :key="c4.id">
                      <CataItem :item="c4" :editing="editing" :lastChild="true" @refresh="fetchData" :dragId="dragId" @start="dragstart"></CataItem>
                    </div>
                  </template>
                </div>
              </template>
            </div>
          </template>
        </div>
      </template>
    </div>

  我们看到这调用简直就是噩梦啊,而且不能做到无限层级,想要多少级,就得写多少次,而且很容易写错。

二、使用组件递归思想优化

  通过观察发现很多层级都是一样的

<CataItem :item="c3" :editing="editing" @refresh="fetchData" :dragId="dragId" @start="dragstart"></CataItem>
<template v-if="c3.childrenList.length > 0">
  <div class="ml23" v-for="c4 in c3.childrenList" :key="c4.id">
  ...   </div> </template>

  每个这一级都是一样的,那么我们就来通过组件递归来优化一下组件和调用

1、组件优化

<template>
<div>
  <div :class="{'expand': expand}">
    <div class="cata-item flex" :class="{'draggable': editing}"
    :draggable="editing"
    @dragstart="dragstart($event, item.id)">
      <template v-if="editing">
        <div class="sort-line" :class="{'b-color': peerShow}"
        @dragenter="peerShow = true"
        @dragleave="peerShow = false"
        @dragover.prevent
        @drop.prevent="dragDropPeer($event, item)">
          <div class="before"></div>
        </div>
        <div class="sort-line child" :class="{'b-color': childShow}"
        @dragenter="childShow = true"
        @dragleave="childShow = false"
        @dragover.prevent
        @drop.prevent="dragDropChild($event, item)">
          <div class="before"></div>
        </div>
      </template>
      <i class="nb-pull-down" :class="{'expand': expand}"
      v-if="item.childrenList.length > 0 || adding"
      @click="expand = !expand"></i>
      <a-input v-if="renaming"
      v-model="cataInfo.title"
      style="width: 500px;"
      @keyup.enter.native="submitAdd">
      </a-input>
      <div v-else class="title" @click="jump(item)">{{item.title}}</div>
      <div class="dot" :class="{'edit': editing}"></div>
      <div class="time" v-if="!editing">{{item.createdTime.substring(0,11)}}</div>
      <div class="operate" v-else>
        <a-popover overlayClassName="nav-popover" placement="bottom">
          <div class="popover-nav-box cata" slot="content">
            <div class="popover-item" @click="addType(item.id, 'article')">添加文章</div>
            <div class="popover-item" @click="addType(item.id, 'resource')">添加资源</div>
            <div class="popover-item" @click="addType(item.id, 'link')">添加链接</div>
            <div class="popover-item" @click="addType(item.id, '')">添加分组</div>
          </div>
          <i class="nb-add"></i>
        </a-popover>
        <a-popover overlayClassName="nav-popover" placement="bottom">
          <div class="popover-nav-box cata" slot="content">
            <a-popconfirm
              title="是否删除子级目录?"
              ok-text=""
              cancel-text=""
              @confirm="delCata(item.id, true)"
              @cancel="delCata(item.id, false)">
              <div class="popover-item">移除目录</div>
            </a-popconfirm>
            <div class="popover-item" @click="rename">重命名</div>
          </div>
          <i class="nb-more"></i>
        </a-popover>
      </div>
    </div>
    <div class="add-box" v-if="adding">
      <template v-if="cataInfo.type === 'link'">
        <a-input v-model="cataInfo.title"
        style="width: 300px;"
        placeholder="标题"
        @keyup.enter.native="submitAdd">
        </a-input>
        <a-input v-model="cataInfo.link"
        style="width: 300px;"
        placeholder="链接"
        @keyup.enter.native="submitAdd">
        </a-input>
      </template>
      <a-input v-else v-model="cataInfo.title"
      placeholder="标题"
      style="width: 500px;"
      @keyup.enter.native="submitAdd">
      </a-input>
    </div>
  </div>
  <div class="ml23" v-for="cata in item.childrenList" :key="cata.id">
    <cataTree
    :item="cata"
    :editing="editing"
    :dragId="dragId"
    @refresh="$emit('refresh')"
    @start="$emit('start', arguments[0])">
    </cataTree>
  </div>
</div>
</template>
<script>
import { saveCatalogApi, delCatalogApi, sortCatalogApi } from '@/apis'
import { cbSuccess } from '@/utils'
export default {
  name: 'cataTree',
  props: ['item', 'editing', 'dragId'],
  data () {
    return {
      expand: false,
      renaming: false,
      adding: false,
      cataInfo: {
        type: '',
        title: '',
        link: ''
      },
      peerShow: false,
      childShow: false
    }
  },
  methods: {
    rename () {
      this.renaming = true
      this.cataInfo = this.item
    },
    addType (id, type) {
      this.adding = true
      this.cataInfo.type = type
      this.cataInfo.parentId = id
    },
    async submitAdd () {
      let _cata = this.cataInfo
      if (!_cata.title) {
        this.$message.error('标题不能为空')
        return
      }
      if (_cata.type === 'link' && !_cata.link) {
        this.$message.error('链接不能为空')
        return
      }
      let { data } = await saveCatalogApi(_cata)
      cbSuccess(data, _ => {
        this.$emit('refresh')
        this.adding = false
        this.cataInfo = {
          type: '',
          title: '',
          link: '',
          knowledgeId: this.$route.params.id
        }
        this.renaming = false
      })
    },
    async delCata (id, isAll) {
      let { data } = await delCatalogApi(id, isAll)
      cbSuccess(data, _ => {
        this.$emit('refresh')
      })
    },
    // 拖动排序
    dragstart (e, id) {
      this.$emit('start', id)
    },
    dragDropPeer (e, item) { // 拖动目标的同级
      if (this.dragId === item.id) {
        this.peerShow = false
        return
      }
      let _data = {
        id: this.dragId,
        newParentId: item.parentId,
        newSort: item.sort + 1
      }
      this.dragDrop(_data)
      this.peerShow = false
    },
    dragDropChild (e, item) { // 拖动目标的子级
      if (this.dragId === item.id){
        this.childShow = false
        return
      }
      let _data = {
        id: this.dragId,
        newParentId: item.id
      }
      this.dragDrop(_data)
      this.childShow = false
    },
    async dragDrop (_data) {
      let { data } = await sortCatalogApi(_data)
      cbSuccess(data, _ => {
        this.$emit('refresh')
      })
    },
    jump (item) {
      if (item.link) {
        window.open(item.link, '_blank')
      } else if (item.baseId) {
        let _routePath = this.$router.resolve(`/blog/${item.baseId}`)
        window.open(_routePath.href, '_blank')
      }
    }
  },
  mounted () {
    this.cataInfo.knowledgeId = this.$route.params.id
  }
}
</script>
<style lang="stylus" scoped>
.expand + .ml23{
  display none
}
.ml23{
  margin-left 23px
}
.sort-line{
  width 100%
  box-sizing border-box
  margin-left 8px
  height 34px
  position absolute
  bottom 0
  border-bottom 2px solid transparent
  .before{
    width 8px
    height 8px
    border 2px solid #4882fc
    border-radius 50%
    position absolute
    left -7px
    bottom -5px
    display none
  }
  &.b-color{
    border-color #4882fc
    & > .before{
      display block
    }
  }
}
.sort-line.child{
  width calc(100% - 25px)
  margin-left 25px
}
.cata-item{
  position relative
  height 34px
  line-height 34px
  &:hover{
    background #F7F8FC
  }
  i{
    font-size 13px
    color #B8BECC
    opacity 0.5
    margin-right 10px
    cursor pointer
    &.expand{
      transform rotate(270deg)
    }
  }
  .dot{
    flex 1
    width 200px
    height 1px
    border-top: 1px dashed #B8BECC;
    opacity: 0.5;
    margin 0 30px
    &.edit{
      border-top none
    }
  }
}
.title{
  font-size 14px
  line-height 34px
  color #37393D
  cursor pointer
}
.ml23{
  .title{
    color #858A94
  }
}
.c3 .title{
  font-size 12px
}
.add-box{
  padding-left 23px
  text-align left
  &:hover{
    background #F7F8FC
  }
}
</style>

  主要修改的就是其中标红的,我们看到修改的内容很少。

  需要特别注意的就是:组件递归调用自己的时候,其 props 和 方法传递,均需要捕获并触发一下

2、调用优化

// 目录组件
<CataItem v-for="item in cts" :key="item.id"
:item="item"
:editing="editing"
:dragId="dragId"
@refresh="fetchData"
@start="dragstart">
</CataItem>

  调用优化就直接循环使用即可。我们看到组件递归优化之后,调用就比较方便美观了。

三、简单实例

  其实没啥好说的,就是组件递归,这里呢简单写个例子,面试被问到的时候直接拿来手写代码也行,没多少代码量,主要是让还没懂组件递归的同学好理解,核心就这个,组件自己调用自己

1、组件

<template>
  <ul>
    <li v-for="(item,index) in list " :key="index">
      <p>{{item.name}}</p>
      <treeMenus :list="item.children"></treeMenus>
    </li>
  </ul>
</template>
<script>
export default {
  name: "treeMenus",
  props: {
    list: Array
  }
};
</script>
<style>
    ul {
    padding-left: 20px !important;
    }
</style>

2、html调用

<treeMenus :list="treeMenusData"></treeMenus>
// 数据格式
treeMenusData: [
  {
    name: "菜单1",
    children: [
      {
        name: "菜单1-1",
        children: []
      }
    ]
  }
]

  这个简单的例子就比较好理解,主要就是利用了 组件的 name 属性