前言

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

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

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

需要

能够先在 Github Pages 中预览一下成果。

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

[  {    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">      <div        class="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 = (ref < number) | (null > 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 = 7const 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 = ref<number | null | 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 事件的时候,要把数据源同步一下:

<template>  <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

<template>  <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 为 7    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 = ref(findPath(data, activeId));    const activeIdsChange = (newIds) => {      ids.value = newIds;      console.log("以后选中的id门路", newIds);    };    return {      ids,      activeIdsChange,      data,    };  },  components: {    NestMenu,  },};</script>

NestMenu.vue

<template>  <div class="wrap">    <div class="menu-wrap" :class="`menu-wrap-${depth}`">      <div        class="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 = ref<number | null | 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` 的值 阐明这个值生效了        // 把它调整成数据源中第一个子菜单项的 id        if (!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 的异步渲染和察看策略,可能两头的 bug 就会困扰咱们许久。所以适当学习原理还是挺有必要的。

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

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

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

而不是解构后再去观测:

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

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

开发优良组件的路还是很漫长的,欢送各位也在评论区留下你的认识~

招聘

字节跳动内推啦,Client Infrastructure是字节跳动终端基础架构团队,面向字节跳动全业务线的挪动端、Web、Desktop等终端业务的基础架构部门,为公司业务的高效迭代、质量保证、研发效率和体验提供平台、工具、框架和专项技术能力撑持。
研发畛域包含但不限于APP框架和根底组件、研发体系、自动化测试、APM、跨平台框架、端智能解决方案、Web开发引擎、Node.js基建以及下一代挪动开发技术的预研等,目前在北上广深杭五地均设有研发核心。

上海的同学点这里一键投递,来咱们部门和我做共事吧~

https://job.toutiao.com/s/JhR...

其余地区(北上广深杭)也能够本人搜寻你想要的业务线和工作地点,通过我的下方内推链接间接投递即可。

https://job.toutiao.com/s/JhR...

校招的同学看这里:

投递链接: https://job.toutiao.com/s/JhR...

❤️ 感激大家

1.如果本文对你有帮忙,就点个赞反对下吧,你的「赞」是我创作的能源。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交换群」,大家一起独特交换和提高。