前言
這是一篇 Vue3 + TS 實(shí)現(xiàn)遞歸組件的文章。
需求
可以先在 Github Pages 中預(yù)覽一下效果。
需求是這樣的,后端會(huì)返回一串可能有無(wú)限層級(jí)的菜單,格式如下:
[
{
id: 1,
father_id: 0,
status: 1,
name: '生命科學(xué)競(jìng)賽',
_child: [
{
id: 2,
father_id: 1,
status: 1,
name: '野外實(shí)習(xí)類',
_child: [{ id: 3, father_id: 2, status: 1, name: '植物學(xué)' }],
},
{
id: 7,
father_id: 1,
status: 1,
name: '科學(xué)研究類',
_child: [
{ id: 8, father_id: 7, status: 1, name: '植物學(xué)與植物生理學(xué)' },
{ id: 9, father_id: 7, status: 1, name: '動(dòng)物學(xué)與動(dòng)物生理學(xué)' },
{ id: 10, father_id: 7, status: 1, name: '微生物學(xué)' },
{ id: 11, father_id: 7, status: 1, name: '生態(tài)學(xué)' },
],
},
{ id: 71, father_id: 1, status: 1, name: '添加' },
],
},
{
id: 56,
father_id: 0,
status: 1,
name: '考研相關(guān)',
_child: [
{ id: 57, father_id: 56, status: 1, name: '政治' },
{ id: 58, father_id: 56, status: 1, name: '外國(guó)語(yǔ)' },
],
},
]
1.每一層的菜單元素如果有 _child 屬性,這一項(xiàng)菜單被選中 以后就要繼續(xù)展示這一項(xiàng)的所有子菜單,預(yù)覽一下動(dòng)圖:

1.并且點(diǎn)擊其中的任意一個(gè)層級(jí),都需要把菜單的 完整的
id 鏈路** 傳遞到最外層,給父組件請(qǐng)求數(shù)據(jù)用。比如點(diǎn)擊了 科學(xué)研究類 。那么向外emit 的時(shí)候還需要帶上它的第一個(gè)子菜單 植物學(xué)與植物生理學(xué) 的 id ,以及它的父級(jí)菜單 生命科學(xué)競(jìng)賽 的id,也就是[1, 7, 8] 。2.每一層的樣式還可以自己定制。
實(shí)現(xiàn)
這很顯然是一個(gè)遞歸組件的需求,在設(shè)計(jì)遞歸組件的時(shí)候,我們要先想清楚數(shù)據(jù)到視圖的映射。
在后端返回的數(shù)據(jù)中,數(shù)組的每一層可以分別對(duì)應(yīng)一個(gè)菜單項(xiàng),那么數(shù)組的層則就對(duì)應(yīng)視圖中的一行,當(dāng)前這層的菜單中, 被點(diǎn)擊選中 的那一項(xiàng)菜單的 child 就會(huì)被作為子菜單數(shù)據(jù),交給遞歸的 NestMenu 組件,直到某一層的高亮菜單不再有child,則遞歸終止。

由于需求要求每一層的樣式可能是不同的,所以再每次調(diào)用遞歸組件的時(shí)候,我們都需要從父組件的 props 中拿到一個(gè)
depth 代表層級(jí),并且把這個(gè) depth + 1 繼續(xù)傳遞給遞歸的NestMenu組件。
重點(diǎn)主要就是這些,接下來(lái)編碼實(shí)現(xiàn)。
先看 NestMenu 組件的 template部分的大致結(jié)構(gòu):
<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>
和我們預(yù)想設(shè)計(jì)中的一樣,menu-wrap代表當(dāng)前菜單層, nest-menu 則就是組件本身,它負(fù)責(zé)遞歸的渲染子組件。
首次渲染
在第一次獲取到整個(gè)菜單的數(shù)據(jù)的時(shí)候,我們需要先把每層菜單的選中項(xiàng)默認(rèn)設(shè)置為第一個(gè)子菜單,由于它很可能是異步獲取的,所以我們最好是watch 這個(gè)數(shù)據(jù)來(lái)做這個(gè)操作。
// 菜單數(shù)據(jù)源發(fā)生變化的時(shí)候 默認(rèn)選中當(dāng)前層級(jí)的第一項(xiàng)
const activeId = (ref < number) | (null > null)
watch(
() => props.data,
(newData) => {
if (!activeId.value) {
if (newData && newData.length) {
activeId.value = newData[0].id
}
}
},
{
immediate: true,
}
)
現(xiàn)在我們從最上層開始講起,第一層的activeId 被設(shè)置成了 生命科學(xué)競(jìng)賽 的 id,注意我們傳遞給遞歸子組件的data,也就是 生命科學(xué)競(jìng)賽 的 child ,是通過(guò) subMenu 獲取到的,它是一個(gè)計(jì)算屬性:
const getActiveSubMenu = () => {
return data.find(({ id }) => id === activeId.value)._child
}
const subMenu = computed(getActiveSubMenu)
這樣,就拿到了生命科學(xué)競(jìng)賽 的 child ,作為子組件的數(shù)據(jù)傳遞下去了。
點(diǎn)擊菜單項(xiàng)
回到之前的需求設(shè)計(jì),在點(diǎn)擊了菜單項(xiàng)后,無(wú)論點(diǎn)擊的是哪層,都需要把完整的 id 鏈路通過(guò)emit 傳遞到最外層去,所以這里我們需要多做一些處理:
/**
* 遞歸收集子菜單第一項(xiàng)的 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)
// 把子菜單的默認(rèn)第一項(xiàng) ids 也拼接起來(lái) 向父組件 emit
context.emit('change', [newActiveId, ...subIds])
}
}
由于我們之前定的規(guī)則是,點(diǎn)擊了新的菜單以后默認(rèn)選中子菜單的第一項(xiàng),所以這里我們也遞歸去找子菜單數(shù)據(jù)里的第一項(xiàng),放到 subIds 中,直到最底層。
注意這里的 context.emit("change", [newId, ...subIds]); ,這里是把事件向上 emit ,如果這個(gè)菜單是中間層級(jí)的菜單,那么它的父組件也是 NestMenu ,我們需要在父層級(jí)遞歸調(diào)用 NestMenu 組件的時(shí)候監(jiān)聽這個(gè) change 事件。
<nest-menu
:key="activeId"
v-if="activeId !== null"
:data="getActiveSubMenu()"
:depth="depth + 1"
@change="onSubActiveIdChange"
></nest-menu>
在父層級(jí)的菜單接受到了子層級(jí)的菜單的 change 事件后,需要怎么做呢?沒(méi)錯(cuò),需要進(jìn)一步的再向上傳遞:
const onSubActiveIdChange = (ids) => {
context.emit('change', [activeId.value].concat(ids))
}
這里就只需要簡(jiǎn)單的把自己當(dāng)前的 activeId 拼接到數(shù)組的最前面,再繼續(xù)向上傳遞即可。
這樣,任意一層的組件點(diǎn)擊了菜單后,都會(huì)先用自己的activeId 拼接好所有子層級(jí)的默認(rèn) activeId ,再一層層向上 emit 。并且向上的每一層父菜單都會(huì)把自己的 activeId 拼在前面,就像接力一樣。
最后,我們?cè)趹?yīng)用層級(jí)的組件里,就可以輕松的拿到完整的 id 鏈路:
<template>
<nest-menu :data="menu" @change="activeIdsChange" />
</template>
export default {
methods: {
activeIdsChange(ids) {
this.ids = ids;
console.log("當(dāng)前選中的id路徑", ids);
},
},
樣式區(qū)分
由于我們每次調(diào)用遞歸組件的時(shí)候,都會(huì)把depth + 1,那么就可以通過(guò)把這個(gè)數(shù)字拼接到類名后面來(lái)實(shí)現(xiàn)樣式區(qū)分了。
<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>
默認(rèn)高亮
上面的代碼寫完后,應(yīng)對(duì)沒(méi)有默認(rèn)值時(shí)的需求已經(jīng)足夠了,這時(shí)候面試官說(shuō),產(chǎn)品要求這個(gè)組件能通過(guò)傳入任意一個(gè)層級(jí)的id 來(lái)默認(rèn)展示高亮。
其實(shí)這也難不倒我們,稍微改造一下代碼,在父組件里假設(shè)我們通過(guò) url 參數(shù)或者任意方式拿到了一個(gè) activeId,先通過(guò)深度優(yōu)先遍歷的方式查找到這個(gè)id 的所有父級(jí)。
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)
這里我選擇在遞歸的時(shí)候帶上上一層的id ,在找到了目標(biāo)id 以后就能輕松的拼接處完整的父子 id 數(shù)組。
然后我們把構(gòu)造好的ids 作為activeIds 傳遞給 NestMenu ,此時(shí)這時(shí)候NestMenu就要改變一下設(shè)計(jì),成為一個(gè)「受控組件」,它的渲染狀態(tài)是受我們外層傳遞的數(shù)據(jù)控制的。
所以我們需要在初始化參數(shù)的時(shí)候改變一下取值邏輯,優(yōu)先取 activeIds[depth] ,并且在點(diǎn)擊菜單項(xiàng)的時(shí)候,要在最外層的頁(yè)面組件中,接收到 change 事件時(shí),把 activeIds的數(shù)據(jù)同步改變。這樣繼續(xù)傳遞下去才不會(huì)導(dǎo)致NestMenu接收到的數(shù)據(jù)混亂。
<template>
<nest-menu :data="data" :defaultActiveIds="ids" @change="activeIdsChange" />
</template>
NestMenu初始化的時(shí)候,對(duì)有默認(rèn)值的情況做一下處理,優(yōu)先使用數(shù)組中取到的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數(shù)組中取不到的話,默認(rèn)還是 null,在 watch 到菜單數(shù)據(jù)變化的邏輯中,如果activeId 是 null 的話,會(huì)被初始化為第一個(gè)子菜單的 id 。
watch(
() => props.data,
(newData) => {
if (!activeId.value) {
if (newData && newData.length) {
activeId.value = newData[0].id
}
}
},
{
immediate: true,
}
)
在最外層頁(yè)面容器監(jiān)聽到 change 事件的時(shí)候,要把數(shù)據(jù)源同步一下:
<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>
如此一來(lái),外部傳入activeIds的時(shí)候,就可以控制整個(gè)NestMenu的高亮選中邏輯了。
數(shù)據(jù)源變動(dòng)引發(fā)的 bug。
這時(shí)候,面試官對(duì)著你的 App 文件稍作改動(dòng),然后演示了這樣一個(gè) bug:
App.vue 的 setup 函數(shù)中加了這樣的一段邏輯:
onMounted(() => {
setTimeout(() => {
menu.value = [data[0]].slice()
}, 1000)
})
也就是說(shuō),組件渲染完成后過(guò)了一秒,菜單的最外層只剩下一項(xiàng)了,這時(shí)候面試官在一秒之內(nèi)點(diǎn)擊了最外層的第二項(xiàng),這個(gè)組件在數(shù)據(jù)源改變之后,會(huì)報(bào)錯(cuò):

這是因?yàn)閿?shù)據(jù)源已經(jīng)改變了,但是組件內(nèi)部的
activeId 狀態(tài)依然停留在了一個(gè)已經(jīng)不存在了的id 上。
這會(huì)導(dǎo)致 subMenu 這個(gè) computed屬性在計(jì)算時(shí)出錯(cuò)。
我們對(duì) watch data 觀測(cè)數(shù)據(jù)源的這段邏輯稍加改動(dòng):
watch(
() => props.data,
(newData) => {
if (!activeId.value) {
if (newData && newData.length) {
activeId.value = newData[0].id
}
}
// 如果當(dāng)前層級(jí)的 data 中遍歷無(wú)法找到 `activeId` 的值 說(shuō)明這個(gè)值失效了
// 把它調(diào)整成數(shù)據(jù)源中第一個(gè)子菜單項(xiàng)的 id
if (!props.data.find(({ id }) => id === activeId.value)) {
activeId.value = props.data?.[0].id
}
},
{
immediate: true,
// 在觀測(cè)到數(shù)據(jù)變動(dòng)之后 同步執(zhí)行 這樣會(huì)防止渲染發(fā)生錯(cuò)亂
flush: 'sync',
}
)

注意這里的flush: "sync" 很關(guān)鍵,Vue3 對(duì)于 watch到數(shù)據(jù)源變動(dòng)之后觸發(fā) callback 這一行為,默認(rèn)是以post 也就是渲染之后再執(zhí)行的,但是在當(dāng)前的需求下,如果我們用錯(cuò)誤的 activeId去渲染,就會(huì)直接導(dǎo)致報(bào)錯(cuò)了,所以我們需要手動(dòng)把這個(gè)watch 變成一個(gè)同步行為。
這下再也不用擔(dān)心數(shù)據(jù)源變動(dòng)導(dǎo)致渲染錯(cuò)亂了。
完整代碼
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() {
// 假設(shè)默認(rèn)選中 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("當(dāng)前選中的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 {
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'
}
);
/**
* 菜單數(shù)據(jù)源發(fā)生變化的時(shí)候 默認(rèn)選中當(dāng)前層級(jí)的第一項(xiàng)
*/
watch(
() => props.data,
(newData) => {
if (!activeId.value) {
if (newData && newData.length) {
activeId.value = newData[0].id;
}
}
// 如果當(dāng)前層級(jí)的 data 中遍歷無(wú)法找到 `activeId` 的值 說(shuō)明這個(gè)值失效了
// 把它調(diào)整成數(shù)據(jù)源中第一個(gè)子菜單項(xiàng)的 id
if (!props.data.find(({ id }) => id === activeId.value)) {
activeId.value = props.data?.[0].id;
}
},
{
immediate: true,
// 在觀測(cè)到數(shù)據(jù)變動(dòng)之后 同步執(zhí)行 這樣會(huì)防止渲染發(fā)生錯(cuò)亂
flush: "sync",
}
);
const onMenuItemClick = (menuItem) => {
const newActiveId = menuItem.id;
if (newActiveId !== activeId.value) {
activeId.value = newActiveId;
const child = getActiveSubMenu();
const subIds = getSubIds(child);
// 把子菜單的默認(rèn)第一項(xiàng) ids 也拼接起來(lái) 向父組件 emit
context.emit("change", [newActiveId, ...subIds]);
}
};
/**
* 接受到子組件更新 activeId 的同時(shí)
* 需要作為一個(gè)中介告知父組件 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);
/**
* 樣式相關(guān)
*/
const getActiveClass = (id) => {
if (id === activeId.value) {
return "menu-active";
}
return "";
};
/**
* 遞歸收集子菜單第一項(xiàng)的 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>
總結(jié)
一個(gè)遞歸的菜單組件,說(shuō)簡(jiǎn)單也簡(jiǎn)單,說(shuō)難也有它的難點(diǎn)。如果我們不理解 Vue 的異步渲染和觀察策略,可能中間的 bug 就會(huì)困擾我們?cè)S久。所以適當(dāng)學(xué)習(xí)原理還是挺有必要的。
在開發(fā)通用組件的時(shí)候,一定要注意數(shù)據(jù)源的傳入時(shí)機(jī)(同步、異步),對(duì)于異步傳入的數(shù)據(jù),要利用好 watch 這個(gè) API 去觀測(cè)變動(dòng),做相應(yīng)的操作。并且要考慮數(shù)據(jù)源的變化是否會(huì)和組件內(nèi)原來(lái)保存的狀態(tài)沖突,在適當(dāng)?shù)臅r(shí)機(jī)要做好清理操作。
另外留下一個(gè)小問(wèn)題,我在 NestMenu 組件 watch 數(shù)據(jù)源的時(shí)候,選擇這樣去做:
watch((() => props.data);
而不是解構(gòu)后再去觀測(cè):
const { data } = props;
watch(() => data);
這兩者之間有區(qū)別嗎?這又是一道考察深度的面試題。
開發(fā)優(yōu)秀組件的路還是很漫長(zhǎng)的,歡迎各位也在評(píng)論區(qū)留下你的看法喲~
如果你現(xiàn)在也想學(xué)習(xí)前端開發(fā)技術(shù),在學(xué)習(xí)前端的過(guò)程當(dāng)中有遇見(jiàn)任何關(guān)于學(xué)習(xí)方法,學(xué)習(xí)路線,學(xué)習(xí)效率等方面的問(wèn)題,你都可以加入到我的Q群中:前114中6649后671,里面有許多前端學(xué)習(xí)資料以及2020大廠面試真題 點(diǎn)贊、評(píng)論、轉(zhuǎn)發(fā) 即可免費(fèi)獲取,希望能夠?qū)δ銈冇兴鶐椭?/p>
