preface

Dear friends, I haven’t seen you for a long time. I just joined a new company recently, so I have a full schedule of demands. Usually, I really don’t have time to write articles, so the update frequency will be slow.

I was idle and bored at home on the weekend. Suddenly, my younger brother came to me for urgent help, saying that in the interview with Tencent, the other party gave me a recursive menu of Vue, which was required to be realized.

Just this week is a small week, I didn’t want to go out to play, I will write code at home, I looked at the requirements, it is really complicated, need to make good use of recursive components, just take this opportunity to summarize an article on Vue3 + TS to achieve recursive components.

demand

You can preview the effects on Github Pages.

The requirement is that the back end returns a list of possibly infinite levels of menus in the following format:

[{id: 1.father_id: 0.status: 1.name: 'Life Science Competition'._child: [{id: 2.father_id: 1.status: 1.name: 'Field practice'._child: [{ id: 3.father_id: 2.status: 1.name: 'Botany'}],}, {id: 7.father_id: 1.status: 1.name: 'Scientific Research'._child: [{id: 8.father_id: 7.status: 1.name: 'Botany and Plant Physiology' },
          { id: 9.father_id: 7.status: 1.name: 'Zoology and Animal Physiology' },
          { id: 10.father_id: 7.status: 1.name: 'Microbiology' },
          { id: 11.father_id: 7.status: 1.name: 'ecology'},],}, {id: 71.father_id: 1.status: 1.name: 'add'},],}, {id: 56.father_id: 0.status: 1.name: 'Postgraduate entrance examination related'._child: [{id: 57.father_id: 56.status: 1.name: 'political' },
      { id: 58.father_id: 56.status: 1.name: 'Foreign language'},],},]Copy the code
  1. Menu elements for each layer if any_childProperties,This menu item is selectedGo ahead and show all the submenus of this item, preview the giFs:

  1. And clicking on any of these levels requires passing the full menu ID link to the outermost layer for the parent component to request data for. For example, click on the science research category. Emit with the id of its first submenu botany and Plant Physiology, and the ID of its parent menu Life Sciences Competition, which is [1, 7, 8].

  2. The style of each layer can also be customized.

implementation

This is obviously a requirement for a recursive component, and when designing a recursive component, we need to think about the mapping of data to view first.

In the data returned at the back end, each layer of the array can correspond to a menu item, so the layer of the array corresponds to a row in the view. In the current layer of the menu, the child of the selected menu item will be given to the recursive NestMenu component as sub-menu data. The recursion terminates until there is no child in the highlighted menu at one level.

Since the styling of each layer may be different, we need to get a depth representing the hierarchy from the parent component’s props and pass this depth + 1 to the recursive NestMenu component each time we call the recursive component.

The main focus is these, the next coding implementation.

The template section of the NestMenu component looks like this:

<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>
Copy the code

As expected, menu-wrap represents the current menu layer, and Nest-menu is the component itself, which is responsible for recursive rendering sub-components.

For the first time to render

When we get the data of the whole menu for the first time, we need to set the selected item of each layer as the first sub-menu by default. Since it is likely to be acquired asynchronously, we had better use the data of Watch to do this operation.

// The first item in the current hierarchy is selected by default when the menu data source changes
const activeId = ref<number | null> (null)

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

The activeId of the first layer is set to the life Sciences contest ID. Note that the data we pass to the recursive child, the Life Sciences Contest child, is obtained via subMenu and is a calculated property:

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

In this way, the Child of the Life Sciences competition is taken and passed as a child component of the data.

Click on the menu item

Back to the previous requirement design, after clicking the menu item, no matter which layer is clicked, the whole ID link needs to be sent to the outermost layer through emit, so we need to do a little more processing here:

/** * Recursively collects the id of the first submenu item */
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 = newActiveIdconst child = getActiveSubMenu()
    const subIds = getSubIds(child)
    // Concatenate the default first ids item of the submenu to emit to the parent component
    context.emit('change', [newActiveId, ...subIds])
  }
}
Copy the code

Since our previous rule is that the first item in the submenu is selected by default when a new menu is clicked, we recurse back to the first item in the submenu data and place it in the subIds, all the way to the bottom.

Pay attention to context.emit(“change”, [newId,…subIds]); If this menu is a middle level menu, then its parent component is also NestMenu. We need to listen for the change event when the parent level recursively calls the NestMenu component.

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

What do I need to do after the parent menu receives the change event from the child menu? Yes, it needs to be passed up further:

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

You simply concatenate your current activeId to the top of the array and pass it up.

In this way, when a component at any level clicks the menu, it first concatenates the default Activeids of all the sub-levels with its Own activeId, and then emits from one layer to the next. And each level up the parent menu will spell its own activeId in front of it, like a relay.

Finally, we can easily obtain the complete ID link in the application level component:

<template>
  <nest-menu :data="menu" @change="activeIdsChange" />
</template>export default { methods: { activeIdsChange(ids) { this.ids = ids; Console. log(" currently selected ID path ", ids); }},Copy the code

Style to distinguish

Since we place a depth of + 1 each time we call the recursive component, we can achieve style differentiation by concatenating this number after the class name.

<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>
Copy the code

The default highlight

After the above code is written, it is sufficient to address the requirement that there is no default value, at which point the interviewer says that the product requires this component to display highlighting by default by passing in any level of ID.

If we get an activeId from a URL argument or any other method, we will do depth-first traversal to find all the parents of the activeId.

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)
Copy the code

Here I choose to recurse with the id of the previous layer, and after finding the target ID, I can easily concatenate the entire array of parent and child ids.

We then pass the constructed IDS as activeIds to NestMenu, at which point NestMenu changes its design to become a “controlled component” whose rendering state is controlled by the data we pass in our outer layer.

So we need to change the value logic when initializing the parameter, take the activeIds[depth] first, and synchronize the activeIds data when the change event is received in the outermost page component when the menu item is clicked. This will not lead to continued transmission of data received by NestMenu confusion.

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

When NestMenu is initialized, if there is a default value, use the id value from the array first.

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

  /** * activeIds can also be obtained asynchronously, so use watch to ensure that the initialization */
  const activeId = ref<number | null | undefined> (null);
  watch(
    () = > activeIds,
    (newActiveIds) = > {
      if (newActiveIds) {
        const newActiveId = newActiveIds[depth];
        if(newActiveId) { activeId.value = newActiveId; }}}, {immediate: true}); }Copy the code

If the activeId is null, it will be initialized to the id of the first submenu. If the activeId is null, it will be initialized to the id of the first submenu.

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

When the outermost page container listens for the change event, synchronize the data source:

<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>
Copy the code

In this way, when external activeIds are passed in, you can control the entire NestMenu highlighting logic.

Bugs caused by data source changes.

At this point, the interviewer makes a few changes to your App file and demonstrates a bug like this:

The setup function in app. vue adds the following logic:

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

In other words, a second after the component is rendered, there is only one item left in the outermost menu, and within a second the inspector clicks the second outermost item. This component will report an error when the data source changes:

This is because the data source has changed, but the activeId status inside the component remains on an ID that no longer exists.

This causes the computed property subMenu to be computed incorrectly.

We changed the logic of watch Data’s observation data source slightly:

watch(
  () = > props.data,
  (newData) = > {
    if(! activeId.value) {if (newData && newData.length) {
        activeId.value = newData[0].id
      }
    }
    // If the activeId value cannot be found in the current level of data, the value is invalid
    // Adjust it to the ID of the first submenu item in the data source
    if(! props.data.find(({ id }) = >id === activeId.value)) { activeId.value = props.data? .0].id
    }
  },
  {
    immediate: true.// Execute synchronously after data changes are observed to prevent rendering errors
    flush: 'sync',})Copy the code

Note the flush here: “Sync” is critical. By default, Vue3 uses post (or render) to trigger callback after the watch changes to the data source. However, if we use the wrong activeId to render, this will cause an error. So we need to manually turn this watch into a synchronous behavior.

Now you don’t have to worry about changing the data source and rendering it out of order.

The complete code

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() {
    // Assume that the default id is 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("Currently selected ID path", newIds);
    };

    return {
      ids,
      activeIdsChange,
      data,
    };
  },
  components: {
    NestMenu,
  },
};
</script>
Copy the code

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: typeofdata; depth: number; activeIds? : number[]; }export default {
  name: "NestMenu".props: ["data"."depth"."activeIds"].setup(props: IProps, context) {
    const { depth = 0, activeIds, data } = props;

    /** * activeIds can also be obtained asynchronously, so use watch to ensure that the initialization */
    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'});/** * The first item in the current hierarchy is selected by default when the data source in the menu changes
    watch(
      () = > props.data,
      (newData) = > {
        if(! activeId.value) {if (newData && newData.length) {
            activeId.value = newData[0].id; }}// If the activeId value cannot be found in the current level of data, the value is invalid
        // Adjust it to the ID of the first submenu item in the data source
        if(! props.data.find(({ id }) = >id === activeId.value)) { activeId.value = props.data? .0].id; }}, {immediate: true.// Execute synchronously after data changes are observed to prevent rendering errors
        flush: "sync"});const onMenuItemClick = (menuItem) = > {
      const newActiveId = menuItem.id;
      if(newActiveId ! == activeId.value) { activeId.value = newActiveId;const child = getActiveSubMenu();
        const subIds = getSubIds(child);
        // Concatenate the default first ids item of the submenu to emit to the parent component
        context.emit("change", [newActiveId, ...subIds]); }};/** * Receiving an updated activeId from a child component requires that the parent component be notified that the activeId is updated */
    const onSubActiveIdChange = (ids) = > {
      context.emit("change", [activeId.value].concat(ids));
    };
    const getActiveSubMenu = () = > {
      returnprops.data? .find(({ id }) = > id === activeId.value)._child;
    };
    const subMenu = computed(getActiveSubMenu);

    /** * style dependent */
    const getActiveClass = (id) = > {
      if (id === activeId.value) {
        return "menu-active";
      }
      return "";
    };

    /** * Recursively collects the id of the first submenu item */
    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>
Copy the code

The source address

Github.com/sl1673495/v…

conclusion

A recursive menu component, simple to say is also simple, difficult to say also has its difficulties. If we don’t understand Vue’s asynchronous rendering and observation strategy, there may be bugs in the middle that will haunt us for a long time. So it’s necessary to learn the principles properly.

When developing general components, we must pay attention to the incoming timing of data sources (synchronous and asynchronous). For asynchronous incoming data, we should make good use of the API watch to observe changes and perform corresponding operations. Consider whether changes to the data source conflict with the previously stored state in the component, and clean up when appropriate.

In addition, there is a small problem that I choose to do in the NestMenu component watch data source:

watch((() = > props.data);
Copy the code

Instead of deconstructing and observing:

const { data } = props;
watch(() = > data);
Copy the code

Is there a difference between the two? This is another in-depth interview question.

There’s still a long way to go in developing good components, so leave your thoughts in the comments

recruitment

Client Infrastructure is bytedance’s terminal Infrastructure team, which is an Infrastructure department for bytedance’s mobile terminal, Web, Desktop and other terminal businesses. Provide platform, tools, framework and special technical capability support for efficient iteration, quality assurance, r&d efficiency and experience of the company’s business. Research and development areas include but are not limited to APP framework and basic components, RESEARCH and development system, automated testing, APM, cross-platform framework, terminal intelligence solutions, Web development engine, Node.JS infrastructure and the pre-research of the next generation mobile development technology, etc. At present, there are research and development centers in Beijing, Shanghai, Guangzhou, Shenzhen and Hangzhou.

Shanghai students click here to deliver, come to our department and I do colleagues ~

job.toutiao.com/s/JhRtmQv

Other regions (Beijing, Shanghai, Guangzhou, Shenzhen and Hangzhou) can also search for the business line and work place you want, through my inner link below the direct delivery.

job.toutiao.com/s/JhRDWep

For students enrolled in the school, see here:

Post links: job.toutiao.com/s/JhRV7nC

❤️ thank you

1. If this article is helpful to you, please support it with a like. Your like is the motivation for my writing.

2. Follow the public account “front-end from advanced to hospital” to add my friends, I pull you into the “front-end advanced communication group”, we communicate and progress together.