小伙伴们很久不见,最近刚入职新公司,需求排的很满,日常是实在没时间写文章了,更新频率会变得比较慢。前端
周末在家闲着无聊,忽然小弟过来紧急求助,说是面试腾讯的时候,对方给了个 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.关注公众号「前端从进阶到入院」便可加我好友,我拉你进「前端进阶交流群」,你们一块儿共同交流和进步。