Element源码之tree组件分析

625 阅读2分钟

这篇文章主要分析Element-ui源码中的tree组件源码。 由于tree功能比较多,我决定采用循序渐进的方式来讲述,先介绍tree组件的基本功能,后续功能再一步步叠加上去。

tree组件的基本功能就是展示出一个树结构,点击节点的时候能展开子节点。

1. 思路

我们都知道树结构是需要递归展示的,通过遍历树的子节点,如果当前节点没有子节点(也就是children为null/[]), 就展示节点内容,否则就递归树结构,直到叶子节点。

根据Element-uitree组件目录结构(下图所示),我们来分析各个文件该怎么写。

目录结构.png 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 || childNodeRenderedfalse的时候无论expaned为什么值都是不展示的。 其实这样设计还挺巧妙的,因为renderAfterExpand默认为true, 取反就是false, childNodeRendered表示子节点是否渲染完毕,当遍历到叶子节点的时候childNodeRenderedfalse, 直接终止递归,此时也不用考虑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)。

这样的话树结构展开收起的功能也实现了。

总结:要善于用类抽象出对应的数据模型,每个类专注于自己的实现,类之间也要有所关联。

有什么不对的地方,还请大家指正哦~~~

下一节我们就讲述一下树的懒加载功能实现