Vue3 仿知乎项目实战(一) —— 项目初始化、封装专栏列表 / 头部 / 下拉菜单组件、初次使用自定义函数

目录

一. 项目初始化

1.1 创建项目及删除多余代码

1.2 安装并测试 bootstrap 样式

1.3 创建 git 仓库,添加远程链接并提交初始化代码

二. 封装专栏列表组件 ColumnList

2.1 创建 interface.ts 归纳数据类型接口

2.2 初步封装专栏列表组件

2.3 App.vue 中,测试专栏列表组件 

2.4 通过 computed 处理没有数据时的默认头像 

三. 封装头部组件 GlobalHeader

3.1 封装逻辑分析

3.2 定义用户信息数据类型,初步封装组件

四. 初步封装下拉菜单组件 Dropdown/DropdownItem

4.1 封装下拉菜单容器组件 Dropdown

4.2 封装下拉菜单选项组件 DropdownItem

4.3 在头部组件中使用下拉菜单组件

五. 使用自定义函数优化下拉菜单组件

5.1 vue3 中,获取 dom 元素

5.2 初步实现 点击外部,隐藏菜单 的逻辑

5.3 抽出纯逻辑的自定义函数

5.4 在下拉菜单中引用自定义函数


一. 项目初始化

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对象:
  1. 给模版添加 ref属性:<div class="dropdown" ref="dropdownRef">
  2. 在逻辑中,通过与 ref属性 相同的命名,获取对应 dom元素:const dropdownRef = ref<null | HTMLElement>(null)
  3. 将 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文件中的东西,比如生命周期钩子等等

  • 该自定义函数:
  1. 接受一个响应式dom元素
  2. 返回一个布尔值,即判断当前点击位置是否位于 传入 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;
        }

  • 4
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lyrelion

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值