这篇文章主要分析Element-ui源码中的tree组件源码。
由于tree功能比较多,我决定采用循序渐进的方式来讲述,先介绍tree组件的基本功能,后续功能再一步步叠加上去。
tree组件的基本功能就是展示出一个树结构,点击节点的时候能展开子节点。
1. 思路
我们都知道树结构是需要递归展示的,通过遍历树的子节点,如果当前节点没有子节点(也就是children为null/[]), 就展示节点内容,否则就递归树结构,直到叶子节点。
根据Element-ui的tree组件目录结构(下图所示),我们来分析各个文件该怎么写。
tree.vue文件中写的的整棵树组件, tree-node.vue是树节点组件, node.js文件写的是树节点类,tree-store.js写的是整棵树类。
1.1 tree.vue
<template>
<div
class="el-tree"
role="tree"
>
<el-tree-node
v-for="child in root.childNodes"
:node="child"
:props="props"
:render-after-expand="renderAfterExpand"
:key="getNodeKey(child)"
:render-content="renderContent"
@node-expand="handleNodeExpand">
</el-tree-node>
<div class="el-tree__empty-block" v-if="isEmpty">
<span class="el-tree__empty-text">{{ emptyText }}</span>
</div>
</div>
</template>
<script>
import emitter from '@/mixin/emitter';
import TreeStore from './model/tree-store';
import { getNodeKey } from './model/util';
import ElTreeNode from './tree-node.vue';
export default {
name: 'ElTree',
mixins: [emitter],
components: {
ElTreeNode,
},
data() {
return {
store: null,
root: null,
currentNode: null,
};
},
props: {
data: {
type: Array,
},
emptyText: {
type: String,
default() {
return '暂无数据';
},
},
renderAfterExpand: {
type: Boolean,
default: true,
},
nodeKey: String,
expandOnClickNode: {
type: Boolean,
default: true,
},
renderContent: Function,
props: {
default() {
return {
children: 'children',
label: 'label',
disabled: 'disabled',
};
},
},
indent: {
type: Number,
default: 18,
},
iconClass: String,
},
computed: {
isEmpty() {
const { childNodes } = this.root;
return !childNodes || childNodes.length === 0;
},
},
watch: {
data(newVal) {
this.store.setData(newVal);
},
},
methods: {
getNodeKey(node) {
return getNodeKey(this.nodeKey, node.data);
},
handleNodeExpand(nodeData, node, instance) {
this.broadcast('ElTreeNode', 'tree-node-expand', node);
this.$emit('node-expand', nodeData, node, instance);
},
},
created() {
this.isTree = true;
this.store = new TreeStore({
key: this.nodeKey,
data: this.data,
props: this.props,
});
this.root = this.store.root;
console.log('root===', this.root);
},
};
</script>
tree.vue文件中引入了tree-node.vue文件,并根据传过来的data数据遍历子节点,并接收了一个node-expand(节点展开)事件。在created生命周期中定义了一个TreeStore类进行树结构的初始化。接下来我们看下tree-store.js文件是如何初始化树结构的。
1.2 tree-store.js
话不多说,先贴代码。
import Node from './node';
export default class TreeStore{
constructor(options) {
for (let option in options) {
if (options.hasOwnProperty(option)) {
this[option] = options[option];
}
}
this.root = new Node({
data: this.data,
store: this
});
}
};
在tree-store.js文件中遍历传过来的对象,并把对象的值赋值为当前类变量。然后定义了一个Node类实例。那么我们接下来的重点就是关注node.js文件实现。
1.3 node.js
node.js文件内容比较多,我们根据类的执行顺序来描述。首先看下类的构造函数。
constructor(options) {
this.id = nodeIdSeed++;
this.data = null;
this.expanded = false;
this.parent = null;
for (let name in options) {
if (options.hasOwnProperty(name)) {
this[name] = options[name];
}
}
// internal 内部的
this.level = 0;
this.childNodes = [];
if (this.parent) {
this.level = this.parent.level + 1;
}
const store = this.store;
if (!store) {
throw new Error('[Node]store is required!');
}
const props = store.props;
if (props && typeof props.isLeaf !== 'undefined') {
const isLeaf = getPropertyFromData(this, 'isLeaf');
if (typeof isLeaf === 'boolean') {
this.isLeafByUser = isLeaf;
}
}
if (store.lazy !== true && this.data) {
this.setData(this.data);
}
if (!Array.isArray(this.data)) {
markNodeData(this, this.data);
}
if (!this.data) return;
this.updateLeafState();
}
构造函数里面定义了一些类变量,并根据传过来的data定义树结构(这个树结构可以访问父节点、子节点、兄弟节点,是一棵真正意义上的树)。
接下来我们看下树结构的构造,也就是看下setData是怎么实现的。
setData(data) {
if (!Array.isArray(data)) {
markNodeData(this, data);
}
this.data = data;
this.childNodes = [];
// 获取children
let children;
if (this.level === 0 && this.data instanceof Array) {
children = this.data;
} else {
children = getPropertyFromData(this, 'children') || [];
}
// 遍历children 数组,把值插进去
for(let i = 0, j = children.length; i < j; i++) {
this.insertChild({ data: children[i] });
}
}
insertChild(child, index, batch) {
// 对child 判空
if (!child) throw new Error('insertChild error: child is required');
// 如果当前child 不是 Node 实例
if (!(child instanceof Node)) {
if (!batch) {
// 获取当前树节点的子节点数据
const children = this.getChildren(true) || [];
if (children.indexOf(child.data) === -1) {
if (typeof index === 'undefined' || index < 0) {
// push child数据
children.push(child.data);
} else {
children.splice(index, 0, child.data);
}
}
}
objectAssign(child, {
parent: this, // 设置parent
store: this.store, // 设置treeData
});
// 初始化类的时候会重新执行构造函数 会执行setData, setData又会insertChild, 形成递归
child = new Node(child);
}
// 层级+1
child.level = this.level + 1;
// 设置childNodes
if (typeof index === 'undefined' || index < 0) {
this.childNodes.push(child);
} else {
this.childNodes.splice(index, 0, child);
}
// 更新叶子节点状态
this.updateLeafState();
}
setData方法中获取了当前节点的children数组,并进行循环插入child数据。注意insertChild方法中在进行插入数据的时候重新初始化一个Node类实例,这样的话程序又会执行构造函数,然后再执行setData方法进行child数据插入,这样就形成了递归,把所有的数据都插入到树结构中。注意在插入数据的时候更新叶子节点状态(判断是否为叶子节点)。
updateLeafState() {
const childNodes = this.childNodes;
if (!this.store.lazy) {
this.isLeaf = !childNodes || childNodes.length === 0;
return;
}
this.isLeaf = false;
}
childNodes的长度为0(也就是没有子节点)就是叶子节点,否则就是非叶子节点。
1.4 tree-node.vue
准备工作都做好了,接下来就需要渲染树节点了。 我们先看html部分
<template>
<div
class="el-tree-node"
@click.stop="handleClick"
:class="{
'is-expanded': expanded,
}"
role="treeitem"
tabindex="-1"
:aria-expanded="expanded"
ref="node"
>
<!-- 节点内容 -->
<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>
<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"
:key="getNodeKey(child)"
:node="child"
@node-expand="handleChildNodeExpand"
>
</el-tree-node>
</div>
</el-collapse-transition>
</div>
</template>
上述代码递归渲染出树结构,有一个地方需要注意一下,递归树的时候v-if="!renderAfterExpand || childNodeRendered"和v-show="expaned"同时使用,v-if的优先级是大于v-show的,也就是说!renderAfterExpand || childNodeRendered为false的时候无论expaned为什么值都是不展示的。
其实这样设计还挺巧妙的,因为renderAfterExpand默认为true, 取反就是false, childNodeRendered表示子节点是否渲染完毕,当遍历到叶子节点的时候childNodeRendered为false, 直接终止递归,此时也不用考虑expaned的值为什么。也就是说先判断是否需要递归展示子节点,然后再根据expaned值来设置元素的显隐,用v-show设置显隐不用频繁操作dom, 能避免性能消耗。
元素是渲染完毕了,接下来就需要监听树节点的展开收缩来设置树的子节点显隐了。
watch: {
'node.expanded': function (val) {
this.$nextTick(() => this.expanded = val);
if (val) {
this.childNodeRendered = true;
}
},
},
methods: {
handleClick() {
const { store } = this.tree;
if (this.tree.expandOnClickNode) {
this.handleExpandIconClick();
}
this.tree.$emit('node-click', this.node.data, this.node, this);
},
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);
}
},
handleChildNodeExpand(nodeData, node, instance) {
this.broadcast('ElTreeNode', 'tree-node-expand', node);
this.tree.$emit('node-expand', nodeData, node, instance);
},
},
这里我只截取一些关键代码,在树节点点击的时候触达handleExpandIconClick事件的执行,其中执行收缩和展开的代码是this.node.collapse();和this.node.expand();
注意:此时this.node表示的是Node类实例,接下来查看 node.js文件实现部分
expand() {
this.expanded = true;
}
collapse() {
this.expanded = false;
}
展开的时候设置为true, 收缩的时候设置为false。
当node.expanded值更改时,tree-node.vue里面监听了这个值的变化,并设置当前组件实例数据expanded为对应的值,如果是展开的话,把childNodeRendered的值设置为true(因为v-if的优先级大于v-show, 所以也需要设置childNodeRendered值为true)。
这样的话树结构展开收起的功能也实现了。
总结:要善于用类抽象出对应的数据模型,每个类专注于自己的实现,类之间也要有所关联。
有什么不对的地方,还请大家指正哦~~~
下一节我们就讲述一下树的懒加载功能实现