本文为分析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的实现和使用你有什么心得呢?欢迎留言和我交流~