Vue3+TypeScript實現遞迴選單元件的完整例項
目錄
- 前言
- 需求
- 實現
- 首次渲染
- 點選選單項
- 樣式區分
- 預設高亮
- 資料來源變動引發的 bug
- 完整程式碼
- App.
- 總結
前言
小夥伴們好久不見,最近剛入職新公司,需求排的很滿,平常是實在沒時間寫文章了,更新頻率會變得比較慢。
週末在家閒著無聊,突然小弟過來緊急求助,說是面試騰訊的時候,對方給了個 Vue 的遞迴選單要求實現,回來找我覆盤。
正好這周是小周,沒想著出去玩,就在家寫寫程式碼吧,我看了一下需求,確實是比較複雜,需要利用好遞迴元件,正好趁著這
個機會總結一篇 Vue3 + TS 實現遞迴元件的文章。
需求
可以先在 Pages 中預覽一下效果。
需求是這樣的,後端會返回一串可能有無限層級的選單,格式如下:
[ { id: 1,father_id: 0,status: 1,name: '生命科學競賽',_child: [ { id: 2,father_id: 1,name: '野外實習類',_child: [{ id: 3,father_id: 2,name: '植物學' }],},{ id: 7,name: '科學研究類',_child: [ { id: 8,father_id: 7,name: '植物學與植物生理學' },{ id: 9,name: '動物學與動物生理學' },{ id: 10,name: '微生物學' },{ id: 11,name: '生態學' },],http://www.cppcns.com{ id: 71,name: '新增' },{ id: 56,name: '考研相關',_child: [ { id: 57,father_id: 56,name: '政治' },{ id: 58,name: '外國語' },]
1、每一層的選單元素如果有 _child 屬性,這一項選單被選中以後就要繼續展示這一項的所有子選單,預覽一下動圖:
2、並且點選其中的任意一個層級,都需要把選單的 完整的 id 鏈路 傳遞到最外層,給父元件請求資料用。比如點選了 科學研究類。那麼向外 emit 的時候還需要帶上它的第一個子選單 植物學與植物生理學 的 id,以及它的父級選單 生命科學競賽 的 id,也就是 [1,7,8]。
3、每一層的樣式還可以自己定製。
實現
這很顯然是一個遞迴元件的需求,在設計遞迴元件的時候,我們要先想清楚資料到檢視的對映。
在後端返回的資料中,陣列的每一層可以分別對應一個選單項,那麼陣列的層則就對應檢視中的一行,當前這層的選單中,被點選選中 的那一項選單的 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,} )
在最外層頁面容器監聽到 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 } },// 在觀測到資料變動之後 同步執行 這樣會防止渲染髮生錯亂 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."; 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,subMenu.id]); }); }; traverse(menus,[]); return ids; }; const ids = ref(findPath(data,activeId)); const activeIdsChange = (newIds) => { ids.value = newIds; console.log("當前選中的id路徑",newIds); }; return { ids,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" http://www.cppcns.com ></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; } },// 在觀測到資料變動之後 同步執行 這樣會防止渲染髮生錯亂 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",...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>
原始碼地址
github.com/sl1673495/v…
總結
一個遞迴的選單元件,說簡單也簡單,說難也有它的難點。如果我們不理解 Vue 的非同步渲染和觀察策略,可能中間的 bug 就會困擾我們許久。所以適當學習原理還是挺有必要的。
在開發通用元件的時候,一定要注意資料來源的傳入時機(同步、非同步),對於非同步傳入的資料,要利用好 watch 這個 API 去觀測變動,做相應的操作。並且要考慮資料來源的變化是否會和元件內原來儲存的狀態衝突,在適當的時機要做好清理操作。
另外留下一個小問題,我在 NestMenu 元件 watch 資料來源的時候,選擇這樣去做:
watch((() => props.data);
而不是解構後再去觀測:
const { data } = props; watch(() => data);
這兩者之間有區別嗎?這又是一道考察深度的面試題。
到此這篇關於Vue3+TypeScript實現遞迴選單元件的文章就介紹到這了,更多相關Vue3+TypeScript遞迴選單元件內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!