在这项目已经待有一年多了,决定复盘下所学内容。
工作内容日常就是开发和维护公共UI、业务组件和hook,所以决定从组件开始入手。
组件介绍
一个简单的菜单只需要点击展示,在点击关闭即可,如图。
菜单组件作为我最早期开发的元老组件,伴随项目迭代过于庞大,本次demo代码省略了长按打开、二级菜单、菜单中内置常用方法等一些不常用的方法,只保留核心功能。
1、你能学到什么
1、企业中小组件需要有哪些考虑
2、自定义指令和hook的实现与使用
2、组件设计需要考虑
以下经验并不适用所有项目
生命周期
公共组件设计时应该尽可能抛出完善的api,以应对各种特殊场景。
例子:
实际开发中有提供ref手动打开和关闭等api,本次代码省略了。谁知道当时为什么会有自动打开菜单的需求呢?!~
scss
// 菜单打开前
if (!beforeCreate.value()) return
// 菜单打开前的回调
emits('onBeforeCreate')
// 菜单打开后的回调
emits('onMounted')
// 选中菜单项时
emits('onSelected', item)
// 菜单关闭前
if (!beforeUnmount.value()) return
// 菜单关闭前的回调
emits('onBeforeUnmount')
// 菜单关闭后的回调
emits('onUnmounted')
组件拆分
实际开发中是提供一个默认菜单项组件,在特殊场景下菜单项变动较大时,只需要通过参数切换新的菜单项组件即可。也可通过插槽自定义。
功能拆分
通过hook和自定义指令,减少重复代码实现,也可以提供其他同事使用。
参数统一
多个组件常用参数命名应该统一,并提供更多可控参数应对项目迭代。在使用公司组件库时能减少学习成本,同时进行维护可提高效率。
例子: 将两个不同模式菜单封装为一个,当参数通用只需要v-if即可。
3、核心代码
通过event获取点击坐标
csharp
// 获取鼠标或触摸事件的客户端坐标
const getClientCoordinates = (event: MouseEvent | TouchEvent) => {
if (event instanceof TouchEvent) {
return {
clientX: event.touches[0].clientX,
clientY: event.touches[0].clientY
}
} else if (event instanceof MouseEvent) {
return {
clientX: event.clientX,
clientY: event.clientY
}
}
}
获取窗口宽高
本次是学习所以自己实现,实际工作中推荐使用vueuse的useWindowSize
useWindowSize功能实现
ini
const windowWidth = ref(document.documentElement.clientWidth)
const windowHeight = ref(document.documentElement.clientHeight)
const updateWindowSize = (cb?: Function) => {
windowWidth.value = document.documentElement.clientWidth
windowHeight.value = document.documentElement.clientHeight
cb &&
cb({
windowWidth,
windowHeight
})
}
封装成hooks和自定义指令
typescript
import { ref, onMounted, onBeforeUnmount, type App } from 'vue'
export const useWindowSize = (cb?: Function) => {
const handleResize = () => updateWindowSize(cb)
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
return {
windowWidth,
windowHeight
}
}
// 局部注册
export const windowSize = {
mounted: (el: any, binding: any) => {
const handleResize = () => updateWindowSize(binding.value)
window.addEventListener('resize', handleResize)
el._windowSizeCleanup = () => {
window.removeEventListener('resize', handleResize)
}
},
unmounted: (el: any) => {
el._windowSizeCleanup()
}
}
// 全局注册
export const install = (app: App): void => {
app.directive('windowSize', windowSize)
}
获取元素宽高
功能实现
arduino
// 创建一个弱映射表,用于存储元素和回调之间的关系
const elementMap = new WeakMap<Element, any>()
// 创建 ResizeObserver 实例,并在回调函数中处理元素大小变化事件
const elementObserver = new ResizeObserver((entries) => {
// 获取目标元素和大小
for (const { target, borderBoxSize } of entries) {
// 根据目标元素获取对应的回调
const callback = elementMap.get(target)
// 解构出目标大小的宽度和高度,并传递给回调函数
const { inlineSize: width, blockSize: height } = borderBoxSize[0]
callback?.({ width, height })
}
})
const start = (el: any, cb: any) => {
elementObserver.observe(el)
elementMap.set(el, cb)
}
const clear = (el: Element) => {
elementObserver.unobserve(el) // 取消监听
elementMap.delete(el) // 从映射表中删除元素和回调之间的关系
}
封装成hooks和自定义指令
typescript
import { onMounted, type App, onBeforeUnmount } from 'vue'
export const useResize = (el: any, cb: any) => {
onMounted(() => {
start(el, cb)
})
onBeforeUnmount(() => {
clear(cb)
})
}
// 局部注册
export const reSize = {
mounted: (el: Element, binding: any) => {
start(el, binding.value)
},
unmounted: (el: Element) => {
clear(el)
}
}
// 全局注册
export const install = (app: App): void => {
app.directive('resize', reSize)
}
位置计算
javascript
const pos = computed(() => {
// 菜单坐标
let posX
let posY
// 视图大小
let vW
let vH
// 菜单大小
let menuW
let menuH
// x 坐标
posX = posX > vW - menuW ? posX - menuW : posX
// Y 坐标
posY = posY > vH - menuH ? vH - menuH : posY
return {
left: posX + 'px',
top: posY + 'px'
}
})
4、完整代码
Menu.vue
xml
<template>
<div
@click="handleOpenMenu($event, 'click')"
@contextmenu="handleOpenMenu($event, 'contextmenu')"
@touchstart="handleOpenMenu($event, 'click')"
>
<!-- 默认插槽 触发菜单内容区域 -->
<slot></slot>
<teleport to="body">
<Transition
@beforeEnter="handleBeforeEnter"
@enter="handleEnter"
@afterEnter="handleAfterEnter"
>
<div v-if="show" class="container" :style="pos" v-resize="handleMenuViewport">
<slot name="menu">
<!-- 以下代码应该是通过组件渲染 -->
<div
v-for="(item, index) in list"
:key="item?.id || index"
class="list"
@click="handleItemCallBack(item)"
>
{{ item.name }}
</div>
</slot>
</div>
</Transition>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRefs, onMounted, onUnmounted } from 'vue'
import { useWindowSize } from './useWindowSize'
import { reSize as vResize } from './useElementResize'
import type { MenuProps } from './types'
import { menuEmits, SUPPORT_TYPE } from './types'
const props = withDefaults(defineProps<MenuProps>(), {
list: () => [
{
name: '下班',
fn: () => {
console.log('我到点下班啦!~ ')
}
},
{
name: '吃啥',
fn: () => {
console.log('这餐吃炸鸡~ ')
}
}
],
name: 'name',
fn: 'fn',
lock: true,
// 点击模式 contextmenu 右键 click 左键 all 同时触发
type: 'click',
beforeCreate: () => true,
beforeUnmount: () => true,
stop: true,
prevent: true
})
defineOptions({
name: 'SfMenu' // snowflakeMenu
})
const { beforeCreate, beforeUnmount, stop, prevent } = toRefs(props)
const timer: any = ref(null)
const handleClear = () => {
clearTimeout(timer.value)
}
const emits = defineEmits(menuEmits)
const show = ref(false)
const menuX = ref(0)
const menuY = ref(0)
/**
* 打开菜单
*/
function handleOpenMenu(e: MouseEvent | TouchEvent, type: string) {
// 类型校验
if (!SUPPORT_TYPE.includes(props.type)) return
if (props.type !== 'all' && type != props.type) return
// 菜单展开前
if (!beforeCreate.value()) return
// 阻止事件冒泡
if (stop.value) {
e.stopPropagation()
}
// 阻止默认事件
if (prevent.value) {
e.preventDefault()
}
// 打开菜单
emits('onBeforeCreate')
const { clientX, clientY } = getClientCoordinates(e)
menuX.value = clientX
menuY.value = clientY
show.value = true
emits('onMounted')
}
// 获取鼠标或触摸事件的客户端坐标
const getClientCoordinates = (event: MouseEvent | TouchEvent) => {
if (event instanceof TouchEvent) {
return {
clientX: event.touches[0].clientX,
clientY: event.touches[0].clientY
}
} else {
return {
clientX: event.clientX,
clientY: event.clientY
}
}
}
// 菜单项事件点击 调用回调函数
function handleItemCallBack(item: any) {
emits('onSelected', item)
item[props.fn] && item[props.fn]()
}
function handleCloseMenu() {
if (!beforeUnmount.value()) return
emits('onBeforeUnmount')
show.value = false
emits('onUnmounted')
}
// 获取 视图大小
const { windowWidth, windowHeight } = useWindowSize()
// 获取菜单大小
const w = ref(0)
const h = ref(0)
const handleMenuViewport = (size: any) => {
w.value = size.width
h.value = size.height
}
// 菜单加载前
const handleBeforeEnter = (el: any) => {
el.style.height = 0
}
// 菜单加载后
const handleEnter = (el: any) => {
el.style.height = 'auto'
const height = el.clientHeight
h.value = height
el.style.height = 0
requestAnimationFrame(() => {
el.style.height = height + 'px'
el.style.transition = '.3s'
})
}
// 菜单离开时
const handleAfterEnter = (el: any) => {
el.style.transition = 'none'
}
// 动态计算菜单坐标
const pos = computed(() => {
// 菜单坐标
let posX = menuX.value
let posY = menuY.value
// 视图大小
let vW = windowWidth.value
let vH = windowHeight.value
// 菜单大小
let menuW = w.value
let menuH = h.value
// x 坐标
posX = posX > vW - menuW ? posX - menuW : posX
// Y 坐标
posY = posY > vH - menuH ? vH - menuH : posY
return {
left: posX + 'px',
top: posY + 'px'
}
})
onMounted(() => {
window.addEventListener('click', handleCloseMenu, { capture: true })
window.addEventListener('contextmenu', handleCloseMenu, { capture: true })
})
onUnmounted(() => {
handleClear()
window.removeEventListener('click', handleCloseMenu, { capture: true })
window.removeEventListener('contextmenu', handleCloseMenu, { capture: true })
})
</script>
<style lang="scss" scoped>
.container {
position: fixed;
border: 1px solid #e3e3e3;
background: #fff;
overflow: hidden;
width: 120px;
z-index: 999999999;
// 以下代码应该是通过组件渲染
.list {
border-radius: 8px;
border-bottom: 1px #e3e3e3;
margin: 2px;
box-sizing: border-box;
background: rgb(227, 198, 203);
padding: 12px;
height: 32px;
display: flex;
align-items: center;
}
}
</style>
type.ts
类型定义写得比较随意~
typescript
export interface listItem {
name: string
fn?: () => void
lock?: boolean
children?: any[]
id?: string | number
}
export interface MenuProps {
list: listItem[]
name?: string
fn?: string
/**
* @description 菜单展开前
*/
beforeCreate?: () => boolean
/**
* @description 菜单展开后
*/
beforeUnmount?: () => boolean
stop?: boolean
prevent?: boolean
type?: any
}
export const SUPPORT_TYPE = ['contextmenu', 'click', 'all']
export const menuEmits = {
/**
* @description 菜单展开前
*/
onBeforeCreate: () => true,
/**
* @description 菜单展开后
*/
onMounted: () => true,
/**
* @description 菜单项中时
*/
onSelected: (menu: any) => menu,
/**
* @description 菜单关闭前
*/
onBeforeUnmount: () => true,
/**
* @description 菜单关闭后
*/
onUnmounted: () => true
}
后续迭代v2版本会更新较完成的版本~ 接下来该享受愉快的周末了,看到这里的同学也该休息了!