通过一个小组件了解企业中如何设计和封装组件和hook

在这项目已经待有一年多了,决定复盘下所学内容。

工作内容日常就是开发和维护公共UI、业务组件和hook,所以决定从组件开始入手。

组件介绍

一个简单的菜单只需要点击展示,在点击关闭即可,如图。

通过一个小组件了解企业中如何设计和封装组件和hook 菜单组件作为我最早期开发的元老组件,伴随项目迭代过于庞大,本次demo代码省略了长按打开、二级菜单、菜单中内置常用方法等一些不常用的方法,只保留核心功能。

1、你能学到什么

1、企业中小组件需要有哪些考虑

2、自定义指令和hook的实现与使用

2、组件设计需要考虑

以下经验并不适用所有项目

生命周期

公共组件设计时应该尽可能抛出完善的api,以应对各种特殊场景。

例子:

实际开发中有提供ref手动打开和关闭等api,本次代码省略了。谁知道当时为什么会有自动打开菜单的需求呢?!~

 // 菜单打开前
 if (!beforeCreate.value()) return
 // 菜单打开前的回调
 emits('onBeforeCreate')
 // 菜单打开后的回调
 emits('onMounted')
 // 选中菜单项时
 emits('onSelected', item)
 // 菜单关闭前
 if (!beforeUnmount.value()) return
 // 菜单关闭前的回调
 emits('onBeforeUnmount')
 // 菜单关闭后的回调
 emits('onUnmounted')

组件拆分

实际开发中是提供一个默认菜单项组件,在特殊场景下菜单项变动较大时,只需要通过参数切换新的菜单项组件即可。也可通过插槽自定义。

功能拆分

通过hook和自定义指令,减少重复代码实现,也可以提供其他同事使用。

参数统一

多个组件常用参数命名应该统一,并提供更多可控参数应对项目迭代。在使用公司组件库时能减少学习成本,同时进行维护可提高效率。

例子: 将两个不同模式菜单封装为一个,当参数通用只需要v-if即可。

通过一个小组件了解企业中如何设计和封装组件和hook

3、核心代码

通过event获取点击坐标

 ​
 // 获取鼠标或触摸事件的客户端坐标
 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功能实现

 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和自定义指令

 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)
 }
 ​

获取元素宽高

功能实现

 ​
 // 创建一个弱映射表,用于存储元素和回调之间的关系
 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和自定义指令

 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)
 }
 ​

位置计算

 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

 <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

类型定义写得比较随意~

 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版本会更新较完成的版本~ 接下来该享受愉快的周末了,看到这里的同学也该休息了!

原文链接:https://juejin.cn/post/7349025789537304602 作者:百思不得小李

(0)
上一篇 2024年3月22日 下午5:13
下一篇 2024年3月23日 上午10:05

相关推荐

发表回复

登录后才能评论