element-ui源码分析:剖析el-tree源码,看看实现一个树组件有多么复杂(3)

3,394 阅读6分钟

本文为分析el-tree源码的第三篇文章,在上一篇文章中分析tree-store.js文件中的方法,了解到树组件应该具有哪些方法,本次分析tree-node.vue和tree.vue中的方法。阅读本文你将了解el-tree树组件的节点点击和节点拖拽相关方法的具体实现。

1.tree-node.vue文件分析

tree-node.vue定义了节点的UI结构和交互事件,我们先来看UI结构的最外层:

<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>

1.1 handleClick方法

handleClick() {
  const store = this.tree.store;
  store.setCurrentNode(this.node);
  this.tree.$emit('current-change', store.currentNode ? store.currentNode.data : null, store.currentNode);
  this.tree.currentNode = this;
  if (this.tree.expandOnClickNode) {
    this.handleExpandIconClick();
  }
  if (this.tree.checkOnClickNode && !this.node.disabled) {
    this.handleCheckChange(null, {
      target: { checked: !this.node.checked }
    });
  }
  this.tree.$emit('node-click', this.node.data, this.node, this);
},

handleClick方法定义了节点被点击时执行的操作。首先将被点击节点设置为当前节点,这是通过调用setCurrentNode来完成的,然后通用树组件触发current-change方法,并将当前节点的数据和当前节点作为参数。接着把树的currentNode设置为当前节点。我们顺便看一下文档中对current-change的定义。

然后判断树的expandOnClickNode属性是否为真值,如果为真则在点击节点的时候要展开和收缩节点,这通过调用handleExpandIconClick方法来完成的,稍后分析。

然后再判断在点击节点的时候是否需要展开节点,如果需要则调用handleCheckChange方法(稍后分析),注意传递的参数中第二个参数的target属性是一个对象,对象的checked属性是对当前节点的勾选状态取反。

最后是让树触发node-click事件,传递的参数很好理解,看一下文档对node-click的描述:

1.2 handleContextMenu方法

handleContextMenu(event) {
  if (this.tree._events['node-contextmenu'] && this.tree._events['node-contextmenu'].length > 0) {
    event.stopPropagation();
    event.preventDefault();
  }
  this.tree.$emit('node-contextmenu', event, this.node.data, this.node, this);
}

handleContextMenu方法定义了某一节点被鼠标右键点击时执行的动作,即触发node-contextmenu事件。文档对事件的定义如下图所示:

1.3 拖拽相关方法

handleDragStart(event) {
  if (!this.tree.draggable) return;
  this.tree.$emit('tree-node-drag-start', event, this);
},

handleDragOver(event) {
  if (!this.tree.draggable) return;
  this.tree.$emit('tree-node-drag-over', event, this);
  event.preventDefault();
},

handleDrop(event) {
  event.preventDefault();
},

handleDragEnd(event) {
  if (!this.tree.draggable) return;
  this.tree.$emit('tree-node-drag-end', event, this);
}

这几个方法定了拖拽相关的方法,分别是节点开始拖拽时触发事件(handleDragStart)、在拖拽节点时触发事件(handleDragOver)、拖拽成功完成时触发的事件(handleDrop)以及拖拽结束时触发的事件(handleDragEnd)。我们对照一下文档,看一下文档中定义的相关事件:

对比tree-node.vue中定义的方法和文档发现文档中的node-drag-leave和node-drag-over事件没有在tree-node.vue中触发;此外handleDrop方法也没有触发node-drop事件。原来这些方法都是在tree-node.vue的父组件tree.vue中触发的。如下图所示:

1.4 handleExpandIconClick方法

handleExpandIconClick() {
  if (this.node.isLeaf) return;
  if (this.expanded) {
    this.tree.$emit('node-collapse', this.node.data, this.node, this);
    this.node.collapse();
  } else {
    this.node.expand();
    this.$emit('node-expand', this.node.data, this.node, this);
  }
},

handleExpandIconClick为点击展开图标要做的事情。如果是叶子节点则什么也不做。然后判断节点当前的展开状态,如果当前是展开则需要收缩,是收缩则展开。除了调用节点相关的方法还要触发对应的事件。

1.5 handleCheckChange方法

handleCheckChange(value, ev) {
  this.node.setChecked(ev.target.checked, !this.tree.checkStrictly);
  this.$nextTick(() => {
    const store = this.tree.store;
    this.tree.$emit('check', this.node.data, {
      checkedNodes: store.getCheckedNodes(),
      checkedKeys: store.getCheckedKeys(),
      halfCheckedNodes: store.getHalfCheckedNodes(),
      halfCheckedKeys: store.getHalfCheckedKeys(),
    });
  });
}

handleCheckChange方法定义了勾选状态发生变化后执行的动作。首先调用Node类的setChecked方法,之后触发check事件。看一下文档对check事件的描述:

1.6 NodeContent组件

接着往下看tree-node.vue的模板部分:

此部分模板节点的内容部分的展示效果。最外层div的样式上定义了padding-left属性,层级越深那么则距离左侧越远。然后是span标签用于显示节点的图标,是否展开时展示的不一样(如果是不是叶子节点,没展开则是向右的小三角,展开了则是向下的小三角)。接着是勾选框,如果节点可以勾选则显示勾选框。然后是加载中图标(如下图所示)。

最后是用于显示节点内容的node-content组件,组件的定义在本文件的components中:

NodeContent: {
  props: {
    node: {
      required: true
    }
  },
  render(h) {
    const parent = this.$parent;
    const tree = parent.tree;
    const node = this.node;
    const { data, store } = node;
    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>
   );
  }
}

组件是用render函数定义的,最后的return语句中首先判断父组件tree是否提供了renderContent,如果提供了则优先使用renderContent。如果没有则优先渲染作用域插槽,最后才是span标签中的node.label。

1.7 子节点渲染(这里有亮点)

tree-node模板的最后一部分就是子节点的渲染,模板内容如下:

可以看到在渲染子节点的模板部分中又使用了el-tree-node。看到这里感没感觉挺神奇的,一个组件的子组件尽然是自己,难道这就“递归组件”!为什么可以这么用呢?tree-node.vue中又没有引入自己,难道是tree-node.vue被全局注册了?发现也没有。其实能使用自己的原因很简单,因为父组件tree.vue引用并注册了它,在父组件的作用域中,它就是有效的。

1.8 handleChildNodeExpand方法

handleChildNodeExpand(nodeData, node, instance) {
  this.broadcast('ElTreeNode', 'tree-node-expand', node);
  this.tree.$emit('node-expand', nodeData, node, instance);
},

handleChildNodeExpand处理某个子节点点开时的事件。首先调用混入进来的broadcast,然后由父组件触发node-expand事件。

看一下broadcast方法的定义,位置在src\mixins\emitter.js:

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => {
    var name = child.$options.componentName;

    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
export default {
  methods: {
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    }
  }
};

可以看到broadcast最原始的定义是一个递归方法,检查当前组件的所有子组件,如果子组件的componentName属性和参数componentName一样,则子组件触发eventName事件,参数为params。如果子组件的componentName属性和参数componentName不一样则子组件递归调用broadcast向下查找,以便最终找到能够触发eventName事件的组件。

在tree-node.vue中的created中有如下代码:

if(this.tree.accordion) {
  this.$on('tree-node-expand', node => {
    if(this.node !== node) {
      this.node.collapse();
    }
  });
}

这里首先对树的accordion属性进行了判断,如果为真则每一次只能展开一个同级节点。见下图中的文档说明:

这段代码主要是对tree-node-expand属性监听,判断参数node和当前节点node是不是同一个节点,如果不是则需要折叠当前节点。

至此,tree-node.vue文件的分析就告一段落,还是比较好理解的,下面看tree.vue文件。

2.tree.vue文件分析

tree.vue提供的方法,大都在文档中有所描述。这些方法很多都是调用了tree-store.js中定义的方法,我们逐一分析一下:

2.1 filter方法

filter(value) {
  if (!this.filterNodeMethod) throw new Error('[Tree] filterNodeMethod is required when filter');
  this.store.filter(value);
}

filter方法用于节点过滤。首先检查是否提供了filterNodeMethod,如果没有则报错,如果有则调用tree-store类的filter方法。

2.2 getNodeKey方法

getNodeKey(node) {
  return getNodeKey(this.nodeKey, node.data);
},

getNodeKey获取节点的key值,调用了util.js文件中的方法。主要用于给节点绑定唯一key值使用。

2.3 getNodePath方法

getNodePath(data) {
  if (!this.nodeKey) throw new Error('[Tree] nodeKey is required in getNodePath');
  const node = this.store.getNode(data);
  if (!node) return [];
  const path = [node.data];
  let parent = node.parent;
  while (parent && parent !== this.root) {
    path.push(parent.data);
    parent = parent.parent;
  }
  return path.reverse();
},

getNodePath用于获取节点路径。根据节点路径获取当前节点,然后循环获取当前节点的父节点,放入path数组,然后调用数组的reverse方法。

2.4 getCheckedNodes方法

getCheckedNodes(leafOnly, includeHalfChecked) {
  return this.store.getCheckedNodes(leafOnly, includeHalfChecked);
},

获取已经勾选的节点,通过调用tree-store.js中定义的getCheckedNodes方法实现的。

2.5 getCheckedKeys方法

getCheckedKeys(leafOnly) {
  return this.store.getCheckedKeys(leafOnly);
},

getCheckedKeys用于获取已经勾选节点的key值,通过调用tree-store.js中的getCheckedKeys方法实现。

2.6 getCurrentNode方法

getCurrentNode() {
  const currentNode = this.store.getCurrentNode();
  return currentNode ? currentNode.data : null;
}

getCurrentNode用于获取获取当前被选中节点的 data,若没有节点被选中则返回 null。

2.7 getCurrentKey方法

getCurrentKey() {
  if (!this.nodeKey) throw new Error('[Tree] nodeKey is required in getCurrentKey');
  const currentNode = this.getCurrentNode();
  return currentNode ? currentNode[this.nodeKey] : null;
},

getCurrentKey方法用于获取当前被选中节点的 key,使用此方法必须设置 node-key 属性。若没有节点被选中则返回 null。

2.8 setCheckedNodes方法

setCheckedNodes(nodes, leafOnly) {
  if (!this.nodeKey) throw new Error('[Tree] nodeKey is required in setCheckedNodes');
  this.store.setCheckedNodes(nodes, leafOnly);
},

setCheckedNodes用于设置目前勾选的节点,使用此方法必须设置 node-key 属性。参数nodes为要勾选节点的数组组成的数组,方法在实现上也是通过调用tree-store.js中定义的setCheckedNodes方法。

2.9 setCheckedKeys方法

setCheckedKeys(keys, leafOnly) {
  if (!this.nodeKey) throw new Error('[Tree] nodeKey is required in setCheckedKeys');
  this.store.setCheckedKeys(keys, leafOnly);
}

setCheckedKeys通过 keys 设置目前勾选的节点,使用此方法必须设置 node-key 属性。方法的第一个参数是要设置节点的key数组,第二个参数表示是否只勾选叶子节点,默认为false。方法实现上也是通过调用store-tree.js的setCheckedKeys方法。

2.10 getHalfCheckedNodes方法

getHalfCheckedNodes() {
  return this.store.getHalfCheckedNodes();
},

getHalfCheckedNodes返回目前半选中的节点所组成的数组,实现上调用store-tree.js的getHalfCheckedNodes方法。

2.11 getHalfCheckedKeys方法

getHalfCheckedKeys() {
  return this.store.getHalfCheckedKeys();
}

getHalfCheckedKeys方法用于返回目前半选中的节点的 key 所组成的数组,实现上调用store-tree.js的getHalfCheckedKeys方法。

2.12 setCurrentNode方法

setCurrentNode(node) {
  if (!this.nodeKey) throw new Error('[Tree] nodeKey is required in setCurrentNode');
  this.store.setUserCurrentNode(node);
},

通过 node 设置某个节点的当前选中状态,使用此方法必须设置 node-key 属性,实现上调用store-tree.js的setUserCurrentNode方法。

2.13 setCurrentKey方法

setCurrentKey(key) {
  if (!this.nodeKey) throw new Error('[Tree] nodeKey is required in setCurrentKey');
  this.store.setCurrentNodeKey(key);
}

通过 key 设置某个节点的当前选中状态,实现上调用store-tree.js的setCurrentNodeKey方法。

2.14 getNode方法

getNode(data) {
  return this.store.getNode(data);
}

getNode可以根据 data 或者 key 拿到 Tree 组件中的 node,实现上调用store-tree.js的getNode方法。

2.15 remove方法

remove(data) {
  this.store.remove(data);
}

remove用于删除 Tree 中的一个节点,实现上调用store-tree.js的remove方法。

2.16 append方法

append(data, parentNode) {
  this.store.append(data, parentNode);
}

append为 Tree 中的一个节点追加一个子节点,接收两个参数分别是要追加的子节点的 data 以及 子节点的 parent 的 node,实现上调用store-tree.js的append方法。

2.17 insertBefore方法

insertBefore(data, refNode) {
  this.store.insertBefore(data, refNode);
}

insertBefore用于为 Tree 的一个节点的前面增加一个节点,实现上调用store-tree.js的insertBefore方法。

2.18 insertAfter方法

insertAfter(data, refNode) {
  this.store.insertAfter(data, refNode);
}

insertAfter用于为 Tree 的一个节点的后面增加一个节点,实现上调用store-tree.js的insertAfter方法。

2.19 handleNodeExpand方法

handleNodeExpand(nodeData, node, instance) {
  this.broadcast('ElTreeNode', 'tree-node-expand', node);
  this.$emit('node-expand', nodeData, node, instance);
},

handleNodeExpand定义了节点被展开时触发的事件,首先要调用混入的broadcast方法,以便子组件触发tree-node-expand方法。然后触发node-expand方法。

2.20 updateKeyChildren方法

updateKeyChildren(key, data) {
  if (!this.nodeKey) throw new Error('[Tree] nodeKey is required in updateKeyChild');
  this.store.updateChildren(key, data);
},

updateKeyChildren方法通过 keys 设置节点子元素,参数key是节点的key,data是节点数组的数组。实现上调用store-tree.js的updateChildren方法。

2.21 handleKeydown方法

handleKeydown(ev) {
  const currentItem = ev.target;
  if (currentItem.className.indexOf('el-tree-node') === -1) return;
  const keyCode = ev.keyCode;
  this.treeItems = this.$el.querySelectorAll('.is-focusable[role=treeitem]');
  const currentIndex = this.treeItemArray.indexOf(currentItem);
  let nextIndex;
  if ([38, 40].indexOf(keyCode) > -1) { // up、down
    ev.preventDefault();
    if (keyCode === 38) { // up
      nextIndex = currentIndex !== 0 ? currentIndex - 1 : 0;
    } else {
      nextIndex = (currentIndex < this.treeItemArray.length - 1) ? currentIndex + 1 : 0;
    }
    this.treeItemArray[nextIndex].focus(); // 选中
  }
  if ([37, 39].indexOf(keyCode) > -1) { // left、right 展开
    ev.preventDefault();
    currentItem.click(); // 选中
  }
  const hasInput = currentItem.querySelector('[type="checkbox"]');
  if ([13, 32].indexOf(keyCode) > -1 && hasInput) { // space enter选中checkbox
    ev.preventDefault();
    hasInput.click();
  }
}

handleKeydown定义了键盘按键按下时触发的动作。例如按下right或者left是切换展开和收缩状态,按下空格和回车是切换选中状态。

2.22 created生命周期

下面分析一下生命周期中的内容:

this.$on('tree-node-drag-start', (event, treeNode) => {
  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', '');
  } catch (e) {}
  dragState.draggingNode = treeNode;
  this.$emit('node-drag-start', treeNode.node, event);
});

注册了tree-node-drag-start事件,用于监听子组件拖拽开始的事件并向组件使用者触发node-drag-start。

this.$on('tree-node-drag-over', (event, treeNode) => {
  // 寻找最近的祖先节点
  const dropNode = findNearestComponent(event.target, 'ElTreeNode');
  // 上一次的dropNode
  const oldDropNode = dragState.dropNode;
  // 本次和上一次的进行比较
  if (oldDropNode && oldDropNode !== dropNode) {
    removeClass(oldDropNode.$el, 'is-drop-inner');
  }
  // 正在拖拽的节点
  const draggingNode = dragState.draggingNode;
  if (!draggingNode || !dropNode) return;

  let dropPrev = true;
  let dropInner = true;
  let dropNext = true;
  let userAllowDropInner = true;
  // 检查是否允许拖拽
  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';
  if ((dropPrev || dropInner || dropNext) && oldDropNode !== dropNode) {
    if (oldDropNode) {
      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的后一个兄弟节点nextSibling是正在拖动的节点
  if (dropNode.node.nextSibling === draggingNode.node) {
    dropNext = false;
  }
  // dropNode的前一个兄弟节点previousSibling是正在拖动的节点
  if (dropNode.node.previousSibling === draggingNode.node) {
    dropPrev = false;
  }
  // dropNode包含正在拖动的节点
  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;
  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.dropIndicator;
  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
  dragState.showDropIndicator = dropType === 'before' || dropType === 'after';
  dragState.allowDrop = dragState.showDropIndicator || userAllowDropInner;
  dragState.dropType = dropType;
  // 触发事件
  this.$emit('node-drag-over', draggingNode.node, dropNode.node, event);
});

注册了tree-node-drag-over事件,定义了在拖拽节点时触发的事件。主要逻辑包括更新dragState(一个持有拖拽状态相关的变量)和计算拖拽节点被放置的位置。此处使用了getBoundingClientRect,关于这个API更多内容可以参考 developer.mozilla.org/zh-CN/docs/…

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') {
      draggingNode.node.remove();
    }
    // 根据拖拽类型,插入被拖拽的节点
    if (dropType === 'before') {
      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
  dragState.showDropIndicator = false;
  dragState.draggingNode = null;
  dragState.dropNode = null;
  dragState.allowDrop = true;
});

注册了tree-node-drag-end事件,定义了在拖拽节点结束时触发的事件。结束时主要是确定最终将节点插入到哪里和重置重置dragState。

至此,tree.vue文件就分析完了,整个el-tree组件也分析完了。关于el-tree的实现和使用你有什么心得呢?欢迎留言和我交流~