目录
四. 初步封装下拉菜单组件 Dropdown/DropdownItem
一. 项目初始化
1.1 创建项目及删除多余代码
- 创建项目:vue create mock-zheye
- 未选择 vuex、vue-router,故需要手动在 src 根目录下添加文件:router.ts、store.ts
- 删除 helloWorld.vue 组件文件及引用
1.2 安装并测试 bootstrap 样式
- 安装 bootstrap:yarn add bootstrap@next
- App.vue 中,测试 bootstrap:
<template> <div> <button type="button" class="btn btn-primary">测试 BootStrap</button> </div> </template> <script lang="ts"> import { defineComponent } from 'vue'; // 引入 bootstrap 样式 import 'bootstrap/dist/css/bootstrap.min.css' export default defineComponent({ name: 'App', }); </script>
1.3 创建 git 仓库,添加远程链接并提交初始化代码
- 我本地使用的 gitlab / github 用户名及密码是不一致的
- 但是这无所谓,因为当你执行 git remote xx 的时候,是需要输入账户密码进行验证的
- 这个跟 SSH 啥的没啥关系,别想太多,干就完了
二. 封装专栏列表组件 ColumnList
2.1 创建 interface.ts 归纳数据类型接口
- 在 src 目录下新建 interface.ts 文件,该文件用于存储 各种数据类型接口
- 举个栗子,专栏列表的数据类型长这个样子:
// 专栏数据类型接口 export interface ColumnProps { id: number; // id title: string; // 标题 avatar?: string; // 图片 description: string; // 简介 }
2.2 初步封装专栏列表组件
- 从 interface.ts 中引入专栏数据类型接口:import { ColumnProps } from "../interface"}
- 指定专栏列表组件 接受的 数据 及 类型:props.list
错误的写法:type: Array as ColumnProps[],因为 Array 是数组的构造函数,不是类型,因此无法断言- 可以使用 PropType 接受泛型,规定构造函数的类型
- 正确的写法:type: Array as PropType<ColumnProps[]>
- 注意:模板中可以直接使用 props 接受的参数,不需要 props.list
<template> <ul> <!-- 专栏列表 --> <li v-for="column in list" :key="column.id"> <img :src="column.avatar" :alt="column.title" /> <h5>{{ column.title }}</h5> <p>{{ column.description }}</p> <a href="#"> 进入专栏 </a> </li> </ul> </template> <script lang="ts"> import { defineComponent, PropType } from 'vue' // 引入专栏列表类型接口 import { ColumnProps } from "../interface"} export default defineComponent({ name: 'ColumnList', props: { list: { type: Array as PropType<ColumnProps[]>, required: true } }, }) </script>
2.3 App.vue 中,测试专栏列表组件
- 图片使用了 阿里oss 进行大小限制
- 此处使用的 测试数据 testData 写在了 setup() 的外面,为了让模板中成功使用 testData,需要 在 setup() 中把数据 return 出去
<template> <div class="container"> <column-list :list="list"></column-list> </div> </template> // 引入专栏列表组件 及 专栏列表接口 import ColumnList from "./components/ColumnList.vue" import { ColumnProps } from "./interface" // 专栏列表测试数据 const testData: ColumnProps[] = [{ id: 1, title: 'TestOne专栏', description: '这是 TestOne专栏 www', avatar: 'http://111.jpg?x-oss-process=image/resize,m_pad,h_100,w_100' }] export default defineComponent({ name: 'App', components: { ColumnList, }, setup() { // 将模板中需要使用的数据 return 出去 return { list: testData, } }
2.4 通过 computed 处理没有数据时的默认头像
- setup() 中,接受两个参数 props/context
- 计算属性返回值是响应式的
- 【待解决问题】本来想用 require('@/assets/xx.png') 这种方法引入本地图片的,但是我发现我用不了 require,它提示我安装 @types/node,但是并没有什么x用
- 【待解决问题】我也不能使用 import 引入本地图片…
- 此处返回了 columnList 是处理过的专栏列表数据,因此模板中的绑定数据也要替换
setup(props) { const columnList = computed(() => { return props.list.map(column => { if(!column.avatar) { column.avatar = require('@/assets/column.jpg') } return column }) }) return { columnList, } }
三. 封装头部组件 GlobalHeader
3.1 封装逻辑分析
- 头部组件包含可以点击的标题,及右侧操作按钮
- 未登录的时候,右侧操作按钮应该显示:登录 / 注册
- 登录的时候,右侧操作按钮应该显示:下拉菜单(包含各种操作)
- 具体如下图所示:
3.2 定义用户信息数据类型,初步封装组件
- 用户信息数据类型:
// 用户信息接口类型 export interface UserProps { isLogin: boolean; name?: string; id?: number; }
- 通过 v-if 判断用户是否登录,决定显示下拉菜单,还是显示登录注册按钮组
<template> <nav class="navbar navbar-dark bg-primary justify-content-between mb-4 px-4"> <a class="navbar-brand" href="#">第五人格专栏</a> <!-- 用户未登录时,显示登录注册按钮 --> <ul v-if="!user.isLogin" class="list-inline mb-0"> <li class="list-inline-item"> <a href="#" class="btn btn-outline-light my-2">登录</a> </li> <li class="list-inline-item"> <a href="#" class="btn btn-outline-light my-2">注册</a> </li> </ul> <!-- 用户登录时,显示下拉菜单 --> <ul v-else class="list-inline mb-0"> <li class="list-inline-item"> <a href="#" class="btn btn-outline-light my-2">你好 {{ user.name }}</a> </li> </ul> </nav> </template> <script lang="ts"> import { defineComponent, PropType } from 'vue' import { UserProps } from '../interface' export default defineComponent({ name: 'GlobalHeader', props: { user: { type: Object as PropType<UserProps>, required: true } } }) </script>
四. 初步封装下拉菜单组件 Dropdown/DropdownItem
4.1 封装下拉菜单容器组件 Dropdown
- 点击 ”你好,xxx“ 的按钮,可以实现下拉菜单的显示与隐藏,也就是说给按钮绑定点击事件,切换 isOpen 值,即可实现 菜单展开折叠的效果
- dropdown-menu 在 bootstrap中默认为 display:none,在 GlobalHeader.vue 中是通过 v-if 判断是否显示的,因此,此处要修改为 display:block,通过变量 isOpen 决定菜单是否显示
<template> <div class="dropdown"> <!-- 下拉框切换按钮 - 点击实现菜单展开折叠的切换 --> <a href="#" @click.prevent="toggleOpen" > {{ title }} </a> <!-- 下拉菜单 --> <ul class="dropdown-menu" :style="{ display: 'block' }" v-if="isOpen"> <!-- 下拉选项 --> <li class="dropdown-item"> <a href="#">新建文章</a> </li> </ul> </div> </template> <script lang="ts"> import { defineComponent, ref } from 'vue' export default defineComponent({ name: 'Dropdown', props: { // 用户名 title: { type: String, required: true } }, setup() { // 菜单状态,默认关闭 const isOpen = ref(false); // 切换菜单状态的方法 const toggleOpen = () => { isOpen.value = !isOpen.value }; return { isOpen, toggleOpen, } } }) </script>
4.2 封装下拉菜单选项组件 DropdownItem
- 菜单选项可能需要定制化,比如禁用,同时为了更好的符合 语义结构化,可以考虑把菜单选项单独抽成一个组件
<template> <!-- 如果传入的 disabled 是 true,则动态添加类 is-disabled --> <li class="dropdown-option" :class="{ 'is-disabled': disabled }"> <!-- 通过插槽自定义菜单内容 --> <slot></slot> </li> </template> <script lang="ts"> import { defineComponent } from 'vue' export default defineComponent({ props: { // 当前选项是否禁用 disabled: { type: Boolean, default: false } } }) </script> <style> .dropdown-option.is-disabled * { background-color: transparent; color: #6c757d; /* 阻止点击事件 */ pointer-events: none; } </style>
- 下拉菜单容器中的下拉菜单选项组件,可以通过插槽的方式放入,因此要改写下拉菜单容器
<ul class="dropdown-menu" :style="{ display: 'block' }" v-if="isOpen"> <!-- 下拉选项 - 通过插槽自定义菜单内容 --> <slot></slot> </ul>
4.3 在头部组件中使用下拉菜单组件
- 如下代码所示: 符合结构语义化效果
<!-- 使用模板字符串 --> <dropdown :title="`你好 ${user.name}`"> <dropdown-item ><a href="#" class="dropdown-item">新建文章</a></dropdown-item > <dropdown-item disabled ><a href="#" class="dropdown-item">编辑资料</a></dropdown-item > </dropdown> // 下拉菜单组件 import Dropdown from './Dropdown.vue' // 下拉菜单选项组件 import DropdownItem from './DropdownItem.vue'
五. 使用自定义函数优化下拉菜单组件
5.1 vue3 中,获取 dom 元素
- 点击菜单外部自动隐藏,需要获取菜单dom
- 在 setup() 中,没有 this,因此无法通过 this.$ref 获取 dom对象(vue2中,通过此方法获取 dom对象)
vue3 中获取 dom对象:
- 给模版添加 ref属性:<div class="dropdown" ref="dropdownRef">
- 在逻辑中,通过与 ref属性 相同的命名,获取对应 dom元素:const dropdownRef = ref<null | HTMLElement>(null)
- 将 setup() 中的 dropdownRef return 出去,返回和 ref 同名的响应式对象,就可以拿到对应的 DOM节点
- 注意,dom节点设置类型,应该使用 联合类型,因为 下拉组件可能存在,即 HTMLElement 类型,可能不存在,即 null 类型
5.2 初步实现 点击外部,隐藏菜单 的逻辑
- 在 onMounted() 时,添加整体点击事件,判断 当前点击位置 是否属于下拉菜单范围
- 如果 当前点击位置 不位于下拉菜单范围内,即点击到菜单外面了,且当前菜单处于开启状态,那就隐藏菜单
- 注意 当前菜单元素可能还不存在,因此一定要加判断,不然会报错
<div class="dropdown" ref="dropdownRef"> // 此处命名需要和 template 中的 ref 相同,才能获取 DOM对象 const dropdownRef = ref<null | HTMLElement>(null); // 判断点击是否发生在 下拉菜单 DOM元素 外面 const handler = (e: MouseEvent) => { if(dropdownRef.value) { // 如果 下拉菜单内的DOM元素 不包含 当前点击的位置,且菜单处于开启状态 if(!dropdownRef.value.contains(e.target as HTMLElement) && isOpen.value) { // 关闭菜单 isOpen.value = false; } } }; onMounted(() => { document.addEventListener("click", handler); }); onUnmounted(() => { document.removeEventListener("click", handler); }); return { // 返回和 ref 同名的响应式对象,就可以拿到对应的 DOM节点 dropdownRef, }
5.3 抽出纯逻辑的自定义函数
- 可以看出,组件本身 和 判断当前点击位置是否在菜单内 这段逻辑没啥关系,因此考虑把他抽成 自定义函数,以 useXxx.ts 为名,放在 hooks 文件夹下
- useXxx.ts 文件中,可以引入 vue文件中的东西,比如生命周期钩子等等
该自定义函数:
- 接受一个响应式dom元素
- 返回一个布尔值,即判断当前点击位置是否位于 传入 dom元素的外面
响应式 DOM节点 所属类型是 —— Ref,不是小写的 ref- 单纯的 dom对象,在 setup() 中,就不是响应式的
import { ref, onMounted, onUnmounted, Ref } from 'vue'; // 该方法判断:当前点击位置是否在某 DOM元素外部,决定是否隐藏下拉菜单 // 此方法接受一个 Ref<null | HTMLElement> 类型的 dom对象 // 注意:响应式 DOM节点 所属类型是 —— Ref,不是小写的 ref // 只是单纯的 dom对象,在setup()中就不是响应式的 const useClickOutside = (elementRef: Ref<null | HTMLElement>) => { // 是否点击到了外面,默认没有 const isClickOutside = ref(false); // 判断点击是否发生在 下拉菜单 DOM元素 外面 const handler = (e: MouseEvent) => { // 如果节点存在 if(elementRef.value) { if(elementRef.value.contains(e.target as HTMLElement)) { isClickOutside.value = false; } else { isClickOutside.value = true; } } }; // 添加点击事件 onMounted(() => { document.addEventListener('click', handler); }); // 移除点击事件 onUnmounted(() => { document.removeEventListener('click', handler); }); return isClickOutside; }; export default useClickOutside;
5.4 在下拉菜单中引用自定义函数
- 删掉原来的判断逻辑,引入自定义函数
// 导入自定义函数,判断点击位置 import useClickOutside from '../hooks/useClickOutside'; // 判断点击是否发生在 当前菜单DOM元素 外面 const isClickOutside = useClickOutside(dropdownRef);
- 因为点击是一个随时可能发生的事件,因此要监听该事件的变化,否则只会在 setup() 初次执行的时候进行判断,之后不会对点击事件进行判断
watch(isClickOutside, () => { // 如果当前菜单处于打开状态,并且点击到菜单外面了 if(isOpen.value && isClickOutside.value) { // 关闭菜单 isOpen.value = false; }