Vue 案例

作者:xie392地址:https://github.com/xie392/duyi-demo/tree/main/example/vue-example更新时间:2024-12-23

右键菜单组件的封装

contextmenu.vue

1
<script setup lang="ts">
2
import { onMounted, onUnmounted, ref } from 'vue'
3
import { useBeforeEnter, useEnter, useAfterEnter } from '@/hooks/useEnter'
4
5
interface MenuOptions {
6
label: string
7
[key: string]: any
8
}
9
10
interface PropsOptions {
11
menu: MenuOptions[]
12
}
13
14
defineProps<PropsOptions>()
15
16
const containerRef = ref<HTMLElement | null>(null)
17
const contextRef = ref<HTMLElement | null>(null)
18
19
const emit = defineEmits(['select'])
20
let x = ref(0)
21
let y = ref(0)
22
let show = ref(false)
23
24
const open = (e: MouseEvent) => {
25
e.preventDefault()
26
e.stopPropagation()
27
show.value = true
28
x.value = e.clientX
29
y.value = e.clientY
30
if (contextRef.value) {
31
useEnter(contextRef.value)
32
useAfterEnter(contextRef.value)
33
}
34
}
35
36
const close = (e: MouseEvent) => {
37
// 这里是阻止默认行为,不阻止的话鼠标右键快速点击会触发浏览器默认行为
38
e.preventDefault()
39
e.stopPropagation()
40
show.value = false
41
if (contextRef.value) {
42
useBeforeEnter(contextRef.value)
43
useAfterEnter(contextRef.value)
44
}
45
}
46
47
onMounted(() => {
48
containerRef.value && containerRef.value.addEventListener('contextmenu', open)
49
window.addEventListener('click', close, true)
50
window.addEventListener('contextmenu', close)
51
contextRef.value && useBeforeEnter(contextRef.value)
52
})
53
54
onUnmounted(() => {
55
containerRef.value && containerRef.value.removeEventListener('contextmenu', open)
56
})
57
58
const handleClick = (item: MenuOptions) => {
59
show.value = false
60
emit('select', item)
61
}
62
</script>
63
64
<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.label
72
}}</div>
73
</div>
74
</div>
75
</Transition>
76
</Teleport>
77
</div>
78
</template>
79
80
<style scoped>
81
.context-menu {
82
position: fixed;
83
z-index: 99;
84
background: #fff;
85
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
86
height: auto;
87
overflow: hidden;
88
}
89
90
.context-menu-list {
91
padding: 0.2rem 0;
92
box-sizing: border-box;
93
}
94
95
.context-menu-list-item {
96
padding: 0.35rem 1rem;
97
box-sizing: border-box;
98
cursor: pointer;
99
transition: 0.3s;
100
font-size: 0.78rem;
101
}
102
103
.context-menu-list-item:not(:last-of-type) {
104
border-bottom: 1px solid #ddd;
105
}
106
107
.context-menu-list-item:hover {
108
background: #d5d5d5;
109
}
110
</style>

hooks/useEnter.ts

1
export const useBeforeEnter = (el: HTMLElement) => {
2
el.style.height = '0px'
3
}
4
5
export const useEnter = (el: HTMLElement) => {
6
el.style.height = 'auto'
7
const h = el.clientHeight
8
el.style.height = '0'
9
requestAnimationFrame(() => {
10
requestAnimationFrame(() => {
11
el.style.height = h + 'px'
12
el.style.transition = '.5s'
13
})
14
})
15
}
16
17
export const useAfterEnter = (el: HTMLElement) => {
18
el.style.transition = 'none'
19
}

AJAX进度监控

使用 xhr

1
async function xhrRequest(options: { url: string, method?: 'GET' | 'POST', data?: any }) {
2
return new Promise((resolve, reject) => {
3
const { url, method = 'GET', data = null } = options
4
const xhr = new XMLHttpRequest()
5
6
xhr.addEventListener('progress',onProgress)
7
xhr.upload.addEventListener('progress',onProgress)
8
9
xhr.open(method, url)
10
xhr.setRequestHeader('Content-Type', 'application/json')
11
xhr.responseType = 'json'
12
13
xhr.send(data)
14
15
xhr.addEventListener('load', () => {
16
resolve(xhr.response)
17
})
18
19
xhr.addEventListener('error', () => {
20
reject(xhr.response)
21
})
22
})
23
}
24
25
/**
26
* 可以在这里实现 loading 效果
27
* @param event
28
*/
29
function onProgress(event:ProgressEvent<XMLHttpRequestEventTarget>) {
30
console.log("onProgress",event)
31
if (event.lengthComputable) {
32
const percentComplete = (event.loaded / event.total) * 100;
33
console.log('下载进度:' + percentComplete + '%');
34
} else {
35
console.log('无法计算总大小的进度更新');
36
}
37
}
38
39
export default xhrRequest

例子:

1
import xhrRequest from '@/utils/xhr'
2
3
const xhrGet = async () => {
4
const res = await xhrRequest({ url:'/api/request', method: 'GET' })
5
console.log(res)
6
}

使用 fetch

1
function fetchRequest(options: { url: string, method: 'GET' | 'POST', data?: any }) {
2
const { url, method = 'GET', data = {} } = options
3
4
return new Promise( async (resolve) => {
5
const resp = await fetch(url, {
6
method,
7
headers: {
8
'Content-Type': 'application/json;charset=UTF-8'
9
},
10
// body: JSON.stringify(data )
11
})
12
// 获取响应体的长度
13
const total = Number(resp.headers.get('Content-Length'))
14
// 获取响应体的编码
15
const decoder = new TextDecoder()
16
let body = ''
17
const reader = (resp.body as any).getReader()
18
let loaded = 0
19
while(1) {
20
const { done, value } = await reader.read()
21
if (done) {
22
break
23
}
24
loaded += value?.length ?? 0
25
body += decoder.decode(value)
26
onProgress({ loaded, total })
27
}
28
// resolve(resp)
29
})
30
}
31
32
function onProgress({ loaded, total }: { loaded: number, total: number }) {
33
console.log('下载进度:' + loaded / total * 100 + '%')
34
}
35
36
export default fetchRequest

例子:

1
import fetchRequest from '@/utils/fetch'
2
3
const fetchGet = async () => {
4
const res = await fetchRequest({ url:'/request', method: 'GET' })
5
console.log(res)
6
}

请求取消

1
<script setup lang="ts">
2
import { ElSelect, ElOption } from 'element-plus'
3
import { ref } from 'vue'
4
5
interface ListItem {
6
value: string
7
label: string
8
}
9
10
const options = ref<ListItem[]>([])
11
const value = ref<string[]>([])
12
const loading = ref(false)
13
14
15
let controller: AbortController
16
const remoteMethod = async (query: string) => {
17
if (query === '') {
18
options.value = []
19
return
20
}
21
// 如果有正在请求的请求, 就取消请求
22
controller && controller.abort()
23
24
// 如果需要终止请求, 只需要调用 controller.abort() 即可
25
controller = new AbortController()
26
27
const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal }).then(res => res.json())
28
options.value = res.data.map((v: string) => ({ value: v, label: v }))
29
}
30
</script>
31
32
<template>
33
<h2>请求取消</h2>
34
<el-select v-model="value" filterable remote reserve-keyword :remote-method="remoteMethod" :loading="loading"
35
style="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">
2
import { customRef } from 'vue'
3
4
const debounceRef = (value: any, duration: number = 1000) => {
5
let timer: string | number | NodeJS.Timeout | undefined;
6
return customRef((track, trigger) => {
7
return {
8
get() {
9
// 收集依赖
10
track()
11
return value
12
},
13
set(val) {
14
clearTimeout(timer)
15
timer = setTimeout(() => {
16
// 触发依赖
17
trigger()
18
value = val
19
}, duration)
20
21
}
22
}
23
})
24
}
25
const text = debounceRef('',500)
26
</script>
27
28
<template>
29
<div>
30
<h2>自定义 ref 实现防抖</h2>
31
<input v-model="text"/>
32
<p>{{ text }}</p>
33
</div>
34
</template>

使用defer优化白屏时间

1
import { ref } from "vue"
2
3
export function useDefer(max:number = 100) {
4
const frameCount = ref<number>(0)
5
let refId:number
6
function updateFrameCount() {
7
refId = requestAnimationFrame(() => {
8
frameCount.value++
9
if(frameCount.value >= max) {
10
return
11
}
12
updateFrameCount()
13
})
14
}
15
updateFrameCount()
16
17
// 如果取消渲染,那么页面就是空白页
18
onMounted(() => {
19
cancelAnimationFrame(refId)
20
})
21
22
// return function defer(n:number) {
23
// return frameCount.value >= n
24
// }
25
26
return {
27
defer: (n:number) =>frameCount.value >= n,
28
cancelDefer: () => cancelAnimationFrame(refId)
29
}
30
}

使用

1
<script setup lang="ts">
2
import Defer from '@/components/defer.vue'
3
4
const {defer} = useDefer(1000)
5
</script>
6
7
<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>
16
17
<style scoped>
18
19
.list-all {
20
display: flex;
21
column-gap: 5px;
22
}
23
.list {
24
width: 100%;
25
display: flex;
26
flex-wrap: wrap;
27
gap: 10px;
28
margin-bottom: 10px;
29
}
30
</style>

如何封装命令式组件

1
import { createApp } from "vue"
2
import MessageBox from "@/components/message-box.vue"
3
4
function showMsg(msg: string, callback: Function) {
5
const msgBox = document.createElement('div')
6
document.body.appendChild(msgBox)
7
8
// 创建组件
9
const app = createApp(MessageBox, {
10
msg,
11
handleClick() {
12
callback && callback()
13
app.unmount()
14
document.body.removeChild(msgBox)
15
}
16
})
17
// 渲染组件
18
app.mount(msgBox)
19
}
20
21
export default showMsg

MessageBox.vue

1
<script setup lang="ts">
2
interface PropsOptions {
3
msg: string
4
handleClick: () => void
5
}
6
7
defineProps<PropsOptions>()
8
9
</script>
10
11
<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">
2
import { ElButton } from 'element-plus'
3
import showMsg from '@/composables/showMsg'
4
5
const open = () => {
6
showMsg('你好', () => {
7
8
})
9
}
10
</script>
11
12
<template>
13
<h2>如何封装命令式组件</h2>
14
<el-button type="primary" @click="open">打开弹窗</el-button>
15
</template>

自定义指令 reszie

1
import { Directive } from 'vue'
2
3
const map = new WeakMap<HTMLElement, (size: { width: number, height: number }) => void>()
4
5
const ob = new ResizeObserver((entries) => {
6
for (const entry of entries) {
7
const handler = map.get(entry.target as HTMLElement)
8
9
if (handler) {
10
handler({
11
width: entry.borderBoxSize[0].inlineSize,
12
height: entry.borderBoxSize[0].blockSize
13
})
14
}
15
16
}
17
})
18
19
export const resize: Directive = {
20
mounted(el:HTMLElement, binding:any) {
21
map.set(el, binding.value)
22
ob.observe(el)
23
24
},
25
unmounted(el: HTMLElement) {
26
map.delete(el)
27
ob.unobserve(el)
28
}
29
}
30
31
export default resize

需要在 main.ts 中注册指令:

1
const app = createApp(App)
2
3
app.directive('resize', resize)
4
app.mount('#app')

使用:

1
<script setup lang="ts">
2
import { reactive } from 'vue'
3
4
const sizes = reactive<{width:number,height:number}>({width:0,height:0})
5
6
const handleSizeChange = (size:{width:number,height:number}) => {
7
console.log('handleSizeChange',size)
8
sizes.width = size.width
9
sizes.height = size.height
10
}
11
</script>
12
13
<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>

数据的流式获取

后端:

1
import type { MockMethod } from 'vite-plugin-mock'
2
import { faker } from '@faker-js/faker'
3
4
const content = faker.lorem.paragraph({ min: 3, max: 5 })
5
6
export default [
7
{
8
url: '/api/stream',
9
method: 'post',
10
// response: () => {
11
// return response(200, 'success', {content})
12
// },
13
rawResponse: async (req, res) => {
14
// 设置响应头,指定内容类型为流
15
res.setHeader('Content-Type', 'application/octet-stream');
16
// 创建一个可写流,并将其绑定到响应对象
17
const stream = res;
18
19
// 将内容分成小块,每个小块一个字符,并逐个发送
20
for (const char of content) {
21
stream.write(char);
22
await new Promise(resolve => setTimeout(resolve, 100)); // 添加延迟以模拟逐字逐字发送
23
}
24
res.end()
25
}
26
},
27
] as MockMethod[]

使用:

1
<script setup lang="ts">
2
import { ElButton } from 'element-plus'
3
import { ref } from 'vue'
4
5
const content = ref<string>('')
6
7
const getStream = async () => {
8
content.value = ''
9
const resp = await fetch('/api/stream', {
10
method: 'POST',
11
headers: {
12
'Content-Type': 'application/json'
13
},
14
body: JSON.stringify({
15
content: 'hello world'
16
})
17
})
18
if (!resp.body) return
19
const reader = resp.body.getReader()
20
21
while (true) {
22
const { done, value } = await reader.read()
23
if (done) break
24
content.value += new TextDecoder().decode(value)
25
}
26
}
27
28
</script>
29
30
<template>
31
<div>
32
<h2>数据的流式获取</h2>
33
<el-button @click="getStream" type="primary">获取数据</el-button>
34
<p>{{ content }}</p>
35
</div>
36
</template>
37
38
<style scoped></style>

watchEffect 中的异步问题

1
<script setup lang="ts">
2
import { ElButton } from 'element-plus'
3
import { ref, watchEffect } from 'vue'
4
5
const speed = ref<number>(0)
6
const videoRef = ref<HTMLVideoElement | null>()
7
8
watchEffect(() => {
9
if (videoRef.value) {
10
videoRef.value.playbackRate = speed.value
11
}
12
})
13
14
</script>
15
16
<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>
27
28
<style scoped>
29
.container {
30
display: flex;
31
flex-direction: column;
32
align-items: center;
33
gap: 10px;
34
}
35
36
.btns {
37
display: flex;
38
gap: 20px;
39
align-items: center;
40
}
41
</style>

约定式路由

1
import { createRouter, createWebHistory } from 'vue-router'
2
3
// 拿到所有的路由,只包含到二级目录下的 vue 文件
4
const pages = import.meta.glob('../views/**/*.vue',/* { eager: true, import: 'default' } */)
5
6
7
const routesList = Object.entries(pages).map(([path]) => {
8
const pagePath = path.replace('../views/', '').replace('.vue', '') ?? '/'
9
const name = pagePath.split('/').filter(Boolean).join('-') ?? 'index'
10
return {
11
path: '/' + name,
12
name,
13
component: pages[path],
14
meta: {}
15
}
16
})
17
18
const routes = [
19
...routesList
20
]
21
22
console.log("routes", routes)
23
24
25
const router = createRouter({
26
history: createWebHistory(import.meta.env.BASE_URL),
27
routes
28
})
29
30
export default router