#

支持单多选,可拖拽改变结构和顺序的树组件

# 1、主组件

XTree.vue

<script >
import { defineComponent, h, ref, watch, getCurrentInstance, onMounted, reactive, toRaw, nextTick } from 'vue';
import { trave, trave2, checkIsLeaf, deepClone, findDraggableNode } from './utils';
import CollapseTransition from './CollapseTransition.js';
import XLoading from './XLoading.vue';
import XCheckbox from '@/components/XCheckbox/XCheckbox.vue';

const renderEmptyContent = (h, props, context) => {
  return h('div', {}, props.emptyText);
};
export default defineComponent({
  name: 'XTree',
  props: {
    treeData: {
      type: Array,
      default: []
    },
    defaultProps: {
      type: Object,
      default: () => ({
        children: 'children',
        title: 'title'
      })
    },
    treeItemHeight: {
      type: Number,
      default: 26
    },
    /* 重复点击某节点,是否总是选中当前文本节点 单选时有效(multiple=false) 注意:重复点击已选择的节点,也会触发on-select事件 */
    alwaysSelect: {
      type: Boolean,
      default: false
    },
    // 文本节点是否可选中 注意:设置false, 则点击文本节点,其事件功能会转向checkbox选中
    selectable: {
      type: Boolean,
      default: true
    },
    nodeKey: {
      type: [String, Number],
      default: ''
    },
    defaultExpandedKeys: {
      type: Array,
      default: []
    },
    // 对于同一级的节点,每次只能展开一个
    accordion: {
      type: Boolean,
      default: false
    },
    lazy: {
      type: Boolean,
      default: false
    },
    load: {
      type: Function,
      default: () => {}
    },
    showCheckbox: {
      type: Boolean,
      default: false
    },
    //在显示复选框的情况下,是否严格的遵循父子不互相关联的做法,默认为 false
    checkStrictly: {
      type: Boolean,
      default: false
    },

    defaultCheckedKeys: {
      type: Array,
      default: []
    },
    emptyText: {
      type: String,
      default: '暂无数据'
    },
    // TODO 是否在第一次展开某个树节点后才渲染其子节点
    renderAfterExpand: {
      type: Boolean,
      default: false
    },
    // render-content	树节点的内容区的渲染 Function	Function(h, { node, data, store }
    renderContent: {
      type: Function,
      default: undefined
    },
    // 是否默认展开所有节点
    defaultExpandAll: {
      type: Boolean,
      default: false
    },
    // 是否在点击节点的时候展开或者收缩节点, 默认值为 true,如果为 false,则只有点箭头图标的时候才会展开或者收缩节点。
    expandOnClickNode: {
      type: Boolean,
      default: false
    },
    // 是否在点击节点的时候选中节点,默认值为 false,即只有在点击复选框时才会选中节点。
    checkOnClickNode: {
      type: Boolean,
      default: false
    },
    // 当前选中的节点
    currentNodeKey: {
      type: [String, Number],
      default: ''
    },
    // 	相邻级节点间的水平缩进,单位为像素
    indent: {
      type: Number,
      default: 16
    },
    draggable: {
      type: Boolean,
      default: false
    },
    // 判断节点能否被拖拽
    allowDrag: {
      type: Function,
      default: undefined
    }
  },
  // emits: {},
  // components: {},
  setup(props, context) {
    const nodeKey = props.nodeKey || 'id';

    let treeData = [];
    const root = {
      title: '根节点',
      [nodeKey]: 'x_tree_root',
      level: 0,
      children: treeData
    };
    if (props.lazy) {
      props.load(root, (newNodeList) => {
        root.children = newNodeList;
      });
    } else {
      treeData = deepClone(props.treeData);
      root.children = treeData;
    }

    // 渲染空节点
    if (!root.children || root.children.length === 0) {
      if (context.slots.empty && typeof context.slots.empty === 'function') {
        return () => context.slots.empty();
      }
      return () => renderEmptyContent(h, props, context);
    }

    const nodeKeyMap = new Map();

    nodeKeyMap.set(root[nodeKey], root);
    const rootProxy = reactive(root);

    const initData = () => {
      trave2(
        rootProxy.children,
        {
          nodeKey,
          defaultExpandedKeys: props.defaultExpandedKeys,
          defaultCheckedKeys: props.defaultCheckedKeys,
          checkStrictly: props.checkStrictly
        },
        null,
        nodeKeyMap
      );
    };

    initData();

    if (props.defaultExpandAll) {
      for (let node of nodeKeyMap.values()) {
        node.expand = true;
      }
    } else {
      // 处理默认展开功能
      for (let _nodeKey of props.defaultExpandedKeys) {
        const node = nodeKeyMap.get(_nodeKey);
        if (node) {
          node.expand = true;
          let parentNodeKey = node[nodeKey];
          while (parentNodeKey) {
            const parentNode = nodeKeyMap.get(parentNodeKey);
            if (parentNode) {
              parentNode.expand = true;
              parentNodeKey = parentNode.parentNodeKey;
            } else {
              parentNodeKey = null;
            }
          }
        }
      }
    }

    let _currentCheckBoxChangeNode = null;

    const currentSelectNode = ref({});

    watch(currentSelectNode, (newVal, oldVal) => {
      // console.log('watch currentSelectNode', newVal);
    });

    const getCurrentNode = () => {
      if (currentSelectNode && currentSelectNode.value && currentSelectNode.value[nodeKey]) {
        return toRaw(currentSelectNode.value);
      }
      return null;
    };
    // 设定默认选中的行
    if (props.selectable && props.currentNodeKey) {
      const node = nodeKeyMap.get(props.currentNodeKey);
      if (node) {
        currentSelectNode.value = node;
      }
    }

    const getCheckedNodes = () => {
      const checkedNodes = [];
      for (let node of nodeKeyMap.values()) {
        if (node.check && node.indeterminate === false) {
          checkedNodes.push(toRaw(node));
        }
      }
      return checkedNodes;
    };

    const onCheckBoxChangeForChild = (node, checkVal) => {
      if (node.children && node.children.length > 0) {
        for (let i = 0, len = node.children.length; i < len; i++) {
          node.children[i].check = checkVal;
          node.children[i].indeterminate = false;
          onCheckBoxChangeForChild(node.children[i], checkVal);
        }
      }
    };
    const onCheckBoxChangeForParent = (node, checkVal) => {
      const parentNodeKey = node.parentNodeKey;
      const parentNode = nodeKeyMap.get(parentNodeKey);

      // 判断一下父元素的状态
      let childCheckCount = 0;
      let childIndeterminateCount = 0;
      if (parentNode && parentNode.children && parentNode.children.length > 0) {
        parentNode.children.forEach((child) => {
          if (child.check) {
            childCheckCount++;
          }
          if (child.indeterminate) {
            childIndeterminateCount++;
          }
        });
        if (childCheckCount === 0) {
          parentNode.indeterminate = false;
          parentNode.check = false;
        } else if (childCheckCount === parentNode.children.length) {
          parentNode.indeterminate = false;
          parentNode.check = true;
        }

        if (
          (0 < childCheckCount && childCheckCount < parentNode.children.length) ||
          (0 < childIndeterminateCount && childIndeterminateCount < parentNode.children.length)
        ) {
          parentNode.indeterminate = true;
          parentNode.check = false;
        }
      }

      if (parentNode) {
        onCheckBoxChangeForParent(parentNode, checkVal);
      }
    };

    let _timer = null;
    const handleCheckboxChange = (node, val) => {
      context.emit('current-check-change', toRaw(node), val);
      if (_timer) {
        clearTimeout(_timer);
        _timer = null;
      }
      _timer = setTimeout(() => {
        _currentCheckBoxChangeNode = null;
        const checkedNodes = getCheckedNodes();
        context.emit('check-change', checkedNodes);
      });
      if (props.checkStrictly) {
        // 父子不联动
        return;
      }
      // 下面是父子联动的逻辑 考虑懒加载的时候,可能check的值需要读取自父节点
      onCheckBoxChangeForChild(node, val);
      onCheckBoxChangeForParent(node, val);
    };

    const handleExpandIconClcik = (node) => {
      if (checkIsLeaf(node)) {
        return;
      }
      if (node.loading) {
        return;
      }
      let _expand = false;
      if (props.accordion && !node.expand) {
        if (node.parentNodeKey) {
          let parentNode = nodeKeyMap.get(node.parentNodeKey);
          if (parentNode) {
            parentNode.children.forEach((node) => {
              node.expand = false;
            });
          } else {
          }
          _expand = true;
        }
      } else {
        _expand = !node.expand;
      }
      node.expand = props.lazy && _expand ? false : _expand;
      if (props.lazy && _expand) {
        // 懒加载并且展开
        if (node.children && node.children.length > 0) {
          // 说明已经加载过了, 不必再加载
          node.expand = _expand;
        } else {
          // 需要加载
          node.loading = true;
          props.load(node, (newNodeList = []) => {
            node.loading = false;
            node.children = newNodeList;
            if (newNodeList.length === 0) {
              node.isleaf = true;
            }
            initData();
            nextTick(() => {
              node.expand = _expand;
            });
          });
        }
      }
    };

    const expand = ref(false);

    const treeEl = ref(null);

    let dropType = ref(''); // prev inner next
    const activeNodeId = ref('');

    function renderTree(h, parentNode, chidlren, options) {
      return h(
        CollapseTransition,
        {},
        {
          default: () => {
            const parentKey = parentNode ? parentNode[nodeKey] || parentNode.index : '';
            return h(
              'ul',
              {
                role: 'tree',
                class: [options.outside ? 'x-tree' : 'x-tree-node__children'],
                key: !parentNode || parentNode.expand ? parentKey + 'block' : parentKey + 'none',
                style: {
                  display: !parentNode || parentNode.expand ? 'block' : 'none'
                },
                ref: options.outside ? treeEl : null
              },
              chidlren.map((node) => {
                let children = null;
                if (node[options.defaultProps.children] && node[options.defaultProps.children].length > 0) {
                  children = renderTree(h, node, node[options.defaultProps.children], { ...options, outside: false });
                }
                const rowVNode = h(
                  'li',
                  {
                    role: 'treeitem',
                    class: ['x-tree-node', activeNodeId.value === node[nodeKey] && dropType.value ? 'x-tree-node--' + dropType.value : ''],
                    'data-index': node.index,
                    'data-id': node[nodeKey],
                    'data-leaf': node.isleaf !== undefined ? node.isleaf.toString() : '',
                    'data-parentNodeKey': node.parentNodeKey,
                    key: node.index,
                    '^draggable': props.allowDrag ? props.allowDrag(node) : props.draggable
                  },
                  [renderNodeContent(h, node), children]
                );
                return rowVNode;
              })
            );
          }
        }
      );
    }

    function renderNodeContent(h, node) {
      return h(
        'div',
        {
          class: [
            'x-tree-node__content',
            currentSelectNode.value[nodeKey] && currentSelectNode.value[nodeKey] === node[nodeKey] ? 'x-tree-node__content--selected' : ''
          ],
          style: {
            paddingLeft: (node.level - 1) * props.indent + 'px',
            height: props.treeItemHeight + 'px'
          },
          onClick: (e) => {
            e.stopPropagation();
            if (props.selectable) {
              if (props.alwaysSelect) {
                currentSelectNode.value = node;
              } else {
                if (currentSelectNode.value[nodeKey] === node[nodeKey]) {
                  currentSelectNode.value = {};
                } else {
                  currentSelectNode.value = node;
                }
              }
            }
            context.emit('node-click', toRaw(node));
          }
        },
        [
          h('span', {
            class: {
              'x-tree-node__expand-icon': true,
              expanded: node.expand,
              'x-tree-node__expand-icon--hidden': checkIsLeaf(node)
            },
            onClickCapture: (e) => {
              e.stopPropagation();
              handleExpandIconClcik(node);
            }
          }),
          // 复选框
          props.showCheckbox
            ? h(
                XCheckbox,
                {
                  showLabel: false,
                  disabled: node.disabled,
                  modelValue: node.check,
                  indeterminate: node.indeterminate,
                  'onUpdate:modelValue': (val) => {
                    node.check = val;
                    node.indeterminate = false;
                  },
                  onChange: (val) => {
                    if (!_currentCheckBoxChangeNode) {
                      _currentCheckBoxChangeNode = node;
                    }
                    if (node[nodeKey] === _currentCheckBoxChangeNode[nodeKey]) {
                      handleCheckboxChange(node, val);
                    }
                  },
                  'data-check': node.check.toString()
                },
                {
                  default: () => ''
                }
              )
            : null,
          // loading 状态
          node.loading ? h(XLoading, { style: { marginRight: '4px' } }) : null,
          // 文本内容
          h(
            'div',
            {
              class: 'x-tree-node__label-content',
              style: {
                lineHeight: props.treeItemHeight + 'px'
              },
              onClick: (e) => {
                // e.stopPropagation();
                if (props.expandOnClickNode) {
                  handleExpandIconClcik(node);
                }
                if (props.checkOnClickNode) {
                  const oldVal = node.check;
                  node.check = !oldVal;
                  node.indeterminate = false;

                  if (!_currentCheckBoxChangeNode) {
                    _currentCheckBoxChangeNode = node;
                  }
                  if (node[nodeKey] === _currentCheckBoxChangeNode[nodeKey]) {
                    handleCheckboxChange(node, !oldVal);
                  }
                }
              }
            },
            props.renderContent ? props.renderContent(h, node) : node.title
          )
        ]
      );
    }

    /* 
    为 Tree 中的一个节点追加一个子节点	
    (data, parentNode) 接收两个参数,1. 要追加的子节点的 data 2. 子节点的 parent 的 data、key 或者 node
    */
    const append = (newNode, parentNode) => {
      if (parentNode) {
        const parentNodeKey = parentNode[nodeKey] || parentNode;
        if (parentNodeKey) {
          const parentNode = nodeKeyMap.get(parentNodeKey);
          if (!parentNode.children) {
            parentNode.children = [];
          }
          parentNode.children.push(newNode);
          initData();
        }
      } else {
        rootProxy.children.push(newNode);
        initData();
      }
    };

    context.expose({
      getCurrentNode,
      getCheckedNodes,
      append
    });

    let draggingNode = null;
    let targetNode = null;
    const handleDrag = () => {
      if (props.draggable) {
        const toNodeIsChildOfFromNode = (fromNode, toNode) => {
          let flag = false;
          if (fromNode[nodeKey] === toNode[nodeKey]) {
            return true;
          }
          let node = toNode;
          while (node) {
            if (node[nodeKey] === fromNode[nodeKey]) {
              flag = true;
              break;
            }
            node = nodeKeyMap.get(node.parentNodeKey);
          }
          return flag;
        };

        const resetStatus = () => {
          draggingNode = null;
          targetNode = null;
          dropType.value = '';
          activeNodeId.value = '';
        };

        const treeOuterEl = treeEl.value;
        const overHandler = (e) => {
          const target = e.target;
          const overTarget = findDraggableNode(target);
          if (overTarget && draggingNode) {
            targetNode = overTarget;
            activeNodeId.value = targetNode.dataset.id;
            const clientY = e.clientY;
            const { top } = overTarget.getBoundingClientRect();
            const height = props.treeItemHeight;
            const step = height / 3;
            if (top <= clientY && clientY < top + step) {
              dropType.value = 'prev';
            } else if (top + step <= clientY && clientY < top + step * 2) {
              dropType.value = 'inner';
            } else if (top + step * 2 <= clientY && clientY < top + height) {
              dropType.value = 'next';
            }
          }
        };

        const dragEndHandler = (e) => {
          if (draggingNode && targetNode && dropType.value) {
            const draggingNodeId = draggingNode.dataset.id;
            const fromNode = nodeKeyMap.get(draggingNodeId);
            const targetNodeId = targetNode.dataset.id;
            const toNode = nodeKeyMap.get(targetNodeId);
            if (fromNode[nodeKey] === toNode[nodeKey]) {
              resetStatus();
              return;
            }
            // 如果父节点移动到子节点,不被允许
            if (toNodeIsChildOfFromNode(fromNode, toNode)) {
              resetStatus();
              return;
            }
            // 移除源节点
            const fromParentNode = nodeKeyMap.get(fromNode.parentNodeKey);
            for (let i = 0, len = fromParentNode.children.length; i < len; i++) {
              if (fromParentNode.children[i][nodeKey] === fromNode[nodeKey]) {
                fromParentNode.children.splice(i, 1);
                break;
              }
            }
            // 插入到目标节点
            if (dropType.value === 'inner') {
              if (!toNode.children) {
                toNode.children = [];
              }
              fromNode.parentNodeKey = toNode[nodeKey];
              console.log({ ...fromNode }, toNode[nodeKey], '====');
              toNode.children.push(fromNode);
              toNode.expand = true;
            }

            const toNodeParentNodeKey = toNode.parentNodeKey;
            fromNode.parentNodeKey = toNodeParentNodeKey;
            const parentNode = nodeKeyMap.get(toNodeParentNodeKey);
            for (let i = 0, len = parentNode.children.length; i < len; i++) {
              if (parentNode.children[i][nodeKey] === toNode[nodeKey]) {
                if (dropType.value === 'prev') {
                  parentNode.children.splice(i, 0, fromNode);
                }
                if (dropType.value === 'next') {
                  parentNode.children.splice(i + 1, 0, fromNode);
                }

                break;
              }
            }
            toNode.expand = true;
            initData();
            resetStatus();
          }
        };
        treeOuterEl.addEventListener('dragstart', (e) => {
          const target = findDraggableNode(e.target);
          if (target) {
            draggingNode = target;
          }
        });
        treeOuterEl.addEventListener('dragend', (e) => {
          dragEndHandler(e);
        });
        treeOuterEl.addEventListener('dragover', (e) => {
          e.preventDefault();
          overHandler(e);
        });
        treeOuterEl.addEventListener('dragenter', (e) => {
          e.preventDefault();
          e.stopPropagation();
        });
        treeOuterEl.addEventListener('dragleave', (e) => {
          e.preventDefault();
          e.stopPropagation();
        });

        treeOuterEl.addEventListener('drop', (e) => {
          // resetStatus();
        });
      }
    };

    onMounted(() => {
      const vm = getCurrentInstance();
      // console.log(vm.ctx.$options.name, 'vm', treeEl.value);
      handleDrag();
    });

    return () =>
      renderTree(h, null, rootProxy.children, {
        defaultProps: props.defaultProps,
        outside: true
      });
  }
});
</script>

<style>
@import url('./XTree.css');
</style>

# 1.1样式

XTree.css

.x-tree,
.x-tree-node__children {
  list-style: none;
}
.x-tree {
  position: relative;
  cursor: default;
  background: #fff;
  color: #606266;
}
.red {
  color: red;
}

.x-tree-node__content {
  display: flex;
  align-items: center;
  height: 26px;
  cursor: pointer;
  white-space: nowrap;
  position: relative;
}
.x-tree-node.x-tree-node--prev > .x-tree-node__content::before {
  content: ' ';
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  height: 1px;
  background-color: #409eff;;
}
.x-tree-node.x-tree-node--next > .x-tree-node__content::after {
  content: ' ';
  position: absolute;
  left: 0;
  bottom: 0;
  right: 0;
  height: 1px;
  background-color: #409eff;;
}
.x-tree-node.x-tree-node--inner > .x-tree-node__content {
  background-color: #409eff;;
}
.x-tree-node__content:hover {
  background-color: #f5f7fa;
}
.x-tree-node__content.x-tree-node__content--selected {
  background-color: #f5f7fa;
}

.x-tree-node__content > label.x-checkbox {
  margin-right: 8px;
}

.x-tree-node__label {
  font-size: 14px;
  display: inline-block;
  height: 100%;
  line-height: 26px;
}
.x-tree-node__label-content {
  flex: 1;
  overflow: hidden;
  height: 100%;
  font-size: 14px;
}
.x-tree-node__expand-icon {
  position: relative;
  flex: 0 0 24px;
  height: 24px;
  padding: 8px;
  transform: rotate(-90deg);
  transition: transform 0.4s ease;
}

.x-tree-node__expand-icon.expanded {
  transform: rotate(0deg);
}
.x-tree-node__expand-icon::before {
  content: ' ';
  position: absolute;
  left: 8px;
  top: 10px;
  width: 0px;
  height: 0px;
  border-style: solid;
  border-width: 4px 4px;
  border-color: #c0c4cc transparent transparent transparent;
}
.x-tree-node__expand-icon.x-tree-node__expand-icon--hidden::before {
  display: none;
}

# 2、复选框组件

XCheckbox.vue

<template>
  <label :class="{
    'x-checkbox': true,
    'is-checked': isChecked, 
    'is-disabled': disabled,
    'is-indeterminate': isIndeterminate,
    'is-focus': isFocus
    }"
         @click.stop="noop">
    <span :class="{
        'x-checkbox__input': true, 
        'is-checked': isChecked, 
        'is-disabled': disabled,
        'is-indeterminate': isIndeterminate,
        'is-focus': isFocus
        }">
      <input type="checkbox"
             ref="CheckBoxInputRef"
             class="x-checkbox__original"
             :disabled="disabled"
             @blur="onInputBlur"
             @focus="onInputFocus">
      <span class="x-checkbox__inner"
            @click.stop="handleTrigger"></span>

    </span>
    <span v-if="showLabel"
          class="x-checkbox__label"
          @click.stop="handleTrigger">
      <slot></slot>
    </span>
  </label>
</template>
<script lang="ts">
import { defineComponent, ref, toRef, computed, watch } from 'vue';
export default defineComponent({
  name: 'XCheckBox',
  props: {
    modelValue: {
      type: [String, Number, Boolean],
      default: ''
    },
    trueLabel: {
      type: [String, Number],
      default: undefined
    },
    falseLabel: {
      type: [String, Number],
      default: undefined
    },
    disabled: {
      type: Boolean,
      default: false
    },
    indeterminate: {
      type: Boolean,
      default: false
    },
    showLabel: {
      type: Boolean,
      default: true
    }
  },
  computed: {},
  emits: ['update:modelValue', 'change'],
  // components: {},
  setup(props, context) {
    const modelValue = ref(props.modelValue);
    const isBooleanMode = props.trueLabel === undefined;

    const CheckBoxInputRef = ref(null);
    const isFocus = ref(false);
    const focusInput = () => {
      if (props.disabled) {
        return;
      }
      if (CheckBoxInputRef && CheckBoxInputRef.value) {
        CheckBoxInputRef.value.focus();
        isFocus.value = true;
      }
    };

    watch(
      () => props.modelValue,
      (newVal, oldVal) => {
        modelValue.value = newVal;
        context.emit('change', modelValue.value);
        focusInput();
      }
    );
    const isIndeterminate = computed(() => {
      if (props.disabled) {
        return false;
      }
      return props.indeterminate;
    });

    const isChecked = computed(() => {
      if (isIndeterminate.value) {
        return false;
      }
      if (isBooleanMode) {
        return modelValue.value;
      }
      return modelValue.value === props.trueLabel;
    });

    const handleTrigger = () => {
      if (props.disabled) return;
      const oldValue = modelValue.value;
      if (isBooleanMode) {
        modelValue.value = isIndeterminate.value ? true : !modelValue.value;
      } else {
        if (isIndeterminate.value) {
          modelValue.value = props.trueLabel;
        } else {
          modelValue.value = modelValue.value === props.trueLabel ? props.falseLabel : props.trueLabel;
        }
      }
      if (oldValue !== modelValue.value) {
        context.emit('update:modelValue', modelValue.value);
        context.emit('change', modelValue.value);
      }
      focusInput();
    };

    const onInputBlur = () => {
      isFocus.value = false;
    };

    const onInputFocus = () => {};

    const noop = (e) => {};

    return {
      modelValue,
      handleTrigger,
      isChecked,
      isIndeterminate,
      CheckBoxInputRef,
      onInputBlur,
      onInputFocus,
      isFocus,
      noop
    };
  }
});
</script>
<style >
.x-checkbox {
  color: #606266;
  font-weight: 500;
  font-size: 14px;
  position: relative;
  cursor: pointer;
  display: inline-block;
  white-space: nowrap;
  user-select: none;
  margin-right: 30px;
}
.x-checkbox.is-disabled {
  cursor: not-allowed;
}
.x-checkbox:last-of-type {
  margin-right: 0;
}
.x-checkbox__input {
  position: relative;
  display: inline-block;
  vertical-align: middle;
  outline: none;
  line-height: 1;
}
.x-checkbox__inner {
  width: 14px;
  height: 14px;
  display: inline-block;
  border: 1px solid #dcdfe6;
  position: relative;
  z-index: 1;
  box-sizing: border-box;
  border-radius: 2px;
  transition: border-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46), background-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46);
}
.x-checkbox__inner::after {
  box-sizing: content-box;
  content: '';
  border: 1px solid #fff;
  border-left: 0;
  border-top: 0;
  height: 7px;
  left: 4px;
  position: absolute;
  top: 1px;
  transform: rotate(45deg) scaleY(0);
  width: 3px;
  transition: transform 0.15s ease-in 0.05s;
  transform-origin: center;
}

.x-checkbox__input.is-checked .x-checkbox__inner:after {
  transform: rotate(45deg) scaleY(1);
}
.x-checkbox__input.is-checked .x-checkbox__inner,
.x-checkbox__input.is-indeterminate .x-checkbox__inner {
  border-color: #409eff;
  background-color: #409eff;
}
.x-checkbox__input.is-disabled .x-checkbox__inner {
  background-color: #edf2fc;
  border-color: #dcdfe6;
  cursor: not-allowed;
}
.x-checkbox__input.is-focus .x-checkbox__inner {
  border-color: #409eff;
}
.x-checkbox__input.is-indeterminate .x-checkbox__inner::after {
  display: none;
}
.x-checkbox__input.is-indeterminate .x-checkbox__inner::before {
  content: '';
  position: absolute;
  display: block;
  background-color: #fff;
  height: 2px;
  transform: scale(0.5);
  left: 0;
  right: 0;
  top: 5px;
}

.x-checkbox__original {
  opacity: 0;
  outline: none;
  position: absolute;
  margin: 0;
  width: 0;
  height: 0;
  z-index: -1;
}
.x-checkbox__label {
  display: inline-block;
  padding-left: 10px;
  font-size: 14px;
  font-weight: 500;
}
.x-checkbox.is-checked .x-checkbox__label {
  color: #409eff;
}

.x-checkbox.is-disabled .x-checkbox__label {
  color: #c0c4cc;
  cursor: not-allowed;
}
</style>

# 3、Loading组件

XLoading.vue

<template>
  <svg class="xtree-loading"
       style="width: 1em;height: 1em;vertical-align: middle;fill: currentColor;overflow: hidden;"
       viewBox="0 0 1024 1024"
       version="1.1"
       xmlns="http://www.w3.org/2000/svg"
       p-id="1473">
    <path d="M511.882596 287.998081h-0.361244a31.998984 31.998984 0 0 1-31.659415-31.977309v-0.361244c0-0.104761 0.115598-11.722364 0.115598-63.658399V96.000564a31.998984 31.998984 0 1 1 64.001581 0V192.001129c0 52.586273-0.111986 63.88237-0.119211 64.337537a32.002596 32.002596 0 0 1-31.977309 31.659415zM511.998194 959.99842a31.998984 31.998984 0 0 1-31.998984-31.998984v-96.379871c0-51.610915-0.111986-63.174332-0.115598-63.286318s0-0.242033 0-0.361243a31.998984 31.998984 0 0 1 63.997968-0.314283c0 0.455167 0.11921 11.711527 0.11921 64.034093v96.307622a31.998984 31.998984 0 0 1-32.002596 31.998984zM330.899406 363.021212a31.897836 31.897836 0 0 1-22.866739-9.612699c-0.075861-0.075861-8.207461-8.370021-44.931515-45.094076L195.198137 240.429485a31.998984 31.998984 0 0 1 45.256635-45.253022L308.336112 263.057803c37.182834 37.182834 45.090463 45.253022 45.41197 45.578141A31.998984 31.998984 0 0 1 330.899406 363.021212zM806.137421 838.11473a31.901448 31.901448 0 0 1-22.628318-9.374279L715.624151 760.859111c-36.724054-36.724054-45.018214-44.859267-45.097687-44.93874a31.998984 31.998984 0 0 1 44.77618-45.729864c0.32512 0.317895 8.395308 8.229136 45.578142 45.411969l67.88134 67.88134a31.998984 31.998984 0 0 1-22.624705 54.630914zM224.000113 838.11473a31.901448 31.901448 0 0 0 22.628317-9.374279l67.88134-67.88134c36.724054-36.724054 45.021826-44.859267 45.097688-44.93874a31.998984 31.998984 0 0 0-44.776181-45.729864c-0.32512 0.317895-8.395308 8.229136-45.578142 45.411969l-67.88134 67.884953a31.998984 31.998984 0 0 0 22.628318 54.627301zM255.948523 544.058589h-0.361244c-0.104761 0-11.722364-0.115598-63.658399-0.115598H95.942765a31.998984 31.998984 0 1 1 0-64.00158h95.996952c52.586273 0 63.88237 0.111986 64.337538 0.11921a31.998984 31.998984 0 0 1 31.659414 31.97731v0.361244a32.002596 32.002596 0 0 1-31.988146 31.659414zM767.939492 544.058589a32.002596 32.002596 0 0 1-31.995372-31.666639v-0.361244a31.998984 31.998984 0 0 1 31.659415-31.970085c0.455167 0 11.754876-0.11921 64.34115-0.11921h96.000564a31.998984 31.998984 0 0 1 0 64.00158H831.944685c-51.936034 0-63.553638 0.111986-63.665624 0.115598h-0.335957zM692.999446 363.0176a31.998984 31.998984 0 0 1-22.863126-54.381656c0.317895-0.32512 8.229136-8.395308 45.41197-45.578141l67.88134-67.884953A31.998984 31.998984 0 1 1 828.693489 240.429485l-67.892177 67.88134c-31.020013 31.023625-41.644196 41.759794-44.241539 44.393262l-0.697201 0.722488a31.908673 31.908673 0 0 1-22.863126 9.591025z"
          fill=""
          p-id="1474"></path>
  </svg>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
  name: 'XLoading',
  // props: {},
  // emits: {},
  // components: {},
  setup(props, context) {
    return {};
  }
});
</script>
<style scoped>
.xtree-loading {
  animation: rotating 2s linear infinite;
}
@keyframes rotating {
  0% {
    transform: rotate(0);
  }
  50% {
    transform: rotate(180deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

# 4、动画组件

CollapseTransition.js

import { h, Transition } from 'vue';
import './CollapseTransition.css';
function addClass(el, className) {
  el.classList.add(className);
  // Element.prototype.classList.remove
}
function removeClass(el, className) {
  el.classList.remove(className);
}
export default function CollapseTransition(props, context) {
  return h(
    Transition,
    {
      name: 'collapse-transition',
      'on-before-enter': el => {
        addClass(el, 'collapse-transition');
        if (!el.dataset) el.dataset = {};

        el.dataset.oldPaddingTop = el.style.paddingTop;
        el.dataset.oldPaddingBottom = el.style.paddingBottom;

        el.style.height = '0';
        el.style.paddingTop = 0;
        el.style.paddingBottom = 0;
      },
      'on-enter': el => {
        el.dataset.oldOverflow = el.style.overflow;
        if (el.scrollHeight !== 0) {
          el.style.height = el.scrollHeight + 'px';
          el.style.paddingTop = el.dataset.oldPaddingTop;
          el.style.paddingBottom = el.dataset.oldPaddingBottom;
        } else {
          el.style.height = '';
          el.style.paddingTop = el.dataset.oldPaddingTop;
          el.style.paddingBottom = el.dataset.oldPaddingBottom;
        }

        el.style.overflow = 'hidden';
      },
      'on-after-enter': el => {
        // for safari: remove class then reset height is necessary
        removeClass(el, 'collapse-transition');
        el.style.height = '';
        el.style.overflow = el.dataset.oldOverflow;
      },
      'on-before-leave': el => {
        if (!el.dataset) el.dataset = {};
        el.dataset.oldPaddingTop = el.style.paddingTop;
        el.dataset.oldPaddingBottom = el.style.paddingBottom;
        el.dataset.oldOverflow = el.style.overflow;

        el.style.height = el.scrollHeight + 'px';
        el.style.overflow = 'hidden';
      },
      'on-leave': el => {
        if (el.scrollHeight !== 0) {
          // for safari: add class after set height, or it will jump to zero height suddenly, weired
          addClass(el, 'collapse-transition');
          el.style.height = 0;
          el.style.paddingTop = 0;
          el.style.paddingBottom = 0;
        }
      },
      'on-after-leave': el => {
        removeClass(el, 'collapse-transition');
        el.style.height = '';
        el.style.overflow = el.dataset.oldOverflow;
        el.style.paddingTop = el.dataset.oldPaddingTop;
        el.style.paddingBottom = el.dataset.oldPaddingBottom;
      }
    },
    () => context.slots.default()
  );
}

动画组件样式 CollapseTransition.css

.collapse-transition {
  transition: 0.3s height ease-in-out, 0.3s padding-top ease-in-out, 0.3s padding-bottom ease-in-out;
}

c

# 5、辅助函数

utils.js

export function trave(root, options = {}) {
  if (!root) return root;
  const nodeIndexMap = new Map();

  const { nodeKey, defaultExpandedKeys, checkStrictly } = options;
  const defaultExpandedKeysMap = new Map();
  if (nodeKey && defaultExpandedKeys.length > 0) {
    defaultExpandedKeys.forEach(key => {
      defaultExpandedKeysMap.set(key, true);
    });
  }

  const queue = [];
  queue.push(...root);
  let level = 1;
  while (queue.length > 0) {
    // 关键循环
    const len = queue.length;
    for (let i = 0; i < len; i++) {
      const node = queue.shift();
      node.level = level;
      node.index = `${level}_${i}`;
      node[nodeKey] = node[nodeKey] || node.index;
      nodeIndexMap.set(node.index, node);

      // node.isleaf = node.children && node.children.length > 0 ? false : true;
      node.check = false;
      node.disabled = node.disabled || false;
      let expand = node.expand || false;
      node.expand = expand;
      //   node.parentId =
      if (node.children && node.children.length > 0) {
        queue.push(...node.children);
      }
    }
    level++;
  }
  trave2(root, options);

  for (let [_nodeKey, _nodeIndex] of defaultExpandedKeysMap) {
    const node = nodeIndexMap.get(_nodeIndex);
    if (node) {
      node.expand = true;
      let parentIndex = node.parentIndex;
      while (parentIndex && parentIndex !== '-1') {
        const parentNode = nodeIndexMap.get(parentIndex);
        parentNode.expand = true;
        parentIndex = parentNode.parentIndex;
      }
    }
  }

  return root;
}

export function trave2(root, options = { nodeKey: 'id' }, parentNode = null, nodeKeyMap) {
  const { nodeKey, defaultCheckedKeys, checkStrictly } = options;
  for (let i = 0, len = root.length; i < len; i++) {
    const node = root[i];
    let index = parentNode ? `${parentNode.index}_${i}` : i.toString();
    node.index = index;

    let level = parentNode ? parentNode.level + 1 : 1;
    node.level = level;

    node[nodeKey] = node[nodeKey] || node.index;
    nodeKeyMap.set(node[nodeKey], node);

    // node.isleaf = node.children && node.children.length > 0 ? false : true;

    const defaultCheck = defaultCheckedKeys.some(value => {
      return value === node[nodeKey];
    });
    node.check = node.check || defaultCheck || false;
    node.indeterminate = node.indeterminate || false;
    if (!checkStrictly) {
      if (parentNode && parentNode.check) {
        node.check = true;
      }
    }

    node.disabled = node.disabled || false;
    let expand = node.expand || false;
    node.expand = expand;
    node.loading = false;
    if (parentNode) {
      node.parentNodeKey = parentNode[nodeKey];
    } else {
      node.parentNodeKey = 'x_tree_root';
    }
    if (node && node.children && node.children.length > 0) {
      trave2(node.children, options, node, nodeKeyMap);
    }
  }
}

export function checkIsLeaf(node) {
  if (!node) {
    return false;
  }
  if (node.isleaf === true) {
    return true;
  }
  if (node.isleaf === false) {
    return false;
  }
  return !(node.children && node.children.length > 0);
}

export function deepClone(target, hash = new WeakMap()) {
  // 已经遍历过了就返回
  if (hash.has(target)) return target;
  let cobj;
  // 处理为null的特殊情况
  if (target === null) {
    return target;
  }
  // 如果是基本数据类型 则直接返回了
  const t = typeof target;
  switch (t) {
    case 'string':
    case 'number':
    case 'boolean':
    case 'undefined':
    case 'symbol':
      return target;
  }
  if (Array.isArray(target)) {
    hash.set(target, true);
    cobj = [];
    target.forEach(o => {
      cobj.push(deepClone(o, hash));
    });
  } else {
    cobj = {};
    if (Object.prototype.toString.call(target) === '[object Object]') {
      hash.set(target, true);
      // 同时考虑string key 和 symbol key
      const keys = [...Object.getOwnPropertyNames(target), ...Object.getOwnPropertySymbols(target)];
      for (let i = 0, len = keys.length; i < len; i++) {
        const key = keys[i];
        cobj[key] = deepClone(target[key], hash);
      }
    } else {
      // 考虑到可能是函数等的情况
      cobj = target;
    }
  }
  return cobj;
}

export function findDraggableNode(
  el,
  predicate = el => {
    try {
      return el.getAttribute('role') === 'treeitem';
    } catch (error) {
      return false;
    }
  }
) {
  if (!el) return null;
  let target = el;
  while (target) {
    if (predicate(target)) {
      break;
    } else {
      target = target.parentNode;
    }
  }
  return target;
}

# 6、用法

<template>
  <div>

    <div :style=" {
              width: '400px',
              padding: '20px',
              border: '1px solid #ccc',
              margin: '20px'
            }">
      <XTree ref="xTreeRef"
             :treeData="treeData"
             :selectable="true"
             :alwaysSelect="false"
             nodeKey="id"
             :accordion="false"
             :defaultExpandedKeys="defaultExpandedKeys"
             :defaultExpandAll="false"
             :defaultCheckedKeys="defaultCheckedKeys"
             :showCheckbox="true"
             :checkStrictly="false"
             :renderContent="renderContent"
             @node-click="handleNodeClick"
             :expandOnClickNode="false"
             :checkOnClickNode="false"
             currentNodeKey="A-1"
             draggable
             :treeItemHeight="32"
             :allowDrag="allowDrag"
             @check-change="onCheckChange"></XTree>
    </div>
    <div :style=" {
              width: '400px',
              padding: '20px',
              border: '1px solid #ccc',
              margin: '20px'
            }">
      <XTree ref="xTreeRef2"
             nodeKey="id"
             :accordion="true"
             lazy
             :load="loadMore"></XTree>
    </div>
    <div :style=" {
              width: '400px',
              padding: '20px',
              border: '1px solid #ccc',
              margin: '20px'
            }">
      <XTree :treeData="[]"
             :selectable="true"
             :alwaysSelect="true"
             nodeKey="id"
             :accordion="true"
             :defaultExpandedKeys="defaultExpandedKeys"
             :defaultCheckedKeys="defaultCheckedKeys"
             showCheckbox
             :checkStrictly="false"
             @node-click="handleNodeClick">
        <template v-slot:empty>
          <div>
            暂无数据啊啊啊
          </div>
        </template>
      </XTree>
    </div>
    <div>
      <button @click="triggerVisible">triggerVisible</button>
      <CollapseTransition>
        <div v-show="visible"
             class="">adasdasd</div>
      </CollapseTransition>
    </div>
    <div>
      {{treeData[0].expand}}
    </div>
    <div>
      <!-- <x-check-box v-model="checked">选项1</x-check-box> -->
      <x-checkbox v-model="allCheck"
                  :true-label="1"
                  :false-label="0"
                  :indeterminate="indeterminate"
                  @change="onChange1">选项1</x-checkbox>
      <x-checkbox v-model="checked"
                  true-label="1"
                  false-label="0"
                  @change="onChange">选项2</x-checkbox>
      <button @click="triggerChecked">triggerChecked --- {{checked}}--{{allCheck}}</button>
      <button @click="makeindeterminate">make indeterminate</button>
      <button @click="getCurrentNode">getCurrentNode</button>
      <br>
      <button @click="deleteNode">delete node</button>
      <br>
      <button @click="handleClickAdd">add</button>
      <p>
        <button @click="getCheckedNodes">getCheckedNodes</button>
      </p>
    </div>
  </div>
</template>
<script lang="ts">
import { defineComponent, h, reactive, toRaw, ref } from 'vue';
import XTree from './XTree.vue';
import CollapseTransition from './CollapseTransition';
import XCheckbox from '@/components/XCheckbox/XCheckbox.vue';
import { trave2 } from './utils';
import XLoading from './XLoading.vue';

export default defineComponent({
  name: 'Tree',
  // props: {},
  // emits: {},
  components: {
    XTree,
    XLoading,
    CollapseTransition,
    XCheckbox
  },

  setup(props, context) {
    const treeData = [
      {
        title: 'node-A',
        id: 'A',
        children: [
          { title: 'node-A-1', id: 'A-1' },
          {
            title: 'node-A-2',
            id: 'A-2',
            disabled: true,
            children: [{ title: 'node-A-2-1', id: 'A-2-1' }]
          },
          {
            title: 'node-A-3',
            id: 'A-3',
            children: [
              { title: 'node-A-3-1', id: 'A-3-1' },
              { title: 'node-A-3-2', id: 'A-3-2' },
              { title: 'node-A-3-3', id: 'A-3-3' }
            ]
          }
        ]
      },
      {
        title: 'node-B',
        id: 'B',
        children: [
          {
            title: 'node-B-1',
            id: 'B-1',
            children: [
              {
                title: 'node-B-1-1',
                id: 'B-1-1',
                children: [
                  {
                    title: 'node-B-1-1-1',
                    id: 'B-1-1-1'
                  }
                ]
              },
              {
                title: 'node-B-1-2',
                id: 'B-1-2'
              }
            ]
          },
          {
            title: 'node-B-2',
            id: 'B-2'
          },
          {
            title: 'node-B-3',
            id: 'B-3'
          },
          {
            title: 'node-B-4',
            id: 'B-4'
          }
        ]
      }
    ];

    // trave2(treeData);

    const handleNodeClick = (node) => {
      // console.log(node.title);
      // console.log(toRaw(node));
    };

    const visible = ref(true);
    const triggerVisible = () => {
      visible.value = !visible.value;
    };

    const checked = ref('1');
    const triggerChecked = () => {
      if (checked.value === '1') {
        checked.value = '0';
      } else {
        checked.value = '1';
      }
      // checked.value = !checked.value;
    };

    const onChange = (val) => {
      console.log(val);
    };
    const allCheck = ref(false);
    const indeterminate = ref(true);
    const onChange1 = (val) => {
      allCheck.value = val;
      indeterminate.value = false;
    };

    const makeindeterminate = () => {
      indeterminate.value = true;
    };

    const xTreeRef = ref(null);
    const getCurrentNode = () => {
      const node = xTreeRef.value.getCurrentNode();
      console.log('getCurrentNode', node);
    };

    const getCheckedNodes = () => {
      const node = xTreeRef.value.getCheckedNodes();
      console.log('getCheckedNodes', node);
    };

    let _id = 1000;
    const handleClickAdd = () => {
      xTreeRef.value.append({
        id: 'new' + _id,
        title: 'node-c',
        children: [
          {
            id: _id + '_1',
            title: 'node-' + _id
          }
        ]
      });
      _id++;
    };

    const deleteNode = () => {
      treeData[0].children.splice(1, 1);
    };

    const defaultExpandedKeys = ref(['A']);

    const defaultCheckedKeys = ref(['A-2']);

    const loadMore = (node, resolve) => {
      if (node.level === 0) {
        return resolve([
          {
            title: 'A',
            id: 'A',
            isleaf: false
          },
          { title: 'B', id: 'B' }
        ]);
      }
      setTimeout(() => {
        const children = [];
        for (let i = 0; i < 4; i++) {
          children.push({
            id: node.id + i.toString(),
            title: node.title + '-' + i,
            isleaf: i === 0
          });
        }
        resolve(children);
      }, 100);
    };

    const onCheckChange = (node, val) => {
      console.log(node, val);
    };

    const renderContent = (h, node) => {
      return h('span', { style: { color: '#ccc' } }, node.title.replace('node-', ''));
    };

    const allowDrag = (node) => {
      if (node.id === 'B-2') {
        return false;
      }
      return true;
    };

    return {
      treeData,
      handleClickAdd,
      handleNodeClick,
      visible,
      triggerVisible,
      checked,
      triggerChecked,
      onChange,
      allCheck,
      onChange1,
      indeterminate,
      makeindeterminate,
      xTreeRef,
      getCurrentNode,
      deleteNode,
      defaultExpandedKeys,
      loadMore,
      onCheckChange,
      defaultCheckedKeys,
      renderContent,
      getCheckedNodes,
      allowDrag
    };
  }
});
</script>

上次更新: 1/22/2025, 9:39:13 AM