泛化、高内聚、低耦合、延迟绑定的,支持双向绑定和懒加载的vue3树组件

标题释义

泛化

组件的功能模型是一个能尽可能支持多种应用场景的递归树组件,除了基本的可自定义样式的树状结构数据驱动视图传递了节点、层级、子集的插槽功能外,还集成了实际开发中经常遇到的可异步懒加载单/复选双向绑定递归查找/过滤/展开节点等功能。在个人能力所及范围内尽可能地泛化了组件模型。

高内聚

  1. 展开/收起过渡动画回调外 ,所有逻辑代码都集中在一个组件内;
  2. 组件内部变量、函数(即在setup中声明的内容)相互之间的关系基本都是顺序内聚;
  3. 内部共定义了11个递归函数,尽可能增强了单个职责内部的功能内聚。

低耦合

  1. 依赖的展开/收起过渡动画回调与组件间关系为标志或数据耦合(虽然传递的参数是一个复杂结构的DOM对象,但是DOM本就是浏览器提供的原生类,而且传递时组件既不会修改也不会依赖修改,因此个人倾向于数据耦合),并且该模块还可以复用于折叠面板等其他组件;
  2. 对外暴露的查找、过滤、展开节点等方法入参均为数据耦合;
  3. 外部传入的属性除了data和复选模式的modelValue是标志耦合,其他都是数据耦合,并且相互之间都可以逻辑独立;
  4. 内外交互有6处用到了高阶函数,以尽可能少的传入参数满足内部功能需求,降低外部数据相互间的耦合,增强使用的灵活性;
  5. 驱动视图渲染的数据用 shallowReactive 进行了二次包装,除加载子集这一必要修改外,包括过滤节点在内的任何组件内部行为都不会修改传入的data属性原始数据,避免了对外部不可预期的影响。

延迟绑定

组件实例化时根据modelValue的数据类型判断双向绑定的类型,如果是Array则为复选,否则单选。并且如果modelValue为null,则不会在视图上渲染input:radio元素。故实现了运行时绑定。

双向绑定

  1. 支持外部通过非null的v-model主动更新选中状态,并且UI触发内部选中状态更新后也会继发update:modelValue以被动更新外部数据;
  2. 由于update:modelValue只能传出主键值,因此其后紧接着触发change事件,传出选中数据的详情。组件还提供了getSelected方法,返回值结构与change事件一致,便于外部主动更新时仅凭主键值就能获取数据详情,进一步增强双向绑定的易用性。

懒加载

对于不存在子集属性的节点,组件内部调用同步的高阶函数hasChildren从业务层获知该节点是否具有子集,若该函数返回true则继续调用高阶函数getChildren加载子集。getChildren是以async/await形式调用的,因此其返回值既可以是同步也可以是异步的。

复选的特殊之处

传统用法

组件提供getAllChecked和getFullChecked两个方法分别用于获取所有已选中节点仅完全选中的节点,返回值是一个数组。数组元素包含节点数据node、节点层级level、选中状态checked和indeterminate,以及相同结构元素组成的子集数组children。

双向绑定

双向绑定要满足的是一种与传统用法差异很大的场景,这是一种需求广泛但技术难度较大的场景:若父节点完全选中,则不再需要子节点的主键值等数据。同时,本组件还补充了高阶函数属性selectable控制节点数据是否可用的功能,若该函数对某个节点的返回值为false,则会略过该节点的数据,向下递归查询子节点的数据。示例如下图:
在这里插入图片描述

组件代码

核心部分

建议将树组件的代码放在一个目录中,并将核心组件命名为index.js。
在这里插入图片描述
然后就能如下图这样引入:
在这里插入图片描述
逻辑代码index.js:

import {defineComponent,computed,nextTick,watch,withDirectives,h} from '@vue/runtime-core';
import {Transition,vShow} from '@vue/runtime-dom';
import {shallowReactive} from '@vue/reactivity';
import {collapseForVnode} from '../utils/animations';
import './index.scss';
const filterRowsRecursively=(rows,cbk)=>rows.filter(row=>!!row.children).reduce((pre,cur)=>pre.concat(filterRowsRecursively(cur.children,cbk)),rows.filter(cbk));
export default defineComponent(
  (props,{emit,expose,slots})=>{
    let emitted=false;
    const isMultiple=computed(()=>props.modelValue instanceof Array),
    getChildrenList=async (row,childrenKey,level)=>{
      if(!row.node[childrenKey]){
        row.loading=true;
        row.node[childrenKey]=await props.getChildren(row.node,level);
        delete row.loading;
      }
      row.children=shallowReactive([]);
      if(!row.node[childrenKey].length) return false;
      refreshRows(row.node[childrenKey],row.children,level+1);
      if(row.checked) row.children.forEach(r=>{r.checked=true});
      nextTick(()=>{row.expanded=true});
      return true;
    },
    // 向下递归全选
    checkAll=(checked,rows,level,parentSelectable=false)=>{
      rows.forEach(row=>{
        row.indeterminate=false;
        row.checked=checked;
        if(row.children) checkAll(row.checked,row.children,level+1,parentSelectable||props.selectable(row.node,level));
      });
      if(!checked||parentSelectable){
        // 如果父级可以选中,则不管是全选中还是全不选中,都应该移除子级所有值
        // 否则如果是全不选中,则也要移除所有值
        rows.forEach(r=>{
          const i=props.modelValue.indexOf(r.node[props.valueKey]);
          if(i!==-1) props.modelValue.splice(i,1);
        });
      }else{
        rows.forEach(r=>{
          if(props.selectable(r.node,level)&&!props.modelValue.includes(r.node[props.valueKey])) props.modelValue.push(r.node[props.valueKey]);
        });
      }
    },
    // 把同级节点已选中的加入list数组
    getSiblings=(row,rows,level)=>{
      const list=[];
      rows.forEach(r=>{
        if(!r.checked||r===row) return;
        if(!r.indeterminate&&props.selectable(r.node,level)) list.push(r.node);
        else if(r.children) pushChecked(r.children,level+1,list);
      });
      return list;
    },
    // 取节点并向下递归
    pushChecked=(rows,level,list)=>{
      rows.forEach(r=>{
        if(!r.checked) return;
        if(!r.indeterminate&&props.selectable(r.node,level)) list.push(r.node);
        else if(r.children) pushChecked(r.children,level+1,list);
      });
    },
    /**
     * 向上递归变更选中状态
     * @param {Array} path 选中节点的路径
     * @returns {Boolean} 上级是否已选中有效节点
     */
    checkParent=(path,level,checked)=>{
      const parent=path[path.length-2];
      if(!parent) return false;
      const cur=path[path.length-1];
      if(cur.checked){
        parent.checked=true;
        parent.indeterminate=parent.children.some(r=>!r.checked||r.indeterminate);
      }else parent.indeterminate=parent.checked=parent.children.some(r=>r.checked);

      const prePathChecked=checkParent(path.slice(0,-1),level-1,checked);
      if(parent.indeterminate||(!prePathChecked&&!props.selectable(parent.node,level-1))){
        // 上级链路上只要有一个节点半选,再往上的都是半选
        // 递归都会走到这里,所以剩余上级都会在这里删除本节点值、加入旁支
        const i=props.modelValue.indexOf(parent.node[props.valueKey]);
        if(i!==-1) props.modelValue.splice(i,1);
        getSiblings(cur,parent.children,level).forEach(n=>{
          checked.push(n);
          if(!props.modelValue.includes(n[props.valueKey])) props.modelValue.push(n[props.valueKey]);
        });
        return false;
      }
      if(parent.checked){
        checked.push(parent.node);
        if(!props.modelValue.includes(parent.node[props.valueKey])) props.modelValue.push(parent.node[props.valueKey]);
      }
      // 如果存在上级且是可选的且更新后未处于半选状态,则移除本级及下级所有值
      const list=[];
      pushChecked(parent.children,level,list);
      list.forEach(n=>{
        const i=props.modelValue.indexOf(n[props.valueKey]);
        if(i!==-1) props.modelValue.splice(i,1);
      });
      return true;
    },
    changeMultiple=(path,level)=>{
      const checked=getSiblings(path[0],rows,props.level),
      prePathSelected=checkParent(path,level,checked),
      cur=path[path.length-1],
      i=props.modelValue.indexOf(cur.node[props.valueKey]);
      let currentSelectable; // “本级是否可选”即下级的“上级是否可选”,决定是否移除下级的值
      if(prePathSelected||!props.selectable(cur.node,level)){
        currentSelectable=prePathSelected;
        if(i!==-1) props.modelValue.splice(i,1);
      }else{
        currentSelectable=true;
        if(cur.checked){
          checked.push(cur.node);
          if(i===-1) props.modelValue.push(cur.node[props.valueKey]);
        }else if(i!==-1) props.modelValue.splice(i,1);
      }
      if(cur.children){
        checkAll(cur.checked,cur.children,level+1,currentSelectable);
        if(!currentSelectable) pushChecked(cur.children,level+1,checked);
      }
      emitted=true;
      emit('update:modelValue',[...props.modelValue]);
      emit('check',{path:path.map(r=>r.node),level,checked:cur.checked});
      emit('change',checked);
    },
    buildChildren=(rows,level,prePath=[])=>{
      const children=[],
      childrenKey=props.childrenKey(level),
      emptyIcon=props.showGuideLine?'':(rows.some(r=>(r.children?r.children.length:props.hasChildren(r.node,level)))?'empty':'corner');
      rows.forEach(row=>{
        const path=[...prePath,row],
        nodePath=path.map(r=>r.node),
        liContent=[
          h(
            'div',
            {
              class:'tree-row-label flex1',
              async onClick(){
                if(!row.children&&props.hasChildren(row.node,level)&&await getChildrenList(row,childrenKey,level)) return;
                if(!isMultiple.value){
                  // 单选模式
                  if(props.disabled||!props.selectable(row.node,level)) return;
                  emit('update:modelValue',row.node[props.valueKey]);
                  emit('change',{node:row.node,level,path:nodePath});
                }else if(!row.children?.length){
                  if(props.disabled) return;
                  row.indeterminate=false;
                  row.checked=!row.checked;
                  changeMultiple(path,level);
                }else row.expanded=!row.expanded;
              }
            },
            slots.row?slots.row({...row,path:nodePath}):row.node[props.labelKey]
          )
        ];
        if(isMultiple.value) liContent.unshift(h('input',{
          type:'checkbox',
          disabled:props.disabled,
          checked:row.checked,
          indeterminate:row.indeterminate,
          value:row.node[props.valueKey],
          async onChange(e){
            e.stopPropagation();
            row.indeterminate=false;
            row.checked=e.target.checked;
            if(!props.selectable(row.node,level)&&!row.children&&props.hasChildren(row.node,level)) await getChildrenList(row,childrenKey,level);
            changeMultiple(path,level);
          }
        }));
        else if(props.modelValue!==null&&props.selectable(row.node,level)) liContent.unshift(h('input',{
          type:'radio',
          disabled:props.disabled,
          checked:props.modelValue===row.node[props.valueKey],
          onChange(e){
            e.stopPropagation();
            if(props.selectable(row.node,level)){
              emit('update:modelValue',row.node[props.valueKey]);
              emit('change',{node:row.node,level,path:nodePath});
            }else e.target.checked=false;
          }
        }));
        if(row.children){
          if(row.children.length){
            liContent.unshift(h('i',{
              class:['arrow',{expanded:row.expanded}],
              onClick(){row.expanded=!row.expanded},
              onTransitionend(e){
                e.stopPropagation();
                emit('expand',row);
              }
            }));
          }else if(emptyIcon) liContent.unshift(h('i',{class:emptyIcon}));
        }else if(props.hasChildren(row.node,level)) liContent.unshift(h('i',{class:row.loading?'loading':'arrow',onClick(){getChildrenList(row,childrenKey,level)}}));
        else if(emptyIcon) liContent.unshift(h('i',{class:emptyIcon}));
        children.push(h('li',{class:'tree-row',key:row.node[props.valueKey]},liContent));
        if(row.children?.length) children.push(h(
          Transition,
          collapseForVnode,
          ()=>withDirectives(
            h(
              'ul',
              {key:row.node[props.valueKey],class:'tree'},
              buildChildren(row.children,level+1,[...prePath,row])
            ),
            [
              [vShow,row.expanded]
            ]
          )
        ));
      });
      return children;
    },
    rows=shallowReactive([]),
    refreshRows=(nodes,rows,level)=>{
      const old=rows.splice(0),
      childrenKey=props.childrenKey(level),
      promises=[];
      nodes.forEach(node=>{
        let row=old.find(r=>r.node[props.valueKey]===node[props.valueKey]);
        if(!row){
          row=shallowReactive({node,level});
          if(node[childrenKey]){
            row.children=shallowReactive([]);
            promises.push(refreshRows(node[childrenKey],row.children,level+1));
          }
        }else{
          row.node=node;
          if(node[childrenKey]){
            if(!row.children) row.children=shallowReactive([]);
            promises.push(refreshRows(node[childrenKey],row.children,level+1));
          }else if(row.children) delete row.children;
        }
        rows.push(row);
      });
      if(rows.length===1){
        if(!rows[0].children){
          if(props.hasChildren(nodes[0],level)) promises.push(getChildrenList(rows[0],childrenKey,level));
        }else if(rows[0].children.length) rows[0].expanded=true;
      }
      if(promises.length) return Promise.all(promises);
    },
    expand=(rows,level,checker,prePath=[])=>{
      const childrenKey=props.childrenKey(level);
      return Promise.all(
        rows.map(async row=>{
          const path=[row,...prePath];
          if(row.children){
            if(!row.children.length) return;
            row.expanded=checker(path);
          }else if(!props.hasChildren(row.node,level)||!checker(path)||!await getChildrenList(row,childrenKey,level)) return;
          await expand(row.children,level+1,checker,path);
        })
      );
    },
    toExpose={
      expand:(checker=()=>true)=>expand(rows,props.level,checker),
      async filter(callback=null,{getChildren=false}={}){
        /**
         * 如果有多个回调,通常用于多条件筛选;
         * 应对所有回调求交集,即所有回调都满足才算合格
         */
        if(callback instanceof Array){
          switch(callback.length){
            case 0:
              await refreshRows(props.data,rows,props.level);
              return props.data;
            case 1:
              callback=callback[0];
              break;
            default:{
              const functions=callback;
              callback=async (...args)=>{
                for(let i=0;i!==functions.length;i++){
                  if(!await functions[i](...args)) return false;
                }
                return true;
              };
              break;
            }
          }
        }else if(!(callback instanceof Function)){
          await refreshRows(props.data,rows,props.level);
          return props.data;
        }
        const filter=async (nodes,level)=>{
          const available=[],
          childrenKey=props.childrenKey(level);
          for(let i=0;i!==nodes.length;i++){
            if(!await callback(nodes[i],level)){
              if(!nodes[i][childrenKey]){
                if(!getChildren||!props.hasChildren(nodes[i],level)) continue;
                nodes[i][childrenKey]=await props.getChildren(nodes[i],level);
              }
              if(!nodes[i][childrenKey].length) continue;
              const node={...nodes[i]};
              node[childrenKey]=await filter(nodes[i][childrenKey],level+1);
              if(node[childrenKey].length) available.push(node);
            }else available.push(nodes[i]);
          }
          return available;
        },
        available=await filter(props.data,props.level);
        if(available.length!==props.data.length||available.some((node,i)=>node!==rows[i]?.node)) await refreshRows(available,rows,props.level);
        if(isMultiple.value){
          props.modelValue.splice(0);
          emit('update:modelValue',[]);
        }
        return available;
      }
    };
    
    watch(
      ()=>props.data,
      ()=>{refreshRows(props.data,rows,props.level)},
      {immediate:true,deep:props.deepWatch}
    );

    let getSelected;
    if(isMultiple.value){
      const refreshChecked=(rows,level)=>{
        rows.forEach(row=>{
          if(!row.children){
            row.checked=props.modelValue.includes(row.node[props.valueKey]);
            row.indeterminate=false;
            return;
          }
          if(props.modelValue.includes(row.node[props.valueKey])){
            row.checked=true;
            if(row.children.length){
              refreshChecked(row.children,level+1);
              if(!row.children.some(ch=>ch.checked)){
                checkAll(true,row.children,level+1,props.selectable(row.node,level));
                row.indeterminate=false;
              }else row.indeterminate=row.children.some(ch=>!ch.checked||ch.indeterminate);
            }else row.indeterminate=false;
            return;
          }
          if(row.children.length){
            refreshChecked(row.children,level+1);
            if(row.children.some(ch=>ch.checked)){
              row.checked=true;
              row.indeterminate=row.children.some(ch=>!ch.checked||ch.indeterminate);
              return;
            }
          }
          row.checked=row.indeterminate=false;
        });
      };
      //如果外部要刷新选中态,则应重新赋值modelValue,而不是变更reactive
      watch(
        ()=>props.modelValue,
        ()=>{
          if(emitted) emitted=false;
          else refreshChecked(rows,props.level);
        },
        {immediate:true}
      );

      toExpose.checkAll=checked=>{checkAll(checked,rows,props.level)};
      toExpose.getAllChecked=()=>filterRowsRecursively(rows,r=>r.checked);
      toExpose.getFullChecked=()=>filterRowsRecursively(rows,r=>r.checked&&!r.indeterminate);
      getSelected=async (val,rows,level)=>{
        const selected=[],
        childrenKey=props.childrenKey(level);
        for(let i=0;i!==rows.length;i++){
          const row=rows[i];
          if(val.includes(row.node[props.valueKey])&&props.selectable(row.node,level)){
            selected.push(row.node);
            continue;
          }
          
          if(!row.children){
            if(!props.hasChildren(row.node,level)||!await getChildrenList(row,childrenKey,level)) continue;
          }else if(!row.children.length) continue;
          selected.push(...await getSelected(val,row.children,level+1));
        }
        return selected;
      };
    }else{
      getSelected=async (val,rows,level,path=[])=>{
        const row=rows.find(r=>r.node[props.valueKey]===val);
        if(row) return {node:row.node,level,path:path.map(r=>r.node).concat([row.node])};
        const childrenKey=props.childrenKey(level);
        for(let i=0;i!==rows.length;i++){
          const row=rows[i];
          if(!row.children){
            if(!props.hasChildren(row.node,level)||!await getChildrenList(row,childrenKey,level)) continue;
          }else if(!row.children.length) continue;
          const ret=getSelected(val,row.children,level+1,[...path,row]);
          if(ret) return ret;
        }
      };
    }
    /**
     * 由于外部变更v-model不会触发change事件,进而无法仅通过节点值获取节点信息
     * 故提供getSelected方法用于初始化或外部变更选中项时,获取已选中的节点信息
     * @param {Array|Number,String} val 与v-model一致
     * @returns 结构与change事件一致
     */
    toExpose.getSelected=val=>getSelected(val,rows,props.level);

    expose(toExpose);
    return ()=>h(Transition,collapseForVnode,()=>h('ul',{class:['tree',{showGuideLine:props.showGuideLine}]},buildChildren(rows,props.level)));
  },
  {
    name: 'tree',
    props: {
      data:Array,
      modelValue:{type:[Array,Number,String],default:null},
      hasChildren:{type:Function,default:(node,level)=>false},
      getChildren:{type:Function,default:(node,level)=>[]},
      // 避免在懒加载+复选模式下使用selectable特性,会导致modelValue不可预期
      selectable:{type:Function,default:(node,level)=>true},
      valueKey:{type:String,default:'value'},
      labelKey:{type:String,default:'label'},
      childrenKey:{type:Function,default:level=>'children'},
      disabled:{type:Boolean,default:false},
      deepWatch:{type:Boolean,default:false},
      level:{type:Number,default:0},
      showGuideLine:{type:Boolean,default:false}
    },
  }
);

样式代码index.scss:

.tree{
  overflow-y:hidden;
  @mixin guideLine(){
    width:1em;
    margin-right:0.2em;
    align-self:stretch;
    border-width:0 0 1px 1px;
    border-color:#ccc;
    transform:translateY(-50%);
  }
  &-row{
    display:flex;
    align-items:center;
    position:relative;
    min-height:30px;
    padding-left:10px;
    >i{
      border-style:solid;
      &.arrow{
        width:0;
        height:0;
        cursor:pointer;
        margin-left:0.3em;
        margin-right:0.4em;
        transition:transform 0.4s ease;
        transform:rotate(0);
        transform-origin:60% center;
        border-width:0.4em 0 0.4em 0.5em;
        border-color:transparent transparent transparent #ccc;
        position:relative;
        &::after{
          position:absolute;
          content:'';
          width:1.2em;
          left:-0.6em;
          height:1em;
          top:-0.5em;
        }
        &.expanded{ transform:rotate(90deg); }
        &.disabled{ cursor:not-allowed; }
      }
      &.loading{
        width:1em;
        height:1em;
        margin-right:0.2em;
        border-radius:50%;
        border-width:2px;
        border-color:#ccc #ccc transparent #ccc;
        animation:rotate 0.5s 0s linear infinite;
      }
      &.corner{ @include guideLine(); }
      &.empty{ width:1.2em; }
    }
    &-label{
      overflow:hidden;
      white-space:nowrap;
      text-overflow:ellipsis;
      word-wrap:normal;
      &:active{ background-color:rgba(0,0,0,0.1); }
    }
  }
  >.tree{ margin-left:0.6em; }
  &.showGuideLine>.tree{
    .tree-row::before{
      content:'';
      border-style:solid;
      @include guideLine();
    }
    .tree{ margin-left:1.8em; }
  }
}

可复用的依赖

展开收起动画回调…/utils/animations.js:

import './tree.scss';
export const collapseForVnode={
  'onBefore-enter':el=>{
    el.style.opacity=0;
    el.style.height=0;
  },
  onEnter(el){
    el.style.opacity=1;
    el.style.height=el.scrollHeight+'px';
    el.style.transition='opacity 0.3s ease,height 0.5s ease';
  },
  'onAfter-enter':el=>{
    el.style.removeProperty('height');
    el.style.removeProperty('opacity');
  },
  'onBefore-leave':el=>{ el.style.height=el.offsetHeight+'px' },
  onLeave(el){
    el.style.opacity=0;
    el.style.height=0;
    el.style.transition='opacity 0.3s ease 0.2s,height 0.5s ease';
  },
  'onAfter-leave':el=>{
    el.style.removeProperty('height');
    el.style.removeProperty('opacity');
  }
}

公共样式…/utils/tree.scss:

.tree{
  .tree-row > i{
    box-sizing:border-box;
    &.empty{ border: 0; }
  }
  &.showGuideLine>.tree .tree-row::before{ box-sizing:border-box; }
  .flex1{ flex:1; }
  input[type="radio"]{ margin:0 2px 0 0; }
  input[type="checkbox"]{
    margin:0 2px 0 0;
    width:20px;
    height:20px;
    flex-shrink: 0;
    background:#fff;
    border-radius:0;
    border:1px solid #ddd;
    -webkit-appearance:none;
    -moz-appearance:none;
    appearance:none;
    cursor:pointer;
  }
  input[type="checkbox"]:hover{ border-color:#ccc; }
  input[type="checkbox"]:indeterminate::after{
    content:'';
    display:block;
    width:64%;
    height:64%;
    margin:18%;
    background-color:var(--van-blue);
  }
  input[type="checkbox"]:checked{ background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAkUExURUxpcYiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiEE8YQYAAAALdFJOUwDvDtUmRHS0i8RgeGwPhAAAAPlJREFUOMvVlKEOwkAMhi9jjAGGkGCYOTSGYGcWEJBgQBIMHoPHzGPwOBLUDMsgwPpyXG8Lye3aB9iJivuy9m//3oSo27lzwMsZ4J5eDNnAkwYdgB5NDgATujwAXEiSKHKkQBM4smJJgIRS0EIAfVqyOsQMXA3gbZMGS4pk3zWXbG9czjC0NRgYwP9g3CHIImMmYfpvc2q6qJN7urysSlJ9j61PtnijCp2xiqwCAOlYI1uGmsQ+xsg0WGt6dIlZLpDkidV+KS4NtIyq+0GhgjBzrkEqbeJcGS/LBYjJDVSjy+h1Vt4wT0CETDIhRhn3Bt0h+25vtfnD/AB74JHhA6bUugAAAABJRU5ErkJggg') no-repeat center/100%; }
  input[type="checkbox"]:indeterminate,
  input[type="checkbox"]:checked{ border-color:#999; }
}

穿梭框

示例图中出现的Transfer.vue 并不是核心代码,而是基于树组件实现的穿梭框组件。实现原理如下:

  1. 通过JSON.stringify和JSON.parse深拷贝一份原始数据,左右使用不同的原始数据对象;
  2. 在响应式属性transfered中存储已穿梭到右侧的节点的主键值,并对左右原始数据进行过滤;
  3. 穿梭时更新transfered即可

组件代码如下:

<template>
  <div class="transfer">
    <Tree class="flex1" v-bind="{hasChildren,getChildren,valueKey,labelKey,data:leftData}" v-model="cache.left" ref="leftTree" />
    <div class="boundary flex">
      <div @click="toRight">添加》</div>
      <div @click="toLeft">《删除</div>
    </div>
    <Tree class="flex1" v-bind="{hasChildren,getChildren,valueKey,labelKey,data:rightData}" v-model="cache.right" ref="rightTree" />
  </div>
</template>
<script>
import {shallowRef,ref,shallowReactive,reactive,watch,computed,nextTick} from 'vue';
import Tree from './index';
export default {
  props: {
    data:Array,
    transfered:{type:Array,default:()=>shallowReactive([])},
    hasChildren:{type:Function,default:row=>false},
    getChildren:{type:Function,default:row=>[]},
    valueKey:{type:String,default:'value'},
    labelKey:{type:String,default:'label'},
  },
  setup(props,{emit,expose}){
    const leftTree=shallowRef(null),
    rightTree=shallowRef(null),
    rightData=ref([]),
    filterLeft=list=>{
      const ret=[];
      list.forEach(row=>{
        if(!row.children){
          if(!props.transfered.includes(row[props.valueKey])) ret.push(row);
          return;
        }else if(props.transfered.includes(row[props.valueKey])) return;
        const children=filterLeft(row.children);
        if(children.length){
          const copy=reactive({...row});
          copy.children=children;
          ret.push(copy);
        }
      });
      return ret;
    },
    filterRight=list=>{
      const ret=[];
      list.forEach(row=>{
        // 如果是全选中,则不需要解构
        if(props.transfered.includes(row[props.valueKey])) return ret.push(row);
        if(!row.children) return;
        const children=filterRight(row.children);
        if(children.length){
          // 否则需要解构,避免变更原对象的chindren
          const copy=reactive({...row});
          copy.children=children;
          ret.push(copy);
        }
      });
      return ret;
    },
    cache=shallowReactive({left:[],right:[]});
    watch(
      ()=>props.data,
      val=>{rightData.value=JSON.parse(JSON.stringify(val))},
      {immediate:true}
    );

    expose({
      rightTree,
    });

    return {
      leftTree,
      leftData:computed(()=>reactive(filterLeft(props.data))),
      rightTree,
      rightData:computed(()=>reactive(filterRight(rightData.value))),
      cache,
      toRight(){
        leftTree.value.getFullChecked().forEach(row=>{
          if(!props.transfered.includes(row.node[props.valueKey])) props.transfered.push(row.node[props.valueKey]);
        });
        emit('update:transfered',[...props.transfered]);
        nextTick(()=>{
          rightTree.value.checkAll(true);
        });
      },
      toLeft(){
        rightTree.value.getFullChecked().forEach(row=>{
          const i=props.transfered.indexOf(row.node[props.valueKey]);
          if(i!==-1) props.transfered.splice(i,1);
        });
        emit('update:transfered',[...props.transfered]);
        nextTick(()=>{
          leftTree.value.checkAll(false);
        });
      },
    }
  },
  components:{Tree}
}
</script>
<style lang="scss" scoped>
.transfer{
  display:flex;
  .tree{
    overflow-y:scroll;
    &::-webkit-scrollbar{ display:none; }
  }
  .boundary{
    flex-direction:column;
    justify-content:space-around;
  }
}
</style>

写在最后

本组件是基于我对树结构这一概念的理解和日常开发过程中对树组件的功能需求而设计开发的。因个人认知的局限性,组件行为模型难免与普遍期望的有较大偏差,不当之处欢迎批评指正。
欢迎将本组件应用到你觉得可以试用的任何项目开发中,但是鉴于原创不易,未经允许请勿转载或用于其他任何非开发场景。

  • 15
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值