一道价值25k的腾讯递归组件面试题(Vue3 + TS 实现)

前端公虾米

共 11039字,需浏览 23分钟

 ·

2020-09-04 14:53

需求

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

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

[
  {
    id1,
    father_id0,
    status1,
    name'生命科学竞赛',
    _child: [
      {
        id2,
        father_id1,
        status1,
        name'野外实习类',
        _child: [{ id3father_id2status1name'植物学' }],
      },
      {
        id7,
        father_id1,
        status1,
        name'科学研究类',
        _child: [
          { id8father_id7status1name'植物学与植物生理学' },
          { id9father_id7status1name'动物学与动物生理学' },
          { id10father_id7status1name'微生物学' },
          { id11father_id7status1name'生态学' },
        ],
      },
      { id71father_id1status1name'添加' },
    ],
  },
  {
    id56,
    father_id0,
    status1,
    name'考研相关',
    _child: [
      { id57father_id56status1name'政治' },
      { id58father_id56status1name'外国语' },
    ],
  },
]
  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 = refnull>(null)

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

现在我们从最上层开始讲起,第一层的 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;
        }
      }
    },
    {
      immediatetrue,
    }
  );
}

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

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

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