vue Element Tree组件分析

2,369 阅读1分钟

今天来搞一搞 tree 树组件啊 朋友们 感觉这个还是有点复杂 代码有点多 OK 不用着急 先来分析一波最简单的示例 渲染流程

示例代码

<template>
  <div>
    <el-tree ref="tree" :data="data"></el-tree>
  </div>
</template>
<script>
export default {
  data() {
    return {
      data: [{
        label: '一级 1',
        children: [{
          label: '二级 1-1',
          children: [{
            label: '三级 1-1-1',
          }],
        }],
      }, {
        label: '一级 2',
        children: [{
          label: '二级 2-1',
          children: [{
            label: '三级 2-1-1',
          }],
        }, {
          label: '二级 2-2',
          children: [{
            label: '三级 2-2-1',
          }],
        }],
      }, {
        label: '一级 3',
        children: [{
          label: '二级 3-1',
          children: [{
            label: '三级 3-1-1',
          }],
        }, {
          label: '二级 3-2',
          children: [{
            label: '三级 3-2-1',
          }],
        }],
      }],
    };
  },
};
</script>
  • tree组件 会对你传进去的数据 做一层处理 变成好用的数据结构
  • tree.vue
created() {
  this.store = new TreeStore({
    // ....
  })
}
  • OK 朋友们这边的处理封装的流程是怎么样的 我这边先搞一波简版流程来梳理下
const data = [{
  label: '一级 1',
  children: [{
    label: '二级 1-1',
    children: [{
      label: '三级 1-1-1',
    }],
  }],
}, {
  label: '一级 2',
  children: [{
    label: '二级 2-1',
    children: [{
      label: '三级 2-1-1',
    }],
  }, {
    label: '二级 2-2',
    children: [{
      label: '三级 2-2-1',
    }],
  }],
}];
let index = 0;

class Node {
  constructor(options) {
    this.id = index++;
    this.parent = null;
    this.data = options.data;
    this.store = options.data;
    if (options.parent) {
      this.parent = options.parent;
    }

    this.level = 0;
    this.childNode = [];

    if (this.parent) {
      this.level = this.parent.level + 1;
    }

    this.setData();
  }
  setData() {
    let children;
    if (this.level === 0) {
      children = this.data;
    } else {
      children = this.data.children || [];
    }
    for (let i = 0, j = children.length; i < j; i++) {
      this.insertChild({ data: children[i] });
    }
  }
  insertChild(child) {
    const children = this.getChildren();
    child.parent = this;
    child.store = this.store;
    child = new Node(child);
    this.childNode.push(child);
  }
  getChildren() {
    if (this.level === 0) return this.data;
    return this.data.children;
  }
}
class TreeStore {
  constructor(options) {
    this.data = options.data;
    // TreeStore 传进去 能让Node 能方便得到 Store里面的配置
    this.root = new Node({
      data: this.data,
      store: this.store,
    });
  }
}
const store = new TreeStore({
  data,
});
console.log(store);
  • 这边理顺了之后那么第一次
  • 经过这一层的 数据组装 那么数据就形成了
  • this.root = this.store.root
  • 顶层数据结构
// 存放顶层数据 配置啥的
this.store = new TreeStore({})
// Node 控制每一层数据的状态  勾选  展开啥的
class Node {}
// 
  • 然后这边渲染
<template>
  <!-- // 模板先来看下 -->
  <div
    class="el-tree"
    :class="{
      'el-tree--highlight-current': highlightCurrent,
      'is-dragging': !!dragState.draggingNode,
      'is-drop-not-allow': !dragState.allowDrop,
      'is-drop-inner': dragState.dropType === 'inner'
    }"
    role="tree"
  >
    <!-- OK的 这边一波 循环 -->
    <el-tree-node
      v-for="child in root.childNodes"
      :node="child"
      :props="props"
      :render-after-expand="renderAfterExpand"
      :show-checkbox="showCheckbox"
      :key="getNodeKey(child)"
      :render-content="renderContent"
      @node-expand="handleNodeExpand">
    </el-tree-node>
    <!-- OK的  如果是空的 占位符 -->
    <div class="el-tree__empty-block" v-if="isEmpty">
      <span class="el-tree__empty-text">{{ emptyText }}</span>
    </div>
    <!-- OK 暂时不懂是什么先不管 -->
    <!-- 然后来看看初始化 created mounted -->
    <div
      v-show="dragState.showDropIndicator"
      class="el-tree__drop-indicator"
      ref="dropIndicator">
    </div>
  </div>
</template>

el-tree-node

<template>
  <!-- 一些七七八八的属性 没什么 -->
  <div
    class="el-tree-node"
    @click.stop="handleClick"
    @contextmenu="($event) => this.handleContextMenu($event)"
    v-show="node.visible"
    :class="{
      'is-expanded': expanded,
      'is-current': node.isCurrent,
      'is-hidden': !node.visible,
      'is-focusable': !node.disabled,
      'is-checked': !node.disabled && node.checked
    }"
    role="treeitem"
    tabindex="-1"
    :aria-expanded="expanded"
    :aria-disabled="node.disabled"
    :aria-checked="node.checked"
    :draggable="tree.draggable"
    @dragstart.stop="handleDragStart"
    @dragover.stop="handleDragOver"
    @dragend.stop="handleDragEnd"
    @drop.stop="handleDrop"
    ref="node"
  >
    <!-- 层级这边来控制padding -->
    <div class="el-tree-node__content"
      :style="{ 'padding-left': (node.level - 1) * tree.indent + 'px' }">
      <span
        @click.stop="handleExpandIconClick"
        :class="[
          { 'is-leaf': node.isLeaf, expanded: !node.isLeaf && expanded },
          'el-tree-node__expand-icon',
          tree.iconClass ? tree.iconClass : 'el-icon-caret-right'
        ]"
      >
      </span>
      <el-checkbox
        v-if="showCheckbox"
        v-model="node.checked"
        :indeterminate="node.indeterminate"
        :disabled="!!node.disabled"
        @click.native.stop
        @change="handleCheckChange"
      >
      </el-checkbox>
      <span
        v-if="node.loading"
        class="el-tree-node__loading-icon el-icon-loading">
      </span>
      <!-- // 这边渲染要看下  看他是怎么渲染的  在这里的components直接定义  -->
      <node-content :node="node"></node-content>
    </div>
    <el-collapse-transition>
      <!-- // 这边就是子级的 展开折叠 -->
      <div
        class="el-tree-node__children"
        v-if="!renderAfterExpand || childNodeRendered"
        v-show="expanded"
        role="group"
        :aria-expanded="expanded"
      >
        <!-- // 递归渲染这个组件 -->
        <el-tree-node
          :render-content="renderContent"
          v-for="child in node.childNodes"
          :render-after-expand="renderAfterExpand"
          :show-checkbox="showCheckbox"
          :key="getNodeKey(child)"
          :node="child"
          @node-expand="handleChildNodeExpand">
        </el-tree-node>
      </div>
    </el-collapse-transition>
  </div>
</template>

<script>
// 先看下初始化吧 created
// 这个展开折叠动画组件 之前砸门也有分析过 
import ElCollapseTransition from '@/transitions/collapse-transition';
import ElCheckbox from '@/components/checkbox';
import emitter from '@/mixins/emitter';
import { getNodeKey } from './model/util';

export default {
  name: 'ElTreeNode',

  componentName: 'ElTreeNode',

  mixins: [emitter],

  props: {
    node: {
      default() {
        return {};
      },
    },
    props: {},
    renderContent: Function,
    renderAfterExpand: {
      type: Boolean,
      default: true,
    },
    showCheckbox: {
      type: Boolean,
      default: false,
    },
  },

  components: {
    ElCollapseTransition,
    ElCheckbox,
    // 来看   先看下
    NodeContent: {
      props: {
        node: {
          required: true,
        },
      },
      render(h) {
        const parent = this.$parent;
        const { tree } = parent;
        const { node } = this;
        const { data, store } = node;
        // 这边渲染方式有三种
        // 传入renderContent 函数   parent._renderProxy  其实就是parent组件  如果直接Es6 Proxy parent._renderProxy就是对组件做了一层 proxy包装
        // 比如说<el-tree><span>111</span></el-tree>   通过 tree.$scopedSlots.default 获取也就是span那部分  然后调用传入props
        // 默认的模板
        // 那一次的渲染逻辑大概就是这样
        return (
          parent.renderContent
            ? parent.renderContent.call(parent._renderProxy, h, {
              _self: tree.$vnode.context, node, data, store,
            })
            : tree.$scopedSlots.default
              ? tree.$scopedSlots.default({ node, data })
              : <span class="el-tree-node__label">{ node.label }</span>
        );
      },
    },
  },

  data() {
    return {
      tree: null,
      expanded: false,
      childNodeRendered: false,
      oldChecked: null,
      oldIndeterminate: null,
    };
  },

  watch: {
    // ...
  },

  methods: {
    // ...
  },

  created() {
    const parent = this.$parent;
    // 最高一级的 也就是tree 有个标识符 isTree = true
    if (parent.isTree) {
      this.tree = parent;
    } else {
      // 一级一级往上取   类似于 $store $router 的赋值方式
      this.tree = parent.tree;
    }
  
    const { tree } = this;
    if (!tree) {
      console.warn('Can not find node\'s tree.');
    }

    const props = tree.props || {};
    const childrenKey = props.children || 'children';
    // 监听当前这个数据的子级变化
    this.$watch(`node.data.${childrenKey}`, () => {
      this.node.updateChildren();
    });
    // 是否是展开 状态
    if (this.node.expanded) {
      this.expanded = true;
      this.childNodeRendered = true;
    }
    // 手风琴模式做的处理  展示先不用管   回到 NodeContent 的渲染
    if (this.tree.accordion) {
      this.$on('tree-node-expand', (node) => {
        if (this.node !== node) {
          this.node.collapse();
        }
      });
    }
  },
};

初次渲染大概就是这样 后面我在一个个 功能分析下

  • 今天有空 在来分析下拖拽实现吧

拖拽的实现

  • 回顾下 树组件 总组件是tree 子组件是tree-node递归组件
  • 使用代码
<template>
  <div>
    <!-- 设置了 draggable 属性变成可拖拽 -->
    <!-- 监听拖拽过程的一些事件 -->
    <!-- allow-drop 判断节点能否被拖拽 -->
    <!-- allow-drop  拖拽时判定目标节点能否被放置。type 参数有三种情况:'prev'、'inner' 和 'next',分别表示放置在目标节点前、插入至目标节点和放置在目标节点后 -->
    <!-- 那我们 draggable 入手看看做了些什么事 -->
    <el-tree
      :data="data"
      @node-drag-start="handleDragStart"
      @node-drag-enter="handleDragEnter"
      @node-drag-leave="handleDragLeave"
      @node-drag-over="handleDragOver"
      @node-drag-end="handleDragEnd"
      @node-drop="handleDrop"
      draggable
      :allow-drop="allowDrop"
      :allow-drag="allowDrag">
    </el-tree>
  </div>
</template>

tree-node子组件

<template>
  <!-- 可以看到这边是元素设置为可拖拽 然后监听了 拖拽的事件
  然后来看下每个事件做了什么 -->
  <div
    class="el-tree-node"
    @click.stop="handleClick"
    @contextmenu="($event) => this.handleContextMenu($event)"
    v-show="node.visible"
    :class="{
      'is-expanded': expanded,
      'is-current': node.isCurrent,
      'is-hidden': !node.visible,
      'is-focusable': !node.disabled,
      'is-checked': !node.disabled && node.checked
    }"
    role="treeitem"
    tabindex="-1"
    :aria-expanded="expanded"
    :aria-disabled="node.disabled"
    :aria-checked="node.checked"
    :draggable="tree.draggable"
    @dragstart.stop="handleDragStart"
    @dragover.stop="handleDragOver"
    @dragend.stop="handleDragEnd"
    @drop.stop="handleDrop"
    ref="node"
  >
    <!---->
  </div>
</template>
<script>
// ...
export default {
  methods: {
    // OK 开始拖动
    handleDragStart(event) {
      if (!this.tree.draggable) return;
      // 这边就直接让父组件tree触发这个事件 传出去组件本身 我们看看
      this.tree.$emit('tree-node-drag-start', event, this);
    },
    handleDragOver(event) {
      if (!this.tree.draggable) return;
      // OK 触发tree 监听的这个事件  往下面看 监听做了啥
      this.tree.$emit('tree-node-drag-over', event, this);
      event.preventDefault();
    },
    handleDragEnd(event) {
      if (!this.tree.draggable) return;
      this.tree.$emit('tree-node-drag-end', event, this);
    },
  }
}

</script>

tree 组件

<script>
// ...
export default {
  created() {
    const { dragState } = this;
    this.$on('tree-node-drag-start', (event, treeNode) => {
      // allowDrag 判断是否能被拖动   如果不能在这边阻止
      if (typeof this.allowDrag === 'function' && !this.allowDrag(treeNode.node)) {
        event.preventDefault();
        return false;
      }
      // 属性指定拖放操作所允许的一个效果
      event.dataTransfer.effectAllowed = 'move';
      // wrap in try catch to address IE's error when first param is 'text/plain'
      try {
        // setData is required for draggable to work in FireFox
        // the content has to be '' so dragging a node out of the tree won't open a new tab in FireFox
        event.dataTransfer.setData('text/plain', '');
      // eslint-disable-next-line no-empty
      } catch (e) {}
      // dragState 拖拽的状态都放这边
      // 设置正在拖拽的节点
      dragState.draggingNode = treeNode;
      // 然后就是发射事件出去   基本也没啥  在往上看handleDragOver
      this.$emit('node-drag-start', treeNode.node, event);
    });
    // 这个事件 处理就有点多了 
    this.$on('tree-node-drag-over', (event, treeNode) => {
      // 找到要放入的节点组件 如果拖动不在其他节点范围上就是自己   返回的是个vue组件对象
      // export const findNearestComponent = (element, componentName) => {
      //   let target = element;
      //   // target 不一定是节点的最外层 这个就是找到这个节点的最外层 也就是 tree-node的$el
      //   while (target && target.tagName !== 'BODY') {
      //     if (target.__vue__ && target.__vue__.$options.name === componentName) {
      //       return target.__vue__;
      //     }
      //     target = target.parentNode;
      //   }
      //   return null;
      // };
      const dropNode = findNearestComponent(event.target, 'ElTreeNode');
      // dropNode 可以看做是手上拖拽的节点 跟目标节点 进行某个操作
      // 这个是目标节点 
      // 老目标节点
      const oldDropNode = dragState.dropNode;
      // 老目标节点不是前面要操作的节点 那就去掉老目标节点的class
      if (oldDropNode && oldDropNode !== dropNode) {
        removeClass(oldDropNode.$el, 'is-drop-inner');
      }
      // 当前手上拖拽的
      const { draggingNode } = dragState;
      if (!draggingNode || !dropNode) return;
      // 默认都可以操作  放上放下放里面去
      let dropPrev = true;
      let dropInner = true;
      let dropNext = true;
      let userAllowDropInner = true;  
      // 如果有传进来位置限制函数allowDrop   对这些限制在做赋值
      if (typeof this.allowDrop === 'function') {
        dropPrev = this.allowDrop(draggingNode.node, dropNode.node, 'prev');
        userAllowDropInner = dropInner = this.allowDrop(draggingNode.node, dropNode.node, 'inner');
        dropNext = this.allowDrop(draggingNode.node, dropNode.node, 'next');
      }
      // 如果遇到不能放入的 设置下鼠标系统样式
      event.dataTransfer.dropEffect = dropInner ? 'move' : 'none';
      // 有一个位置允许放入  并且这个不是刚刚的那个就比如说  1放到2上面 在放到3上面 2就是老节点 3就是新的
      if ((dropPrev || dropInner || dropNext) && oldDropNode !== dropNode) {
        if (oldDropNode) {
          // 如果有的话  2老节点  就要发射这个离开事件
          this.$emit('node-drag-leave', draggingNode.node, oldDropNode.node, event);
        }
        // 新的这个就是进入
        this.$emit('node-drag-enter', draggingNode.node, dropNode.node, event);
      }
      // 赋值当前要操作的目标节点
      if (dropPrev || dropInner || dropNext) {
        dragState.dropNode = dropNode;
      }
      // dropNode.node 是组件构造的一个类 class Node{}
      // get nextSibling() {
      //   const { parent } = this;
      //   if (parent) {
      //     const index = parent.childNodes.indexOf(this);
      //     if (index > -1) {
      //       return parent.childNodes[index + 1];
      //     }
      //   }
      //   return null;
      // }
      // 要放的下个是正拖拽的这个 那么久不能放下面
      if (dropNode.node.nextSibling === draggingNode.node) {
        dropNext = false;
      }
      // 上面
      if (dropNode.node.previousSibling === draggingNode.node) {
        dropPrev = false;
      }
      // 是否包括这个  不能放里面  也就是说比如3是1的children  那么1就包含3
      if (dropNode.node.contains(draggingNode.node, false)) {
        dropInner = false;
      }
      // 正在拖拽 放入的是自己  正在拖拽的要放入自己的下级
      if (draggingNode.node === dropNode.node || draggingNode.node.contains(dropNode.node)) {
        dropPrev = false;
        dropInner = false;
        dropNext = false;
      }
      // 要放到的这个元素位置
      const targetPosition = dropNode.$el.getBoundingClientRect();
      // 树元素 最外面的元素
      const treePosition = this.$el.getBoundingClientRect();

      let dropType;
      // 得到百分比  上下的百分比 
      // 比如说  这个元素高度是 40 那么pre系数是0.25 那么就是距离顶部10的位置就是向这个元素的前面插入
      const prevPercent = dropPrev ? (dropInner ? 0.25 : (dropNext ? 0.45 : 1)) : -1;
      const nextPercent = dropNext ? (dropInner ? 0.75 : (dropPrev ? 0.55 : 0)) : 1;
      // 指示器的高度  就是一根蓝色的横线
      let indicatorTop = -9999;
      const distance = event.clientY - targetPosition.top;
      // 距离小于   这个元素高度的这个百分比 那就当做是插入上面
      if (distance < targetPosition.height * prevPercent) {
        dropType = 'before';
        // 后面
      } else if (distance > targetPosition.height * nextPercent) {
        dropType = 'after';
        // 里间
      } else if (dropInner) {
        dropType = 'inner';
      } else {
        dropType = 'none';
      }
      // 得到这个位置
      const iconPosition = dropNode.$el.querySelector('.el-tree-node__expand-icon').getBoundingClientRect();
      // 指示器元素
      const { dropIndicator } = this.$refs;
      // 设置指示器的位置
      if (dropType === 'before') {
        indicatorTop = iconPosition.top - treePosition.top;
      } else if (dropType === 'after') {
        indicatorTop = iconPosition.bottom - treePosition.top;
      }
      dropIndicator.style.top = `${indicatorTop}px`;
      dropIndicator.style.left = `${iconPosition.right - treePosition.left}px`;
      // 设置这个元素的放入样式
      if (dropType === 'inner') {
        addClass(dropNode.$el, 'is-drop-inner');
      } else {
        removeClass(dropNode.$el, 'is-drop-inner');
      }
      // 这个 拖拽主要就是设置 样式交互
      dragState.showDropIndicator = dropType === 'before' || dropType === 'after';
      dragState.allowDrop = dragState.showDropIndicator || userAllowDropInner;
      dragState.dropType = dropType;
      this.$emit('node-drag-over', draggingNode.node, dropNode.node, event);
      // 再看看上面的 handleDragEnd
    });
    // 拖拽结束时
    this.$on('tree-node-drag-end', (event) => {
      // 获取状态
      const { draggingNode, dropType, dropNode } = dragState;
      event.preventDefault();
      event.dataTransfer.dropEffect = 'move';
      // 两个都有才行
      if (draggingNode && dropNode) {
        const draggingNodeCopy = { data: draggingNode.node.data };
        if (dropType !== 'none') {
          // 删除掉自己 在父亲的children及childNodes删除
          draggingNode.node.remove();
        }
        // 放在哪里更新数据  更新下这个树组件的数据了
        if (dropType === 'before') {
          // 比如说这个插入前面的
          // insertBefore(child, ref) {
          //   let index;
          //   if (ref) {
          //     index = this.childNodes.indexOf(ref);
          //   }
          //   this.insertChild(child, index);
          // }
          // 传入当前这个删除的 以及要操作的目标  找到要操作目标的位置  然后插入这个新的   其他也是差不多了
          dropNode.node.parent.insertBefore(draggingNodeCopy, dropNode.node);
        } else if (dropType === 'after') {
          dropNode.node.parent.insertAfter(draggingNodeCopy, dropNode.node);
        } else if (dropType === 'inner') {
          dropNode.node.insertChild(draggingNodeCopy);
        }
        if (dropType !== 'none') {
          this.store.registerNode(draggingNodeCopy);
        }

        removeClass(dropNode.$el, 'is-drop-inner');
        this.$emit('node-drag-end', draggingNode.node, dropNode.node, dropType, event);
        if (dropType !== 'none') {
          this.$emit('node-drop', draggingNode.node, dropNode.node, dropType, event);
        }
      }
      // 有正在拖拽的没有 要操作的对象
      if (draggingNode && !dropNode) {
        this.$emit('node-drag-end', draggingNode.node, null, dropType, event);
      }
      // 重置拖拽对象
      dragState.showDropIndicator = false;
      dragState.draggingNode = null;
      dragState.dropNode = null;
      dragState.allowDrop = true;
    });
  }
}

</script>

后面继续 更新 大家要是觉得有啥问题 可以留言 交流
或者点个赞啥的表示下鼓励