一道價值25k的騰訊遞歸組件面試題(Vue3 + TS 實現)

前言

小夥伴們很久不見,最近剛入職新公司,需求排的很滿,日常是實在沒時間寫文章了,更新頻率會變得比較慢。前端

週末在家閒着無聊,忽然小弟過來緊急求助,說是面試騰訊的時候,對方給了個 Vue 的遞歸菜單要求實現,回來找我覆盤。vue

正好這周是小周,沒想着出去玩,就在家寫寫代碼吧,我看了一下需求,確實是比較複雜,須要利用好遞歸組件,正好趁着這個機會總結一篇 Vue3 + TS 實現遞歸組件的文章。git

需求

能夠先在 Github Pages[1] 中預覽一下效果。github

需求是這樣的,後端會返回一串可能有無限層級的菜單,格式以下:web

[
  {
    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 = ref<number | null>(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 = ref<number | null | 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 事件的時候,要把數據源同步一下:

<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
    }
  },
  {
    immediatetrue,
    // 在觀測到數據變更以後 同步執行 這樣會防止渲染髮生錯亂
    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 {
  datatypeof 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;
          }
        }
      },
      {
        immediatetrue,
        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;
        }
      },
      {
        immediatetrue,
        // 在觀測到數據變更以後 同步執行 這樣會防止渲染髮生錯亂
        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 {
  padding12px 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-left16px;
  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);

這二者之間有區別嗎?這又是一道考察深度的面試題。

開發優秀組件的路仍是很漫長的,歡迎各位也在評論區留下你的見解~

本文分享自微信公衆號 - web前端學習圈(web-xxq)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索