介绍

因工作中须要实现一个不限层级的目录展现以及勾选问题,而支付宝小程序没有相干框架可用,故整顿一套计划解决该类问题。
该篇文章次要是记录树结构数据的解决办法,当然也可用于web端,思路都是一样的。内容包含一维数组转化为树结构数据,树结构数据的渲染,父子节点的联动抉择,历史节点的默认勾选等。
上面是针对关键性代码的一些解析。

源码

简略粗犷,间接上源码。

解决数据的类办法

/** * 图片空间目录构造排序 */const REG_CHAR_NUM = /[a-zA-Z0-9]/ // 字母和数字正则class CatalogSort {  /**   * @param {Object} resData 从接口或缓存拿到的数据   * @param {Boolean} isSingleChecked 是否单选 true-单选 false-多选   * @param {Array} historyChecked 初始化时须要选中的ids(历史ids)   */  constructor(resData, isSingleChecked, historyChecked = []) {    this.treeMap = null // 父-子id映射    this.tree = null // 结构实现的树结构数据    this.currentChecked = false // 以后点击选中状态,缩小子节点递归次数    this.isSingleChecked = isSingleChecked    this.checkedList = historyChecked[0] === 'all' ? [] : [...historyChecked] // 选中目录id的数组    this.checkedNameList = [] // 选中的目录名称数组    this.checkedAll = historyChecked[0] === 'all' // 是否全选,只在初始加载时用到    this.totalCount = 0 // 目录总数    this.sortInit(resData)  }  sortInit(resData) {    this.solveToTree(resData)    this.sortByNumLetter(this.tree)  }  /**   * 一维数据结构树结构   * @param {Array} resData 从接口获取到的源数据   */  solveToTree(resData) {    // 构建父-子映射    this.treeMap = resData.reduce((acc, cur) => {      cur.pictureCategoryName = cur.pictureCategoryName.trim()      cur.children = []      if (this.checkedAll) { // 如果全选,就填充选中的ids数组        cur.checked = true        this.checkedList.push(cur.pictureCategoryId)      } else { // 否则依据初始化实例对象时的默认选中项设置checked是否选中        cur.checked = this.checkedList.includes(cur.pictureCategoryId)      }      acc[cur.pictureCategoryId] = cur      this.totalCount ++      return acc    }, {})    // 初始化选中的目录名称列表,不间接在下面循环中结构,是为了管制与checkList索引统一    this.checkedNameList = this.checkedList.reduce((acc, id) => {      return acc.concat(this.treeMap[id].pictureCategoryName)    }, [])    // 构建树目录    this.tree = resData.filter(item => {      this.treeMap[item.parentId] && this.treeMap[item.parentId].children.push(item)      return item.parentId === ''    })  }  /**   * 目录排序 按数字、字母、中文排序   * @param {Array} tree 树结构数据   */  sortByNumLetter(tree) {    tree.sort((item1, item2) => {      if (REG_CHAR_NUM.test(item1.pictureCategoryName) || REG_CHAR_NUM.test(item2.pictureCategoryName)) {        // 如果是数字或字母,先依照数字或字母由1-10...a-z...A-Z...排序        if (item1.pictureCategoryName > item2.pictureCategoryName) {          return 1        } else if (item1.pictureCategoryName < item2.pictureCategoryName) {          return -1        }else{          return 0        }      } else {        // 如果是中文,依照规定排序(此处依据淘宝目录设置)        return item1.pictureCategoryName.localeCompare(item2.pictureCategoryName, 'en')      }    })    tree.forEach(item => {      if (item.children.length) {        this.sortByNumLetter(item.children)      }    })  }  /**   * 目录抉择   */  selectNode(id) {    let item = this.treeMap[id]    item.checked = !item.checked    this.currentChecked = item.checked    if (this.isSingleChecked) {      // 如果是单选,将上一次设置的值置为false      const checkPrev = this.checkedList.shift()      this.checkedNameList.shift()      if (checkPrev) {        this.treeMap[checkPrev].checked = false      }      this.setCheckedList(item.pictureCategoryId)      return    }    this.setCheckedList(item.pictureCategoryId)    this.checkChilds(item.children, item.checked)    this.checkParents(item.parentId)  }   /**   * 目录抉择的另一种办法(不举荐,递归了),放在这里做个借鉴,此处只兼容了多选,等须要单选再持续设置   */  /*selectNodeAnother(tree, id) {    for (let item of tree) {      if (item.pictureCategoryId === id) {        item.checked = !item.checked        this.currentChecked = item.checked        if (this.isSingleChecked) {          return true        }        this.setCheckedList(item.pictureCategoryId)        this.checkChilds(item.children, item.checked)        this.checkParents(item.parentId)        return true      }      if (item.children.length) {        const res = this.selectNode(item.children, id)        if (res) return res      }    }    return null  }*/  /**   * 子节点checked状态的扭转   * @param {Array} childItems 须要操作的子节点   * @param {Boolean} checked 是否选中   */  checkChilds(childItems, checked) {    if (childItems.length) {      childItems.forEach(item => {        // 如果子节点曾经是以后的选中态,跳出,缩小递归次数        if (item.checked === this.currentChecked) return        item.checked = checked        this.setCheckedList(item.pictureCategoryId)        this.checkChilds(item.children, checked)      })    }  }  /**   * 父节点checked状态的扭转   * @param {String} parentId 父id   * @param {Object} treeMap 父-子id映射对象   */  checkParents(parentId) {    if (this.treeMap[parentId] && this.treeMap[parentId].children.length) {      const parentChecked = this.treeMap[parentId].children.every(item => item.checked)      if (this.treeMap[parentId].checked === parentChecked) return // 如果父节点与须要选中的状态统一,则退出循环,不须要再往上冒泡递归      this.treeMap[parentId].checked = parentChecked      this.setCheckedList(this.treeMap[parentId].pictureCategoryId)      this.treeMap[parentId].parentId && this.checkParents(this.treeMap[parentId].parentId)    }  }  /**   * 设置选中ids   * @param {String} id 正在设置checked属性的节点id   */  setCheckedList(id) {    const checkedIndex = this.checkedList.findIndex(item => item === id)    if (this.currentChecked && checkedIndex === -1) { // 如果以后态选中,且节点id不在选中数组中就填充      this.checkedList.push(id)      this.checkedNameList.push(this.treeMap[id].pictureCategoryName)    } else if (!this.currentChecked && checkedIndex > -1) { // 如果以后态未选中,且节点id在选中数组中就删除      this.checkedList.splice(checkedIndex, 1)      // this.checkedNameList.findIndex(name => name === this.treeMap[id].pictureCategoryName) 不必此办法是避免重名导致删除出错      // 管制名称插入与id插入统一,即可间接依据独特索引来删除      this.checkedNameList.splice(checkedIndex, 1)    }  }}export { CatalogSort }

父组件

  • acss

    /* 目录树弹窗 */.selectCatalogDialog_contentStyle {flex: 1;width: 750rpx;background-color: rgba(0, 0, 0, 0.6);display: flex;position: fixed;top: 0;left: 0;z-index: 66;flex-direction: column;height: 100vh;}.selectCatalogDialog_Btn {height: 114rpx;background-color: #ffffff;border-radius: 24rpx;margin: 20rpx 25rpx 25rpx 25rpx;justify-content: center;display: flex;align-items: center;}.selectCatalogDialog_Btn_txt {font-size: 32rpx;color: #3089dc;text-align: center;flex: 1;line-height: 114rpx;}.comfirm-btn {border-left: 1rpx solid #ddd;}.selectCatalogDialog_body {position: relative;border-radius: 24rpx;background-color: #ffffff;margin: 25rpx 25rpx 0 25rpx;flex: 1;overflow: hidden;display: flex;}.catalog_list_row_refresh_block {position: absolute;top: 26rpx;right: -30rpx;z-index: 1;flex: 1;flex-direction: row;justify-content:flex-end;align-items: center;display: flex;}.catalog_list_row_refresh_touch {width: 150rpx;flex-direction: row;align-items: center;display: flex;}.catalog_list_row_refresh_touch_text {color: #3089dc;}
  • axml

    <view class="selectCatalogDialog_contentStyle" style="left:{{show?'0rpx':'-750rpx'}}"><scroll-view class="selectCatalogDialog_body" scroll-y="{{true}}">  <block a:if="{{cataloglist}}">    <view class="catalog_list_row_refresh_block">      <view class="catalog_list_row_refresh_touch" onTap="refreshCategory">        <image src="{{imgUrl+'/imageOff/common/refresh.png'}}" style="width:35rpx;height:35rpx"/>         <view class="catalog_list_row_refresh_touch_text">刷新</view>      </view>    </view>    <tree-child a:for="{{cataloglist}}" catalog="{{item}}" level="{{0}}" onSelectCatalog="onSelectCatalog"></tree-child>  </block></scroll-view><view class="selectCatalogDialog_Btn">  <text onTap="hide" class="selectCatalogDialog_Btn_txt">勾销</text>  <text a:if="{{!isSingleChecked}}" onTap="confirm" class="selectCatalogDialog_Btn_txt comfirm-btn">确定</text></view></view>
  • js

    import PictureCatalogService from '/pages/pictureSpace/public/PictureCatalogService.js';import { RyUrlConfigure } from '../../js/together/RyUrlConfigure.js'import { CatalogSort } from '../../js/together/catalogSort.js'const app = getApp();Component({mixins: [],data: {  imgUrl: RyUrlConfigure.getUrlImg(),  cataloglist: null,},props: {  show: false,  isSingleChecked: false, // 是否单选 false-多选 true-单选  historyChecked: [], // 已选中的列表  onSelect: () => {},  onClose: () => {},},didMount() {  this.loadData()  this.treeInstance = null // 树结构实例},didUpdate() {  },didUnmount() {},methods: {  loadData(){    PictureCatalogService.getCatalogTreeData().then((data) => {      this.treeInstance = new CatalogSort(data, this.props.isSingleChecked, this.props.historyChecked)      this.setData({        cataloglist: this.treeInstance.tree      })    }).catch(err => {      my.alert({ content: err })    });  },  /**   * 图片目录抉择   */  onSelectCatalog(id) {    this.treeInstance.selectNode(id)    this.setData({      cataloglist: this.treeInstance.tree    }, () => {      // 单选选中后间接将选中的值传递给父组件      if (this.props.isSingleChecked) {        this.props.onSelect(this.treeInstance.treeMap[this.treeInstance.checkedList[0]])      }    })  },  hide(){    this.props.onClose()  },  /**   * 多选时点击确定   */  confirm() {    // 将选中的ids传递给父组件    const { checkedList, checkedNameList, totalCount } = this.treeInstance    if (checkedList.length === 0) {      app.toast('请先抉择目录')      return    }    const values = {      checkedList,      checkedNameList,      totalCount,    }    this.props.onSelect(values)  },  refreshCategory(){    PictureCatalogService.getCatalogTreeData(true).then(res=>{      app.toast('刷新胜利')      this.loadData()    }).catch(err => {      my.alert({ content: err })    })  }},});
  • json

    {"component": true,"usingComponents": {  "check-box": "/common/components/ryCheckbox/index",  "am-checkbox": "mini-ali-ui/es/am-checkbox/index",  "tree-child": "/common/components/picCategoryTree/tree-child/tree-child"}}

    子组件(树递归渲染节点)

  • acss

    .catalog_list_row {border-bottom: 1rpx solid #dddddd;background-color: #ffffff;height: 89rpx;flex-direction: row;align-items: center;position: relative;display: flex;padding-left: 20rpx;}.catalog_list_row_select_block {flex: 1;flex-direction: row;align-items: center;display: flex;}.catalog_list_row_title {font-size: 28rpx;color: #333333;margin-left: 20rpx;}.child_toggle_btn {position: absolute;height: 88rpx;width: 78rpx;justify-content: center;align-items: center;right: 0rpx;top: 0rpx;display: flex;}.child_toggle_btn_icon {width: 38rpx;height: 38rpx;}
  • axml

    <view style="width: 100%;"><view class="catalog_list_row">  <view onTap="checkNode" data-id="{{catalog.pictureCategoryId}}" class="catalog_list_row_select_block" style="padding-left:{{level * 50}}rpx;">    <check-box checked="{{catalog.checked}}" />    <view class="catalog_list_row_title">{{catalog.pictureCategoryName}}</view>  </view>  <view onTap="toggleChild" class="child_toggle_btn" a:if="{{isBranch && catalog.pictureCategoryId !== '0'}}">    <image class="child_toggle_btn_icon" src="{{ImgUrlPrefix + (open ? 'icon-fold.png' : 'icon-unfold.png')}}" />  </view></view><view hidden="{{!open}}">  <tree-child a:for="{{catalog.children}}" catalog="{{item}}" level="{{level + 1}}" onSelectCatalog="selectCatalog"></tree-child></view></view>
  • js

    import { RyUrlConfigure } from '../../../js/together/RyUrlConfigure.js'Component({mixins: [],data: {  imgUrl: RyUrlConfigure.getUrlImg(),  ImgUrlPrefix: RyUrlConfigure.getUrlImg() + '/imageOff/picture-space/',  open: false, // 是否开展  isBranch: false, // 是否是父节点},props: {  catalog: {}, // 树结构对象  level: 0, // 层级  onSelectCatalog: () => {},},didMount() {  this.setData({    isBranch: this.props.catalog.children.length > 0,    open: this.props.level === 0,  })},didUpdate() {},didUnmount() {},methods: {  toggleChild() {    this.setData({      open: !this.data.open,    })  },  checkNode({ target: { dataset: { id } } } = e) {    this.selectCatalog(id)  },  selectCatalog(id) {    this.props.onSelectCatalog(id)  },},});
  • json

    {"component": true,"usingComponents": {  "check-box": "/common/components/ryCheckbox/index",  "tree-child": "/common/components/picCategoryTree/tree-child/tree-child"}}

    步骤解析

    一维数组转化为树结构

    因从接口中获取到的数据不是曾经结构好了的树数据,而是一维数组,所以咱们须要先将其转化为树结构。此处内容关键点在于构建父-子节点映射关系,不便后续节点的查找,大大减少了递归的调用。
    初始化遍历源数组,构建父-子关系时减少了默认的选中项,因全选的时候可能不会传所有的节点而只是传一个代表全选的字段,故此处会暂存一个初始化时是否全选的判断,以给每个子项减少checked(是否选中)状态项,totalCount值与勾选的数组长度值用于判断是否全选。
    正式构建树结构,遍历源数组,依据每一项的父节点id,利用后面刚结构好的父-子映射对象,给每一项对应的父节点填充相应的子节点项。最初利用filter返回第一层数据,即无父节点关系(此处为空)。

    目录排序

    目录排序利用递归给每一层数据进行数字、字母、中文排序,localeCompare在不同浏览器中兼容性不同,故这里先判断目录名是否为数字字母,是的话用sort先进行排序,否则再用localeCompare。

    树结构渲染

    树渲染用到了递归组件,所以须要拆分为父子两个组件来进行渲染,子组件次要是用于渲染每一个目录单项,而后递归子组件来渲染整棵树。
    父组件引入子组件时,申明一个level为0的属性示意层级,后续通过层级来指定子组件的padding-left值给予缩进,构建父子视觉关系。
    子组件用两个属性值操作开展收起,isBrand示意是否有子节点,open示意是否开展,通过level操控默认的开展层级;开展收起状态图标通过是否有子节点的形式进行展现(即isBrand属性)。因我的业务中第一层为一个父节点,不收起,故第一层不渲染开展收起图标。isOpen状态值用于判断是否暗藏子节点内容。这两个属性只针对节点自身,而不必初始化时给节点的每一项减少相似于checked的属性,十分不便事件操作,而不必再递归树结构数组。

    树节点抉择

    节点抉择包含单选、多选两方面,通过初始化树组件实例传isSingleChecked判断是否单选,传historyChecked示意默认选中项。选中节点时,selectNode办法,通过父-子节点映射查找到选中的节点。
    单选时,存储一下上一次的选中值(用于每一次单选将其checked置为false)以及本次的抉择状态(是选中还是勾销选中);同时将选中的id存入checkedList数组(此处多存了一个name数组,为了业务需要),存储时如果是选中且不在选中数组中就push,如果是勾销选中且在数组中就删除id。
    能够多选时,针对父-子抉择的关系就更简单,除了扭转自身选中态外,也要对其子节点和父节点进行遍历抉择。父子节点递归的过程中通过判断是否与以后节点态统一来跳出循环,缩小递归次数。子节点选中间接递归children子数组即可;父节点抉择通过父子映射列表查找父关系,再对父关系的子节点列表的checked属性进行判断,若都为true,则选中,否则为不选中状态。

    组件接管

    组件接管值次要是节点的选中ids数组,选中的节点名称数组,总长度。若前期有其余业务可在此扩大。

    结语

    以上就是对于树结构业务的所有思路。为了不便前期查看,在此记录。若其中有能帮忙到他人的,十分开心。