前言

小伙伴们好久不见,最近刚入职新公司,需求排的很满,平常是实在没时间写文章了,更新频率会变得比较慢。

周末在家闲着无聊,突然小弟过来紧急求助,说是面试腾讯的时候,对方给了个 Vue 的递归菜单要求实现,回来找我复盘。

正好这周是小周,没想着出去玩,就在家写写代码吧,我看了一下需求,确实是比较复杂,需要利用好递归组件,正好趁着这个机会总结一篇 Vue3 + TS 实现递归组件的文章。

需求

可以先在 Github Pages[1] 中预览一下效果。

需求是这样的,后端会返回一串可能有无限层级的菜单,格式如下:

[  {    id: 1,    father_id: 0,    status: 1,    name: '生命科学竞赛',    _child: [      {        id: 2,        father_id: 1,        status: 1,        name: '野外实习类',        _child: [{ id: 3, father_id: 2, status: 1, name: '植物学' }],      },      {        id: 7,        father_id: 1,        status: 1,        name: '科学研究类',        _child: [          { id: 8, father_id: 7, status: 1, name: '植物学与植物生理学' },          { id: 9, father_id: 7, status: 1, name: '动物学与动物生理学' },          { id: 10, father_id: 7, status: 1, name: '微生物学' },          { id: 11, father_id: 7, status: 1, name: '生态学' },        ],      },      { id: 71, father_id: 1, status: 1, name: '添加' },    ],  },  {    id: 56,    father_id: 0,    status: 1,    name: '考研相关',    _child: [      { id: 57, father_id: 56, status: 1, name: '政治' },      { id: 58, father_id: 56, status: 1, name: '外国语' },    ],  },]
  1. 每一层的菜单元素如果有 _child 属性,这一项菜单被选中以后就要继续展示这一项的所有子菜单,预览一下动图:

  1. 并且点击其中的任意一个层级,都需要把菜单的 完整的 id 链路 传递到最外层,给父组件请求数据用。比如点击了 科学研究类。那么向外 emit 的时候还需要带上它的第一个子菜单 植物学与植物生理学id,以及它的父级菜单 生命科学竞赛 的 id,也就是 [1, 7, 8]

  2. 每一层的样式还可以自己定制。

实现

这很显然是一个递归组件的需求,在设计递归组件的时候,我们要先想清楚数据到视图的映射。

在后端返回的数据中,数组的每一层可以分别对应一个菜单项,那么数组的层则就对应视图中的一行,当前这层的菜单中,被点击选中 的那一项菜单的 child 就会被作为子菜单数据,交给递归的 NestMenu 组件,直到某一层的高亮菜单不再有 child,则递归终止。


由于需求要求每一层的样式可能是不同的,所以再每次调用递归组件的时候,我们都需要从父组件的 props 中拿到一个 depth 代表层级,并且把这个 depth + 1 继续传递给递归的 NestMenu 组件。

重点主要就是这些,接下来编码实现。

先看 NestMenu 组件的 template 部分的大致结构:

<template>  <div class="wrap">    <div class="menu-wrap">      <divclass="menu-item"v-for="menuItem in data"      >{{menuItem.name}}div>    div>    <nest-menu:key="activeId":data="subMenu":depth="depth + 1"    >nest-menu>  div>template>

和我们预想设计中的一样, menu-wrap 代表当前菜单层, nest-menu 则就是组件本身,它负责递归的渲染子组件。

首次渲染

在第一次获取到整个菜单的数据的时候,我们需要先把每层菜单的选中项默认设置为第一个子菜单,由于它很可能是异步获取的,所以我们最好是 watch 这个数据来做这个操作。

// 菜单数据源发生变化的时候 默认选中当前层级的第一项const activeId = refnull>(null)watch(() => props.data,  (newData) => {if (!activeId.value) {if (newData && newData.length) {        activeId.value = newData[0].id      }    }  },  {immediate: true,  })

现在我们从最上层开始讲起,第一层的 activeId 被设置成了 生命科学竞赛 的 id,注意我们传递给递归子组件的 data ,也就是 生命科学竞赛child,是通过 subMenu 获取到的,它是一个计算属性:

const getActiveSubMenu = () => {  return data.find(({ id }) => id === activeId.value)._child}const subMenu = computed(getActiveSubMenu)

这样,就拿到了 生命科学竞赛child,作为子组件的数据传递下去了。

点击菜单项

回到之前的需求设计,在点击了菜单项后,无论点击的是哪层,都需要把完整的 id 链路通过 emit 传递到最外层去,所以这里我们需要多做一些处理:

/** * 递归收集子菜单第一项的 id */const getSubIds = (child) => {  const subIds = []  const traverse = (data) => {    if (data && data.length) {      const first = data[0]      subIds.push(first.id)      traverse(first._child)    }  }  traverse(child)  return subIds}

const onMenuItemClick = (menuItem) => {  const newActiveId = menuItem.id  if (newActiveId !== activeId.value) {    activeId.value = newActiveId    const child = getActiveSubMenu()    const subIds = getSubIds(child)    // 把子菜单的默认第一项 ids 也拼接起来 向父组件 emit    context.emit('change', [newActiveId, ...subIds])  }}

由于我们之前定的规则是,点击了新的菜单以后默认选中子菜单的第一项,所以这里我们也递归去找子菜单数据里的第一项,放到 subIds 中,直到最底层。

注意这里的 context.emit("change", [newId, ...subIds]);,这里是把事件向上 emit,如果这个菜单是中间层级的菜单,那么它的父组件也是 NestMenu,我们需要在父层级递归调用 NestMenu 组件的时候监听这个 change 事件。

<nest-menu:key="activeId"v-if="activeId !== null":data="getActiveSubMenu()":depth="depth + 1"    @change="onSubActiveIdChange">nest-menu>

在父层级的菜单接受到了子层级的菜单的 change 事件后,需要怎么做呢?没错,需要进一步的再向上传递:

const onSubActiveIdChange = (ids) => {  context.emit('change', [activeId.value].concat(ids))}

这里就只需要简单的把自己当前的 activeId 拼接到数组的最前面,再继续向上传递即可。

这样,任意一层的组件点击了菜单后,都会先用自己的 activeId 拼接好所有子层级的默认 activeId,再一层层向上 emit。并且向上的每一层父菜单都会把自己的 activeId 拼在前面,就像接力一样。

最后,我们在应用层级的组件里,就可以轻松的拿到完整的 id 链路:

<template>  <nest-menu :data="menu" @change="activeIdsChange" />template>

export default {  methods: {    activeIdsChange(ids) {      this.ids = ids;      console.log("当前选中的id路径", ids);  },},

样式区分

由于我们每次调用递归组件的时候,都会把 depth + 1,那么就可以通过把这个数字拼接到类名后面来实现样式区分了。

<template>  <div class="wrap">    <div class="menu-wrap" :class="`menu-wrap-${depth}`">      <div class="menu-item">{{menuItem.name}}div>    div>    <nest-menu />  div>template>

<style>.menu-wrap-0 {background: #ffccc7;}.menu-wrap-1 {background: #fff7e6;}.menu-wrap-2 {background: #fcffe6;}style>

默认高亮

上面的代码写完后,应对没有默认值时的需求已经足够了,这时候面试官说,产品要求这个组件能通过传入任意一个层级的 id 来默认展示高亮。

其实这也难不倒我们,稍微改造一下代码,在父组件里假设我们通过 url 参数或者任意方式拿到了一个 activeId,先通过深度优先遍历的方式查找到这个 id 的所有父级。

const activeId = 7

const findPath = (menus, targetId) => {  let ids

  const traverse = (subMenus, prev) => {    if (ids) {      return    }    if (!subMenus) {      return    }    subMenus.forEach((subMenu) => {      if (subMenu.id === activeId) {        ids = [...prev, activeId]        return      }      traverse(subMenu._child, [...prev, subMenu.id])    })  }

  traverse(menus, [])

  return ids}

const ids = findPath(data, activeId)

这里我选择在递归的时候带上上一层的 id,在找到了目标 id 以后就能轻松的拼接处完整的父子 id 数组。

然后我们把构造好的 ids 作为 activeIds 传递给 NestMenu,此时这时候 NestMenu 就要改变一下设计,成为一个「受控组件」,它的渲染状态是受我们外层传递的数据控制的。

所以我们需要在初始化参数的时候改变一下取值逻辑,优先取 activeIds[depth] ,并且在点击菜单项的时候,要在最外层的页面组件中,接收到 change 事件时,把 activeIds 的数据同步改变。这样继续传递下去才不会导致 NestMenu 接收到的数据混乱。

<template>  <nest-menu :data="data" :defaultActiveIds="ids" @change="activeIdsChange" />template>

NestMenu 初始化的时候,对有默认值的情况做一下处理,优先使用数组中取到的 id 值。

setup(props: IProps, context) {  const { depth = 0, activeIds } = props;

  /**   * 这里 activeIds 也可能是异步获取到的 所以用 watch 保证初始化   */  const activeId = refnull | undefined>(null);  watch(() => activeIds,    (newActiveIds) => {if (newActiveIds) {const newActiveId = newActiveIds[depth];if (newActiveId) {          activeId.value = newActiveId;        }      }    },    {immediate: true,    }  );}

这样,如果 activeIds 数组中取不到的话,默认还是 null,在 watch 到菜单数据变化的逻辑中,如果 activeIdnull 的话,会被初始化为第一个子菜单的 id

watch(  () => props.data,  (newData) => {    if (!activeId.value) {      if (newData && newData.length) {        activeId.value = newData[0].id      }    }  },  {    immediate: true,  })

在最外层页面容器监听到 change 事件的时候,要把数据源同步一下:

<nest-menu :data="data" :activeIds="ids" @change="activeIdsChange" />template><script>import { ref } from "vue";export default {name: "App",  setup() {const activeIdsChange = (newIds) => {      ids.value = newIds;    };return {      ids,      activeIdsChange,    };  },};script>

如此一来,外部传入 activeIds 的时候,就可以控制整个 NestMenu 的高亮选中逻辑了。

数据源变动引发的 bug。

这时候,面试官对着你的 App 文件稍作改动,然后演示了这样一个 bug:

App.vue 的 setup 函数中加了这样的一段逻辑:

onMounted(() => {  setTimeout(() => {    menu.value = [data[0]].slice()  }, 1000)})

也就是说,组件渲染完成后过了一秒,菜单的最外层只剩下一项了,这时候面试官在一秒之内点击了最外层的第二项,这个组件在数据源改变之后,会报错:


这是因为数据源已经改变了,但是组件内部的 activeId 状态依然停留在了一个已经不存在了的 id 上。

这会导致 subMenu 这个 computed 属性在计算时出错。

我们对 watch data 观测数据源的这段逻辑稍加改动:

watch(  () => props.data,  (newData) => {    if (!activeId.value) {      if (newData && newData.length) {        activeId.value = newData[0].id      }    }    // 如果当前层级的 data 中遍历无法找到 `activeId` 的值 说明这个值失效了    // 把它调整成数据源中第一个子菜单项的 id    if (!props.data.find(({ id }) => id === activeId.value)) {      activeId.value = props.data?.[0].id    }  },  {    immediate: true,    // 在观测到数据变动之后 同步执行 这样会防止渲染发生错乱    flush: 'sync',  })

注意这里的 flush: "sync" 很关键,Vue3 对于 watch 到数据源变动之后触发 callback 这一行为,默认是以 post 也就是渲染之后再执行的,但是在当前的需求下,如果我们用错误的 activeId 去渲染,就会直接导致报错了,所以我们需要手动把这个 watch 变成一个同步行为。

这下再也不用担心数据源变动导致渲染错乱了。


完整代码

App.vue

<nest-menu :data="data" :activeIds="ids" @change="activeIdsChange" />template><script>import { ref } from "vue";import NestMenu from "./components/NestMenu.vue";import data from "./menu.js";import { getSubIds } from "./util";export default {name: "App",  setup() {// 假设默认选中 id 为 7const activeId = 7;const findPath = (menus, targetId) => {let ids;const traverse = (subMenus, prev) => {if (ids) {return;        }if (!subMenus) {return;        }        subMenus.forEach((subMenu) => {if (subMenu.id === activeId) {            ids = [...prev, activeId];return;          }          traverse(subMenu._child, [...prev, subMenu.id]);        });      };      traverse(menus, []);return ids;    };const ids = ref(findPath(data, activeId));const activeIdsChange = (newIds) => {      ids.value = newIds;console.log("当前选中的id路径", newIds);    };return {      ids,      activeIdsChange,      data,    };  },components: {    NestMenu,  },};script>

NestMenu.vue

<div class="wrap"><div class="menu-wrap" :class="`menu-wrap-${depth}`"><divclass="menu-item"v-for="menuItem in data":class="getActiveClass(menuItem.id)"        @click="onMenuItemClick(menuItem)":key="menuItem.id"      >{{menuItem.name}}div>div><nest-menu:key="activeId"v-if="subMenu && subMenu.length":data="subMenu":depth="depth + 1":activeIds="activeIds"      @change="onSubActiveIdChange"    >nest-menu>div>template><script lang="ts">import { watch, ref, onMounted, computed } from "vue";import data from "../menu";interface IProps {data: typeof data;  depth: number;  activeIds?: number[];}export default {name: "NestMenu",props: ["data", "depth", "activeIds"],  setup(props: IProps, context) {const { depth = 0, activeIds, data } = props;/**     * 这里 activeIds 也可能是异步获取到的 所以用 watch 保证初始化     */const activeId = refnull | undefined>(null);    watch(() => activeIds,      (newActiveIds) => {if (newActiveIds) {const newActiveId = newActiveIds[depth];if (newActiveId) {            activeId.value = newActiveId;          }        }      },      {immediate: true,flush: 'sync'      }    );/**     * 菜单数据源发生变化的时候 默认选中当前层级的第一项     */    watch(() => props.data,      (newData) => {if (!activeId.value) {if (newData && newData.length) {            activeId.value = newData[0].id;          }        }// 如果当前层级的 data 中遍历无法找到 `activeId` 的值 说明这个值失效了// 把它调整成数据源中第一个子菜单项的 idif (!props.data.find(({ id }) => id === activeId.value)) {          activeId.value = props.data?.[0].id;        }      },      {immediate: true,// 在观测到数据变动之后 同步执行 这样会防止渲染发生错乱        flush: "sync",      }    );const onMenuItemClick = (menuItem) => {const newActiveId = menuItem.id;if (newActiveId !== activeId.value) {        activeId.value = newActiveId;const child = getActiveSubMenu();const subIds = getSubIds(child);// 把子菜单的默认第一项 ids 也拼接起来 向父组件 emit        context.emit("change", [newActiveId, ...subIds]);      }    };/**     * 接受到子组件更新 activeId 的同时     * 需要作为一个中介告知父组件 activeId 更新了     */const onSubActiveIdChange = (ids) => {      context.emit("change", [activeId.value].concat(ids));    };const getActiveSubMenu = () => {return props.data?.find(({ id }) => id === activeId.value)._child;    };const subMenu = computed(getActiveSubMenu);/**     * 样式相关     */const getActiveClass = (id) => {if (id === activeId.value) {return "menu-active";      }return "";    };/**     * 递归收集子菜单第一项的 id     */const getSubIds = (child) => {const subIds = [];const traverse = (data) => {if (data && data.length) {const first = data[0];          subIds.push(first.id);          traverse(first._child);        }      };      traverse(child);return subIds;    };return {      depth,      activeId,      subMenu,      onMenuItemClick,      onSubActiveIdChange,      getActiveClass,    };  },};script><style>.wrap {padding: 12px 0;}.menu-wrap {display: flex;flex-wrap: wrap;}.menu-wrap-0 {background: #ffccc7;}.menu-wrap-1 {background: #fff7e6;}.menu-wrap-2 {background: #fcffe6;}.menu-item {margin-left: 16px;cursor: pointer;white-space: nowrap;}.menu-active {color: #f5222d;}style>

源码地址

https://github.com/sl1673495/vue-nested-menu

总结

一个递归的菜单组件,说简单也简单,说难也有它的难点。如果我们不理解 Vue 的异步渲染和观察策略,可能中间的 bug 就会困扰我们许久。所以适当学习原理还是挺有必要的。

在开发通用组件的时候,一定要注意数据源的传入时机(同步、异步),对于异步传入的数据,要利用好 watch 这个 API 去观测变动,做相应的操作。并且要考虑数据源的变化是否会和组件内原来保存的状态冲突,在适当的时机要做好清理操作。

另外留下一个小问题,我在 NestMenu 组件 watch 数据源的时候,选择这样去做:

watch((() => props.data);

而不是解构后再去观测:

const { data } = props;watch(() => data);

这两者之间有区别吗?这又是一道考察深度的面试题。

开发优秀组件的路还是很漫长的,欢迎各位也在评论区留下你的看法~

vue自定义组件递归实现树状_一道价值25k的腾讯递归组件面试题(Vue3 + TS 实现)...相关推荐

  1. php递归实现层级树状展开,PHP递归实现层级树状展开,php递归层级树状_PHP教程...

    PHP递归实现层级树状展开,php递归层级树状 本文实例为大家分享了PHP递归实现层级树状展开的主要代码,供大家参考,具体内容如下 效果图: 实现代码: $arr['id'], 'fid' => ...

  2. 递归展示树状图/树状表格

    递归展示树状图 一.数据库表设计 二.后端java递归代码 三.前端展示树状表格 四.效果展示 一.数据库表设计 这里我们采用自关联的设计,通过id和pid的对应来确认数据的上下级关系 建表语句,我这 ...

  3. java 树 右键菜单_jQuery实现自定义右键菜单的树状菜单效果

    本文实例讲述了jQuery实现自定义右键菜单的树状菜单效果.分享给大家供大家参考.具体如下: 这是一款基于jQuery的自定义右键菜单,在树状结构的子节点(下级没有节点)上单击右键才会弹出自定义菜单, ...

  4. 使用递归遍历树状结构数据

    递归遍历树状结构 datas:[{id: 1,name: '分组1',expand: true,type:'group',editable:false,active:false,children: [ ...

  5. 递归实现树状分级部门树《部门单表》

    展示: 为了更好的展示数据实现过程:该功能采用mybatis写xml的方式实现,同样的mybatis-plus也能实现该功能,实现树状主要是通过递归这个方法把数据封装到集合里面返回给前端: 1.数据库 ...

  6. php和mysql处理树状_分级_无限分类_分层数据的方法_PHP和MySQL处理树状、分级、无限分类、分层数据的方法...

    文章标题中的多个词语表达的其实是一个意思,就是递归分类数据,分级数据非常类似数据结构中的树状结构,即每个节点有自己的孩子节点,孩子结点本身也是父亲节点.这是一个递归.分层形式.可以称之为树形层级数据. ...

  7. vue项目实现G6双向树状图最详细教程

    在项目中使用 NPM 包引入 npm install --save @antv/g6 AntV G6 示例图 初始化数据 点击节点动态加入数据 上代码(vue 文件) <template> ...

  8. html树状图右侧_treeview-树形菜单js组件编写及应用

    简要介绍: 之前手头的一个项目需要去做一个左侧的树形菜单,右侧则是一个整体的iframe,从而构成一个整体的网站.一开始是打算用bootstrap的tree-view插件,直接把菜单的数据传过去就好了 ...

  9. mysql如何实现树状结构_实现树状结构的两种方法

    实现树状结构的两种方法 实现树状结构的两种方法 1.递归法 递归是指在函数中显式的调用它自身. 利用递归法实现树状结构的特点是写入数据速度较快,显示速度较慢(在树的分支/层次较多的情况下尤其明显).适 ...

最新文章

  1. Linux命令之 —— grep \ls \ ll \ sed \ bg fg \ ipset \ wc \ ifconfig \ awk
  2. Reveal 的初步用法
  3. python判断文件是否为空文件对象_判断Python对象是不是为文件对象的方法有哪些...
  4. Java基础:常用IO流
  5. 【机器学习基础】XGBoost、LightGBM与CatBoost算法对比与调参
  6. boost::hana::typeid_用法的测试程序
  7. npm和yarn的区别,我们该如何选择?
  8. 解决SQL Server 阻止了对组件 'Ad Hoc Distributed Queries' 的 STATEMENT 'OpenRowset/OpenDatasource' 的访问...
  9. layout elements
  10. C++ container member map
  11. zabbix修改和查看登录密码
  12. Python Tornado web框架简单例子
  13. matlab 离散数据 拐点,MATLAB 寻找离散点拐点
  14. 西湖大学教授怎么看AI制药革命?|量子位智库圆桌实录
  15. Nginx完全正向保密(perfect forward secrecy)设置
  16. 真的,你不了解石家庄。
  17. DebugView 使用技巧
  18. 端到端的图像压缩----《Variational Image Compression With A Scale Hyperprior》论文笔记
  19. 麦咖啡 导出安全策略.reg文件。
  20. 使用POTEUS软件来进行模拟仿真 8086汇编语言花式跑马灯课程设计及代码

热门文章

  1. ubuntu下使用visual studio code来编译和调试C++
  2. 内部通信服务Factory(WCF)
  3. asp.net 用正则表达式过滤内容中的电话,qq,email
  4. 职场80后新人修炼五诀
  5. 集合框架-Collection与List集合
  6. 使用ip命令搭建基于隧道的虚拟专有网络
  7. Codeforces Round #441 (Div. 2, by Moscow Team Olympiad)
  8. 学习IOS 开发需要什么?
  9. C#中通过Selenium定位a标签的问题
  10. hdu 5154 Harry and Magical Computer