介绍
因工作中需要完成一个不限层级的目录展示以及勾选问题,而支付宝小程序没有相关框架可用,故整理一套方案处理该类问题。
该篇文章主要是记录树结构数据的处理方法,当然也可用于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数组,选中的节点名称数组,总长度。若后期有其他业务可在此扩展。
结语
以上就是关于树结构业务的所有思路。为了方便后期查看,在此记录。若其中有能帮助到别人的,非常开心。