如何实现一个这样的级联组件

6,854 阅读6分钟

Vue封装组件系列文章

开发了新的开源组件库, www.npmjs.com/package/adv… ,欢迎大家下载使用,如有问题可以提issues

组件背景

根据产品原型实现一个级联组件,下面看演示图

级联组件

应用场景很多,如:后台管理系统,旅游系统,广告投放系统,营销系统...等,现在流行VueReactAnagular 三大框架,下面看看怎么使用Vue实现

实现逻辑

产品经理的评审功能需求如下

  • 根据大分类到子分类层级选择,无层级限制(根据UI的横板宽度,适合做多级,但深度很深的场景并不多)
  • 每个层级支持全选,根据子级可以推导全选项选中,并对其父级执行选中操作
  • 已选层级可显示出结果列表,可对其结果操作,并有快速清空结果功能
  • 分类名称字数并不做限制,待选区域分类名称应在该项中居中显示,长度过长换行显示
  • 结果选项结构简化,每项固定一行,过长在尾部出现...代表过长,鼠标移上时显示全部内容

思路

Vue.js 的核心包括一套“响应式系统”。

"响应式",开发思路跟Jquery的开发思路完全不同。

“响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。

根据地区数据 JSON 可以看出其结构

[
  {
    "value": "中国",
    "key": 1156,
    "id": 1156,
    "children": [
      {
        "value": "北京市",
        "id": 10000,
        "key": 10000,
        "children": []
      },
      {
        "value": "河北省",
        "key": 200107,
        "id": 200107,
        "children": [
          {
            "value": "石家庄",
            "key": 20010701,
            "id": 20010701
          },
          {
            "value": "唐山市",
            "key": 20010702,
            "id": 20010702,
            "children": [
               {
                  "value": "路南区",
                  "key": 2001070201,
                 "id": 2001070201,
                  "children": []
               }
            ]
          }
        ]
      }
    ]

  • 中国
    • 直辖市
    • xx省
      • xx市
        • xx区
      • xx市
        • xx县

待选数据组件

这是一个循环嵌套的数据对象,而组件嵌套似乎不能满足产品需求,如果使用数组来代替层级,似乎可以解决数据嵌套的问题

array => level 1 -> level 2 -> level 3 -> level 4

level 1 => current, children => level 2 (array) level 2 => current, children => level 3 (array) ...

每个level 都是一个整体,

  • 有标题 title
  • 有全选 计算data中是否都选中 select
  • 子集的集合数据 data
  • 有当前选中 current
  • 标记当期层级 数组的索引 level

首先定义个空的数组代表组件

const array = []

把数据处理成数组格式就能展开这个组件,那怎么处理数据呢 初始化组件时不是所有都显示,必须让用户选择当前一个顶级大类

拿到所有顶级大类,并构建第一个元素

  • title = 省级
  • data = 顶级大类
  • current = 空
  • level = 1
  • select = false

array.push({title, select, data, current, level})

在选择顶级大类时,给这个数组增加其一个子集元素

array.push({title, select, data, current, level}) ...

依次类推

结果选择器

获取组件的选择结果, 可以过滤数据的check 属性得到, 可使用Vue的计算属性得知随时的结果

结果选择框可以直接绑定已选的计算组件,可构建结果UI

组件构想

  • 主组件
  • 布局组件
  • 选择项

主组件 Selecter

用来负责组件框架, 左右分栏, 左边是选择区域, 右边是结果区域 这个是组件引用层,统一对外提供导入props 数据 和 导出的 emit 事件 组件需要做到完全配置化,内部所以参数需要被抽象

  • 选择区 更具层级平均分配空间,所有在横向固定空间中,不能做过多的层级,太窄了没法显示 因为需要循环显示其层级,抽离层级为布局组件,布局组件由 标题滚动的选择区域 组成
        <Row>
          <Col :span="col" v-for="(box, idx) in resource" :key="idx">
            <select-item :title="box.title">
              <select-box v-model="box.current" :data="box.data" :level="box.level" @on-child="pushChild" @on-select="selectAll" />
            </select-item>
          </Col>
        </Row>
  • 结果区 在有选择时才显示,有标题栏显示,结果区可统计结果个数,选择项使用Tag标签,支持快速删除,建立纵向滚动条 可使用布局组件 与选择区保持风格统一,
      <Col span="7" offset="1">
        <select-item v-if="resultLen && transfer" title="已选" clear @on-clear="$emit('on-clear', {list: data})">
          <div v-for="item in result" :key="item.id" class="c-pop-tip">
            <Tag :name="item.value" closable class="c-tag-item" @on-close="handleClose">{{item.value}}</Tag>
          </div>
        </select-item>
      </Col>

布局组件 item

要兼容选择区与结果区使用,所以统计个数得有开关控制, 边框,颜色 UI 控制

全选状态按钮 CheckBox

搜索输入框组件带搜索按钮

抽象 清空按钮UI 抽象 统计个数UI

box.png

选择项 box(子组件)

最关键的组件就算这个了 选择项应该可以类分成两种,

  • 一种是到这一层级就没有子级的
  • 一种是到这一层级还有子级的

使用条件判断即可实现分支显示,但是用 CheckBox 组件,他本身有change功能,如果是v-model绑定的,他的值改变,会让主树上通知到这次更新,

这针对于上面的第二种,在这层级没有子级可以完成他的工作,他的更新,他的父级可以计算半选状态,也可以在父级计算选择的个数,但是如果是有子级,这里要响应他的所有子集也要选中,如果子集选中后,子集的全选也是选中状态

在开发的过程中,这里的变化关系很复杂,不用图形可解释不清楚

  • 事件 点击行可以更改子集变化, 选中子集也要更改数据变化

  • UI排版

逻辑

双向绑定

v-model 绑定数据的好处是: 数据在内部发生了改变,而在原始端同样改变了,只要使用就可以了, 当然在使用上也有些不方便的地方, props导入的数据,通过什么props 属性接收呢, value

...
props: {
  value: {
    type: Array
  }
}
...

在组件内部是不能Set 改变的,只能通过事件传到父组件中来 通过什么方法名来传呢, input (初级很多人不知道)
this.$emit('input', val)

原始数据构建选择层级组件

在初始化过程中,构建第一层级组件的 title data current level 假使省市json 数据为 cityJson 构建第一层级的data

const data = this.cityJson.map(ret => {
  delete ret.children
  return ret
})

当用户选择层级的 item 时触发 动作新增层级数据 当用户选中层级的 item 时触发 动作新增层级数据 选中该层级下所有数据

全选

selectAll ({level, check, cat}) {
  let index = level - 2
  let current = index > -1 ? this.resource[index].current : ''
      cat && (current = cat)
      this.$emit('on-select', {
        check,
        current,
        list: this.data
      })
}

抛到根组件引用处处理,主要是循环当前层级的数据的check 属性为true

全选的checkbox 要屏蔽不能选择,让其选择事件通讯子组件中

搜索

搜索有两种实现,一种是前端正则实现,这里比较考验前端的正则能力,还有优化循环速度

另一种解法,就是通过后台查询结果,在根据结果筛选出数据显示,不能直接使用后端数据,因为破坏了树根数据,是没法计算选择的,在搜索里有清空功能,清空后的选择搜索前的当前项,代码如下

clearBox (level) {
      let current
      const index = level - 2
      // 还原原来所有的data
      if (index > -1) {
        current = this.resource[index].current
        this.pushChild({ level: index + 1, current })
      } else this.resource[0].data = this.data
    }

删除

结果框的清空的逻辑相对比较简单,只要把所有选择的数据 check 属性为 false 当然也可以用循环都设置一遍,但设置这里都要使用$set 去更新数据

<select-item
    v-if="resultLen && transfer"
    title="已选"
    clear
    @on-clear="$emit('on-clear', {list: data})">
    <div
      v-for="item in result"
      :key="item.id"
      class="c-pop-tip">
      <Tag
        :name="item.value"
        closable
        class="c-tag-item"
        @on-close="handleClose">{{item.value}}</Tag>
    </div>
  </select-item>

事件是组件的关键的开发,事件的响应在引用的组件里处理

代码

贴上所有源代码,难免里面有些引用的文件,如果不能直接使用,请不要喷,因为这篇文章不是送个伸手党的,是你有一定的基础,想提升一下技能的你

主组件 Selecter

<template>
  <div class="c-selecter">
    <Row :gutter="12">
      <Col span="16">
        <Row>
          <Col
            :span="col"
            v-for="(box, idx) in resource"
            :key="idx">
            <select-item :title="box.title">
              <select-box
                v-model="box.current"
                :data="box.data"
                :level="box.level"
                @on-child="pushChild"
                @on-select="selectAll" />
            </select-item>
          </Col>
        </Row>
      </Col>
      <Col span="7" offset="1">
        <select-item
          v-if="resultLen && transfer"
          title="已选"
          clear
          @on-clear="$emit('on-clear', {list: data})">
          <div
            v-for="item in result"
            :key="item.id"
            class="c-pop-tip">
            <Tag
              :name="item.value"
              closable
              class="c-tag-item"
              @on-close="handleClose">{{item.value}}</Tag>
          </div>
        </select-item>
      </Col>
    </Row>
  </div>
</template>
<script>
import SelectItem from './select-item.vue'
import SelectBox from './select-box.vue'
export default {
  name: 'selecter',
  components: { SelectItem, SelectBox },
  props: {
    value: {
      type: Array
    },
    title: {
      type: Array
    },
    data: {
      type: Array
    },
    transfer: {
      type: Boolean,
      default: true
    }
  },
  data () {
    return {
      resource: []
    }
  },
  computed: {
    col () {
      return 24 / this.resource.length
    },
    result () {
      return this.value
    },
    resultLen () {
      return Boolean(this.value.length)
    }
  },
  watch: {
    data (nVal) {
      if (nVal && nVal.length) this.updateResource()
      else this.resource = []
    }
  },
  methods: {
    updateResource () {
      this.resource = []
      this.resource.push({
        data: this.data,
        current: '',
        level: 1,
        title: this.title[0]
      })
    },
    handleClose (event, name) {
      this.$emit('on-delete', {list: this.data, name})
    },
    selectAll ({level, check, cat}) {
      let index = level - 2
      let current = index > -1 ? this.resource[index].current : ''
      cat && (current = cat)
      this.$emit('on-select', {
        check,
        current,
        list: this.data
      })
    },
    pushChild (params) {
      const {item, level} = params
      const len = this.resource.length
      if (level <= len - 1) {
        this.resource.splice(level, len - level)
      }
      this.resource.push({
        data: item.children,
        current: '',
        level: level + 1,
        title: this.title[level] || item.value
      })
      this.resource[level - 1].current = item.value
    }
  },
  created () {
    this.updateResource()
  }
}
</script>
<style lang="stylus" scoped>
@import "~assets/styles/mixin.styl"

.c-pop-tip
  width 100%
.c-tag-item
  width 90%
  margin 8px 8px 0
  padding 2px 6px
  display block
  font-size 14px
  height 28px
  >>>span.ivu-tag-text
    $no-wrap()
    width calc(100% - 22px)
    display inline-block
  >>>.ivu-icon-ios-close
    top -8px
</style>

布局组件 item

<template>
  <div class="c-select-item">
    <div class="c-header">
      <span class="c-header-title">{{title}}</span>
      <span class="c-header-clear" v-if="clear" @click="$emit('on-clear')">清空全部</span>
    </div>
    <div class="c-selecter-content">
      <slot></slot>
    </div>
  </div>
</template>
<script>
export default {
  name: 'selectItem',
  props: {
    title: {
      type: String
    },
    clear: {
      type: Boolean
    }
  }
}
</script>
<style lang="stylus" scoped>
@import "~assets/styles/mixin.styl"

.c-select-item
  background-color #fff
  border solid 1px #dee4f5
  .c-header
    padding 0 12px
    height 34px
    font-size 14px
    color #333
    border-bottom solid 1px #dee4f5
    background-color #fafbfe
    .c-header-title, .c-header-clear
      height 34px
      line-height 34px
      vertical-align middle
    .c-header-clear
      color #598fe6
      float right
      cursor pointer
  .c-selecter-content
    $scroll()
    height 246px
    width 100%
    padding-bottom 8px
</style>

选择项(子组件)box

<template>
  <div class="c-select-box">
    <div class="c-check-all">
      <div class="c-item-select c-cataract" @click="selectAll"></div>
      <Checkbox class="c-check-item" v-model="all">全选</Checkbox>
    </div>
    <div v-for="item in data" :key="item.id">
      <div v-if="item.children && item.children.length" :class="itemClasses(item)" @click="$emit('on-child', {item, level})">
        <Checkbox v-model="item.check" :indeterminate="itemIndeterminate(item)"></Checkbox>
        <span>{{item.value}}</span>
        <Icon type="ios-arrow-forward" class="c-check-arrow" size="14" color="#c1c1c1" />
        <span class="c-item-checkbox c-cataract" @click="selectItem(item)"></span>
      </div>
      <Checkbox v-else class="c-check-item" v-model="item.check">{{item.value}}</Checkbox>
    </div>
  </div>
</template>
<script>

const computeChild = (list, Vue) => {
  list.forEach(item => {
    if (item.children && item.children.length) {
      const child = item.children
      if (child.every(ret => ret.check)) Vue.$set(item, 'check', true)
      else Vue.$set(item, 'check', false)
      computeChild(child, Vue)
    }
  })
}

export default {
  name: 'selectBox',
  props: {
    value: {
      type: [String, Number]
    },
    data: {
      type: Array
    },
    level: {
      type: Number
    }
  },
  computed: {
    itemClasses () {
      return item => {
        const cls = ['c-check-item']
        item.value === this.value && cls.push('active')
        return cls
      }
    },
    all () {
      const len = this.data.filter(ret => ret.check).length
      return this.data.length === len
    }
  },
  methods: {
    selectAll () {
      this.$emit('on-select', {
        check: !this.all,
        level: this.level
      })
    },
    selectItem (item) {
      this.$emit('on-select', {
        check: !item.check,
        level: this.level,
        cat: item.value
      })
    },
    itemIndeterminate (child) {
      const hasChild = (meta) => {
        return meta.children.reduce((sum, item) => {
          let foundChilds = []
          if (item.check) sum.push(item)
          if (item.children) foundChilds = hasChild(item)
          return sum.concat(foundChilds)
        }, [])
      }
      const some = hasChild(child).length > 0
      const every = child.children && child.children.every(ret => ret.check)
      return some && !every
    }
  },
  watch: {
    data: {
      handler (nVal, oVal) {
        computeChild(nVal, this)
      },
      deep: true
    }
  },
  mounted () {
    computeChild(this.data, this)
  }
}
</script>
<style lang="stylus" scoped>
@import "~assets/styles/mixin.styl"

.c-cataract
  display block
  position absolute
  top 0
  left 0
  z-index 8
  cursor pointer
.c-check-all
  width 100%
  height 36px
  position relative
  z-index 9
  &:hover
    .c-check-item
      background-color #f8f8f8
  .c-item-select
    width 100%
    height 100%
.c-check-item
  margin 0
  padding 0 12px
  display block
  position relative
  height 36px
  line-height 36px
  &:hover
    background-color #f8f8f8
  &.active
    color #598fe6
    background-color #f8f8f8
    .c-check-arrow
      color #598fe6 !important
  .c-check-arrow
    float right
    margin-top 10px
  .c-item-checkbox
    width 36px
    height 36px
.c-select-box >>>.ivu-checkbox-indeterminate
  .ivu-checkbox-inner
    background-color #6fb3fb
    border-color #6fb3fb
</style>

优化体验

  • 半选功能 在一个大分类的子分类里选择的分类,但是切到别的大类项,虽然结果框里有选择的分类,但是待选的框里还是不能显示子集,需求上线后,客户反应体验不好,所以就研究了复选框的 半选状态,其实改起来很简单,只要在计算属性的加个布尔值显示半选,布尔值就是该分类的data里是否有选中的项check = true

  • 行内文本过长,换行显示优化 因为分类的字数没有限制,做前端其实不能相信用户,同时也不能相信后端返回给的数据,也不能相信产品,在产品没有碰到过字数限制的功能时候产生的问题时,都是期待着用户是个正常的用户的。

    • 文本过长有两种方式解决:
      • 在文本区域设置固定宽度,在超过长度显示... (如果要显示全,只能增加鼠标悬停显示功能了)
      • item 行的高度不使用line-height的参数,用padding 做上下间隔后,让文本自动换行 (这样的问题是,右手边图标的居中问题,字数太多就会加高item项,美观度没那么统一)

经验总结

很多前端新人都接触Vue一年、甚至两年多才会使用像element uiiviewvant开源的UI基础库,但细心的你可能发现,这些只适合参照原型图实现html编码,但业务的层次抽离、逻辑的复用、组件化业务层方面都没有手把手教我们上路。

三大流行框架的核心是快速地组件化开发,而我们只是简单的在路由组件页面堆积UI库的组件吗,显然这不是我们想要的高效开发。一个项目可以大到100多个页面,如果不抽离组件,重复工作量不可预估,效率更是谈不上了。那么如何像作者一样能更深层次使用Vue呢,其实element ui的开源库,每一个组件的实现其实都是很基础的方法实现的,假如你要实现这样的基础库,你就会想办法去看源代码,看着看着你就学会了作者的很多思想,那还会有什么的组件实现不了了?

师傅领进门,修行靠个人,人人都是我们的老师。不知你是否赞成...

以上,欢迎拍砖~


欢迎关注我的开源仓库 GITHUB:xiejunping (Cabber) · GitHub 微信二维码: 扫码添加好友,交个朋友

微信二维码