用quasar+vue3+组合式api 实现小米商城标题栏动画
约 2933 字大约 10 分钟
2025-06-25
小米商城标题栏动画展示
小米商城标题栏动画主要特点:
- 移入时二级菜单缓慢出现;
- 移出时二级菜单缓慢消失;
- 在一级菜单之间移动时,二级菜单内容直接切换,没有过渡效果。
实现思路
纯 css 实现(❌)
首先肯定是考虑 :hover
,但是经过试验发现,:hover
可以实现鼠标移入移出时的过渡效果,但在一级菜单之间移动时,二级菜单总是有过渡效果。
纯 css 代码:
<script setup>
import { ref } from "vue";
const titles = ref([
{
name: "小米商城", path: "", children: []
},
{
name: "Xiaomi手机", path: "", children: [
{ name: "小米13", path: "" },
{ name: "小米13Pro", path: "" },
{ name: "小米11 青春活力版", path: "" },
]
}, {
name: "Redmi手机", path: "", children: [
{ name: "红米 K60", path: "" },
{ name: "红米 12C", path: "" },
{ name: "红米 Note 12", path: "" },
]
},
{
name: "电视", path: "", children: [
{ name: "智能电视X65", path: "" },
{ name: "小米透明电视", path: "" },
{ name: "小米电视 大师 77", path: "" },
{ name: "小米电视 大师 65英寸", path: "" },
]
},
{
name: "笔记本", path: "", children: [
{ name: "Xiaomi BookAir 13", path: "" },
{ name: "Xiaomi Book Pro14", path: "" },
]
}
])
</script>
<template>
<q-page>
<header class="row q-pa-lg bg-grey justify-center">
<div class="container row justify-center bg-yellow">
<!-- 一级标题 -->
<div class="menu text-h5 col text-center" vfor="menu in titles" :key="menu.name">
<span>{{ menu.name }}</span>
<!-- 二级标题 -->
<ul class="sub-menu row justify-center b-green">
<li class="q-ma-lg" v-for="submenu in menu.cildren" :key="submenu.name" @click="clickSubmenu(submenu)">
{{ submenu.name }}
</li>
</ul>
</div>
</div>
</header>
<div>
<h4 class="text-center">模拟小米官网titlebar动画</h4>
<ul class="text-body1">动画特点如下:
<li>鼠标从container外部移入任意一级菜单(有cildren)时,显示二级菜单(有过渡效果)</li>
<li>鼠标从一级菜单(有children)移出container时,二菜单消失(有过渡效果)</li>
<li>鼠标从一级菜单(有children)移入一级菜单(无cildren)等同于移出container:二级菜单消失(有过渡效果)</li>
<li>--------------- 以上可以用 纯css 实现 --------------</li>
<li>--------------- 以下要用 js 实现 --------------</li>
<li>鼠标在一级菜单(有children)之间移动时,二级菜单容切换(没有过渡效果)</li>
<li>鼠标在一级菜单(有children)之间移动、然后移出cntainer,二级菜单消失(有过渡效果)</li>
</ul>
</div>
</q-page>
</template>
<style scoped>
.container ul {
padding: 0;
margin: 0;
list-style: none;
}
/* 包裹所有标题tab的容器 */
.container {
position: relative;
width: 100%;
}
/* 一级标题样式 */
.menu {
border: 1px solid blue;
}
/* 二级标题样式 */
.sub-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
}
.sub-menu {
/* 这里不需要设置 transition */
/* 初始化,必需 */
max-height: 0;
overflow: hidden;
transition: all 1s;
}
.menu:nth-child(n):hover .sub-menu {
max-height: 200px;
transition: all 1s;
}
</style>
css+js 实现(✅ )
既然纯 css 实现不了,那么就要考虑 js。主要思路是用 css 设置了基本样式之后:
用 js 监听:当鼠标在一级菜单之间移入移出时,如果是一级菜单之间的切换(没有移出包裹一级菜单的容器),就设置
transition:all 0s
; 反之则设置transition:all 1s
;,因此选择mouseenter+ mouseleave
。mouseenter
:当一个定点设备(通常指鼠标)第一次移动到触发事件元素中的激活区域时触发;mouseleave
:事件在定点设备(通常是鼠标)的指针移出某个元素时被触发。mouseover
:当鼠标移动到一个元素上时,会在这个元素上触发mouseover
事件。mouseout
:当移动指针设备(通常是鼠标),使指针不再包含在这个元素或其子元素中时,mouseout
事件被触发。
html 部分
<template>
<q-page>
<header class="row q-pa-lg bg-grey justify-center">
<div class="container row justify-center bg-yellow">
<!-- 一级标题 -->
<div
class="menu text-h5 col text-center"
v-for="menu in titles"
:key="menu.name"
>
<span>{{ menu.name }}</span>
<!-- 二级标题 -->
<ul class="sub-menu row justify-center bg-green">
<li
class="q-ma-lg"
v-for="submenu in menu.children"
:key="submenu.name"
@click="clickSubmenu(submenu)"
>
{{ submenu.name }}
</li>
</ul>
</div>
</div>
</header>
<div>
<h4 class="text-center">模拟小米官网titlebar动画</h4>
<ul class="text-body1">
动画特点如下:
<li>
鼠标从container外部移入任意一级菜单(有children)时,显示二级菜单(有过渡效果)
</li>
<li>
鼠标从一级菜单(有children)移出container时,二级菜单消失(有过渡效果)
</li>
<li>
鼠标从一级菜单(有children)移入一级菜单(无children)等同于移出container:二级菜单消失(有过渡效果)
</li>
<li>--------------- 以上可以用 纯css 实现 👆--------------</li>
<li>--------------- 以下要用 js 实现 👇--------------</li>
<li>
鼠标在一级菜单(有children)之间移动时,二级菜单内容切换(没有过渡效果)
</li>
<li>
鼠标在一级菜单(有children)之间移动、然后移出container,二级菜单消失(有过渡效果)
</li>
</ul>
</div>
</q-page>
</template>
css 部分
<style scoped>
.container ul {
padding: 0;
margin: 0;
list-style: none;
}
/* 包裹所有标题tab的容器 */
.container {
position: relative;
width: 100%;
}
/* 一级标题样式 */
.menu {
border: 1px solid blue;
}
/* 二级标题样式 */
.sub-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
}
.sub-menu {
/* 这里不需要设置 transition */
/* 初始化,必需 */
max-height: 0;
overflow: hidden;
}
</style>
js 部分(以下“一级菜单”用 menus、menu
表示,包裹所有一级菜单的容器用 container
表示,“二级菜单”用 subMenus、submenu
表示):
首先找到 dom
元素,并新建一个数组变量记录已经被 hover
过的一级元素 menu
:
let container = document.querySelector(".container");
let menus = document.querySelectorAll(".menu");
let subMenus = document.querySelectorAll(".sub-menu");
let checkedMenus = ref([false]);
initCheckMenu();
/* 初始化 checkedMenus ,全都设为 false ,即此时所有 menu 都没有被hover */
function initCheckMenu() {
// index = 0 始终是false,因为它没有 children
checkedMenus = ref([false]);
for (let i = 1; i < menus.length; i++) {
checkedMenus.value.push(false);
}
}
因为默认情况下鼠标肯定是从 container
外移入 menu
的,所以刚开始就要设定过度时间为 0.5s
:
setSubMenuLeaveTrans();
/* 设置所有 submenu 的过渡效果 */
function setSubMenuLeaveTrans() {
menus.forEach((menu, index) => {
// 设置 css 的代码顺序不能变,否则没有过渡效果
subMenus[index].style = "max-height: 0px;overflow: hidden;";
subMenus[index].style.transition = "all .5s";
});
}
然后可以先考虑鼠标移出 container
的情况,此时肯定有过渡效果。而且鼠标离开 container
后,记录有几个 menu
被 hover
过的变量 checkedMenus
要恢复默认值全 false
。所以先监听 container
的鼠标离开事件:
// 离开 container-nav(一定离开了 menu),离开需要动画
container.addEventListener("mouseleave", () => {
setSubMenuLeaveTrans();
initCheckMenu();
});
/* 初始化 checkedMenus ,全都设为 false ,即此时所有 menu 都没有被hover */
function initCheckMenu() {
// index = 0 始终是false,因为它没有 children
checkedMenus = ref([false]);
for (let i = 1; i < menus.length; i++) {
checkedMenus.value.push(false);
}
}
/* 设置所有 submenu 的过渡效果 */
function setSubMenuLeaveTrans() {
menus.forEach((menu, index) => {
// 设置 css 的代码顺序不能变,否则没有过渡效果
subMenus[index].style = "max-height: 0px;overflow: hidden;";
subMenus[index].style.transition = "all .5s";
});
}
下一步考虑鼠标进入和离开 menu
的事件:
menus.forEach((menu, index) => {
// 离开 menus[index] 但没有离开 container-nav
menu.addEventListener("mouseleave", () => {
subMenus[index].style = "max-height: 0px;overflow: hidden;";
if (index == 1) {
/*
如果离开的是第2个menu(第一个menu没有子菜单):
-- 移出时menu,但没有移出container: 需要动画
-- 移出时menu + 移出container: 需要动画
*/
subMenus[index].style.transition = "all .5s";
} else {
/*
如果离开的其其她menu:
-- 移出时menu,但没有移出container:必然是在 tab 之间移动,不需要动画
-- 移出时menu + 移出container: 被 container.mouseleave 的动画覆盖
*/
subMenus[index].style.transition = "all 0s";
}
});
menu.addEventListener("mouseenter", () => {
if (index != 0) {
checkedMenus.value[index] = true;
subMenus[index].style = "max-height: 200px;";
} else {
initCheckMenu();
}
// console.log('checkedMenus', checkedMenus.value);
if (countCheckedMenu(checkedMenus.value) > 1) {
// console.log('已经有被选中的 menu,hover不需要动画');
subMenus[index].style.transition = "all 0s";
} else {
// console.log('没有被选中的 menu,hover需要动画');
subMenus[index].style.transition = "all .5s";
}
});
});
css+js 实现 全部代码如下
<script setup>
import { ref } from "vue";
import { onMounted } from "vue";
const titles = ref([
{
name: "小米商城", path: "", children: []
},
{
name: "Xiaomi手机", path: "", children: [
{ name: "小米13", path: "" },
{ name: "小米13Pro", path: "" },
{ name: "小米11 青春活力版", path: "" },
]
}, {
name: "Redmi手机", path: "", children: [
{ name: "红米 K60", path: "" },
{ name: "红米 12C", path: "" },
{ name: "红米 Note 12", path: "" },
]
},
{
name: "电视", path: "", children: [
{ name: "智能电视X65", path: "" },
{ name: "小米透明电视", path: "" },
{ name: "小米电视 大师 77", path: "" },
{ name: "小米电视 大师 65英寸", path: "" },
]
},
{
name: "笔记本", path: "", children: [
{ name: "Xiaomi BookAir 13", path: "" },
{ name: "Xiaomi Book Pro14", path: "" },
]
}
])
onMounted(() => {
let container = document.querySelector(".container");
let menus = document.querySelectorAll(".menu");
let subMenus = document.querySelectorAll(".sub-menu");
let checkedMenus = ref([false]);
initCheckMenu();
setSubMenuLeaveTrans();
// 离开 container-nav(一定离开了 menu),离开需要动画
container.addEventListener("mouseleave", () => {
setSubMenuLeaveTrans();
initCheckMenu();
})
menus.forEach((menu, index) => {
// 离开 menus[index] 但没有离开 container-nav
menu.addEventListener("mouseleave", () => {
subMenus[index].style = "max-height: 0px;overflow: hidden;"
if (index == 1) {
/*
如果离开的是第2个menu(第一个menu没有子菜单):
-- 移出时menu,但没有移出container: 需要动画
-- 移出时menu + 移出container: 需要动画
*/
subMenus[index].style.transition = "all .5s";
} else {
/*
如果离开的其其她menu:
-- 移出时menu,但没有移出container:必然是在 tab 之间移动,不需要动画
-- 移出时menu + 移出container: 被 container.mouseleave 的动画覆盖
*/
subMenus[index].style.transition = "all 0s";
}
})
menu.addEventListener("mouseenter", () => {
if (index != 0) {
checkedMenus.value[index] = true;
subMenus[index].style = "max-height: 200px;";
} else {
initCheckMenu();
}
// console.log('checkedMenus', checkedMenus.value);
if (countCheckedMenu(checkedMenus.value) > 1) {
// console.log('已经有被选中的 menu,hover不需要动画');
subMenus[index].style.transition = "all 0s";
} else {
// console.log('没有被选中的 menu,hover需要动画');
subMenus[index].style.transition = "all .5s";
}
})
})
/* 初始化 checkedMenus ,全都设为 false ,即此时所有 menu 都没有被hover */
function initCheckMenu() {
// index = 0 始终是false,因为它没有 children
checkedMenus = ref([false]);
for (let i = 1; i < menus.length; i++) {
checkedMenus.value.push(false);
}
}
/* 设置所有 submenu 的过渡效果 */
function setSubMenuLeaveTrans() {
menus.forEach((menu, index) => {
// 设置 css 的代码顺序不能变,否则没有过渡效果
subMenus[index].style = "max-height: 0px;overflow: hidden;"
subMenus[index].style.transition = "all .5s";
})
}
/* 计算共有几个menu被hover过 */
function countCheckedMenu(array) {
return array.filter((e) => e == true).length;
}
})
function clickSubmenu(submenu) {
console.log(submenu.name);
}
</script>
<template>
<q-page>
<header class="row q-pa-lg bg-grey justify-center">
<div class="container row justify-center bg-yellow">
<!-- 一级标题 -->
<div class="menu text-h5 col text-center" v-for="menu in titles" :key="menu.name">
<span>{{ menu.name }}</span>
<!-- 二级标题 -->
<ul class="sub-menu row justify-center bg-green">
<li class="q-ma-lg" v-for="submenu in menu.children" :key="submenu.name" @click="clickSubmenu(submenu)">
{{ submenu.name }}
</li>
</ul>
</div>
</div>
</header>
<div>
<h4 class="text-center">模拟小米官网titlebar动画</h4>
<ul class="text-body1">动画特点如下:
<li>鼠标从container外部移入任意一级菜单(有children)时,显示二级菜单(有过渡效果)</li>
<li>鼠标从一级菜单(有children)移出container时,二级菜单消失(有过渡效果)</li>
<li>鼠标从一级菜单(有children)移入一级菜单(无children)等同于移出container:二级菜单消失(有过渡效果)</li>
<li>--------------- 以上可以用 纯css 实现 👆--------------</li>
<li>--------------- 以下要用 js 实现 👇--------------</li>
<li>鼠标在一级菜单(有children)之间移动时,二级菜单内容切换(没有过渡效果)</li>
<li>鼠标在一级菜单(有children)之间移动、然后移出container,二级菜单消失(有过渡效果)</li>
</ul>
</div>
</q-page>
</template>
<style scoped>
.container ul {
padding: 0;
margin: 0;
list-style: none;
}
/* 包裹所有标题tab的容器 */
.container {
position: relative;
width: 100%;
}
/* 一级标题样式 */
.menu {
border: 1px solid blue;
}
/* 二级标题样式 */
.sub-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
}
.sub-menu {
/* 这里不需要设置 transition */
/* 初始化,必需 */
max-height: 0;
overflow: hidden;
}
</style>
css+js 实现效果
注意事项
- 项目用的 quasar 搭建,所以有些 css 样式和往常不同,属性直接写在 class 中,不用 quasar 的话可以自行转化为普通 css 样式;
- script 部分用的是 vue3 组合式 api,同样可以自行转化为选项式 api;
- 用 js 操作 dom 设置 css 样式时,
subMenus[index].style = "max-height: 0px;overflow: hidden;" subMenus[index].style.transition = "all .5s";
的顺序不要变,否则没有过渡效果; - 上一条原因参考:js 控制 transition 失效问题 、 你可能并不了解 Transition