去年做了一个项目需要用到下拉树,功能还需要非常强大,由于项目用的框架是Vue,Element UI,网上找了一圈,发现vue-treeselect 这个组件十分强大,比较符合自己的需求,因此果断选择了这个组件,没想到光是封装这个组件断断续续一共整了3个月(因为最开始选型的是自己实现,后来由于回显问题不好解决,只好重头开始做了),做到后面都快麻木了。现在项目结束了,现在就把自己遇到的一些坑给大家分享一下,希望有心人可以少走弯路,也欢迎批评指正。
事先声明一下,下面单独举的例子应该都无法直接运行,每个例子都只是把关键代码截取出来,方便大家理解。在文章的最后我会把完整的代码贴出来,那个肯定能运行(前提是要自行安装好包)
碰到的一些坑
坑1:回显时出现undefined
效果如下图所示
treeselect 绑定的值需要与options输出的id相对应,若是空值,必须是null,请不要给"",0,等,因为会出现unknown,并且当选择了值以后,会出现选中的值后面会拼上unknown
问题的原因如上所述,但是我们常见的需求是需要回显下拉树的值,此时值已经绑定,但是树选项还在加载中,那么就会出现短暂的unknown,稍后就会恢复正常。我的解决思路是判断是否还在加载下拉树的选项,如果加载完就显示实际值,否则赋值null。实际落地就是要结合computed的get函数来实现。
实际关键代码如下:
<template>
<treeselect
v-model="treeValue"
:options="getOptions"
>
</treeselect>
</template>
export default {
data: () => ({
options:[],//下拉树选项
}),
computed:{
getOptions(){
return this.options;
},
treeValue:{
set(val){
// this.$emit('change',val);
},
get(){
//没有数据时不显示
if( this.options.length == 0 ){
return null;
}
return this.value;
}
}
},
}
好了,刷新页面,发现效果达到预期,详细完整代码见最下方
坑2:下拉树宽度过小或者下拉选项层级过深时,无法看到全部的下拉选项
效果图如下
原因:下拉框的宽度和上面的input框的宽度保持一致,只要下面的内容过深过长时,就会出现遮挡,此时需要支持手动拖拽下拉框的边框,从而改变下拉款宽度。我的具体实现思路是是在打开下拉选项时添加鼠标事件监听。
关键代码如下:
<template>
<treeselect
:multiple="multiple"
v-model="treeValue"
:options="getOptions"
:normalizer="normalizer"
:appendToBody="appendToBody"
:limit="3"
:limitText="count => `...`"
:maxHeight="200"
:placeholder="placeholder"
flat
@open="open"
>
</treeselect>
</template>
export default {
props:{
//是否挂靠在body上,一般不需要,特殊情况是表格新增行时需要注意加上
appendToBody:{
type:Boolean,
default:true,
},
},
data: () => ({
}),
methods: {
//增加拖拽下拉功能
open(instanceId){
let dom = document.querySelector(`.vue-treeselect[data-instance-id='${instanceId}']`);
let listDom;
this.$nextTick(()=>{
//只有在挂靠在appendToBody下实现拖拽功能
if(!this.appendToBody) return; //
listDom = dom.querySelector(".vue-treeselect__menu");
if(listDom) {
let startX = listDom.getBoundingClientRect().right;
let oldWidth = dom.getBoundingClientRect().width; //原宽度
//初始化参数
listDom.onmousedown = function(e){
// e.stopPropagation();
let curDom = e.target;
//捕捉焦点
//设置事件
document.onmousemove = function (ev) {
if(ev.clientX - startX>0){
dom.style.width = oldWidth+ ev.clientX - startX + "px";
}
};
document.onmouseup = function (ev) {
ev.stopPropagation();
document.onmousemove = null;
document.onmouseup = null;
};
//防止默认事件发生
e.preventDefault();
};
}
})
},
}
//只在append-to-body下实现拖拽功能手势
.vue-treeselect--append-to-body .vue-treeselect__menu{
cursor: e-resize;
}
实现效果如下图所示:
目前上面这种实现还有许多不如意的地方,比如只能解决appendToBody的情况,还有如果下拉选项如果出现滚动条时,鼠标浮上去无法进行拖拽,得左右偏移一点才能拖拽。没办法,由于当时项目比较紧,还有自己比较菜,所以只好先这样了,如果有大神觉得可以改进,欢迎指教。
坑3:ElementUI的table行内使用vue-treeselect,下拉框无法显示
该问题有个帖子写的比较好,我就不重复写了,当时我还没看到这篇帖子,自己用设置append-to-body属性的方式来解决的,详细地址如下:elementui组件table行内使用vue-treeselect无效
坑4 You are using flat mode. But you forgot to add "multiple=true"
出现这个原因是由于vue-treeselect不支持单选下的flat模式,但是我的需求又必须得有,由于发现该库作者已经很久没有维护代码了,在没有办法情况下我只好看了一下源码,发现这个报错知识一个提示,并不会有什么实际影响,所以我决定把源码的这个提示去掉,然后上传新的文件到npm,这样就可以避免该问题。我自己上传了这个资源到npm,需要的同学可以直接下载。这个y_treeselect和源码并没有什么区别,就是把这个提示去掉了,所以可以放心使用
$ npm i -S y_treeselect
实现的一些特殊需求
需求1:父子节点没有关联,还要实现特殊情况下只能选择叶子节点
关于前半个要求,其实用平面模式就可以搞定,后半个需求借助disableBranchNodes属性可以实现。
关键代码如下:
- 组件外部引用
<MyTreeSelect
isChildOnly
multiple
v-model="treeValue1"
/>
- 组件内部引用
<treeselect
v-model="treeValue"
:options="getOptions"
flat
value-consists-of="BRANCH_PRIORITY"
:disableBranchNodes="isChildOnly"
>
</treeselect>
export default {
props:{
isChildOnly:{//是否只能选择或者点击叶子节点
type:Boolean,
default:false,
},
}
}
需求2 支持返回值为id或者node节点
这个也简单,api可以支持,这里只是为了后面做铺垫用的
- 组件外部引用
<gl-select-tree2
:multiple="true"
valueFormat="object"
v-model="treeValue1"/>
{{treeValue1}}
...
treeValue1:[{id:"2-1-1"}],//定义在data中,此处只需对象中有id属性即可
- 组件内部定义
<treeselect
v-model="treeValue"
:options="getOptions"
:valueFormat="valueFormat"
>
</treeselect>
export default {
props:{
valueFormat:{//定义返回的值为id还是整个node节点数据
type:String,
default:"id",//id和object两种类型
},
}
}
object类型结果如下:
需求3 支持增加搜索文本作为下拉树值
目前的下拉树只支持选项中有的才能选,但是我们需要在输入框中输入下拉树外部数据,比如外部的组织机构,输入完失焦直接显示在输入框中
关键代码如下:
<treeselect
...
>
<div slot="value-label" slot-scope="{ node }">{{ renderTrueValue(node.label) }}</div>
</treeselect>
export default {
props:{
isSupportExternalInput:{//是否支持增加搜索文本作为下拉树值
type:Boolean,
default:false,
},
},
methods:{
close(v, instanceId){
let val = this.$el.querySelector(".vue-treeselect__input").value;
if(this.isSupportExternalInput){
let newVal = val.trim();//清除空格
if(newVal === ""){
this.$el.querySelector(".vue-treeselect__input").value = "";
return;
}
let value;
if(this.multiple){
value = this.value.slice();
if(this.valueFormat == "object"){
newVal = {id:newVal};
}
value.push(newVal);//清除尾部空格
}else{//单选
value = this.valueFormat == 'object'?{id:newVal}:newVal;
this.$el.querySelector(".vue-treeselect__input").blur();//收起下拉
}
this.$emit("change",value);
setTimeout(()=>{//清空搜索值
this.$el.querySelector(".vue-treeselect__input").value = "";
},0)
}
},
//针对外部输入值时将unknown换成外部
renderTrueValue(label){
if(label.includes("(unknown)")){//隐藏不匹配时的(unknown)
return label.replace('(unknown)',"(外部)")
}
return label;
},
}
}
实现的结果如下:其中sdf就是直接输入的外部值,在失焦后就会显示在输入框内
需求4 选择内容过多,超出限定个数无法看到所有选项值
截图如下:目前限定最多展示三项,超出三项显示... ,所以需求就是需要针对所有选值可以支持tooltip展示
分析思路:考虑到有两种场景,1. 可能一开始就需要回显很多项,此时放在mounted里执行 2. 可以在选择的过程中选了很多项,此时可以借助input事件来实现
关键代码如下:
export default {
methods:{
inputChange(val,instanceId){
this.$emit("change",val);
if(this.multiple){//只有多选模式下才考虑提示功能
this.allLabel = val.map(item=>{
let label = "";
//getNode是我自己查找下拉树的内置方法,呕心沥血才找到的
label = this.$children[0].getNode(this.valueFormat == "object"?item.id:item).label;
label = label.replace('(unknown)',"(外部)");
return label;
})
let el = this.$el.querySelector(".vue-treeselect__multi-value");
el.setAttribute("title",this.allLabel.join(" , "));
}else{
this.removePlaceHolder();
}
this.addPlaceHolder(val);
},
//增加文字提示tooltip
addPlaceHolder(value){
let placeholder = this.$el.querySelector(".vue-treeselect__placeholder");
let temp = value !== "_first"? value:this.value;
if(placeholder && (!temp || !temp.length)){
let content = placeholder.innerText;
placeholder.parentNode.setAttribute("title",content);
}
},
removePlaceHolder(){
let placeholder = this.$el.querySelector(".vue-treeselect__placeholder");
if(placeholder){
placeholder.parentNode.removeAttribute("title");
}
},
}
}
效果图如下:
该组件所有代码如下(当然,还有很多内置需求没体现在该文中)
<template>
<treeselect
:multiple="multiple"
v-model="treeValue"
:options="getOptions"
:normalizer="normalizer"
:appendToBody="appendToBody"
:disableBranchNodes="isChildOnly"
value-consists-of="BRANCH_PRIORITY"
:valueFormat="valueFormat"
:limit="3"
:limitText="count => `...`"
:maxHeight="200"
:placeholder="placeholder"
flat
:autoLoadRootOptions="true"
@open="open"
@close="close"
@input="inputChange"
>
<div slot="value-label" slot-scope="{ node }">{{ renderTrueValue(node.label) }}</div>
</treeselect>
</template>
<script>
import Treeselect from '@riophae/vue-treeselect'
export default {
model:{
prop:'value',
event:'change',
},
components: { Treeselect },
data: () => ({
options:[],//下拉树选项
normalizer(node){
return {
id: node.id ,
label: node.text ,
children: node.children,
}
},
}),
props:{
multiple:Boolean,
value: {default:null},
placeholder:{default:'请选择'},
//是否挂靠在body上,一般不需要,特殊情况是表格新增行时需要注意加上
appendToBody:{
type:Boolean,
default:true,
},
isChildOnly:{//是否只能选择或者点击叶子节点
type:Boolean,
default:false,
},
isSupportExternalInput:{//是否支持增加搜索文本作为下拉树值
type:Boolean,
default:false,
},
valueFormat:{//定义返回的值为id还是整个node节点数据
type:String,
default:"id",//id和object两种类型
},
},
created(){
},
mounted(){
this.addPlaceHolder("_first");//首次进来
this.generateOptions();
},
computed:{
getOptions(){
return this.options;
},
treeValue:{
set(val){
let temp = val;
if(val === "" || val === undefined){
temp = null;
}
this.$emit('change',temp);
},
get(){
//没有数据时不显示
if( this.options.length == 0 ){
return null;
}
return this.value;
}
}
},
watch:{
},
methods: {
//生成初始选项
generateOptions(){
//模拟网络请求
setTimeout(()=>{
this.options = sOptions;
},1000);
},
inputChange(val,instanceId){
this.$emit("change",val);
if(this.multiple){//只有多选模式下才考虑提示功能
this.allLabel = val.map(item=>{
let label = "";
//getNode是我自己查找下拉树的内置方法,呕心沥血才找到的
label = this.$children[0].getNode(this.valueFormat == "object"?item.id:item).label;
label = label.replace('(unknown)',"(外部)");
return label;
})
let el = this.$el.querySelector(".vue-treeselect__multi-value");
el.setAttribute("title",this.allLabel.join(" , "));
}else{
this.removePlaceHolder();
}
this.addPlaceHolder(val);
},
//增加文字提示tooltip
addPlaceHolder(value){
let placeholder = this.$el.querySelector(".vue-treeselect__placeholder");
let temp = value !== "_first"? value:this.value;
if(placeholder && (!temp || !temp.length)){
let content = placeholder.innerText;
placeholder.parentNode.setAttribute("title",content);
}
},
removePlaceHolder(){
let placeholder = this.$el.querySelector(".vue-treeselect__placeholder");
if(placeholder){
placeholder.parentNode.removeAttribute("title");
}
},
//增加拖拽下拉功能
open(instanceId){
let dom = document.querySelector(`.vue-treeselect[data-instance-id='${instanceId}']`);
let listDom;
this.$nextTick(()=>{
if(!this.appendToBody) return; //
listDom = dom.querySelector(".vue-treeselect__menu");
if(listDom) {
let startX = listDom.getBoundingClientRect().right;
let oldWidth = dom.getBoundingClientRect().width; //原宽度
//初始化参数
listDom.onmousedown = function(e){
// e.stopPropagation();
let curDom = e.target;
//捕捉焦点
//设置事件
document.onmousemove = function (ev) {
if(ev.clientX - startX>0){
dom.style.width = oldWidth+ ev.clientX - startX + "px";
}
};
document.onmouseup = function (ev) {
ev.stopPropagation();
document.onmousemove = null;
document.onmouseup = null;
};
//防止默认事件发生
e.preventDefault();
};
}
})
},
close(v, instanceId){
let val = this.$el.querySelector(".vue-treeselect__input").value;
if(this.isSupportExternalInput){
let newVal = val.trim();//清除空格
if(newVal === ""){
this.$el.querySelector(".vue-treeselect__input").value = "";
return;
}
let value;
if(this.multiple){
value = this.value.slice();
if(this.valueFormat == "object"){
newVal = {id:newVal};
}
value.push(newVal);//清除尾部空格
}else{//单选
value = this.valueFormat == 'object'?{id:newVal}:newVal;
this.$el.querySelector(".vue-treeselect__input").blur();//收起下拉
}
this.$emit("change",value);
setTimeout(()=>{//清空搜索值
this.$el.querySelector(".vue-treeselect__input").value = "";
},0)
}
},
//针对外部输入值时将unknown换成外部
renderTrueValue(label){
if(label.includes("(unknown)")){//隐藏不匹配时的(unknown)
return label.replace('(unknown)',"(外部)")
}
return label;
},
},
}
const sOptions = [{
id:'1-1',
hasChildren: true,
text:'教育局',
children:[
{
id:'2-1',
hasChildren: true,
text:'教育处1',
children:[{
id:'2-1-1',
hasChildren: false,
text:'老师1',
}]
},
{
id:'2-2',
hasChildren: true,
text:'教育处2',
children:[{
id:'2-2-2',
hasChildren: false,
text:'老师2',
},{
id:'2-2-3',
hasChildren: false,
text:'老师3',
},{
id:'2-2-4',
hasChildren: false,
text:'老师4',
}]
},
{
id:'3-2',
hasChildren: true,
text:'教育处3',
children:[{
id:'3-2-1',
hasChildren: false,
text:'老师2',
},{
id:'3-2-2',
hasChildren: false,
text:'老师3',
},{
id:'3-2-3',
hasChildren: false,
text:'老师4',
}]
},
],
}]
</script>
<style lang="scss">
//只在append-to-body下实现拖拽功能
.vue-treeselect--append-to-body .vue-treeselect__menu{
cursor: e-resize;
}
</style>
完整的代码详见我的github代码,里面又大量的实例和有趣的组件,欢迎star!
vue-awesome-demos