如何實現一個這樣的級聯組件

Vue封裝組件系列文章javascript

組件背景

根據產品原型實現一個級聯組件,下面看演示圖html

級聯組件

應用場景不少,如:後臺管理系統,旅遊系統,廣告投放系統,營銷系統...等,如今流行 VueReactAnagular 三大框架,下面看看怎麼使用 Vue實現

實現邏輯

產品經理的評審功能需求以下前端

  • 根據大分類到子分類層級選擇,無層級限制(根據UI的橫板寬度,適合作多級,但深度很深的場景並很少)
  • 每一個層級支持全選,根據子級能夠推導全選項選中,並對其父級執行選中操做
  • 已選層級可顯示出結果列表,可對其結果操做,並有快速清空結果功能
  • 分類名稱字數並不作限制,待選區域分類名稱應在該項中居中顯示,長度過長換行顯示
  • 結果選項結構簡化,每項固定一行,過長在尾部出現...表明過長,鼠標移上時顯示所有內容

思路

Vue.js 的核心包括一套「響應式系統」。vue

"響應式",開發思路跟Jquery的開發思路徹底不一樣。java

「響應式」,是指當數據改變後,Vue 會通知到使用該數據的代碼。例如,視圖渲染中使用了數據,數據改變後,視圖也會自動更新。ios

根據地區數據 JSON 能夠看出其結構git

[
  {
    "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": []
               }
            ]
          }
        ]
      }
    ]
  • 中國github

    • 直轄市
    • xx省json

      • xx市segmentfault

        • 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
微信二維碼: 掃碼添加好友,交個朋友

微信二維碼

相關文章
相關標籤/搜索