小夥伴們很久不見,最近剛入職新公司,需求排的很滿,日常是實在沒時間寫文章了,更新頻率會變得比較慢。前端
週末在家閒着無聊,忽然小弟過來緊急求助,說是面試騰訊的時候,對方給了個 Vue 的遞歸菜單要求實現,回來找我覆盤。vue
正好這周是小周,沒想着出去玩,就在家寫寫代碼吧,我看了一下需求,確實是比較複雜,須要利用好遞歸組件,正好趁着這個機會總結一篇 Vue3 + TS 實現遞歸組件的文章。git
能夠先在 Github Pages 中預覽一下效果。github
需求是這樣的,後端會返回一串可能有無限層級的菜單,格式以下:面試
[ { 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: '外國語' }, ], }, ]
_child
屬性,這一項菜單被選中之後就要繼續展現這一項的全部子菜單,預覽一下動圖:
id
鏈路 傳遞到最外層,給父組件請求數據用。好比點擊了 科學研究類
。那麼向外 emit
的時候還須要帶上它的第一個子菜單 植物學與植物生理學
的 id
,以及它的父級菜單 生命科學競賽
的 id,也就是 [1, 7, 8]
。這很顯然是一個遞歸組件的需求,在設計遞歸組件的時候,咱們要先想清楚數據到視圖的映射。後端
在後端返回的數據中,數組的每一層能夠分別對應一個菜單項,那麼數組的層則就對應視圖中的一行,當前這層的菜單中,被點擊選中 的那一項菜單的 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 = 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; } } }, { immediate: true, } ); }
這樣,若是 activeIds
數組中取不到的話,默認仍是 null
,在 watch
到菜單數據變化的邏輯中,若是 activeId
是 null
的話,會被初始化爲第一個子菜單的 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
的高亮選中邏輯了。
這時候,面試官對着你的 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
變成一個同步行爲。
這下不再用擔憂數據源變更致使渲染錯亂了。
<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>
<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.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。