Vue 案例
作者:xie392地址:https://github.com/xie392/duyi-demo/tree/main/example/vue-example更新时间:2024-12-23
右键菜单组件的封装
contextmenu.vue
1<script setup lang="ts">2import { onMounted, onUnmounted, ref } from 'vue'3import { useBeforeEnter, useEnter, useAfterEnter } from '@/hooks/useEnter'45interface MenuOptions {6label: string7[key: string]: any8}910interface PropsOptions {11menu: MenuOptions[]12}1314defineProps<PropsOptions>()1516const containerRef = ref<HTMLElement | null>(null)17const contextRef = ref<HTMLElement | null>(null)1819const emit = defineEmits(['select'])20let x = ref(0)21let y = ref(0)22let show = ref(false)2324const open = (e: MouseEvent) => {25e.preventDefault()26e.stopPropagation()27show.value = true28x.value = e.clientX29y.value = e.clientY30if (contextRef.value) {31useEnter(contextRef.value)32useAfterEnter(contextRef.value)33}34}3536const close = (e: MouseEvent) => {37// 这里是阻止默认行为,不阻止的话鼠标右键快速点击会触发浏览器默认行为38e.preventDefault()39e.stopPropagation()40show.value = false41if (contextRef.value) {42useBeforeEnter(contextRef.value)43useAfterEnter(contextRef.value)44}45}4647onMounted(() => {48containerRef.value && containerRef.value.addEventListener('contextmenu', open)49window.addEventListener('click', close, true)50window.addEventListener('contextmenu', close)51contextRef.value && useBeforeEnter(contextRef.value)52})5354onUnmounted(() => {55containerRef.value && containerRef.value.removeEventListener('contextmenu', open)56})5758const handleClick = (item: MenuOptions) => {59show.value = false60emit('select', item)61}62</script>6364<template>65<div class="container" ref="containerRef">66<slot name="default"></slot>67<Teleport to="body">68<Transition>69<div class="context-menu" :style="{ left: `${x}px`, top: `${y}px` }" ref="contextRef">70<div class="context-menu-list">71<div class="context-menu-list-item" v-for="v, i in menu" :key="i" @click="handleClick(v)">{{ v.label72}}</div>73</div>74</div>75</Transition>76</Teleport>77</div>78</template>7980<style scoped>81.context-menu {82position: fixed;83z-index: 99;84background: #fff;85box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);86height: auto;87overflow: hidden;88}8990.context-menu-list {91padding: 0.2rem 0;92box-sizing: border-box;93}9495.context-menu-list-item {96padding: 0.35rem 1rem;97box-sizing: border-box;98cursor: pointer;99transition: 0.3s;100font-size: 0.78rem;101}102103.context-menu-list-item:not(:last-of-type) {104border-bottom: 1px solid #ddd;105}106107.context-menu-list-item:hover {108background: #d5d5d5;109}110</style>
hooks/useEnter.ts
1export const useBeforeEnter = (el: HTMLElement) => {2el.style.height = '0px'3}45export const useEnter = (el: HTMLElement) => {6el.style.height = 'auto'7const h = el.clientHeight8el.style.height = '0'9requestAnimationFrame(() => {10requestAnimationFrame(() => {11el.style.height = h + 'px'12el.style.transition = '.5s'13})14})15}1617export const useAfterEnter = (el: HTMLElement) => {18el.style.transition = 'none'19}
AJAX进度监控
使用 xhr
1async function xhrRequest(options: { url: string, method?: 'GET' | 'POST', data?: any }) {2return new Promise((resolve, reject) => {3const { url, method = 'GET', data = null } = options4const xhr = new XMLHttpRequest()56xhr.addEventListener('progress',onProgress)7xhr.upload.addEventListener('progress',onProgress)89xhr.open(method, url)10xhr.setRequestHeader('Content-Type', 'application/json')11xhr.responseType = 'json'1213xhr.send(data)1415xhr.addEventListener('load', () => {16resolve(xhr.response)17})1819xhr.addEventListener('error', () => {20reject(xhr.response)21})22})23}2425/**26* 可以在这里实现 loading 效果27* @param event28*/29function onProgress(event:ProgressEvent<XMLHttpRequestEventTarget>) {30console.log("onProgress",event)31if (event.lengthComputable) {32const percentComplete = (event.loaded / event.total) * 100;33console.log('下载进度:' + percentComplete + '%');34} else {35console.log('无法计算总大小的进度更新');36}37}3839export default xhrRequest
例子:
1import xhrRequest from '@/utils/xhr'23const xhrGet = async () => {4const res = await xhrRequest({ url:'/api/request', method: 'GET' })5console.log(res)6}
使用 fetch
1function fetchRequest(options: { url: string, method: 'GET' | 'POST', data?: any }) {2const { url, method = 'GET', data = {} } = options34return new Promise( async (resolve) => {5const resp = await fetch(url, {6method,7headers: {8'Content-Type': 'application/json;charset=UTF-8'9},10// body: JSON.stringify(data )11})12// 获取响应体的长度13const total = Number(resp.headers.get('Content-Length'))14// 获取响应体的编码15const decoder = new TextDecoder()16let body = ''17const reader = (resp.body as any).getReader()18let loaded = 019while(1) {20const { done, value } = await reader.read()21if (done) {22break23}24loaded += value?.length ?? 025body += decoder.decode(value)26onProgress({ loaded, total })27}28// resolve(resp)29})30}3132function onProgress({ loaded, total }: { loaded: number, total: number }) {33console.log('下载进度:' + loaded / total * 100 + '%')34}3536export default fetchRequest
例子:
1import fetchRequest from '@/utils/fetch'23const fetchGet = async () => {4const res = await fetchRequest({ url:'/request', method: 'GET' })5console.log(res)6}
请求取消
1<script setup lang="ts">2import { ElSelect, ElOption } from 'element-plus'3import { ref } from 'vue'45interface ListItem {6value: string7label: string8}910const options = ref<ListItem[]>([])11const value = ref<string[]>([])12const loading = ref(false)131415let controller: AbortController16const remoteMethod = async (query: string) => {17if (query === '') {18options.value = []19return20}21// 如果有正在请求的请求, 就取消请求22controller && controller.abort()2324// 如果需要终止请求, 只需要调用 controller.abort() 即可25controller = new AbortController()2627const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal }).then(res => res.json())28options.value = res.data.map((v: string) => ({ value: v, label: v }))29}30</script>3132<template>33<h2>请求取消</h2>34<el-select v-model="value" filterable remote reserve-keyword :remote-method="remoteMethod" :loading="loading"35style="width:500px;" placeholder="请输入关键词">36<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />37</el-select>38</template>
利用自定义 ref 实现防抖
1<script setup lang="ts">2import { customRef } from 'vue'34const debounceRef = (value: any, duration: number = 1000) => {5let timer: string | number | NodeJS.Timeout | undefined;6return customRef((track, trigger) => {7return {8get() {9// 收集依赖10track()11return value12},13set(val) {14clearTimeout(timer)15timer = setTimeout(() => {16// 触发依赖17trigger()18value = val19}, duration)2021}22}23})24}25const text = debounceRef('',500)26</script>2728<template>29<div>30<h2>自定义 ref 实现防抖</h2>31<input v-model="text"/>32<p>{{ text }}</p>33</div>34</template>
使用defer优化白屏时间
1import { ref } from "vue"23export function useDefer(max:number = 100) {4const frameCount = ref<number>(0)5let refId:number6function updateFrameCount() {7refId = requestAnimationFrame(() => {8frameCount.value++9if(frameCount.value >= max) {10return11}12updateFrameCount()13})14}15updateFrameCount()1617// 如果取消渲染,那么页面就是空白页18onMounted(() => {19cancelAnimationFrame(refId)20})2122// return function defer(n:number) {23// return frameCount.value >= n24// }2526return {27defer: (n:number) =>frameCount.value >= n,28cancelDefer: () => cancelAnimationFrame(refId)29}30}
使用
1<script setup lang="ts">2import Defer from '@/components/defer.vue'34const {defer} = useDefer(1000)5</script>67<template>8<div>9<h1>使用defer优化白屏时间 </h1>10<div v-for="i in 1000" :key="i" class="list-all">11<div style="width: 40px;"> {{ i < 10 ? '0' + i : i }}</div>12<Defer v-if="defer(i)" class="list"/>13</div>14</div>15</template>1617<style scoped>1819.list-all {20display: flex;21column-gap: 5px;22}23.list {24width: 100%;25display: flex;26flex-wrap: wrap;27gap: 10px;28margin-bottom: 10px;29}30</style>
如何封装命令式组件
1import { createApp } from "vue"2import MessageBox from "@/components/message-box.vue"34function showMsg(msg: string, callback: Function) {5const msgBox = document.createElement('div')6document.body.appendChild(msgBox)78// 创建组件9const app = createApp(MessageBox, {10msg,11handleClick() {12callback && callback()13app.unmount()14document.body.removeChild(msgBox)15}16})17// 渲染组件18app.mount(msgBox)19}2021export default showMsg
MessageBox.vue
1<script setup lang="ts">2interface PropsOptions {3msg: string4handleClick: () => void5}67defineProps<PropsOptions>()89</script>1011<template>12<div class="message-box">13<div class="message-content">14<div class="message">{{ msg }}></div>15<button @click="handleClick">确定</button>16</div>17</div>18</template>
使用:
1<script setup lang="ts">2import { ElButton } from 'element-plus'3import showMsg from '@/composables/showMsg'45const open = () => {6showMsg('你好', () => {78})9}10</script>1112<template>13<h2>如何封装命令式组件</h2>14<el-button type="primary" @click="open">打开弹窗</el-button>15</template>
自定义指令 reszie
1import { Directive } from 'vue'23const map = new WeakMap<HTMLElement, (size: { width: number, height: number }) => void>()45const ob = new ResizeObserver((entries) => {6for (const entry of entries) {7const handler = map.get(entry.target as HTMLElement)89if (handler) {10handler({11width: entry.borderBoxSize[0].inlineSize,12height: entry.borderBoxSize[0].blockSize13})14}1516}17})1819export const resize: Directive = {20mounted(el:HTMLElement, binding:any) {21map.set(el, binding.value)22ob.observe(el)2324},25unmounted(el: HTMLElement) {26map.delete(el)27ob.unobserve(el)28}29}3031export default resize
需要在 main.ts
中注册指令:
1const app = createApp(App)23app.directive('resize', resize)4app.mount('#app')
使用:
1<script setup lang="ts">2import { reactive } from 'vue'34const sizes = reactive<{width:number,height:number}>({width:0,height:0})56const handleSizeChange = (size:{width:number,height:number}) => {7console.log('handleSizeChange',size)8sizes.width = size.width9sizes.height = size.height10}11</script>1213<template>14<div ref="testRef">15<h2>自定义指令 reszie</h2>16<div >width:{{ sizes.width }} Height:{{ sizes.height }} {{$route.meta}}</div>17<textarea v-resize="handleSizeChange"></textarea>18</div>19</template>
数据的流式获取
后端:
1import type { MockMethod } from 'vite-plugin-mock'2import { faker } from '@faker-js/faker'34const content = faker.lorem.paragraph({ min: 3, max: 5 })56export default [7{8url: '/api/stream',9method: 'post',10// response: () => {11// return response(200, 'success', {content})12// },13rawResponse: async (req, res) => {14// 设置响应头,指定内容类型为流15res.setHeader('Content-Type', 'application/octet-stream');16// 创建一个可写流,并将其绑定到响应对象17const stream = res;1819// 将内容分成小块,每个小块一个字符,并逐个发送20for (const char of content) {21stream.write(char);22await new Promise(resolve => setTimeout(resolve, 100)); // 添加延迟以模拟逐字逐字发送23}24res.end()25}26},27] as MockMethod[]
使用:
1<script setup lang="ts">2import { ElButton } from 'element-plus'3import { ref } from 'vue'45const content = ref<string>('')67const getStream = async () => {8content.value = ''9const resp = await fetch('/api/stream', {10method: 'POST',11headers: {12'Content-Type': 'application/json'13},14body: JSON.stringify({15content: 'hello world'16})17})18if (!resp.body) return19const reader = resp.body.getReader()2021while (true) {22const { done, value } = await reader.read()23if (done) break24content.value += new TextDecoder().decode(value)25}26}2728</script>2930<template>31<div>32<h2>数据的流式获取</h2>33<el-button @click="getStream" type="primary">获取数据</el-button>34<p>{{ content }}</p>35</div>36</template>3738<style scoped></style>
watchEffect 中的异步问题
1<script setup lang="ts">2import { ElButton } from 'element-plus'3import { ref, watchEffect } from 'vue'45const speed = ref<number>(0)6const videoRef = ref<HTMLVideoElement | null>()78watchEffect(() => {9if (videoRef.value) {10videoRef.value.playbackRate = speed.value11}12})1314</script>1516<template>17<h2>watchEffect 中的异步问题</h2>18<div class="container">19<video src="@/assets/ikun.mp4" controls ref="videoRef" width="400" ></video>20<div class="btns">21<el-button type="primary" :disabled="speed === 0.5" @click="speed -= 0.5">-0.5</el-button>22<span>{{ speed.toFixed(1) }}</span>23<el-button type="primary" :disabled="speed === 3" @click="speed += 0.5">+0.5</el-button>24</div>25</div>26</template>2728<style scoped>29.container {30display: flex;31flex-direction: column;32align-items: center;33gap: 10px;34}3536.btns {37display: flex;38gap: 20px;39align-items: center;40}41</style>
约定式路由
1import { createRouter, createWebHistory } from 'vue-router'23// 拿到所有的路由,只包含到二级目录下的 vue 文件4const pages = import.meta.glob('../views/**/*.vue',/* { eager: true, import: 'default' } */)567const routesList = Object.entries(pages).map(([path]) => {8const pagePath = path.replace('../views/', '').replace('.vue', '') ?? '/'9const name = pagePath.split('/').filter(Boolean).join('-') ?? 'index'10return {11path: '/' + name,12name,13component: pages[path],14meta: {}15}16})1718const routes = [19...routesList20]2122console.log("routes", routes)232425const router = createRouter({26history: createWebHistory(import.meta.env.BASE_URL),27routes28})2930export default router
目录