Skip to content

自定义级联组件

背景

机构数据+账户数据使用一个简单的级联机构可以实现联动,某天产品给出了一户两地的需求,如下:


对应的数据结构概览如下:

  • 第一级为机构(organId + organName
  • 第二级为账户(accountUser 资金账号 + accountName 账户名称) 一个资金账号可以归属于不同的机构,一个资金账号可以对应多个账户名称,账户名称通过字符串拼接。 organId 可以作为第一层级的唯一键,第二层级的唯一键需要自行构建,可以使用索引。
机构和账户级联结构
├── 机构1 (organ1)
│   ├── 资金账号1 (账户名称1+账户名称2)
│   └── 资金账号2 (账户名称3+账户名称4)
├── 机构2 (organ2)
│   ├── 资金账号3 (账户名称1+账户名称2)
│   └── 资金账号4 (账户名称1)
└── 机构3 (organ3)
    ├── 资金账号5 (账户名称1+账户名称2+账户名称3)
    └── 资金账号6 (账户名称1+账户名称2)
层级 1层级 2
机构 1资金账号 1 (账户名称 1+账户名称 2)

除了自定义的数据结构,一个机构下的资金账号可达上千条,性能优化也需考虑。基于elementui v2.15.13版本的cascader组件只提供了自定义默认面板节点,不支持自定义建议面板节点;并且只支持动态加载,不适用于同层级下上千条的数据量。

需求拆解

  1. 正常选中某项时,输入框中需要回显资金账号下的所有账户
  2. 二级节点是资金账号+该资金账号下的所有账户名称
  3. 模糊搜索时需要做匹配高亮效果,分两种情况回显
    • 只要匹配到资金账号(优先级更高),输入框回显拼接后的账户名称
    • 匹配到某个(些)账户名称,输入框展示对应的账户名称
  4. 选中后的级联节点高亮,分两种情况高亮
    • 模糊搜索后匹配到资金账号,节点的账户名称都高亮
    • 模糊搜索后匹配到的是账户名称,对应的账户名称高亮

级联组件本身就比较复杂,如果再加上一些自定义功能或者魔改组件,pr 也不管用了。因此只能在对 element-ui的组件进行二次修改。二次修改也有以下两种方式:

  1. 复制element-ui 的代码,然后新增功能后,发布到私服组件库
  2. 如果公司没有维护组件库,也可以在业务代码里,将 node_modules/element-ui 中的 cascader 组件拖到业务文件中,然后新增功能,形成自定义组件 为了演示方便以及重点在于如何在已有级联组件上新增功能,直接新建项目 vue-el-cascader。对应仓库地址:https://github.com/murph-1999/vue-el-cascader

由于公司已经维护私服组件库,并且该业务组件需要在多个项目中复用,因此选择第一种方式。

执行开发

复制 element-ui cascader 相关源码

  1. 创建一个新项目,作为自定义级联组件,后续进行发布package.json 如下,需要指定 element-ui 作为 peerDependencies
json
{
  "name": "vue-el-cascader",
  "version": "0.1.0",
  "private": false,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "lib": "vue-cli-service build --target lib --name vue-el-cascader --dest lib packages/index.js"
  },
  "dependencies": {
    "core-js": "^3.8.3",
    "vue": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3",
    "vue-template-compiler": "^2.6.14"
  },
  "peerDependencies": {
    "element-ui": "^2.15.14",
    "vue": "^2.5.17"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}
  1. 复制源码中的 packages 中的 cascadercascader-panel 组件,目录结构如下:


    其中 index.js 入口文件的内容包括导出所有组件等,组件名称重新定义为VueElCascaderVueElCascaderPanel

js
import VueElCascader from './cascader/index'
import VueElCascaderPanel from './cascader-panel/index'
const components = [VueElCascader, VueElCascaderPanel]
const install = function (Vue) {
  components.forEach(component => {
    Vue.component(component.name, component);
  });
}

if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}
export default {
  version: '0.1.0',
  install,
  VueElCascader,
  VueElCascaderPanel
}
  1. cascader.vue 中对 cascader-panel.vue 的引用改成复制后的的 cascader-panel 路径:


  2. 创建 examples 文件,引入组件进行测试以及远程数据的模拟。

js
<vue-el-cascader
   v-model="value"
   :props="props"
   filterable
   collapse-tags
    :show-all-levels="false"
    >
</vue-el-cascader>
  1. npm run serve 运行项目 直接运行时会发现/element-ui/packages/scrollbar.vue这样的报错:


    这是因为该文件包含 e6+代码,需要被转译才能在某些浏览器中正常工作,因此可以指定transpileDependencies确保转译。具体解决方式可参考https://github.com/ElemeFE/element/issues/14379

js
<!-- vue.config.js -->
module.exports = {
  transpileDependencies: [
    'element-ui/packages'
  ],
  // 其他配置...
}

完成了项目的初始搭建,接下来就要结合需求自定义功能了。

自定义节点高亮

包括默认展示面板节点和建议节点面板,一般考虑搜索面板中搜索节点的高亮匹配,默认面板的高亮匹配在自定义的场景下设置。而目前原生组件只支持节点面板的自定义,不支持建议面板的自定义,因此需要重写建议面板高亮节点展示组件highlight.vue

确定传参

参数说明类型
text节点原文本String
inputValue搜索文本String

正则匹配逻辑

使用正则切分节点原完整文本后并赋予 highlight 标识来区分高亮展示。

vue
<template>
  <span>
    <template v-for="(item, index) in highlightedText">
      <span v-if="item.highlight" :key="index" class="highlighted"
        >{{ item.text }}</span
      >
      <span v-else :key="index">{{ item.text }}</span>
    </template>
  </span>
</template>

<script>
  export default {
    name: "Highlight",
    props: {
      text: {
        type: String,
        required: true,
      },
      inputValue: {
        type: String,
        required: true,
      },
    },
    computed: {
      highlightedText() {
        if (!this.inputValue) {
          return [{ text: this.text, highlight: false }];
        }

        const regex = new RegExp(this.escapeRegExp(this.inputValue), "gi");
        const parts = this.text.split(regex);
        const matches = this.text.match(regex);

        const result = [];
        for (let i = 0; i < parts.length; i++) {
          if (parts[i]) {
            result.push({ text: parts[i], highlight: false });
          }
          if (matches && matches[i]) {
            result.push({ text: matches[i], highlight: true });
          }
        }

        return result;
      },
    },
    methods: {
      escapeRegExp(string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      },
    },
  };
</script>

<style scoped>
  .highlighted {
    font-weight: bold;
    color: #409eff;
  }
</style>

插入组件

cascader.vue 文件建议面板中部分代码如下:

html
<el-scrollbar
  ref="suggestionPanel"
  v-if="filterable"
  v-show="filtering"
  tag="ul"
  class="el-cascader__suggestion-panel"
  view-class="el-cascader__suggestion-list"
  @keydown.native="handleSuggestionKeyDown"
>
  <template v-if="suggestions.length">
    <li
      v-for="(item, index) in suggestions"
      :key="item.uid"
      :class="[
                'el-cascader__suggestion-item',
                item.checked && 'is-checked',
              ]"
      :tabindex="-1"
      @click="handleSuggestionClick(index)"
    >
      <!-- old -->
      <!-- <span>{{ item.text }}</span> -->

      <!-- add by Murphy, highlight text-->
      <highlight :inputValue="filterValue" :text="item.text"></highlight>
      <i v-if="item.checked" class="el-icon-check"></i>
    </li>
  </template>
  <slot v-else name="empty">
    <li class="el-cascader__empty-text">{{ t("el.cascader.noMatch") }}</li>
  </slot>
</el-scrollbar>

原组件中没有保存用户输入的搜索文本,因此这里 data 中维护一个变量 filterValue暂存搜索文本:

js
 data() {
    return {
      // ...
      filterValue: "", // add by Murphy, temporarily store a search keyword
    };
  },

getSuggestions方法中将inputValue赋值给filterValue

js
 getSuggestions() {
      let { filterMethod } = this;

      if (!isFunction(filterMethod)) {
        filterMethod = (node, keyword) => node.text.includes(keyword);
      }
      // add by Murphy
      this.filterValue = this.inputValue;
      // ...
 }

这里需要注意的一点是,输入框清空操作中也要将filterValue清空。

js
handleClear() {
  this.presentText = "";
  this.filterValue = "";
  this.panel.clearCheckedNodes();
},

至此实现高亮匹配效果如下:


动态加载和滚动加载

一次性选择渲染所有子节点在大数据量情景下是不可取的,千级别的数据量下需要注重性能。因此组件本身提供的动态加载会大大提升性能。这还不够,如果某个层级下有几千个数据,当展开时依旧需要同时渲染对应的数据,因此这里引入滚动分页加载。

在添加新功能之前先梳理一遍动态加载 lazyLoad 的参数和执行过程。

参数说明类型
lazyLoad加载动态数据的方法,仅在 lazy 为 true 时有效function(node, resolve),node 为当前点击的节点,resolve 为数据加载完成的回调(必须调用)
null

定义用户 lazyLoad

由于该动态加载融入了滚动加载,因此给节点添加了三个属性如下:

js
Node {
  //...
  currentPage: number; // 标识当前加载的子节点页码
  total: number; // 标识当前层级的总子节点数
  isEnd: number; // 标识当前是否加载完子节点
}

动态加载和滚动加载可以共用一套加载数据的方法,因为动态加载相当于是加载第一页,而滚动加载是加载更多页,只需要控制页数即可。

js
props: {
  lazy: true,// 动态加载时必须指定
  // 动态加载
  async lazyLoad(node, resolve) {
    const { isLeaf } = node;
    if (isLeaf) return resolve([]);

    const isRoot = node.root;
    setTimeout(async () => {
      // 动态加载第一页数据
      const { children } = _this.onLoadData(node, isRoot, 1);
      resolve(children);
    }, 200);
  },
},

对应的onLoadData方法如下:

js
onLoadData(node, isRoot, page) {
  const nodeData = isRoot ? this.rootNodeData : node.data;
  if (nodeData.isEnd) return { children: [] };

  const parentId = isRoot ? null : nodeData.value;
  if (page) {
    nodeData.currentPage = page;
  } else {
    nodeData.currentPage++;
  }
  const { data: children, total } = getData(
    parentId,
    nodeData.currentPage,
    PAGE_SIZE
  );

  // 更新结束标志
  const currentTotal = isRoot
    ? nodeData.children.length
    : node.children.length;
  nodeData.isEnd = currentTotal + children.length >= total;
  nodeData.total = total;
  if (isRoot) {
    nodeData.children = nodeData.children.concat(children);
  }
  return { children };
}

这里为了标识第一层节点的加载,维护了一个根节点对象。

js
rootNodeData: {
  label: "",
  value: "",
  children: [],
  currentPage: 0,
  isEnd: false,
  total: 0
},

这个的 mock 数据来源于 getData 方法:

js
export const PAGE_SIZE = 10;
// mock 原始数据
const data = Array.from(
  {
    length: 100,
  },
  (item, index) => ({
    label: `机构${index + 1}`,
    value: index + 1,
    total: 1000,
    children: Array.from(
      {
        length: 1000,
      },
      (subItem, subIndex) => ({
        label: `账户名称${index + 1}-${subIndex + 1}`,
        value: `${index + 1}-${subIndex + 1}`,
        leaf: true,
        parent: index + 1,
        disabled: subIndex == 14,
      })
    ),
  })
);

export function getData(parentId, pageNum, pageSize) {
  // parentId 为空
  if (!parentId) {
    // 直接从 data 第一维度 截取数据
    const startIndex = (pageNum - 1) * pageSize;
    const endIndex = pageNum * pageSize;
    return {
      total: data.length,
      data: data.slice(startIndex, endIndex).map((v) => ({
        ...v,
        // 不设置为空,则使用的是原数据的children,影响内部计算,所以第一层一定是[],因为是懒加载,且也不应该纳入计算
        children: [],
      })),
    };
  }
  const parentIndex = parentId - 1;
  if (parentIndex >= 0 && parentIndex < data.length) {
    const parent = data[parentIndex];
    if (parent.children) {
      const startIndex = (pageNum - 1) * pageSize;
      const endIndex = pageNum * pageSize;
      return {
        total: parent.children.length,
        data: parent.children.slice(startIndex, endIndex),
      };
    }
  }
  return {
    total: 0,
    data: [],
  };
}

监听滚动

cascader-panel.vue加载后设置滚动监听,包括防抖、触底阈值、节点去重等处理。

js
bindScrollbarListener() {
  this.$nextTick(() => {
    if (this.$refs.scrollbar.override) {
      return;
    }

    let scrollTimer = null;
    let isLoading = false; // 添加加载标志位
    const THRESHOLD = 10; // 设置10px的阈值
    const DEBOUNCE_DELAY = 300; // 200ms的防抖延迟

    this.$refs.scrollbar.handleScroll = () => {
      const wrap = this.$refs.scrollbar.wrap;
      this.$refs.scrollbar.moveY =
        (wrap.scrollTop * 100) / wrap.clientHeight;
      this.$refs.scrollbar.moveX =
        (wrap.scrollLeft * 100) / wrap.clientWidth;

      // 计算到底部的距离
      const scrollBottom =
        wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;

      if (scrollBottom <= THRESHOLD && !isLoading) {
        // 清除之前的定时器
        if (scrollTimer) {
          clearTimeout(scrollTimer);
        }

        // 添加防抖
        scrollTimer = setTimeout(() => {
          isLoading = true; // 开始加载,设置标志位
          let parentNode = this.nodes[0] && this.nodes[0].parent;
          const resolve = (data) => {
            // 无数据
            if (isEmpty(data)) {
              isLoading = false; // 重置加载状态
              return;
            }

            // append当前父节点中不存在的节点到menu中
            let loadedVals,
              toAppendData = [];
            // 第一层节点
            if (!parentNode) {
              loadedVals = this.nodes.map((n) => n.getValue());
            } else {
              loadedVals = parentNode.children.map((n) => n.getValue());
            }
            toAppendData = data.filter(
              (d) => !loadedVals.includes(d[this.panel.config.value])
            );
            if (!toAppendData.length) {
              isLoading = false; // 重置加载状态
              return;
            }
            toAppendData.forEach((d) => {
              this.panel.store.appendNode(d, parentNode);
            });

            // 计算一次展示值
            this.$parent.$parent.computePresentContent();
            // 同步checkedValue到节点checked
            this.panel.syncMultiCheckState();
            // 加载完成,重置状态
            isLoading = false;
          };
          this.$emit("menu-scroll-bottom", parentNode, resolve);
        }, DEBOUNCE_DELAY);
      }
    };
    this.$refs.scrollbar.override = true;
  });
},

问题

当用户指定默认选中的value时,加载到对应数据时并不会立即勾选上,因此可以手动触发一次同步 check状态和修改当前输入框文本,修改cascader-panel.vue中的lazyLoad方法:

js
lazyLoad(node, onFullfiled) {
  // ...
  const resolve = (datalist) => {
    const parent = node.root ? null : node;
    dataList && dataList.length && this.store.appendNodes(dataList, parent);

    // add by Murphy,初始展示懒加载的节点时,设置对应v-model 节点value值的节点状态
    this.syncMultiCheckState();
    // add by Murphy,对应修改输入框展示文本
    this.$parent.computePresentContent();
    // ...
  }
}

远程搜索

如果使用了动态加载和滚动加载,filterMethod无法从全量节点中进行筛选,因此必须加入远程搜索。和lazyLoad类似,在props新增一个属性remoteMethod,传入自定义的远程搜索方法,将搜索结果中已加载节点不存在appendstore中。

js
mounted(){
  // ...
  this.filterHandler = debounce(this.debounce, () => {
    const { inputValue } = this;

    if (!inputValue) {
      this.filtering = false;
      return;
    }
    /**
     * add by Murphy
     * 1.远程搜索,将搜索结果中已加载节点不存在的,append到store中
     * 2.调用getSuggestions并update popper
     */
    const { remoteMethod } = this.config;
    if (this.remote && remoteMethod) {
      this.suggestionLoading = true;
      remoteMethod(inputValue, this.remoteSearchResolve(inputValue));
      return;
    }
  // ...
});
}

对应的的回调:

js
remoteSearchResolve(inputValue) {
  const resolve = (data) => {
    this.suggestionLoading = false;
    if (isEmpty(data)) {
      this.getSuggestions();
      return;
    }
    // 1.获取已经加载的节点value list
    const loadedFlattedNodesVals = this.panel
      .getFlattedNodes(false)
      .map((n) => n.value);

    // 2.从data中过滤掉出没加载的数据, 并append到store
    const toAppendData = data.filter(
      (d) => !loadedFlattedNodesVals.includes(d[this.panel.config.value])
    );
    // 2.1 如果没有待append的数据,说明用户给的data都是已经加载过的,那么emit触底事件
    if (toAppendData.length == 0) {
      this.$nextTick(() => {
        this.$emit("suggestion-scroll-bottom", inputValue, resolve);
      });
    }
    toAppendData.forEach((d) => {
      // append into store
      const parentVal = d.parent;
      const parentNode = this.panel.store.getNodeByValue(parentVal);
      parentNode && this.panel.store.appendNode(d, parentNode);
    });
    // 3.根据checkedValue同步多选状态(用户一开始就传入了选中的value)
    toAppendData.length && this.panel.syncMultiCheckState();

    // 3.调用前端搜索方法
    this.getSuggestions();

    this.$nextTick(() => {
      this.$refs.suggestionPanel.update();
    });
  };
  return resolve;
}

源组件内部维护一个远程搜索时loading属性``

js
data(){
  return{
    suggestionLoading: false, // add by Murphy, show suggestion loading
  }
}

用户使用时在props属性中传入自定义的remoteMethod

js
remoteMethod(query, resolve) {
  if (query !== "") {
    this.searchCurrentPage = 1;
    setTimeout(() => {
      const res = searchData(query, this.searchCurrentPage, PAGE_SIZE);
      resolve(res.data);
    }, 0);
  }
}

同样这里也维护了分页,远程搜索面板也需要滚动搜索;使用searchData方法通过递归从级联数据中查找数据:

js
// 递归函数,用于搜索并返回符合条件的叶子节点
function searchLeaves(node, searchString, results) {
  if (node.children) {
    node.children.forEach(child => {
      searchLeaves(child, searchString, results);
    });
  } else {
    if (searchString === "" || node.label.includes(searchString)) {
      results.push(node);
    }
  }
}
// 根据搜索字符串在树结构中查找符合条件的叶子节点,并返回扁平化的数据
export function searchData(searchString, pageNum, pageSize) {
  const results = [];
  let startIndex = (pageNum - 1) * pageSize;
  let endIndex = pageNum * pageSize;

  data.forEach(node => {
    searchLeaves(node, searchString, results);
  });

  const paginatedResults = results.slice(startIndex, endIndex);

  return {
    total: results.length,
    data: paginatedResults
  };
}

在建议面板中也需要设置滚动监听:

js
watch: {
  filtering(val) {
    this.$nextTick(() => {
      this.updatePopper();
      // add by Murphy, listen for panel scrolling
      val && this.bindScrollbarListener();
    });
  }
}
// ...
methods:{
  bindScrollbarListener() {
    if (this.$refs.suggestionPanel.override) {
      return;
    }

    let scrollTimer = null;
    const THRESHOLD = 10; // 设置10px的阈值
    const DEBOUNCE_DELAY = 200; // 200ms的防抖延迟

    this.$refs.suggestionPanel.handleScroll = () => {
      const wrap = this.$refs.suggestionPanel.wrap;
      this.$refs.suggestionPanel.moveY =
        (wrap.scrollTop * 100) / wrap.clientHeight;
      this.$refs.suggestionPanel.moveX =
        (wrap.scrollLeft * 100) / wrap.clientWidth;

      // 计算到底部的距离
      const scrollBottom =
        wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;

      if (scrollBottom <= THRESHOLD) {
        // 清除之前的定时器
        if (scrollTimer) {
          clearTimeout(scrollTimer);
        }
        // 添加防抖
        scrollTimer = setTimeout(() => {
          this.$emit(
            "suggestion-scroll-bottom",
            this.inputValue,
            this.remoteSearchResolve(this.inputValue)
          );
        }, DEBOUNCE_DELAY);
      }
    };
    this.$refs.suggestionPanel.override = true;
 }
}

虚拟列表

动态加载和滚动加载主要解决了一次性数据量过大时渲染卡顿的问题,同时按需请求节省带宽和服务器资源。但是在极端情景下,某一层级数据量很大且滚动加载了所有节点,那么浏览器渲染大量 DOM 节点依旧存在卡顿,内存暴涨,这时可以引入虚拟列表,保证无论数据量多大,始终只渲染可视区域的几十条数据,体验极佳。这里借助vue-virtual-scroll-list实现。 实现步骤:

  1. cascader-menu中添加vue-virtual-scroll-list
  2. 创建一个vue-virtual-scroll-list需要的data-component组件
  3. cascader-panel中改写自动滚动的方法

第一步开始改造渲染节点的逻辑,cascader-menu中通过renderNodeList渲染节点。

js
renderNodeList(h) {
  const { menuId } = this;
  const { isHoverMenu } = this.panel;
  const events = { on: {} };

  if (isHoverMenu) {
    events.on.expand = this.handleExpand;
  }

  const nodes = this.nodes.map((node, index) => {
    const { hasChildren } = node;
    return (
      <cascader-node
        key={ node.uid }
        node={ node }
        node-id={ `${menuId}-${index}` }
        aria-haspopup={ hasChildren }
        aria-owns = { hasChildren ? menuId : null }
        { ...events }></cascader-node>
    );
  });

  return [
    ...nodes,
    isHoverMenu ? <svg ref='hoverZone' class='el-cascader-menu__hover-zone'></svg> : null
  ];
}
js
renderNodeList(h) {
  const {
    menuId,
    nodes
  } = this;
  const { isHoverMenu, config } = this.panel;
  const events = { on: {} };

  if (isHoverMenu) {
    events.on.expand = this.handleExpand;
  }

  this.virtualListProps.menuId = menuId;
  const nodeItems = nodes.map((node, index) => {
    const { hasChildren } = node;
    return (
      <cascader-node
        key={node.uid}
        node={node}
        node-id={`${menuId}-${index}`}
        aria-haspopup={hasChildren}
        aria-owns={hasChildren ? menuId : null}
        {...events}
      ></cascader-node>
    );
  });

  return [
    config.virtualScroll ? (
      <virtual-list
        style={{ height: "200px", overflowY: "auto" }}
        ref="virtualList"
        class="el-cascader-menu__virtual-list"
        data-key="uid"
        data-sources={nodes}
        extra-props={this.virtualListProps}
        data-component={virtualListItem}
      ></virtual-list>
    ) : (
      [...nodeItems]
    ),
    isHoverMenu ? (
      <svg ref="hoverZone" class="el-cascader-menu__hover-zone"></svg>
    ) : null,
  ];
}

通过判断config.virtualScroll来确认是否渲染virtual-listvirtualScroll在使用组件时绑定的props中指定。

js
props: {
  // ...
  virtualScroll: true, //开启虚拟列表
}

virtual-list组件需要传递的主要参数和说明如下:

参数说明类型
data-key数据源的唯一键String
data-sources数据源Array[Object]
data-component子项组件Component
extra-props向每个渲染的子项组件额外传递自定义的 props 属性Object

这里维护的virtualListProps包含menuId,通过使用extra-props 使每个渲染出来的子项组件都会自动收到 menuId 这个 prop。这个menuId面板id用来构建节点的唯一标识。

对应render中也需要区分原面板渲染方式:

js
render(h) {
  const { isEmpty, menuId } = this;
  const events = { nativeOn: {} };
  const { config } = this.panel;

  // optimize hover to expand experience (#8010)
  if (this.panel.isHoverMenu) {
    events.nativeOn.mousemove = this.handleMouseMove;
    // events.nativeOn.mouseleave = this.clearHoverZone;
  }

  return config.virtualScroll ? (
    <div class="el-cascader-menu" style={{ position: "relative" }}>
      {isEmpty ? this.renderEmptyText(h) : this.renderNodeList(h)}
    </div>
  ) : (
    <el-scrollbar
      ref="scrollbar"
      tag="ul"
      role="menu"
      id={menuId}
      class="el-cascader-menu"
      wrap-class="el-cascader-menu__wrap"
      view-class={{
        "el-cascader-menu__list": true,
        "is-empty": isEmpty,
      }}
      {...events}
    >
      {isEmpty ? this.renderEmptyText(h) : this.renderNodeList(h)}
    </el-scrollbar>
  );
}

第二步是创建一个vue-virtual-scroll-list需要的data-component组件。

js
<script>
import CascaderNode from "./cascader-node.vue";

export default {
  name: "ElCascaderVirtualScrollItem",
  components: { CascaderNode },
  props: {
    index: {
      // index of current item
      type: Number,
    },
    source: {
      // here is: {uid: 'unique_1', text: 'abc'}
      type: Object,
      default() {
        return {};
      },
    },
    menuId: {
      type: String,
      default() {
        return "";
      },
    },
  },

  render(h) {
    const { source, menuId, index } = this;
    return (
      <cascader-node
        key={source.id}
        node={source}
        node-id={`${menuId}-${index}`}
        aria-haspopup={source.hasChildren}
        aria-owns={source.hasChildren ? menuId : null}
      ></cascader-node>
    );
  },
};
</script>

最后需要在cascader-panel改写滚动方法scrollIntoView,依旧是识别用户指定的virtualList

js
scrollIntoView() {
  if (this.$isServer) return;
  const menus = this.$refs.menu || [];
  menus.forEach((menu) => {
    if (this.config.virtualScroll) {
      let currentNodeIndex = -1;
      menu.nodes.find((item, index) => {
        let flag = item.inActivePath;
        flag && (currentNodeIndex = index);
        return flag;
      });
      if (currentNodeIndex !== -1) {
        menu.$refs.virtualList &&
          menu.$refs.virtualList.scrollToIndex(currentNodeIndex);
      } else {
        currentNodeIndex = -1;
        menu.nodes.find((item, index) => {
          let flag = false;
          if (this.config.multiple) {
            flag = item.checked || item.indeterminate;
          } else {
            // 如果是单选,得区分emitPath
            // 因为emitPath为true时,this.checkValue是数组
            // 为false时,是字符串
            if (this.config.emitPath) {
              flag =
                Array.isArray(this.value) &&
                this.value.includes(item.value);
            } else {
              flag = this.value === item.value;
            }
          }
          flag && (currentNodeIndex = index);
          return flag;
        });
        menu.$refs.virtualList && currentNodeIndex === -1
          ? menu.$refs.virtualList.reset()
          : menu.$refs.virtualList.scrollToIndex(currentNodeIndex);
      }
    } else {
      // 原逻辑
      const menuElement = menu.$el;
      if (menuElement) {
        const container = menuElement.querySelector(".el-scrollbar__wrap");
        const activeNode =
          menuElement.querySelector(".el-cascader-node.is-active") ||
          menuElement.querySelector(".el-cascader-node.in-active-path");
        scrollIntoView(container, activeNode);
      }
    }
  });
}

方案对比

这里可以区分下【动态加载+滚动加载】和【虚拟列表】两个方案,讨论下使用场景和局限。

先说【动态加载+滚动加载】,以上实现了分页处理,实现复杂度较高,对源码的侵入性也比较强;并且需要考虑在多选情况下,选中父节点时,如何保证还未加载出的子节点将来也被选中?这是个保留实现问题。再说虚拟列表,在实现中引入了外部组件vue-virtual-scroll-list,降低了实现复杂度;无论数据量多大,页面始终只渲染几十个 DOM 节点,极大提升前端渲染性能,但若数据量全量一次性加载,带宽和接口压力依然很大,因此需要结合动态加载完成。


使用场景

场景动态加载+滚动加载虚拟列表推荐方案
每层数据量几百,层级较多✔️可选动态加载即可
每层数据量几千、用户可能全展开✔️✔️两者结合
数据全量本地、几万条✔️虚拟列表
数据全量远程、接口支持分页✔️可选动态+滚动加载

其他组件现状

组件库懒加载虚拟滚动滚动分页远程搜索备注
Element UI✔️需二次开发虚拟滚动
Element Plus✔️Tree V2虚拟化树形控件,V2.9.1支持树节点过滤
Ant Design Vue✔️✔️TreeSelect 支持虚拟滚动
Naive UI✔️✔️✔️官方推荐大数据用法
View UI✔️
Vant✔️移动端,数据量一般不大
Arco Design✔️✔️✔️

  • 懒加载是所有主流 UI 组件库的标配;
  • 只有部分新一代组件库(如 Naive UIArco Design)内置虚拟滚动,Element/AntdTreeSelect或二次开发,如果不想侵入并维护源码时,使用TreeSelect是一个很好的选择;
  • 滚动分页功能很少有组件内置,前面也提到过滚动加载要额外处理的场景较多,比如无法选中将来渲染的节点;
  • 远程搜索在常用组件中支持情况比较普遍,适合数据量极大且需模糊检索的场景。

总结

  1. 节点高亮需要自定义功能完成,高亮逻辑可根据业务进行相应调整;
  2. 懒加载+虚拟滚动即可满足性能上的需求,这样组合既解决了一次请求数据量大的问题,又解决了一次性渲染大量节点造成卡顿的问题;
  3. 业务开发时先理清并拆解好需求,再记录一步步行动,方便追溯问题!