实现一个MVP版的vue3 Menu component

363 阅读3分钟

需求分析

Vue3简单实现一个MVP版本的Menu,需求分析:

  • props接收一个menuList,作为菜单选项列表进行渲染
  • 消费组件(父组件)可以通过v-model获取到当前被选中的菜单选项的值,即menuList[index]
  • 消费组件可以通过监听Menu组件的changeMenu事件来指定在菜单切换时的回调

结构搭建

MVP布局的话,单纯使用一个ul作为容器,menuList中的每一项用li渲染即可,但这里出于多种考虑,外面再包一层div.menu-container,这样的好处是:

  • 增加导航栏的布局灵活性,易于拓展。例如我们一个横向的菜单导航,左边可能几个选项,右边也有几个选项,这时候可以在div.container里面再添加一个ul,实现选项集合两个ul之间的左右布局
  • 作为一个vue组件,包裹一个div在开发者工具中看dom结构也更加清晰
  • ...

并简单添加样式,让li标签一行显示,并增加一定的间距

<template>
  <div class="menu-container">
    <ul class="menu-ul">
      <li>导航选项1</li>
      <li>导航选项2</li>
      <li>导航选项3</li>
      <li>导航选项4</li>
    </ul>
  </div>
</template>
​
<style scoped lang="less">
.menu-container {
  .menu-ul {
    display: flex; // 布局属性: li标签一行排列
    column-gap: 50px; // 样式属性: 控制flex项目之间的间隙
    li {
      list-style-type: none; // 样式属性: 去除li标签的默认小圆点样式
      background-image: linear-gradient(90deg, red, green); // 样式属性: 方便观察
    }
  }
}
</style>

效果:

Menu静态布局.png

实现props接收渲染列表

<script>中添加一个props配置项,我们图方便直接用default给一个默认值,然后<template>中也v-for替换渲染数据

<template>
  <div class="menu-container">
    <ul class="menu-ul">
      <li v-for="(menuItem, index) in menuList" :key="index">{{ menuItem }}</li>
    </ul>
  </div>
</template>
​
<script lang="ts">
import { defineComponent, PropType } from "vue";
​
export default defineComponent({
  name: "Menu",
  props: {
    menuList: {
      type: Array as PropType<string[]>,
      default() {
        return [
          "产品&市场",
          "技术&研发",
          "管理&职场",
          "活动&生活",
          "其他",
        ];
      },
    }
  },
  setup(props) {
    return {};
  },
});
</script>

效果:

Menu数据动态渲染.png

但这里有个坑,如果在setup中通过props.menuList的形式访问props属性可能会报错,我不知道具体是不是因为打包工具的原因,用vue-cli创建的基于webpack的项目上面的代码会TS报错:Property 'menuList' does not exist on type 'Readonly<LooseRequired<Readonly<ExtractPropTypes<readonly string[] | Readonly<ComponentObjectPropsOptions<Record<string, unknown>>>>> & { ...; }>>',但是基于vite创建的项目就没事。报错时如果想要在js代码中访问props属性,暂时我只想到了(props as any).menuList。针对这个错误真的很无奈...

添加v-model支持

相当于给自定义组件添加v-model,首先Menu组件中需要添加一个props属性,这个属性即为消费组件(父组件)中v-model:冒号后面的值,即父组件

App.vue

<template>
  // 即我们希望通过activeMenuInfo变量来获取Menu组件中选中的标签值,v-model:activeMenuItem,这里的activeMenuItem对应Menu组件中的props.activeMenuItem
  <Menu v-model:activeMenuItem="activeMenuInfo" :menuList="menuList"/>
</template>
​
<script setup lang="ts">
import Menu from "@/components/Menu.vue";
import {ref} from "vue";
  
const menuList = ["产品&市场", "技术&研发", "管理&职场", "活动&生活", "其他"];
const activeMenuInfo = ref(menuList[0]); // activeMenuInfo初始化值理论上应为传给Menu组件的渲染列表的第一项
</script>

Menu.vue:

...
props: {
    ...
    activeMenuItem: { // 添加activeMenuItem
      type: String
    }
  },
},

同时,给li标签绑定Menu导航切换时的回调函数changeMenu,并传入index,这样可以在changeMenu函数体中通过props.menuList[index]访问选中的新导航的值。

<Menu />组件中emits数组中添加一个"update:activeMenuItem"事件,只需要调用emit("update:activeMenuItem", value)即相当于把父组件中通过v-model:activeMenuItem="xxx"绑定的xxx变量修改为value,这里emit是从setup函数第二个对象参数中解构出来的:

<template>
  <div class="menu-container">
    <ul class="menu-ul">
      <li 
        v-for="(menuItem, index) in menuList" 
        :key="index"
        @click="changeMenu(index)"
      >
        {{ menuItem }}
      </li>
    </ul>
  </div>
</template>
​
<script lang="ts">
import { defineComponent, PropType } from "vue";
export default defineComponent({
  name: "Menu",
  props: {
    menuList: {
      type: Array as PropType<string[]>,
      default() {
        return [
          "产品&市场",
          "技术&研发",
          "管理&职场",
          "活动&生活",
          "其他",
        ];
      },
    },
    activeMenuItem: { // 这个props属性即为父组件中 v-model: 冒号后面的字段,同时也是emit事件 update: 冒号后面的字段
      type: String
    }
  },
  emits: ["update:activeMenuItem"],
  setup(props, {emit}) {
    const changeMenu = (index: number) => {
      emit("update:activeMenuItem", props.menuList[index]);  // 切换导航时修改父组件中v-model绑定的变量为props.menuList[index]
    }
    return {
      changeMenu
    };
  },
});
</script>

这样已经完成了Menu组件对v-model的支持,可以在父组件中通过watch函数监听来测试v-model绑定的变量的变化,点击导航后newValueoldValue都正常访问,证明功能实现是没有问题的。

当然一般都会给选中的导航添加一些样式,所以我们在Menu组件中维护一个被激活导航的标识变量activeIndexv-for遍历生成li时给index === activeIndexli添加样式。

实现父组件对导航切换事件的监听

这个需求很简单,只需要在Menu组件中emits数组中添加一个changeMenu事件,然后在Menu组件内部的导航切换回调中执行emit("changeMenu"),即可触发父组件中对changeMenu事件监听的回调函数。

<Menu />最终代码

gitHub仓库地址

Menu.vue:

<template>
  <div class="menu-container">
    <ul class="menu-ul">
      <li 
        v-for="(menuItem, index) in menuList" 
        :key="index"
        @click="changeMenu(index)"
        :class="{
          'active-li': index===activeIndex
        }"
      >
        {{ menuItem }}
      </li>
    </ul>
  </div>
</template>
​
<script lang="ts">
import { defineComponent, PropType, ref} from "vue";
export default defineComponent({
  name: "Menu",
  props: {
    menuList: {
      type: Array as PropType<string[]>,
      default() {
        return [
          "产品&市场",
          "技术&研发",
          "管理&职场",
          "活动&生活",
          "其他",
        ];
      },
    },
    activeMenuItem: {
      type: String
    }
  },
  emits: ["update:activeMenuItem", "changeMenu"],
  setup(props, {emit}) {
    // 选中导航的标识变量
    const activeIndex = ref(0);
    // 切换导航的回调
    const changeMenu = (index: number) => {
      activeIndex.value = index;
      emit("update:activeMenuItem", props.menuList[index]);
      emit("changeMenu");
    }
    return {
      changeMenu,
      activeIndex
    };
  },
});
</script>
​
<style scoped lang="less">
.menu-container {
  .menu-ul {
    display: flex; // 布局属性: li标签一行排列
    column-gap: 50px; // 样式属性: 控制选项间隙,在flex项目与项目之间添加间隙
    li {
      list-style-type: none; // 样式属性: 去除li标签的默认小圆点样式
      background-image: linear-gradient(
        90deg,
        red,
        green
      ); // 样式属性: 方便观察
      &.active-li {
        background-image: linear-gradient(
          270deg,
          red,
          green
        );
      }
    }
  }
}
</style>

App.vue

<template>
  <Menu 
    v-model:activeMenuItem="activeMenuInfo" 
    :menuList="menuList"
    @changeMenu="test"
  />
</template>
​
<script setup lang="ts">
import Menu from "@/components/Menu.vue";
import {ref, watch} from "vue";
const menuList = ["产品&市场", "技术&研发", "管理&职场", "活动&生活", "其他"];
const activeMenuInfo = ref(menuList[0]); // activeMenuInfo初始化值理论上应为传给Menu组件的渲染列表的第一项
watch(activeMenuInfo, (newValue, oldValue) => {
  console.log(newValue, oldValue);
})
const test = () => {
  console.log("父组件中切换导航的回调函数");
}
</script>
​
<style>
* {
  margin: 0;
  padding: 0;
}
</style>